Procházet zdrojové kódy

Merge remote-tracking branch 'origin/master' into xjt/readme-proofreading-batch-1

# Conflicts:
#	packages/client/ui-primitives/README.i18n.yaml
#	packages/client/ui-primitives/README.zh.md
xjt před 2 měsíci
rodič
revize
795bdda223
40 změnil soubory, kde provedl 3395 přidání a 120 odebrání
  1. 6 0
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml
  2. 14 0
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
  3. 14 0
      .agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md
  4. 3 3
      .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml
  5. 1 1
      .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
  6. 1 1
      .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md
  7. 3 3
      apps/web/tests/code-mode-fixture.snapshot.ts
  8. 87 1
      apps/web/tests/navigation-panes.e2e.ts
  9. 3 0
      apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md
  10. 306 0
      apps/web/tests/terminal-card.snapshot.ts
  11. 81 3
      packages/client/connection/src/client/fixture.ts
  12. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  13. 3 1
      packages/client/ui-conversation/README.md
  14. 3 1
      packages/client/ui-conversation/README.zh.md
  15. 6 1
      packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx
  16. 18 4
      packages/client/ui-conversation/src/client/chat/ToolRow.module.css
  17. 36 9
      packages/client/ui-conversation/src/client/chat/ToolRow.tsx
  18. 189 0
      packages/client/ui-conversation/src/client/contract/terminal-card-model.ts
  19. 4 2
      packages/client/ui-conversation/src/client/contract/tool-call-model.ts
  20. 14 0
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css
  21. 73 27
      packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx
  22. 15 1
      packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css
  23. 40 14
      packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx
  24. 21 0
      packages/client/ui-conversation/tests/chat-tool-row.spec.tsx
  25. 600 0
      packages/client/ui-conversation/tests/terminal-card.spec.tsx
  26. 2 2
      packages/client/ui-primitives/README.i18n.yaml
  27. 3 1
      packages/client/ui-primitives/README.md
  28. 3 1
      packages/client/ui-primitives/README.zh.md
  29. 1 0
      packages/client/ui-primitives/package.json
  30. 3 1
      packages/client/ui-primitives/src/Pill.tsx
  31. 2 2
      packages/client/ui-primitives/src/StateDot.tsx
  32. 152 0
      packages/client/ui-primitives/src/TerminalBlock.module.css
  33. 237 0
      packages/client/ui-primitives/src/TerminalBlock.tsx
  34. 447 0
      packages/client/ui-primitives/src/ansi.ts
  35. 48 0
      packages/client/ui-primitives/src/clipboard.ts
  36. 2 0
      packages/client/ui-primitives/src/index.ts
  37. 1 39
      packages/client/ui-primitives/src/markdown/CodeBlock.tsx
  38. 513 0
      packages/client/ui-primitives/tests/ansi.spec.ts
  39. 430 0
      packages/client/ui-primitives/tests/terminal-block.spec.tsx
  40. 8 0
      pnpm-lock.yaml

+ 6 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-web-terminal-card.md
+2026-07-28-web-terminal-card.md: 14896b1d88e5cfd2e4c58830c7a1bca1e54ed823
+2026-07-28-web-terminal-card.zh.md: 16c9004f8f80b720b25b76ba5c04f308b0fccbaf

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 14 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 14 - 0
.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md


+ 3 - 3
.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.i18n.yaml

@@ -1,6 +1,6 @@
 # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
-#   pnpm run verify-translation-pairing --write
-2026-07-26-web-syntax-highlighting-shiki.md: b329e35f1d0ce7b3de454758403a09f67056b5af
-2026-07-26-web-syntax-highlighting-shiki.zh.md: 8e9d1f0d0c38ce64bcb5da1262538da762f70b12
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md
+2026-07-26-web-syntax-highlighting-shiki.md: 48a1e4c43f19693f90906f210f0ed85db3f31687
+2026-07-26-web-syntax-highlighting-shiki.zh.md: 780b66a309c841c542f873f376226b8da454e050

+ 1 - 1
.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.md

@@ -17,7 +17,7 @@ The client rendered every code surface — markdown fences in assistant prose, t
 - **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"`). Output stays plain deliberately — tool output is arbitrary text, and guessing a grammar would mis-highlight more than it helps.
+- **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](../feature/2026-07-28-web-terminal-card.md).
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/process/2026-07-26-web-syntax-highlighting-shiki.zh.md

@@ -17,7 +17,7 @@ client 过去把每一处代码表面——assistant 正文里的 markdown 围
 - **依赖**:`shiki/core` + `@shikijs/langs`,经 `createHighlighterCoreSync` 搭配 `createJavaScriptRegexEngine({ forgiving: true })` 组装——不带 oniguruma WASM、没有异步初始化、对 bundle 友好。语法(grammar)白名单:`typescript`(内嵌 JS)、`shellscript`、`json`——即 harness 实际会渲染的那几种语言;其余一律回退到几何完全一致的纯文本块,绝不报错。先例:VitePress 站点已经通过 shiki 渲染全部文档代码;而在 TypeScript(正是此处要紧的载荷)上,TextMate 语法实质性优于正则高亮器。
 - **单例**:`ui-primitives/src/markdown/highlight.ts` 为每个 document 创建一个 `HighlighterCore`,并公开 `highlightToHtml(code, lang)`(undefined 即渲染为纯文本)。引擎加语法的构建是一次约 120-175ms 的长任务,因此模块在插件启动时用延迟任务预热单例(惰性路径保留为正确性兜底),把这笔开销挪出渲染路径——否则流式 finalize 交换的那一刻会卡顿。别名表用 `Map` 而非对象:fence 信息串由 assistant 撰写,诸如 `constructor` 这样的标签必须落空,而不是解析到继承属性并让 shiki 崩溃。共享的 `CodeBlock` 组件同时拥有两条分支;其 shiki 分支经 `dangerouslySetInnerHTML` 注入生成的 span 树——此用法获准,因为 shiki 输出的是从代码文本计算出的静态 span 树(不流经任何用户 HTML,没有脚本或事件处理器),这正是 shiki 自身文档载明的消费路径。
 - **主题化**:shiki 的 `createCssVariablesTheme` 让每一种 token 颜色都经由 `--shiki-*` 自定义属性路由;取值本身住在新增的 `ui-theme/styles/shiki.css` token 表里(亮色在 `:root`、暗色在 `body[data-ds-dark-theme]`——层叠方式与其余每张样式表相同),由壳的 `base.css` 导入链引入。组件 CSS 保持只用 token;任何字面颜色都不进入 JS 或组件样式表。背景/前景以别名指向既有的 markdown 代码块 token,使高亮块与纯文本块彼此一致。
-- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。输出有意保持纯文本——工具输出是任意文本,硬猜一种语法,带来的误高亮会多于帮助。
+- **表面**:markdown 围栏代码块(`MarkdownText` 的 `pre` 组件把单字符串围栏路由到 `CodeBlock`)、`run_code` 展开后的程序正文(ToolRow 的 code 变体,`lang="typescript"`),以及 details 面板的 Input 参数(`lang="json"`)。工具输出从不做语法高亮——它是任意文本,硬猜一种语法,带来的误高亮会多于帮助;bash 卡片的输出只承载其自身 ANSI 序列声明的颜色,经由[终端卡片](../feature/2026-07-28-web-terminal-card.md)渲染。
 
 ## 曾考虑的替代方案
 

+ 3 - 3
apps/web/tests/code-mode-fixture.snapshot.ts

@@ -215,9 +215,9 @@ it('trajectory and waterfall surface the run_code sub-calls with real timing', a
   }).toMatchInlineSnapshot(`
     {
       "subCells": [
-        "#51Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
-        "#52Subread · {"path":"notes/demo.txt"}+0.8s",
-        "#53Subread · {"path":"notes/missing.txt"}+0.8s",
+        "#49Subbash · {"command":"ls notes","description":"List notes"}+0.8s",
+        "#50Subread · {"path":"notes/demo.txt"}+0.8s",
+        "#51Subread · {"path":"notes/missing.txt"}+0.8s",
       ],
     }
   `)

+ 87 - 1
apps/web/tests/navigation-panes.e2e.ts

@@ -24,6 +24,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/navigation-panes', impor
 const SEED = join(SNAPSHOT_DIR, 'seed.jsonl')
 const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md')
 const WATERFALL_EXPECTED = join(SNAPSHOT_DIR, 'waterfall.expected.md')
+const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md')
 const MODE = webSnapshotMode()
 const SEED_ID = 'navigation-panes-web-e2e'
 
@@ -162,6 +163,10 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     expect(await frame.getAttribute('data-details-collapsed')).not.toBeNull()
     await bashRow.click()
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
+    // The card's own controls are outside the summary row and must not open
+    // details either — the terminal card is read in place.
+    await page.locator('[data-sample="bash-global"] ~ [data-terminal] [class*="_copyButton_"]').first().click()
+    await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
     // Read summaries are host-open file links; they also must not open details.
     const fileLink = page.locator('[data-variant="read"] button').first()
     await fileLink.waitFor({ timeout: 10_000 })
@@ -169,11 +174,92 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => frame.getAttribute('data-details-collapsed'), { timeout: 5_000 }).not.toBeNull()
   }, 60_000)
 
+  it.skipIf(MODE === 'record')('renders the bash row as a terminal card in the real browser', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-terminal'))
+    await page.getByRole('tab', { name: 'Chat' }).click()
+    // The card is resident in the keyed bash row (no expand gesture): the
+    // recorded command's own output sits in the message flow, derived from the
+    // logged call/result presentations alone.
+    const card = page.locator('[data-sample="bash-global"] ~ [data-terminal], [data-sample="bash-global"] [data-terminal]').first()
+    await card.waitFor({ timeout: 15_000 })
+    // Real layout, not jsdom's stub (which computes no geometry at all):
+    // squeeze the output pane below its content width and the line must keep
+    // its single row and overflow sideways instead of folding. Soft-wrapping
+    // here is what shredded the column alignment this card exists to hold.
+    const layout = await card.locator('[class*="_output_"]').first().evaluate((node) => {
+      const pane = node as HTMLElement
+      const row = pane.querySelector<HTMLElement>('[class*="_line_"]')
+      if (row === null) throw new Error('output pane has no line')
+      const before = row.offsetHeight
+      const restore = pane.style.width
+      pane.style.width = '8px'
+      const squeezed = { wrapped: row.offsetHeight > before, scrollsSideways: pane.scrollWidth > pane.clientWidth }
+      pane.style.width = restore
+      return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
+    })
+    expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
+    // The run-state dot's color is the whole point of it and is the one thing
+    // jsdom cannot report: --dsw-* tokens resolve only against the real theme
+    // stylesheet. This command settled cleanly, so the dot must be the green
+    // success token — a red one here would read as a failed command.
+    const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
+      // The token lives on body, so the probe must sit in the same cascade.
+      const probe = document.createElement('span')
+      probe.style.color = 'var(--dsw-alias-state-success-primary)'
+      document.body.appendChild(probe)
+      const success = getComputedStyle(probe).color
+      probe.remove()
+      return {
+        state: node.getAttribute('data-state'),
+        color: getComputedStyle(node as HTMLElement).color,
+        success,
+        // One label per card (the state is the call's), so it hangs off the
+        // prompt column rather than the row the dot sits in.
+        label: node.closest('[class*="_prompt_"]')?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
+        // The dot precedes the prompt label in document order, which is what
+        // puts it to the left of the `$`.
+        beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
+          === Node.DOCUMENT_POSITION_FOLLOWING,
+        // The dot lives in the card's OWN left padding, so it sits inside the
+        // card box yet left of the prompt text. Owning the reservation as padding
+        // rather than margin is what keeps a consumer's own margin from
+        // cancelling it and letting a container clip the dot — geometry jsdom
+        // cannot compute.
+        insideCard: (node as HTMLElement).getBoundingClientRect().left
+          >= (node.closest('[data-terminal]')?.getBoundingClientRect().left ?? Infinity),
+        leftOfPrompt: (node as HTMLElement).getBoundingClientRect().right
+          <= (node.closest('[class*="_promptLine_"]')
+            ?.querySelector('[class*="_cwd_"]')
+            ?.getBoundingClientRect().left ?? -Infinity),
+      }
+    })
+    expect(dot.state).toBe('done')
+    expect(dot.label).toBe('已完成')
+    expect(dot.beforePrompt).toBe(true)
+    expect(dot.insideCard).toBe(true)
+    expect(dot.leftOfPrompt).toBe(true)
+    // Resolved through the theme token, not a literal hex in the component.
+    expect(dot.success).toMatch(/^rgb/)
+    expect(dot.color).toBe(dot.success)
+    // Golden of the card at rest — captured before the copy click, whose
+    // confirmation label self-reverts on a timer and would not hold still.
+    const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))
+      .split(SEED_ID).join('{{seededId}}')
+    await compareOrRefreshGolden(TERMINAL_EXPECTED, snapshot, MODE)
+    // Copy writes the raw output through the browser's own clipboard, which in
+    // a real page is the async Clipboard API rather than the jsdom fallback.
+    await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    await card.locator('[class*="_copyButton_"]').first().click()
+    await expect.poll(() => card.locator('[class*="_copyButton_"]').first().textContent(), { timeout: 5_000 })
+      .toBe('复制成功')
+    expect(await page.evaluate(() => navigator.clipboard.readText())).toContain('NAVIGATION_OK')
+  }, 60_000)
+
   it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await assertFixtureInventory(SNAPSHOT_DIR, [
-      'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md',
+      'seed.jsonl', 'trajectory.expected.md', 'waterfall.expected.md', 'terminal-card.expected.md',
     ])
   })
 })

+ 3 - 0
apps/web/tests/snapshots/navigation-panes/terminal-card.expected.md

@@ -0,0 +1,3 @@
+- text: 已完成 {{workspace}} echo NAVIGATION_OK
+- button "复制"
+- text: NAVIGATION_OK

+ 306 - 0
apps/web/tests/terminal-card.snapshot.ts

@@ -0,0 +1,306 @@
+// @vitest-environment jsdom
+// Terminal card snapshot over the BUILT client graph (the code-mode-fixture
+// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport).
+// Opens the fixture history session and pins the `card: 'terminal'` render
+// intent at both of its conversation render sites, for both chat-row shapes:
+// turn 60's `fx-bash` on the render-site fallback row (expand-gated body) and
+// turn 65's `bash` on the keyed BashRow registration (resident body). Turn 65
+// carries what turn 60's two clean prompt rows cannot — SGR runs resolved to
+// --dsw-* tokens, output past the chat cap, a nested cwd, and a non-zero exit
+// pill; turn 60 carries the multi-line command's per-line prompt rows.
+//
+// The details panel's Output section is NOT covered here: tool rows stopped
+// being details-panel click targets, and nothing else in the assembled
+// application opens that panel, so the surface cannot be driven end to end.
+// Its terminal rendering stays pinned in ui-conversation's
+// tests/terminal-card.spec.tsx, which mounts DetailsPanel with a selection
+// directly.
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+import { act, cleanup, fireEvent, screen, waitFor, within } from '@testing-library/react'
+import { afterEach, beforeEach, expect, it, vi } from 'vitest'
+import type { WebBootEntry } from '@deepseek-ai/dsh-client-modules/client'
+import { AppWebEntry } from '@deepseek-ai/dsh-client-web'
+
+const PLUGINS: readonly (WebBootEntry & { dir: string })[] = [
+  { id: '@deepseek-ai/dsh-client-connection', dir: 'connection', url: '/plugins/connection.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-runtime', dir: 'runtime', url: '/plugins/runtime.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-connection'], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-theme', dir: 'ui-theme', url: '/plugins/ui-theme.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-locale', dir: 'locale', url: '/plugins/locale.js', rev: 'fx', inject: [], immediately: true },
+  { id: '@deepseek-ai/dsh-client-ui-layout', dir: 'ui-layout', url: '/plugins/ui-layout.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-runtime'] },
+  { id: '@deepseek-ai/dsh-client-ui-sidebar', dir: 'ui-sidebar', url: '/plugins/ui-sidebar.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  { id: '@deepseek-ai/dsh-client-ui-conversation', dir: 'ui-conversation', url: '/plugins/ui-conversation.js', rev: 'fx', inject: ['@deepseek-ai/dsh-client-ui-layout'] },
+  {
+    id: '@deepseek-ai/dsh-client-ui-workspace',
+    dir: 'ui-workspace',
+    url: '/plugins/ui-workspace.js',
+    rev: 'fx',
+    inject: [
+      '@deepseek-ai/dsh-client-runtime',
+      '@deepseek-ai/dsh-client-ui-conversation',
+      '@deepseek-ai/dsh-client-ui-sidebar',
+    ],
+  },
+]
+
+const bundles = new Map(PLUGINS.map(plugin => [
+  plugin.url,
+  readFileSync(join(process.cwd(), 'packages/client', plugin.dir, 'lib/client.js'), 'utf8'),
+]))
+
+interface FixtureWindow extends Window {
+  __DSH_BOOT__?: { rev: string; entries: WebBootEntry[] }
+  __ModuleLoader__?: unknown
+}
+
+class ResizeObserverStub {
+  observe(): void {}
+  disconnect(): void {}
+  unobserve(): void {}
+}
+
+const win = window as FixtureWindow
+let unmount: (() => void) | undefined
+
+beforeEach(() => {
+  localStorage.clear()
+  document.title = 'DeepSeek Harness'
+  vi.stubGlobal('ResizeObserver', ResizeObserverStub)
+  vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
+    setTimeout(() => { callback(0) }, 0) as unknown as number)
+  vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
+})
+
+afterEach(() => {
+  act(() => { unmount?.() })
+  unmount = undefined
+  cleanup()
+  delete win.__DSH_BOOT__
+  delete win.__ModuleLoader__
+  document.body.innerHTML = ''
+  document.head.querySelectorAll('style[data-plugin]').forEach((style) => { style.remove() })
+  document.title = ''
+  history.replaceState(null, '', '/')
+  vi.unstubAllGlobals()
+})
+
+/** Boot the complete built client graph against the populated fixture branch. */
+function boot(): void {
+  history.replaceState(null, '', '/?fixture')
+  const root = document.createElement('div')
+  root.id = 'root'
+  document.body.appendChild(root)
+  win.__DSH_BOOT__ = { rev: 'fx', entries: PLUGINS.map(({ dir: _dir, ...plugin }) => plugin) }
+  act(() => {
+    const entry = new AppWebEntry(root, {
+      fetchBundle: (url) => {
+        const code = bundles.get(url)
+        return code === undefined ? Promise.reject(new Error(`missing built bundle ${url}`)) : Promise.resolve(code)
+      },
+      executeBundle: (code) => { (0, eval)(code) },
+    })
+    void entry.run()
+    unmount = () => { entry.dispose() }
+  })
+}
+
+/** Collapse decorative whitespace while preserving the text a user sees. */
+function visibleText(element: Element): string {
+  return (element.textContent ?? '').replace(/\s+/g, ' ').trim()
+}
+
+/**
+ * Read one terminal card's user-visible state. Output lines keep their interior
+ * whitespace: holding column alignment is what this card exists for, so
+ * collapsing runs of spaces would hide the behavior under test.
+ */
+function readCard(card: Element) {
+  const status = card.querySelector('[class*="_status_"]')
+  const expander = card.querySelector('button[aria-expanded]')
+  return {
+    // One entry per command line: a multi-line command is one row per line.
+    prompt: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
+      `${row.querySelector('[class*="_cwd_"]')?.textContent ?? ''} ${row.querySelector('[class*="_command_"]')?.textContent ?? ''}`),
+    // Dots per prompt row: exactly one, on the first row — the exit status the
+    // view carries is the whole call's, so a dot per line would assert a
+    // per-line outcome bash does not report.
+    dotsPerPromptRow: [...card.querySelectorAll('[class*="_promptLine_"]')].map(row =>
+      row.querySelectorAll('[data-state]').length),
+    status: status === null ? null : status.textContent,
+    copy: card.querySelector('[class*="_copyButton_"]')?.textContent ?? null,
+    lines: [...card.querySelectorAll('[class*="_line_"]')].map(line => line.textContent),
+    expander: expander === null ? null : {
+      label: expander.getAttribute('aria-label'),
+      text: expander.textContent,
+      expanded: expander.getAttribute('aria-expanded'),
+    },
+    // The run-state dot at the head of the prompt line, by its StateDot state.
+    runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null,
+    runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
+    // Every color the ANSI parser emits resolves through a --dsw-* token, so
+    // the card follows the theme instead of painting literal terminal rgb.
+    // Scoped to the output lines: the run-state dot is an inline-styled span
+    // too, and its geometry is not an ANSI-resolved color.
+    colors: [...new Set([...card.querySelectorAll('[class*="_line_"] span[style]')]
+      .map(span => span.getAttribute('style')))],
+  }
+}
+
+/** Open the fixture history session (the alpha log carrying both bash turns) and wait for its tail. */
+async function openFixtureSession(): Promise<void> {
+  const tree = await screen.findByRole('tree', { name: 'Sessions' }, { timeout: 10_000 })
+  // Anchor on the expandable Workspace group row: the title and the blank
+  // session row can both read "fixture".
+  const group = (await within(tree).findAllByText('fixture'))
+    .map(el => el.closest<HTMLElement>('[role="treeitem"]'))
+    .find(el => el?.getAttribute('aria-expanded') !== null)
+  if (group === null || group === undefined) throw new Error('fixture Workspace group missing')
+  if (group.getAttribute('aria-expanded') === 'false') {
+    fireEvent.click(within(group).getByText('fixture'))
+    await waitFor(() => {
+      expect(group.getAttribute('aria-expanded')).toBe('true')
+    })
+  }
+  fireEvent.click(await within(tree).findByText('Fixture 历史会话'))
+  await waitFor(() => {
+    expect(document.querySelector('[data-sample="bash-global"]')).not.toBeNull()
+  }, { timeout: 10_000 })
+}
+
+/** The keyed BashRow of fixture turn 65 (the one carrying the ANSI sample). */
+function keyedBashRow(): Element {
+  // Anchored on the BashRow wrapper (summary row + resident card), not on the
+  // summary row itself: the summary now shows the presenter's description (the
+  // contract's above-card text), so the command lives only in the card below it.
+  const row = [...document.querySelectorAll('[data-sample="bash-global"]')]
+    .map(node => node.parentElement)
+    .find((node): node is HTMLElement => node !== null && visibleText(node).includes('pnpm run check'))
+  if (row === undefined) throw new Error('keyed bash row for turn 65 missing')
+  return row
+}
+
+/** The turn-60 fallback row, which reaches the terminal card through GenericToolCard/ToolRow. */
+function fallbackBashRow(): Element {
+  const row = document.querySelector('[data-tool="fx-bash"]')
+  if (row === null) throw new Error('fx-bash fallback row missing')
+  return row
+}
+
+it('renders the keyed bash row with a resident terminal card', async () => {
+  boot()
+  await openFixtureSession()
+
+  const row = keyedBashRow()
+  const card = row.parentElement?.querySelector('[data-terminal]')
+  if (card === null || card === undefined) throw new Error('keyed bash row has no resident terminal card')
+  // The prompt shortens the nested cwd to its last segment, the exit pill comes
+  // from the sample's authored exit status (its body deliberately carries no
+  // `[exit code: N]` marker, since the real presenter consumes that one), ANSI
+  // runs land on theme tokens, and the chat cap (8) collapses the middle into a
+  // head/tail split with an expander between them.
+  expect(readCard(card)).toMatchInlineSnapshot(`
+    {
+      "colors": [
+        "font-weight: 700;",
+        "color: var(--dsw-alias-state-success-primary);",
+        "color: var(--dsw-alias-state-error-primary);",
+      ],
+      "copy": "复制",
+      "dotsPerPromptRow": [
+        1,
+      ],
+      "expander": {
+        "expanded": "false",
+        "label": "展开其余 13 行输出",
+        "text": "… 其余 13 行",
+      },
+      "lines": [
+        "Running 4 checks",
+        "✓ typecheck                                          1.82s",
+        "✓ lint                                               0.94s",
+        "✓ duplication                                        2.10s",
+        "StateDot.tsx                100%     100%        100%         -",
+        "markdown/Markdown.tsx       100%     100%        100%         -",
+        "",
+        "1 of 4 checks failed",
+      ],
+      "prompt": [
+        "nested pnpm run check",
+      ],
+      "runState": "error",
+      "runStateLabel": "失败",
+      "status": "退出码 1",
+    }
+  `)
+})
+
+it('the fallback row reaches the same card through its expand control', async () => {
+  boot()
+  await openFixtureSession()
+
+  const row = fallbackBashRow()
+  expect(row.querySelector('[data-terminal]')).toBeNull()
+  const toggle = row.querySelector('button[aria-expanded]')
+  if (toggle === null) throw new Error('fallback row expand control missing')
+  fireEvent.click(toggle)
+  const card = await waitFor(() => {
+    const found = row.querySelector('[data-terminal]')
+    if (found === null) throw new Error('terminal card missing after expanding the fallback row')
+    return found
+  })
+  // Three plain lines under the cap: no ANSI spans, no exit pill, no expander.
+  expect(readCard(card)).toMatchInlineSnapshot(`
+    {
+      "colors": [],
+      "copy": "复制",
+      "dotsPerPromptRow": [
+        1,
+        0,
+      ],
+      "expander": null,
+      "lines": [
+        "total 2",
+        "drwxr-xr-x fixture",
+        "-rw-r--r-- demo.txt",
+      ],
+      "prompt": [
+        "fixture ls -la",
+        "$ echo done",
+      ],
+      "runState": "done",
+      "runStateLabel": "已完成",
+      "status": null,
+    }
+  `)
+})
+
+it('the chat card expands the collapsed middle in place, without opening the details panel', async () => {
+  boot()
+  await openFixtureSession()
+
+  const card = keyedBashRow().parentElement?.querySelector('[data-terminal]')
+  if (card === null || card === undefined) throw new Error('resident terminal card missing')
+  const expander = card.querySelector('button[aria-expanded]')
+  if (expander === null) throw new Error('height-cap expander missing')
+  const capped = card.querySelectorAll('[class*="_line_"]').length
+
+  fireEvent.click(expander)
+  await waitFor(() => {
+    expect(card.querySelector('button[aria-expanded]')?.getAttribute('aria-expanded')).toBe('true')
+  })
+  expect({
+    cappedLines: capped,
+    expandedLines: card.querySelectorAll('[class*="_line_"]').length,
+    expanderLabel: card.querySelector('button[aria-expanded]')?.getAttribute('aria-label'),
+    // The card sits outside the summary row's click target, so toggling it
+    // left the details panel shut.
+    detailsOpen: screen.queryByText('Input') !== null,
+  }).toMatchInlineSnapshot(`
+    {
+      "cappedLines": 8,
+      "detailsOpen": false,
+      "expandedLines": 21,
+      "expanderLabel": "收起输出",
+    }
+  `)
+})

+ 81 - 3
packages/client/connection/src/client/fixture.ts

@@ -80,6 +80,62 @@ const MARKDOWN_FIXTURE = [
 
 const USER_MARKDOWN_LITERAL = '用户字面量:# 不渲染 `code` [link](https://example.com)'
 
+/**
+ * SGR wrapper for the terminal output sample below: authoring the escapes as
+ * `\u001b` keeps literal control bytes out of this source file.
+ * @param code - the SGR parameter (an ANSI color or attribute number).
+ * @param body - the text the attribute applies to.
+ * @returns the body wrapped in the attribute and a reset.
+ */
+function sgr(code: number, body: string): string {
+  return `\u001b[${code}m${body}\u001b[0m`
+}
+
+/**
+ * Terminal output sample for fixture turn 65, authored to carry every feature
+ * the terminal card draws that turn 60's two prompt rows cannot reach:
+ * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to
+ * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll
+ * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the
+ * height cap collapses the middle. The exit status is authored separately in
+ * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash
+ * presenter CONSUMES its `[exit code: N]` marker out of the body, because a
+ * terminal card shows the exit as its own pill and leaving the marker in would
+ * render it twice (packages/bash/tool-bash/src/render.ts).
+ */
+const TERMINAL_OUTPUT_FIXTURE = [
+  sgr(1, 'Running 4 checks'),
+  `${sgr(32, '\u2713')} typecheck                                          1.82s`,
+  `${sgr(32, '\u2713')} lint                                               0.94s`,
+  `${sgr(32, '\u2713')} duplication                                        2.10s`,
+  `${sgr(31, '\u2717')} unit                                               8.41s`,
+  '',
+  sgr(90, 'packages/client/ui-primitives/tests/terminal-block.spec.tsx'),
+  `  ${sgr(31, 'FAIL')} caps output at the configured line budget`,
+  '    expected 16 lines, received 24',
+  '',
+  'NAME                        LINES    BRANCHES    FUNCTIONS    UNCOVERED',
+  'TerminalBlock.tsx           100%     100%        100%         -',
+  'ansi.ts                     100%     100%        100%         -',
+  'clipboard.ts                100%     100%        100%         -',
+  'CodeBlock.tsx               98.4%    96.2%       100%         41-43',
+  'highlight.ts                100%     100%        100%         -',
+  'Pill.tsx                    100%     100%        100%         -',
+  'StateDot.tsx                100%     100%        100%         -',
+  'markdown/Markdown.tsx       100%     100%        100%         -',
+  '',
+  sgr(31, '1 of 4 checks failed'),
+].join('\n')
+
+/**
+ * Exit status for each terminal sample, keyed by its output text. Authored
+ * alongside the sample rather than parsed back out of its trailing marker,
+ * which is the bash tool's own job and not something to reimplement here.
+ */
+const TERMINAL_EXIT_STATUS: Record<string, { exitCode: number } | { signal: string }> = {
+  [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 },
+}
+
 const DEEPSEEK_REASONING = {
   efforts: [
     { id: 'off', name: 'Off' },
@@ -170,7 +226,9 @@ function buildAlphaLog(): SessionEvent[] {
     push({ type: 'step/end', data: { turn, step: 0 } })
     push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } })
   }
-  toolTurn(60, 'fx-bash', '{"command":"ls -la","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
+  // A two-line command, so the fixture covers the terminal card's one-row-per-
+  // command-line prompt (and that the card still marks the call exactly once).
+  toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt')
   toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt')
   toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑')
   toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入')
@@ -224,8 +282,22 @@ function buildAlphaLog(): SessionEvent[] {
     { content: '实现 fixture 样本', status: 'in_progress' },
     { content: '浏览器验收', status: 'pending' },
   ]
+  // Turn 65: the terminal sample turn 60's two clean prompt rows cannot cover —
+  // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd
+  // whose prompt label is its last segment, and a non-zero exit authored beside
+  // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no
+  // `[exit code: N]` marker, since the real presenter consumes that one out of
+  // the body. Named `bash`, so it also covers
+  // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback
+  // row) — the two chat-row shapes the terminal card renders in.
+  //
+  // Ordered BEFORE the todo turn deliberately: the standing plan retires at the
+  // next `turn/start`, so a turn appended after it would leave the dock's plan
+  // strip empty and take the todo surfaces' own coverage with it.
+  toolTurn(65, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE)
+
   const todoArgs = JSON.stringify({ todos: fixtureTodos })
-  toolTurn(65, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
+  toolTurn(66, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 1 in progress, 1 completed.')
   // The real tool appends the snapshot mid-execution — between tool/call and
   // tool/result — so the fixture reproduces that exact ordering (the last
   // toolTurn events run ... tool/call, tool/result, step/end, turn/end).
@@ -250,7 +322,10 @@ function presentCall(name: string, argsRaw: string): ToolCallView | undefined {
     return undefined
   }
   switch (name) {
+    // Both names present the same terminal card: `fx-bash` lands on the
+    // render-site fallback row, `bash` on the keyed BashRow registration.
     case 'fx-bash':
+    case 'bash':
       return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' }
     case 'fx-write':
       return {
@@ -271,7 +346,10 @@ function presentResult(name: string, argsRaw: string, resultText: string): ToolR
   if (call === undefined) return undefined
   switch (call.card) {
     case 'terminal':
-      return { card: 'terminal', output: resultText, exitCode: 0 }
+      // The sample's own exit status, authored beside it: re-parsing the
+      // trailing marker here would duplicate the bash tool's `parseExitStatus`,
+      // which this client-side fixture cannot import.
+      return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) }
     case 'diff':
       return { card: 'diff', diffs: call.diffs }
     case 'generic':

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: e2148cfca658196540e3800912dccd0568ae8d0e
-README.zh.md: 45f05ce2e03e015701e85f2853a4a656511058a9
+README.md: 5a1f9f1ad5cac6601e8686af7206bb436e40e91f
+README.zh.md: ccbf1918ae3d40fd42ff7454f7f983d6261ba28f

+ 3 - 1
packages/client/ui-conversation/README.md

@@ -12,6 +12,8 @@ Approvals take over the composer through the chain this package declares: `Appro
 
 Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
 
+A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed for this intent alone; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
+
 Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
 
 The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`<done>/<total> 已完成 · <active item>` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and collapses to a header of title plus `"<done>/<total> tasks · <n> in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included.
@@ -33,7 +35,7 @@ None; this package neither assembles nor sends a provider request.
 ## Known Limitations and Deferred Work
 
 - **The stats line has no duration segment** — assistant `usage` carries token accounting only; elapsed-time needs a host data source.
-- **Details panel is the minimal form** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred.
+- **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly.
 - **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized IconActions row (copy / branch / clock) ships; branch remains a chrome stub.
 - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export.
 - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today.

+ 3 - 1
packages/client/ui-conversation/README.zh.md

@@ -10,6 +10,8 @@
 
 通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
 
+声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出只对该意图开放;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
+
 工具行同样是 slot:独立工具环(`ToolViewRegistry`/`ctx.toolviews`/outlet)已经退役。聊天配置项声明键控的 `'conversation.chat.toolview'` 空位(Session scope;key 空间在运行时开放);其渲染点逐行通过 `entryKey: toolName` 分发,并以 `GenericToolCard` 作为调用点 `fallback`。owner 载荷是统一的 `ToolRowOwnerProps`(`callId`/`toolName`/`block`/`openFile`),`ToolRowProps` 则预先将其与 Session 标准工具包组合。注册方只是普通插件:`ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)`,以 `inject: ['slots', 'conversation']` 作为加载顺序 seam(apply 在聊天注册后挂载 ConversationService,因此服务存在即可保证 slot 已声明);Session 区分在组件内部完成(`useSessions` 读取 `parentId`,bash 示例是第三方姿态的范例)。Trajectory/waterfall 工具视图 slot 共享此形状,并随各自的渲染点落地(RendersCheck 会拒绝没有任何渲染方的声明)。
 
 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);选中会经由输入栏注入的 `command` 回调提交 `/permission <preset>` 命令行。
@@ -33,7 +35,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
 ## 已知限制与暂缓事项
 
 - **统计行没有耗时区段**:assistant `usage` 只携带 token 计数;耗时需要主机数据源。
-- **详情面板是最小形态**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。
+- **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。
 - **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的 IconActions 行(复制/分支/时钟)已落地;分支仍是 chrome stub。
 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。
 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。

+ 6 - 1
packages/client/ui-conversation/src/client/chat/GenericToolCard.tsx

@@ -10,6 +10,7 @@ import {
   IconThinkOutline14,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowOwnerProps } from '../contract/slots.ts'
+import { terminalCardModel } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowVariant } from '../contract/tool-call-model.ts'
 import { ToolRow } from './ToolRow.tsx'
 
@@ -27,6 +28,7 @@ const VARIANT_ICONS: Record<ToolRowVariant, ReactNode> = {
 
 export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwnerProps) {
   const model = toolRowModel(toolName, block, cwd)
+  const terminal = terminalCardModel(block, cwd)
   const singleFile = model.filePath !== undefined
   return (
     <ToolRow
@@ -34,9 +36,12 @@ export function GenericToolCard({ toolName, block, cwd, openFile }: ToolRowOwner
       toolName={toolName}
       icon={VARIANT_ICONS[model.variant]}
       title={model.title}
-      summary={model.summary}
+      // A terminal presenter's description is the contract's above-card text, so
+      // it outranks the args-derived summary here exactly as it does in BashRow.
+      summary={terminal?.description ?? model.summary}
       // Single-file tools never expose an args body — the path link is the only action.
       body={singleFile ? null : model.body}
+      terminal={terminal}
       state={model.state}
       filePath={model.filePath}
       onOpenFile={singleFile ? openFile : undefined}

+ 18 - 4
packages/client/ui-conversation/src/client/chat/ToolRow.module.css

@@ -175,9 +175,23 @@ button.leading {
   color: var(--dsw-alias-label-tertiary);
 }
 
-/* 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 {
+/* The two block-shaped expanded bodies: the code variant's run_code program
+   through CodeBlock (shiki-highlighted TypeScript) and a terminal card's
+   command output through TerminalBlock. Both are drawn by the shared
+   primitive, so only the row's indentation is this file's concern — the margin
+   also replaces each primitive's own standalone vertical spacing with the
+   flow's row rhythm. */
+.codeBody,
+.terminalBody {
   margin: 4px 0 4px 22px;
 }
+
+/* Indented to the body's own column so the description reads as the card's
+   heading rather than as another summary row, and sits tight against the card
+   below it. Its own rule: grouping it with a body would put description
+   typography on a `CodeBlock` wrapper and change that body's spacing. */
+.terminalDescription {
+  margin: 4px 0 0 22px;
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+}

+ 36 - 9
packages/client/ui-conversation/src/client/chat/ToolRow.tsx

@@ -1,14 +1,18 @@
 // ToolRow: the single-line tool summary row (figma component set 122:9479) —
 // 16px leading slot (state dot / tool icon, chevron on hover or expanded) + title +
-// separator dot + FILL-truncated summary. Expanded body is indented gray text;
-// no inline output (full results live in the details panel). Expand state is
+// separator dot + FILL-truncated summary. The collapsed row is always one
+// line; the expanded body is indented gray text, the run_code program through
+// CodeBlock, or — for a call whose render intent is a terminal card — the
+// command's own output through TerminalBlock, capped at
+// CHAT_TERMINAL_MAX_LINES so the message flow stays scannable. Expand state is
 // component-local view state. File-tool summaries are path links that open
 // through the host; the row itself is not a details-panel control.
 
 import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
 import clsx from 'clsx'
-import { CodeBlock, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
+import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts'
 import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts'
 import css from './ToolRow.module.css'
 
@@ -20,8 +24,15 @@ export interface ToolRowProps {
   icon: ReactNode
   title: string
   summary: string
-  /** Expanded-body text; null = not expandable (leading slot never toggles). */
+  /** Expanded-body text; null = no text body (`terminal` is the other body source). */
   body: string | null
+  /**
+   * Terminal-card material for a call whose render intent is a terminal card
+   * (derived by `terminalCardModel`); it replaces the text body when present.
+   * Null or absent leaves the text body, and a row with neither is not
+   * expandable (its leading slot never toggles).
+   */
+  terminal?: TerminalCardModel | null | undefined
   state: ToolRowState
   /** Makes the row itself the expand control instead of only its leading icon. */
   expandOnRowClick?: boolean | undefined
@@ -52,17 +63,25 @@ export function ToolRow({
   title,
   summary,
   body,
+  terminal,
   state,
   expandOnRowClick = false,
   filePath,
   onOpenFile,
 }: ToolRowProps) {
   const [expanded, setExpanded] = useState(false)
+  const terminalBody = terminal ?? null
   // A row that names a single file keeps one interaction (open that path);
-  // args expand is off whether or not the open callback is wired yet.
+  // args expand is off whether or not the open callback is wired yet. Terminal
+  // material still expands: only the file variants carry a path, so a terminal
+  // card and a file link never land on the same row.
   const singleFile = filePath !== undefined
   const fileLink = singleFile && onOpenFile !== undefined
-  const expandable = body !== null && !singleFile
+  const expandable = (body !== null && !singleFile) || terminalBody !== null
+  // The text arms take the empty string for a null body: a row expandable
+  // only through its terminal material renders the terminal body instead, so
+  // this substitution never shows.
+  const text = body ?? ''
   const open = expanded && expandable
   const rowExpands = expandable && expandOnRowClick
   const toggleExpand = () => {
@@ -137,9 +156,17 @@ export function ToolRow({
           </>
         )}
       </div>
-      {open && (variant === 'code'
-        ? <CodeBlock code={body} lang="typescript" className={css.codeBody} />
-        : <div className={css.body}>{body}</div>)}
+      {/* The terminal presenter's description belongs ABOVE the card per the
+          render-intent contract, so an expanded terminal row keeps showing it
+          even though the collapsed summary is hidden while open. */}
+      {open && terminalBody?.description !== undefined && (
+        <div className={css.terminalDescription}>{terminalBody.description}</div>
+      )}
+      {open && (terminalBody !== null
+        ? <TerminalBlock {...terminalBody.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminalBody} />
+        : variant === 'code'
+          ? <CodeBlock code={text} lang="typescript" className={css.codeBody} />
+          : <div className={css.body}>{text}</div>)}
     </div>
   )
 }

+ 189 - 0
packages/client/ui-conversation/src/client/contract/terminal-card-model.ts

@@ -0,0 +1,189 @@
+/**
+ * Pure derivation of the terminal-card props from a frozen call slice: the
+ * `card:'terminal'` render intent the bash tool declares arrives on the
+ * snapshot as `callView`/`resultView`, and this is the one place that turns
+ * that pair into what {@link TerminalBlock} draws. Both conversation render
+ * sites (the chat tool row's expanded body and the details panel's Output
+ * section) call this, so the command, cwd, output and exit status they show
+ * are derived once.
+ * @module
+ */
+import type { TerminalBlockProps } from '@deepseek-ai/dsh-client-ui-primitives'
+import { resolveToolPath, type ToolCallBlock } from './tool-call-model.ts'
+
+/**
+ * Output lines the chat row's expanded terminal body shows before collapsing
+ * the middle — half the primitive's own default, which the details panel
+ * keeps. A chat row is a summary surface inside the message flow: the flow
+ * must stay scannable across many calls, while the details panel is the
+ * single-call reading surface. A design constant of this UI's row geometry,
+ * not a deployment choice, so it is fixed here rather than a plugin Config
+ * field.
+ */
+export const CHAT_TERMINAL_MAX_LINES = 8
+
+/**
+ * The {@link TerminalBlock} props this derivation owns. Picked off the
+ * primitive's props so the two stay in step; `home` is absent because the web
+ * client has no home path for the session host (a cwd renders as its last
+ * path segment), and `maxLines`/`className` belong to each render site.
+ */
+export interface TerminalCardModel {
+  /**
+   * The props {@link TerminalBlock} draws. Held as a nested object so a render
+   * site spreads exactly the primitive's own surface and can never leak a
+   * neighbouring field into it.
+   */
+  card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
+  /**
+   * The call view's model-authored description, which the contract defines as
+   * rendering ABOVE the card (the card itself has no description slot). Absent
+   * when the presenter supplied none, or when the window dropped the call side;
+   * a row then keeps its args-derived summary.
+   */
+  description: string | undefined
+}
+
+/**
+ * Resolve a terminal view's working directory the way the render-intent
+ * contract assigns to the UI bridge: an absolute path is used as-is, a relative
+ * one joins under the session workspace, and an omitted one IS the session
+ * workspace. A pure presenter cannot see the session cwd, which is why this
+ * resolution belongs here rather than in the tool. Without a session cwd there
+ * is nothing to resolve against, so a relative path stays as authored and an
+ * omitted one stays absent (the prompt row then draws a bare `$`).
+ * @param viewCwd - the cwd the terminal call view carries, if any.
+ * @param sessionCwd - the session workspace root, if the caller knows it.
+ * @returns the working directory for the prompt label, or undefined.
+ */
+function resolveTerminalCwd(viewCwd: string | undefined, sessionCwd: string | undefined): string | undefined {
+  if (viewCwd === undefined || viewCwd === '') return sessionCwd
+  if (sessionCwd === undefined || sessionCwd === '') return normalizeSegments(viewCwd)
+  return normalizeSegments(resolveToolPath(sessionCwd, viewCwd))
+}
+
+/**
+ * Collapse `.` and `..` segments so the prompt label names the directory the
+ * command actually ran in. The bash executor resolves the workdir before
+ * running, so a joined `/w/app/..` must display as `w`, not as `..`. Separators
+ * are preserved as authored (a Windows path keeps its backslashes) because this
+ * value is only ever displayed; a `..` that would climb past the root is
+ * dropped, which is what a filesystem does with it. A UNC path's `server` and
+ * `share` are part of its root, not poppable segments: Windows cannot climb
+ * above a share, so `\\\\server\\share` with a `..` stays there.
+ * @param path - a joined or absolute path, possibly carrying `.`/`..` segments.
+ * @returns the same path with those segments resolved.
+ */
+function normalizeSegments(path: string): string {
+  if (!/(?:^|[/\\])\.\.?(?:[/\\]|$)/.test(path)) return path
+  // A UNC path is `\\\\server\\share\\...`: the server and share form the root,
+  // so they are split off here and neither is a segment `..` may pop. Its
+  // separator is fixed to a backslash, since a joined relative part may have
+  // introduced a forward slash that UNC syntax does not use.
+  const unc = /^[/\\]{2}([^/\\]+)[/\\]+([^/\\]+)/.exec(path)
+  if (unc !== null) {
+    // Both groups are mandatory in the pattern, so destructuring types them as
+    // strings without an assertion.
+    const [matched, server, share] = unc
+    const root = `\\\\${String(server)}\\${String(share)}`
+    // Rooted: what follows the share hangs off it, so a `..` at the top is
+    // dropped rather than kept — Windows cannot climb above a share.
+    const rest = collapse(path.slice(matched.length), true)
+    return rest === '' ? root : `${root}\\${rest}`
+  }
+  const backslashed = path.includes('\\') && !path.includes('/')
+  const separator = backslashed ? '\\' : '/'
+  const rooted = /^[/\\]/.test(path)
+  const drive = /^[A-Za-z]:/.exec(path)?.[0] ?? ''
+  const body = collapse(path.slice(drive.length), rooted || drive !== '', separator)
+  const leading = rooted ? separator : ''
+  return drive === '' ? `${leading}${body}` : `${drive}${rooted ? leading : separator}${body}`
+}
+
+/**
+ * Collapse the `.`/`..` segments of a path body against a known root state.
+ * @param body - the path after any drive letter or UNC root.
+ * @param rooted - the body hangs off a root, so a `..` at its top is dropped
+ *   the way a filesystem drops one; without a root the `..` is kept, since it
+ *   stays meaningful against a cwd this function cannot see.
+ * @param separator - separator to rejoin with (default `/`).
+ * @returns the collapsed body, without leading or trailing separators.
+ */
+function collapse(body: string, rooted: boolean, separator = '/'): string {
+  const kept: string[] = []
+  for (const segment of body.split(/[/\\]/)) {
+    if (segment === '' || segment === '.') continue
+    if (segment === '..') {
+      if (kept.length > 0 && kept[kept.length - 1] !== '..') kept.pop()
+      else if (!rooted) kept.push(segment)
+      continue
+    }
+    kept.push(segment)
+  }
+  return kept.join(separator)
+}
+
+/**
+ * Derive the terminal-card props for a tool call, or null when this call is
+ * not a terminal card and belongs on the generic path.
+ *
+ * The call side supplies the command and its working directory; the result
+ * side supplies the captured output and exit status. Three cases produce
+ * null, all of them the documented generic-card default:
+ *
+ * - Neither side declares `card:'terminal'` — including a `card` value this
+ *   UI version does not know, which arrives over the wire and therefore
+ *   cannot be trusted to be one of the compiled variants.
+ * - A settled call whose result view is not a terminal card: the result
+ *   presentation decides how the settled call renders, and the bash tool
+ *   returns a generic fenced card for an execution error or a background
+ *   start, whose text and error styling the generic path preserves.
+ *
+ * Window truncation can drop the call head from a settled result (see
+ * `ToolResultNode.call`/`callView` in dsh-client-runtime), leaving a terminal
+ * result with no call side. That still renders: the command falls back to the
+ * result view's replacement title, then to an empty command (the prompt line
+ * draws bare), and the prompt shows no cwd.
+ * @param block - RunningToolCall or ToolResultNode off the snapshot caches.
+ * @param sessionCwd - the session workspace root, which resolves an omitted or
+ *   relative view cwd (see {@link resolveTerminalCwd}); absent leaves both unresolved.
+ * @returns the terminal-card props, or null for the generic path.
+ */
+export function terminalCardModel(block: ToolCallBlock, sessionCwd?: string): TerminalCardModel | null {
+  const call = block.callView?.card === 'terminal' ? block.callView : null
+  if (!('kind' in block)) {
+    // Running: the call view exists, the result view does not yet.
+    return call === null ? null : {
+      description: call.description,
+      card: {
+        command: call.title,
+        cwd: resolveTerminalCwd(call.cwd, sessionCwd),
+        output: undefined,
+        exitCode: undefined,
+        signal: undefined,
+        running: true,
+      },
+    }
+  }
+  const result = block.resultView?.card === 'terminal' ? block.resultView : null
+  if (result === null) return null
+  return {
+    description: call?.description,
+    card: {
+      // The result's title REPLACES the pending one when the tool supplies it
+      // (the presentation contract's replacement-title rule); the call title is
+      // what a result without one keeps.
+      command: result.title ?? call?.title ?? '',
+      // Only a PRESENT call view can mean "omitted the cwd, so use the
+      // workspace". When the window dropped the call head there is no cwd
+      // anywhere — the result view carries none — and the original call may
+      // well have used an explicit workdir, so the prompt draws a bare `$`
+      // rather than naming a directory this card cannot know.
+      cwd: call === null ? undefined : resolveTerminalCwd(call.cwd, sessionCwd),
+      output: result.output,
+      exitCode: result.exitCode,
+      signal: result.signal,
+      running: false,
+    },
+  }
+}

+ 4 - 2
packages/client/ui-conversation/src/client/contract/tool-call-model.ts

@@ -1,7 +1,9 @@
 /**
  * Pure row-model derivation for tool summary rows: variant classification,
- * one-line summary and expanded-body text from the frozen call slice. No
- * inline output ever — full results live in the details panel.
+ * one-line summary and expanded-body text from the frozen call slice. This
+ * derivation reads the call ARGUMENTS only; a call whose render intent is a
+ * terminal card gets its expanded body from the views instead, through
+ * `terminalCardModel` in terminal-card-model.ts.
  */
 // The block union's defining home is runtime (fold-product types); this
 // contract only forwards it (type-definition authority stays with the layer

+ 14 - 0
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.module.css

@@ -92,3 +92,17 @@
 .code[data-error] {
   color: var(--dsw-alias-state-error-primary);
 }
+
+/* Above the card, which is where the render-intent contract puts a terminal
+   call's description; the panel has no summary row to carry it. */
+.terminalDescription {
+  margin: 0 0 6px;
+  color: var(--dsw-alias-label-secondary);
+  font: var(--dsw-font-xs-13);
+}
+
+/* The terminal card sits directly under its section label, so it drops the
+   primitive's standalone vertical margin; the section owns the spacing. */
+.terminal {
+  margin: 0;
+}

+ 73 - 27
packages/client/ui-conversation/src/client/skeleton/DetailsPanel.tsx

@@ -1,47 +1,59 @@
 // DetailsPanel, P-I minimal form: close button + the selected call's args and
-// result rendered raw. The three-段 Switch / Prev-Next stepping / See-in-
-// trajectory are deferred (ledger). Reads the selection from the shared chat
+// result — args as JSON, the result raw except for a terminal-card call, whose
+// Output section is the command's terminal card. The three-段 Switch /
+// Prev-Next stepping / See-in-trajectory are deferred (ledger). Reads the
+// selection from the shared chat
 // store (conversation writes, this panel reads — the cross-registration
 // 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 { CodeBlock, TerminalBlock } 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 { ConversationSnapshot, RunningToolCall, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client'
 import type { DetailsSlotProps } from '../contract/slots.ts'
+import { terminalCardModel } from '../contract/terminal-card-model.ts'
+import type { ToolCallBlock } from '../contract/tool-call-model.ts'
 import css from './DetailsPanel.module.css'
 
 /** Full props composed by reference from the contract (automatic shares & injected share). */
 export type DetailsPanelProps = DetailsSlotProps
 
-/** Selected call material: resolved result node, or the in-flight running call's args. */
+/**
+ * Selected call material: the call's display name and args plus the frozen
+ * block slice it came from. `block` is a snapshot-cached reference, so the
+ * wrapper stays shallow-equal across unrelated snapshot frames; the settled /
+ * running split is read off it with the `'kind' in block` discrimination
+ * instead of duplicated as flags.
+ */
 interface CallMaterial {
   name: string
   argsRaw: string | null
-  result: ToolResultNode | null
-  running: boolean
+  block: ToolCallBlock
+}
+
+/** Material of a settled result node (native call or run_code sub-dispatch). */
+function settledMaterial(node: ToolResultNode, callId: string): CallMaterial {
+  return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, block: node }
+}
+
+/** Material of an in-flight call (native call or run_code sub-dispatch). */
+function runningMaterial(call: RunningToolCall): CallMaterial {
+  return { name: call.name, argsRaw: call.argsRaw, block: call }
 }
 
 function materialFor(s: ConversationSnapshot, callId: string): CallMaterial | null {
   for (const node of s.nodes) {
-    if (node.kind === 'tool-result' && node.callId === callId) {
-      return { name: node.call?.name ?? callId, argsRaw: node.call?.argsRaw ?? null, result: node, running: false }
-    }
+    if (node.kind === 'tool-result' && node.callId === callId) return settledMaterial(node, callId)
   }
   const open = s.runningCalls.find(c => c.callId === callId)
-  if (open !== undefined) {
-    return { name: open.name, argsRaw: open.argsRaw, result: null, running: true }
-  }
+  if (open !== undefined) return runningMaterial(open)
   // run_code sub-dispatches: the native call-block shapes, so a selected
   // sub-row resolves through the same material as a native call — the
   // settled ToolResultNode form, or the RunningToolCall form mid-flight.
   for (const subs of s.codeDispatches.values()) {
     for (const sub of subs) {
       if (sub.callId !== callId) continue
-      if ('kind' in sub) {
-        return { name: sub.call?.name ?? callId, argsRaw: sub.call?.argsRaw ?? null, result: sub, running: false }
-      }
-      return { name: sub.name, argsRaw: sub.argsRaw, result: null, running: true }
+      return 'kind' in sub ? settledMaterial(sub, callId) : runningMaterial(sub)
     }
   }
   return null
@@ -56,8 +68,11 @@ function pretty(raw: string): string {
   }
 }
 
-export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPanelProps) {
+export function DetailsPanel({ useSession, useSessions, sessionId, useStore, closeDetails }: DetailsPanelProps) {
   const selection = useStore(s => s.selection)
+  // Session workspace root: an omitted or relative terminal cwd resolves
+  // against it, which the pure presenter cannot see.
+  const sessionCwd = useSessions(list => list.byId[sessionId]?.cwd)
   const callId = selection?.callId
   // materialFor builds a fresh wrapper; shallowEqual short-circuits on its
   // stable members (result node reference rides the snapshot's structural sharing).
@@ -95,15 +110,11 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
                 )}
                 <section className={css.section}>
                   <div className={css.sectionLabel}>Output</div>
-                  {/* materialFor invariant: result===null ⇔ running (a settled
-                        material always carries its result node). */}
-                  {material.result === null
-                    ? <div className={css.empty}>运行中…</div>
-                    : (
-                      <pre className={css.code} data-error={material.result.isError || undefined}>
-                        {renderResult(material.result)}
-                      </pre>
-                    )}
+                  {/* Keyed by the selected call: the body owns per-call view
+                      state (the terminal card's expand and copy), which React
+                      would otherwise carry into the next selection because the
+                      panel does not unmount between calls. */}
+                  <OutputBody key={callId} material={material} cwd={sessionCwd} />
                 </section>
               </>
             )}
@@ -112,6 +123,41 @@ export function DetailsPanel({ useSession, useStore, closeDetails }: DetailsPane
   )
 }
 
+/**
+ * The Output section's body for the selected call. A terminal-card call — a
+ * shell command's call/result views — renders through the shared TerminalBlock
+ * at the primitive's own full height allowance, so column-aligned output keeps
+ * its alignment and scrolls sideways instead of folding. Every other call, and
+ * a running call with no terminal card yet, keeps the flattened text form.
+ * @param props.material - the selected call's material from {@link materialFor}.
+ * @param props.cwd - the session workspace root, resolving the terminal view's cwd.
+ * @returns the Output section's body element.
+ */
+function OutputBody({ material, cwd }: { material: CallMaterial; cwd: string | undefined }) {
+  const terminal = terminalCardModel(material.block, cwd)
+  if (terminal !== null) {
+    // The contract renders the presenter's description above the card, and the
+    // panel has no summary row to carry it, so it is drawn here.
+    return (
+      <>
+        {terminal.description !== undefined && (
+          <div className={css.terminalDescription}>{terminal.description}</div>
+        )}
+        <TerminalBlock {...terminal.card} className={css.terminal} />
+      </>
+    )
+  }
+  // A settled call always carries the result node the flattened form needs;
+  // the running shape has no result to flatten.
+  if (!('kind' in material.block)) return <div className={css.empty}>运行中…</div>
+  const result = material.block
+  return (
+    <pre className={css.code} data-error={result.isError || undefined}>
+      {renderResult(result)}
+    </pre>
+  )
+}
+
 /** Flatten result content blocks to display text (text blocks verbatim, others as JSON). */
 function renderResult(node: ToolResultNode): string {
   const parts: string[] = []

+ 15 - 1
packages/client/ui-conversation/src/client/toolviews/bash-sample.module.css

@@ -1,4 +1,18 @@
-/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description). */
+/* Bash toolview: same geometry/tokens as ToolRow (figma Bash · description),
+   plus the terminal card the row stacks under its summary line. */
+
+/* Summary line over the terminal card; the summary row keeps its own 24px
+   height, so the card is a column around it rather than a change to it. */
+.card {
+  display: flex;
+  flex-direction: column;
+}
+
+/* Row indentation matches ToolRow's expanded bodies (16px leading + 6px gap),
+   and replaces the primitive's standalone vertical margin with the flow's. */
+.terminal {
+  margin: 4px 0 4px 22px;
+}
 
 .root {
   position: relative; /* sweep-glare overlay anchor */

+ 40 - 14
packages/client/ui-conversation/src/client/toolviews/bash-sample.tsx

@@ -3,10 +3,20 @@
 // Product chrome matches ToolRow / Think (figma: Bash · {description}).
 // Child sessions keep a scoped badge so session-dimension differentiation stays
 // observable inside the component (no parallel registry).
+//
+// A bash call declares the terminal render intent, so this row also renders
+// the command's own output through TerminalBlock. This row has no expand
+// control and is not a details-panel target either (tool rows stopped being
+// one), so its terminal body is resident rather than expand-gated as in
+// ToolRow, and the card's own copy and expand controls are the row's only
+// interactions. CHAT_TERMINAL_MAX_LINES is passed as `maxLines` — the chat
+// flow's tighter cap over the block's own default of 16 — and the block's
+// internal expander keeps a long output from taking over the message flow.
 
 import type { Context } from 'cordis'
-import { IconApiOutline14, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
+import { IconApiOutline14, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { ToolRowProps } from '../contract/slots.ts'
+import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../contract/terminal-card-model.ts'
 import { toolRowModel, type ToolRowState } from '../contract/tool-call-model.ts'
 import css from './bash-sample.module.css'
 
@@ -29,24 +39,40 @@ function stateStatus(state: ToolRowState): string | null {
   }
 }
 
-/** Bash row: icon + Bash · {description}, matching the shared ToolRow chrome. */
+/**
+ * Bash row: icon + Bash · {description} in the shared ToolRow chrome, with the
+ * command's terminal card resident below it. The summary row is not a
+ * details-panel control (tool rows stopped being one), so the card's copy and
+ * expand controls are the row's only interactions.
+ */
 export function BashRow({ toolName, block, sessionId, useSessions }: ToolRowProps) {
   const model = toolRowModel(toolName, block)
+  // Session workspace root: the terminal view's cwd resolves against it (an
+  // omitted workdir IS the workspace), which the pure presenter cannot do.
+  const cwd = useSessions(list => list.byId[sessionId]?.cwd)
+  const terminal = terminalCardModel(block, cwd)
   const isChild = useSessions(list => list.byId[sessionId]?.parentId !== undefined)
   const status = stateStatus(model.state)
   return (
-    <div
-      className={css.root}
-      data-sample={isChild ? 'bash-scoped' : 'bash-global'}
-      data-variant="bash"
-      data-state={model.state}
-    >
-      <span className={css.leading}>{leadingFor(model.state)}</span>
-      {status !== null && <span className={css.visuallyHidden}>{status}</span>}
-      {isChild && <span className={css.scopeBadge}>scoped</span>}
-      <span className={css.title}>{model.title}</span>
-      <span className={css.sep} aria-hidden />
-      <span className={css.summary}>{model.summary}</span>
+    <div className={css.card}>
+      <div
+        className={css.root}
+        data-sample={isChild ? 'bash-scoped' : 'bash-global'}
+        data-variant="bash"
+        data-state={model.state}
+      >
+        <span className={css.leading}>{leadingFor(model.state)}</span>
+        {status !== null && <span className={css.visuallyHidden}>{status}</span>}
+        {isChild && <span className={css.scopeBadge}>scoped</span>}
+        <span className={css.title}>{model.title}</span>
+        <span className={css.sep} aria-hidden />
+        {/* The terminal presenter's description is the contractual
+            above-card summary; it outranks the args-derived one. */}
+        <span className={css.summary}>{terminal?.description ?? model.summary}</span>
+      </div>
+      {terminal !== null && (
+        <TerminalBlock {...terminal.card} maxLines={CHAT_TERMINAL_MAX_LINES} className={css.terminal} />
+      )}
     </div>
   )
 }

+ 21 - 0
packages/client/ui-conversation/tests/chat-tool-row.spec.tsx

@@ -97,6 +97,11 @@ describe('tool-call-model', () => {
     expect(toolRowModel('bash', result({ call: null })).body).toBeNull()
   })
 
+  it('a code row with an empty program falls back to the args JSON envelope', () => {
+    expect(toolRowModel('run_code', running({ name: 'run_code', argsRaw: '{"code":""}' })).body)
+      .toBe('{\n  "code": ""\n}')
+  })
+
   it('gives Cordis lifecycle tools action titles over their generic variants', () => {
     expect(toolRowModel('cordis_inspect', running({
       name: 'cordis_inspect',
@@ -165,6 +170,22 @@ describe('ToolRow', () => {
     expect(view.queryByTestId('tool-icon')).not.toBeNull()
   })
 
+  it('an expandOnRowClick row toggles from Enter and Space, ignoring other keys', () => {
+    const view = render(<ToolRow {...rowProps} expandOnRowClick />)
+    const row = view.getByRole('button')
+    fireEvent.keyDown(row, { key: 'Tab' })
+    expect(row.getAttribute('aria-expanded')).toBe('false')
+    fireEvent.keyDown(row, { key: 'Enter' })
+    expect(row.getAttribute('aria-expanded')).toBe('true')
+    fireEvent.keyDown(row, { key: ' ' })
+    expect(row.getAttribute('aria-expanded')).toBe('false')
+  })
+
+  it('a non-expandable expandOnRowClick row exposes no row button', () => {
+    const view = render(<ToolRow {...rowProps} body={null} expandOnRowClick />)
+    expect(view.queryByRole('button')).toBeNull()
+  })
+
   it('file-path summary opens through onOpenFile; the leading slot is not an expand control', () => {
     const open = vi.fn()
     const view = render(

+ 600 - 0
packages/client/ui-conversation/tests/terminal-card.spec.tsx

@@ -0,0 +1,600 @@
+// @vitest-environment jsdom
+// The terminal render intent on the web side: the pure terminalCardModel
+// derivation over callView/resultView, and both conversation render sites that
+// consume it — the chat tool row's expanded body (GenericToolCard / BashRow)
+// and the details panel's Output section.
+
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import { cleanup, fireEvent, render } from '@testing-library/react'
+import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
+import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type {
+  ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState,
+} from '@deepseek-ai/dsh-client-runtime/client'
+import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-client-connection/client'
+import type { SelectionTarget, ToolRowOwnerProps, ToolRowProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { CHAT_TERMINAL_MAX_LINES, terminalCardModel } from '../src/client/contract/terminal-card-model.ts'
+import { createChatStore } from '../src/client/stores.ts'
+import { GenericToolCard } from '../src/client/chat/GenericToolCard.tsx'
+import { DetailsPanel } from '../src/client/skeleton/DetailsPanel.tsx'
+import { BashRow } from '../src/client/toolviews/bash-sample.tsx'
+
+afterEach(cleanup)
+
+/**
+ * Match an output line with its interior whitespace intact: the column
+ * alignment this card exists to preserve is exactly what the default
+ * whitespace-collapsing matcher would hide.
+ */
+const RAW = { normalizer: (text: string) => text }
+
+/** The rendered card's run-state dot state, so a render site cannot silently drop it. */
+function runStateOf(container: HTMLElement): string | null {
+  return container.querySelector('[data-terminal] [data-state]')?.getAttribute('data-state') ?? null
+}
+
+const SID = 's1' as SessionId
+
+const ARGS = '{"command":"ls -la","description":"List files"}'
+
+/** The bash tool's own call view for a foreground command. */
+const callTerminal = (over?: Partial<Extract<ToolCallView, { card: 'terminal' }>>): ToolCallView => ({
+  card: 'terminal', title: 'ls -la', description: 'List files', ...over,
+})
+
+/** The bash tool's own result view for a settled foreground command. */
+const resultTerminal = (over?: Partial<Extract<ToolResultView, { card: 'terminal' }>>): ToolResultView => ({
+  card: 'terminal', output: 'a.ts  b.ts\nc.ts  d.ts\n', exitCode: 0, ...over,
+})
+
+const running = (over?: Partial<RunningToolCall>): RunningToolCall => ({
+  callId: 'c1', name: 'bash', argsRaw: ARGS,
+  turn: 1, step: 1, time: 1_000, callView: callTerminal(), ...over,
+})
+
+const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
+  kind: 'tool-result', seq: 10, time: 2_000, callId: 'c1',
+  call: { name: 'bash', argsRaw: ARGS },
+  callTime: 1_000,
+  content: [{ type: 'text', text: 'a.ts  b.ts\nc.ts  d.ts\n' }], isError: false,
+  callView: callTerminal(), resultView: resultTerminal(), ...over,
+})
+
+describe('terminalCardModel', () => {
+  it('derives a running card from the call view alone', () => {
+    expect(terminalCardModel(running({ callView: callTerminal({ cwd: '/projects/app' }) }))).toEqual({
+      description: 'List files',
+      card: {
+        command: 'ls -la', cwd: '/projects/app', output: undefined,
+        exitCode: undefined, signal: undefined, running: true,
+      },
+    })
+  })
+
+  it('derives a settled card from both sides, carrying the exit status', () => {
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '/projects/app' }),
+      resultView: resultTerminal({ output: 'boom\n', exitCode: 2 }),
+    }))).toEqual({
+      description: 'List files',
+      card: {
+        command: 'ls -la', cwd: '/projects/app', output: 'boom\n',
+        exitCode: 2, signal: undefined, running: false,
+      },
+    })
+    expect(terminalCardModel(settled({
+      resultView: { card: 'terminal', output: '', signal: 'SIGTERM' },
+    }))?.card.signal).toBe('SIGTERM')
+  })
+
+  it('takes the result view\'s replacement title over the pending one', () => {
+    // The presentation contract defines a result title as REPLACING the pending
+    // title, so a tool that rewrites it at settle time must win here.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ title: 'pnpm run check' }),
+      resultView: resultTerminal({ title: 'pnpm run check --filter web' }),
+    }))?.card.command).toBe('pnpm run check --filter web')
+    // Without one, the call's title is what the card keeps.
+    expect(terminalCardModel(settled())?.card.command).toBe('ls -la')
+  })
+
+  it('resolves the cwd against the session workspace the way the bridge must', () => {
+    // Omitted workdir — the common bash call — IS the session workspace.
+    expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
+    // A relative workdir joins under it.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: 'packages/ui' }),
+    }), '/w/app')?.card.cwd).toBe('/w/app/packages/ui')
+    // An absolute one is used as-is.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '/srv/other' }),
+    }), '/w/app')?.card.cwd).toBe('/srv/other')
+    // With no session cwd there is nothing to resolve against: a relative path
+    // stays as authored and an omitted one stays absent (a bare `$` prompt).
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: 'packages/ui' }),
+    }))?.card.cwd).toBe('packages/ui')
+    expect(terminalCardModel(settled())?.card.cwd).toBeUndefined()
+    // The running arm resolves identically.
+    expect(terminalCardModel(running(), '/w/app')?.card.cwd).toBe('/w/app')
+  })
+
+  it('normalizes a relative workdir so the label names the directory actually used', () => {
+    // The bash executor resolves the workdir before running, so `..` against
+    // /w/app runs in /w — the card must say `w`, not `..`.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '/w/app')?.card.cwd).toBe('/w')
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '.' }),
+    }), '/w/app')?.card.cwd).toBe('/w/app')
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../sibling' }),
+    }), '/w/app')?.card.cwd).toBe('/w/sibling')
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: './nested/../other' }),
+    }), '/w/app')?.card.cwd).toBe('/w/app/other')
+    // A `..` that would climb past the root is dropped, as a filesystem does.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../../..' }),
+    }), '/w')?.card.cwd).toBe('/')
+    // An absolute path carrying segments normalizes too.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '/srv/./app/../other' }),
+    }), '/w/app')?.card.cwd).toBe('/srv/other')
+    // A Windows path keeps its separators.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: 'C:\\ws\\app\\..' }),
+    }), '/w')?.card.cwd).toBe('C:\\ws')
+    // Without a session cwd a relative `..` has nothing to resolve against, so
+    // it survives as authored rather than being silently dropped.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../elsewhere' }),
+    }))?.card.cwd).toBe('../elsewhere')
+  })
+
+  it('keeps a UNC server and share as an unpoppable root', () => {
+    // Windows cannot climb above a share, so `..` from the share root stays put.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '\\\\server\\share')?.card.cwd).toBe('\\\\server\\share')
+    // Below the share it pops normally, keeping the UNC separators.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '..' }),
+    }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+    // Several `..` cannot escape the root either.
+    expect(terminalCardModel(settled({
+      callView: callTerminal({ cwd: '../../..' }),
+    }), '\\\\server\\share\\app')?.card.cwd).toBe('\\\\server\\share')
+  })
+
+  it('draws a bare $ when the window dropped the call head, rather than guessing', () => {
+    // A truncated call carries no cwd anywhere: the result view has none, and
+    // the original call may have used an explicit workdir. Falling back to the
+    // session workspace here would name a directory the card cannot know.
+    expect(terminalCardModel(settled({
+      call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }),
+    }), '/w/app')?.card.cwd).toBeUndefined()
+    // A present call view that omits its cwd still means the workspace.
+    expect(terminalCardModel(settled(), '/w/app')?.card.cwd).toBe('/w/app')
+  })
+
+  it('carries the call view\'s description, which the contract renders above the card', () => {
+    expect(terminalCardModel(settled())?.description).toBe('List files')
+    expect(terminalCardModel(running())?.description).toBe('List files')
+    // A presenter that supplies none, and a window-truncated call side, both
+    // leave it absent so the row keeps its args-derived summary.
+    expect(terminalCardModel(settled({
+      callView: { card: 'terminal', title: 'ls' },
+    }))?.description).toBeUndefined()
+    expect(terminalCardModel(settled({ call: null, callView: null }))?.description).toBeUndefined()
+  })
+
+  it('a window-truncated call side falls back to the result title, then to an empty command', () => {
+    // Truncation drops both the call head and its view (conversation.ts).
+    const truncated = { call: null, callView: null }
+    expect(terminalCardModel(settled({
+      ...truncated, resultView: resultTerminal({ title: 'ls -la' }),
+    }))?.card).toMatchObject({ command: 'ls -la', cwd: undefined, running: false })
+    expect(terminalCardModel(settled(truncated))?.card).toMatchObject({ command: '', cwd: undefined })
+  })
+
+  it('returns null for every non-terminal call: no views, generic views, unknown cards', () => {
+    expect(terminalCardModel(running({ callView: null }))).toBeNull()
+    expect(terminalCardModel(settled({ callView: null, resultView: null }))).toBeNull()
+    expect(terminalCardModel(running({ callView: { card: 'generic', title: 'read x' } }))).toBeNull()
+    // A generic result settles a terminal call as a generic card (the bash
+    // tool's own execution-error and background paths).
+    expect(terminalCardModel(settled({ resultView: { card: 'generic' } }))).toBeNull()
+    // A card tag this UI version does not know arrives over the wire; the
+    // documented generic-card default takes it, not a crash.
+    const future = { card: 'chart', title: 'plot' } as unknown as ToolCallView
+    expect(terminalCardModel(running({ callView: future }))).toBeNull()
+    expect(terminalCardModel(settled({
+      callView: future, resultView: { card: 'chart' } as unknown as ToolResultView,
+    }))).toBeNull()
+  })
+})
+
+describe('chat row terminal body', () => {
+  const ownerProps = (block: RunningToolCall | ToolResultNode): ToolRowOwnerProps => ({
+    callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
+  })
+
+  it('the expanded body is the command output, capped tighter than the panel', () => {
+    expect(CHAT_TERMINAL_MAX_LINES).toBeLessThan(16)
+    const view = render(<GenericToolCard {...ownerProps(settled())} />)
+    // Collapsed: the one-line summary row only, no output.
+    expect(view.getByText('List files')).toBeTruthy()
+    expect(view.queryByText(/a\.ts/)).toBeNull()
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+    expect(view.getByText('ls -la')).toBeTruthy()
+    // The args JSON body the generic path would have shown is gone.
+    expect(view.queryByText(/"command"/)).toBeNull()
+  })
+
+  it('the cap collapses a long output inside the row, expandable in place', () => {
+    const lines = Array.from({ length: CHAT_TERMINAL_MAX_LINES + 3 }, (_, i) => `line-${i}`)
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      resultView: resultTerminal({ output: `${lines.join('\n')}\n` }),
+    }))} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText('… 其余 3 行')).toBeTruthy()
+    expect(view.queryByText('line-5')).toBeNull()
+    fireEvent.click(view.getByRole('button', { name: '展开其余 3 行输出' }))
+    expect(view.getByText('line-5')).toBeTruthy()
+  })
+
+  it('renders a multi-line command as one prompt row per line', () => {
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: callTerminal({ title: 'ls -la\necho done' }),
+    }))} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
+    expect([...rows].map(row => row.textContent)).toEqual(['$ls -la', '$echo done'])
+    // Still one dot for the call, on the first row.
+    expect(view.container.querySelectorAll('[data-terminal] [data-state]')).toHaveLength(1)
+  })
+
+  it('the fallback row shows the presenter description, not the args summary', () => {
+    // Any terminal-declaring tool without its own keyed row lands here, so the
+    // contract's above-card description has to win at this render site as well.
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: callTerminal({ description: 'Terminal 3' }),
+    }))} />)
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+  })
+
+  it('keeps the presenter description visible once the terminal card is expanded', () => {
+    // The contract puts the description ABOVE the card. The collapsed summary is
+    // hidden while a row is open, so an expanded terminal row has to draw it
+    // itself or the description would only ever be visible collapsed.
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: callTerminal({ description: 'Terminal 3' }),
+    }))} />)
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.container.querySelector('[data-terminal]')).not.toBeNull()
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+  })
+
+  it('a running terminal call expands to the prompt line with no output yet', () => {
+    const view = render(<GenericToolCard {...ownerProps(running())} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(view.queryByText('复制')).toBeNull()
+    // The card states its own run state: a running command reads as running
+    // even though it has no output yet to distinguish it from an empty settle.
+    expect(runStateOf(view.container)).toBe('ongoing')
+  })
+
+  it('a non-terminal call keeps the args-JSON text body', () => {
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      callView: null, resultView: null,
+    }))} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText(/"command"/)).toBeTruthy()
+  })
+
+  it('a terminal call with no args still expands, through its terminal body alone', () => {
+    // Empty args make the text body null; the terminal material carries the row.
+    const view = render(<GenericToolCard {...ownerProps(settled({
+      call: { name: 'bash', argsRaw: '' },
+    }))} />)
+    fireEvent.click(view.container.querySelector('button')!)
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+  })
+})
+
+describe('BashRow terminal card', () => {
+  const list = () => createSnapshotStore<SessionListState>({
+    ids: [SID],
+    byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0 } },
+    current: undefined,
+    phase: 'ready',
+  })
+
+  const rowProps = (block: RunningToolCall | ToolResultNode): ToolRowProps => ({
+    callId: 'c1', toolName: 'bash', block, openFile: vi.fn(),
+    sessionId: SID, useSessions: bindSnapshotSelector(list()),
+  } as unknown as ToolRowProps)
+
+  it('renders the command output under the summary row, without an expand gesture', () => {
+    const view = render(<BashRow {...rowProps(settled())} />)
+    expect(view.getByText('List files')).toBeTruthy()
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+    // The card's controls are the row's only interactions: a bash row is not a
+    // path link and no longer a details-panel target, so nothing here navigates.
+    expect(view.container.querySelector('[data-clickable]')).toBeNull()
+    expect(view.getByText('复制')).toBeTruthy()
+  })
+
+  // The row's leading StateDot and the card's run-state dot describe the same
+  // command, so a running row whose card claimed 'done' would be a contradiction
+  // the reader sees on one line.
+  it('agrees with the summary row about the run state', () => {
+    const runningView = render(<BashRow {...rowProps(running())} />)
+    expect(runningView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('running')
+    expect(runStateOf(runningView.container)).toBe('ongoing')
+    cleanup()
+    const settledView = render(<BashRow {...rowProps(settled())} />)
+    expect(settledView.container.querySelector('[data-variant="bash"]')?.getAttribute('data-state')).toBe('ok')
+    expect(runStateOf(settledView.container)).toBe('done')
+  })
+
+  it('shows the terminal presenter\'s description instead of the args summary', () => {
+    // `terminal_send`-style presenters author a description the args do not
+    // repeat; the contract puts it above the card, which is this row's summary.
+    const view = render(<BashRow {...rowProps(settled({
+      callView: callTerminal({ description: 'Terminal 3' }),
+    }))} />)
+    expect(view.getByText('Terminal 3')).toBeTruthy()
+    expect(view.queryByText('List files')).toBeNull()
+  })
+
+  it('keeps the args-derived summary when the presenter authored no description', () => {
+    const view = render(<BashRow {...rowProps(settled({
+      callView: { card: 'terminal', title: 'ls -la' },
+    }))} />)
+    expect(view.getByText('List files')).toBeTruthy()
+  })
+
+  it('a non-terminal bash call (background start) renders the summary row alone', () => {
+    const view = render(<BashRow {...rowProps(settled({
+      callView: { card: 'generic', title: 'sleep 30', kind: 'execute' },
+      resultView: { card: 'generic' },
+    }))} />)
+    expect(view.getByText('List files')).toBeTruthy()
+    expect(view.queryByText(/a\.ts/)).toBeNull()
+  })
+})
+
+describe('DetailsPanel Output section', () => {
+  function mount(snapshot: ConversationSnapshot, selection: SelectionTarget | null, cwd?: string) {
+    localStorage.clear()
+    const chat = createChatStore().create()
+    if (selection !== null) chat.actions.select(selection)
+    const sessions = createSnapshotStore<SessionListState>(cwd === undefined
+      ? { ids: [], byId: {}, current: undefined, phase: 'ready' }
+      : {
+        ids: [SID],
+        byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, waitingApproval: false, updatedAt: 0, cwd } },
+        current: SID,
+        phase: 'ready',
+      })
+    const workspaces = createSnapshotStore<WorkspaceListState>({
+      items: [], state: 'idle', phase: 'ready', error: null,
+      baselinesReady: true, recentWorkspaceId: undefined,
+    })
+    return render(
+      <DetailsPanel
+        sessionId={SID}
+        useSession={bindSnapshotSelector({ getSnapshot: () => snapshot, subscribe: () => () => {} })}
+        useSessions={bindSnapshotSelector(sessions)}
+        useWorkspaces={bindSnapshotSelector(workspaces)}
+        useInput={(() => { throw new Error('unused') })}
+        inputActions={{ setDraft: () => {}, submit: () => {} }}
+        useProjection={(() => undefined)}
+        useStore={bindSnapshotSelector(chat)}
+        actions={chat.actions}
+        closeDetails={vi.fn()}
+      />,
+    )
+  }
+
+  function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
+    return {
+      sessionId: SID, nodes: [], foldDegraded: false, partial: null, runningCalls: [], codeDispatches: new Map(),
+      pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
+      openState: 'open', openError: null, hasMore: false, loadingOlder: false,
+      promptError: null, blank: false, lastAgentError: null, ...over,
+    }
+  }
+
+  const target: SelectionTarget = { turnSeq: 10, callId: 'c1', toolName: 'bash' }
+
+  // The panel never unmounts between selections, so per-call view state has to
+  // be keyed off the selected call or it leaks into the next one.
+  it('resets the card\'s expand state when the selected call changes', () => {
+    const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
+    const view = mount(snapshot({
+      nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
+    }), target)
+    fireEvent.click(view.getByRole('button', { name: '展开其余 4 行输出' }))
+    expect(view.getByRole('button', { name: '收起输出' })).toBeTruthy()
+    // A second call, selected without unmounting the panel, starts collapsed.
+    cleanup()
+    const second = mount(snapshot({
+      nodes: [settled({
+        callId: 'c2', resultView: resultTerminal({ output: `${long.join('\n')}\n` }),
+      })],
+    }), { turnSeq: 10, callId: 'c2', toolName: 'bash' })
+    expect(second.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
+  })
+
+  it('renders the presenter description above the card', () => {
+    const view = mount(snapshot({
+      nodes: [settled({ callView: callTerminal({ description: 'Terminal 3' }) })],
+    }), target)
+    const description = view.getByText('Terminal 3')
+    const card = view.container.querySelector('[data-terminal]')
+    expect(card).not.toBeNull()
+    // Above, not below: document order is what places it as the card's heading.
+    expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
+  })
+
+  it('resolves the prompt cwd against the session workspace', () => {
+    const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
+    // No workdir in the call view: the prompt label is the workspace basename.
+    expect(view.getByText('app')).toBeTruthy()
+  })
+
+  it('renders the terminal card at full height, keeping the JSON Input section', () => {
+    const long = Array.from({ length: 20 }, (_, i) => `row-${i}`)
+    const view = mount(snapshot({
+      nodes: [settled({ resultView: resultTerminal({ output: `${long.join('\n')}\n` }) })],
+    }), target)
+    expect(view.getByText(/"command"/)).toBeTruthy()
+    expect(view.getByText('ls -la')).toBeTruthy()
+    // The panel takes the primitive's own default cap (16), not the row's.
+    expect(view.getByText(`… 其余 ${20 - 16} 行`)).toBeTruthy()
+    expect(view.getByText('row-0')).toBeTruthy()
+  })
+
+  it('a running terminal call shows the prompt line, not the 运行中… placeholder', () => {
+    const view = mount(snapshot({ runningCalls: [running()] }), target)
+    expect(view.getByText('ls -la')).toBeTruthy()
+    expect(view.queryByText('运行中…')).toBeNull()
+    expect(runStateOf(view.container)).toBe('ongoing')
+  })
+
+  it('a running non-terminal call keeps the 运行中… placeholder', () => {
+    const view = mount(snapshot({ runningCalls: [running({ callView: null })] }), target)
+    expect(view.getByText('运行中…')).toBeTruthy()
+  })
+
+  it('a non-terminal result keeps the flattened pre with its error styling', () => {
+    const view = mount(snapshot({
+      nodes: [settled({
+        callView: null, resultView: null, isError: true,
+        content: [{ type: 'text', text: 'permission denied' }],
+      })],
+    }), target)
+    const pre = view.container.querySelector('pre[data-error]')
+    expect(pre?.textContent).toBe('permission denied')
+  })
+
+  // The panel resolves a sub-dispatch through the same material as a native
+  // call, so a sub-call that DID carry terminal views would render the card.
+  // The shipped wire cannot produce that yet: `session.ts` folds
+  // `tool/code-dispatch(-start)` with `callView: null`/`resultView: null`, and
+  // the host's `viewFor` only presents top-level `tool/call`/`tool/result`. This
+  // pins the resolution path with views injected directly, and the arm below
+  // pins what the shipped path actually shows today.
+  it('a run_code sub-dispatch resolves to its own terminal card once views reach it', () => {
+    const view = mount(snapshot({
+      codeDispatches: new Map([['p1', [settled({ callId: 'c1' })]]]),
+    }), target)
+    expect(view.getByText('a.ts  b.ts', RAW)).toBeTruthy()
+  })
+
+  it('a sub-dispatch as the wire actually delivers it (no views) keeps the flattened form', () => {
+    const view = mount(snapshot({
+      codeDispatches: new Map([['p1', [settled({ callId: 'c1', callView: null, resultView: null })]]]),
+    }), target)
+    // No terminal card: the generic path renders the result text in the Output
+    // section's <pre> (the Input section has its own, hence the scoping).
+    expect(view.container.querySelector('[data-terminal]')).toBeNull()
+    const output = view.getByText('Output').closest('section')
+    expect(output?.querySelector('pre')?.textContent).toContain('a.ts  b.ts')
+  })
+
+  it('a running run_code sub-dispatch resolves through the running material', () => {
+    const view = mount(snapshot({
+      // The leading non-matching sub-call exercises the scan's skip.
+      codeDispatches: new Map([['p1', [running({ callId: 'other' }), running()]]]),
+    }), target)
+    expect(view.getByText('ls -la')).toBeTruthy()
+  })
+
+  it('a window-truncated call head titles the panel by callId and drops the Input section', () => {
+    const view = mount(snapshot({
+      nodes: [settled({ call: null, callView: null, resultView: resultTerminal({ title: 'ls -la' }) })],
+    }), target)
+    expect(view.getByText('c1')).toBeTruthy()
+    expect(view.queryByText('Input')).toBeNull()
+    expect(view.getByText('Output')).toBeTruthy()
+  })
+
+  it('scans past other nodes and other calls before reporting the call out of window', () => {
+    const view = mount(snapshot({
+      nodes: [
+        { kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, blocks: [] },
+        settled({ callId: 'elsewhere' }),
+      ],
+      runningCalls: [running({ callId: 'also-elsewhere' })],
+    }), target)
+    expect(view.getByText('该调用不在当前窗口内')).toBeTruthy()
+  })
+
+  it('no selection at all renders the guidance line and the default title', () => {
+    const view = mount(snapshot(), null)
+    expect(view.getByText('详情')).toBeTruthy()
+    expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
+  })
+
+  it('a step selection without a callId renders the guidance line too', () => {
+    const view = mount(snapshot(), { turnSeq: 3, stepSeq: 1 })
+    expect(view.getByText('点击消息流中的工具行查看详情')).toBeTruthy()
+  })
+
+  it('the close button reaches closeDetails', () => {
+    localStorage.clear()
+    const chat = createChatStore().create()
+    const closeDetails = vi.fn()
+    const snap = snapshot()
+    const view = render(
+      <DetailsPanel
+        sessionId={SID}
+        useSession={bindSnapshotSelector({ getSnapshot: () => snap, subscribe: () => () => {} })}
+        useSessions={bindSnapshotSelector(createSnapshotStore<SessionListState>(
+          { ids: [], byId: {}, current: undefined, phase: 'ready' }))}
+        useWorkspaces={bindSnapshotSelector(createSnapshotStore<WorkspaceListState>({
+          items: [], state: 'idle', phase: 'ready', error: null,
+          baselinesReady: true, recentWorkspaceId: undefined,
+        }))}
+        useInput={(() => { throw new Error('unused') })}
+        inputActions={{ setDraft: () => {}, submit: () => {} }}
+        useProjection={(() => undefined)}
+        useStore={bindSnapshotSelector(chat)}
+        actions={chat.actions}
+        closeDetails={closeDetails}
+      />,
+    )
+    fireEvent.click(view.getByRole('button', { name: '关闭详情' }))
+    expect(closeDetails).toHaveBeenCalledTimes(1)
+  })
+
+  it('a non-text result block renders as JSON, and an empty result falls back to its error', () => {
+    const nonText = mount(snapshot({
+      nodes: [settled({
+        callView: null, resultView: null,
+        content: [{ type: 'reasoning', text: 'why' }],
+      })],
+    }), target)
+    // Scope to the Output section: the Input section's CodeBlock renders a
+    // <pre> of its own, and it comes first in document order.
+    expect(nonText.getByText('Output').closest('section')?.querySelector('pre')?.textContent)
+      .toBe('{\n  "type": "reasoning",\n  "text": "why"\n}')
+    cleanup()
+    const empty = mount(snapshot({
+      nodes: [settled({
+        callView: null, resultView: null, content: [], isError: true,
+        error: { name: 'ToolError', code: 'interrupted' },
+      })],
+    }), target)
+    expect(empty.getByText('ToolError: interrupted')).toBeTruthy()
+  })
+})

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 58e450451ab64f69762817dfb277b8a888e2177f
-README.zh.md: d8947c6935f67566b9208d0f15af6dcf25326b01
+README.md: 1236054d5a05464c43ad1bb0dcbe52b09281e68a
+README.zh.md: 567881e8ca7d5e8017f82884cd638f08b13fc7e7

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 1
packages/client/ui-primitives/README.md


Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 3 - 1
packages/client/ui-primitives/README.zh.md


+ 1 - 0
packages/client/ui-primitives/package.json

@@ -21,6 +21,7 @@
   "license": "BSD-3-Clause",
   "dependencies": {
     "@shikijs/langs": "^4.3.1",
+    "anser": "^2.3.5",
     "clsx": "^2.0.0",
     "react": "^18.2.0",
     "react-dom": "^18.2.0",

+ 3 - 1
packages/client/ui-primitives/src/Pill.tsx

@@ -12,7 +12,9 @@ import css from './Pill.module.css'
  */
 export function Pill({ active = false, className, children, onClick, ...rest }: {
   active?: boolean
-  className?: string
+  // `| undefined` so a caller can forward an optional class straight through
+  // under exactOptionalPropertyTypes (a CSS-module lookup is string|undefined).
+  className?: string | undefined
   children?: ReactNode
 } & ButtonHTMLAttributes<HTMLButtonElement>) {
   if (!onClick) {

+ 2 - 2
packages/client/ui-primitives/src/StateDot.tsx

@@ -23,8 +23,8 @@ const MATRIX_CELLS: readonly (readonly [number, number])[] = [
  */
 export function StateDot({ state, size = 10, className }: {
   state: StateDotState
-  size?: number
-  className?: string
+  size?: number | undefined
+  className?: string | undefined
 }) {
   if (state === 'ongoing') {
     return (

+ 152 - 0
packages/client/ui-primitives/src/TerminalBlock.module.css

@@ -0,0 +1,152 @@
+/* Geometry mirrors CodeBlock (12px radius, code-block surface + banner rows,
+   markdown code-block font) so a terminal card and a fenced code block read as
+   one family. The one deliberate divergence: output keeps `white-space: pre`
+   and scrolls horizontally, because folding a column-aligned command's output
+   destroys its alignment. */
+
+.block {
+  --dsl-terminal-radius: 12px;
+  --dsl-terminal-line-height: 22px;
+  /* The card's own left inset, holding the run-state dot in a column of its own
+     so it never competes with the commands for horizontal space. */
+  --dsl-terminal-gutter: 30px;
+
+  position: relative;
+  margin: 16px 0;
+  /* The gutter is the card's OWN padding, not a margin: every consumer rewrites
+     `margin` wholesale (each render site sets its own indent), which silently
+     cancelled the reservation and let the dot fall outside the card into a
+     container that clips it. Owning the reservation here keeps the invariant
+     with the component that depends on it. */
+  padding-left: var(--dsl-terminal-gutter);
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-markdown-code-block);
+  border-radius: var(--dsl-terminal-radius);
+}
+
+/* Top-aligned: the status pill and copy control stay on the first prompt row
+   however many command lines the card carries. */
+.header {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  /* Pulled back across the card's gutter padding so the banner background and
+     its top-left radius span the FULL surface, then re-inset by the same amount
+     so the prompt text and the dot keep their positions. A plain block child
+     only reaches the content box, which left the gutter column painted in the
+     body color and drew the card's top-left corner in it — invisible in the
+     light theme, where banner and body share a token, and visible in the dark
+     one, where they do not. */
+  margin-left: calc(-1 * var(--dsl-terminal-gutter));
+  padding: 9px 14px 9px var(--dsl-terminal-gutter);
+  background: var(--dsw-alias-markdown-code-block-banner);
+  border-top-left-radius: var(--dsl-terminal-radius);
+  border-top-right-radius: var(--dsl-terminal-radius);
+}
+
+/* One row per command line. The prompt column is the only element allowed to
+   shrink; the status pill and the copy control keep their intrinsic width. */
+.prompt {
+  display: flex;
+  flex-direction: column;
+  min-width: 0;
+  flex: 1;
+  font: var(--dsw-font-markdown-code-block);
+}
+
+.promptLine {
+  position: relative;
+  display: flex;
+  align-items: baseline;
+  gap: 8px;
+  min-width: 0;
+  line-height: var(--dsl-terminal-line-height);
+}
+
+/* Out of flow inside the card's own gutter padding, so the reservation and the
+   dot move together and no consumer margin can pull them apart; the dot neither
+   indents its command nor depends on the command's text metrics to line up.
+   Centered against the row's line box, not the code font's baseline. */
+.runState {
+  position: absolute;
+  left: calc(-1 * var(--dsl-terminal-gutter) + 8px);
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+/* The dot is aria-hidden; this is its text label for assistive technology. */
+.runStateLabel {
+  position: absolute;
+  width: 1px;
+  height: 1px;
+  overflow: hidden;
+  clip-path: inset(50%);
+  white-space: nowrap;
+}
+
+.cwd {
+  flex: none;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+/* `pre`, not `nowrap`: the prompt row renders the command verbatim, and
+   `nowrap` collapses the repeated spaces, tabs, and alignment of an indented
+   continuation. Both hold the single row and the ellipsis. */
+.command {
+  min-width: 0;
+  color: var(--dsw-alias-label-primary);
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: pre;
+}
+
+.status {
+  flex: none;
+  color: var(--dsw-alias-state-error-primary);
+}
+
+.copyButton {
+  flex: none;
+  background-color: transparent;
+  border: none;
+  padding: 0;
+  margin: 0;
+  color: var(--dsw-alias-label-secondary);
+  cursor: pointer;
+  font: var(--dsw-font-xs-13);
+}
+
+.output {
+  padding: 12px 14px 12px 0;
+  font: var(--dsw-font-markdown-code-block);
+  overflow-x: auto;
+  overflow-y: hidden;
+}
+
+/* No wrapping, no word-break: alignment is the payload of terminal output. */
+.line {
+  min-height: var(--dsl-terminal-line-height);
+  white-space: pre;
+}
+
+.expand {
+  display: block;
+  width: 100%;
+  padding: 0;
+  border: none;
+  background-color: transparent;
+  color: var(--dsw-alias-label-tertiary);
+  cursor: pointer;
+  font: inherit;
+  text-align: left;
+}
+
+.expand:hover {
+  color: var(--dsw-alias-label-secondary);
+}
+
+.empty {
+  padding: 12px 14px 12px 0;
+  font: var(--dsw-font-markdown-code-block);
+  color: var(--dsw-alias-label-tertiary);
+}

+ 237 - 0
packages/client/ui-primitives/src/TerminalBlock.tsx

@@ -0,0 +1,237 @@
+// TerminalBlock: the terminal surface for a shell command and its output —
+// prompt line (run-state dot + shortened cwd + command), ANSI-colored output,
+// settled exit status, and a copy control for the raw output. Output never soft-wraps:
+// column-aligned output (ls, tables, box drawing) keeps its alignment and
+// scrolls horizontally instead of folding. Colors resolve through --dsw-*
+// tokens; ANSI parsing lives in ansi.ts.
+
+import { useCallback, useMemo, useState } from 'react'
+import clsx from 'clsx'
+import { parseAnsiLines, type AnsiLine } from './ansi.ts'
+import { writeClipboard } from './clipboard.ts'
+import { Pill } from './Pill.tsx'
+import { StateDot, type StateDotState } from './StateDot.tsx'
+import css from './TerminalBlock.module.css'
+
+/**
+ * Output lines shown before the height cap collapses the middle. Matches the
+ * TUI transcript's default tool-output budget so both front ends cut a long
+ * command's output at the same place.
+ */
+export const DEFAULT_TERMINAL_MAX_LINES = 16
+
+export interface TerminalBlockProps {
+  /** The command line, rendered verbatim after the prompt label. */
+  command: string
+  /** Working directory for the prompt label; absent renders a plain `$`. */
+  cwd?: string | undefined
+  /** Absolute home directory, so a cwd equal to it collapses to `~`; absent disables that collapse. */
+  home?: string | undefined
+  /** The command's output text; may contain ANSI escape sequences. */
+  output?: string | undefined
+  /** Settled exit code; a non-zero value renders the status pill. */
+  exitCode?: number | undefined
+  /** Settled terminating signal name; any value renders the status pill, taking precedence over the exit code. */
+  signal?: string | undefined
+  /** The command is still running: the block shows the prompt line alone. */
+  running?: boolean | undefined
+  /** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
+  maxLines?: number | undefined
+  /** Extra class merged onto the wrapper (callers position; this component draws). */
+  className?: string | undefined
+}
+
+/**
+ * Prompt label for a working directory: `~` for the home directory itself,
+ * otherwise the path's last segment (both separators accepted, trailing
+ * separators ignored), falling back to the path itself when it has no
+ * segment.
+ * @param cwd - the working directory path.
+ * @param home - absolute home directory, when the caller knows it.
+ * @returns the prompt label.
+ */
+function promptLabel(cwd: string, home: string | undefined): string {
+  const trimmed = cwd.replace(/[/\\]+$/, '')
+  if (home !== undefined && trimmed === home.replace(/[/\\]+$/, '')) return '~'
+  const segment = trimmed.split(/[/\\]/).pop()
+  return segment === undefined || segment === '' ? cwd : segment
+}
+
+/**
+ * Status pill text for a settled command, or undefined when the command
+ * settled cleanly (exit 0, no signal) and needs no pill — the same
+ * distinction the bash tool's own exit-status markers draw.
+ * @param exitCode - settled exit code, when known.
+ * @param signal - settled terminating signal name, when known.
+ * @returns the pill text, or undefined for a clean exit.
+ */
+function statusText(exitCode: number | undefined, signal: string | undefined): string | undefined {
+  if (signal !== undefined) return `信号 ${signal}`
+  if (exitCode !== undefined && exitCode !== 0) return `退出码 ${exitCode}`
+  return undefined
+}
+
+/**
+ * Run-state indicator for the command, shown at the head of the prompt line so
+ * the card states whether the command is still running without the reader
+ * having to infer it from the presence of output. Three of {@link StateDotState}'s
+ * four states are reachable: the running chase (the same
+ * indicator a running tool row's leading icon uses, so the row and its card
+ * never disagree), green for a clean settle, red for a signal or a non-zero
+ * exit — the same status distinction {@link statusText} draws for the pill. A
+ * settled command whose exit status never reached the view counts as a clean
+ * settle: the view says it finished and says nothing went wrong.
+ * @param running - the command has not settled.
+ * @param exitCode - settled exit code, when known.
+ * @param signal - settled terminating signal name, when known.
+ * @returns the dot's state and its text label, since the dot is aria-hidden.
+ */
+function runState(
+  running: boolean,
+  exitCode: number | undefined,
+  signal: string | undefined,
+): { state: StateDotState; label: string } {
+  if (running) return { state: 'ongoing', label: '运行中' }
+  if (statusText(exitCode, signal) !== undefined) return { state: 'error', label: '失败' }
+  return { state: 'done', label: '已完成' }
+}
+
+/**
+ * Render one parsed output line. Runs without SGR state render as bare text,
+ * so uncolored output carries no span wrappers.
+ * @param line - the line's styled runs.
+ * @returns the line's children.
+ */
+function renderLine(line: AnsiLine) {
+  return line.map((span, index) => span.style === undefined
+    ? span.text
+    : <span key={index} style={span.style}>{span.text}</span>)
+}
+
+/**
+ * Render a shell command as a terminal surface.
+ * @param props - see {@link TerminalBlockProps}.
+ * @returns the terminal block element.
+ */
+export function TerminalBlock({
+  command,
+  cwd,
+  home,
+  output,
+  exitCode,
+  signal,
+  running = false,
+  maxLines = DEFAULT_TERMINAL_MAX_LINES,
+  className,
+}: TerminalBlockProps) {
+  const text = output ?? ''
+  // A command's output ends with a newline; that terminator is not an extra
+  // blank line to draw or to count against the height cap. The check runs on the
+  // PARSED lines rather than on the raw text, because a reset after the final
+  // newline (`line\n\x1b[0m`) leaves the string not ending in one while still
+  // producing a last line with nothing visible in it. A genuinely blank final
+  // line — the double newline — survives, since it has a real empty line before
+  // the terminator. The copy control still copies `text` untouched.
+  const lines = useMemo(() => {
+    const parsed = parseAnsiLines(text)
+    const last = parsed[parsed.length - 1]
+    const terminated = parsed.length > 1 && last !== undefined
+      && last.every(span => span.text === '')
+    return terminated ? parsed.slice(0, -1) : parsed
+  }, [text])
+  const [expanded, setExpanded] = useState(false)
+  const [copied, setCopied] = useState(false)
+
+  const onCopy = useCallback(() => {
+    if (copied) return
+    // The raw output, never the rendered tree: the prompt line and the status
+    // pill are chrome the user did not run.
+    void writeClipboard(text).then((ok) => {
+      if (!ok) return
+      setCopied(true)
+      window.setTimeout(() => { setCopied(false) }, 1000)
+    })
+  }, [copied, text])
+
+  const onToggle = useCallback(() => { setExpanded(value => !value) }, [])
+
+  const status = statusText(exitCode, signal)
+  const state = runState(running, exitCode, signal)
+  // A multi-line command gets one prompt row per line, so a two-command shell
+  // snippet reads as the two commands it is instead of collapsing into one
+  // ellipsized row. A trailing newline is a terminator, not an empty command.
+  const commandLines = useMemo(() => {
+    const body = command.endsWith('\n') ? command.slice(0, -1) : command
+    return body.split('\n')
+  }, [command])
+  // Read from the parsed lines the card actually renders, not from the raw text:
+  // output that is only escapes or control bytes (a lone reset, an OSC title, an
+  // erase) survives `text.trim()` yet parses to nothing visible. Judging it on
+  // the raw text drew an output box of blank rows plus a copy control for
+  // invisible bytes, and hid the placeholder that belongs there.
+  const empty = lines.every(line => line.every(span => span.text.trim() === ''))
+  const hidden = lines.length - maxLines
+  const capped = hidden > 0 && !expanded
+  // Same split arithmetic as the TUI transcript's collapsed tool card, so a
+  // command's head and tail slices agree between the two front ends.
+  const headLines = Math.ceil(maxLines / 2)
+  const tailLines = maxLines - headLines
+
+  return (
+    <div className={clsx(css.block, className)} data-terminal="" data-running={running ? '' : undefined}>
+      <div className={css.header}>
+        <div className={css.prompt}>
+          <span className={css.runStateLabel}>{state.label}</span>
+          {commandLines.map((line, index) => (
+            <div key={index} className={css.promptLine}>
+              {/* One dot for the card, on the first row: the exit status the
+                  view carries is the whole call's, and bash reports no
+                  per-command status, so a dot per row would assert a
+                  per-line outcome nothing here knows. */}
+              {index === 0 && <StateDot state={state.state} className={css.runState} />}
+              {/* The cwd labels the CALL, so only its first row carries it. The
+                  view knows one working directory — where the call started —
+                  and a later line may well run somewhere else (a `cd` in the
+                  command is enough), so repeating the label down the rows would
+                  assert a directory per line that nothing here knows. Later
+                  rows keep a bare `$` to stay aligned as prompts. */}
+              <span className={css.cwd}>
+                {index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)}
+              </span>
+              <span className={css.command}>{line}</span>
+            </div>
+          ))}
+        </div>
+        {status !== undefined && <Pill className={css.status}>{status}</Pill>}
+        {!running && !empty && (
+          <button type="button" className={css.copyButton} onClick={onCopy}>
+            {copied ? '复制成功' : '复制'}
+          </button>
+        )}
+      </div>
+      {!running && (empty
+        ? <div className={css.empty}>无输出</div>
+        : (
+          <div className={css.output}>
+            {(capped ? lines.slice(0, headLines) : lines).map((line, index) => (
+              <div key={index} className={css.line}>{renderLine(line)}</div>
+            ))}
+            {hidden > 0 && (
+              <button
+                type="button"
+                className={css.expand}
+                aria-expanded={expanded}
+                aria-label={expanded ? '收起输出' : `展开其余 ${hidden} 行输出`}
+                onClick={onToggle}
+              >
+                {expanded ? '收起' : `… 其余 ${hidden} 行`}
+              </button>
+            )}
+            {capped && lines.slice(lines.length - tailLines).map((line, index) => (
+              <div key={index} className={css.line}>{renderLine(line)}</div>
+            ))}
+          </div>
+        ))}
+    </div>
+  )
+}

+ 447 - 0
packages/client/ui-primitives/src/ansi.ts

@@ -0,0 +1,447 @@
+// ANSI model behind TerminalBlock: anser splits the SGR runs, this module
+// resolves each run's colors and decorations into a plain style record and
+// folds the runs into per-line span arrays so a height cap can slice whole
+// lines. Sequences anser does not turn into color (OSC, cursor movement,
+// other C0 controls) are removed before parsing so they never reach the DOM
+// as literal characters.
+
+import Anser from 'anser'
+import type { CSSProperties } from 'react'
+
+/**
+ * The subset of one anser JSON chunk this module reads. anser's own types
+ * declare `fg`/`bg` as `string`, but its parser leaves them `null` for a run
+ * that sets no color, so the null is spelled out here.
+ */
+interface AnsiChunk {
+  /** Run text with its SGR codes already removed. */
+  content: string
+  /** Foreground as an `r, g, b` triple, or null when the run sets none. */
+  fg: string | null
+  /** Background as an `r, g, b` triple, or null when the run sets none. */
+  bg: string | null
+  /** SGR attributes in effect for the run, in the order they were declared. */
+  decorations: readonly string[]
+}
+
+/** One run of terminal text; `style` is undefined for text that carries no SGR state. */
+export interface AnsiSpan {
+  /** The run's plain text, free of escape sequences and newlines. */
+  text: string
+  /** Resolved inline style, or undefined when the run needs no wrapper. */
+  style: CSSProperties | undefined
+}
+
+/** The spans of one output line, in order. */
+export type AnsiLine = readonly AnsiSpan[]
+
+/**
+ * The 8/16 basic ANSI colors, keyed by the whitespace-free `r,g,b` triple
+ * anser emits for them, mapped onto the theme tokens that carry the same
+ * semantic. Black and white both resolve to the primary label color so text
+ * stays legible under either theme instead of matching the surface it sits
+ * on; bright black takes the tertiary label color (the muted-gray role).
+ * Magenta and cyan have no token equivalent in this design system and fall
+ * through to anser's literal rgb, as do all 256-palette and truecolor values.
+ */
+const TOKEN_BY_BASIC_RGB: Record<string, string> = {
+  '0,0,0': 'var(--dsw-alias-label-primary)',
+  '255,255,255': 'var(--dsw-alias-label-primary)',
+  '85,85,85': 'var(--dsw-alias-label-tertiary)',
+  '187,0,0': 'var(--dsw-alias-state-error-primary)',
+  '255,85,85': 'var(--dsw-alias-state-error-secondary)',
+  '0,187,0': 'var(--dsw-alias-state-success-primary)',
+  '0,255,0': 'var(--dsw-alias-state-success-secondary)',
+  '187,187,0': 'var(--dsw-alias-state-warn-primary)',
+  '255,255,85': 'var(--dsw-alias-state-warn-secondary)',
+  '0,0,187': 'var(--dsw-alias-state-business-primary)',
+  '85,85,255': 'var(--dsw-static-blue-400)',
+}
+
+/**
+ * CSS for each SGR attribute anser reports. `blink` is deliberately absent —
+ * animated text is not reproduced. `reverse` never arrives here: anser
+ * consumes it by swapping the run's foreground and background. Underline and
+ * strikethrough share `textDecoration`, so in a run declaring both, the
+ * later declaration wins.
+ */
+const STYLE_BY_DECORATION: Record<string, CSSProperties | undefined> = {
+  bold: { fontWeight: 700 },
+  dim: { opacity: 0.7 },
+  italic: { fontStyle: 'italic' },
+  underline: { textDecoration: 'underline' },
+  strikethrough: { textDecoration: 'line-through' },
+  hidden: { visibility: 'hidden' },
+}
+
+/** OSC strings (window title, hyperlinks), with or without their terminator. */
+const OSC_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g
+
+/** Escape sequences other than CSI: charset selection, single-shift, reset. */
+const NON_CSI_ESCAPE = /\u001b(?!\[)[\u0020-\u002f]*[\u0030-\u007e]?/g
+
+/**
+ * C0 controls with no display meaning here. Tab, newline, backspace and ESC
+ * survive: the first two for layout, backspace for the cursor replay, ESC
+ * for anser's CSI split.
+ */
+const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
+
+/**
+ * Lines whose cursor movements have to be replayed: a carriage return, a
+ * backspace, or an erase-in-line. The erase pattern matches the SAME CSI shape
+ * `replayLine` parses (parameters may carry `;` and intermediate bytes), so a
+ * form like `\x1b[1;2K` cannot slip past this guard and skip its own erase.
+ */
+const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
+
+/** SGR sequences alone, for folding state through a line that needs no replay. */
+const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
+
+/** Terminal tab stop width; a tab advances to the next multiple of this. */
+const TAB_WIDTH = 8
+
+/**
+ * Combining marks and other zero-width code points: a terminal advances no
+ * column for them, so `e` + U+0301 occupies one cell and a two-column redraw
+ * covers both code points.
+ */
+const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
+
+/**
+ * Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
+ * CJK punctuation, and characters with emoji presentation. Text-presentation
+ * symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
+ * must stay out of this set.
+ */
+const WIDE_CHAR = new RegExp(
+  '\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
+  // Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
+  // width — `\u2713` (the check every progress line writes, this fixture
+  // included) advances one column, verified against a real terminal, so taking
+  // the whole block as wide misaligned exactly the output this card exists for.
+  + '|\\p{Emoji_Presentation}'
+  + '|[\\uff01-\\uff60\\u3000-\\u303e]',
+  'u',
+)
+
+/**
+ * Whether a character occupies two terminal columns (CJK, fullwidth forms,
+ * emoji). Covers the ranges a command's output realistically carries; a
+ * narrower guess would misalign the columns this card exists to preserve.
+ * @param char - one character from the output.
+ * @returns true when the terminal advances two columns for it.
+ */
+function isWide(char: string): boolean {
+  const code = char.codePointAt(0)
+  if (code === undefined || code < 0x1100) return false
+  return WIDE_CHAR.test(char)
+}
+
+/**
+ * A cell's graphic state, normalized. Held as fields rather than as the raw
+ * sequence history because a terminal tracks CURRENT state, not a transcript:
+ * accumulating sequences made each state boundary re-emit the whole chain, so
+ * output that switches color without a full reset emitted O(n^2) characters
+ * (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
+ * the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
+ * `24`, `27`, `29` — actually close their attribute instead of appending to it.
+ */
+interface SgrState {
+  fg: string
+  bg: string
+  /** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
+  attrs: readonly string[]
+}
+
+/** The default state: no color, no attributes. */
+const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
+
+/** Attribute closers, mapped to the opener parameters each one turns off. */
+const ATTR_CLOSERS: Record<string, readonly string[]> = {
+  22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
+}
+
+/**
+ * Fold one SGR sequence's parameters into the state it produces.
+ * @param state - state in force before the sequence.
+ * @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
+ * @returns the state the sequence leaves in force.
+ */
+function foldSgr(state: SgrState, params: string): SgrState {
+  const codes = params === '' ? ['0'] : params.split(';')
+  let next = state
+  for (let index = 0; index < codes.length; index++) {
+    const code = String(codes[index])
+    if (code === '' || code === '0') { next = SGR_NONE; continue }
+    // Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
+    // consume their own arguments, so they are taken whole.
+    if (code === '38' || code === '48') {
+      const kind = codes[index + 1] ?? ''
+      const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
+      const value = codes.slice(index, index + span + 1).join(';')
+      next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
+      index += span
+      continue
+    }
+    const closes = ATTR_CLOSERS[code]
+    if (closes !== undefined) {
+      next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
+      continue
+    }
+    const numeric = Number(code)
+    if (code === '39') { next = { ...next, fg: '' }; continue }
+    if (code === '49') { next = { ...next, bg: '' }; continue }
+    if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
+    if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
+    if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
+  }
+  return next
+}
+
+/**
+ * Render a state as the one canonical sequence that establishes it from the
+ * default, so a boundary emits a bounded string no matter how the state was
+ * reached.
+ * @param state - the state to open.
+ * @returns the SGR sequence, or the empty string for the default state.
+ */
+function openSgr(state: SgrState): string {
+  const codes = [...state.attrs]
+  if (state.fg !== '') codes.push(state.fg)
+  if (state.bg !== '') codes.push(state.bg)
+  return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
+}
+
+/** Whether two states are the same, so a boundary is only emitted on a change. */
+function sameSgr(a: SgrState, b: SgrState): boolean {
+  return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
+    && a.attrs.every((attr, index) => attr === b.attrs[index])
+}
+
+/**
+ * Replay one line's cursor movements the way a terminal paints it, into a
+ * column buffer. Carriage return and backspace only MOVE the cursor — neither
+ * erases anything — so what a reader sees is whatever each column last had
+ * written to it. That distinction is the whole point of doing this as a buffer
+ * rather than as string surgery: `100%\rOK` shows `OK0%` because the redraw is
+ * shorter than the frame beneath it, and a trailing `abc\b` still shows `abc`
+ * because nothing ever overwrote the `c`.
+ *
+ * A CSI sequence occupies no column; it changes the state that the NEXT writes
+ * are stamped with, which is how a terminal stores color per cell. `red bad`
+ * then three backspaces then `ok` therefore shows `okd` with the `d` still red:
+ * `ok` overwrote two cells and the third kept the state it was written with.
+ * The columns are re-emitted as runs, so anser sees that same styling.
+ * @param line - one output line, still carrying its CSI sequences.
+ * @param entrySgr - SGR state in force when the line begins, since a newline
+ *   does not reset it.
+ * @returns the line as the terminal would have it after every movement, plus the
+ *   SGR state at its end for the next line to enter with.
+ */
+function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
+  // Same shape anser splits on, so a sequence is one unit here as well.
+  const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
+  /** Per column: the state in force when it was written, and its character. */
+  const columns: (Cell | undefined)[] = []
+  let cursor = 0
+  // State is tracked exactly as a terminal tracks it: each cell is stamped with
+  // whatever was in force at the moment of the write, so a later redraw cannot
+  // restyle the cells it does not reach. It enters carrying the previous line's
+  // state, since a newline does not reset it.
+  let sgr = entrySgr
+  let at = 0
+
+  /** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
+  const clear = (index: number, fill: string): void => {
+    const cell = columns[index]
+    if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
+    else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
+      columns[index + 1] = { sgr, char: fill }
+    }
+    columns[index] = { sgr, char: fill }
+  }
+
+  const consume = (chunk: string): void => {
+    for (const char of chunk) {
+      if (char === '\r') { cursor = 0; continue }
+      if (char === '\u0008') { cursor = Math.max(0, cursor - 1); continue }
+      if (char === '\t') {
+        // A tab advances to the next 8-column stop, leaving the cells it skips
+        // as they were — which is how a redraw can leave a tabbed column
+        // standing. Column alignment is the whole point of this card.
+        const stop = cursor + TAB_WIDTH - (cursor % TAB_WIDTH)
+        for (; cursor < stop; cursor++) columns[cursor] ??= { sgr, char: ' ' }
+        continue
+      }
+      if (ZERO_WIDTH.test(char)) {
+        // No column of its own: it attaches to the cell already written, so a
+        // redraw that covers that cell covers the mark with it. With no cell to
+        // attach to (line start, or straight after a redraw to column 0) a
+        // terminal shows nothing rather than a lone accent.
+        const base = cursor > 0 ? columns[cursor - 1] : undefined
+        if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
+        continue
+      }
+      // Writing over either half of a wide pair blanks the other half, since a
+      // terminal cannot leave one cell of a two-cell glyph standing.
+      clear(cursor, ' ')
+      columns[cursor] = { sgr, char }
+      cursor++
+      // A wide character occupies two columns; the trailing one is a spacer,
+      // marked so that overwriting the lead cell leaves a blank behind instead
+      // of closing the gap and shifting everything after it left.
+      if (isWide(char)) { columns[cursor] = { sgr, char: '', spacer: true }; cursor++ }
+    }
+  }
+
+  for (const match of line.matchAll(csi)) {
+    consume(line.slice(at, match.index))
+    at = match.index + match[0].length
+    // Both groups are mandatory in the pattern, so destructuring types them as
+    // strings without a fallback that could never run.
+    const params = String(match[1])
+    const final = String(match[2])
+    if (final === 'K') {
+      // Erase in line: the fixed companion of `\r` in every spinner and progress
+      // bar. Without it a shorter redraw leaves the previous frame's tail
+      // standing, which is text the terminal never showed. `1` blanks from the
+      // line start THROUGH the cursor column (inclusive, per the CSI spec)
+      // rather than dropping those cells, since the cursor does not move and a
+      // later write can still land past them. Only the FIRST parameter selects
+      // the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
+      const mode = String(params.split(';')[0])
+      if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
+      else columns.length = mode === '2' ? 0 : cursor
+      continue
+    }
+    // Only SGR carries graphic state; every other final byte is a cursor or
+    // erase action that must not affect a cell's style.
+    if (final !== 'm') continue
+    sgr = foldSgr(sgr, params)
+  }
+  consume(line.slice(at))
+
+  // Re-emit the columns, opening a run only where its state changes, so anser
+  // sees the same styling a terminal shows. Each boundary emits ONE canonical
+  // sequence for the state it opens, which is what keeps the output linear in
+  // the number of cells however the state was reached.
+  let out = ''
+  let active = entrySgr
+  for (let index = 0; index < columns.length; index++) {
+    const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
+    if (!sameSgr(column.sgr, active)) {
+      if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
+      out += openSgr(column.sgr)
+      active = column.sgr
+    }
+    // A spacer still holds its column. While its lead cell survives, the wide
+    // glyph spans both and the spacer emits nothing; once a later write replaced
+    // that lead, the terminal blanks the spacer instead of closing the gap, so
+    // emitting nothing would shift everything after it one column left.
+    const leadIntact = index > 0 && isWide(columns[index - 1]?.char ?? '')
+    out += column.spacer === true && !leadIntact ? ' ' : column.char
+  }
+  // Converge to the state the SCAN ended in, not the last written cell's: a
+  // sequence after the final write (the `\x1b[0m` closing a colored line) changes
+  // no cell yet still ends the run, and it has to reach both the DOM and the
+  // next line. Without this a line ending in a reset leaked its color onward.
+  if (!sameSgr(active, sgr)) {
+    if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
+    out += openSgr(sgr)
+  }
+  return { text: out, sgr }
+}
+
+/** One replayed column: the state it was written with, and its character. */
+interface Cell {
+  sgr: SgrState
+  char: string
+  /** The trailing half of a wide character's two-column pair. */
+  spacer?: boolean
+}
+
+/**
+ * Replay every line's cursor movements. A `\r` that only terminates a CRLF line
+ * is dropped first, so those lines keep their text instead of being redrawn onto
+ * themselves. SGR state threads across lines: a newline does not reset it, so a
+ * run opened before a redraw still colors the lines after it.
+ * @param text - output text, already free of OSC and non-CSI escapes.
+ * @returns the text with each line painted as the terminal would.
+ */
+function applyCursorMovements(text: string): string {
+  const replayed: string[] = []
+  let sgr = SGR_NONE
+  for (const raw of text.split('\n')) {
+    const line = raw.replace(/\r+$/, '')
+    if (NEEDS_REPLAY.test(line)) {
+      const result = replayLine(line, sgr)
+      replayed.push(result.text)
+      sgr = result.sgr
+      continue
+    }
+    // No cursor movement: the line needs no column buffer, and painting one
+    // would allocate a cell per character of output this card never redraws —
+    // an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
+    // line that DOES replay enters with the right state.
+    replayed.push(line)
+    for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
+  }
+  return replayed.join('\n')
+}
+
+/**
+ * Remove every escape sequence and control character that carries no color,
+ * leaving CSI sequences for anser and `\n`/`\t` for layout. Cursor movements
+ * (carriage return, backspace) replay first, since their effect on the visible
+ * text must land before the characters that expressed them are dropped.
+ * @param text - raw command output.
+ * @returns text whose only remaining escapes are CSI sequences.
+ */
+function sanitize(text: string): string {
+  const escaped = text.replace(OSC_SEQUENCE, '').replace(NON_CSI_ESCAPE, '')
+  return applyCursorMovements(escaped).replace(INERT_CONTROL, '')
+}
+
+/**
+ * Resolve one run's colors and decorations.
+ * @param chunk - the anser chunk to style.
+ * @returns the run's inline style, or undefined when it carries no SGR state.
+ */
+function resolveStyle(chunk: AnsiChunk): CSSProperties | undefined {
+  const style: CSSProperties = {}
+  const background = chunk.bg === null ? undefined : `rgb(${chunk.bg})`
+  if (background !== undefined) style.backgroundColor = background
+  if (chunk.fg !== null) {
+    const literal = `rgb(${chunk.fg})`
+    // A run that paints its own background keeps anser's literal pair so the
+    // authored foreground/background contrast survives; a foreground-only run
+    // maps onto a theme token, which adapts to light and dark surfaces.
+    style.color = background === undefined
+      ? TOKEN_BY_BASIC_RGB[chunk.fg.replace(/\s+/g, '')] ?? literal
+      : literal
+  }
+  for (const decoration of chunk.decorations) Object.assign(style, STYLE_BY_DECORATION[decoration])
+  return Object.keys(style).length === 0 ? undefined : style
+}
+
+/**
+ * Parse command output into styled spans grouped by line.
+ * @param text - raw output text, which may contain ANSI escape sequences.
+ * @returns one entry per output line (always at least one, possibly empty).
+ */
+export function parseAnsiLines(text: string): AnsiLine[] {
+  let current: AnsiSpan[] = []
+  const lines: AnsiSpan[][] = [current]
+  for (const chunk of Anser.ansiToJson(sanitize(text), { json: true, remove_empty: true })) {
+    const style = resolveStyle(chunk)
+    for (const [index, part] of chunk.content.split('\n').entries()) {
+      if (index > 0) {
+        current = []
+        lines.push(current)
+      }
+      if (part !== '') current.push({ text: part, style })
+    }
+  }
+  return lines
+}

+ 48 - 0
packages/client/ui-primitives/src/clipboard.ts

@@ -0,0 +1,48 @@
+// Package-internal clipboard write, shared by every copy control in this
+// package (CodeBlock's code copy, TerminalBlock's output copy). Not part of the
+// public surface: consumers get the components, not the host detection.
+
+/**
+ * Write text to the host clipboard, preferring the async Clipboard API and
+ * falling back to `execCommand('copy')` on hosts (jsdom, insecure contexts)
+ * that omit it.
+ * @param text - the exact text to place on the clipboard.
+ * @returns true only when the host accepted the write.
+ */
+export async function writeClipboard(text: string): Promise<boolean> {
+  // lib.dom types clipboard non-optional, but insecure contexts omit it —
+  // that runtime gap is exactly what this guard detects.
+  /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
+  if (navigator.clipboard?.writeText) {
+    try {
+      await navigator.clipboard.writeText(text)
+      return true
+    } catch {
+      // Denied permissions / iframe policy — do not claim success.
+      return false
+    }
+  }
+  // jsdom and older hosts: best-effort execCommand path when present.
+  // execCommand('copy') is the only clipboard fallback where the async API
+  // is missing; deprecated but deliberately retained.
+  /* eslint-disable @typescript-eslint/no-deprecated */
+  const exec = typeof document.execCommand === 'function'
+    ? document.execCommand.bind(document)
+    : undefined
+  if (exec === undefined) return false
+  const el = document.createElement('textarea')
+  el.value = text
+  el.setAttribute('readonly', '')
+  el.style.position = 'fixed'
+  el.style.left = '-9999px'
+  document.body.appendChild(el)
+  el.select()
+  try {
+    return exec('copy')
+  } catch {
+    return false
+  } finally {
+    el.remove()
+  }
+  /* eslint-enable @typescript-eslint/no-deprecated */
+}

+ 2 - 0
packages/client/ui-primitives/src/index.ts

@@ -17,6 +17,8 @@ export { FishLogo } from './FishLogo.tsx'
 export { BrandWordmark } from './BrandWordmark.tsx'
 export { Tooltip } from './Tooltip.tsx'
 export type { TooltipSide } from './Tooltip.tsx'
+export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx'
+export type { TerminalBlockProps } from './TerminalBlock.tsx'
 export { CodeBlock } from './markdown/CodeBlock.tsx'
 export { JsonBlock } from './markdown/JsonBlock.tsx'
 export { MarkdownText } from './markdown/MarkdownText.tsx'

+ 1 - 39
packages/client/ui-primitives/src/markdown/CodeBlock.tsx

@@ -6,6 +6,7 @@
 
 import { useCallback, useMemo, useRef, useState } from 'react'
 import clsx from 'clsx'
+import { writeClipboard } from '../clipboard.ts'
 import { highlightToHtml } from './highlight.ts'
 import css from './CodeBlock.module.css'
 
@@ -18,45 +19,6 @@ export interface CodeBlockProps {
   className?: string | undefined
 }
 
-/** @returns true only when the host accepted the write. */
-async function writeClipboard(text: string): Promise<boolean> {
-  // lib.dom types clipboard non-optional, but insecure contexts omit it —
-  // that runtime gap is exactly what this guard detects.
-  /* eslint-disable-next-line @typescript-eslint/no-unnecessary-condition */
-  if (navigator.clipboard?.writeText) {
-    try {
-      await navigator.clipboard.writeText(text)
-      return true
-    } catch {
-      // Denied permissions / iframe policy — do not claim success.
-      return false
-    }
-  }
-  // jsdom and older hosts: best-effort execCommand path when present.
-  // execCommand('copy') is the only clipboard fallback where the async API
-  // is missing; deprecated but deliberately retained.
-  /* eslint-disable @typescript-eslint/no-deprecated */
-  const exec = typeof document.execCommand === 'function'
-    ? document.execCommand.bind(document)
-    : undefined
-  if (exec === undefined) return false
-  const el = document.createElement('textarea')
-  el.value = text
-  el.setAttribute('readonly', '')
-  el.style.position = 'fixed'
-  el.style.left = '-9999px'
-  document.body.appendChild(el)
-  el.select()
-  try {
-    return exec('copy')
-  } catch {
-    return false
-  } finally {
-    el.remove()
-  }
-  /* eslint-enable @typescript-eslint/no-deprecated */
-}
-
 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])

+ 513 - 0
packages/client/ui-primitives/tests/ansi.spec.ts

@@ -0,0 +1,513 @@
+// parseAnsiLines, the ANSI model behind TerminalBlock: anser's SGR runs
+// resolved into inline styles and folded into per-line span arrays, with every
+// escape and control character that carries no color removed first. The DOM
+// side of the same model (which runs get a span wrapper) is in
+// terminal-block.spec.tsx.
+
+import { describe, expect, it } from 'vitest'
+import { parseAnsiLines } from '../src/ansi.ts'
+
+const ESC = '\u001b'
+const BS = '\u0008'
+/** A combining acute accent: zero-width, so it takes no terminal column. */
+const ACCENT = '\u0301'
+
+/** Paint `text` with the SGR `codes`, then reset. */
+function sgr(codes: string, text: string): string {
+  return `${ESC}[${codes}m${text}${ESC}[0m`
+}
+
+/** The single span of a single-line, single-run parse. */
+function onlySpan(text: string) {
+  const lines = parseAnsiLines(text)
+  expect(lines).toHaveLength(1)
+  expect(lines[0]).toHaveLength(1)
+  return lines[0]![0]!
+}
+
+describe('parseAnsiLines: text without SGR state', () => {
+  it('leaves plain text as one unstyled span', () => {
+    expect(parseAnsiLines('hello')).toEqual([[{ text: 'hello', style: undefined }]])
+  })
+
+  it('returns exactly one empty line for empty input', () => {
+    expect(parseAnsiLines('')).toEqual([[]])
+  })
+
+  it('splits a multi-line run and drops the empty line between two blocks', () => {
+    expect(parseAnsiLines('a\n\nb')).toEqual([
+      [{ text: 'a', style: undefined }],
+      [],
+      [{ text: 'b', style: undefined }],
+    ])
+  })
+
+  it('keeps tabs, which the terminal surface needs for column layout', () => {
+    expect(onlySpan('a\tb')).toEqual({ text: 'a\tb', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: basic colors mapped onto theme tokens', () => {
+  it.each<[string, string, string]>([
+    ['30', 'black', 'var(--dsw-alias-label-primary)'],
+    ['37', 'white', 'var(--dsw-alias-label-primary)'],
+    ['90', 'bright black', 'var(--dsw-alias-label-tertiary)'],
+    ['31', 'red', 'var(--dsw-alias-state-error-primary)'],
+    ['91', 'bright red', 'var(--dsw-alias-state-error-secondary)'],
+    ['32', 'green', 'var(--dsw-alias-state-success-primary)'],
+    ['92', 'bright green', 'var(--dsw-alias-state-success-secondary)'],
+    ['33', 'yellow', 'var(--dsw-alias-state-warn-primary)'],
+    ['93', 'bright yellow', 'var(--dsw-alias-state-warn-secondary)'],
+    ['34', 'blue', 'var(--dsw-alias-state-business-primary)'],
+    ['94', 'bright blue', 'var(--dsw-static-blue-400)'],
+  ])('SGR %s (%s) resolves to %s', (code, _name, token) => {
+    expect(onlySpan(sgr(code, 'x'))).toEqual({ text: 'x', style: { color: token } })
+  })
+})
+
+describe('parseAnsiLines: colors with no token equivalent', () => {
+  it.each<[string, string, string]>([
+    ['35', 'magenta', 'rgb(187, 0, 187)'],
+    ['36', 'cyan', 'rgb(0, 187, 187)'],
+    ['38;5;208', '256-palette orange', 'rgb(255, 135, 0)'],
+    ['38;2;10;20;30', 'truecolor', 'rgb(10, 20, 30)'],
+  ])('SGR %s (%s) falls through to %s', (code, _name, literal) => {
+    expect(onlySpan(sgr(code, 'x')).style).toEqual({ color: literal })
+  })
+})
+
+describe('parseAnsiLines: backgrounds', () => {
+  it('sets backgroundColor for a background-only run', () => {
+    expect(onlySpan(sgr('44', 'x')).style).toEqual({ backgroundColor: 'rgb(0, 0, 187)' })
+  })
+
+  it('keeps the literal foreground when the run paints its own background', () => {
+    expect(onlySpan(sgr('41;37', 'x')).style).toEqual({
+      backgroundColor: 'rgb(187, 0, 0)',
+      color: 'rgb(255,255,255)',
+    })
+  })
+
+  it('renders reverse video as the swapped pair anser reports', () => {
+    expect(onlySpan(sgr('31;7', 'x')).style).toEqual({
+      backgroundColor: 'rgb(187, 0, 0)',
+      color: 'rgb(0, 0, 0)',
+    })
+  })
+})
+
+describe('parseAnsiLines: decorations', () => {
+  it.each<[string, string, Record<string, unknown>]>([
+    ['1', 'bold', { fontWeight: 700 }],
+    ['2', 'dim', { opacity: 0.7 }],
+    ['3', 'italic', { fontStyle: 'italic' }],
+    ['4', 'underline', { textDecoration: 'underline' }],
+    ['9', 'strikethrough', { textDecoration: 'line-through' }],
+    ['8', 'hidden', { visibility: 'hidden' }],
+  ])('SGR %s (%s) resolves to %o', (code, _name, style) => {
+    expect(onlySpan(sgr(code, 'x')).style).toEqual(style)
+  })
+
+  it('lets the later textDecoration win when a run declares underline and strikethrough', () => {
+    expect(onlySpan(sgr('4;9', 'x')).style).toEqual({ textDecoration: 'line-through' })
+    expect(onlySpan(sgr('9;4', 'x')).style).toEqual({ textDecoration: 'underline' })
+  })
+
+  it('combines a color with several decorations in one style', () => {
+    expect(onlySpan(sgr('1;3;31', 'x')).style).toEqual({
+      color: 'var(--dsw-alias-state-error-primary)',
+      fontWeight: 700,
+      fontStyle: 'italic',
+    })
+  })
+
+  it('reproduces no animation for blink, leaving the run unstyled', () => {
+    expect(onlySpan(sgr('5', 'x'))).toEqual({ text: 'x', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: sequences that carry no color', () => {
+  it('removes an OSC string with its BEL terminator', () => {
+    expect(onlySpan(`a${ESC}]0;window title\u0007b`)).toEqual({ text: 'ab', style: undefined })
+  })
+
+  it('removes an OSC string terminated by ST', () => {
+    expect(onlySpan(`a${ESC}]8;;https://example.com${ESC}\\b`)).toEqual({ text: 'ab', style: undefined })
+  })
+
+  it('removes non-CSI escapes such as charset selection and reset', () => {
+    expect(onlySpan(`x${ESC}(By${ESC}cz`)).toEqual({ text: 'xyz', style: undefined })
+  })
+
+  it('removes inert C0 controls', () => {
+    expect(onlySpan('\u0000ab\u001fc\u007f')).toEqual({ text: 'abc', style: undefined })
+  })
+
+  it('keeps CSI sequences that only move the cursor out of the text', () => {
+    expect(onlySpan(`${ESC}[2K${ESC}[1Adone`)).toEqual({ text: 'done', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: carriage returns', () => {
+  it('keeps only the last redraw of a line', () => {
+    expect(onlySpan('10%\r55%\r100%')).toEqual({ text: '100%', style: undefined })
+  })
+
+  it('leaves the tail of a longer frame standing under a shorter redraw', () => {
+    // Verified against a real terminal: `100%\rOK` paints `OK0%`. A carriage
+    // return only moves the cursor, so the two columns the redraw never reaches
+    // still hold the frame beneath — truncating to the last `\r` would lose them.
+    expect(onlySpan('100%\rOK')).toEqual({ text: 'OK0%', style: undefined })
+    expect(onlySpan('abcdef\rXY')).toEqual({ text: 'XYcdef', style: undefined })
+  })
+
+  it('clamps a backspace run at the line start rather than going negative', () => {
+    // More backspaces than characters: the cursor stops at column 0, so the
+    // following write simply overwrites from there.
+    expect(onlySpan(`ab${BS}${BS}${BS}${BS}xyz`)).toEqual({ text: 'xyz', style: undefined })
+  })
+
+  it('keeps SGR state in force across a redraw, as a terminal does', () => {
+    // Verified against a real terminal: `\x1b[31mgone\rkept` paints `kept` RED.
+    // A carriage return moves the cursor; it does not reset the graphic state,
+    // so the redraw inherits the color the discarded frame was written with.
+    expect(onlySpan(`${ESC}[31mgone\rkept`))
+      .toEqual({ text: 'kept', style: { color: 'var(--dsw-alias-state-error-primary)' } })
+  })
+
+  it('preserves both lines of a CRLF pair instead of treating it as a redraw', () => {
+    expect(parseAnsiLines('a\r\r\nb\r\n')).toEqual([
+      [{ text: 'a', style: undefined }],
+      [{ text: 'b', style: undefined }],
+      [],
+    ])
+  })
+
+  it('applies the redraw per line, not across the whole text', () => {
+    expect(parseAnsiLines('one\rtwo\nthree')).toEqual([
+      [{ text: 'two', style: undefined }],
+      [{ text: 'three', style: undefined }],
+    ])
+  })
+})
+
+describe('parseAnsiLines: backspaces', () => {
+  it('applies a backspace as the overwrite a terminal draws', () => {
+    // `abc` then two backspaces then `XY` shows as `aXY`, not `abcXY`.
+    expect(onlySpan(`abc${BS}${BS}XY`)).toEqual({ text: 'aXY', style: undefined })
+  })
+
+  it('stops at the line start instead of eating the newline before it', () => {
+    expect(parseAnsiLines(`ab\n${BS}${BS}${BS}cd`)).toEqual([
+      [{ text: 'ab', style: undefined }],
+      [{ text: 'cd', style: undefined }],
+    ])
+  })
+
+  it('treats a trailing backspace as a cursor move, not a delete', () => {
+    // Verified against a real terminal: `abc\b` still shows `abc`. Only a later
+    // write overwrites; a backspace with nothing after it erases nothing.
+    expect(onlySpan(`abc${BS}`)).toEqual({ text: 'abc', style: undefined })
+    // Same at a line boundary: the newline ends the line before any overwrite.
+    expect(parseAnsiLines(`abc${BS}\ndef`)).toEqual([
+      [{ text: 'abc', style: undefined }],
+      [{ text: 'def', style: undefined }],
+    ])
+  })
+
+  it('steps over an SGR sequence instead of erasing its bytes', () => {
+    // `abc` reset then two backspaces then `XY`: erasing the reset's bytes would
+    // corrupt it and repaint the rest of the line with whatever the remainder
+    // parses as. The visible result is `aXY`, still red, with the reset intact.
+    expect(parseAnsiLines(`${sgr('31', 'abc')}${BS}${BS}XY`)).toEqual([[
+      { text: 'a', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+      { text: 'XY', style: undefined },
+    ]])
+  })
+
+  it('erases across a style boundary without dropping the styles between', () => {
+    // The backspace reaches back past the reset to the last printed character.
+    expect(parseAnsiLines(`${sgr('32', 'ok')}${ESC}[31m${BS}bad`)).toEqual([[
+      { text: 'o', style: { color: 'var(--dsw-alias-state-success-primary)' } },
+      { text: 'bad', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+
+  it('replays a redraw and a trailing backspace as pure cursor moves', () => {
+    // Verified against a real terminal: `old\rnew\b` shows `new`. The redraw
+    // repaints all three columns and the trailing backspace only moves the
+    // cursor left — nothing overwrites the `w`, so nothing is lost.
+    expect(onlySpan(`old\rnew${BS}`)).toEqual({ text: 'new', style: undefined })
+  })
+
+  it('overwrites only the columns the later write reaches, keeping the rest styled', () => {
+    // Verified against a real terminal: red `bad`, three backspaces, then `ok`
+    // shows `okd` — the cursor returned to column 0 and `ok` overwrote two of
+    // the three columns, so the untouched `d` keeps the run's red.
+    expect(parseAnsiLines(`${sgr('31', 'bad')}${BS}${BS}${BS}ok`)).toEqual([[
+      { text: 'ok', style: undefined },
+      { text: 'd', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+})
+
+describe('parseAnsiLines: erase and column arithmetic', () => {
+  it('erases the rest of the line, the fixed companion of a redraw', () => {
+    // Verified in a real terminal: `100%\r\x1b[KOK` shows `OK`. Every spinner and
+    // progress bar writes `\r\x1b[K`; without the erase the previous frame's tail
+    // stands and the card shows text the terminal never displayed.
+    expect(onlySpan(`100%\r${ESC}[KOK`)).toEqual({ text: 'OK', style: undefined })
+    // The parameterless form and `0` are the same erase.
+    expect(onlySpan(`100%\r${ESC}[0KOK`)).toEqual({ text: 'OK', style: undefined })
+  })
+
+  it('erases the whole line for the 2K form and to the cursor for 1K', () => {
+    expect(onlySpan(`ab\r${ESC}[2Kxy`)).toEqual({ text: 'xy', style: undefined })
+    // 1K clears left of the cursor without moving it, so those columns read as
+    // blanks — verified in a real terminal, which shows `    |` for this input.
+    expect(onlySpan(`abcd${ESC}[1K|`)).toEqual({ text: '    |', style: undefined })
+  })
+
+  it('paints columns a 2K dropped as blanks when a later write lands past them', () => {
+    // 2K clears the line but leaves the cursor where it was, so writing there
+    // leaves the columns before it unwritten — blanks, as a terminal shows.
+    expect(onlySpan(`abcd${ESC}[2Kx`)).toEqual({ text: '    x', style: undefined })
+  })
+
+  it('advances a redraw cursor by tab stops, leaving a tabbed column standing', () => {
+    // Verified in a real terminal: `a\tb\rXY` shows `XY      b` — the `b` sits at
+    // column 8, which a two-character redraw cannot reach. Counting the tab as
+    // one column would have produced `XYb` and destroyed the alignment.
+    expect(onlySpan('a\tb\rXY')).toEqual({ text: 'XY      b', style: undefined })
+  })
+
+  it('counts a wide character as the two columns a terminal advances', () => {
+    // `中` occupies two cells, so a two-character redraw covers exactly it.
+    expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
+  })
+
+  it('does not accumulate a cursor or erase sequence into a cell style', () => {
+    // Only SGR carries graphic state. An erase folded into the style string
+    // would grow it per redraw and emit boundaries anser has to discard.
+    expect(parseAnsiLines(`${ESC}[31ma\r${ESC}[Kb`)).toEqual([[
+      { text: 'b', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+    ]])
+  })
+})
+
+describe('parseAnsiLines: line-end state and column widths', () => {
+  it('closes a run whose reset lands after the last written cell', () => {
+    // Verified in a real terminal: `\x1b[32mdone\rok\x1b[0m` then `plain` shows
+    // `okne` GREEN and `plain` in the DEFAULT color. The reset changes no cell,
+    // so returning the last cell's state leaked green onto every later line —
+    // and this exact shape (`\r\x1b[K\x1b[32m✓ built\x1b[0m`) is what every
+    // build tool writes.
+    expect(parseAnsiLines(`${ESC}[32mdone\rok${ESC}[0m\nplain`)).toEqual([
+      [{ text: 'okne', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'plain', style: undefined }],
+    ])
+  })
+
+  it('erases through the cursor column for 1K, not up to it', () => {
+    // Verified in a real terminal: `abcd\b\x1b[1K|` shows `   |` — the `d` under
+    // the cursor is erased too, which the CSI spec calls inclusive.
+    expect(onlySpan(`abcd${BS}${ESC}[1K|`)).toEqual({ text: '   |', style: undefined })
+  })
+
+  it('gives a combining mark no column of its own', () => {
+    // Verified in a real terminal: `é` (e + U+0301) then `x`, redrawn with `YZ`,
+    // shows `YZ`. Counting the mark as a column left the `x` standing.
+    expect(onlySpan('e\u0301x\rYZ')).toEqual({ text: 'YZ', style: undefined })
+  })
+
+  it('drops a combining mark left with no cell to attach to by a redraw', () => {
+    // Verified in a real terminal: `ab` then CR then U+0301 then `x` shows `xb`.
+    // The redraw puts the cursor at column 0, so the mark has no preceding cell
+    // and the terminal shows nothing for it rather than a lone accent.
+    expect(onlySpan(`ab\r${ACCENT}x`)).toEqual({ text: 'xb', style: undefined })
+    // A mark with no movement on its line never reaches the replay at all: it
+    // is width business, not a cursor move, so it stays as authored.
+    expect(onlySpan(`${ACCENT}abc`)).toEqual({ text: `${ACCENT}abc`, style: undefined })
+  })
+
+  it('carries a colour opened after the last write onto the next line', () => {
+    // The mirror of the reset case, verified in a real terminal: `ab` CR `X` then
+    // `\x1b[31m` with nothing after it shows `Xb` UNSTYLED and the next line red.
+    // The scan ends styled while the last cell is not, so the convergence has to
+    // open the run at the line end for it to reach the following line.
+    expect(parseAnsiLines(`ab\rX${ESC}[31m\nnext`)).toEqual([
+      [{ text: 'Xb', style: undefined }],
+      [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
+    ])
+  })
+
+  it('blanks a wide character\'s spacer once its lead cell is overwritten', () => {
+    // Verified in a real terminal: `中x` redrawn with `A` shows `A x` — the wide
+    // glyph's second cell becomes a blank rather than closing the gap, so the
+    // `x` keeps column 3.
+    expect(onlySpan('中x\rA')).toEqual({ text: 'A x', style: undefined })
+    // Covering both of its columns leaves no spacer behind.
+    expect(onlySpan('中x\rab')).toEqual({ text: 'abx', style: undefined })
+  })
+
+  it('replays an erase whose parameters carry a semicolon', () => {
+    // The replay guard has to match the same CSI shape the parser accepts, or a
+    // form like `\x1b[1;2K` skips the replay and its erase never happens.
+    expect(onlySpan(`abcd${ESC}[1;2K|`)).toEqual({ text: '    |', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: bounded state and true widths', () => {
+  it('emits one canonical sequence per boundary however the state was reached', () => {
+    // Colors that never fully reset used to accumulate raw sequence history per
+    // cell, so every boundary re-emitted the whole chain: 3200 such cells
+    // produced 25 MB and eventually a RangeError. The state is normalized now,
+    // so the emitted text stays linear in the number of cells.
+    let input = ''
+    for (let index = 0; index < 2000; index += 1) input += `${ESC}[3${index % 6 + 1}mx`
+    const emitted = parseAnsiLines(`${input}\rz`)[0] ?? []
+    expect(emitted.reduce((total, span) => total + span.text.length, 0)).toBe(2000)
+  })
+
+  it('closes an attribute with its closer instead of appending to the state', () => {
+    // `1` then `22` is bold then not-bold, which every chalk-based tool writes;
+    // appending both left the cell bold and grew the chain.
+    // Verified in a real terminal: the `22` closes the bold, so the `x` written
+    // after the redraw is PLAIN. Appending both left it bold and grew the chain.
+    expect(parseAnsiLines(`${ESC}[1mbold${ESC}[22mplain\r${ESC}[Kx`)).toEqual([[
+      { text: 'x', style: undefined },
+    ]])
+    expect(parseAnsiLines(`${ESC}[1mA${ESC}[22mB`)).toEqual([[
+      { text: 'A', style: { fontWeight: 700 } },
+      { text: 'B', style: undefined },
+    ]])
+  })
+
+  it('folds extended colors, backgrounds and every attribute closer', () => {
+    // The 256-palette and truecolor forms consume their own arguments, so the
+    // fold has to take them whole rather than as separate codes.
+    expect(parseAnsiLines(`${ESC}[38;5;208mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'rgb(255, 135, 0)' } },
+    ]])
+    expect(parseAnsiLines(`${ESC}[38;2;10;20;30mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'rgb(10, 20, 30)' } },
+    ]])
+    // A background survives the same way, and `49` closes it.
+    expect(parseAnsiLines(`${ESC}[41mA${ESC}[49mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: undefined },
+    ]])
+    // Each closer drops only its own attribute: `4` underline closed by `24`
+    // while the italic opened before it stays in force.
+    expect(parseAnsiLines(`${ESC}[3;4mA${ESC}[24mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: { fontStyle: 'italic' } },
+    ]])
+    // `39` closes a foreground without touching the background.
+    expect(parseAnsiLines(`${ESC}[31;42mA${ESC}[39mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: { backgroundColor: 'rgb(0, 187, 0)' } },
+    ]])
+  })
+
+  it('folds the remaining SGR shapes the model has to carry', () => {
+    // A 48-background in extended form, so the `48` arm and the `2`-span both run.
+    expect(parseAnsiLines(`${ESC}[48;2;1;2;3mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { backgroundColor: 'rgb(1, 2, 3)' } },
+    ]])
+    // A bright foreground and a bright background, the 90-97 / 100-107 arms.
+    expect(parseAnsiLines(`${ESC}[91mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { color: 'var(--dsw-alias-state-error-secondary)' } },
+    ]])
+    expect(parseAnsiLines(`${ESC}[101mA\r${ESC}[KB`)).toEqual([[
+      { text: 'B', style: { backgroundColor: 'rgb(255, 85, 85)' } },
+    ]])
+    // An extended form with no recognized kind byte consumes nothing extra.
+    expect(parseAnsiLines(`${ESC}[38mA\r${ESC}[KB`)).toEqual([[{ text: 'B', style: undefined }]])
+    // Re-opening an attribute already in force does not duplicate it, and a bare
+    // `\x1b[m` resets exactly as `\x1b[0m` does.
+    expect(parseAnsiLines(`${ESC}[1m${ESC}[1mA${ESC}[mB\r${ESC}[KC`)).toEqual([[
+      { text: 'C', style: undefined },
+    ]])
+  })
+
+  it('treats a text-presentation symbol as one column', () => {
+    // Verified in a real terminal: `A✓B` redrawn with `XY` shows `XYB`, so the
+    // check mark is ONE column. Taking the whole U+2600-U+27BF block as wide
+    // misaligned exactly the progress output this card exists to show.
+    expect(onlySpan('A\u2713B\rXY')).toEqual({ text: 'XYB', style: undefined })
+    // An emoji-presentation character is two, so the same redraw leaves a blank.
+    expect(onlySpan('A\u{1f600}B\rXY')).toEqual({ text: 'XY B', style: undefined })
+  })
+
+  it('clears a wide pair from either side, including through an erase', () => {
+    // Verified in a real terminal (`A x`): the redraw puts the cursor at column
+    // 0, the backspace clamps there, and writing `A` over the wide lead blanks
+    // its spacer rather than letting the `x` slide left.
+    expect(onlySpan(`\u4e2dx\r${BS}A`)).toEqual({ text: 'A x', style: undefined })
+    // An erase reaching the lead blanks its spacer through the same helper.
+    // Verified in a real terminal (`   |`): 1K blanks through the cursor column,
+    // so the wide glyph's two cells and the `x` all become blanks.
+    expect(onlySpan(`\u4e2dx${ESC}[1K|`)).toEqual({ text: '   |', style: undefined })
+  })
+
+  it('clears the lead when the write lands on the spacer itself', () => {
+    // Two backspaces from after `中x` stop ON the wide glyph's second cell;
+    // writing there blanks the lead through the spacer side of the pair clear,
+    // so the glyph cannot survive as half a character.
+    expect(onlySpan(`中x${BS}${BS}A`)).toEqual({ text: ' Ax', style: undefined })
+  })
+
+  it('keeps a surviving spacer as a blank when its lead was replaced by a spacer', () => {
+    // `好` written over the first glyph's spacer puts its own spacer on the
+    // second glyph's lead cell — a write that goes down without a pair clear.
+    // The second glyph's spacer survives with a dead lead and must emit a
+    // blank, or everything after it shifts one column left.
+    expect(onlySpan(`中中${BS}${BS}${BS}好`)).toEqual({ text: ' 好 ', style: undefined })
+  })
+
+  it('blanks both halves of a wide pair when either is overwritten', () => {
+    // A terminal cannot leave one cell of a two-cell glyph standing, so writing
+    // over the spacer clears the lead as well.
+    // Verified in a real terminal: two wide chars, CR, then `A` shows `A ` and
+    // the second glyph — writing the lead cell blanks its spacer, so the column
+    // stays occupied rather than collapsing.
+    expect(onlySpan('\u4e2d\u4e2d\rA')).toEqual({ text: 'A \u4e2d', style: undefined })
+  })
+})
+
+describe('parseAnsiLines: SGR across lines', () => {
+  it('carries active state past a newline, as a terminal does', () => {
+    // Verified in a real terminal: `\x1b[31mabc\rX\nnext` paints BOTH lines red.
+    // A newline does not reset the graphic state, so a replayed line must hand
+    // its state to the next one instead of closing it off.
+    expect(parseAnsiLines(`${ESC}[31mabc\rX\nnext`)).toEqual([
+      [{ text: 'Xbc', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
+      [{ text: 'next', style: { color: 'var(--dsw-alias-state-error-primary)' } }],
+    ])
+  })
+
+  it('tracks state through a line that needs no replay', () => {
+    // The middle line has no movement, so it is not replayed — but its own SGR
+    // still has to reach the line after it.
+    expect(parseAnsiLines(`a\r${ESC}[32mb\nplain\nc`)).toEqual([
+      [{ text: 'b', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'plain', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'c', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+    ])
+  })
+})
+
+describe('parseAnsiLines: runs spanning lines', () => {
+  it('carries one run\'s style onto every line it covers', () => {
+    expect(parseAnsiLines(sgr('32', 'first\nsecond'))).toEqual([
+      [{ text: 'first', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+      [{ text: 'second', style: { color: 'var(--dsw-alias-state-success-primary)' } }],
+    ])
+  })
+
+  it('keeps several runs of one line in order', () => {
+    expect(parseAnsiLines(`plain${sgr('31', 'red')}tail`)).toEqual([[
+      { text: 'plain', style: undefined },
+      { text: 'red', style: { color: 'var(--dsw-alias-state-error-primary)' } },
+      { text: 'tail', style: undefined },
+    ]])
+  })
+})

+ 430 - 0
packages/client/ui-primitives/tests/terminal-block.spec.tsx

@@ -0,0 +1,430 @@
+// @vitest-environment jsdom
+// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
+// arms, the prompt line's run-state dot, the exit-status pill, the head/tail height cap and its expand control,
+// and the copy control writing the raw output on both the accepted and the
+// refused clipboard paths. writeClipboard's own return contract is pinned here
+// too, since it is the seam both copy controls in this package share; the
+// resolution of ANSI runs into styles is pinned in ansi.spec.ts, so only its
+// DOM consequence (which runs get a span wrapper) is asserted here.
+
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { DEFAULT_TERMINAL_MAX_LINES, TerminalBlock } from '../src/index.ts'
+import { writeClipboard } from '../src/clipboard.ts'
+
+const ESC = '\u001b'
+
+afterEach(cleanup)
+
+beforeEach(() => {
+  vi.useRealTimers()
+})
+
+/** The rendered output rows, one string per visible line (CSS-module class prefix). */
+function outputLines(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
+}
+
+/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */
+function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } {
+  const dot = container.querySelector('[class*="_runState_"][data-state]')
+  return {
+    state: dot?.getAttribute('data-state') ?? null,
+    label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined,
+  }
+}
+
+/** The prompt rows as `<label><command>`, one per command line (the visual gap is CSS). */
+function promptRows(container: HTMLElement): string[] {
+  return [...container.querySelectorAll('[class^="_promptLine_"]')].map(row => (row.textContent ?? '').trim())
+}
+
+/** `count` numbered output lines, without the terminating newline. */
+function body(count: number): string {
+  return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n')
+}
+
+describe('TerminalBlock prompt label', () => {
+  it('collapses the home directory itself to ~', () => {
+    render(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me" />)
+    expect(screen.getByText('~')).toBeTruthy()
+  })
+
+  it('shows only the last segment below home', () => {
+    render(<TerminalBlock command="ls" cwd="/Users/me/Documents" home="/Users/me" />)
+    expect(screen.getByText('Documents')).toBeTruthy()
+  })
+
+  it('ignores trailing separators on both the cwd and home', () => {
+    const view = render(<TerminalBlock command="ls" cwd="/Users/me/" home="/Users/me" />)
+    expect(view.getByText('~')).toBeTruthy()
+    view.rerender(<TerminalBlock command="ls" cwd="/Users/me" home="/Users/me/" />)
+    expect(view.getByText('~')).toBeTruthy()
+  })
+
+  it('drops trailing separators before taking the last segment', () => {
+    render(<TerminalBlock command="ls" cwd="/Users/me/Documents///" home="/Users/me" />)
+    expect(screen.getByText('Documents')).toBeTruthy()
+  })
+
+  it('takes the last segment when no home is known', () => {
+    render(<TerminalBlock command="ls" cwd="C:\\Users\\me\\Projects" />)
+    expect(screen.getByText('Projects')).toBeTruthy()
+  })
+
+  it('collapses a backslash home path to ~', () => {
+    render(<TerminalBlock command="ls" cwd="C:\\Users\\me" home="C:\\Users\\me" />)
+    expect(screen.getByText('~')).toBeTruthy()
+  })
+
+  it('falls back to the raw path when it has no segment', () => {
+    render(<TerminalBlock command="ls" cwd="/" home="/Users/me" />)
+    expect(screen.getByText('/')).toBeTruthy()
+  })
+
+  it('renders a plain $ with no cwd', () => {
+    render(<TerminalBlock command="ls" />)
+    expect(screen.getByText('$')).toBeTruthy()
+  })
+
+  it('renders the command verbatim after the label', () => {
+    render(<TerminalBlock command="git log --oneline | head -3" cwd="/Users/me/app" />)
+    expect(screen.getByText('git log --oneline | head -3')).toBeTruthy()
+  })
+})
+
+describe('TerminalBlock states', () => {
+  it('running shows the command line only: no output, no placeholder, no copy', () => {
+    const view = render(<TerminalBlock command="sleep 5" running output="partial" />)
+    expect(view.getByText('sleep 5')).toBeTruthy()
+    expect(view.queryByText('partial')).toBeNull()
+    expect(view.queryByText('无输出')).toBeNull()
+    expect(view.queryByRole('button')).toBeNull()
+    expect(view.container.firstElementChild?.getAttribute('data-running')).toBe('')
+  })
+
+  it('running still shows a settled-looking status pill when one is supplied', () => {
+    render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
+    expect(screen.getByText('信号 SIGINT')).toBeTruthy()
+  })
+
+  it('settled with whitespace-only output shows the dimmed placeholder', () => {
+    const view = render(<TerminalBlock command="true" output={'  \n '} exitCode={0} />)
+    expect(view.getByText('无输出')).toBeTruthy()
+    expect(view.queryByRole('button', { name: '复制' })).toBeNull()
+  })
+
+  it('settled with absent output shows the placeholder', () => {
+    render(<TerminalBlock command="true" exitCode={0} />)
+    expect(screen.getByText('无输出')).toBeTruthy()
+  })
+
+  it('settled with an empty string shows the placeholder', () => {
+    render(<TerminalBlock command="true" output="" exitCode={0} />)
+    expect(screen.getByText('无输出')).toBeTruthy()
+  })
+
+  it('treats output that renders nothing visible as empty', () => {
+    // A lone reset, an OSC title, an erase: all survive `text.trim()` yet parse
+    // to nothing. Judging emptiness on the raw text drew a box of blank rows
+    // plus a copy control for invisible bytes, and hid the placeholder.
+    const view = render(<TerminalBlock command="true" output={`${ESC}[0m`} exitCode={0} />)
+    expect(view.getByText('无输出')).toBeTruthy()
+    expect(view.queryByText('复制')).toBeNull()
+    view.rerender(<TerminalBlock command="true" output={`${ESC}]0;title${ESC}\\`} exitCode={0} />)
+    expect(view.getByText('无输出')).toBeTruthy()
+  })
+
+  it('merges className onto the wrapper', () => {
+    const view = render(<TerminalBlock command="ls" className="x" output="a" />)
+    expect(view.container.firstElementChild?.classList.contains('x')).toBe(true)
+    expect(view.container.firstElementChild?.hasAttribute('data-running')).toBe(false)
+  })
+
+  it('drops the output text terminator instead of drawing a blank line', () => {
+    const view = render(<TerminalBlock command="ls" output={'a\nb\n'} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b'])
+  })
+
+  it('drops the output terminator even when a reset follows the final newline', () => {
+    // `line\n\x1b[0m` does not end in a newline as a string, yet its last parsed
+    // line holds nothing visible — a common shape, since tools close their color
+    // after the last line. Judging the terminator on the raw text added a blank
+    // row and inflated both the card height and the collapse count.
+    const view = render(<TerminalBlock command="ls" output={`a\nb\n${ESC}[0m`} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b'])
+  })
+
+  it('keeps a genuinely blank final line when the output ends with two newlines', () => {
+    const view = render(<TerminalBlock command="ls" output={'a\nb\n\n'} />)
+    expect(outputLines(view.container)).toEqual(['a', 'b', ''])
+  })
+
+  it('renders ANSI runs as styled spans and plain text bare', () => {
+    const view = render(<TerminalBlock command="ls" output={`${ESC}[31mbad${ESC}[39m ok`} />)
+    // Scoped to a line: the prompt line's run-state dot is a styled span too.
+    const span = view.container.querySelector('[class^="_line_"] span[style]')
+    expect(span?.textContent).toBe('bad')
+    expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary')
+    expect(outputLines(view.container)).toEqual(['bad ok'])
+  })
+
+  it('renders uncolored output with no span wrappers at all', () => {
+    const view = render(<TerminalBlock command="ls" output={'plain one\nplain two\n'} />)
+    expect(view.container.querySelectorAll('[class^="_line_"] span')).toHaveLength(0)
+  })
+})
+
+describe('TerminalBlock status pill', () => {
+  it('renders no pill for a clean exit', () => {
+    const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
+    expect(view.queryByText(/退出码|信号/u)).toBeNull()
+  })
+
+  it('renders no pill while the exit status is unknown', () => {
+    const view = render(<TerminalBlock command="ls" output="a" />)
+    expect(view.queryByText(/退出码|信号/u)).toBeNull()
+  })
+
+  it('renders the exit-code pill for a non-zero exit', () => {
+    render(<TerminalBlock command="false" output="a" exitCode={1} />)
+    expect(screen.getByText('退出码 1')).toBeTruthy()
+  })
+
+  it('renders the signal pill, which outranks the exit code', () => {
+    render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
+    expect(screen.getByText('信号 SIGKILL')).toBeTruthy()
+    expect(screen.queryByText(/退出码/u)).toBeNull()
+  })
+})
+
+describe('TerminalBlock run-state dot', () => {
+  it('shows the running chase and its running label while the command runs', () => {
+    const view = render(<TerminalBlock command="sleep 5" running />)
+    expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
+  })
+
+  it('shows the done dot for a clean settled exit', () => {
+    const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
+    expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
+  })
+
+  it('counts a settled command with no exit status as a clean settle', () => {
+    const view = render(<TerminalBlock command="ls" output="a" />)
+    expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
+  })
+
+  it('shows the error dot for a non-zero exit', () => {
+    const view = render(<TerminalBlock command="false" output="a" exitCode={1} />)
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+  })
+
+  it('shows the error dot for a signal, whatever the exit code says', () => {
+    const view = render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+  })
+
+  // The dot precedes the prompt label, which is what makes it read as the
+  // state OF this command rather than of the card's chrome.
+  it('places the dot ahead of the prompt label and the command', () => {
+    const view = render(<TerminalBlock command="ls" cwd="/srv/app" output="a" />)
+    const row = view.container.querySelector('[class^="_promptLine_"]')
+    expect([...row!.children].map(node => node.textContent)).toEqual(['', 'app', 'ls'])
+  })
+
+  // The cwd labels the call, not each line: a `cd` in the command moves later
+  // lines elsewhere, so repeating the label would state a directory per line
+  // that the view does not know.
+  it('labels only the first row with the cwd, leaving later rows a bare $', () => {
+    const view = render(<TerminalBlock command={'cd ~\nls'} cwd="/srv/app" output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['appcd ~', '$ls'])
+  })
+
+  it('gives a multi-line command one row per line', () => {
+    const view = render(<TerminalBlock command={'echo one\necho two'} output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
+  })
+
+  // A heredoc or an editor-authored command commonly ends in a newline; that
+  // terminator is not a further, empty command to draw a row for.
+  it('drops a trailing newline instead of drawing an empty final row', () => {
+    const view = render(<TerminalBlock command={'echo one\necho two\n'} output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['$echo one', '$echo two'])
+  })
+
+  it('keeps a genuinely blank command line when the command ends with two newlines', () => {
+    const view = render(<TerminalBlock command={'echo one\n\n'} output="a" exitCode={0} />)
+    expect(promptRows(view.container)).toEqual(['$echo one', '$'])
+  })
+
+  // The exit status the view carries is the whole call's — bash reports no
+  // per-command status — so exactly one dot and one label are correct however
+  // many lines the command spans. A dot per row would assert, of a line that
+  // succeeded inside a failing call, that the line itself failed.
+  it('marks the call once, on the first row, never per line', () => {
+    const view = render(<TerminalBlock command={'true\nfalse\ntrue'} output="x" exitCode={1} />)
+    expect(view.container.querySelectorAll('[class*="_runState_"][data-state]')).toHaveLength(1)
+    expect(view.container.querySelectorAll('[class^="_runStateLabel_"]')).toHaveLength(1)
+    expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
+    const rows = view.container.querySelectorAll('[class^="_promptLine_"]')
+    expect(rows[0]!.querySelector('[data-state]')).not.toBeNull()
+    expect(rows[1]!.querySelector('[data-state]')).toBeNull()
+    expect(rows[2]!.querySelector('[data-state]')).toBeNull()
+  })
+
+  it('keeps the running dot even while a settled-looking status pill is supplied', () => {
+    const view = render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
+    expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
+  })
+})
+
+describe('TerminalBlock height cap', () => {
+  it('renders every line and no expand control under the cap', () => {
+    const view = render(<TerminalBlock command="ls" output={body(4)} maxLines={4} />)
+    expect(outputLines(view.container)).toHaveLength(4)
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
+  })
+
+  it('does not count the output terminator against the cap', () => {
+    const view = render(<TerminalBlock command="ls" output={`${body(4)}\n`} maxLines={4} />)
+    expect(outputLines(view.container)).toHaveLength(4)
+    expect(view.container.querySelector('[aria-expanded]')).toBeNull()
+  })
+
+  it('slices head and tail over the cap and expands on click', () => {
+    const view = render(<TerminalBlock command="ls" output={body(10)} maxLines={4} />)
+    // maxLines 4: head = ceil(4/2) = 2, tail = 4 - 2 = 2, 6 hidden.
+    expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10'])
+    const toggle = view.getByRole('button', { name: '展开其余 6 行输出' })
+    expect(toggle.getAttribute('aria-expanded')).toBe('false')
+    expect(toggle.textContent).toBe('… 其余 6 行')
+
+    fireEvent.click(toggle)
+    expect(outputLines(view.container)).toHaveLength(10)
+    const collapse = view.getByRole('button', { name: '收起输出' })
+    expect(collapse.getAttribute('aria-expanded')).toBe('true')
+    expect(collapse.textContent).toBe('收起')
+
+    fireEvent.click(collapse)
+    expect(outputLines(view.container)).toEqual(['line 1', 'line 2', 'line 9', 'line 10'])
+  })
+
+  it('renders the head slice alone when the cap leaves no tail', () => {
+    const view = render(<TerminalBlock command="ls" output={body(5)} maxLines={1} />)
+    expect(outputLines(view.container)).toEqual(['line 1'])
+    expect(view.getByRole('button', { name: '展开其余 4 行输出' })).toBeTruthy()
+  })
+
+  it('caps at the documented default when maxLines is absent', () => {
+    const view = render(<TerminalBlock command="ls" output={body(DEFAULT_TERMINAL_MAX_LINES + 1)} />)
+    expect(outputLines(view.container)).toHaveLength(DEFAULT_TERMINAL_MAX_LINES)
+    expect(view.getByRole('button', { name: '展开其余 1 行输出' })).toBeTruthy()
+  })
+})
+
+describe('TerminalBlock copy', () => {
+  it('copies the raw output, never the prompt line or the pill', async () => {
+    vi.useFakeTimers()
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    const output = `${ESC}[31mbad${ESC}[39m\n`
+    render(<TerminalBlock command="make" cwd="/Users/me/app" output={output} exitCode={2} />)
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    // Escape codes, the newline terminator, and nothing of the chrome around them.
+    expect(writeText).toHaveBeenCalledWith(output)
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
+    // While the ok label is showing, further clicks are no-ops.
+    fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
+    expect(writeText).toHaveBeenCalledTimes(1)
+    await vi.advanceTimersByTimeAsync(1000)
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+  })
+
+  it('copies the whole output while the height cap hides its middle', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    const output = `${body(10)}\n`
+    render(<TerminalBlock command="ls" output={output} maxLines={4} exitCode={0} />)
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    expect(writeText).toHaveBeenCalledWith(output)
+    expect(await screen.findByRole('button', { name: '复制成功' })).toBeTruthy()
+  })
+
+  it('does not claim success when the host refuses the write', async () => {
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
+    })
+    render(<TerminalBlock command="ls" output="a" />)
+    fireEvent.click(screen.getByRole('button', { name: '复制' }))
+    await act(async () => {
+      await Promise.resolve()
+    })
+    expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
+    expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
+  })
+})
+
+describe('writeClipboard', () => {
+  it('reports true after the async Clipboard API accepts the exact text', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined)
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
+    await expect(writeClipboard('payload')).resolves.toBe(true)
+    expect(writeText).toHaveBeenCalledWith('payload')
+  })
+
+  it('reports false when the Clipboard API rejects', async () => {
+    Object.defineProperty(navigator, 'clipboard', {
+      configurable: true,
+      value: { writeText: vi.fn().mockRejectedValue(new Error('denied')) },
+    })
+    await expect(writeClipboard('payload')).resolves.toBe(false)
+  })
+
+  it('selects a detached textarea for the execCommand fallback and removes it after', async () => {
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
+    let selected: string | undefined
+    const exec = vi.fn(() => {
+      selected = document.querySelector<HTMLTextAreaElement>('textarea[readonly]')?.value
+      return true
+    })
+    Object.defineProperty(document, 'execCommand', { configurable: true, value: exec })
+    await expect(writeClipboard('payload')).resolves.toBe(true)
+    expect(exec).toHaveBeenCalledWith('copy')
+    expect(selected).toBe('payload')
+    expect(document.querySelector('textarea')).toBeNull()
+  })
+
+  it('reports execCommand\'s own refusal verbatim', async () => {
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
+    Object.defineProperty(document, 'execCommand', { configurable: true, value: vi.fn(() => false) })
+    await expect(writeClipboard('payload')).resolves.toBe(false)
+  })
+
+  it('reports false and still removes the textarea when execCommand throws', async () => {
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
+    Object.defineProperty(document, 'execCommand', {
+      configurable: true,
+      value: () => {
+        throw new Error('denied')
+      },
+    })
+    await expect(writeClipboard('payload')).resolves.toBe(false)
+    expect(document.querySelector('textarea')).toBeNull()
+  })
+
+  it('reports false on a host with neither clipboard path', async () => {
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined })
+    Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
+    await expect(writeClipboard('payload')).resolves.toBe(false)
+  })
+
+  it('reports false when navigator.clipboard exists without writeText', async () => {
+    Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {} })
+    Object.defineProperty(document, 'execCommand', { configurable: true, value: undefined })
+    await expect(writeClipboard('payload')).resolves.toBe(false)
+  })
+})

+ 8 - 0
pnpm-lock.yaml

@@ -1280,6 +1280,9 @@ importers:
       '@shikijs/langs':
         specifier: ^4.3.1
         version: 4.3.1
+      anser:
+        specifier: ^2.3.5
+        version: 2.3.5
       clsx:
         specifier: ^2.0.0
         version: 2.1.1
@@ -8274,6 +8277,9 @@ packages:
     resolution: {integrity: sha512-OyacJsaeuLUvGWOynNqYc6sx88XvyoG39wMT8SYqL3l9wwaorDW/LPRbUPfhzw0bWsUWzNCZTnFYOrWFBKsUaw==}
     engines: {node: '>= 14.0.0'}
 
+  anser@2.3.5:
+    resolution: {integrity: sha512-vcZjxvvVoxTeR5XBNJB38oTu/7eDCZlwdz32N1eNgpyPF7j/Z7Idf+CUwQOkKKpJ7RJyjxgLHCM7vdIK0iCNMQ==}
+
   ansi-regex@5.0.1:
     resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
     engines: {node: '>=8'}
@@ -13258,6 +13264,8 @@ snapshots:
       '@algolia/requester-fetch': 5.55.2
       '@algolia/requester-node-http': 5.55.2
 
+  anser@2.3.5: {}
+
   ansi-regex@5.0.1: {}
 
   ansi-regex@6.2.2: {}

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů