Markdown Rendering
Markdown rendering in a terminal AI coding agent is deceptively hard. The problem is not just converting markdown to ANSI sequences — it is doing so incrementally as tokens stream in, without flickering, while keeping code blocks correctly syntax-highlighted, and without exceeding terminal width. The three reference implementations take completely different approaches at every level: Rich’s Python renderer, a custom Rust writer built on pulldown-cmark, and a Zig-native cell renderer fed by a TypeScript marked.js pipeline.
Feature Definition
Section titled “Feature Definition”A markdown renderer for a coding agent must handle at minimum:
- Inline formatting: bold (
**text**), italic (_text_), inline code (`text`), strikethrough - Block elements: headings (H1–H6), ordered and unordered lists, blockquotes, fenced code blocks, thematic breaks
- Syntax highlighting: code blocks need per-language highlighting, requiring either a bundled grammar library or a tree-sitter integration
- Streaming partial input: tokens arrive one chunk at a time; the renderer must not break the display while the fenced code block opening delimiter is still being received
- Conceal mode: in some display contexts it is useful to render
**bold**as bold text only, hiding the**markers - Width awareness: word-wrapped text must respect the terminal column width, with list and blockquote indentation preserved across wrapped lines
Aider Implementation
Section titled “Aider Implementation”Source: aider/mdstream.py
MarkdownStream
Section titled “MarkdownStream”Aider’s primary output renderer is MarkdownStream, a class that wraps Rich’s Live display to provide smooth incremental output.
# mdstream.py:92-108class MarkdownStream: live_window = 6 # Lines of "unstable" content shown in the live area min_delay = 1.0 / 20 # 20 FPS cap
def __init__(self, mdargs=None): self.mdargs = mdargs or {} self.live = None self.printed_lines = 0 ...The core insight is that a streaming LLM response grows from the top. Lines rendered early are stable — the model is not going back to modify them. Aider exploits this by splitting the rendered output into stable lines (already scrolled off into the terminal’s scrollback buffer, never touched again) and unstable lines (the trailing live_window = 6 lines shown inside a Rich Live context that can update in place).
# mdstream.py:149-175 (simplified)def update(self, text, final=False): lines = self._render_markdown_to_lines(text) num_stable = max(0, len(lines) - self.live_window) new_stable = lines[:num_stable]
# Print newly-stable lines directly to terminal (forever) for line in new_stable[self.printed_lines:]: self.live.console.print(line, end="") self.printed_lines = len(new_stable)
# Update the live area with remaining unstable lines self.live.update(...)Custom Rich Components
Section titled “Custom Rich Components”Aider subclasses three Rich classes to override the default rendering behavior:
NoInsetCodeBlock (lines 52–58): Removes the default padding Rich adds around code blocks, and delegates syntax highlighting to Rich’s Syntax class using the configured code_theme.
class NoInsetCodeBlock(MarkdownCodeBlock): @staticmethod def create(theme: str, code: str, lexer_name: str, padding: PaddingDimensions) -> "NoInsetCodeBlock": obj = cls.__new__(cls) obj.code = Syntax(code, lexer_name, theme=theme, padding=(1, 0)) return objLeftHeading (lines 61–78): Overrides Rich’s default centered heading. H1 renders with a box.HEAVY border framing the text. H2 and below render as left-aligned bold text.
NoInsetMarkdown (lines 81–89): The top-level markdown parser subclass. It registers the custom renderers:
ELEMENTS = MarkdownElement.ELEMENTS.copy()ELEMENTS["fence"] = NoInsetCodeBlockELEMENTS["code_block"] = NoInsetCodeBlockELEMENTS["heading_open"] = LeftHeadingRendering Pipeline
Section titled “Rendering Pipeline”# mdstream.py:122-139def _render_markdown_to_lines(self, text): string_io = io.StringIO() console = Console(file=string_io, force_terminal=True) markdown = NoInsetMarkdown(text, **self.mdargs) console.print(markdown) output = string_io.getvalue() return output.splitlines(keepends=True)Each call re-renders the entire accumulated buffer into a hidden console that writes to a StringIO. The resulting ANSI string is split into lines for stable/unstable classification. This re-render on every token is acceptable because the buffer is small (typically < 2k tokens of displayed content) and the 20 FPS throttle bounds the call frequency.
Integration with IO
Section titled “Integration with IO”The InputOutput class (io.py:1014–1021) constructs a MarkdownStream for each assistant response:
def get_assistant_mdstream(self): mdargs = dict( style=self.assistant_output_color, code_theme=self.code_theme, inline_code_lexer="text", ) mdStream = MarkdownStream(mdargs=mdargs) return mdStreamThe code_theme parameter is a Pygments theme name (default, monokai, solarized-dark, etc.), chosen via --code-theme.
Codex Implementation
Section titled “Codex Implementation”Source: codex-rs/tui/src/markdown_render.rs (678 lines), codex-rs/tui/src/markdown_stream.rs, codex-rs/tui/src/markdown.rs
Codex builds a custom markdown-to-ratatui renderer from scratch using pulldown-cmark, without delegating to any external terminal markdown library. The output is typed ratatui Text<'static> objects containing styled Line and Span primitives.
Dependencies
Section titled “Dependencies”pulldown-cmark = { workspace = true }ratatui = { workspace = true, features = ["scrolling-regions", "unstable-rendered-line-info", ...] }MarkdownStyles
Section titled “MarkdownStyles”Styles are assembled as a struct of ratatui Style values, all defined at compile time:
// markdown_render.rs:17-55struct MarkdownStyles { h1: Style::new().bold().underlined(), h2: Style::new().bold(), h3: Style::new().bold().italic(), h4: Style::new().italic(), h5: Style::new().italic(), h6: Style::new().italic(), code: Style::new().cyan(), emphasis: Style::new().italic(), strong: Style::new().bold(), strikethrough: Style::new().crossed_out(), ordered_list_marker: Style::new().light_blue(), unordered_list_marker: Style::new(), link: Style::new().cyan().underlined(), blockquote: Style::new().green(),}There is no runtime color selection: all styles are hardcoded ratatui attributes. Code blocks use Color::Cyan; no per-language syntax highlighting is applied.
Writer State Machine
Section titled “Writer State Machine”The Writer<'a, I> struct (markdown_render.rs:87–108) accumulates ratatui text as it processes pulldown-cmark events. Key fields:
struct Writer<'a, I: Iterator<Item = Event<'a>>> { iter: I, text: Text<'static>, styles: MarkdownStyles, inline_styles: Vec<Style>, // Stack of active inline styles indent_stack: Vec<IndentContext>, // For lists and blockquotes list_indices: Vec<Option<u64>>, // Tracks ordered list numbering link: Option<String>, needs_newline: bool, in_code_block: bool, wrap_width: Option<usize>, current_line_content: Option<Line<'static>>, current_initial_indent: Vec<Span<'static>>, // For first line of block current_subsequent_indent: Vec<Span<'static>>, // For wrapped lines current_line_style: Style, current_line_in_code_block: bool,}The run() method drains the pulldown-cmark iterator, calling handle_event() on each event. Tag events push/pop from the style stacks; text events append styled Span values to the current Line.
Public API
Section titled “Public API”// markdown_render.rs:74-85pub fn render_markdown_text(input: &str) -> Text<'static> { render_markdown_text_with_width(input, None)}
pub(crate) fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> { let mut options = Options::empty(); options.insert(Options::ENABLE_STRIKETHROUGH); let parser = Parser::new_ext(input, options); let mut w = Writer::new(parser, width); w.run(); w.text}Word wrapping is performed via word_wrap_line() (from crate::wrapping) with RtOptions containing the initial and subsequent indent spans. Code blocks bypass wrapping entirely (if self.current_line_in_code_block { return }), preserving their whitespace.
MarkdownStreamCollector
Section titled “MarkdownStreamCollector”markdown_stream.rs implements streaming-friendly buffering:
pub(crate) struct MarkdownStreamCollector { buffer: String, committed_line_count: usize, width: Option<usize>,}push_delta(delta: &str)— appends a streamed token to the buffer.commit_complete_lines() -> Vec<Line<'static>>— finds the last newline in the buffer, renders everything up to it, and returns only the newly-rendered lines since the last call. Incomplete lines at the tail are left in the buffer.finalize_and_drain() -> Vec<Line<'static>>— renders whatever remains when the stream ends.
This newline-gated approach is the same insight as Aider’s stable/unstable split: nothing before the last newline will change, so it is safe to commit those lines to the TUI’s line queue.
The StreamState in streaming/mod.rs uses a VecDeque<Line> as a FIFO. Lines committed by MarkdownStreamCollector are enqueued; step() and drain_n() dequeue them one or a few at a time, controlled by StreamController at 120 FPS.
OpenCode Implementation
Section titled “OpenCode Implementation”Source: opencode/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx, opentui/packages/core/src/renderables/Markdown.ts
Dual-Mode Architecture
Section titled “Dual-Mode Architecture”OpenCode uses a feature flag OPENCODE_EXPERIMENTAL_MARKDOWN to switch between two renderers:
// session/index.tsx:1378-1407function TextPart(props: { last: boolean; part: TextPart; message: AssistantMessage }) { const { theme, syntax } = useTheme() return ( <Show when={props.part.text.trim()}> <box paddingLeft={3} marginTop={1} flexShrink={0}> <Switch> <Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}> <markdown syntaxStyle={syntax()} streaming={true} content={props.part.text.trim()} conceal={ctx.conceal()} /> </Match> <Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}> <code filetype="markdown" drawUnstyledText={false} streaming={true} syntaxStyle={syntax()} content={props.part.text.trim()} conceal={ctx.conceal()} fg={theme.text} /> </Match> </Switch> </box> </Show> )}The stable path uses <code filetype="markdown">, which leverages OpenTUI’s tree-sitter-based syntax highlighter treating the entire response as a markdown file. The experimental path uses the dedicated <markdown> renderable.
Both paths share two props:
syntaxStyle={syntax()}— aSyntaxStyleobject built from the active theme’s 100+ TextMate scope mappingsconceal={ctx.conceal()}— controls whether syntax markers (**,`,_) are hidden or shown
MarkdownRenderable (OpenTUI)
Section titled “MarkdownRenderable (OpenTUI)”opentui/packages/core/src/renderables/Markdown.ts implements the <markdown> component. It uses marked.js (a JavaScript markdown parser) as the lexer, and builds TextChunk[] arrays as its output format.
export class MarkdownRenderable extends Renderable { private _content: string = "" private _syntaxStyle: SyntaxStyle private _conceal: boolean _parseState: ParseState | null = null private _streaming: boolean = false _blockStates: BlockState[] = [] // ...
updateBlocks(): void { // Re-parse _content using marked.lexer() // Create/update child Renderable per block token // Each BlockState = { token, tokenRaw, renderable } }}For streaming, parseMarkdownIncremental() compares the new content against the previous parse state, only reconstructing blocks that changed. This avoids re-rendering the entire response on every delta.
Inline Token Processing and Conceal Mode
Section titled “Inline Token Processing and Conceal Mode”The inline token handler maps marked.js token types to TextChunk arrays:
// Markdown.ts:173-200 (simplified)private renderInlineToken(token: MarkedToken, chunks: TextChunk[]): void { switch (token.type) { case "codespan": if (this._conceal) { chunks.push(this.createChunk(token.text, "markup.raw")) } else { // Show the backtick markers when not concealing chunks.push(this.createChunk("`", "markup.raw")) chunks.push(this.createChunk(token.text, "markup.raw")) chunks.push(this.createChunk("`", "markup.raw")) } break case "strong": if (!this._conceal) { chunks.push(this.createChunk("**", "markup.strong")) } for (const child of token.tokens) { this.renderInlineTokenWithStyle(child, chunks, "markup.strong") } if (!this._conceal) { chunks.push(this.createChunk("**", "markup.strong")) } break // ... em, del, link, text, etc. }}Syntax Scope Mapping
Section titled “Syntax Scope Mapping”The SyntaxStyle object injected via syntaxStyle={syntax()} maps TextMate-compatible scope names to RGBA colors and text attributes. OpenCode’s getSyntaxRules() in theme.tsx defines 100+ scope entries, including markdown-specific ones:
{ scope: ["markup.heading"], style: { foreground: theme.markdownHeading, bold: true } },{ scope: ["markup.bold", "markup.strong"], style: { foreground: theme.markdownStrong, bold: true } },{ scope: ["markup.italic"], style: { foreground: theme.markdownEmph, italic: true } },{ scope: ["markup.raw.inline"], style: { foreground: theme.markdownCode } },{ scope: ["markup.quote"], style: { foreground: theme.markdownBlockQuote, italic: true } },{ scope: ["markup.link"], style: { foreground: theme.markdownLink, underline: true } },The SyntaxStyle object compiles these scope-to-style mappings into numeric IDs stored in the Zig native library, enabling O(1) style lookup during rendering.
Pitfalls & Hard Lessons
Section titled “Pitfalls & Hard Lessons”The Streaming Re-Render Problem
Section titled “The Streaming Re-Render Problem”The naive implementation — render the full accumulated string on every token — works correctly but wastes CPU. For a 2000-token response at 30 tokens/second, you re-render 2000 lines 30 times in the last second. Aider mitigates this with the 20 FPS cap; Codex mitigates it by only committing complete lines; OpenTUI mitigates it with incremental block comparison in parseMarkdownIncremental.
Code Block Detection Is Fencepost-Sensitive
Section titled “Code Block Detection Is Fencepost-Sensitive”A streaming renderer must not try to syntax-highlight a code block until it has seen the closing fence. The opening ```python sequence arrives as a single token, but the closing ``` may be 500 tokens later. Until then, the renderer must treat the block content as plain text or leave it pending. Codex’s in_code_block boolean in the Writer state machine handles this; Aider’s approach of rendering the entire accumulated string means pulldown-cmark (or Rich’s parser) handles this at the parser level.
Word Wrap and Indentation Must Be Coordinated
Section titled “Word Wrap and Indentation Must Be Coordinated”List items with multi-line content must wrap such that continuation lines are indented to align with the start of the content, not the bullet character. Getting this right requires tracking two indent levels per block: one for the first line (which may include the bullet) and one for subsequent wrapped lines. Codex’s current_initial_indent / current_subsequent_indent split handles this explicitly.
Rich’s Table Support Is Limited
Section titled “Rich’s Table Support Is Limited”Rich’s Markdown class does not render GFM tables. If an LLM emits a markdown table in its response, Aider will show the raw pipe characters. This is a known limitation. Codex’s pulldown-cmark can parse tables with the Options::ENABLE_TABLES flag, but the Codex Writer does not implement a table rendering path — tables fall through to plain text output.
Syntax Highlighting Requires Grammar Bundling
Section titled “Syntax Highlighting Requires Grammar Bundling”Full per-language syntax highlighting inside code blocks requires bundling grammars for every language you want to support. Codex skips this entirely, using only Color::Cyan for all code. OpenTUI does it properly via tree-sitter grammars compiled into the Zig native library, at the cost of binary size.
OpenOxide Blueprint
Section titled “OpenOxide Blueprint”Architecture
Section titled “Architecture”OpenOxide’s markdown renderer should follow Codex’s pulldown-cmark Writer approach: typed Text<'static> output, incremental streaming via newline-gated buffering. No re-render on every token.
Token stream → MarkdownStreamCollector::push_delta() ↓ Find last newline in buffer ↓ render_markdown_text_with_width(committed, width) ↓ Vec<Line<'static>> → VecDeque in StreamState ↓ StreamController drains N lines per frame tick ↓ ChatWidget renders lines to ParagraphCore Types
Section titled “Core Types”pub fn render_markdown_text(input: &str) -> Text<'static>;pub fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static>;
// openoxide-tui/src/markdown/stream.rspub struct MarkdownStreamCollector { buffer: String, committed_line_count: usize, width: Option<usize>,}impl MarkdownStreamCollector { pub fn push_delta(&mut self, delta: &str); pub fn commit_complete_lines(&mut self) -> Vec<Line<'static>>; pub fn finalize_and_drain(&mut self) -> Vec<Line<'static>>;}Style System
Section titled “Style System”Avoid hardcoding colors in the renderer. Instead, accept a MarkdownTheme struct that maps heading levels, code, links, and list markers to ratatui Style values. This struct is populated at startup from the active color theme, enabling proper dark/light adaptation.
pub struct MarkdownTheme { pub h1: Style, pub h2: Style, pub h3: Style, pub code_block: Style, pub code_inline: Style, pub emphasis: Style, pub strong: Style, pub strikethrough: Style, pub link: Style, pub blockquote: Style, pub list_marker: Style,}Syntax Highlighting in Code Blocks
Section titled “Syntax Highlighting in Code Blocks”For code blocks, integrate tree-sitter-highlight. Use a lazy-loaded HighlightConfiguration registry keyed by language name. On first encounter of a language, load the grammar and cache it. Fall back to the code_block style if the grammar is not available.
pub struct SyntaxHighlighter { configs: HashMap<String, HighlightConfiguration>, theme: HighlightTheme,}
impl SyntaxHighlighter { pub fn highlight(&self, code: &str, lang: &str) -> Text<'static>;}Crates
Section titled “Crates”| Crate | Purpose |
|---|---|
pulldown-cmark | Markdown parsing events |
ratatui | Text, Line, Span, Style primitives |
tree-sitter-highlight | Syntax highlighting for code blocks |
unicode-width | Column width calculation for wrapping |
tree-sitter-{lang} | Per-language grammars (lazy-loaded) |