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