Glamour v2 shipped on March 9, 2026. The headline changes: a new import path (charm.land/glamour/v2), Lip Gloss v2 integration, rewritten word wrapping, and clickable OSC 8 hyperlinks. Upgrading isn’t hard — it’s mostly find-and-replace on import paths plus one fmt.Print → lipgloss.Print swap — but it’s a good forcing function to ask a more interesting question.
Should term-llm own its markdown rendering entirely?
After looking carefully at the codebase, the answer is probably yes — and the surprising thing is how much of the work is already done.
What glamour actually provides
It helps to be precise about what the dependency actually does. glamour.TermRenderer is essentially three things wired together:
- goldmark — CommonMark-compliant markdown parser, produces an AST
glamour/ansi— a goldmarkNodeRendererthat walks the AST and emits ANSI escape sequences- Chroma — syntax highlighting for fenced code blocks
Both goldmark and Chroma are standalone deps that term-llm would keep. The thing being replaced is just the middle layer: the ~2,500-line ANSI renderer in glamour/ansi.
What term-llm already owns
Here’s the thing: the streaming infrastructure — which is most of the hard work — is entirely term-llm’s own code, not glamour’s.
internal/ui/streaming/streaming.go is ~900 lines of a block detection state machine. It detects block type boundaries (fenced code, tables, lists, headings, blockquotes), tracks state across partial line arrivals, handles partial rendering, manages resize events, and emits delta output incrementally. None of this is glamour. Glamour only gets involved at the end of each committed block, when the state machine calls tr.RenderBytes().
The theme system (internal/ui/styles.go) is also entirely term-llm’s — it defines a gruvbox colour palette with semantic slots (primary, secondary, warning, muted, etc.) and currently expresses it as a glamour/ansi.StyleConfig. That struct is glamour’s vocabulary. In an owned pipeline, the theme feeds directly into the ANSI renderer without translating into someone else’s type system.
The full-document re-render overhead
The most expensive thing glamour forces on term-llm is a subtle architectural constraint in the streaming renderer.
Every time a block completes, streaming.go calls tr.RenderBytes(sr.normalizedMarkdown()) — passing the entire accumulated document since the start of the conversation, not just the new block. This exists because glamour’s renderer is document-scoped: it needs full context for consistent spacing, numbering, and heading rendering.
The result is renderedLen, lastRendered, and applyRenderedSnapshot tracking — a delta diffing mechanism invented purely to find what’s new in the latest full-document render. This is infrastructure that exists only to work around glamour’s batch-rendering model.
With a block-by-block renderer, each committed block goes in and styled ANSI comes out. No full-document re-render. No delta machinery. The streaming renderer sheds several fields and a significant chunk of complexity.
The multi-output pipeline
term-llm runs on multiple platforms: terminal (inline and altscreen), web UI, Telegram, background jobs. Each has different rendering needs. Currently, all of them get ANSI output — which is fine for terminal but wrong for everything else.
goldmark’s renderer interface is NodeRenderer — a handler registered per AST node type. You implement EnterNode/ExitNode for each node type you care about, register the handlers, and goldmark drives the traversal.
This means the pipeline would look like:
markdown stream
→ block detector (streaming.go — unchanged)
→ committed block string
→ goldmark.Parse()
→ ast.Node
→ Renderer.Render(node) ← swap per platform
→ output bytes
Where Renderer is an interface term-llm defines. ANSIRenderer for terminal output. HTMLRenderer for the web UI — goldmark already ships one. PlainRenderer for piped output and jobs. The block detector doesn’t change. The streaming infrastructure doesn’t change. Only the final rendering step is platform-aware.
There’s even a goldmark-tgmd extension that implements a Telegram markdown renderer as a goldmark NodeRenderer — directly applicable to term-llm’s Telegram platform.
The scattered glamour problem
Before the main swap, there’s a consolidation step needed. Glamour is imported in 11 files. The core rendering files are expected — but four files in the TUI layer have each independently implemented their own renderMarkdown() function rather than calling ui.RenderMarkdown:
internal/tui/chat/render.go—renderMarkdown()method on the chat Model, with its own inline renderer cacheinternal/tui/chat/chat.go— stores aglamour.TermRendererin the Model struct for that cacheinternal/ui/postexec.go— localrenderMarkdown(), identical pattern toui.RenderMarkdowninternal/ui/inline_diff.go— localrenderInfoMarkdown(), same again
Each of these reimplements the same glamour.NewTermRenderer + WithStyles + WithWordWrap + tab normalisation + TrimSpace dance. They’re copy-paste divergences from ui.RenderMarkdown, not intentional specialisations. Consolidating them into the shared abstraction is Phase 0 — it makes the actual renderer swap a single-site change.
The phased plan
Phase 0: Consolidate direct glamour calls
Replace the local renderMarkdown functions in postexec.go, inline_diff.go, and tui/chat/render.go with calls to ui.RenderMarkdown. Remove the glamour.TermRenderer cache from the chat Model struct — the shared rendererCache in markdown.go already handles this.
Scope: 4 files. No behaviour change, no new tests needed. Mechanical.
Phase 1: Define the renderer interface
Create internal/render/markdown with a clean interface:
// Renderer renders a complete markdown block to bytes.
// Implementations: ANSIRenderer, HTMLRenderer, PlainRenderer.
type Renderer interface {
Render(source []byte) ([]byte, error)
Resize(width int)
}
Keep it narrow. The block detector in streaming.go stays untouched — it produces committed block strings. The interface just wraps the rendering step.
Scope: New package, no existing code touched.
Phase 2: Implement ANSIRenderer
Write a goldmark NodeRenderer that emits ANSI escape sequences. Register handlers for:
ast.Heading— bold + secondary colour, prefix with#charactersast.Paragraph— text colour, word wrap to widthast.FencedCodeBlock— Chroma syntax highlighting, primary colourast.CodeSpan— primary colour inlineast.List/ast.ListItem— bullet prefix, indentast.Blockquote— warning colour, italic, indentast.Link— secondary colour, underline, OSC 8 if supportedast.Table— column width calculation, separator charsast.Emphasis/ast.Strong— italic/boldast.ThematicBreak— horizontal rule
The hard parts are tables (column sizing) and word wrapping (runewidth-aware for CJK). For tables, lipgloss’s table package is usable without glamour and handles the sizing correctly. For wrapping, lipgloss.Wrap or a standalone go-runewidth implementation both work.
Scope: New file ~1,000–1,500 lines. The test suite from spec_test.go and parity_test.go provides the correctness baseline — these tests can be adapted to drive the new renderer directly.
Phase 3: Swap streaming.go
Replace glamour.TermRenderer in StreamRenderer with the new Renderer interface. The key change: commitPendingLines() calls renderer.Render(blockBytes) and appends the result directly to output — no full-document accumulation, no delta diffing.
This removes from StreamRenderer:
glamourOpts []glamour.TermRendererOptionallMarkdown bytes.Buffer(no longer need full document history)renderedLen intlastRendered []byteapplyRenderedSnapshot()
Resize handling simplifies: call renderer.Resize(newWidth) and re-render only the current pending block, not the full document history.
Scope: Core change. Needs the most careful testing. The existing streaming_test.go spec tests are the guard rail.
Phase 4: Swap markdown.go
Replace glamour.NewTermRenderer with the new ANSIRenderer. Remove the rendererCache sync.Map — renderer creation is no longer expensive. Remove NormalizeNewlines — that function exists specifically to compensate for glamour’s extra newline before headings. Remove the tab normalisation workaround — the new renderer handles tabs correctly. Remove the width-1 margin fudge.
Scope: markdown.go shrinks significantly. RenderMarkdown and RenderMarkdownWithError keep the same signatures — callers are unchanged.
Phase 5: Clean up styles.go
Remove the glamour/ansi import. Replace GlamourStyle() ansi.StyleConfig with a direct theme struct that the ANSIRenderer consumes. The gruvbox colour values are unchanged — just expressed in term-llm’s own types instead of glamour’s.
Scope: styles.go loses one import and one function. Theme config in the renderer becomes cleaner.
Phase 6: Add HTMLRenderer (optional, but the real prize)
Implement HTMLRenderer using goldmark’s built-in HTML renderer (goldmark/renderer/html). Wire it into the web UI path so the API returns clean HTML instead of raw markdown or ANSI-stripped text. Add PlainRenderer for piped output and jobs (just strip formatting, emit plain text).
The block detector stays the same. The streaming state machine stays the same. Each platform gets the right output format without any per-platform special casing in the shared infrastructure.
Scope: New renderer implementations. The multi-output pipeline becomes real.
Phase 7: Remove glamour from go.mod
go get github.com/charmbracelet/glamour@none
go mod tidy
Done.
What doesn’t change
Everything in the TUI layer built on Bubble Tea and Lip Gloss — tui/chat/, tui/sessions/, tui/inspector/, tui/plan/, tools/, cmd/ — none of it touches glamour. Lip Gloss is load-bearing throughout and stays. Bubble Tea stays. The viewport, spinner, textarea, and selector components stay. The theme system stays. The diff rendering stays. The tool approval UI stays.
The blast radius is genuinely contained to the rendering layer.
Is this worth doing now?
The glamour v2 upgrade is mechanical — two days of import path churn and one API swap. If there are no pressing rendering bugs and the web UI HTML output isn’t immediately needed, staying on v1 with a documented plan to diverge is defensible.
The natural triggers for doing the migration:
- A rendering bug glamour won’t fix upstream
- The v2 migration turns out messier than expected (the
charm.landimport path is a non-trivial commitment to a private domain) - The HTML output need becomes real for the web UI
- The streaming performance overhead on the full-document re-render becomes measurable
The architecture is ready. The streaming state machine is the hard part and it’s already done. What remains is writing ~1,500 lines of ANSI renderer, adapting the existing tests to drive it, and doing the mechanical swap. The codebase is closer to this than it probably looks from the outside.