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

This commit was merged in pull request #19.
This commit is contained in:
2026-10-07 07:26:40 +00:00
parent 550a2bbefe
commit a804afd56a
12 changed files with 2543 additions and 30 deletions
+36 -2
View File
@@ -16,6 +16,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **Persistent conversations.** Every chat is saved locally. The sidebar lists them by recency, and reopening the command picks up where you left off, unless it has been idle longer than you allow.
- **Readable code.** Fenced blocks render in a dark panel with the language named in a header, syntax colouring, and a Copy button.
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
- **Maths, real HTML, and diagrams.** LaTeX renders, so an equation arrives as an equation instead of a wall of backslashes. A small amount of HTML draws as well, for the status chip or tiny grid a sentence is worse at, and a diagram drawn with box characters keeps its alignment instead of being reflowed. See [Rendering](#rendering).
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
- **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
- **Models, from a list.** Browse every model your host can reach, pick one and start using it immediately, and on a server you run, pull new ones or delete old ones. Tag names are picked, never typed.
@@ -262,6 +263,36 @@ The photo clean-up applies exactly the same rule as the sweep, including the one
When a conversation cannot be read there is no way to know which captures are still in use, so that action says it could not check and deletes nothing, rather than risking a screenshot a surviving chat still shows.
## Rendering
The answer is model output, and Vicinae draws a fixed set of things. Everything between the model and the pane happens in one pass, in `render.ts`, and every step of it is there because the renderer cannot do that part alone.
### LaTeX
Vicinae has no maths renderer. `MarkdownModel` maps cmark nodes to a fixed set of block components (`MdHeading`, `MdCodeBlock`, `MdTable`, `MdImage` and friends) and there is no maths block among them, so `$x^2$` used to reach the pane as literal TeX. `latex.ts` converts it instead.
Two facts about the renderer decided the design. 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. And the pane is a launcher-width column, so a formula has to read linearly: a stacked fraction could not survive a narrow column anyway.
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 only what Unicode has no form for falls back to a tag, so `x^{a/b}` becomes `x<sup>a/b</sup>`. `$...$`, `\(...\)`, `$$...$$` or `\[...\]` on a line of its own, and a fence labelled `math`, are all read.
The one guard is worth knowing about. A lone `$` pair counts as maths only when the inside looks like maths, because `x=$a$b` and `cd $A; echo $B` are not formulas, and turning them into one is the single way this feature can make an answer worse than the dollar signs it replaced. `$5 and $10` is left exactly as written.
### Custom HTML
Raw HTML is not merely tolerated by the renderer, it is the only route to anything past what GFM offers: a `<span>` with a colour or a size draws, and so does a table. That is the extension point for a model that would rather show a status chip than describe one, and the default prompt says the capability exists.
**A block of HTML has to be moved before it can draw anything.** `markdown-model.cpp` has no HTML renderer. An HTML *block* goes through `processHtmlNodes`, which pulls out every `<img>` as a block image and keeps the trimmed text of everything else with the markup thrown away, so `<table><tr><td>a</td><td>b</td></tr></table>` arrives as the word `ab`. A *paragraph*, by contrast, keeps its raw HTML, because `CMARK_NODE_HTML_INLINE` is passed straight through to the `TextEdit`. So a well-formed block is joined onto one line and wrapped in a `<span>`, which is not on CommonMark's list of block tags, and it arrives as the paragraph the rich text engine can read. Two rules keep that rewrite from doing damage: markup that does not balance is left to the block path, which at least keeps the words, and a block holding an `<img>` is left alone entirely, because the extractor is the one place an image written as HTML is loaded for real.
It is also a hole, because the same route carries whatever else the model writes, so `html.ts` filters it. 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, and an event handler, a `url()` in a style value and a `javascript:` destination are dropped. An `<a href>` is rewritten as a markdown link, because that is the only form Vicinae makes clickable. An `<img>` keeps its tag and has its source judged instead, since the block extractor reads the attribute rather than any markdown this pass could write, and `data:` counts only when it carries an image.
What the renderer cannot draw is kept as text rather than dropped, which is the deliberate choice in there. A tag that is not on the list, `<foo>` say, is shown literally: ugly, but honest, and better than a mystery about where the markup went.
### Everything else
The rest of the pass is defensive, and stays away from guessing what the model meant. A diagram drawn with box characters is fenced so it keeps its alignment, because `MdParagraph.qml` wraps and a directory tree reflowed is a directory tree scrambled. A fence label KSyntaxHighlighting would print but never resolve is renamed (`console` and `terminal` become `bash`) and one that carries no information at all (`text`, `plaintext`) is dropped, which is what stops a header naming a language nobody highlighted. A fence whose body is all URLs still becomes links, a real GFM table is left to the table renderer, and markup inside a code span is shown rather than applied.
The whole pass is plain TypeScript with no Vicinae imports, so it unit tests without a runtime, and copying a conversation to the clipboard uses the source text rather than the rendering.
## Architecture
```
@@ -285,7 +316,10 @@ src/
location.ts # time/place, derived locally
thread.ts # turns -> flat message list
markdown.ts # messages -> detail-panel markdown
code.ts # code fences and links, repaired
render.ts # the rendering pass: fences, maths, markup
latex.ts # TeX -> readable inline markup
html.ts # raw HTML -> the subset the renderer draws
code.ts # code fences, diagrams and links, repaired
preferences.ts # preferences, normalised
hooks/useChat.ts # the chat state machine
hooks/usePreferences.ts # preferences + the model pick, loaded once
@@ -320,7 +354,7 @@ A few decisions worth knowing about before you change things:
The rendered height stays capped at 200px whatever is asked for (`Math.min(_rawH, 200, maxImageHeight)`), so this is a thumbnail rather than something readable, which is why the action panel still offers Open Screenshot for the full image. The alt text is short for the same reason it is not empty: whatever is there is drawn centred under the image, so a sentence reads as a stray line of prose, and it is the only thing left if the capture file has since been deleted. A preview of an unsent attachment is shown the same way, so a snip is visible before the question about it exists.
**Code fences are labelled so Vicinae can colour them.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, and the default prompt asks for a language on every fence.
**Fences are labelled so Vicinae can colour them, and a diagram is fenced so it stays readable.** `MdCodeBlock.qml` already provides the dark panel, a header naming the language, and a Copy button, so none of that is built here. The language string is what turns on highlighting: `syntax::highlight` resolves it with `definitionForName(language)` and then `definitionForFileName("file." + language)`. So `bash` and `ts` colour correctly, while an unrecognised string such as `terminal` still renders a label but produces no highlighting at all, so a wrong label is worse than none. `code.ts` therefore labels bare fences conservatively, only when a signal is clear, the default prompt asks for a language on every fence, and an alias that resolves to nothing (`console`, `terminal`) is renamed to one that does. The same module fences a paragraph drawn with box characters, because a paragraph wraps and a boxed diagram that reflows is a boxed diagram that no longer says anything.
**A URL is only clickable as a markdown link, so `code.ts` takes them back out of code.** Vicinae's parser attaches the `table` and `strikethrough` GFM extensions and nothing else, so the `autolink` extension is off: a bare URL is plain dead text. Clickability comes from `MarkdownModel` turning `CMARK_NODE_LINK` into an `<a href>`, which the selection controller opens on release. A URL the model fenced is therefore the worst possible presentation, a dark panel with a Copy button and no way to open the address. `restoreUrlLinks` recovers three narrow shapes (a fence whose entire body is URLs, a lone `` `https://...` `` span, and a line that is only a URL) into `[url](url)` links, and the default prompt now says never to fence a URL in the first place. Anything that is not one of those three shapes is copied through, so a `curl` invocation containing an address is never rewritten.