rsmarkdown-tui
Streaming markdown, in Rust — a faithful re-implementation of jinghaihan/vue-stream-markdown (itself derived from vercel/streamdown), with the core and display layers separated.
Markdown that arrives incrementally (LLM output, typing, pipes) is kept renderable at every intermediate state: incomplete syntax is self-healed on the fly, completed blocks are never re-parsed, and only the trailing block is re-rendered.
Layout
crates/
core/ rsmarkdown-core — pipeline, zero terminal dependencies
tui/ rsmarkdown-tui — ratatui display-layer adapter + interactive demo
rsmarkdown-core
raw content
-> normalize CRLF, trailing whitespace, LaTeX pre-processing
-> parse_markdown_into_blocks stable block segmentation (footnotes collapse
the document; unclosed HTML / $$ merge blocks)
-> preprocess LAST block only syntax self-healing (11 fix steps, ordered)
-> parse each block to AST pulldown-cmark bridge, LRU-cached (cap 100)
-> Document handed to any display adapter (Renderer trait)
use ;
let mut processor = default;
let mut renderer = new;
for chunk in stream
The preprocess steps (in order): code, html, footnote, strong,
emphasis, delete, taskList, link, table, inlineMath, math —
ported one-to-one from markmend/core/src/preprocess/*.ts, including
exclusions (markers inside code blocks, URLs, math, HTML tags) and removal
fallbacks for bare markers.
rsmarkdown-tui — a component TUI framework
The TUI is a small component framework, not a markdown viewer:
crates/tui/src/
component.rs Component trait (draw / event / on_tick / status / hints)
app.rs App host: event loop, focus routing, status bar
activities.rs agent hints: thinking blocks + tool calls (framework-level)
image.rs image backend plumbing (protocol detection, slicing)
components/ pluggable panes:
markdown.rs streaming markdown viewer (uses rsmarkdown-core)
image.rs scrollable image viewer
chat.rs agent session: thinking + tool hints + markdown replies
text.rs plain-text streaming log (non-markdown component)
list.rs selectable task list (interactive component)
renderer/ StreamMarkdownRenderer: AST -> styled lines (per-block cache)
Agent hints (Claude Code style)
The framework ships hint types and rendering in activities.rs — any component can
hold Vec<AgentHint> and render them with hint_lines:
⠋ thinking · 1.4s (running: braille spinner)
─ thinking · 2.3s · checking imports ─ (done: collapsed, dim)
⠙ bash cargo test -p core (tool running: cyan)
✓ bash cargo test -p core · 900ms (done: green, with duration)
✗ curl fetch https://x · 3000ms · exit 7 (error: red)
… 54 passed · finished in 0.02s (output preview line, dim)
The [3] chat component runs a scripted agent session: type a message
([enter] to send), watch it think (spinner + collapsed digest), call tools
(bash, read), then stream a markdown reply through the same display
adapter as the markdown component. Consecutive hints are deduplicated by
identity (tool name / thinking stage) so running -> done updates replace the
right line.
Terminal image backend
Images render through the best protocol the terminal supports (detected via
Picker::from_query_stdio): kitty graphics (kitty, ghostty, wezterm…),
sixel, iTerm2, with a unicode half-blocks fallback everywhere else (pure
text — works headless and in CI).
Scroll correctness comes from two layers:
SlicedImagerenders only the visible rows of a partially-scrolled image (skip/drop), so position is exact at any scroll offset — including images inside the markdown document flow (paragraphs, or the generateddemo://gradient).- The kitty backend uses unicode placeholders, so the terminal keeps the picture attached to its cells as the viewport scrolls — no ghost images, no manual erase/replace.
The markdown component lays out documents as text lines + image blocks; the
demo doc includes one image, and [2] image is a standalone scrollable
viewer component. Headless tests (tests/image.rs) verify scroll shifting,
off-screen clipping and image/text interleaving using the half-blocks
protocol.
StreamMarkdownRenderer implements rsmarkdown_core::Renderer: it converts
each block's AST into styled terminal lines with per-block caching — only
blocks whose source changed are re-rendered, mirroring the original's memoized
Block components. Any component can be mounted into the host.
cargo run -p rsmarkdown-tui
[1] markdown— streams a demo document chunk-by-chunk (simulated LLM output);ttypes markdown yourself and watches unclosed**bold, fences, tables, task lists get completed live;ploads a ~200 KB stress document[2] image— scrollable image via the terminal graphics backend[3] chat— agent session with thinking/tool hints + markdown replies[4] log— a streaming plain-text log (proves rendering is not markdown-specific)[5] tasks— a selectable task list (spacetoggles)Tab/[]/1-3switch focus,j/k/PgUp/PgDn/g/G/mouse wheel scroll,sauto-scroll,qquits
Headless checks (no terminal required):
cargo run -p rsmarkdown-tui --example headless # full markdown pipeline
cargo test -p rsmarkdown-tui # incl. component smoke tests
Using as a library
The TUI is a library, not a markdown viewer — the demo binary only consumes
it. Custom apps assemble [App] with their own [Component]s:
use Buffer;
use Rect;
use ;
;
let mut app = new;
run_tui?;
The public API is fully documented (cargo doc -p rsmarkdown-tui --open):
activity model, footer badges, image backend, and the markdown renderer are
all reusable pieces. crates/tui/examples/custom.rs shows a self-contained
custom component with +/- interaction and a footer badge.
Permission dialog
App::ask (or a component's on_ask) raises a Claude Code style modal: numbered options with a ❯ selection marker, Esc to cancel, Enter / digit / double-click to confirm, and an optional pre-rendered content preview — the caller supplies styled lines, so the demo feeds it with the activity diff renderer (capped at 8 rows).
Command menu & help
Typing / into an empty prompt opens a filterable command menu (arrow keys
move, Enter confirms, Esc closes, mouse click selects / double-click
confirms); ? toggles a grouped keybinding panel. Both are reusable
library pieces (SlashCommandMenu, HelpPanel); the demo wires /clear
and /help.
Agent overview
AgentView renders a Claude Code Agent View style session table (Pinned /
Ready for review / Needs input / Working / Completed, status icons with a
Working animation, PR + age columns, collapsed … N more tails, transcript
and peek overlays). The host broadcasts agent state between components:
any component publishes [Component::agents], every component receives the
merged table via [Component::absorb_agents] — the demo chat feeds its
subagents to the overview with no coupling.
Expand / collapse policy
Activities auto-expand while active (running thinking / tool / subagent) and collapse back when finished — a click reopens them, and a manual click survives subsequent updates.
Todo checklists live in a host task area like Claude Code: Ctrl+T
toggles the panel (bottom-docked, up to five tasks with pending /
in-progress / done indicators). Components publish their checklist via
Component::tasks() and the host merges them — nothing renders in the
transcript. The todo_lines priority-folding window (last 3 done +
… N done, 5 active items, … +N more) is available to any app that
wants the checklist inline.
Theming
Every color flows through one [Theme] value object of semantic tokens
(text, inactive, claude accent, permission, success/error/
warning, …). Presets: Theme::dark() (the default look), Theme::light()
and Theme::automatic() (resolves to dark for now). App::set_theme applies
the theme to the status bar and broadcasts it to every component
(Component::set_theme); the markdown renderer, activities, panels and
dialogs all paint through the same tokens.
Performance
Core: 7x faster than the JS original (per-block cache, find-based rewrites).
Chat component (criterion, release): a full scripted turn costs ~60µs, a single frame at 100×40 ~45µs, a frame over an 11-turn transcript ~160µs — all under 1% of the 33ms tick budget. Run:
cargo bench -p rsmarkdown-core
cargo bench -p rsmarkdown-tui --bench chat
Two tools:
cargo bench -p rsmarkdown-core # criterion benchmarks
cargo run -p rsmarkdown-tui --example perf # headless report demo
In the TUI, press p to instantly load a ~200 KB stress document; the status
bar shows the parse time (µs) and cache hits for every update.
Representative numbers (release build, Apple silicon):
doc size blocks parse (ms) MB/s
2 KB 45 0.62 3.2
64 KB 1349 1.39 45.9
512 KB 10767 10.18 50.3
incremental streaming: 64 KB doc, 1026 chunks of 64 B
block-cached 710 ms ~690 µs/chunk cache hits 100%
naive full-reparse ~1000 ms ~890 µs/chunk
Where the time goes on a 512 KB document (per stream update):
normalize 4.6 ms 46% full-text scan (CRLF, LaTeX rewrites)
block split 1.9 ms 19% full-text scan
preprocess + parse 3.4 ms 34% only the trailing block (LRU-cached)
The block cache turns the AST parse — the single most expensive step — from
O(document) into O(tail) per update, with a 100% hit rate on completed blocks.
The remaining O(document) per-update cost is the normalize + block-split scan,
the same architecture the original project uses. At interactive streaming
rates (~33 ms/frame) a 512 KB document still leaves ~70% of the frame budget
free. normalize skips all rewriting when no $/\/CRLF are present.
Fidelity
The core ships with the original project's own test suite: 145 assertions
extracted from test/markmend/preprocess/test-cases.ts (generated by
scripts/gen-tests.cjs, verified against a verbatim JS port of fixStrong),
plus hand-written pipeline/parser/block tests:
cargo test
Notes on differences
- Math: the original's
preprocessLaTeXmangles single-line$$x$$into$$$x$$(its$rewrite also re-processes the$$it just inserted), breaking single-line formulas. Ours skips$characters that are part of an existing$$pair, so$$x$$,\(x\)and\[x\]all normalize to clean$$x$$; the single-dollar$x$->$$x$rewrite is kept as in the original.
Terminal math display
Math nodes are rendered as Unicode text (crates/tui/src/renderer/math.rs),
the approach used by terminal markdown viewers (innomd, latex-terminal):
LaTeX is converted to Unicode glyphs, so
$$\int_a^b f(x)\,dx = F(b) - F(a)$$ -> ∫ₐᵇ f(x) dx = F(b) − F(a)
$$\sum_{i=1}^n i = rac{n(n+1)}{2}$$ -> Σᵢ₌₁ⁿ i = n(n+1)⁄2
Covered: Greek letters, operators (∫ Σ ∏ √ …), Unicode sub/superscripts with
_(...)/^(...) fallback for missing glyphs, rac as ⁄ fractions,
blackboard letters (\mathbb{R} -> ℝ), matrices as (a b ; c d), accents,
ext{}, and environments. Unknown commands degrade to their plain name.
Alternatives considered: terminal graphics protocols (kitty/sixel/iTerm2
inline images via ratatui-image) give true typesetting but need a
math-to-image backend (no mature Rust one; KaTeX requires Node), split
terminal support, and are expensive per-chunk in streaming; block-character
self-rendering (texterm/termula) is a full math layout engine. Unicode text
is zero-dependency, terminal-agnostic, and deterministic — the right fit for
streaming TUI rendering.
- Block tokenization approximates
marked's lexer (blank-line runs + fences- boundary lines) rather than depending on
markeditself; block strings keepmarked's trailing-newlinerawsemantics.
- boundary lines) rather than depending on
- The parse bridge uses
pulldown-cmark(the Rust equivalent ofmdast-util-from-markdown), exposed through a small, stable renderer-facing AST — display adapters never see pulldown types. - Rendering is terminal-native: bold/italic/strike/code/link/math styling,
CJK-aware wrapping and table column fitting (
unicode-width).