feat: render LaTeX, custom HTML and diagrams in the transcript #19

Merged
adrianbonpin merged 1 commits from feat/transcript-rendering into prod 2026-10-07 07:26:41 +00:00
Owner

Closes the three rendering gaps in the transcript pane. Every step is there because Vicinae cannot do that part alone; the reasoning is in the new ## Rendering section of the README.

LaTeX (src/lib/latex.ts)

Vicinae maps cmark nodes to a fixed set of block components and there is no maths block among them, so $x^2$ reached the pane as literal TeX.

A paragraph is a TextEdit with textFormat: RichText (MdParagraph.qml) and markdown-model.cpp passes CMARK_NODE_HTML_INLINE through untouched, so sup, sub, b and i are available. The pane is also a launcher-width column, so this is a linearisation rather than a typesetter: \frac{a}{b} becomes a/b, \sqrt{x+1} becomes √(x+1), \sum_{i=1}^{n} becomes ∑ᵢ₌₁ⁿ, and a tag is used only where Unicode has no form (x^{a/b} becomes x<sup>a/b</sup>).

$...$, \(...\), $$...$$ and \[...\] on a line of their own, and a fence labelled math, are all read. A lone $ pair counts only when the inside looks like maths, so $5 and $10 and cd $A; echo $B are untouched, and an opening $$ with no closer is only a formula when what it swallowed reads as one.

Custom HTML (src/lib/html.ts)

Raw inline HTML is the only route past GFM, and it is also a hole. The tags and style declarations the renderer acts on are kept; script, style and the other element types whose body is not content are removed with it; an event handler, a url() in a style value and a javascript: destination are dropped; and a tag that is not on the list is shown as text rather than swallowed silently. An <a href> becomes a markdown link, the only form Vicinae makes clickable, and an <img> keeps its tag with its source judged instead, because the block extractor is the one place an image written as HTML is loaded for real.

A block of HTML is also moved into a paragraph. processHtmlNodes in markdown-model.cpp is not a renderer: it pulls out <img> and keeps the trimmed text of everything else, so <table><tr><td>a</td><td>b</td></tr></table> arrives as ab. A well-formed block is joined onto one line and wrapped in a <span>, which is not on CommonMark's list of block tags, so it arrives as the paragraph whose raw HTML is passed through. Markup that does not balance and a block holding an <img> are left alone.

Everything else (src/lib/code.ts)

  • A paragraph drawn with box characters is fenced, because a paragraph wraps and a reflowed diagram says nothing. A real GFM table is left to the table renderer, one stray pipe in prose is not art, and an indented block is already code.
  • A fence label KSyntaxHighlighting would print but never resolve is renamed (console, terminal and friends become bash), and one that carries no information (text, plaintext) is dropped.

Wiring

renderModelOutput runs the whole pass in one place, in render.ts: the fence repair first because it decides where the fences are, maths next, HTML last so the tags the maths pass adds go through the same allowlist as the ones the model wrote. A question goes through the maths and markup passes without the fence repair, since that repair is for model output.

The default system prompt now says maths and a small amount of HTML are drawn, and that a diagram belongs in a fence.

Verification

npm run typecheck, npm run lint:code and npm test all pass; 578 tests, 66 of them new across latex.test.ts, html.test.ts, render.test.ts and code.test.ts.

Closes the three rendering gaps in the transcript pane. Every step is there because Vicinae cannot do that part alone; the reasoning is in the new `## Rendering` section of the README. ### LaTeX (`src/lib/latex.ts`) Vicinae maps cmark nodes to a fixed set of block components and there is no maths block among them, so `$x^2$` reached the pane as literal TeX. A paragraph is a `TextEdit` with `textFormat: RichText` (`MdParagraph.qml`) and `markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE` through untouched, so `sup`, `sub`, `b` and `i` are available. The pane is also a launcher-width column, so this is a linearisation rather than a typesetter: `\frac{a}{b}` becomes `a/b`, `\sqrt{x+1}` becomes `√(x+1)`, `\sum_{i=1}^{n}` becomes `∑ᵢ₌₁ⁿ`, and a tag is used only where Unicode has no form (`x^{a/b}` becomes `x<sup>a/b</sup>`). `$...$`, `\(...\)`, `$$...$$` and `\[...\]` on a line of their own, and a fence labelled `math`, are all read. A lone `$` pair counts only when the inside looks like maths, so `$5 and $10` and `cd $A; echo $B` are untouched, and an opening `$$` with no closer is only a formula when what it swallowed reads as one. ### Custom HTML (`src/lib/html.ts`) Raw inline HTML is the only route past GFM, and it is also a hole. The tags and style declarations the renderer acts on are kept; `script`, `style` and the other element types whose body is not content are removed with it; an event handler, a `url()` in a style value and a `javascript:` destination are dropped; and a tag that is not on the list is shown as text rather than swallowed silently. An `<a href>` becomes a markdown link, the only form Vicinae makes clickable, and an `<img>` keeps its tag with its source judged instead, because the block extractor is the one place an image written as HTML is loaded for real. A block of HTML is also moved into a paragraph. `processHtmlNodes` in `markdown-model.cpp` is not a renderer: it pulls out `<img>` and keeps the trimmed text of everything else, so `<table><tr><td>a</td><td>b</td></tr></table>` arrives as `ab`. A well-formed block is joined onto one line and wrapped in a `<span>`, which is not on CommonMark's list of block tags, so it arrives as the paragraph whose raw HTML is passed through. Markup that does not balance and a block holding an `<img>` are left alone. ### Everything else (`src/lib/code.ts`) - A paragraph drawn with box characters is fenced, because a paragraph wraps and a reflowed diagram says nothing. A real GFM table is left to the table renderer, one stray pipe in prose is not art, and an indented block is already code. - A fence label KSyntaxHighlighting would print but never resolve is renamed (`console`, `terminal` and friends become `bash`), and one that carries no information (`text`, `plaintext`) is dropped. ### Wiring `renderModelOutput` runs the whole pass in one place, in `render.ts`: the fence repair first because it decides where the fences are, maths next, HTML last so the tags the maths pass adds go through the same allowlist as the ones the model wrote. A question goes through the maths and markup passes without the fence repair, since that repair is for model output. The default system prompt now says maths and a small amount of HTML are drawn, and that a diagram belongs in a fence. ### Verification `npm run typecheck`, `npm run lint:code` and `npm test` all pass; 578 tests, 66 of them new across `latex.test.ts`, `html.test.ts`, `render.test.ts` and `code.test.ts`.
adrianbonpin added 1 commit 2026-10-07 07:26:36 +00:00
The transcript rendered whatever the model wrote as markdown, and three
things a model writes are not markdown the renderer draws.

LaTeX. Vicinae has no maths block, so `$x^2$` reached the pane as
literal TeX. `latex.ts` converts the subset models actually write into
inline markup the pane can draw: `sup`, `sub`, `b` and `i` are available
because `MdParagraph.qml` is a rich text `TextEdit` and
`markdown-model.cpp` passes `CMARK_NODE_HTML_INLINE` through untouched.
It is a linearisation rather than a typesetter, because the pane is a
launcher-width column: `\frac{a}{b}` becomes `a/b` and `\sqrt{x+1}`
becomes `√(x+1)`, with a Unicode script wherever one exists. A lone `$`
pair only counts when the inside looks like maths, so `$5 and $10` and
`cd $A; echo $B` are left as written.

Custom HTML. Raw HTML is the only route past GFM, and it is also a hole,
so `html.ts` filters it: the tags and style declarations the renderer
acts on are kept, `script` and `style` go with their content, an event
handler, a `url()` in a style value and a `javascript:` destination are
dropped, and a tag that is not on the list is shown as text rather than
swallowed. An `<a href>` becomes a markdown link, the only form Vicinae
makes clickable. A well-formed HTML block is moved into a paragraph,
because the block path keeps only the words of one.

Everything else. `code.ts` now fences a paragraph drawn with box
characters, since a paragraph wraps and a diagram that reflows says
nothing, and renames a fence label KSyntaxHighlighting would print but
never resolve.

The pass lives in `render.ts`, is plain TypeScript with no Vicinae
imports, and the default system prompt now says what the window draws.
adrianbonpin merged commit a804afd56a into prod 2026-10-07 07:26:41 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: adrianbonpin/lam-agent-vicinae#19