diff --git a/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md new file mode 100644 index 0000000000..79ad2153b8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md @@ -0,0 +1,32 @@ +# Agent Note: Web client syntax highlighting — synchronous fine-grained shiki + +Status: implemented + +English | [中文](2026-07-26-web-syntax-highlighting-shiki.zh.md) + +> 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](../feature/2026-07-26-code-mode-chat-subcall-rows.md) shipped the `run_code` program body this exists to make readable. Styling ground rules are owned by [the web styling ruling](2026-07-19-web-styling-system.md). + +## 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` lazily creates one `HighlighterCore` per document and exposes `highlightToHtml(code, lang)` (undefined = render plain). 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"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps. + +## 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. diff --git a/apps/web/tests/code-mode-fixture.snapshot.ts b/apps/web/tests/code-mode-fixture.snapshot.ts index e862474348..5abf8bc6c0 100644 --- a/apps/web/tests/code-mode-fixture.snapshot.ts +++ b/apps/web/tests/code-mode-fixture.snapshot.ts @@ -139,13 +139,20 @@ it('expands the code row into the program body and resolves a sub-row through th boot() await openFixtureSession() - // Expand: the leading control reveals the program verbatim. + // Expand: the leading control reveals the program (shiki-tokenized: the + // text splits into styled spans inside one
 tree).
   const codeRoot = document.querySelector('[data-variant="code"]')
   if (codeRoot === null) throw new Error('code-variant row missing')
   const toggle = codeRoot.querySelector('button[aria-expanded]')
   if (toggle === null) throw new Error('code row expand control missing')
   fireEvent.click(toggle)
-  await screen.findByText(/const listing = await tools\.bash/)
+  await waitFor(() => {
+    // Scope to THIS row: the markdown fixture turn also renders shiki pres.
+    const pre = codeRoot.querySelector('pre.shiki')
+    if (pre === null || !(pre.textContent ?? '').includes('const listing = await tools.bash')) {
+      throw new Error('highlighted program body missing under the code row')
+    }
+  })
 
   // Sub-row click → details panel resolves the sub-callId with FULL output.
   const nest = document.querySelector('[data-subcalls]')
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
index 204af4573d..16878ae91e 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css
@@ -87,14 +87,9 @@ button.leading {
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* The code variant's expanded body is the run_code program: monospace on the
-   markdown code-block fill so the program reads as code, not prose. */
-.root[data-variant='code'] .body {
-  font-family: var(--ds-font-family-code);
-  font-size: 13px;
-  line-height: 20px;
-  padding: 6px 8px;
-  margin-left: 22px;
-  border-radius: 6px;
-  background: var(--dsw-alias-markdown-code-block);
+/* The code variant's expanded body is the run_code program, rendered through
+   the shared CodeBlock (shiki-highlighted TypeScript); only indentation is
+   this row's concern. */
+.codeBody {
+  margin: 4px 0 4px 22px;
 }
diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
index f1a5ce7440..113241eb5d 100644
--- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
+++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx
@@ -6,7 +6,7 @@
 
 import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
 import css from './ToolRow.module.css'
@@ -96,7 +96,9 @@ export function ToolRow({
           
         )}
       
-      {open && 
{body}
} + {open && (variant === 'code' + ? + :
{body}
)} ) } diff --git a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx index 6d998aeff9..3d3c84a646 100644 --- a/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx @@ -5,6 +5,7 @@ // share the store seat exists for) and derives the call material from the // session snapshot — no data of its own. +import { CodeBlock } from '@deepseek-ai/dsh-client-ui-primitives' import { shallowEqual } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' import type { DetailsSlotProps } from '../contract/slots.ts' @@ -89,7 +90,7 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane {material.argsRaw !== null && (
Input
-
{pretty(material.argsRaw)}
+
)}
diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 4751ea815e..2d61edae1c 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -152,7 +152,7 @@ describe('run_code sub-calls through the real chat machinery', () => { expect(view.getByText('Tool call')).toBeTruthy() }) - it('expanding the code row reveals the program body verbatim', async () => { + it('expanding the code row reveals the program body verbatim (shiki-tokenized)', async () => { const parent = 'call-64' const b = await bench(snapshotWith([codeResult(10, parent)], new Map())) const view = mountApp(b.slots) @@ -160,7 +160,12 @@ describe('run_code sub-calls through the real chat machinery', () => { const toggle = view.container.querySelector('[data-variant="code"] button[aria-expanded]') expect(toggle).not.toBeNull() fireEvent.click(toggle!) - expect(view.getByText(/const listing = await tools\.bash/)).toBeTruthy() + // Shiki splits the program into token spans inside one
:
+    // assert the whole text and the highlighted tree rather than one node.
+    const pre = view.container.querySelector('pre.shiki')
+    expect(pre).not.toBeNull()
+    expect(pre!.textContent).toContain('const listing = await tools.bash')
+    expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(3)
   })
 
   it('an isError sub-call renders the error state dot exactly like a failed native row', async () => {
diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json
index 7f5c3555bd..9ce2bc8676 100644
--- a/packages/client/ui-primitives/package.json
+++ b/packages/client/ui-primitives/package.json
@@ -20,11 +20,13 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
+    "@shikijs/langs": "^4.3.1",
     "clsx": "^2.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",
     "react-markdown": "^10.1.0",
-    "remark-gfm": "^4.0.1"
+    "remark-gfm": "^4.0.1",
+    "shiki": "^4.3.1"
   },
   "devDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",
diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts
index 9fd3d149fc..11460779a5 100644
--- a/packages/client/ui-primitives/src/index.ts
+++ b/packages/client/ui-primitives/src/index.ts
@@ -16,6 +16,7 @@ export { FishLogo } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
+export { CodeBlock } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'
 export { MessageText } from './markdown/MessageText.tsx'
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.module.css b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css
new file mode 100644
index 0000000000..f9b5f67136
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.module.css
@@ -0,0 +1,27 @@
+/* One code-block geometry for highlighted and plain arms: the shiki 
+   and the fallback 
 draw identically except for token colors. */
+
+.block :where(pre) {
+  margin: 0;
+  padding: 8px 10px;
+  border-radius: 8px;
+  overflow-x: auto;
+  background: var(--dsw-alias-markdown-code-block);
+  font: var(--dsw-font-markdown-code-block);
+}
+
+/* Shiki inlines its theme background var; route it to the repo token. */
+.block :where(pre.shiki) {
+  background: var(--dsw-alias-markdown-code-block) !important;
+}
+
+.block :where(pre) code {
+  font: inherit;
+  background: none;
+  padding: 0;
+}
+
+.plain {
+  color: var(--dsw-alias-label-primary);
+  white-space: pre;
+}
diff --git a/packages/client/ui-primitives/src/markdown/CodeBlock.tsx b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
new file mode 100644
index 0000000000..1a6349f1e8
--- /dev/null
+++ b/packages/client/ui-primitives/src/markdown/CodeBlock.tsx
@@ -0,0 +1,37 @@
+// CodeBlock: one code surface for every consumer — markdown fences, the
+// run_code program body, and the details panel's raw args/output — with
+// shiki highlighting for the registered grammars and an identical-geometry
+// plain fallback for everything else. Shiki emits a single 
+// tree of nested spans whose colors are --shiki-* custom properties
+// (token sheets own the values); it produces no scripts or event handlers,
+// so injecting its output is safe by construction.
+
+import { useMemo } from 'react'
+import clsx from 'clsx'
+import { highlightToHtml } from './highlight.ts'
+import css from './CodeBlock.module.css'
+
+export interface CodeBlockProps {
+  /** The source text, rendered verbatim (trailing newline trimmed for display). */
+  code: string
+  /** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
+  lang?: string | undefined
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+export function CodeBlock({ code, lang, className }: CodeBlockProps) {
+  const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
+  const html = useMemo(() => highlightToHtml(trimmed, lang), [trimmed, lang])
+  if (html === undefined) {
+    return (
+      
+
{trimmed}
+
+ ) + } + // eslint-disable-next-line react/no-danger -- shiki's output is a static + // span tree it generated from `code` (no user HTML passes through), the + // sanctioned innerHTML consumption path per shiki's own docs. + return
+} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index 425e3969ab..f74e939246 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -1,6 +1,8 @@ +import { isValidElement } from 'react' import ReactMarkdown from 'react-markdown' import type { Components, UrlTransform } from 'react-markdown' import remarkGfm from 'remark-gfm' +import { CodeBlock } from './CodeBlock.tsx' import css from './MarkdownText.module.css' const remarkPlugins = [remarkGfm] @@ -42,6 +44,19 @@ const components: Components = { {children}
), + // Fenced blocks route through the shared CodeBlock (shiki for registered + // grammars, identical-geometry plain fallback for unknown/absent languages); + // inline code keeps the default path (the :not(pre) rule styles it). + pre: ({ children }) => { + const child = isValidElement<{ className?: string; children?: unknown }>(children) ? children : undefined + const raw = child?.props.children + const text = typeof raw === 'string' ? raw : Array.isArray(raw) && typeof raw[0] === 'string' ? raw[0] : undefined + // A fence whose content isn't one plain string (never produced by the + // markdown pipeline) keeps the stock
 rather than guessing.
+    if (text === undefined) return 
{children}
+ const lang = /language-([\w-]+)/.exec(child?.props.className ?? '')?.[1] + return + }, } /** diff --git a/packages/client/ui-primitives/src/markdown/highlight.ts b/packages/client/ui-primitives/src/markdown/highlight.ts new file mode 100644 index 0000000000..34e0359f60 --- /dev/null +++ b/packages/client/ui-primitives/src/markdown/highlight.ts @@ -0,0 +1,68 @@ +/** + * The client's ONE syntax highlighter: a synchronous fine-grained shiki core + * (JavaScript regex engine — no oniguruma WASM, bundle-friendly) with an + * explicit grammar allowlist and a CSS-variables theme. Colors live in the + * theme package's token sheets as `--shiki-*` custom properties (light and + * dark blocks), never here — the repo's tokens-only styling rule. + * + * Grammars are the set the harness actually renders: TypeScript programs + * (`run_code` bodies; TS pulls in JS via grammar embedding), shell commands, + * and JSON payloads. An unknown or absent language falls back to plain text + * (no highlighting, still monospace) — never an error. + */ + +import { createHighlighterCoreSync, createCssVariablesTheme } from 'shiki/core' +import { createJavaScriptRegexEngine } from 'shiki/engine/javascript' +import langTs from '@shikijs/langs/typescript' +import langBash from '@shikijs/langs/shellscript' +import langJson from '@shikijs/langs/json' +import type { HighlighterCore } from 'shiki/core' + +/** Language ids (and aliases) the singleton registers; everything else renders plain. */ +const LANG_ALIASES: Record = { + typescript: 'typescript', + ts: 'typescript', + tsx: 'typescript', + javascript: 'typescript', + js: 'typescript', + shellscript: 'shellscript', + bash: 'shellscript', + sh: 'shellscript', + shell: 'shellscript', + zsh: 'shellscript', + json: 'json', + jsonc: 'json', +} + +/** All token colors resolve through `--shiki-*` custom properties (theme package sheets). */ +const cssVariablesTheme = createCssVariablesTheme({ + name: 'css-variables', + variablePrefix: '--shiki-', + fontStyle: true, +}) + +let singleton: HighlighterCore | undefined + +/** The lazily-created synchronous highlighter (one instance per document). */ +function highlighter(): HighlighterCore { + singleton ??= createHighlighterCoreSync({ + themes: [cssVariablesTheme], + langs: [langTs, langBash, langJson], + engine: createJavaScriptRegexEngine({ forgiving: true }), + }) + return singleton +} + +/** + * Highlight `code` into shiki's HTML (a single `
` tree)
+ * when `lang` maps to a registered grammar; `undefined` means the caller
+ * renders its plain fallback.
+ * @param code - the source text.
+ * @param lang - the language hint (a markdown fence info string or a fixed caller id).
+ * @returns the highlighted HTML, or `undefined` for unknown languages.
+ */
+export function highlightToHtml(code: string, lang: string | undefined): string | undefined {
+  const resolved = lang === undefined ? undefined : LANG_ALIASES[lang.toLowerCase()]
+  if (resolved === undefined) return undefined
+  return highlighter().codeToHtml(code, { lang: resolved, theme: 'css-variables' })
+}
diff --git a/packages/client/ui-primitives/tests/code-block.spec.tsx b/packages/client/ui-primitives/tests/code-block.spec.tsx
new file mode 100644
index 0000000000..a58248afab
--- /dev/null
+++ b/packages/client/ui-primitives/tests/code-block.spec.tsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+// CodeBlock + the shiki singleton: registered grammars highlight into token
+// spans colored by --shiki-* custom properties; unknown/absent languages take
+// the identical-geometry plain arm; aliases resolve; the trailing newline is
+// display-trimmed. MarkdownText's fence route is pinned in markdown.spec.tsx
+// alongside the rest of the markdown family.
+
+import { describe, expect, it } from 'vitest'
+import { cleanup, render } from '@testing-library/react'
+import { afterEach } from 'vitest'
+import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
+import { highlightToHtml } from '../src/markdown/highlight.ts'
+
+afterEach(cleanup)
+
+describe('highlightToHtml', () => {
+  it('highlights a registered grammar into css-variables token spans', () => {
+    const html = highlightToHtml('const x: number = 1', 'typescript')
+    expect(html).toContain('pre class="shiki css-variables"')
+    expect(html).toContain('var(--shiki-')
+  })
+
+  it.each([['ts'], ['js'], ['bash'], ['sh'], ['jsonc']])('resolves the %s alias', (alias) => {
+    expect(highlightToHtml('x', alias)).toContain('shiki')
+  })
+
+  it('returns undefined for unknown or absent languages', () => {
+    expect(highlightToHtml('x', 'cobol')).toBeUndefined()
+    expect(highlightToHtml('x', undefined)).toBeUndefined()
+  })
+})
+
+describe('CodeBlock', () => {
+  it('renders the highlighted tree for TypeScript', () => {
+    const view = render()
+    const pre = view.container.querySelector('pre.shiki')
+    expect(pre).not.toBeNull()
+    expect(pre!.textContent).toBe('const a = 1')
+    expect(pre!.querySelectorAll('span[style]').length).toBeGreaterThan(1)
+  })
+
+  it('renders the plain arm for an unknown language with the text verbatim', () => {
+    const view = render()
+    expect(view.container.querySelector('pre.shiki')).toBeNull()
+    expect(view.getByText('IDENTIFICATION DIVISION.')).toBeTruthy()
+  })
+
+  it('renders the plain arm when no language is given', () => {
+    const view = render()
+    expect(view.container.querySelector('pre.shiki')).toBeNull()
+    expect(view.getByText('plain text')).toBeTruthy()
+  })
+})
diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx
index 4e1dc292d4..dcbf613005 100644
--- a/packages/client/ui-primitives/tests/markdown.spec.tsx
+++ b/packages/client/ui-primitives/tests/markdown.spec.tsx
@@ -57,6 +57,8 @@ describe('MarkdownText', () => {
     expect(container.querySelector('table')?.textContent).toContain('alphabeta')
     expect(container.querySelector('hr')).not.toBeNull()
     expect(container.querySelector('pre code')?.textContent).toContain('const answer = 42')
+    // The ts fence routed through the shared CodeBlock: shiki token spans present.
+    expect(container.querySelector('pre.shiki')).not.toBeNull()
     expect(container.querySelector('br')).not.toBeNull()
     expect(screen.getByRole('link', { name: 'safe' }).getAttribute('target')).toBe('_blank')
     expect(screen.getByRole('link', { name: 'https://deepseek.com' })).toBeTruthy()
diff --git a/packages/client/ui-theme/src/styles/shiki.css b/packages/client/ui-theme/src/styles/shiki.css
new file mode 100644
index 0000000000..c7a3c5d272
--- /dev/null
+++ b/packages/client/ui-theme/src/styles/shiki.css
@@ -0,0 +1,31 @@
+/* Syntax-highlight token palette: the values behind shiki's css-variables
+   theme (--shiki-* custom properties emitted by the ui-primitives CodeBlock).
+   Light values on :root, dark overrides on the body attribute — the same
+   cascade as every other token sheet. Background/foreground deliberately
+   alias the markdown code-block tokens so highlighted and plain blocks agree. */
+
+:root {
+  --shiki-foreground: var(--dsw-alias-label-primary);
+  --shiki-background: var(--dsw-alias-markdown-code-block);
+  --shiki-token-constant: #1c7ed6;
+  --shiki-token-string: #2f9e44;
+  --shiki-token-comment: #868e96;
+  --shiki-token-keyword: #d6336c;
+  --shiki-token-parameter: #e8590c;
+  --shiki-token-function: #6741d9;
+  --shiki-token-string-expression: #2b8a3e;
+  --shiki-token-punctuation: #495057;
+  --shiki-token-link: #1971c2;
+}
+
+body[data-ds-dark-theme] {
+  --shiki-token-constant: #4dabf7;
+  --shiki-token-string: #69db7c;
+  --shiki-token-comment: #adb5bd;
+  --shiki-token-keyword: #faa2c1;
+  --shiki-token-parameter: #ffa94d;
+  --shiki-token-function: #b197fc;
+  --shiki-token-string-expression: #8ce99a;
+  --shiki-token-punctuation: #ced4da;
+  --shiki-token-link: #74c0fc;
+}
diff --git a/packages/client/web/src/base.css b/packages/client/web/src/base.css
index 991a03bbca..b8449634eb 100644
--- a/packages/client/web/src/base.css
+++ b/packages/client/web/src/base.css
@@ -1,9 +1,10 @@
 /* Shell-owned global base: full-height mount plus the theme token sheets.
- * The three ui-theme sheets are the sole token source (--dsw-*); the shell
+ * The four ui-theme sheets are the sole token source (--dsw-*); the shell
  * links them here so tokens exist before any plugin CSS lands. */
 @import '@deepseek-ai/dsh-client-ui-theme/styles/base.css';
 @import '@deepseek-ai/dsh-client-ui-theme/styles/design-platform.css';
 @import '@deepseek-ai/dsh-client-ui-theme/styles/gradient-shadow-text.css';
+@import '@deepseek-ai/dsh-client-ui-theme/styles/shiki.css';
 
 html,
 body,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 24164d7fb5..371778cd17 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -868,6 +868,9 @@ importers:
 
   packages/client/ui-primitives:
     dependencies:
+      '@shikijs/langs':
+        specifier: ^4.3.1
+        version: 4.3.1
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
@@ -883,6 +886,9 @@ importers:
       remark-gfm:
         specifier: ^4.0.1
         version: 4.0.1
+      shiki:
+        specifier: ^4.3.1
+        version: 4.3.1
     devDependencies:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
@@ -6585,24 +6591,52 @@ packages:
   '@shikijs/core@2.5.0':
     resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==}
 
+  '@shikijs/core@4.3.1':
+    resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
+    engines: {node: '>=20'}
+
   '@shikijs/engine-javascript@2.5.0':
     resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==}
 
+  '@shikijs/engine-javascript@4.3.1':
+    resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
+    engines: {node: '>=20'}
+
   '@shikijs/engine-oniguruma@2.5.0':
     resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==}
 
+  '@shikijs/engine-oniguruma@4.3.1':
+    resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
+    engines: {node: '>=20'}
+
   '@shikijs/langs@2.5.0':
     resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==}
 
+  '@shikijs/langs@4.3.1':
+    resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
+    engines: {node: '>=20'}
+
+  '@shikijs/primitive@4.3.1':
+    resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
+    engines: {node: '>=20'}
+
   '@shikijs/themes@2.5.0':
     resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==}
 
+  '@shikijs/themes@4.3.1':
+    resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
+    engines: {node: '>=20'}
+
   '@shikijs/transformers@2.5.0':
     resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==}
 
   '@shikijs/types@2.5.0':
     resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==}
 
+  '@shikijs/types@4.3.1':
+    resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
+    engines: {node: '>=20'}
+
   '@shikijs/vscode-textmate@10.0.2':
     resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
 
@@ -8746,9 +8780,15 @@ packages:
   once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
 
+  oniguruma-parser@0.12.2:
+    resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
+
   oniguruma-to-es@3.1.1:
     resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==}
 
+  oniguruma-to-es@4.3.6:
+    resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
+
   openai@6.26.0:
     resolution: {integrity: sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==}
     hasBin: true
@@ -9105,6 +9145,10 @@ packages:
   shiki@2.5.0:
     resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==}
 
+  shiki@4.3.1:
+    resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
+    engines: {node: '>=20'}
+
   side-channel-list@1.0.1:
     resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
     engines: {node: '>= 0.4'}
@@ -11222,25 +11266,58 @@ snapshots:
       '@types/hast': 3.0.5
       hast-util-to-html: 9.0.5
 
+  '@shikijs/core@4.3.1':
+    dependencies:
+      '@shikijs/primitive': 4.3.1
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+      hast-util-to-html: 9.0.5
+
   '@shikijs/engine-javascript@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
       '@shikijs/vscode-textmate': 10.0.2
       oniguruma-to-es: 3.1.1
 
+  '@shikijs/engine-javascript@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      oniguruma-to-es: 4.3.6
+
   '@shikijs/engine-oniguruma@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
       '@shikijs/vscode-textmate': 10.0.2
 
+  '@shikijs/engine-oniguruma@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+
   '@shikijs/langs@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
 
+  '@shikijs/langs@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+
+  '@shikijs/primitive@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   '@shikijs/themes@2.5.0':
     dependencies:
       '@shikijs/types': 2.5.0
 
+  '@shikijs/themes@4.3.1':
+    dependencies:
+      '@shikijs/types': 4.3.1
+
   '@shikijs/transformers@2.5.0':
     dependencies:
       '@shikijs/core': 2.5.0
@@ -11251,6 +11328,11 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  '@shikijs/types@4.3.1':
+    dependencies:
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   '@shikijs/vscode-textmate@10.0.2': {}
 
   '@smithy/core@3.24.7':
@@ -13813,12 +13895,20 @@ snapshots:
     dependencies:
       wrappy: 1.0.2
 
+  oniguruma-parser@0.12.2: {}
+
   oniguruma-to-es@3.1.1:
     dependencies:
       emoji-regex-xs: 1.0.0
       regex: 6.1.0
       regex-recursion: 6.0.2
 
+  oniguruma-to-es@4.3.6:
+    dependencies:
+      oniguruma-parser: 0.12.2
+      regex: 6.1.0
+      regex-recursion: 6.0.2
+
   openai@6.26.0(ws@8.21.0)(zod@4.4.3):
     optionalDependencies:
       ws: 8.21.0
@@ -14319,6 +14409,17 @@ snapshots:
       '@shikijs/vscode-textmate': 10.0.2
       '@types/hast': 3.0.5
 
+  shiki@4.3.1:
+    dependencies:
+      '@shikijs/core': 4.3.1
+      '@shikijs/engine-javascript': 4.3.1
+      '@shikijs/engine-oniguruma': 4.3.1
+      '@shikijs/langs': 4.3.1
+      '@shikijs/themes': 4.3.1
+      '@shikijs/types': 4.3.1
+      '@shikijs/vscode-textmate': 10.0.2
+      '@types/hast': 3.0.5
+
   side-channel-list@1.0.1:
     dependencies:
       es-errors: 1.3.0