← BACK TO ENGINEERING
Runtime 14 min read

How OpenCode Renders Messages in Bubble Tea: A Source Tour

OpenCode is a coding agent that lives in your terminal. For most of 2025 its terminal UI was a Go program built on Charm's Bubble Tea, and the part that did the most work was the chat transcript: user prompts, assistant answers streaming in token by token, tool calls, diffs, all re-laid out every time the window changed size.

This article reads that source. It shows how a message travels from the agent server to the screen: the event stream, the update loop, the Markdown renderer, the cache, and the scrolling viewport. Then it rebuilds the same pattern in about 170 lines of TypeScript with our ports of the Charm libraries, and runs it.

OpenCode's trick is simple: render every finished part once, keep the string, and re-render only the part that is still streaming.


I – Which OpenCode, and Which TUI

Two projects have been called OpenCode, so first a map.

  • opencode-ai/opencode was a Go agent with a Bubble Tea UI. It is archived (its last push was in September 2025), and its README says the project continues as Crush, developed by the original author with the Charm team.
  • sst/opencode is the one most people mean today. The URL now redirects to anomalyco/opencode. Its server is TypeScript on Bun. Its TUI lived in packages/tui as a separate Go program built on Bubble Tea v2 (beta), Lip Gloss v2, Bubbles v2 and Glamour v0.10, and the Bun process launched it as a child binary.

That Go TUI is gone now. On 31 October 2025 the commit "OpenTUI is here (#2685)" landed, and v1.0.0 shipped the same day with a new TUI written in TypeScript on OpenTUI and SolidJS. v0.15.31, released half an hour earlier, was the last version whose build script ran go build for the TUI. Two days later, on 2 November 2025, commit f68374ad22 deleted the Go code. On the dev branch today (28 October 2026), packages/tui is a TypeScript package that depends on @opentui/core, @opentui/solid and solid-js.

So everything below describes OpenCode as it was up to v0.15.31. The source links point at that tag, so they will keep working. The design is still worth reading: it is a clear, public example of a streaming chat in Bubble Tea, and the problems it solves are the same in any immediate-mode TUI.


II – The Loop: Events In, Messages Out

Bubble Tea is the Elm Architecture: a model, an Update(msg) that returns a new model and a command, and a View() that returns a string. The framework calls View() after every message and redraws the terminal. If you have not used it, Getting Started with Bubble Tea in TypeScript covers the loop from zero.

OpenCode's TUI is a client of the agent server. In cmd/opencode/main.go a goroutine opens the server's event stream and pushes every event into the program:

// packages/tui/cmd/opencode/main.go (v0.15.31), MIT, © 2025 opencode
go func() {
	stream := httpClient.Event.ListStreaming(ctx, opencode.EventListParams{})
	for stream.Next() {
		evt := stream.Current().AsUnion()
		program.Send(evt)
	}
	...
}()

program.Send is how anything outside Bubble Tea talks to it: the event becomes a message, and it goes through Update like a key press would. There is no shared state between the network and the UI; there is one queue.

A conversation is a list of messages, and each message is a list of parts: text, reasoning, tool calls, files, step markers. While the model is answering, the server sends message.part.updated again and again for the same part ID. Each event carries the whole part, with the full text so far (plus an optional delta that the Go TUI did not use). The top-level model in internal/tui/tui.go finds the message, replaces the part with the same ID or appends a new one, and stores the result. That is all it does with the event. Drawing is somebody else's job.


III – One Render for the Whole Transcript

The somebody is messagesComponent in internal/components/chat/messages.go. Almost every case in its Update ends the same way: part updated, message updated, session switched, theme changed, window resized, tool details toggled. Each returns m.renderView().

renderView does not draw anything itself. It returns a command, a function that Bubble Tea runs on a separate goroutine, which builds the whole transcript and sends it back as a renderCompleteMsg. Two booleans keep it from piling up:

// internal/components/chat/messages.go (v0.15.31), MIT, © 2025 opencode
func (m *messagesComponent) renderView() tea.Cmd {
	if m.rendering {
		slog.Debug("pending render, skipping")
		m.dirty = true
		return func() tea.Msg {
			return nil
		}
	}
	m.dirty = false
	m.rendering = true
	...

If a render is already running, a new request only sets dirty. When the running one finishes, Update receives renderCompleteMsg, clears rendering, and if dirty is set it starts exactly one more. A model streaming fifty tokens a second therefore causes at most one render in flight and one queued, never fifty.

Inside the command, the transcript is built as a list of blocks, one per part, joined with blank lines. A user message is a Lip Gloss block with a thick left border; an assistant text part is Markdown rendered by Glamour, in a block without a border; a tool call gets its own block with a title and, if details are on, its output. The joined string goes into a viewport.


IV – Glamour, Lip Gloss and Two Small Hacks

Assistant text goes through util.ToMarkdown, which asks styles.GetMarkdownRenderer for a Glamour renderer:

// internal/styles/markdown.go (v0.15.31), MIT, © 2025 opencode
r, _ := glamour.NewTermRenderer(
	glamour.WithStyles(generateMarkdownStyleConfig(backgroundColor)),
	glamour.WithWordWrap(width),
	glamour.WithChromaFormatter("terminal16m"),
)

Three things are worth noticing here:

  1. The style is generated from the theme. generateMarkdownStyleConfig builds a full Glamour StyleConfig (headings, code, tables, links) from the current OpenCode theme colours, so changing theme changes the Markdown too. That is also why a theme change clears the cache.
  2. Word wrap is the content width. Glamour wraps to width - 6, leaving room for the block's border and padding. Width is part of the output, which matters for the cache in the next section.
  3. A new renderer for every call. ToMarkdown creates a renderer each time instead of keeping one per width. The cache makes that cheap enough.

Before rendering, ToMarkdown makes two text edits. It strips the project root from paths so file names stay short, and it replaces a - followed by another character (not a space, hyphen or table pipe) with U+2011, a non-breaking hyphen. Word wrapping may break a line after a hyphen; with the substitution, names like user-service.ts or pre-commit stay on one line. After rendering, it drops the blank first and last lines Glamour adds.

The rest of the look is Lip Gloss. renderContentBlock in message.go is one style with options: foreground, background, padding, and a ThickBorder on the left side only, coloured per message type: the theme's secondary colour for a user message, the accent colour while it is queued. User messages, reasoning blocks, tool calls and errors all go through it, and plain assistant text does too with the border switched off, which is why the transcript looks consistent.


V – The Cache: Key on Everything That Changes the Output

Rendering Markdown is not free, and Bubble Tea redraws after every message. OpenCode's answer is cache.go, a 62-line map from a key to a rendered string:

// internal/components/chat/cache.go (v0.15.31), MIT, © 2025 opencode
func (c *PartCache) GenerateKey(params ...any) string {
	h := fnv.New64a()
	for _, param := range params {
		h.Write(fmt.Appendf(nil, ":%v", param))
	}
	return hex.EncodeToString(h.Sum(nil))
}

The key is a hash of whatever the caller passes, and callers pass everything the output depends on. For a user message that is the message ID, the text, the width, the attached files, the author and whether it is queued. For an assistant text part it is the ID, the text, the width, whether tool details are shown, and the tool calls that follow the text.

The important line is the condition around it. An assistant part is only cached once it is finished:

// internal/components/chat/messages.go (v0.15.31), MIT, © 2025 opencode
finished := part.Time.End > 0
...
if finished {
	key := m.cache.GenerateKey(casted.ID, part.Text, width, m.showToolDetails, toolCallParts)
	content, cached = m.cache.Get(key)
	if !cached {
		content = renderText(...)
		m.cache.Set(key, content)
	}
} else {
	content = renderText(...)
}

A streaming part changes on every event, so caching it would only fill the map with strings nobody asks for again. Finished parts never change, so every render after the first is a map lookup. During a long session, each render costs one Markdown pass for the part being written plus lookups for everything above it.

Because the key contains the width, a resize would miss the cache anyway. OpenCode still clears it on resize, so the old width's strings do not stay in memory. It also clears it when a message or part is removed, when a message is reverted, and when you switch to a session outside the current one's family (moving between a session and its sub-sessions keeps it).

The archived opencode-ai TUI took a different route to the same place: it cached by message ID and width and deleted the entry for the message being streamed whenever it changed. Explicit invalidation works, but every new event type has to remember to do it. A content-addressed key cannot go stale.


VI – The Viewport: Follow the Tail, Respect the Scroll

The joined transcript goes into a viewport, a component that shows a window of a long string and scrolls it. OpenCode first used the one from Bubbles, then vendored its own copy in July 2025, in a commit whose message blames Bubble Tea's performance and which also added timing logs around Update and View.

What matters for a chat is one boolean, tail. When you are at the bottom, new output should keep you at the bottom. When you have scrolled up to read something, new output must not yank you back down. OpenCode records m.tail = m.viewport.AtBottom() on every update, and when a render completes it restores the position:

// internal/components/chat/messages.go (v0.15.31), MIT, © 2025 opencode
wasAtBottom := m.viewport.AtBottom()
prevYOffset := m.viewport.YOffset
m.viewport = msg.viewport
if wasAtBottom {
	m.viewport.GotoBottom()
} else {
	m.viewport.YOffset = prevYOffset
}

The render also records the line where each message starts (messagePositions), which is how "jump to message" works: it sets the offset to that line and turns tail off.


VII – The Same Pattern in TypeScript

Our TypeScript ports of the Charm libraries have the same pieces: @oakoliver/bubbletea for the loop, @oakoliver/glamour for Markdown, @oakoliver/lipgloss for the border, and the viewport from @oakoliver/bubbles. How they were ported is in Porting Go's Bubble Tea to TypeScript and Porting Bubbles.

mkdir chat && cd chat && bun init -y
bun add @oakoliver/bubbletea @oakoliver/bubbles @oakoliver/glamour @oakoliver/lipgloss

The program below has the same structure as OpenCode's: a fake event stream outside the program calls send(), the model replaces parts by ID, finished parts are cached under a key that includes the text and the width, and the viewport follows the tail unless you scroll up.

One thing changes. In Go, OpenCode's render command ran on another goroutine. In JavaScript a command runs on the same thread as everything else, so moving the render into a command buys nothing. What we keep is the coalescing: the first part update schedules a flush 16 ms later, and updates that arrive before it only replace data.

// chat.ts: a streaming chat transcript, rendered the way OpenCode's Go TUI did it
import {
  type Cmd,
  type Model,
  type Msg,
  KeyPressMsg,
  Program,
  Quit,
  Tick,
  View,
  WindowSizeMsg,
} from '@oakoliver/bubbletea';
import { ViewportModel } from '@oakoliver/bubbles';
import { TermRenderer, withStandardStyle, withWordWrap } from '@oakoliver/glamour';
import { newStyle, thickBorder } from '@oakoliver/lipgloss';

// ── The data: messages made of parts, updated by events ─────────────────────
type Part = { id: string; role: 'user' | 'assistant'; text: string; done: boolean };

class PartUpdatedMsg {
  constructor(readonly part: Part) {}
}
class FlushMsg {}

// ── Rendering one part ──────────────────────────────────────────────────────
const userStyle = newStyle()
  .border(thickBorder(), false, false, false, true) // left edge only
  .borderForeground('#7c3aed')
  .paddingLeft(2);

let glamourCalls = 0;

function renderPart(part: Part, width: number): string {
  if (part.role === 'user') return userStyle.width(width).render(part.text);
  glamourCalls++;
  const md = new TermRenderer(withStandardStyle('dark'), withWordWrap(width));
  return trimBlankLines(md.render(part.text));
}

// Glamour pads the output with blank lines; drop them but keep the left margin.
function trimBlankLines(s: string): string {
  const lines = s.split('\n');
  const blank = (l: string) => l.replace(/\x1b\[[0-9;]*m/g, '').trim() === '';
  while (lines.length && blank(lines[0]!)) lines.shift();
  while (lines.length && blank(lines.at(-1)!)) lines.pop();
  return lines.join('\n');
}

// ── The model ───────────────────────────────────────────────────────────────
class Chat implements Model {
  parts: Part[] = [];
  cache = new Map<string, string>();
  cacheHits = 0;
  viewport = new ViewportModel();
  width = 0;
  tail = true; // follow new output while the user is at the bottom
  flushPending = false;
  events = 0;

  init(): Cmd {
    return null;
  }

  update(msg: Msg): [Model, Cmd] {
    if (msg instanceof WindowSizeMsg) {
      if (msg.width !== this.width) this.cache.clear(); // width changes every wrap
      this.width = msg.width;
      this.viewport.setWidth(msg.width);
      this.viewport.setHeight(msg.height - 1); // one line for the footer
      this.refresh();
      return [this, null];
    }

    if (msg instanceof PartUpdatedMsg) {
      this.events++;
      const i = this.parts.findIndex((p) => p.id === msg.part.id);
      if (i === -1) this.parts.push(msg.part);
      else this.parts[i] = msg.part;
      // Coalesce bursts of tokens into one re-render per frame.
      if (this.flushPending) return [this, null];
      this.flushPending = true;
      return [this, Tick(16, () => new FlushMsg())];
    }

    if (msg instanceof FlushMsg) {
      this.flushPending = false;
      this.refresh();
      return [this, null];
    }

    if (msg instanceof KeyPressMsg && ['q', 'ctrl+c'].includes(msg.toString())) {
      return [this, Quit];
    }

    // Everything else (arrows, pgup/pgdown, mouse wheel) scrolls the viewport.
    this.viewport.update(msg);
    this.tail = this.viewport.atBottom();
    return [this, null];
  }

  refresh() {
    if (this.width === 0) return;
    const blocks = this.parts.map((part) => {
      if (!part.done) return renderPart(part, this.width - 2); // still streaming: never cached
      const key = `${part.id}:${this.width}:${Bun.hash(part.text)}`;
      const hit = this.cache.get(key);
      if (hit !== undefined) {
        this.cacheHits++;
        return hit;
      }
      const out = renderPart(part, this.width - 2);
      this.cache.set(key, out);
      return out;
    });
    this.viewport.setContent(blocks.join('\n\n'));
    if (this.tail) this.viewport.gotoBottom();
  }

  view(): View {
    const footer = newStyle()
      .faint(true)
      .render(
        `${this.parts.length} parts · ${this.cache.size} cached · ${this.tail ? 'following' : 'scrolled'} · q quits`,
      );
    const view = new View(this.viewport.view() + '\n' + footer);
    view.altScreen = true;
    return view;
  }
}

// ── A fake event stream standing in for the agent server ────────────────────
const answer = `Bubble Tea redraws the **whole view** after every message, so a chat
transcript has to be cheap to rebuild:

1. Render each finished part once and keep the string.
2. Re-render only the part that is still streaming.
3. Clear the cache when the width changes.

\`\`\`ts
const key = \`\${part.id}:\${width}:\${Bun.hash(part.text)}\`;
\`\`\`

| part      | rendered            |
|-----------|---------------------|
| finished  | once, then cached   |
| streaming | on every flush      |
`;

async function stream(p: Program) {
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
  await sleep(300);
  p.send(new PartUpdatedMsg({ id: 'u1', role: 'user', text: 'How do I render a streaming chat in Bubble Tea?', done: true }));
  const words = answer.split(/(?<= )/);
  let text = '';
  for (const word of words) {
    text += word;
    p.send(new PartUpdatedMsg({ id: 'a1', role: 'assistant', text, done: false }));
    await sleep(15);
  }
  p.send(new PartUpdatedMsg({ id: 'a1', role: 'assistant', text, done: true }));
}

const program = new Program(new Chat());
void stream(program);
const chat = (await program.run()) as Chat;
console.log(`events: ${chat.events}, glamour renders: ${glamourCalls}, cache hits: ${chat.cacheHits}, rendered lines: ${chat.viewport.totalLineCount()}`);

Run it with bun chat.ts. The user message appears with a purple bar, the answer streams in below it as Markdown, and the footer reads following. This is the last frame in a 72 × 24 terminal, captured from the program's own output (the empty rows above the footer are left out):

┃  How do I render a streaming chat in Bubble Tea?

  Bubble Tea redraws the whole view after every message, so a chat
  transcript has to be cheap to rebuild:

  1. Render each finished part once and keep the string.
  2. Re-render only the part that is still streaming.
  3. Clear the cache when the width changes.

    const key = `${part.id}:${width}:${Bun.hash(part.text)}`;

   part                            │ rendered
  ─────────────────────────────────┼────────────────────────────────
   finished                        │ once, then cached
   streaming                       │ on every flush

2 parts · 2 cached · following · q quits

Press q and the program prints its counters:

events: 87, glamour renders: 43, cache hits: 42, rendered lines: 15

87 events arrived, but Glamour ran 43 times: 42 flushes while the answer was streaming, plus one when it finished and went into the cache. Each of the 42 flushes found the user message in the cache. Without the 16 ms flush, every one of the 87 events would have rebuilt the transcript.

I also drove the program through a pseudo-terminal to check the two behaviours that are easy to get wrong:

  • Scroll up while it streams. After the up arrow, the footer switches to scrolled and the view stays where it was while the rest of the answer arrives below it. Scrolling back to the last line switches it to following again.
  • Resize. Shrinking the terminal to 50 columns clears the cache. The next refresh renders the answer once at the new width and wraps the code block and the table to fit.

VIII – Why the Cache Matters

On my machine (Apple M5 Max, Bun 1.4.4), Glamour renders the answer above at 70 columns in about 0.5 ms. Re-rendering 100 messages like it takes about 60 ms, measured the same way. That is fine once. It is not fine on every token: at 60 ms per frame, typing and scrolling would stutter as soon as the session gets long, which is exactly the point where people use an agent most.

With the cache, the cost of a frame stops depending on how long the conversation is. You pay for the part being written and a map lookup for each of the others. That one decision is most of what made OpenCode's Go TUI feel fast, and it carries over unchanged to TypeScript.


IX – What to Take Away

Treat the transcript as data, and rendering as a function of it. Events only update parts. One function turns parts into a string, and it can run as often as it likes because it is cheap.

Key the cache on everything that changes the output. ID, text, width, theme, toggles. A hash of the inputs cannot go stale; a cache you invalidate by hand eventually will.

Never cache what is still changing. A streaming part is rendered every time. A finished part is rendered once.

Coalesce renders. OpenCode did it with rendering and dirty flags around a goroutine; in JavaScript a short tick does the same job.

Follow the tail only when the reader is at the bottom. Record it on every update, restore the offset after every render.


Requirements

  • Runtime. The example ran on Bun 1.4.4 with @oakoliver/bubbletea 1.2.0, @oakoliver/bubbles 1.2.2, @oakoliver/glamour 2.0.0 and @oakoliver/lipgloss 1.1.3. It uses Bun.hash; on Node, replace it with any string hash such as createHash('sha1').
  • Terminal. Any terminal with ANSI colour. The program reads raw key presses, so it needs an interactive terminal.
  • Source. The OpenCode excerpts are from anomalyco/opencode at v0.15.31, MIT licensed. Each excerpt is shortened; follow the links for the full functions.
  • More on the TypeScript libraries. Getting Started with Glamour, Getting Started with Lip Gloss and Syncing Ten Ports With Upstream.
"Simplicity is the ultimate sophistication."