fix(ui): render markdown in the conversation viewer, scoped and toggleable - #259
Merged
Conversation
…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
force-pushed
the
feat/viewer-markdown
branch
from
August 24, 2026 10:43
0302577 to
1b83beb
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:# Heading, emphasis as**bold**, code as literal ``` fences. The model writes Markdown; the viewer is the only surface that shows it raw.ctx_executeblock, a test summary, a file read — leaving no way to see the rest without leaving the viewer.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: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, defaultassistant.offis today's behaviour;assistantrenders assistant text and leaves results verbatim and dim;allrenders results too, for tools that genuinely emit Markdown. Tri-state followswidgetMode/agentMentions/toolDescriptionMode.min the viewer cycles the mode and persists it, so the key and/agents → Settingsmean 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'sctx_executecase without imposing the rewrites above on everyone. Persisting is toast-free — a notification per press would talk over the overlay it describes.bashExecutiontoo. 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 largectx_executeresults this is for.Markdownper message, held in aWeakMap. The component caches on(text, width), but only across calls to the same instance; a fresh instance per render discards it.getMarkdownTheme(), the same source PR feat: markdown result preview + failure-body fallback + uncapped success output #85 uses on the notification surface, which is what supplieshighlightCode. 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 insiderender()— long after the call returns — and takes the overlay with it. Falls back to a theme built from the viewer's ownTheme, whoseitalic/underline/strikethroughare 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.all, viadefaultTextStyle, preserving the hierarchy the raw path's per-linefg("dim", …)gave it. Fenced code is the one exception and is left undimmed deliberately — pi's theme colors it viahighlightCode, 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":What this does not do
bashExecutionoutput is never rendered as Markdown, at any mode. It gets the cap and nothing else.mis hardcoded, not routed through pi'sKeybindingsManager.viewer-keys.tsresolves onlytui.select.*; there is notui.*id for this, so it follows the existing hardcodedx/Enterprecedent.Related work
CONFLICTINGagainst master (its base predatesshowCostandrenderAgentName). Kept from it: rendering assistant text through pi-tui'sMarkdown, 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-rolledMarkdownTheme(dropshighlightCode, so fences render unhighlighted — the concrete thing #210 asks for), a freshMarkdownper render, and the loss ofdimon results.getMarkdownTheme()choice so the two surfaces do not end up with two themes. ItsresultPreviewModeand thisviewerMarkdownremain separate settings for separate surfaces.get_subagent_resulttool render). No overlapping lines.Behavior and compatibility
viewerMarkdown: "off"restores the previous rendering exactly — theoffbranch is the originalwrapTextWithAnsiloop, unmodified.viewerMarkdown, persisted to.pi/subagents.jsonthrough the existing global/project precedence. Absent →assistant. Invalid values are dropped by the reader, aswidgetModedoes.ConversationViewerconstructor parameters are optional and trailing; omitting both yieldsassistantmode with a viewer-localm. The class is not exported from the package entry point.mwas previously inert in the viewer, so no existing binding is overridden. It also disarms a pendingxstop, matching every other non-xkey.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.Markdownper render (what removing the cap alone would give)Markdownper renderwrapTextWithAnsiover a 500-char sliceThis matters because
buildContentLines()runs on everyrender()and on every scroll key —handleInputcalls it to computemaxScroll— 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
New coverage — 16 tests in
test/conversation-viewer.test.ts, 2 intest/settings.test.ts:offshows# Headingliterally.all.mcycles, persists, updates the footer, works with no persist hook, and disarms a pending stop.Enter steer,x stop,m mdandEsc closeat 80 columns.bashExecutionuses the same rule.Markdownper message across renders, andsetTexton a message whose text is still streaming.truncateToWidthbackstop firing — i.e. nothing is being silently cut.all(asserted on the content line, since every bordered row carries the theme escape on its│).viewerMarkdownround-trips throughsaveSettings/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 toall;preserveOrderedListMarkersoff; cap back to 500; the truncation notice appended back into the content;mhandler removed;mnot disarming stop; footer label removed; theWeakMapcache bypassed;defaultTextStyledim dropped; the bash cap removed; the truncation marker made silent; themoverride ignored; tool results never Markdown;Markdownrendered atwidth + 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
truncateToWidthclamp.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 beforeinitTheme(), pi's theme with a livehighlightCodeafter — but that is not a checked-in test.