diff --git a/apps/web/tests/expected/plugin-install-registry/installed.expected.md b/apps/web/tests/expected/plugin-install-registry/installed.expected.md index 414c580b00..2ddc96e196 100644 --- a/apps/web/tests/expected/plugin-install-registry/installed.expected.md +++ b/apps/web/tests/expected/plugin-install-registry/installed.expected.md @@ -6,7 +6,9 @@ - button "收起安装详情" [expanded] - paragraph: 安装位置:{{cwd}}/.dsh-home/profiles/scaffold - paragraph: 第 1 次 · 中国大陆镜像源 - - text: 失败 $ pnpm add mirrored-package --registry=https://registry.npmmirror.com/ 退出码 1 + - text: 失败 $ + - group "命令第 1 行": pnpm add mirrored-package --registry=https://registry.npmmirror.com/ + - text: 退出码 1 - button "复制" - text: "ERR_PNPM_META_FETCH_FAIL GET https://registry.npmmirror.com/mirrored-package: socket hang up" - paragraph: 第 2 次 · npm 官方源 diff --git a/apps/web/tests/expected/shell-command-details/background.expected.md b/apps/web/tests/expected/shell-command-details/background.expected.md new file mode 100644 index 0000000000..ffc21b3098 --- /dev/null +++ b/apps/web/tests/expected/shell-command-details/background.expected.md @@ -0,0 +1,5 @@ +- button "Bash Start the diagnostic worker" [expanded] +- text: IN +- group "Command arguments": "{ \"command\": \"node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log\", \"description\": \"Start the diagnostic worker\", \"run_in_background\": true }" +- text: OUT started background job bash-1 +- button "Inspect" diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 2aaaaf5d0a..d121cbf579 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -20,6 +20,7 @@ import { launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' +import { expectSelectableTerminalCommand } from './terminal-command-browser.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/navigation-panes', import.meta.url)) const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') @@ -471,6 +472,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { if (await bashRow.getAttribute('aria-expanded') !== 'true') await bashRow.click() const card = page.locator('[data-sample="bash"] ~ div [data-terminal]').first() await card.waitFor({ timeout: 15_000 }) + await expectSelectableTerminalCommand(page, card, 'echo NAVIGATION_OK', 300) // 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 diff --git a/apps/web/tests/plugin-install-registry.e2e.ts b/apps/web/tests/plugin-install-registry.e2e.ts index 54a1dbe3a1..5a29cceada 100644 --- a/apps/web/tests/plugin-install-registry.e2e.ts +++ b/apps/web/tests/plugin-install-registry.e2e.ts @@ -9,6 +9,7 @@ import { chromium } from 'playwright' import { expect, it, onTestFinished } from 'vitest' import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' +import { expectSelectableTerminalCommand } from './terminal-command-browser.ts' const MIRROR = 'https://registry.npmmirror.com/' @@ -117,6 +118,8 @@ it.each(['https://registry.npmjs.org/', MIRROR])('selects the fastest responding await dialog.getByText('Installed from the registry pnpm names', { exact: true }).waitFor() await dialog.getByText('第 1 次 · 中国大陆镜像源', { exact: true }).waitFor() await dialog.getByText('第 2 次 · npm 官方源', { exact: true }).waitFor() + await expectSelectableTerminalCommand(page, dialog.locator('[data-terminal]').first(), + `pnpm add mirrored-package --registry=${MIRROR}`, 380, '命令第 1 行') expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toMatchObject({ dependencies: { 'mirrored-package': '2.0.0' } }) const installed = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) .split(process.execPath).join('{{node}}') diff --git a/apps/web/tests/shell-command-details.e2e.ts b/apps/web/tests/shell-command-details.e2e.ts new file mode 100644 index 0000000000..a219fb6941 --- /dev/null +++ b/apps/web/tests/shell-command-details.e2e.ts @@ -0,0 +1,107 @@ +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' +import { expect, it } from 'vitest' +import { ToolCallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, + seedSession, watchConsole, webSnapshotMode, +} from './scaffold.ts' +import { expandOwningTurnProcess, newEnglishPage } from './support.ts' +import { expectCommandFocusAfterResize, expectSelectableCommandText } from './terminal-command-browser.ts' + +const COMMAND = 'node ./scripts/start-worker.mjs --workspace=/tmp/diagnostics/long-workspace-name-for-background-command-details --log-level=debug --trace-tag=background-shell-full-command-selection --include=worker,lifecycle,transport,subscriptions --output=/tmp/diagnostics/background-worker.log' +const ARGS = { command: COMMAND, description: 'Start the diagnostic worker', run_in_background: true } +const OUTPUT = 'started background job bash-1' +const EXPECTED = fileURLToPath(new URL('./expected/shell-command-details/background.expected.md', import.meta.url)) + +/** A settled background launch acknowledgement rendered from durable call arguments. */ +function backgroundFixture(): string { + const session = Session.create(SessionId('shell-command-details-source')) + const callId = ToolCallId('shell-command-details-background') + const args = JSON.stringify(ARGS) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Start the diagnostic worker in the background.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + stream: [], turn: 1, step: 1, + message: createAssistantMessage({ + content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: args }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + const call = session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: args }) + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ callId, content: [{ type: 'text', text: OUTPUT }], isError: false }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + stream: [], turn: 1, step: 2, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'Diagnostic worker launched.' }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, isSeeded: false, delegationDepth: 0 }), + ...session.snapshotEvents().map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +it('expands a background Bash launch to selectable arguments and its original acknowledgement', async () => { + const scaffold = await launchWebScaffold({}) + try { + await seedSession(scaffold, backgroundFixture(), 'shell-command-details-browser') + const browser = await chromium.launch() + try { + const page = await newEnglishPage(browser) + const tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + await page.locator('[class*="frame"]').waitFor() + const group = page.getByRole('treeitem').first() + await group.waitFor() + await group.click() + await page.getByRole('treeitem').nth(1).click() + const row = page.locator('[data-sample="bash"]').first() + await expandOwningTurnProcess(page, row) + await row.waitFor() + expect(await row.getAttribute('aria-expanded')).toBe('false') + await row.click() + const card = row.locator('..') + const input = card.locator('[data-command-text]') + await input.waitFor() + expect(await input.textContent()).toBe(JSON.stringify(ARGS, null, 2)) + expect(await card.getByText(OUTPUT, { exact: true }).count()).toBe(1) + expect(await card.locator('[data-terminal]').count()).toBe(0) + expect(await card.getByRole('button', { name: 'Inspect', exact: true }).count()).toBe(1) + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + for (const width of ['', '380px']) { + await card.evaluate((element, value) => { element.style.width = value }, width) + await input.scrollIntoViewIfNeeded() + await expectSelectableCommandText(page, input, COMMAND, 'Command arguments') + await expectCommandFocusAfterResize(card, input) + } + await card.evaluate((element) => { element.style.width = '' }) + } + await page.emulateMedia({ colorScheme: null }) + await compareOrRefreshGolden(EXPECTED, + await captureStableAria(page, '[data-chat-call-id="shell-command-details-background"]', scaffold.workspaceCwd), webSnapshotMode()) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + } finally { + await browser.close() + } + } finally { + await scaffold.close() + } +}) diff --git a/apps/web/tests/terminal-command-browser.ts b/apps/web/tests/terminal-command-browser.ts new file mode 100644 index 0000000000..a1ebb77850 --- /dev/null +++ b/apps/web/tests/terminal-command-browser.ts @@ -0,0 +1,181 @@ +import type { Locator, Page } from 'playwright' +import { expect } from 'vitest' + +/** + * Scroll and select an overflowing command in its rendered terminal card. + * @param page - Browser page that owns the card. + * @param card - Terminal card rendered by the application. + * @param expected - Complete single-line command. + * @param width - Narrow card width that leaves part of the command offscreen. + * @param label - Localized accessible name for the command line. + * @returns Resolves when the tail is reachable and native copying retains the full command. + */ +export async function expectSelectableTerminalCommand(page: Page, card: Locator, expected: string, width: number, label = 'Command line 1'): Promise { + const originalWidth = await card.evaluate(element => element.style.width) + const command = card.locator('[data-command-text]').first() + try { + for (const colorScheme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme }) + await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(colorScheme === 'dark') + expect(await command.textContent()).toBe(expected) + expect(await card.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) + expect(await command.evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getClientRects().length + })).toBe(1) + await card.evaluate((element, value) => { element.style.width = `${value}px` }, width) + await command.scrollIntoViewIfNeeded() + const controls = card.locator('[class*="_cwd"], [class*="_runState"]:not([class*="Label"]), [class*="_status"], [class*="_copyButton"]') + expect(await controls.count()).toBeGreaterThanOrEqual(3) + const before = await controls.evaluateAll(elements => elements.map((element) => { + const { x, y, width: w, height } = element.getBoundingClientRect() + return { x, y, width: w, height } + })) + await expectSelectableCommandText(page, command, expected, label) + expect(await controls.evaluateAll(elements => elements.map((element) => { + const { x, y, width: w, height } = element.getBoundingClientRect() + return { x, y, width: w, height } + }))).toEqual(before) + const originalWhitespace = await card.evaluate((element) => { + const property = '--dsl-terminal-command-whitespace' + const previous = { value: element.style.getPropertyValue(property), priority: element.style.getPropertyPriority(property) } + element.style.setProperty(property, 'pre-wrap') + return previous + }) + try { + await expectNoCommandTabStop(command) + expect(await command.evaluate((element) => { + const range = document.createRange() + range.selectNodeContents(element) + return range.getClientRects().length + })).toBeGreaterThan(1) + } finally { + await card.evaluate((element, previous) => { + element.style.setProperty('--dsl-terminal-command-whitespace', previous.value, previous.priority) + }, originalWhitespace) + } + await expectCommandFocusAfterResize(card, command) + await card.evaluate((element, value) => { element.style.width = value }, originalWidth) + } + } finally { + await card.evaluate((element, value) => { element.style.width = value }, originalWidth) + await page.emulateMedia({ colorScheme: null }) + } +} + +/** + * Verify a resized command drops its explicit keyboard stop when all text fits. + * @param container - Width owner of the command. + * @param command - Mounted command text. + * @returns Resolves after widening removes the overflow-only accessibility attributes. + */ +export async function expectCommandFocusAfterResize(container: Locator, command: Locator): Promise { + const originalWidth = await container.evaluate(element => element.style.width) + const extra = await command.evaluate(element => element.scrollWidth - element.clientWidth) + const width = await container.evaluate(element => element.getBoundingClientRect().width) + try { + await container.evaluate((element, value) => { element.style.width = `${value}px` }, width + Math.max(extra, 0) + 20) + await expectNoCommandTabStop(command) + } finally { + await container.evaluate((element, value) => { element.style.width = value }, originalWidth) + } +} + +async function expectNoCommandTabStop(command: Locator): Promise { + await expect.poll(() => command.evaluate(element => ({ + overflow: element.scrollWidth > element.clientWidth + 1, + tabIndex: element.getAttribute('tabindex'), + role: element.getAttribute('role'), + label: element.getAttribute('aria-label'), + }))).toEqual({ overflow: false, tabIndex: null, role: null, label: null }) +} + +/** + * Verify native command selection within a text scrollport. + * @param page - Browser page that owns the text. + * @param command - Text scrollport containing the command, optionally within argument JSON. + * @param expected - Complete command, including its offscreen suffix. + * @param label - Localized accessible name for the overflowing text. + * @returns Resolves after horizontal navigation and native selection/copy succeed. + */ +export async function expectSelectableCommandText(page: Page, command: Locator, expected: string, label: string): Promise { + const geometry = async () => command.evaluate((element, value) => { + const text = element.firstChild + if (!(text instanceof Text) || text.length === 0) throw new Error('Command has no text node') + const offset = text.data.indexOf(value) + if (offset < 0) throw new Error('Command text does not contain the expected command') + const first = document.createRange() + first.setStart(text, offset) + first.setEnd(text, offset + 1) + const last = document.createRange() + last.setStart(text, offset + value.length - 1) + last.setEnd(text, offset + value.length) + const head = first.getBoundingClientRect() + const tail = last.getBoundingClientRect() + const viewport = element.getBoundingClientRect() + return { + head: { x: head.left, y: head.top, height: head.height }, + tail: { x: tail.right, y: tail.top }, + left: viewport.left, + right: viewport.right, + scrollWidth: element.scrollWidth, + clientWidth: element.clientWidth, + scrollLeft: element.scrollLeft, + } + }, expected) + expect(await command.textContent()).toContain(expected) + const start = await geometry() + expect(start.scrollWidth).toBeGreaterThan(start.clientWidth) + expect(start.tail.y).toBeCloseTo(start.head.y, 1) + expect(start.tail.x).toBeGreaterThan(start.right) + try { + await expect.poll(() => command.evaluate(element => ({ + tabIndex: element.getAttribute('tabindex'), + role: element.getAttribute('role'), + label: element.getAttribute('aria-label'), + }))).toEqual({ tabIndex: '0', role: 'group', label }) + await command.focus() + await page.keyboard.press('Shift+Tab') + expect(await command.evaluate(element => document.activeElement === element)).toBe(false) + await page.keyboard.press('Tab') + expect(await command.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press('ArrowRight') + await expect.poll(() => command.evaluate(element => element.scrollLeft)).toBeGreaterThan(0) + await page.keyboard.press('ArrowLeft') + await expect.poll(() => command.evaluate(element => element.scrollLeft)).toBe(0) + await command.hover() + await page.mouse.wheel(start.scrollWidth, 0) + await expect.poll(async () => { + const end = await geometry() + return end.tail.x <= end.right + 1 && end.tail.x >= end.left && end.scrollLeft > 0 + }).toBe(true) + await page.mouse.wheel(-start.scrollWidth, 0) + await expect.poll(() => command.evaluate(element => element.scrollLeft)).toBe(0) + const head = await geometry() + await page.mouse.move(head.head.x + 0.1, head.head.y + head.head.height / 2) + await page.mouse.down() + try { + await page.mouse.move(head.right - 1, head.head.y + head.head.height / 2, { steps: 5 }) + await page.mouse.wheel(start.scrollWidth, 0) + await expect.poll(async () => { + const value = await geometry() + return value.tail.x <= head.right + 1 ? 'visible' : JSON.stringify({ before: head, after: value }) + }).toBe('visible') + const end = await geometry() + await page.mouse.move(end.tail.x - 0.1, head.head.y + head.head.height / 2) + } finally { + await page.mouse.up() + } + await expect.poll(() => page.evaluate(() => document.getSelection()?.toString())).toBe(expected) + await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) + await page.keyboard.press('ControlOrMeta+C') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expected) + } finally { + await command.evaluate((element) => { + element.scrollLeft = 0 + element.blur() + document.getSelection()?.removeAllRanges() + }) + } +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index d867a5f164..295c9d1c56 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -59,6 +59,8 @@ "tests/plan-review.e2e.ts", "tests/steering.e2e.ts", "tests/navigation-panes.e2e.ts", + "tests/shell-command-details.e2e.ts", + "tests/terminal-command-browser.ts", "tests/focus-rings.e2e.ts", "tests/chat-scroll-fixture.ts", "tests/trajectory-virtualization.e2e.ts", diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.i18n.yaml new file mode 100644 index 0000000000..fe08933d1f --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.md +/: + en: 0722f826c1c12360 + zh: 9b48ce197b6091f2 +/terminal-command-labels: + en: a75e371670f4ebc3 + zh: cbad7dd538376e70 +/terminal-command-labels/change: + en: 0e386cd5b5d349dc + zh: c456641a3aa938b3 +/terminal-command-labels/migration: + en: 2044b801804a40aa + zh: 0f98e7da80e2f066 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.md new file mode 100644 index 0000000000..fdebb0aba5 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Custom TerminalBlock consumers must provide localized accessible names for overflowing command lines." +--- + +# Terminal command labels + +English | [中文](guide.zh.md) + +## Change + +`TerminalBlockLabels` from `@deepseek-ai/dsh-client-ui-primitives` requires a `commandLine(line: number): string` formatter. Previously, callers supplied no accessible name for command lines. Each horizontally overflowing line now uses this name for its keyboard-focusable group; lines that fit, including wrapped lines, add no Tab stop. This affects custom UI plugins that render `TerminalBlock`. + +## Migration + +1. Add `commandLine` to each labels object passed to `TerminalBlock`. The argument is the authored line number, starting at one. +2. Return a localized name, such as “Command line 1”, through the plugin's locale dictionary. Keep the original command text separate from this name. +3. Rebuild the plugin. Verify that a long command can receive keyboard focus and scroll with the arrow keys, and that a short or wrapped command adds no Tab stop. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.zh.md new file mode 100644 index 0000000000..39e63f5c69 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/terminal-command-labels/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "自定义 TerminalBlock 消费者必须为横向溢出的命令行提供本地化的无障碍名称。" +--- + +# 终端命令标签 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-client-ui-primitives` 的 `TerminalBlockLabels` 要求提供 `commandLine(line: number): string` 格式化函数。此前,调用者不提供命令行的无障碍名称。现在,每条横向溢出的命令行使用这个名称标识可通过键盘聚焦的分组;能够完整显示的行(包括折行后的行)不会增加 Tab 停靠点。这会影响渲染 `TerminalBlock` 的自定义 UI 插件。 + +## 迁移 + +1. 为传给 `TerminalBlock` 的每个 labels 对象添加 `commandLine`。参数是原始命令文本的行号,从一开始。 +2. 通过插件的语言字典返回本地化名称,例如“命令第 1 行”。名称与原始命令文本分开保留。 +3. 重新构建插件。确认长命令可通过键盘聚焦并使用方向键滚动,短命令或折行后的命令则不会增加 Tab 停靠点。 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 5f978ed0c8..4703ae3440 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -308,6 +308,7 @@ export const zh = { 'row.preparing': '正在准备调用', 'row.failed': '失败', 'row.stopped': '已停止', + 'row.commandArguments': '命令参数', 'row.input': '输入', 'row.output': '输出', 'row.inspect': '查看', @@ -369,6 +370,7 @@ export const zh = { 'terminal.signal': '信号 {signal}', 'terminal.exitCode': '退出码 {code}', 'terminal.noExitCode': '未正常退出', + 'terminal.commandLine': '命令第 {n} 行', 'terminal.running': '运行中', 'terminal.failed': '失败', 'terminal.done': '已完成', @@ -684,6 +686,7 @@ export const en = { 'row.preparing': 'Preparing tool call', 'row.failed': 'Failed', 'row.stopped': 'Stopped', + 'row.commandArguments': 'Command arguments', 'row.input': 'IN', 'row.output': 'OUT', 'row.inspect': 'Inspect', @@ -745,6 +748,7 @@ export const en = { 'terminal.signal': 'signal {signal}', 'terminal.exitCode': 'exit code {code}', 'terminal.noExitCode': 'no exit code', + 'terminal.commandLine': 'Command line {n}', 'terminal.running': 'Running', 'terminal.failed': 'Failed', 'terminal.done': 'Done', diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 2bca64cb3b..3dddcd1c14 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -126,6 +126,7 @@ function formatDuration(elapsedMs: number, t: TranslateNS): string { /** Localized display copy for the embedded terminal panel. */ function terminalLabels(t: TranslateNS): TerminalBlockLabels { return { + commandLine: line => t('terminal.commandLine', { n: line }), // The labels contract requires exit-fact formatters, but this panel never // passes exit facts, so TerminalBlock never invokes them. /* v8 ignore next */ diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index 466288d01e..5db43e0514 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -34,6 +34,7 @@ export const zh = { 'terminal.signal': '信号 {signal}', 'terminal.exitCode': '退出码 {code}', 'terminal.noExitCode': '未正常退出', + 'terminal.commandLine': '命令第 {n} 行', 'terminal.running': '运行中', 'terminal.failed': '已失败', 'terminal.done': '已完成', @@ -77,6 +78,7 @@ export const en: Record = { 'terminal.signal': 'signal {signal}', 'terminal.exitCode': 'exit {code}', 'terminal.noExitCode': 'no exit code', + 'terminal.commandLine': 'Command line {n}', 'terminal.running': 'running', 'terminal.failed': 'failed', 'terminal.done': 'done', diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 0726a4e307..d9c4fc68fc 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -18,8 +18,8 @@ en: 921a2c708727d077 zh: 26a80d58a875bb3b /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle: - en: 36acfd5776cfae9b - zh: be23d0a20c051dc5 + en: a5d83df504b38101 + zh: ddbcd4e940779843 /deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/switching-a-bundle: en: 2d29970dcd0b2836 zh: 73f6b519546d2b6b diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index f037910487..de79ee0b1b 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -52,7 +52,7 @@ On first use, when pnpm uses the unconfigured official npm registry and npmmirro The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://registry.npmmirror.com/-/ping` through its normal fetch proxy. A 2xx response wins; redirects and failures do not. The remaining request is cancelled, both response bodies are discarded, and cleanup completes before the result returns. The default deadline is 1500 ms and results, including unavailable results, are cached for five minutes. Configure `registryProbeTimeoutMs` and `registryProbeCacheTtlMs` on `ui-plugin-manager`; `registryProbeEnabled: false` disables probing. If both requests fail or time out, the existing default remains selected. No IP-location service or Session content is involved. -**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the package-name example; **Use example** drops it into the field. Typing or pasting the Git or local-path template verbatim still raises the reminder asking for the actual value. A trust box above **Install** stays visible including when the guide is collapsed, and explains that on-demand Official entries offer explicit Update, while other plugins are upgraded by uninstalling and reinstalling. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. A finished install shows the version pnpm installed; when a run that asked one registry installed another version than the one read, it names both versions, attributes the difference to pnpm's supply-chain security policy (`minimumReleaseAge`), states the risk of a newly published version, and gives the exact `name@version` to install after uninstalling. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. +**Add plugin** takes a package name with an optional version, a Git address, a tarball, or an absolute local path; the dialog says a package name is what follows `dsh plugin add` in a README. Enter in the package or custom-registry field submits the install request, but Enter used to confirm an input-method candidate does not. **Install guide and examples** under the field opens a guide that shows the package-name example; **Use example** drops it into the field. Typing or pasting the Git or local-path template verbatim still raises the reminder asking for the actual value. A trust box above **Install** stays visible including when the guide is collapsed, and explains that on-demand Official entries offer explicit Update, while other plugins are upgraded by uninstalling and reinstalling. **Registry** beside it names the registry the install asks first and unfolds the choice: pnpm's own registry, named by the registry it actually names — the official npm registry, the mainland China mirror, or its host — which keeps the neutral default name until the Host reads it, is listed once, and carries its host unless that host is already its name; each mirror the Host configured (`pluginManager.registries`), npmmirror as the mainland China mirror; and a typed http(s) address. The options use an opaque selector panel, fill rows only on hover, and show hosts in tertiary text without parentheses. The custom-address area highlights on hover; clicking anywhere in it selects the custom registry and focuses its input, whose focus style matches the package field. Keyboard focus alone preserves the selected registry; activating the custom radio or editing the address selects the custom registry. Tab moves from the selected radio to the address, and Shift+Tab moves back; leaving either end closes the selector and returns focus to **Registry**. Switching to an offered registry retains the custom URL draft for switching back. Submitting an invalid custom address blocks installation, opens the registry selector, and focuses the address. Invalid package and custom-address fields keep a red error border and add the shared blue inset focus ring on focus. The options float over the dialog from the control, so unfolding them never lengthens the card, and the card's content scrolls when the guide makes it taller than the viewport. The initial choice follows the response comparison above; the choice is then remembered in this browser (`localStorage`) and starts the next dialog, and a remembered address the Host no longer offers stays as a typed one and remains the requested address when installation starts before the registry list returns. **Install** first asks the Host to read what the spec names (`pluginManager.inspect`) at the chosen registry: a name the list already shows, a name no registry has, a path without a package, a package without a bundle patch, or a spec pnpm would refuse comes back under the field as one sentence, with the spec kept for editing; when no registry could be reached, the sentence names every one asked. The install then starts at the registry that answered. An accepted spec opens the installing screen, which shows the package's name, one-liner, and version as the Host read them and folds pnpm's command and output behind **Show install details**. Each command line stays on one line and scrolls horizontally; selecting its text includes offscreen arguments. A finished install shows the version pnpm installed; when a run that asked one registry installed another version than the one read, it names both versions, attributes the difference to pnpm's supply-chain security policy (`minimumReleaseAge`), states the risk of a newly published version, and gives the exact `name@version` to install after uninstalling. A finished install offers **Enable now**, which switches the new bundle on, closes the dialog, and scrolls the list to it; closing instead leaves it installed and off. While the Host moves on to another registry, the installing screen says which registry could not serve the package and which is asked now, and each pnpm run behind the details carries a badge naming its registry. A failed install says what went wrong in one line, as the Host attributed it — no registry could be reached, naming every one asked; the host a GitHub address or a tarball link is fetched from could not be reached, which no registry helps; the package was not found, the disk is full, the profile is not writable, pnpm blocked a build script — with pnpm's output behind the details and **Retry** at hand, and **Change registry** beside it when the Host laid the failure at the registry it asked, which returns to the spec with the registry options open; the Host has already put the profile files back. When pnpm blocked a dependency's install scripts, the failed screen lists the packages whose scripts wait for permission and offers **Allow these scripts and retry** in place of **Retry**; the Host saves the permission in the profile's `pnpm-workspace.yaml`, which a failed run leaves as pnpm wrote it, then runs pnpm again, and the installed screen names what was allowed. A successful installation does not certify that a module can activate. During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 9787d2d4b4..936cf2ef46 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https://registry.npmmirror.com/-/ping` 发送 GET 请求,以最先返回 2xx 的源为结果;重定向和失败不参与选择。随后取消另一条请求、丢弃两个响应体,并在清理完成后返回。默认时限为 1500 毫秒,包括不可用结果在内的探测结果均缓存五分钟。在 `ui-plugin-manager` 上配置 `registryProbeTimeoutMs` 和 `registryProbeCacheTtlMs`;`registryProbeEnabled: false` 关闭探测。两条请求都失败或超时时保留现有默认源。不使用 IP 地区服务,也不发送 Session 内容。 -**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出包名示例;**填入示例**把它填进输入框。输入框中若原样保留 Git 地址或本地路径的模板文本,仍会显示要求替换为实际值的提醒。**安装**按钮上方的提示框在引导收起后也可见,并说明官方按需插件提供显式更新;其他插件通过卸载后重新安装升级。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。安装完成的画面显示 pnpm 实际安装的版本;只问过一个注册表的运行装入的版本与检查读到的不同时,会写明两个版本,说明差异来自 pnpm 供应链安全策略(`minimumReleaseAge`),提示新发布版本的风险,并给出卸载后应安装的精确 `name@version`。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 +**添加插件**接受包名(可带版本)、Git 地址、压缩包或本地绝对路径;对话框说明包名就是 README 里 `dsh plugin add` 后面的那一段。在包名或自定义安装源输入框中按 Enter 会提交安装请求,但用于确认输入法候选词的 Enter 不会触发安装。输入框下方的**插件安装引导和示例**展开一段引导,给出包名示例;**填入示例**把它填进输入框。输入框中若原样保留 Git 地址或本地路径的模板文本,仍会显示要求替换为实际值的提醒。**安装**按钮上方的提示框在引导收起后也可见,并说明官方按需插件提供显式更新;其他插件通过卸载后重新安装升级。旁边的**安装源**写着安装首先询问的注册表,展开后可选:pnpm 自身的注册表,按它实际指向的源命名——npm 官方源、中国大陆镜像源,其余以主机名显示——Host 读不到时保留中性的默认名称,只列一次,选项标题带上主机名(主机名即名称时不重复显示);Host 配置的每个镜像(`pluginManager.registries`),npmmirror 显示为中国大陆镜像源;以及手动输入的 http(s) 地址。选项使用不透明选择面板,仅悬停行使用底色,主机名不加括号并使用三级文字色。自定义地址区域在悬停时高亮;点击其中任意位置会选中自定义源并聚焦输入框,焦点样式与包名输入框一致。仅通过键盘聚焦不会改变已选安装源;激活自定义单选项或编辑地址才会选中自定义源。Tab 从已选单选项移到地址框,Shift+Tab 反向返回;从任一端移出时关闭选择器,并将焦点交还**安装源**按钮。切到预设源时保留自定义地址草稿,切回后继续使用。提交无效的自定义地址时会阻止安装,展开安装源选择器并聚焦地址框。包名和自定义地址输入框无效时保留红色错误边框,聚焦时加上共享的蓝色内嵌焦点环。选项从控件上浮在对话框之上,展开不会拉长卡片;引导使卡片高过视口时,卡片内容可以滚动。初始选择遵循上文的响应比较规则;之后选择记在本浏览器(`localStorage`)里,下次打开对话框从它开始,Host 不再提供的已记住地址保留为手动输入的地址;在安装源列表返回前开始安装,也仍请求该地址。**安装**先让 Host 在所选注册表读出 spec 指向什么(`pluginManager.inspect`):列表中已有的名字、所有注册表都没有的名字、没有包的路径、没有组合包 patch 的包,或 pnpm 会拒绝的 spec,都以一句话回到输入框下方,spec 保留可继续编辑;所有注册表都连不上时,这句话列出问过的每一个。随后安装从作答的那个注册表开始。通过检查的 spec 打开安装中界面,展示 Host 读到的包名、一句话简介和版本,pnpm 的命令与输出折叠在**查看安装详情**之后。每条命令行保持单行并可横向滚动;选择文本时可包含屏幕外的参数。安装完成的画面显示 pnpm 实际安装的版本;只问过一个注册表的运行装入的版本与检查读到的不同时,会写明两个版本,说明差异来自 pnpm 供应链安全策略(`minimumReleaseAge`),提示新发布版本的风险,并给出卸载后应安装的精确 `name@version`。安装完成后提供**立即启用**:启用新组合包、关闭对话框并把列表滚动到它;直接关闭则让它保持已安装但关闭。Host 改问另一个注册表时,安装中界面会说明哪个源没能提供这个包、现在改问哪个,详情里每次 pnpm 运行都带一个写明所用源的标记。安装失败时按 Host 的归因用一行话说明原因——所有安装源都连不上,并列出问过的每一个;GitHub 地址或压缩包链接自身的主机连不上,换源无济于事;包不存在、磁盘已满、profile 不可写、pnpm 拦下了构建脚本——pnpm 输出在详情里,**重试**就在手边,Host 把失败归于所问注册表时还在旁边提供**更换安装源**,回到 spec 输入界面并展开安装源选项;Host 已经把 profile 文件放回原样。pnpm 拦下依赖的安装脚本时,失败界面列出等待允许的包,并以**允许这些脚本并重试**取代**重试**;Host 把授权写进 profile 的 `pnpm-workspace.yaml`(失败的运行保留 pnpm 写入的这个文件)再运行 pnpm,安装完成界面会说明允许了哪些脚本。安装成功不代表模块一定能够激活。 准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 10c689b9af..afd254073f 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -732,6 +732,7 @@ const INSTALL_TERMINAL_LINES = 12 /** The install terminal's display copy, from the tab's dictionary. */ function terminalLabels(t: Translate): TerminalBlockLabels { return { + commandLine: line => t('terminalCommandLine', { n: String(line) }), /* v8 ignore next -- the Host reports a killed pnpm as a null exit code, never a signal name; the label interface needs one */ signal: signal => t('terminalSignal', { signal }), exitCode: code => t('terminalExitCode', { code: String(code) }), diff --git a/packages/client/ui-plugin-manager/src/client/locales.ts b/packages/client/ui-plugin-manager/src/client/locales.ts index f94e1a3259..79e889931f 100644 --- a/packages/client/ui-plugin-manager/src/client/locales.ts +++ b/packages/client/ui-plugin-manager/src/client/locales.ts @@ -165,6 +165,7 @@ export const zh = { installFailureTimeout: '安装超时', installFailurePnpmMissing: '没有找到 pnpm,无法安装', installFailureGeneric: '安装过程中出错,原因见安装详情', + terminalCommandLine: '命令第 {n} 行', terminalRunning: '运行中', terminalFailed: '失败', terminalDone: '已完成', @@ -379,6 +380,7 @@ export const en = { installFailureTimeout: 'The install timed out', installFailurePnpmMissing: 'pnpm was not found, so nothing can be installed', installFailureGeneric: 'Something went wrong during the install; the details say what', + terminalCommandLine: 'Command line {n}', terminalRunning: 'Running', terminalFailed: 'Failed', terminalDone: 'Done', diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2a527cf249..432fd0ca04 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -18,8 +18,8 @@ en: 7bae72c97d0449f5 zh: 4589ecc2b216edc5 /deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog: - en: f093268cd20a1c14 - zh: 861c1fae527264fb + en: 66d83132d97e56c6 + zh: fb47139ce1f742fe /deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons: en: f0e54915b896af5a zh: 2047520fd321be90 @@ -42,8 +42,8 @@ en: 8bbfa14c27f3bfd7 zh: 88f7548ac3a114e1 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/geometry-and-overflow: - en: 49f6341d81ded00e - zh: cab87dc54a42ba69 + en: e283bf2366b43879 + zh: d089076d7834e853 /deepseek-ai-dsh-client-ui-primitives/further-exploration: en: e82b713cdb2f954b zh: a877f34b148f433f diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 9ce34c7095..37f795dcfb 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -66,6 +66,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `SettingsFormModel`, `settingsNumberField`, `settingsTextField` | The staged-edit model behind such a page over a settings scope: drafts are staged and written on save, a field is overridden by its presence in the user layer, and a save that did not land keeps its drafts. | | `JsonTree`, `JsonBlock` | Read-only JSON inspection. | | `MarkdownText`, `MarkdownDelegateProvider`, `CodeBlock` | Untrusted GFM with TeX math, owner-delegated HTTP(S) navigation, and highlighted code. `CodeBlock` accepts opt-in `lineNumbers`; copied source excludes the gutter, and `contentRef` exposes its stable source wrapper to an owner that uses it as a scrollport. Set `showHeader={false}` when the owner supplies its own language and copy toolbar. | +| `CommandText` | Selectable command or argument text; only horizontal overflow adds a named keyboard focus target. | | `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, `WebBlock` | The agent-output card matching each tool-result intent. | | `icons/*`, `FishLogo`, `BrandWordmark`, `ReferenceIconRegular`/`ReferenceIconMedium`, `LinkIconRegular`/`LinkIconMedium` | Glyphs and brand marks. The thinking glyph keeps an inset orbit within its 16px view box. Use `LinkIconMedium` for 14px clickable-link categories and known-site marks. | | `PermissionIconReadOnlyRegular`/`Medium`, `PermissionIconWorkspaceWriteRegular`/`Medium`, `PermissionIconFullAccessRegular`/`Medium` | Permission-mode glyphs for read-only, workspace-write, and full-access choices. | @@ -160,7 +161,7 @@ While a reply streams, `MarkdownText` parses incrementally: all but the trailing ### Geometry and overflow -The output cards share one geometry model: `white-space: pre` with horizontal scrolling so column-aligned content keeps its alignment, and a head-plus-tail slice behind an expand button past `maxLines` (default 16) so a long body never stretches the card. `TerminalBlock` parses ANSI into React spans with a per-line column buffer for cursor movement, honoring erase-in-line, tab stops, and character width. Hosts opt out of the shared geometry per surface: rebinding `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` to `pre-wrap` wraps commands and output in full with no sideways scroll, `maxLines: Infinity` disables the fold for hosts capping height through `--dsl-terminal-output-max-height` instead, `copyText` overrides the copy payload (and keeps the control rendered before any output), and `runStateDot: false` omits the run-state dot when the surrounding row already carries the state, reclaiming its gutter via `--dsl-terminal-gutter`. The banner divider follows the rendered body, so a running card that streams live output separates its command from the text like a settled card. +The output cards share one geometry model: `white-space: pre` with horizontal scrolling so column-aligned content keeps its alignment, and a head-plus-tail slice behind an expand button past `maxLines` (default 16) so a long body never stretches the card. `TerminalBlock` uses `CommandText` to keep each authored command line selectable and horizontally scrollable; the prompt and status stay fixed. Only horizontally overflowing text enters the Tab order as a named group, using the owner’s localized `commandLine(line)` label. Text that fits, including wrapped Jobs commands, adds no Tab stop. Size, text, and font changes update focusability without changing the scroll position. `TerminalBlock` parses ANSI into React spans with a per-line column buffer for cursor movement, honoring erase-in-line, tab stops, and character width. Hosts opt out of the shared geometry per surface: rebinding `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` to `pre-wrap` wraps commands and output in full with no sideways scroll, `maxLines: Infinity` disables the fold for hosts capping height through `--dsl-terminal-output-max-height` instead, `copyText` overrides the copy payload (and keeps the control rendered before any output), and `runStateDot: false` omits the run-state dot when the surrounding row already carries the state, reclaiming its gutter via `--dsl-terminal-gutter`. The banner divider follows the rendered body, so a running card that streams live output separates its command from the text like a settled card. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 92a8de27b0..cbd07fc394 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -66,6 +66,7 @@ kind: "package-library" | `SettingsFormModel`、`settingsNumberField`、`settingsTextField` | 这类页面背后基于设置 scope 的暂存编辑模型:草稿先暂存、保存时写入,字段是否被覆盖看用户层是否含有它,未落地的保存保留草稿。 | | `JsonTree`、`JsonBlock` | 只读 JSON 查看。 | | `MarkdownText`、`MarkdownDelegateProvider`、`CodeBlock` | 不可信 GFM 与 TeX 数学、owner 委托的 HTTP(S) 导航,以及高亮代码。`CodeBlock` 可通过 `lineNumbers` 开启行号;复制的源码不含行号栏,`contentRef` 则向需要把稳定源码包装节点用作滚动区的 owner 提供该节点。调用方提供自己的语言与复制工具栏时,设置 `showHeader={false}`。 | +| `CommandText` | 可选中的命令或参数文本;仅横向溢出时添加具名的键盘聚焦目标。 | | `TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock`、`WebBlock` | 与各类工具结果意图对应的 agent 输出卡片。 | | `icons/*`、`FishLogo`、`BrandWordmark`、`ReferenceIconRegular`/`ReferenceIconMedium`、`LinkIconRegular`/`LinkIconMedium` | 字形与品牌标识。思考图标的轨道在 16px 视口内保留内边距。`LinkIconMedium` 用于 14px 的可点击链接分类及已知站点标记。 | | `PermissionIconReadOnlyRegular`/`Medium`、`PermissionIconWorkspaceWriteRegular`/`Medium`、`PermissionIconFullAccessRegular`/`Medium` | 只读、工作区写入与完全访问选项使用的权限模式图形。 | @@ -160,7 +161,7 @@ kind: "package-library" ### 几何与溢出 -输出卡片共享同一套几何模型:`white-space: pre` 并横向滚动,让按列对齐的内容保持对齐;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制,长正文不会撑高卡片。`TerminalBlock` 把 ANSI 解析为 React span,并带逐行列缓冲处理光标移动,遵循行内擦除、制表位与字符宽度。宿主可按表层选择退出共享几何:把 `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` 重绑为 `pre-wrap` 让命令与输出完整换行且不横向滚动;`maxLines: Infinity` 为改用 `--dsl-terminal-output-max-height` 限高滚动的宿主禁用折叠;`copyText` 覆盖复制载荷(并让控件在任何输出出现之前就保持渲染);`runStateDot: false` 在外围行已携带同一状态时省去状态点,并经 `--dsl-terminal-gutter` 收回其落区。横幅分割线跟随渲染出的正文:正在流式输出的 running 卡片像已结束卡片一样把命令与文本分隔开。 +输出卡片共享同一套几何模型:`white-space: pre` 并横向滚动,让按列对齐的内容保持对齐;超过 `maxLines`(默认 16)时折叠为头部切片加尾部切片,由展开按钮控制,长正文不会撑高卡片。`TerminalBlock` 通过 `CommandText` 让每条原始命令行可选中并横向滚动;提示符和状态保持固定。只有横向溢出的文本以具名 group 进入 Tab 顺序,名称使用 owner 的本地化 `commandLine(line)` 标签。能完整显示的文本(包括 Jobs 中已换行的命令)不会添加 Tab 停靠点。尺寸、文本与字体变化会更新可聚焦状态,不改变滚动位置。`TerminalBlock` 把 ANSI 解析为 React span,并带逐行列缓冲处理光标移动,遵循行内擦除、制表位与字符宽度。宿主可按表层选择退出共享几何:把 `--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace` 重绑为 `pre-wrap` 让命令与输出完整换行且不横向滚动;`maxLines: Infinity` 为改用 `--dsl-terminal-output-max-height` 限高滚动的宿主禁用折叠;`copyText` 覆盖复制载荷(并让控件在任何输出出现之前就保持渲染);`runStateDot: false` 在外围行已携带同一状态时省去状态点,并经 `--dsl-terminal-gutter` 收回其落区。横幅分割线跟随渲染出的正文:正在流式输出的 running 卡片像已结束卡片一样把命令与文本分隔开。 diff --git a/packages/client/ui-primitives/src/CommandText.module.css b/packages/client/ui-primitives/src/CommandText.module.css new file mode 100644 index 0000000000..b6b0a3d816 --- /dev/null +++ b/packages/client/ui-primitives/src/CommandText.module.css @@ -0,0 +1,12 @@ +.text { + display: block; + min-width: 0; + overflow-x: auto; + white-space: var(--dsl-command-whitespace, pre); + overflow-wrap: anywhere; +} + +.text:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: -2px; +} diff --git a/packages/client/ui-primitives/src/CommandText.tsx b/packages/client/ui-primitives/src/CommandText.tsx new file mode 100644 index 0000000000..4670857799 --- /dev/null +++ b/packages/client/ui-primitives/src/CommandText.tsx @@ -0,0 +1,46 @@ +/** Selectable command text with keyboard access while horizontally overflowing. */ +import { useLayoutEffect, useRef, useState } from 'react' +import clsx from 'clsx' +import css from './CommandText.module.css' + +/** Display text and owner-localized identification for a command scrollport. */ +export interface CommandTextProps { + /** Original command or formatted command arguments, without truncation. */ + text: string + /** Accessible name while the text can scroll horizontally. */ + label: string + /** Owner typography and positioning. */ + className?: string | undefined +} + +/** + * Render command text without adding Tab stops for content that fits. + * @param props - Text, localized scrollport name, and owner styling. + * @returns A horizontal text scrollport, named and keyboard-focusable only when overflowing. + */ +export function CommandText({ text, label, className }: CommandTextProps) { + const ref = useRef(null) + const [overflow, setOverflow] = useState(false) + useLayoutEffect(() => { + const element = ref.current + /* v8 ignore next -- the span is rendered unconditionally. */ + if (element === null) return undefined + const measure = () => { setOverflow(element.scrollWidth > element.clientWidth) } + measure() + const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure) + observer?.observe(element) + const fonts = 'fonts' in element.ownerDocument ? element.ownerDocument.fonts : undefined + fonts?.addEventListener('loadingdone', measure) + return () => { + observer?.disconnect() + fonts?.removeEventListener('loadingdone', measure) + } + }, [text]) + return ( + {text} + ) +} diff --git a/packages/client/ui-primitives/src/TerminalBlock.module.css b/packages/client/ui-primitives/src/TerminalBlock.module.css index c4d4ddda1a..71196558b1 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.module.css +++ b/packages/client/ui-primitives/src/TerminalBlock.module.css @@ -110,18 +110,11 @@ color: var(--dsw-alias-label-tertiary); } -/* Default `pre`, not `nowrap`: the prompt row renders the command verbatim, - and `nowrap` would collapse the repeated spaces, tabs, and alignment of an - indented continuation. Hosts opt into wrapping long commands by rebinding - the variable to `pre-wrap`; the ellipsis then simply never engages, and - overflow-wrap is inert while white-space stays `pre`. */ +/* Each authored command line scrolls independently of its prompt and status. + `pre` preserves whitespace; hosts can opt into wrapping with `pre-wrap`. */ .command { - min-width: 0; + --dsl-command-whitespace: var(--dsl-terminal-command-whitespace, pre); color: var(--dsw-alias-label-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: var(--dsl-terminal-command-whitespace, pre); - overflow-wrap: anywhere; } /* Capped to the prompt's line height (Pill's own 24px height would exceed a diff --git a/packages/client/ui-primitives/src/TerminalBlock.tsx b/packages/client/ui-primitives/src/TerminalBlock.tsx index 76e7f92808..a6737605a8 100644 --- a/packages/client/ui-primitives/src/TerminalBlock.tsx +++ b/packages/client/ui-primitives/src/TerminalBlock.tsx @@ -6,6 +6,7 @@ import { useCopyFeedback } from './use-copy-feedback.ts' import { Pill } from './Pill.tsx' import { StateDot, type StateDotState } from './StateDot.tsx' import css from './TerminalBlock.module.css' +import { CommandText } from './CommandText.tsx' /** Output lines shown before the height cap collapses the middle. */ export const DEFAULT_TERMINAL_MAX_LINES = 16 @@ -15,6 +16,8 @@ export const DEFAULT_TERMINAL_MAX_LINES = 16 * (this package is cordis-free, so copy arrives via props). */ export interface TerminalBlockLabels { + /** Accessible name for an overflowing authored command line, numbered from one. */ + commandLine: (line: number) => string /** Status pill text for a signal-terminated command. */ signal: (signal: string) => string /** Status pill text for a non-zero exit code. */ @@ -44,7 +47,7 @@ export interface TerminalBlockLabels { } export interface TerminalBlockProps { - /** The command line, rendered verbatim after the prompt label. */ + /** The command, rendered verbatim after the prompt label; long lines scroll horizontally unless the host enables wrapping. */ command: string /** Working directory for the prompt label; absent renders a plain `$`. */ cwd?: string | undefined @@ -244,7 +247,7 @@ export function TerminalBlock({ {index > 0 || cwd === undefined ? '$' : promptLabel(cwd, home)} - {line} + ))} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index a76b58d346..7fe8783d6a 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -83,6 +83,8 @@ export type { RelativeTime, RelativeTimeUnit } from './relative-time.ts' export { JsonTree } from './JsonTree.tsx' export type { JsonTreeProps, JsonTreeLabels } from './JsonTree.tsx' export { TerminalBlock, DEFAULT_TERMINAL_MAX_LINES } from './TerminalBlock.tsx' +export { CommandText } from './CommandText.tsx' +export type { CommandTextProps } from './CommandText.tsx' export type { TerminalBlockProps, TerminalBlockLabels } from './TerminalBlock.tsx' export { ReadBlock, DEFAULT_READ_MAX_LINES } from './ReadBlock.tsx' export type { ReadBlockProps, ReadBlockLine, ReadBlockLabels } from './ReadBlock.tsx' diff --git a/packages/client/ui-primitives/tests/command-text.client.spec.tsx b/packages/client/ui-primitives/tests/command-text.client.spec.tsx new file mode 100644 index 0000000000..da67a5ed13 --- /dev/null +++ b/packages/client/ui-primitives/tests/command-text.client.spec.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { CommandText } from '../src/CommandText.tsx' +import { TerminalBlock } from '../src/TerminalBlock.tsx' +import { terminalBlockLabels } from './labels.client.ts' + +const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts') + +beforeEach(() => { + vi.stubGlobal('ResizeObserver', undefined) + vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100) + vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) { + return (this.textContent?.length ?? 0) * 10 + }) +}) + +afterEach(() => { + cleanup() + vi.unstubAllGlobals() + vi.restoreAllMocks() + if (originalFonts === undefined) Reflect.deleteProperty(document, 'fonts') + else Object.defineProperty(document, 'fonts', originalFonts) +}) + +it('names and focuses overflowing text as it changes, without changing its scroll position', () => { + const view = render() + const command = view.getByText('echo ok') + expect(command.hasAttribute('tabindex')).toBe(false) + expect(command.hasAttribute('role')).toBe(false) + expect(command.hasAttribute('aria-label')).toBe(false) + view.rerender() + expect(view.getByRole('group', { name: 'Command line 1' })).toBe(command) + expect(command.tabIndex).toBe(0) + command.scrollLeft = 40 + view.rerender() + expect(command.scrollLeft).toBe(40) + view.rerender() + expect(command.hasAttribute('tabindex')).toBe(false) + expect(command.hasAttribute('aria-label')).toBe(false) +}) + +it('updates focusability after resizing and font loading, and releases both observers', () => { + let resize: (() => void) | undefined + const observe = vi.fn() + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe = observe + disconnect = disconnect + }) + const fonts = new EventTarget() + Object.defineProperty(document, 'fonts', { configurable: true, value: fonts }) + const removeListener = vi.spyOn(fonts, 'removeEventListener') + const width = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300) + const view = render() + const command = view.getByText('echo wider command') + expect(observe).toHaveBeenCalledWith(command) + expect(command.hasAttribute('tabindex')).toBe(false) + if (resize === undefined) throw new Error('Missing resize observer') + width.mockReturnValue(100) + act(resize) + expect(command.tabIndex).toBe(0) + width.mockReturnValue(300) + act(() => { fonts.dispatchEvent(new Event('loadingdone')) }) + expect(command.hasAttribute('tabindex')).toBe(false) + view.unmount() + expect(disconnect).toHaveBeenCalledOnce() + expect(removeListener).toHaveBeenCalledWith('loadingdone', expect.any(Function)) + width.mockClear() + fonts.dispatchEvent(new Event('loadingdone')) + expect(width).not.toHaveBeenCalled() +}) + +it('adds one named Tab stop for the overflowing line of a multiline command', () => { + const view = render() + const commands = [...view.container.querySelectorAll('[data-command-text]')] + expect(commands.map(command => command.hasAttribute('tabindex'))).toEqual([false, true, false]) + expect(view.getByRole('group', { name: '命令第 2 行' })).toBe(commands[1]) +}) diff --git a/packages/client/ui-primitives/tests/labels.client.ts b/packages/client/ui-primitives/tests/labels.client.ts index 4d5ae803ad..369031de86 100644 --- a/packages/client/ui-primitives/tests/labels.client.ts +++ b/packages/client/ui-primitives/tests/labels.client.ts @@ -42,6 +42,7 @@ export const searchBlockLabels: SearchBlockLabels = { } export const terminalBlockLabels: TerminalBlockLabels = { + commandLine: line => `命令第 ${line} 行`, signal: signal => `信号 ${signal}`, exitCode: code => `退出码 ${code}`, noExitCode: '未正常退出', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index d0c929008d..04158a1ca7 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -21,8 +21,8 @@ en: 2e3c306b13d3bc25 zh: 77127f01763b3976 /deepseek-ai-dsh-client-ui-tool/use-this-package/built-in-views: - en: 2e06c7de0f7f0139 - zh: 9ec0f94c2d7d60bf + en: 4d885fc693b497f4 + zh: 2f68d9ea92f2ebd6 /deepseek-ai-dsh-client-ui-tool/understand-the-implementation: en: 00bc7b0c84edc603 zh: 1cfcf5b435248a91 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index bfbe63f150..c8f88095b1 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -51,7 +51,7 @@ Every registered view receives the explicit `preparing`, `start`, and `result` p File-tool rows prefer a complete `file_path`; other generic rows, including search and unregistered tools, prefer a non-empty `description` before their variant-specific argument fallback. Read `args` during rendering or view construction: a preparing reader grows in place, and only previously observed answer changes trigger subsequent block publication. -This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows share one left-to-right highlight across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable. Background Bash calls expand to their original arguments and job acknowledgement. Their input scrolls horizontally without soft wrapping and remains selectable; only horizontal overflow adds a named keyboard focus target. Inspect is available when provided. The acknowledgement does not report the job's exit status. A native or PTC dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. `ui-skill` demonstrates a business-owned registration for `skill`. Question rows pair answers with questions by id, using the tool result or the `userQuestions` projection for late replies. An answerable call has a button to reopen its panel. Once a late reply is in the agent Inbox, the row shows its submitted answers; “View answers” expands its read-only transcript while admission is pending. A settled call with answers opens the panel read-only. Closed calls without answers and invalid records offer no button. Cancelled and interrupted rows show their verdict without inventing answers. If no panel provider accepts the button action, it expands the row. Unsupported or ambiguous input falls back to flattened Tool text. diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index aabbce7eda..6b0d28087b 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -51,7 +51,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字 文件工具行优先使用完整的 `file_path`;其他通用行(包括搜索和未注册的工具)优先使用非空 `description`,再回退到各自类别的参数摘要。应在渲染或构造视图时读取 `args`:准备态读器原地增长,后续仅在已观察答案变化时重新发布块。 -本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开。后台 Bash 调用可展开查看原始参数与后台任务回执。输入内容可横向滚动,不自动折行,并可选中;仅横向溢出时添加具名的键盘聚焦目标。提供 Inspect 时也可使用。回执不表示后台任务的退出状态。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 问题行按 id 配对问题与答案;答案来自工具结果,迟到回答则来自 `userQuestions` 投影。可回答的调用提供按钮以重新打开面板。迟到回复进入 agent Inbox 后,问题行显示已提交的答案;等待准入期间,「查看回答」展开只读的行内记录。带有答案的已结算调用则以只读方式打开面板。没有答案的已关闭调用和无效记录不提供按钮。已取消和已中断的行显示裁决,不虚构答案。若面板提供方不接受按钮操作,点击会展开该行。不受支持或含糊的输入回退为压平的工具文本。 diff --git a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts index 9a21683649..d6fe0fa3e0 100644 --- a/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts +++ b/packages/client/ui-tool/src/client/tool/models/terminal-card-model.ts @@ -17,6 +17,7 @@ import { recordedAbsolutePath } from './recorded-path.ts' */ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlockLabels { return { + commandLine: line => t('terminal.commandLine', { n: line }), signal: signal => t('terminal.signal', { signal }), exitCode: code => t('terminal.exitCode', { code }), noExitCode: t('terminal.noExitCode'), @@ -210,6 +211,16 @@ function shellCall(name: string, args: Record): ShellCall | nul } } +/** + * Identify a background shell launch whose result acknowledges a job, not its exit. + * @param block - Tool block at any call stage. + * @returns whether the call requests a background shell job. + */ +export function isBackgroundShellCall(block: ToolCallBlock): boolean { + const parsed = parsedToolCall(block) + return parsed !== null && shellCall(parsed.name, parsed.args)?.background === true +} + /** * Identify a settled root call from the persistent Bash or PowerShell tool. * Its result stays on the generic input/output path because the persistent diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css index 6d44e524fb..e6ba3f505e 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.module.css @@ -77,6 +77,10 @@ color: var(--dsw-alias-state-error-primary); } +.commandInput { + color: var(--dsw-alias-label-secondary); +} + /* ToolRow's unified expand interaction, replicated per the registrant posture: pointer on the expandable row (the icon→chevron hover preview is the affordance, no row fill). */ diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index c029ef86aa..f23e369d0b 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -3,11 +3,12 @@ import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular, - TerminalBlock, TextShimmer, + CommandText, TerminalBlock, TextShimmer, } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { + isBackgroundShellCall, isSettledPersistentShellCall, isSpilledShellCall, localizeTerminalCardModel, @@ -55,10 +56,12 @@ export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSe : model.state const status = stateStatus(state, t) const { expanded, toggle: toggleExpand } = useDisclosure() - // Failures, persistent-shell results, and spill previews use a generic body; - // background acknowledgements and malformed calls remain collapsed. + const background = isBackgroundShellCall(block) + // Background launches expose their acknowledgement without assigning the + // job an exit status. Failures, persistent results, and spill previews also + // keep the generic input/output presentation. const genericBody = terminal === null - && (model.state === 'error' || isSettledPersistentShellCall(block) || isSpilledShellCall(block)) + && (model.state === 'error' || isSettledPersistentShellCall(block) || isSpilledShellCall(block) || background) && (model.bodyRaw !== null || model.output !== null) const expandable = terminal !== null || genericBody const open = expanded && expandable @@ -132,7 +135,9 @@ export const BashRow = memo(function BashRow({ toolName, block, sessionId, useSe {body !== null && (
{t('row.input')} - {body} + {background + ? + : {body}}
)} {body !== null && model.output !== null && ( diff --git a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx index d06a4ddf68..a891bad758 100644 --- a/packages/client/ui-tool/tests/terminal-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.client.spec.tsx @@ -527,14 +527,53 @@ describe('BashRow terminal card', () => { expect(view.container.querySelectorAll('[class*="_ioText_"]')[1]?.textContent).toBe(output) }) - it('a non-terminal bash call (background start) renders the summary row alone', () => { + it.each([undefined, 'dispatch-parent'])('expands a background launch and its acknowledgement without reporting a job exit (parent: %s)', (parentCallId) => { + const command = 'pnpm --filter @deepseek-ai/dsh-client-ui-tool run build --watch --verbose' + const args = { command, description: 'Wait', run_in_background: true } + const argsRaw = JSON.stringify(args) + const inspect = vi.fn() + const parent = parentCallId === undefined ? {} : { parentCallId } + const view = render() + const row = view.container.querySelector('[data-sample="bash"]')! + expect(view.getByText('Wait')).toBeTruthy() + expect(row.getAttribute('aria-expanded')).toBe('false') + + fireEvent.keyDown(row, { key: 'Enter' }) + + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-command-text]')?.textContent) + .toBe(JSON.stringify(args, null, 2)) + expect(view.container.querySelector('[data-command-text]')?.hasAttribute('tabindex')).toBe(false) + expect(view.queryByText('输出')).toBeNull() + expect(view.container.querySelector('[data-terminal]')).toBeNull() + + const acknowledgement = 'started background job job-1' + view.rerender() + + expect(row.getAttribute('aria-expanded')).toBe('true') + expect(view.container.querySelector('[data-command-text]')?.textContent) + .toBe(JSON.stringify(args, null, 2)) + expect(view.getByText(acknowledgement)).toBeTruthy() + expect(view.container.querySelector('[data-terminal], [data-state="done"]')).toBeNull() + fireEvent.click(view.getByRole('button', { name: t('row.inspect') })) + expect(inspect).toHaveBeenCalledOnce() + + fireEvent.click(row) + expect(row.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText(acknowledgement)).toBeNull() + }) + + it('keeps malformed shell arguments collapsed even when background execution is requested', () => { const view = render() expect(view.getByText('Wait')).toBeTruthy() - expect(view.queryByText(/a\.ts/)).toBeNull() expect(view.container.querySelector('[data-sample="bash"]')?.getAttribute('role')).toBeNull() + expect(view.queryByText('started background job job-1')).toBeNull() }) it('expands a generic execution error to its original args and full output', () => { diff --git a/tsconfig.host.json b/tsconfig.host.json index aed3888cf8..61ee9d67e3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,6 +51,8 @@ "apps/web/tests/plan-review.e2e.ts", "apps/web/tests/steering.e2e.ts", "apps/web/tests/navigation-panes.e2e.ts", + "apps/web/tests/shell-command-details.e2e.ts", + "apps/web/tests/terminal-command-browser.ts", "apps/web/tests/focus-rings.e2e.ts", "apps/web/tests/chat-scroll-fixture.ts", "apps/web/tests/trajectory-virtualization.e2e.ts",