Skip to content

fix(ui): render markdown in the conversation viewer, scoped and toggleable - #259

Merged
tintinweb merged 7 commits into
masterfrom
feat/viewer-markdown
Aug 24, 2026
Merged

fix(ui): render markdown in the conversation viewer, scoped and toggleable#259
tintinweb merged 7 commits into
masterfrom
feat/viewer-markdown

Conversation

@tintinweb

@tintinweb tintinweb commented Aug 24, 2026

Copy link
Copy Markdown
Owner

Closes #210. Supersedes #211 — thanks @zeroknots, whose PR is the starting point here. States as of opening.

Summary

The conversation viewer builds every content line with wrapTextWithAnsi, which is correct for arbitrary bytes and wrong for the one kind of content that is authored as Markdown. Two distinct failure modes:

  1. Assistant text shows its source. Headings render as # Heading, emphasis as **bold**, code as literal ``` fences. The model writes Markdown; the viewer is the only surface that shows it raw.
  2. Tool results are cut at 500 characters. Small enough to truncate most real results mid-sentence — a ctx_execute block, a test summary, a file read — leaving no way to see the rest without leaving the viewer.
image

What changed

One idea: Markdown rendering is scoped by content contract, not applied to the transcript wholesale.

Assistant text is Markdown by contract. A tool result is whatever bytes the tool produced, and running it through a Markdown renderer rewrites constructs that occur constantly in real command output. Reproduced against the installed @earendil-works/pi-tui:

# section          (shell/Python/YAML comment)  ->  section          the `#` is eaten
3) alpha           7) beta   9) gamma           ->  3. alpha  4. beta  5. gamma
Section\n---\nnext                              ->  the `---` vanishes (setext heading)
    2026-01-01 INFO started                     ->  re-fenced as ```, indentation stripped
| a | b |                                       ->  redrawn as a box-drawing table

None of those are safe edits to make to a diff, a log, or a script, and each reads as the tool having misbehaved.

Consequences of that one idea:

  • viewerMarkdown: off | assistant | all, default assistant. off is today's behaviour; assistant renders assistant text and leaves results verbatim and dim; all renders results too, for tools that genuinely emit Markdown. Tri-state follows widgetMode / agentMentions / toolDescriptionMode.
  • m in the viewer cycles the mode and persists it, so the key and /agents → Settings mean the same thing. This is the load-bearing part: it is the escape hatch that makes rendering safe to default on at all, and it is what serves Render Markdown in the conversation viewer #210's ctx_execute case without imposing the rewrites above on everyone. Persisting is toast-free — a notification per press would talk over the overlay it describes.
  • The 500-char cap is raised to 16 KB, not removed, and now covers bashExecution too. The cap bounds render cost, not just display — see Performance. ... (truncated, N more lines) is emitted as its own viewer line rather than appended to the text: appended, a cut landing inside a fenced code block renders the notice inside the fence, indented and styled as the tool's own source. That is the likely case on exactly the large ctx_execute results this is for.
  • One Markdown per message, held in a WeakMap. The component caches on (text, width), but only across calls to the same instance; a fresh instance per render discards it.
  • Code fences use pi's getMarkdownTheme(), the same source PR feat: markdown result preview + failure-body fallback + uncapped success output #85 uses on the notification surface, which is what supplies highlightCode. It is probed rather than try/caught around the call: getMarkdownTheme() returns arrow functions that read pi's global theme lazily, so an uninitialized theme throws inside render() — long after the call returns — and takes the overlay with it. Falls back to a theme built from the viewer's own Theme, whose italic/underline/strikethrough are raw SGR rather than identity functions; identity would drop *emphasis*'s markers with nothing in their place, turning a formatting change into a content change.
  • Result prose keeps its dim styling under all, via defaultTextStyle, preserving the hierarchy the raw path's per-line fg("dim", …) gave it. Fenced code is the one exception and is left undimmed deliberately — pi's theme colors it via highlightCode, which dimming would flatten.

Footer gains the mode at m raw / m md / m md+. Abbreviated because the idle footer is already full at 80 columns and that group has no degradation step below "drop the line-count readout":

w=80  │ Enter steer · x stop · m md    ↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close │
w=100 │ 2 lines · 100% · Enter steer · x stop · m md       ↑↓ scroll · … · Esc close │

What this does not do

  • User messages are never rendered as Markdown — they are text the user typed literally.
  • bashExecution output is never rendered as Markdown, at any mode. It gets the cap and nothing else.
  • The notification / result-preview surface is untouched. feat: markdown result preview + failure-body fallback + uncapped success output #85 covers that, and this PR deliberately reuses its theme source rather than introducing a second Markdown path.
  • m is hardcoded, not routed through pi's KeybindingsManager. viewer-keys.ts resolves only tui.select.*; there is no tui.* id for this, so it follows the existing hardcoded x / Enter precedent.
  • No CHANGELOG entry — the maintainer curates it.

Related work

# Title State Relation
#210 Render Markdown in the conversation viewer open Closed by this PR.
#211 fix: render Markdown in conversation viewer open Superseded. Same file, same lines — a design conflict, not a rebase, though it is also CONFLICTING against master (its base predates showCost and renderAgentName). Kept from it: rendering assistant text through pi-tui's Markdown, and both its tests (the assistant-Markdown one passes unchanged; the "no 500-char truncation" one became a "capped at 16 KB, not 500" one). Left out: rendering tool results by default, deleting the cap outright, the hand-rolled MarkdownTheme (drops highlightCode, so fences render unhighlighted — the concrete thing #210 asks for), a fresh Markdown per render, and the loss of dim on results.
#85 feat: markdown result preview + failure-body fallback + uncapped success output open Different surface (completion notifications / result previews). No overlapping lines. Converged deliberately: this PR takes its getMarkdownTheme() choice so the two surfaces do not end up with two themes. Its resultPreviewMode and this viewerMarkdown remain separate settings for separate surfaces.
#173 fix(ui): collapse retrieved subagent results open Different surface (get_subagent_result tool render). No overlapping lines.

Behavior and compatibility

  • Default changes for assistant text: it now renders as Markdown instead of showing its source. viewerMarkdown: "off" restores the previous rendering exactly — the off branch is the original wrapTextWithAnsi loop, unmodified.
  • Default changes for tool results: still not Markdown, but the visible cut moves from 500 characters to 16 KB. Strictly more content than before.
  • New setting viewerMarkdown, persisted to .pi/subagents.json through the existing global/project precedence. Absent → assistant. Invalid values are dropped by the reader, as widgetMode does.
  • No breaking changes. The two new ConversationViewer constructor parameters are optional and trailing; omitting both yields assistant mode with a viewer-local m. The class is not exported from the package entry point.
  • m was previously inert in the viewer, so no existing binding is overridden. It also disarms a pending x stop, matching every other non-x key.
  • Applied live; no restart needed, unlike the settings that are read at tool-registration time.

Performance

Standalone script against the installed @earendil-works/pi-tui, width 76, best of 5 warm runs. Input is a synthetic 4000-line source file (196,669 chars); "capped" is its first 16,000 chars.

Path Per call
Uncapped result, fresh Markdown per render (what removing the cap alone would give) 6.37 ms
Capped result, fresh Markdown per render 0.52 ms
Capped result, cached instance (the steady state here) ~0.00 ms
Prior behaviour: wrapTextWithAnsi over a 500-char slice 0.01 ms

This matters because buildContentLines() runs on every render() and on every scroll key — handleInput calls it to compute maxScroll — so the first row is a per-keystroke cost while scrolling, not a one-off. The cap and the per-message cache are both required to keep it off the input path; either alone is not enough.

Not measured: end-to-end frame time inside a live pi session. The figures above are of the parse/render call only.

Testing

npm run lint       Checked 121 files in 89ms. No fixes applied.
npm run typecheck  clean
npm run build      clean
npm run test       78 files passed | 1415 passed | 4 skipped (1419)

New coverage — 16 tests in test/conversation-viewer.test.ts, 2 in test/settings.test.ts:

  • Each mode renders what it should; off shows # Heading literally.
  • Tool results round-trip byte-exact under the default mode, over the exact constructs listed above.
  • Ordered lists are not renumbered even under all.
  • m cycles, persists, updates the footer, works with no persist hook, and disarms a pending stop.
  • The footer keeps Enter steer, x stop, m md and Esc close at 80 columns.
  • Results cap at 16 KB rather than 500 and name the elided line count; the notice stays outside a code fence the cut landed in; a result under the cap is untouched; bashExecution uses the same rule.
  • One Markdown per message across renders, and setText on a message whose text is still streaming.
  • Markdown output fits its width without the truncateToWidth backstop firing — i.e. nothing is being silently cut.
  • Results stay dim under all (asserted on the content line, since every bordered row carries the theme escape on its ).
  • viewerMarkdown round-trips through saveSettings/loadSettings, drops invalid values, and reaches its applier.

Every new assertion was mutation-checked — source line broken, red confirmed, restored. What was broken: assistant Markdown reverted to wrapTextWithAnsi; default mode forced to all; preserveOrderedListMarkers off; cap back to 500; the truncation notice appended back into the content; m handler removed; m not disarming stop; footer label removed; the WeakMap cache bypassed; defaultTextStyle dim dropped; the bash cap removed; the truncation marker made silent; the m override ignored; tool results never Markdown; Markdown rendered at width + 5; the settings validator and applier each disabled.

Two tests were rewritten because the first version did not discriminate: a cap test that read the scrolled viewport instead of the whole transcript, and a width test that only re-tested the pre-existing truncateToWidth clamp.

Not covered by the suite: the branch of resolveMarkdownTheme() that returns pi's real theme. initTheme() is never called under vitest, so the suite exercises only the fallback. Both branches were verified by a standalone script — fallback before initTheme(), pi's theme with a live highlightCode after — but that is not a checked-in test.

@tintinweb tintinweb changed the title fix(ui): render markdown in the conversation viewer, scoped and toggl… fix(ui): render markdown in the conversation viewer, scoped and toggleable Aug 24, 2026
@tintinweb tintinweb added feature New feature or request <📍> labels Aug 24, 2026
tintinweb and others added 2 commits August 24, 2026 12:35
…eable

The viewer wrapped every line with wrapTextWithAnsi, so assistant markdown
showed as raw fences and `#` markers, and tool results were cut at 500
characters (#210).

Assistant text now renders as markdown; tool results do not, by default. A
markdown pass over a tool result is lossy in ways that read as the tool
misbehaving: `# section` in a shell script loses its `#`, `3) 7) 9)` comes back
renumbered `3. 4. 5.`, a `---` line is swallowed as a setext heading, and
indented output is re-fenced. Assistant text is authored as markdown; a tool
result is arbitrary bytes.

viewerMarkdown (off | assistant | all) picks the scope, and `m` in the viewer
cycles it and persists the choice. That escape hatch is what makes rendering
safe to default on at all.

The 500-char cap is raised to 16k rather than removed, and now covers
bashExecution too. It bounds render cost, not just display: buildContentLines
runs on every render and on every scroll key, where an uncapped 200KB result
costs ~19ms per keystroke to re-parse against ~0.04ms for a capped wrap. One
Markdown per message in a WeakMap, so the component's own cache applies.

Code fences use pi's getMarkdownTheme() for syntax highlighting, probed rather
than try/caught around the call: its functions read the global theme lazily, so
an uninitialized theme throws inside render() and would take the overlay with
it. Falls back to a theme built from the viewer's own Theme.

Supersedes #211 — thanks @zeroknots.

Co-authored-by: zeroknots <zeroknots@protonmail.com>
@tintinweb
tintinweb force-pushed the feat/viewer-markdown branch from 0302577 to 1b83beb Compare August 24, 2026 10:43
@tintinweb
tintinweb marked this pull request as ready for review August 24, 2026 10:47
…'t persist

Fuzzing the viewer's Markdown path with hostile tool output — null bytes, lone
surrogates, control characters, unterminated fences and tables, mid-word ANSI,
ZWJ emoji, RTL marks — found one input that throws: ~54 nested blockquotes
overflow pi-tui's recursive renderer with a RangeError. buildContentLines runs
inside render() and again on every scroll key, so that took the overlay down for
content the literal path displays fine.

markdownLines now degrades to literal wrapping on any throw and remembers the
failure, which would otherwise repeat on every render and every keystroke; the
flag clears when the message's text changes. Catching rather than special-casing
blockquote depth, because fuzzing cannot prove that is the only such input.

The three literal-wrapping paths — `off` mode, non-`all` tool results and bash
output — collapse into the rawLines() helper the fallback returns.

Separately, persistSettings discarded saveAndEmitChanged's return, so a failed
write from the `m` key was silent: the mode applied for the session while
looking persisted. It now warns on failure and stays quiet on success, matching
every other settings path.

Tests: the fallback keeps content visible instead of throwing; results stay dim
on the literal path, untested before rawLines took ownership of it; and a tool
result growing past the cap keeps its held-back count moving while the cached
parse is correctly reused.
Editors across the Windows/CJK world write UTF-8 with a BOM, and pi's parser
did not look past one before 0.84.3: the fence missed, frontmatter came back
empty, and the whole file became the body. Such an agent lost every field —
and `tools: none` going missing left it holding bash, edit and write, a wider
grant than its author wrote. `/agents` then refused to toggle it, calling a
file with frontmatter frontmatter-less.

Normalised at the read boundary (parseAgentFrontmatter), the one place agent
files are read, rather than detected per pi version — one behaviour across the
whole supported peer range. The write side looks past the BOM too and leaves it
in the file, since it belongs to the encoding, not the block.

Tests drive the real loader over a real BOM'd Chinese agent file, including the
BOM+CRLF combination a Windows editor actually produces.
@tintinweb
tintinweb merged commit 917853c into master Aug 24, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

<📍> feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Render Markdown in the conversation viewer

1 participant