Files
deepseek-harness/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
Chinesezjc 5081697aaf feat(web): render bash tool output as a terminal card
The bash tool already declares the `card: 'terminal'` render intent for
both its call and its result, and host/connection/runtime already deliver
it to the browser as callView/resultView. The Web client ignored it:
rows derived from raw args, and the details panel flattened every tool's
content into one soft-wrapping `<pre>`. Column-aligned output folded into
a paragraph and a long listing stretched the panel without bound.

`TerminalBlock` (ui-primitives) renders a command as a terminal surface:
a shortened-cwd prompt line, output at `white-space: pre` in a
horizontally scrolling box, a head/tail height cap with an expand
control, an exit-code/signal status pill, and a copy control for the raw
output. ANSI SGR runs are parsed with `anser` and resolved onto `--dsw-*`
theme tokens, with literal rgb kept for values the design system has no
token for. Geometry and fonts mirror CodeBlock; the clipboard write both
need moved into a package-internal `clipboard.ts`.

Both Web render sites for a bash call consume the intent through one
derivation (`terminal-card-model.ts`), so they cannot disagree about a
command, its cwd, or its exit status: the keyed BashRow carries the card
resident below its summary row, and the render-site fallback row keeps it
behind its existing expand control. Rows cap at 8 lines against the
panel's 16.

Inline output in the chat row reverses this package's stated
no-inline-output convention, on the owner's explicit decision; the Agent
Note records the reversal and its bound.

Tests: TerminalBlock/ansi/clipboard unit specs, ui-conversation wiring
specs at every render site, a built-client-graph snapshot covering both
chat-row shapes, and a real-browser e2e asserting the no-wrap layout and
the page's own Clipboard API.
2026-07-28 14:58:06 +08:00

4.9 KiB

Agent Note: Web client syntax highlighting — synchronous fine-grained shiki

Status: implemented

English | 中文

Scope: the web client's one syntax-highlighting system — the dependency ruling, the singleton shape, the token-sheet contract, and the consuming surfaces. Fifth PR of the Code Mode UI stack; the chat sub-call rows note shipped the run_code program body this exists to make readable. Styling ground rules are owned by the web styling ruling.

Problem

The client rendered every code surface — markdown fences in assistant prose, the run_code program body, the details panel's args — as flat monospace text. The stack's primary payload is model-written TypeScript; unhighlighted programs are measurably harder to scan, and the repo already ships shiki-highlighted code on its VitePress site, so the web app was the one code-rendering surface without it.

Decision

Shiki in its synchronous fine-grained form, as one ui-primitives singleton, themed exclusively through CSS custom properties.

  • Dependency: shiki/core + @shikijs/langs, composed via createHighlighterCoreSync with createJavaScriptRegexEngine({ forgiving: true }) — no oniguruma WASM, no async init, bundle-friendly. Grammar allowlist: typescript (embeds JS), shellscript, json — the languages the harness actually renders; everything else falls back to a geometry-identical plain block, never an error. Prior art: the VitePress site already renders all documentation code through shiki, and TextMate grammars materially beat regex highlighters on TypeScript — the payload that matters here.
  • Singleton: ui-primitives/src/markdown/highlight.ts creates one HighlighterCore per document and exposes highlightToHtml(code, lang) (undefined = render plain). Engine + grammar construction is a ~120-175ms long task, so the module pre-warms the singleton in a deferred task at plugin boot (the lazy path stays as the correctness fallback), keeping the cost off the render path where a stream's finalize swap would jank. The alias table is a Map, not an object: fence info strings are assistant-authored, so a label like constructor must miss instead of resolving an inherited property and crashing shiki. The shared CodeBlock component owns both arms; its shiki arm injects the generated span tree via dangerouslySetInnerHTML — sanctioned because shiki emits a static span tree computed from the code text (no user HTML passes through, no scripts/handlers), shiki's own documented consumption path.
  • Theming: shiki's createCssVariablesTheme routes every token color through --shiki-* custom properties; the VALUES live in a new ui-theme/styles/shiki.css token sheet (light on :root, dark on body[data-ds-dark-theme] — the same cascade as every other sheet), imported by the shell's base.css chain. Component CSS stays tokens-only; no literal color ever enters JS or component sheets. Background/foreground alias the existing markdown code-block tokens so highlighted and plain blocks agree.
  • Surfaces: markdown fences (MarkdownText's pre component routes single-string fences through CodeBlock), the run_code expanded program body (ToolRow's code variant, lang="typescript"), and the details panel's Input args (lang="json"). Tool output is never syntax-highlighted — it is arbitrary text, and guessing a grammar would mis-highlight more than it helps; a bash card's output carries only the color its own ANSI sequences declare, through the terminal card.

Alternatives considered

rehype-highlight/lowlight. Runner-up: naturally sync and ~⅓ the bundle, but regex-grammar fidelity on TypeScript is visibly worse, and the repo would then run two highlighter systems (site: shiki, app: highlight.js) with two theming vocabularies.

Full shiki bundle or the oniguruma WASM engine. Rejected: the full bundle ships every grammar/theme; WASM needs async loading the sync client boot deliberately avoids. The fine-grained core with three grammars keeps the cost proportional to actual use.

Highlight in a worker / async. Rejected: the payloads are small (programs, fences, args); the synchronous JS engine tokenizes them in microseconds, and async introduces a flash-of-unhighlighted-code plus render-machinery churn for no measured need.

Consequences

One code surface for every consumer — a future surface imports CodeBlock and inherits highlighting, theming, and the plain fallback. The bundle grows by the shiki core + three grammars (paid once in ui-primitives). Token colors are the first --shiki-* sheet; a theme package registering alias overrides extends them like any other token. jsdom specs pin the token-span structure, alias resolution, both fallback arms, and the fence route; the existing built-bundle snapshot and browser e2e cover the assembled path.