fix(client): localize terminal send presentation

This commit is contained in:
imccyu
2026-08-24 10:24:18 +08:00
parent bfc145cc7c
commit 1dd6bf1973
11 changed files with 152 additions and 54 deletions
@@ -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 .agents/notes/implemented/architecture/2026-08-23-client-derived-tool-presentation.md
2026-08-23-client-derived-tool-presentation.md: 957d2e6c1a79cb0b0a246066463e6ac960fb3d5b
2026-08-23-client-derived-tool-presentation.zh.md: 5fa1a350963ff74306520bad6a4fc89765c592a7
2026-08-23-client-derived-tool-presentation.md: 77fb48552625c1b22908225b0116eeba14c6046c
2026-08-23-client-derived-tool-presentation.zh.md: 50cc447f9f4ca2e946de10d2df31ca18382d95ef
@@ -312,6 +312,8 @@ Standard shell results continue parsing trailing `[exit code: N]` and `[killed b
Call `description` remains above the card and overrides the collapsed summary. Workdir continues handling absolute, relative, and missing values. Relative paths resolve against the Session cwd while preserving normalization for `.`, `..`, drive letters, and UNC roots.
For `terminal_send`, non-empty input and the session id remain verbatim tool data; the empty-input fallback and session label resolve through the render site's conversation locale.
Standard and persistent providers sharing the same tool name are a special compatibility point. The Client uses currently valid argument and result features to preserve their delivered differences. Input that cannot be identified unambiguously uses a Generic settled result rather than gaining new presentation.
`TerminalBlock` ANSI handling, cursor replay, wide characters, line limits, expansion, copying, and assistive text remain unchanged.
@@ -312,6 +312,8 @@ Client terminal model 从工具名称、调用参数、结果 content、error、
调用 `description` 继续显示在 card 上方并覆盖折叠摘要。workdir 继续按绝对、相对和缺失三种情况处理;相对路径基于 Session cwd,且保留 `.``..`、盘符与 UNC root 的归一化。
对于 `terminal_send`,非空 input 与 session id 保持为逐字工具数据;空 input fallback 与 session label 通过 render site 的 conversation locale 解析。
同名普通与 persistent provider 是特殊兼容点。Client 使用当前有效参数与结果特征保留已交付差异;不足以无歧义识别的输入选择 Generic settled 结果,不增加新表现。
TerminalBlock 的 ANSI、光标重放、宽字符、行数上限、展开、复制与辅助技术文本完全不变。
@@ -145,6 +145,8 @@ export const zh = {
'terminal.collapseAria': '收起输出',
'terminal.expandAria': '展开其余 {n} 行输出',
'terminal.expandRest': '… 其余 {n} 行',
'terminal.sendInput': '(发送输入)',
'terminal.session': '终端 {sessionId}',
} satisfies Record<string, string>
/** The conversation namespace key union. */
@@ -288,4 +290,8 @@ export const en = {
'terminal.collapseAria': 'Collapse output',
'terminal.expandAria': 'Expand the remaining {n} output lines',
'terminal.expandRest': '… {n} more lines',
// The Host terminal_send presenter has no locale seat; keep its fallbacks
// aligned with these English values.
'terminal.sendInput': '(send input)',
'terminal.session': 'Terminal {sessionId}',
} satisfies Record<ConversationKey, string>
@@ -4,7 +4,9 @@ import type { ToolDetailsProps } from '../contract/slots.ts'
import { diffCardModel } from './models/diff-card-model.ts'
import { readCardModel } from './models/read-card-model.ts'
import { searchCardModel } from './models/search-card-model.ts'
import { terminalBlockLabels, terminalCardModel } from './models/terminal-card-model.ts'
import {
localizeTerminalCardModel, terminalBlockLabels, terminalCardModel,
} from './models/terminal-card-model.ts'
import {
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from './models/primitive-labels.ts'
@@ -22,8 +24,9 @@ export function ToolDetails({
block, cwd, useHostDescription, t,
}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostDescription' | 't'>) {
const home = useHostDescription(description => description?.home)
const terminal = terminalCardModel(block, cwd)
if (terminal !== null) {
const terminalModel = terminalCardModel(block, cwd)
if (terminalModel !== null) {
const terminal = localizeTerminalCardModel(terminalModel, t)
return (
<>
{terminal.description !== undefined ? (
@@ -7,7 +7,9 @@ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts'
import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts'
import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts'
import { terminalBlockLabels, type TerminalCardModel } from '../models/terminal-card-model.ts'
import {
localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel,
} from '../models/terminal-card-model.ts'
import {
diffBlockLabels, readBlockLabels, searchBlockLabels, webBlockLabels,
} from '../models/primitive-labels.ts'
@@ -106,7 +108,9 @@ export function ToolRow({
const readLabels = useMemo(() => readBlockLabels(t), [t])
const searchLabels = useMemo(() => searchBlockLabels(t), [t])
const webLabels = useMemo(() => webBlockLabels(t), [t])
const terminalBody = terminal ?? null
const terminalBody = terminal === undefined || terminal === null
? null
: localizeTerminalCardModel(terminal, t)
const diffBody = diff ?? null
const readBody = read ?? null
const searchBody = search ?? null
@@ -118,7 +122,7 @@ export function ToolRow({
const status = stateStatus(state, t)
// A failure must replace, not supplement, the normal summary.
const failureLine = state === 'error' ? errorSummary ?? null : null
const summaryText = failureLine ?? summary
const summaryText = failureLine ?? terminalBody?.description ?? summary
const suffix = failureLine === null ? summarySuffix ?? null : null
const fileLink = filePath !== undefined && onOpenFile !== undefined && failureLine === null
const toggleExpand = () => {
@@ -37,16 +37,48 @@ export function terminalBlockLabels(t: TranslateNS<'conversation'>): TerminalBlo
*/
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.
* The locale-neutral props {@link TerminalBlock} draws. The render site adds
* `command` after resolving {@link copy} through its locale seat.
*/
card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
card: Pick<TerminalBlockProps, 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
/**
* The model-authored call description rendered above the card. Absent for
* persistent shells, whose parameter set has no description.
* Verbatim Tool data or semantic `terminal_send` data. Product copy stays
* unresolved until a render site supplies its locale seat.
*/
description: string | undefined
copy:
| { readonly kind: 'shell'; readonly command: string; readonly description: string | undefined }
| { readonly kind: 'terminal-send'; readonly text: string; readonly sessionId: string }
}
interface LocalizedTerminalCardModel {
readonly card: Pick<TerminalBlockProps, 'command' | 'cwd' | 'output' | 'exitCode' | 'signal' | 'running'>
readonly description: string | undefined
}
/**
* Resolve locale-owned `terminal_send` copy while preserving Tool-authored
* shell commands and descriptions verbatim.
* @param model - locale-neutral terminal card data.
* @param t - the render site's conversation locale seat.
* @returns terminal props and description ready for rendering.
*/
export function localizeTerminalCardModel(
model: TerminalCardModel,
t: TranslateNS<'conversation'>,
): LocalizedTerminalCardModel {
if (model.copy.kind === 'shell') {
return {
card: { command: model.copy.command, ...model.card },
description: model.copy.description,
}
}
return {
card: {
command: model.copy.text === '' ? t('terminal.sendInput') : model.copy.text,
...model.card,
},
description: t('terminal.session', { sessionId: model.copy.sessionId }),
}
}
/**
@@ -140,6 +172,7 @@ function collapse(body: string, rooted: boolean, separator = '/'): string {
}
interface ShellCall {
kind: 'shell'
command: string
description: string | undefined
workdir: string | undefined
@@ -159,10 +192,11 @@ function shellCall(name: string, args: Record<string, unknown>): ShellCall | nul
// Standard dsh-tool-bash and dsh-tool-pwsh schemas require `description`;
// persistent shell providers omit it. Their parameter roots stay open, so
// unrelated fields do not change their running-card behavior.
return { command, description: undefined, workdir: undefined, persistent: true, background: false }
return { kind: 'shell', command, description: undefined, workdir: undefined, persistent: true, background: false }
}
if (typeof description !== 'string' || description.trim() === '') return null
return {
kind: 'shell',
command,
description,
workdir,
@@ -172,8 +206,9 @@ function shellCall(name: string, args: Record<string, unknown>): ShellCall | nul
}
interface TerminalSendCall {
command: string
description: string
kind: 'terminal-send'
text: string
sessionId: string
background: boolean
}
@@ -184,10 +219,9 @@ function terminalSendCall(name: string, args: Record<string, unknown>): Terminal
if (submit !== undefined && typeof submit !== 'boolean') return null
if (background !== undefined && typeof background !== 'boolean') return null
return {
// Keep this visible fallback aligned with dsh-tool-terminal's
// `terminal_send.presentCall` implementation.
command: text || '(send input)',
description: `Terminal ${sessionId}`,
kind: 'terminal-send',
text,
sessionId,
background: background === true,
}
}
@@ -213,7 +247,7 @@ function parseExitStatus(text: string): { output: string; exitCode?: number; sig
* the generic path.
* @param block - running or settled Tool block.
* @param sessionCwd - session workspace root used to resolve workdir.
* @returns the terminal-card props, or null for the generic path.
* @returns locale-neutral terminal-card data, or null for the generic path.
*/
export function terminalCardModel(
block: ToolCallBlock,
@@ -222,19 +256,17 @@ export function terminalCardModel(
if (block.parentCallId !== undefined) return null
const parsed = parsedToolCall(block)
if (parsed === null) return null
const shell = shellCall(parsed.name, parsed.args)
const send = terminalSendCall(parsed.name, parsed.args)
if (shell === null && send === null) return null
if (shell?.background === true || send?.background === true) return null
const call = shellCall(parsed.name, parsed.args) ?? terminalSendCall(parsed.name, parsed.args)
if (call === null || call.background) return null
const command = shell?.command ?? send?.command ?? ''
const description = shell?.description ?? send?.description
const cwd = resolveTerminalCwd(shell?.workdir, sessionCwd)
const copy: TerminalCardModel['copy'] = call.kind === 'shell'
? { kind: 'shell', command: call.command, description: call.description }
: { kind: 'terminal-send', text: call.text, sessionId: call.sessionId }
const cwd = resolveTerminalCwd(call.kind === 'shell' ? call.workdir : undefined, sessionCwd)
if (!('kind' in block)) {
return {
description,
copy,
card: {
command,
cwd,
output: undefined,
exitCode: undefined,
@@ -243,14 +275,13 @@ export function terminalCardModel(
},
}
}
if (block.isError || shell?.persistent === true) return null
if (block.isError || (call.kind === 'shell' && call.persistent)) return null
const output = singleResultText(block)
if (output === undefined) return null
const status = shell === null ? { output } : parseExitStatus(output)
const status = call.kind === 'terminal-send' ? { output } : parseExitStatus(output)
return {
description,
copy,
card: {
command,
cwd,
output: status.output,
exitCode: status.exitCode,
@@ -47,7 +47,7 @@ export function GenericToolCard({ toolName, block, cwd, home, openFile, inspect,
toolName={toolName}
icon={VARIANT_ICONS[model.variant]}
title={t(model.titleKey)}
summary={terminal?.description ?? model.summary}
summary={model.summary}
// Single-file tools never expose an args body — the path link is the only
// args interaction. A card is not an args body: a read/write/edit row is
// single-file AND carries a card, so the card expands under the path link.
@@ -6,7 +6,9 @@ import {
} 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 { terminalBlockLabels, terminalCardModel, terminalFailed } from '../models/terminal-card-model.ts'
import {
localizeTerminalCardModel, terminalBlockLabels, terminalCardModel, terminalFailed,
} from '../models/terminal-card-model.ts'
import { toolRowModel, type ToolRowState } from '../models/tool-call-model.ts'
import { CONVERSATION_NS as NS } from '../../locale.ts'
import css from './bash-sample.module.css'
@@ -38,10 +40,11 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
// An omitted shell workdir is the session workspace; relative values resolve
// against it before reaching the terminal primitive.
const cwd = useSessions(list => list.byId[sessionId]?.cwd)
const terminal = terminalCardModel(block, cwd)
const terminalModel = terminalCardModel(block, cwd)
const terminal = terminalModel === null ? null : localizeTerminalCardModel(terminalModel, t)
// A failing exit status is the terminal card's own error signal (the call
// itself settles isError:false), surfaced as the row's red state dot.
const state = model.state === 'ok' && terminal !== null && terminalFailed(terminal)
const state = model.state === 'ok' && terminalModel !== null && terminalFailed(terminalModel)
? 'error'
: model.state
const status = stateStatus(state, t)
@@ -12,19 +12,23 @@ import type {
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { terminalCardModel, terminalFailed } from '../src/client/tool/models/terminal-card-model.ts'
import {
localizeTerminalCardModel, terminalCardModel, terminalFailed,
} from '../src/client/tool/models/terminal-card-model.ts'
import { createChatStore } from '@deepseek-ai/dsh-client-ui-chat/src/client/stores.ts'
import { GenericToolCard, type GenericToolCardProps } from '../src/client/tool/toolviews/GenericToolCard.tsx'
import { DetailsPanel } from '@deepseek-ai/dsh-client-ui-chat/src/client/details/DetailsPanel.tsx'
import { BashRow } from '../src/client/tool/toolviews/bash-sample.tsx'
import { renderToolDetails, toolChatSnapshot, useEmptyTrajectory } from './tool-details-render.client.tsx'
import { zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { en, zh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { zh as chatZh } from '@deepseek-ai/dsh-client-ui-chat/src/client/locale.ts'
type BashRowProps = Parameters<typeof BashRow>[0]
const t: GenericToolCardProps['t'] = makeTranslate(zh, commonZh)
const enT: GenericToolCardProps['t'] = makeTranslate(en, commonEn)
const chatT = makeTranslate(chatZh, commonZh)
afterEach(cleanup)
@@ -65,9 +69,9 @@ const settled = (over?: Partial<ToolResultNode>): ToolResultNode => ({
describe('terminalCardModel', () => {
it('derives a running standard-shell card from raw arguments', () => {
expect(terminalCardModel(running({ argsRaw: shellArgs({ workdir: '/projects/app' }) }))).toEqual({
description: 'List files',
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
card: {
command: 'ls -la', cwd: '/projects/app', output: undefined,
cwd: '/projects/app', output: undefined,
exitCode: undefined, signal: undefined, running: true,
},
})
@@ -78,9 +82,9 @@ describe('terminalCardModel', () => {
call: { name: 'bash', argsRaw: shellArgs({ workdir: '/projects/app' }) },
content: [{ type: 'text', text: 'boom\n[exit code: 2]' }],
}))).toEqual({
description: 'List files',
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
card: {
command: 'ls -la', cwd: '/projects/app', output: 'boom',
cwd: '/projects/app', output: 'boom',
exitCode: 2, signal: undefined, running: false,
},
})
@@ -163,10 +167,13 @@ describe('terminalCardModel', () => {
const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: 'make' })
const run = running({ name: 'terminal_send', argsRaw })
expect(terminalCardModel(run, '/w/app')).toMatchObject({
description: 'Terminal pty-3', card: { command: 'make', cwd: '/w/app', running: true },
copy: { kind: 'terminal-send', text: 'make', sessionId: 'pty-3' },
card: { cwd: '/w/app', running: true },
})
const done = settled({ call: { name: 'terminal_send', argsRaw }, content: [{ type: 'text', text: 'ok' }] })
expect(terminalCardModel(done)?.card).toMatchObject({ command: 'make', output: 'ok', running: false })
expect(localizeTerminalCardModel(terminalCardModel(done)!, enT)).toMatchObject({
description: 'Terminal pty-3', card: { command: 'make', output: 'ok', running: false },
})
expect(terminalCardModel(settled({
call: { name: 'terminal_send', argsRaw: JSON.stringify({ sessionId: 'pty-3', text: 'make', run_in_background: true }) },
}))).toBeNull()
@@ -176,10 +183,10 @@ describe('terminalCardModel', () => {
it('preserves persistent-shell running cards and settled generic output', () => {
const persistent = JSON.stringify({ command: 'pwd' })
expect(terminalCardModel(running({ argsRaw: persistent }))).toMatchObject({
description: undefined, card: { command: 'pwd', running: true },
copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true },
})
expect(terminalCardModel(running({ name: 'pwsh', argsRaw: persistent }))).toMatchObject({
description: undefined, card: { command: 'pwd', running: true },
copy: { kind: 'shell', command: 'pwd', description: undefined }, card: { running: true },
})
expect(terminalCardModel(settled({ call: { name: 'bash', argsRaw: persistent } }))).toBeNull()
expect(terminalCardModel(settled({ call: { name: 'pwsh', argsRaw: persistent } }))).toBeNull()
@@ -190,8 +197,22 @@ describe('terminalCardModel', () => {
call: { name: 'pwsh', argsRaw: ARGS },
content: [{ type: 'text', text: 'failed\n[exit code: 3]' }],
}))).toMatchObject({
description: 'List files',
card: { command: 'ls -la', output: 'failed', exitCode: 3, running: false },
copy: { kind: 'shell', command: 'ls -la', description: 'List files' },
card: { output: 'failed', exitCode: 3, running: false },
})
})
it('keeps terminal_send copy semantic until the render locale is known', () => {
const model = terminalCardModel(running({
name: 'terminal_send',
argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }),
}))!
expect(model.copy).toEqual({ kind: 'terminal-send', text: '', sessionId: 'pty-3' })
expect(localizeTerminalCardModel(model, t)).toMatchObject({
description: '终端 pty-3', card: { command: '(发送输入)' },
})
expect(localizeTerminalCardModel(model, enT)).toMatchObject({
description: 'Terminal pty-3', card: { command: '(send input)' },
})
})
@@ -323,6 +344,20 @@ describe('chat row terminal body', () => {
expect(runStateOf(view.container)).toBe('ongoing')
})
it.each([
{ locale: 'zh', translate: t, description: '终端 pty-3', command: '(发送输入)' },
{ locale: 'en', translate: enT, description: 'Terminal pty-3', command: '(send input)' },
])('renders terminal_send copy through the $locale locale', ({ translate, description, command }) => {
const block = running({
name: 'terminal_send',
argsRaw: JSON.stringify({ sessionId: 'pty-3', text: '' }),
})
const view = render(<GenericToolCard {...ownerProps(block)} toolName="terminal_send" t={translate} />)
expect(view.getByText(description)).toBeTruthy()
toggleRow(view)
expect(view.getByText(command)).toBeTruthy()
})
it('a non-terminal call keeps the args-JSON text body', () => {
const view = render(<GenericToolCard {...ownerProps(settled({
call: { name: 'bash', argsRaw: shellArgs({ run_in_background: true }) },
@@ -527,6 +562,18 @@ describe('DetailsPanel Output section', () => {
expect(description.compareDocumentPosition(card!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
it('localizes terminal_send copy in Details', () => {
const argsRaw = JSON.stringify({ sessionId: 'pty-3', text: '' })
const view = mount(snapshot({
nodes: [settled({
call: { name: 'terminal_send', argsRaw },
content: [{ type: 'text', text: 'ok' }],
})],
}), { ...target, toolName: 'terminal_send' })
expect(view.getByText('终端 pty-3')).toBeTruthy()
expect(view.getByText('(发送输入)')).toBeTruthy()
})
it('resolves the prompt cwd against the session workspace', () => {
const view = mount(snapshot({ nodes: [settled()] }), target, '/w/app')
// No workdir in the call args: the prompt label is the workspace basename.
+2 -2
View File
@@ -284,8 +284,8 @@ export function apply(ctx: Context, config: Config = {}): void {
if (parsed.run_in_background === true) {
return { card: 'generic', title: `Send to terminal ${parsed.sessionId as string} in background`, kind: 'execute', rawInput: parsed.text }
}
// Keep this visible fallback aligned with dsh-client-ui-tool's
// terminal-card model, which cannot import this Host package.
// Keep these Host-only fallbacks aligned with the conversation locale
// keys `terminal.sendInput` and `terminal.session` used by Web.
return { card: 'terminal', title: parsed.text || '(send input)', description: `Terminal ${parsed.sessionId as string}` }
},
presentResult(args, result) {