18 KiB
Rich Markdown Renderer for the REPL
Status: Planning complete, awaiting Momus review before implementation.
Owner: Coyote maintainer
Estimated effort: Phase 1 = 4-5 days, Phase 2 (tables) = +1-2 days
Related flag: raw_markdown (already plumbed; see commit history for the plumbing PR)
Goal
Replace Coyote's current syntect-only markdown rendering with a rich renderer that transforms markdown syntax into styled terminal output (headings become colored + bold text, **bold** becomes actual bold, backticks strip and stylize, blockquotes get a │ prefix, etc.), matching glamour's structural output while preserving the user's existing syntect .tmTheme colors.
The current renderer just applies syntect's markdown grammar for syntax highlighting — the markdown syntax characters (#, **, `) stay in the output, just colored. Users get raw markdown with color, not rendered markdown. The new renderer actually transforms the markdown into styled output like glamour (github.com/charmbracelet/glamour) does.
Non-Goals
- Not replacing the renderer's public API.
MarkdownRender::init,render,render_line, andRenderOptionsall keep their existing signatures. Callers (stream.rs,session.rs,app_config.rs::print_markdown,request_context.rs::session_info) do not change. - Not changing streaming architecture.
stream.rsstill callsrender()on complete lines andrender_line()on the incomplete tail. New renderer must fit this line-by-line contract. - Not touching code block rendering. Fenced code blocks (
```lang ... ```) continue to route to syntect language-specific highlighting viafind_syntax_by_token. The new renderer only affects markdown syntax rendering, never code content. - Not adding new dependencies. All work uses existing
syntect,fancy-regex,crossterm,textwrap. - Not shipping tables in Phase 1. Tables require multi-line buffering, which conflicts with the stateless streaming model. Table rows render as raw
| col | col |until Phase 2. - Not implementing OSC 8 hyperlink fallback logic. Emit OSC 8 codes unconditionally + always show URL visibly. Terminals that don't support OSC 8 strip the codes and see plain "text URL" text.
Design Principles
- Colors from user theme, layout from glamour. Every construct extracts its color from the user's syntect theme via scope lookup with fallback chains. The structural layout (prefixes, indents, borders, box-drawing) matches glamour's default dark style.
raw_markdown: true= current behavior byte-identical. The existing syntect-on-markdown-grammar path is preserved as the "raw" branch and reachable via config/CLI/REPL. Zero regression risk for users who want the old behavior.- Preserve line-by-line rendering. No state beyond the existing
LineTypecode-block tracker. Stateless per-line rendering means the streaming'srender_linefor partial buffer works identically to the mutatingrender_line_mutfor complete lines. - Regex-based inline parsing, not pulldown-cmark. A full markdown parser needs the complete document to disambiguate. Regexes match balanced spans and gracefully leave unclosed spans as raw text — exactly right for streaming's mid-token partial-line rendering.
- Only
src/render/markdown.rschanges. Scope containment: the entire implementation lives in one file. No touches tostream.rs,mod.rs,session.rs,app_config.rs,request_context.rs.
Resolved Design Decisions
Recorded here so future sessions don't re-litigate them:
- Tables: deferred to Phase 2. Phase 1 leaves table rows as raw markdown.
- H2-H6 hash prefixes: matched to glamour — keep
##,###,####,#####,######visible in the heading color as a level indicator. H1 gets paddedtexttreatment. - Link rendering: OSC 8 hyperlink codes wrapping visible
{text} {url}— modern terminals show a clickable link, older terminals show plain styled text. Matches glamour exactly. Users on broken terminals can fall back to.set raw_markdown true.
Architecture
Data structures (added to MarkdownRender)
struct MarkdownStyles {
heading: (Color, bool /* force_bold */),
bold: Color,
italic: Color,
inline_code_fg: Color,
inline_code_bg: Option<Color>,
blockquote: Color,
list_bullet: Color,
link_text: Color,
link_url: Color,
strikethrough: Color,
hrule: Color,
}
Populated once in MarkdownRender::init via a new resolve_scope_style(theme, primary_scope, fallbacks) helper that generalizes the existing get_code_color() pattern (markdown.rs:299).
When options.theme.is_none(), all styles collapse to defaults (raw text output with no colors — matches current behavior).
Line-type detection
Extended check_line returns a new LineKind enum (only for non-code lines):
| Regex | LineKind |
|---|---|
^\s*(#{1,6}) +.+ |
Heading(level) |
^\s*> ?.* |
Blockquote |
^(\s*)- \[[ xX]\] +.+ |
TaskItem(checked) |
^(\s*)[-*+] +.+ |
BulletItem |
^(\s*)\d+\. +.+ |
NumberedItem |
| `^\s*(-{3,} | _{3,} |
^\s*|.*|\s*$ |
(Phase 2: TableRow) — treated as paragraph for now |
| default | Paragraph |
Stateless: line-type detection carries no state beyond the existing prev_line_type/code_syntax fields for code block tracking. Streaming's partial-line render_line works identically to complete-line render_line_mut.
Block-level rendering
Each LineKind triggers a block transformation that strips syntax markers and applies structural styling. All block types then run their remaining text content through the inline pipeline.
| LineKind | Transformation |
|---|---|
Heading(1) |
Prefix , suffix (single spaces), apply bold + heading color to entire line |
Heading(2..=6) |
Keep visible ##/###/etc. prefix, apply bold + heading color |
Blockquote |
Replace > with │ (styled blockquote color); apply blockquote color to remaining content |
BulletItem |
Replace -/*/+ with • (styled list_bullet color); preserve leading whitespace for nesting |
NumberedItem |
Preserve number, style the . in list_bullet color |
TaskItem(false) |
Replace [ ] with [ ] styled in list_bullet color |
TaskItem(true) |
Replace [x] with [✓] styled |
HorizontalRule |
Emit ──────── (8-char box-drawing) styled with hrule color (typically dim/gray) |
Paragraph |
No block transform, inline pass only |
Inline rendering (regex pipeline, applied in order)
Order matters — inline code first prevents re-parsing code content as bold/italic:
- Inline code (
`text`) — regex`([^`\n]+)`, strip backticks, applyinline_code_fg+ optionalinline_code_bg. - Images (
) — regex!\[([^\]]*)\]\(([^)]+)\), emitImage: {alt} → {url}styled withlink_url. Wrap in OSC 8 hyperlink codes. - Links (
[text](url)) — regex\[([^\]]+)\]\(([^)]+)\), emit{text} {url}withlink_texton the label andlink_urlon the URL. Wrap in OSC 8 hyperlink codes. - Bold (
**text**or__text__) — regex\*\*([^*\n]+)\*\*and__([^_\n]+)__, strip markers, apply bold ANSI +boldcolor. - Italic (
*text*or_text_) — regex(?<![*\w])\*([^*\n]+)\*(?!\*)and(?<![_\w])_([^_\n]+)_(?!_)— lookbehind/lookahead prevents word-internal_from matching (e.g.,some_var_name).fancy-regexsupports lookbehind. - Strikethrough (
~~text~~) — regex~~([^~\n]+)~~, strip markers, apply ANSI strikethrough (\x1b[9m).
Partial-span handling for streaming: regexes only match balanced spans. Unclosed spans (**bold with no closing) stay raw. When the closing marker arrives on the next token, the complete-line pass renders the full span correctly.
OSC 8 hyperlinks
\x1b]8;;{url}\x1b\\{visible_text}\x1b]8;;\x1b\\
Emit unconditionally around links and images. Unsupported terminals strip the codes and see plain visible text. Zero degradation.
Branching in highlight_line
fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String {
if is_code {
// unchanged — code block content via language-specific syntect
self.highlight_code_syntect(line, syntax)
} else if self.options.raw_markdown {
// preserved current behavior: syntect on markdown grammar
self.highlight_markdown_syntect(line, &self.md_syntax)
} else {
// new rich rendering path
self.render_markdown_line(line)
}
}
Code blocks route to syntect regardless of raw_markdown — the flag only affects markdown syntax rendering.
Consumers Verified
Complete map of MarkdownRender consumers (from explore agent research). All continue to work without modification because the public API is unchanged:
src/render/mod.rs:16-33—render_stream()(streaming path viamarkdown_stream())src/render/stream.rs:67-171—markdown_stream_inner()callsrender.render(head)andrender.render_line(&buffer)src/config/app_config.rs:420-429—print_markdown()(CLI one-shot)src/config/request_context.rs:1706-1723—session_info()(.infoREPL command)src/config/session.rs:278-396—Session::render()(per assistant message)src/render/markdown.rs:311-397— existing tests
Phase 1 Implementation
Phase 1.1 — Scope lookup helper + precomputed styles
- Add
resolve_scope_style(theme, primary, fallbacks)helper (generalizesget_code_color()) - Add
MarkdownStylesstruct + populate inMarkdownRender::initfor all 10 constructs - Handle
theme.is_none()gracefully (all styles = defaults) - Test: verify each style resolves correctly with the built-in dark theme
- Test: verify each style falls back correctly with a minimal theme that only defines root scopes
Commit: feat(render): precompute markdown scope styles for rich rendering
Phase 1.2 — Line-type detection
- Add
LineKindenum +detect_line_kind()function - Wire into
check_line— returnLineKindalongside existingLineType - Test each pattern in isolation (heading, blockquote, bullets, numbered, task, hrule, paragraph)
- Test edge cases:
##vs##text(no space, not a heading), indented list items, empty blockquote
Commit: feat(render): detect markdown block-level line types
Phase 1.3 — Inline rendering pipeline
- Add regex constants (LazyLock) for each inline construct
- Add
apply_inline(text: &str, styles: &MarkdownStyles) -> Stringthat runs the pipeline in order - Test each construct in isolation
- Test order-dependence:
**foobarbaz**— bold wraps inline code correctly - Test partial spans stay raw:
**unclosed→**unclosed - Test italic doesn't false-positive:
some_var_name,a * b * c(math-like expression) - Test OSC 8 emission for links and images
Commit: feat(render): rich inline markdown rendering (bold, italic, code, links)
Phase 1.4 — Block-level rendering
- Add
render_markdown_line(line)that dispatches onLineKind - Implement each block transform (heading, blockquote, bullet, numbered, task, hrule, paragraph)
- After block transform, always run
apply_inlineon the content - Test each block type with inline styling nested inside (bold in heading, code in list item, link in blockquote)
Commit: feat(render): rich block-level markdown rendering (headings, quotes, lists, hr)
Phase 1.5 — Wire into highlight_line with raw_markdown branch
- Refactor
highlight_lineto branch onoptions.raw_markdown - Remove
#[allow(dead_code)]fromRenderOptions::raw_markdown - Verify all existing tests pass with
raw_markdown: true(byte-identical output) - Manual REPL test: send a message with a mix of constructs, verify output matches expectations
- Manual streaming test: verify no flashing, partial spans render smoothly
Commit: feat(render): activate rich markdown renderer as default
Phase 1.6 — Test coverage
- Heading levels 1-6 (transforms + styling)
- Bold, italic, inline code, strikethrough
- Inline code strips backticks
some_var_nameNOT italicizeda * b * cmath not italicized- Blockquote
│prefix - Bullet
•transformation - Numbered list preservation
- Task items
[ ]/[✓] - Horizontal rule
- Links: styled text + URL, OSC 8 codes present
- Images:
Image: {alt} → {url}format, OSC 8 codes present - Nested inline in blocks (bold in heading, code in list)
- Partial spans in
render_line theme=Nonedegrades to raw stripped text (no colors, but syntax stripped)raw_markdown=truematches current behavior byte-for-byte
Commit: test(render): comprehensive coverage for rich markdown renderer
Phase 2 (Follow-up PR) — Tables
Deferred scope. Rough sketch:
- Add
Option<TableBuffer>field toMarkdownRender - On table row detection, accumulate rows in buffer (emit raw markdown for now to keep streaming visible)
- On non-table line (or blank), flush the buffer: compute column widths, render with box-drawing chars, emit
- Handle streaming: use cursor-erase to replace raw rows with rendered table when buffer flushes
- Test coverage: single-column, multi-column, alignment specifiers (
:---,---:,:---:), empty cells, long content wrapping
Success Criteria (Phase 1)
- All existing tests pass with
raw_markdown: true - All new tests pass with
raw_markdown: false cargo checkcleancargo testall pass- Manual REPL test: streaming looks smooth (no flashing, no visible partial spans getting re-rendered)
- Manual REPL test:
.set raw_markdown truereverts to current behavior - Manual test: user's custom theme colors apply to headings/bold/etc. (not just default)
Progress Log
Append-only. One entry per commit or session.
2026-07-22 — Planning complete
- Scoped implementation via research (glamour source, syntect scope conventions, current renderer consumers)
- Resolved 3 open design questions (tables deferred, glamour hash prefixes matched, OSC 8 with fallback)
- Wrote this plan file
- Next: hand to Momus for review before starting Phase 1.1
2026-07-22 — Phase 1.1 complete (d2940a8)
- Added
resolve_scope_stylehelper +MarkdownStylesstruct with 10 constructs, precomputed once inMarkdownRender::init; new struct is#[allow(dead_code)]until Phase 1.5 wires it in. 6 new tests cover primary/fallback/default paths,theme.is_none(), built-in dark theme, and a minimal-root-scopes theme.
2026-07-22 — Phase 1.2 complete (f40ba4c)
- Added
LineKindenum (Heading/Blockquote/TaskItem/BulletItem/NumberedItem/HorizontalRule/Paragraph) anddetect_line_kind()usingfancy_regexfor the 6 block patterns. Wired intocheck_line— signature now returns(LineType, LineKind, Option<SyntaxReference>, bool); callers ignoreLineKindwith_until Phase 1.4. 8 new tests cover each pattern plus edge cases (##notheading,-nospace,--, indented items, empty blockquote).
2026-07-22 — Phase 1.3 complete (89db5b3)
- Added inline regexes (INLINE_CODE, IMAGE, LINK, BOLD_AST, BOLD_US, ITALIC_AST, ITALIC_US, STRIKETHROUGH, CODE_PLACEHOLDER) and
apply_inline()running the plan's 6-step pipeline. Refined italic regexes with(?!\s)opener +(?<!\s)closer to preventa * b * cfalse-positives while still requiring the word-boundary lookbehind forsome_var_name. Inline code is masked with\x00C{idx}\x00placeholders before other transforms so its content is never re-parsed. Links/images wrap in OSC 8 hyperlink codes. 15 new tests cover each construct, order-dependence, partial spans, italic false-positives, OSC 8 emission, and image-before-link ordering.
2026-07-22 — Phase 1.4 complete (9890cf0)
- Added
render_markdown_line(line, kind, styles)dispatcher plus per-LineKindblock renderers (render_heading,render_blockquote,render_bullet,render_numbered,render_task,render_hrule). H1 gets space-padded, H2-6 keep their##...prefix; blockquotes get│; bullets →•; numbered items keep the number and style only the.; task items →[ ]/[✓]; hrules render as────────. All block variants delegate leftover content toapply_inline. 15 new tests cover each block type, indent preservation, and nested inline (code in bullet, link in blockquote).
2026-07-22 — Phase 1.5 complete (d65d63e)
- Wired the rich renderer into
render_line/render_line_mut: code lines still route to syntect; non-code lines branch onoptions.raw_markdown(true → existing markdown-grammar syntect path, false →render_rich_markdown_line). Removed#[allow(dead_code)]fromRenderOptions::raw_markdown,MarkdownStyles,LineKind,detect_line_kind,render_markdown_line,apply_inline, and thestylesfield. Updated the 3 existing tests (no_theme,no_wrap_code,wrap_all) to setraw_markdown: true— they still produce byte-identical output, proving the raw path is preserved. Manual REPL/streaming tests deferred to user.
2026-07-22 — Phase 1.6 complete (b0eeba1)
- 6 more tests filling out the coverage checklist: bold nested inside a heading, partial bold/link spans via
render_line(streaming path), rich rendering withtheme=Nonestill strips syntax and emits block glyphs, rich vs raw paths diverge on the same input, and fenced code blocks still route through syntect. Total: 55 markdown tests, 1207 total tests pass,cargo checkclean.
2026-07-22 — Phase 2 complete (Phase 1 successor shipped)
- Phase 2 (tables + hanging-indent list/blockquote wrapping) is complete on top of this foundation. See
rich-markdown-renderer-tables.mdfor the full plan and per-sub-phase progress log. Final commits:fcc4a1d(2.1) →7671d28(2.2) →c062f34(2.3) →cdfaa0f(2.4) →bf06d5e(2.5) →d790782(2.6) →336b374(2.7). Total markdown tests grew from 55 → 94; total test suite 1207 → 1246, all passing,cargo checkclean.