Markdown: math-ish content + collapsible tool-call blocks #14

Open
opened 2026-08-03 00:03:48 -04:00 by lyssieth · 0 comments
Owner

Follow-up to #6. Two checklist items on the chat-view issue were deferred to keep that shippable scope tight. Both are still open; the notes below are re-grounded against the tree as of dd1a00f.

  • Math-ish content — the markdown renderer (crates/sermones-core/src/markdown/) covers prose, tables, footnotes, strikethrough, task lists, definition lists, and code blocks with arborium highlight classes. No math, on two counts: Options::ENABLE_MATH is not in the option set (renderer.rs:167-171), so pulldown-cmark never emits math events; and the two events are explicitly discarded where they would land (Event::InlineMath(_) | Event::DisplayMath(_) => { /* skip */ }, renderer.rs:327-333). The renderer is sans-IO and outputs a sanitized HTML fragment, so any math pass must play in that pipeline and survive sanitize_html_fragment.
  • Collapsible tool-call blocks, collapsed by default — reasoning renders as <details class="reasoning"> (crates/sermones/src/streaming.rs:188-196, compose_fragment). Tool calls land in Message::blocks as Block::ToolCall(...) (crates/sermones-core/src/llm/message.rs:79, assembled by merge_tool_call at :308) and are then silently dropped from the render: the fragment is built from Block::Output text plus Block::Reasoning text and nothing else (streaming.rs:211-220, output_text_of / reasoning_text_of at :252-272). So this is not a restyle — nothing reaches the DOM today.

The sanitizer is the hard constraint on math

Worth settling before picking a library, because it rules two obvious options out (crates/sermones-core/src/html_sanitizer.rs, policy in docs/invariants/html-sanitization.md):

  • style is rejected outright — as an attribute (:918, alongside on* and xmlns) and as a tag (:896). KaTeX's HTML output is spans carrying inline style for widths, heights, and vertical-align; sanitized, it renders as scrambled glyphs rather than an equation.
  • MathML does not pass either — no math / mrow / semantics / annotation in ALLOWED_TAGS (:807-875), and the xmlns attribute is rejected.
  • What does pass: <details>, <summary>, the open attribute on details, and class on anything (:823, :859, :951-955). So marker-element approaches survive untouched.

Three viable shapes, cheapest first:

  1. Emit markers, render client-side. The Rust renderer emits e.g. <span class="math-inline">…</span> / <div class="math-display">…</div> with the LaTeX source as text; MessageView.svelte runs KaTeX over those nodes after the {@html} injection (:341, :265). Nothing in the sanitizer changes — the marker is class-only, and the untrusted payload stays text until KaTeX itself renders it. Costs a client-side pass on every re-render, and chunks replace the fragment wholesale (see below).
  2. Widen the policy for MathML and emit MathML from Rust. Honest server-side rendering, no JS at display time, but it is a real expansion of the sanitizer's surface and needs its own entry in docs/invariants/html-sanitization.md plus tests.
  3. KaTeX server-side + a style carve-out. Rejected unless someone has a strong argument: a general style allowance on model-authored HTML is exactly what the policy exists to prevent.

Note KaTeX would be bundled through npm, not the CDN — the app is a local QtWebEngine surface. (cdn.jsdelivr.net on the img origin allowlist at :985 is unrelated.)

Checklist

  • Math syntax chosen ($...$ and $$...$$, plus / minus \(...\) / \[...\]) — pulldown-cmark's ENABLE_MATH gives $/$$ only; the \(...\) forms need a pre-pass
  • Options::ENABLE_MATH added and the two skip arms at renderer.rs:327-333 given real bodies (plus the inline-level equivalent)
  • Math render approach decided — see the three shapes above; the sanitizer decides more than the library does
  • Sanitizer: math output survives sanitize_html_fragment, with docs/invariants/html-sanitization.md updated if the policy moves
  • Renderer emits math in the prose flow
  • Tool-call blocks reach the renderer at all — extend the fragment assembly in streaming.rs:188-220 past output + reasoning
  • Tool-call blocks render as <details class="tool-call"><summary>{fn}</summary>…</details>, collapsed by default (passes the sanitizer as-is)
  • Tool-call arguments JSON: pretty-printed vs raw; round-trip from copy. Note AssistantMessageView.text — what Copy puts on the clipboard — is Block::Output runs only (crates/sermones-core/src/ipc.rs:249-253), so a copyable tool call is a decision about that field, not just about the markup.
  • Reasoning and tool-call share the same collapsed-by-default affordance (mirror <details class="reasoning">, streaming.rs:194)
  • MessageView.svelte styles for both new blocks — next to the existing :global(details.reasoning) rules (MessageView.svelte:517)

Streaming interaction (both items)

stream_chunk carries the whole re-rendered fragment and the frontend replaces it wholesale (frontend/src/lib/conversations.svelte.ts:365-371; {@html assistant.html} at MessageView.svelte:341). Consequences:

  • A client-side math pass (shape 1) re-runs on every chunk unless it is keyed/memoised — cheap per element, not free at streaming rates.
  • A <details> a user opened mid-stream loses its open state on the next chunk. The reasoning block already lives with this because the engine sets open while streaming and collapses on the terminal (compose_fragment's open argument); a tool-call block needs the equivalent decision.

Out of scope

  • Tool-call execution — no tool runtime yet. RequestMessage cannot even carry a tool_call_id (crates/sermones-runtime/src/providers/llama.rs:199-205), and context_messages drops tool blocks on purpose (crates/sermones-runtime/src/session/mod.rs:541-544).
  • Streaming math: math split across chunk boundaries renders as garbage until the closing delimiter arrives; spec that separately.

Refs

  • crates/sermones-core/src/markdown/renderer.rs:167-171,327-333 — enabled options; the skipped math events
  • crates/sermones-core/src/markdown/html.rs — sans-IO renderer (render_to_html, render_inline_html)
  • crates/sermones-core/src/html_sanitizer.rs:807-875,894-955 — tag / attribute policy (docs/invariants/html-sanitization.md)
  • crates/sermones/src/streaming.rs:188-220compose_fragment, the block → HTML path
  • crates/sermones-core/src/llm/message.rs:79,308Block::ToolCall, merge_tool_call
  • crates/sermones-core/src/llm/stream_parser/tool_call.rs — tool-call model
  • #6 — the chat-view issue, closed without these items
Follow-up to #6. Two checklist items on the chat-view issue were deferred to keep that shippable scope tight. Both are still open; the notes below are re-grounded against the tree as of `dd1a00f`. - **Math-ish content** — the markdown renderer (`crates/sermones-core/src/markdown/`) covers prose, tables, footnotes, strikethrough, task lists, definition lists, and code blocks with arborium highlight classes. No math, on two counts: `Options::ENABLE_MATH` is **not** in the option set (`renderer.rs:167-171`), so pulldown-cmark never emits math events; and the two events are explicitly discarded where they would land (`Event::InlineMath(_) | Event::DisplayMath(_) => { /* skip */ }`, `renderer.rs:327-333`). The renderer is sans-IO and outputs a sanitized HTML fragment, so any math pass must play in that pipeline and survive `sanitize_html_fragment`. - **Collapsible tool-call blocks, collapsed by default** — reasoning renders as `<details class="reasoning">` (`crates/sermones/src/streaming.rs:188-196`, `compose_fragment`). Tool calls land in `Message::blocks` as `Block::ToolCall(...)` (`crates/sermones-core/src/llm/message.rs:79`, assembled by `merge_tool_call` at `:308`) and are then **silently dropped from the render**: the fragment is built from `Block::Output` text plus `Block::Reasoning` text and nothing else (`streaming.rs:211-220`, `output_text_of` / `reasoning_text_of` at `:252-272`). So this is not a restyle — nothing reaches the DOM today. ## The sanitizer is the hard constraint on math Worth settling before picking a library, because it rules two obvious options out (`crates/sermones-core/src/html_sanitizer.rs`, policy in `docs/invariants/html-sanitization.md`): - **`style` is rejected outright** — as an attribute (`:918`, alongside `on*` and `xmlns`) and as a tag (`:896`). KaTeX's HTML output is spans carrying inline `style` for widths, heights, and vertical-align; sanitized, it renders as scrambled glyphs rather than an equation. - **MathML does not pass either** — no `math` / `mrow` / `semantics` / `annotation` in `ALLOWED_TAGS` (`:807-875`), and the `xmlns` attribute is rejected. - **What *does* pass:** `<details>`, `<summary>`, the `open` attribute on `details`, and `class` on anything (`:823`, `:859`, `:951-955`). So marker-element approaches survive untouched. Three viable shapes, cheapest first: 1. **Emit markers, render client-side.** The Rust renderer emits e.g. `<span class="math-inline">…</span>` / `<div class="math-display">…</div>` with the LaTeX source as text; `MessageView.svelte` runs KaTeX over those nodes after the `{@html}` injection (`:341`, `:265`). Nothing in the sanitizer changes — the marker is class-only, and the untrusted payload stays *text* until KaTeX itself renders it. Costs a client-side pass on every re-render, and chunks replace the fragment wholesale (see below). 2. **Widen the policy for MathML** and emit MathML from Rust. Honest server-side rendering, no JS at display time, but it is a real expansion of the sanitizer's surface and needs its own entry in `docs/invariants/html-sanitization.md` plus tests. 3. **KaTeX server-side + a `style` carve-out.** Rejected unless someone has a strong argument: a general `style` allowance on model-authored HTML is exactly what the policy exists to prevent. Note KaTeX would be bundled through npm, not the CDN — the app is a local QtWebEngine surface. (`cdn.jsdelivr.net` on the `img` origin allowlist at `:985` is unrelated.) ## Checklist - [ ] Math syntax chosen (`$...$` and `$$...$$`, plus / minus `\(...\)` / `\[...\]`) — pulldown-cmark's `ENABLE_MATH` gives `$`/`$$` only; the `\(...\)` forms need a pre-pass - [ ] `Options::ENABLE_MATH` added and the two skip arms at `renderer.rs:327-333` given real bodies (plus the inline-level equivalent) - [ ] Math render approach decided — see the three shapes above; the sanitizer decides more than the library does - [ ] Sanitizer: math output survives `sanitize_html_fragment`, with `docs/invariants/html-sanitization.md` updated if the policy moves - [ ] Renderer emits math in the prose flow - [ ] Tool-call blocks reach the renderer at all — extend the fragment assembly in `streaming.rs:188-220` past output + reasoning - [ ] Tool-call blocks render as `<details class="tool-call"><summary>{fn}</summary>…</details>`, collapsed by default (passes the sanitizer as-is) - [ ] Tool-call arguments JSON: pretty-printed vs raw; round-trip from copy. Note `AssistantMessageView.text` — what Copy puts on the clipboard — is `Block::Output` runs only (`crates/sermones-core/src/ipc.rs:249-253`), so a copyable tool call is a decision about that field, not just about the markup. - [ ] Reasoning and tool-call share the same collapsed-by-default affordance (mirror `<details class="reasoning">`, `streaming.rs:194`) - [ ] `MessageView.svelte` styles for both new blocks — next to the existing `:global(details.reasoning)` rules (`MessageView.svelte:517`) ## Streaming interaction (both items) `stream_chunk` carries the **whole re-rendered fragment** and the frontend replaces it wholesale (`frontend/src/lib/conversations.svelte.ts:365-371`; `{@html assistant.html}` at `MessageView.svelte:341`). Consequences: - A client-side math pass (shape 1) re-runs on every chunk unless it is keyed/memoised — cheap per element, not free at streaming rates. - A `<details>` a user opened mid-stream loses its `open` state on the next chunk. The reasoning block already lives with this because the engine sets `open` while streaming and collapses on the terminal (`compose_fragment`'s `open` argument); a tool-call block needs the equivalent decision. ## Out of scope - Tool-call *execution* — no tool runtime yet. `RequestMessage` cannot even carry a `tool_call_id` (`crates/sermones-runtime/src/providers/llama.rs:199-205`), and `context_messages` drops tool blocks on purpose (`crates/sermones-runtime/src/session/mod.rs:541-544`). - Streaming math: math split across chunk boundaries renders as garbage until the closing delimiter arrives; spec that separately. ## Refs - `crates/sermones-core/src/markdown/renderer.rs:167-171,327-333` — enabled options; the skipped math events - `crates/sermones-core/src/markdown/html.rs` — sans-IO renderer (`render_to_html`, `render_inline_html`) - `crates/sermones-core/src/html_sanitizer.rs:807-875,894-955` — tag / attribute policy (`docs/invariants/html-sanitization.md`) - `crates/sermones/src/streaming.rs:188-220` — `compose_fragment`, the block → HTML path - `crates/sermones-core/src/llm/message.rs:79,308` — `Block::ToolCall`, `merge_tool_call` - `crates/sermones-core/src/llm/stream_parser/tool_call.rs` — tool-call model - ~~#6~~ — the chat-view issue, closed without these items
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lyssieth/sermones#14
No description provided.