diff --git a/packages/cli/src/i18n/index.test.ts b/packages/cli/src/i18n/index.test.ts index 5c20ccb328f..2e67b31ef4d 100644 --- a/packages/cli/src/i18n/index.test.ts +++ b/packages/cli/src/i18n/index.test.ts @@ -153,3 +153,59 @@ describe('supported language resolution', () => { expect(resolveSupportedLanguage('zh-HK')).toBe('zh'); }); }); + +describe('localizeToolDisplayName', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('translates tool badges without colliding with generic UI strings', async () => { + const { setLanguageAsync, localizeToolDisplayName, t } = await import( + './index.js' + ); + await setLanguageAsync('zh'); + + // The namespaced `toolDisplayName.*` key translates the badge... + expect(localizeToolDisplayName('Shell')).toBe('运行命令'); + expect(localizeToolDisplayName('TodoWrite')).toBe('任务清单'); + // Proper tool names / acronyms are intentionally kept in English. + expect(localizeToolDisplayName('Agent')).toBe('Agent'); + expect(localizeToolDisplayName('Grep')).toBe('Grep'); + expect(localizeToolDisplayName('Glob')).toBe('Glob'); + expect(localizeToolDisplayName('Lsp')).toBe('LSP'); + // ...while a same-spelled standalone UI string keeps its own value. + expect(t('Shell')).toBe('Shell'); + }); + + it('falls back to the English display name for untranslated tools', async () => { + const { setLanguageAsync, localizeToolDisplayName } = await import( + './index.js' + ); + await setLanguageAsync('en'); + + expect(localizeToolDisplayName('TodoWrite')).toBe('TodoWrite'); + expect(localizeToolDisplayName('Shell')).toBe('Shell'); + // An unknown tool name passes through unchanged. + expect(localizeToolDisplayName('MysteryTool')).toBe('MysteryTool'); + }); + + it('has a zh translation for every core tool display name', async () => { + const { setLanguageAsync, localizeToolDisplayName } = await import( + './index.js' + ); + const { ToolDisplayNames } = await import('@qwen-code/qwen-code-core'); + await setLanguageAsync('zh'); + + // Guards against a new tool landing without a `toolDisplayName.*` entry: + // every English display name (except the intentionally-English ones below) + // must resolve to a different (translated) zh string. check-i18n can't catch + // this because the keys are built dynamically, never as + // `t('toolDisplayName.X')` string literals. + const KEEP_ENGLISH = new Set(['Agent', 'Grep', 'Glob']); + const untranslated = Object.values(ToolDisplayNames).filter( + (name) => + !KEEP_ENGLISH.has(name) && localizeToolDisplayName(name) === name, + ); + expect(untranslated).toEqual([]); + }); +}); diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 86f91cb0112..d8eca878ee5 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -272,6 +272,19 @@ export function t(key: string, params?: Record): string { return interpolate(translation, params); } +/** + * Locale-aware tool display name for chat-stream badges. Looks up the + * `toolDisplayName.` key so tool labels never collide + * with same-spelled generic UI strings (e.g. a standalone "Shell" label that + * intentionally stays English). Falls back to the English display name when the + * active locale has no entry, so English and untranslated tools are unaffected. + */ +export function localizeToolDisplayName(displayName: string): string { + const key = `toolDisplayName.${displayName}`; + const translated = t(key); + return translated === key ? displayName : translated; +} + /** * Get a translation that is an array of strings. * @param key The translation key diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 181aea45fbd..47017066dba 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -8,6 +8,50 @@ // The key serves as both the translation key and the default English text export default { + // ============================================================================ + // Tool display names (chat-stream badge labels) + // ---------------------------------------------------------------------------- + // Namespaced `toolDisplayName.` keys (from core + // `ToolDisplayNames`). Per this file's key-is-default-text convention each + // English entry maps to itself; `localizeToolDisplayName` detects that + // self-mapping and returns the bare display name. Localized values live in + // zh.js / zh-TW.js; other locales fall back to the English display name. + // ============================================================================ + 'toolDisplayName.Edit': 'toolDisplayName.Edit', + 'toolDisplayName.WriteFile': 'toolDisplayName.WriteFile', + 'toolDisplayName.ReadFile': 'toolDisplayName.ReadFile', + 'toolDisplayName.Grep': 'toolDisplayName.Grep', + 'toolDisplayName.Glob': 'toolDisplayName.Glob', + 'toolDisplayName.Shell': 'toolDisplayName.Shell', + 'toolDisplayName.Shell Command': 'toolDisplayName.Shell Command', + 'toolDisplayName.TodoWrite': 'toolDisplayName.TodoWrite', + 'toolDisplayName.SaveMemory': 'toolDisplayName.SaveMemory', + 'toolDisplayName.Agent': 'toolDisplayName.Agent', + 'toolDisplayName.Skill': 'toolDisplayName.Skill', + 'toolDisplayName.EnterPlanMode': 'toolDisplayName.EnterPlanMode', + 'toolDisplayName.ExitPlanMode': 'toolDisplayName.ExitPlanMode', + 'toolDisplayName.WebFetch': 'toolDisplayName.WebFetch', + 'toolDisplayName.WebSearch': 'toolDisplayName.WebSearch', + 'toolDisplayName.ListFiles': 'toolDisplayName.ListFiles', + 'toolDisplayName.Lsp': 'toolDisplayName.Lsp', + 'toolDisplayName.AskUserQuestion': 'toolDisplayName.AskUserQuestion', + 'toolDisplayName.CronCreate': 'toolDisplayName.CronCreate', + 'toolDisplayName.CronList': 'toolDisplayName.CronList', + 'toolDisplayName.CronDelete': 'toolDisplayName.CronDelete', + 'toolDisplayName.TaskCreate': 'toolDisplayName.TaskCreate', + 'toolDisplayName.TaskUpdate': 'toolDisplayName.TaskUpdate', + 'toolDisplayName.TaskList': 'toolDisplayName.TaskList', + 'toolDisplayName.TaskStop': 'toolDisplayName.TaskStop', + 'toolDisplayName.TeamCreate': 'toolDisplayName.TeamCreate', + 'toolDisplayName.TeamDelete': 'toolDisplayName.TeamDelete', + 'toolDisplayName.SendMessage': 'toolDisplayName.SendMessage', + 'toolDisplayName.StructuredOutput': 'toolDisplayName.StructuredOutput', + 'toolDisplayName.Monitor': 'toolDisplayName.Monitor', + 'toolDisplayName.NotebookEdit': 'toolDisplayName.NotebookEdit', + 'toolDisplayName.ToolSearch': 'toolDisplayName.ToolSearch', + 'toolDisplayName.EnterWorktree': 'toolDisplayName.EnterWorktree', + 'toolDisplayName.ExitWorktree': 'toolDisplayName.ExitWorktree', + 'toolDisplayName.Workflow': 'toolDisplayName.Workflow', // ============================================================================ // Help / UI Components // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index 832d263b1cc..c2a75e008ba 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -9,6 +9,49 @@ // then extensively hand-corrected for Taiwan vocabulary conventions. // This file is the authoritative source — do not overwrite with auto-generated output. export default { + // ============================================================================ + // Tool display names (chat-stream badge labels) + // ---------------------------------------------------------------------------- + // Keyed by `toolDisplayName.` (from core + // `ToolDisplayNames`); a missing key falls back to the English display name + // via `localizeToolDisplayName`. A product name (e.g. `Notebook`) is kept + // verbatim inside an otherwise-translated label. + // ============================================================================ + 'toolDisplayName.Edit': '編輯', + 'toolDisplayName.WriteFile': '寫入檔案', + 'toolDisplayName.ReadFile': '讀取檔案', + 'toolDisplayName.Grep': 'Grep', + 'toolDisplayName.Glob': 'Glob', + 'toolDisplayName.Shell': '運行命令', + 'toolDisplayName.Shell Command': 'Shell 命令', + 'toolDisplayName.TodoWrite': '任務清單', + 'toolDisplayName.SaveMemory': '儲存記憶', + 'toolDisplayName.Agent': 'Agent', + 'toolDisplayName.Skill': '技能', + 'toolDisplayName.EnterPlanMode': '進入計畫模式', + 'toolDisplayName.ExitPlanMode': '退出計畫模式', + 'toolDisplayName.WebFetch': '網路擷取', + 'toolDisplayName.WebSearch': '網路搜尋', + 'toolDisplayName.ListFiles': '列出檔案', + 'toolDisplayName.Lsp': 'LSP', + 'toolDisplayName.AskUserQuestion': '詢問使用者', + 'toolDisplayName.CronCreate': '建立定時任務', + 'toolDisplayName.CronList': '定時任務清單', + 'toolDisplayName.CronDelete': '刪除定時任務', + 'toolDisplayName.TaskCreate': '建立任務', + 'toolDisplayName.TaskUpdate': '更新任務', + 'toolDisplayName.TaskList': '任務列表', + 'toolDisplayName.TaskStop': '停止任務', + 'toolDisplayName.TeamCreate': '建立團隊', + 'toolDisplayName.TeamDelete': '刪除團隊', + 'toolDisplayName.SendMessage': '傳送訊息', + 'toolDisplayName.StructuredOutput': '結構化輸出', + 'toolDisplayName.Monitor': '監控', + 'toolDisplayName.NotebookEdit': '編輯 Notebook', + 'toolDisplayName.ToolSearch': '工具搜尋', + 'toolDisplayName.EnterWorktree': '進入 Worktree', + 'toolDisplayName.ExitWorktree': '退出 Worktree', + 'toolDisplayName.Workflow': '工作流程', '↑ to manage attachments': '↑ 管理附件', '← → select, Delete to remove, ↓ to exit': '← → 選擇,Delete 刪除,↓ 退出', 'Attachments: ': '附件:', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 3d96c0970e4..a79037d51d1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -7,6 +7,51 @@ // Chinese translations for Qwen Code CLI export default { + // ============================================================================ + // Tool display names (chat-stream badge labels) + // ---------------------------------------------------------------------------- + // Keyed by `toolDisplayName.` (from core + // `ToolDisplayNames`). The namespace prevents collisions with same-spelled + // generic UI strings (e.g. a standalone "Shell"). A missing key falls back to + // the English display name via `localizeToolDisplayName`. Proper tool names / + // acronyms are kept in English (Agent, Grep, Glob, LSP), as is a product name + // inside an otherwise-translated label (e.g. `Notebook`). + // ============================================================================ + 'toolDisplayName.Edit': '编辑', + 'toolDisplayName.WriteFile': '写入文件', + 'toolDisplayName.ReadFile': '读取文件', + 'toolDisplayName.Grep': 'Grep', + 'toolDisplayName.Glob': 'Glob', + 'toolDisplayName.Shell': '运行命令', + 'toolDisplayName.Shell Command': 'Shell 命令', + 'toolDisplayName.TodoWrite': '任务清单', + 'toolDisplayName.SaveMemory': '保存记忆', + 'toolDisplayName.Agent': 'Agent', + 'toolDisplayName.Skill': '技能', + 'toolDisplayName.EnterPlanMode': '进入计划模式', + 'toolDisplayName.ExitPlanMode': '退出计划模式', + 'toolDisplayName.WebFetch': '网络抓取', + 'toolDisplayName.WebSearch': '网络搜索', + 'toolDisplayName.ListFiles': '列出文件', + 'toolDisplayName.Lsp': 'LSP', + 'toolDisplayName.AskUserQuestion': '询问用户', + 'toolDisplayName.CronCreate': '创建定时任务', + 'toolDisplayName.CronList': '定时任务列表', + 'toolDisplayName.CronDelete': '删除定时任务', + 'toolDisplayName.TaskCreate': '创建任务', + 'toolDisplayName.TaskUpdate': '更新任务', + 'toolDisplayName.TaskList': '任务列表', + 'toolDisplayName.TaskStop': '停止任务', + 'toolDisplayName.TeamCreate': '创建团队', + 'toolDisplayName.TeamDelete': '删除团队', + 'toolDisplayName.SendMessage': '发送消息', + 'toolDisplayName.StructuredOutput': '结构化输出', + 'toolDisplayName.Monitor': '监控', + 'toolDisplayName.NotebookEdit': '编辑 Notebook', + 'toolDisplayName.ToolSearch': '工具搜索', + 'toolDisplayName.EnterWorktree': '进入 Worktree', + 'toolDisplayName.ExitWorktree': '退出 Worktree', + 'toolDisplayName.Workflow': '工作流', // ============================================================================ // Help / UI Components // ============================================================================ diff --git a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx index 6ce74240619..24c80841017 100644 --- a/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx +++ b/packages/cli/src/ui/components/background-view/BackgroundTasksDialog.tsx @@ -42,7 +42,7 @@ import { type DreamDialogEntry, entryId, } from '../../hooks/useBackgroundTaskView.js'; -import { t } from '../../../i18n/index.js'; +import { localizeToolDisplayName, t } from '../../../i18n/index.js'; // `DialogEntry['status']` widens the shell status union with the agent-only // `paused` state, so dialog handlers can switch on a single combined enum. @@ -57,7 +57,7 @@ const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries( ); function formatActivityLabel(name: string, description: string | undefined) { - const display = TOOL_DISPLAY_BY_NAME[name] ?? name; + const display = localizeToolDisplayName(TOOL_DISPLAY_BY_NAME[name] ?? name); const singleLineDesc = description ? description.replace(/\s*\n\s*/g, ' ').trim() : ''; diff --git a/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx b/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx index 4ed6f73a9dd..0c0c116decf 100644 --- a/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx +++ b/packages/cli/src/ui/components/background-view/LiveAgentPanel.tsx @@ -34,6 +34,7 @@ import { ToolDisplayNames, ToolNames, } from '@qwen-code/qwen-code-core'; +import { localizeToolDisplayName } from '../../../i18n/index.js'; import { useBackgroundTaskViewActions, useBackgroundTaskViewState, @@ -137,7 +138,9 @@ const TOOL_DISPLAY_BY_NAME: Record = Object.fromEntries( function activityLabel(entry: AgentDialogEntry): string { const last = entry.recentActivities?.at(-1); if (!last) return ''; - const display = TOOL_DISPLAY_BY_NAME[last.name] ?? last.name; + const display = localizeToolDisplayName( + TOOL_DISPLAY_BY_NAME[last.name] ?? last.name, + ); const desc = last.description?.replace(/\s*\n\s*/g, ' ').trim(); return desc ? `${display} ${desc}` : display; } diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx index ea08396acf5..852a7df927e 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx @@ -11,7 +11,7 @@ import { ToolCallStatus } from '../../types.js'; import type { AnsiOutputDisplay } from '@qwen-code/qwen-code-core'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; -import { t } from '../../../i18n/index.js'; +import { localizeToolDisplayName, t } from '../../../i18n/index.js'; import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js'; import { ToolElapsedTime } from '../shared/ToolElapsedTime.js'; @@ -166,7 +166,7 @@ export const CompactToolGroupDisplay: React.FC< {compactLabel ? renderSummaryHeader(compactLabel, toolCalls.length) : renderDefaultHeader( - activeTool.name, + localizeToolDisplayName(activeTool.name), activeToolDescription, toolCalls.length, )} diff --git a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx index a444a48dc5f..c2a00987f9f 100644 --- a/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx +++ b/packages/cli/src/ui/components/messages/InlineParallelAgentsDisplay.tsx @@ -32,6 +32,7 @@ import { ConfigContext } from '../../contexts/ConfigContext.js'; import { theme } from '../../semantic-colors.js'; import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; import { escapeAnsiCtrlCodes } from '../../utils/textUtils.js'; +import { localizeToolDisplayName } from '../../../i18n/index.js'; interface InlineParallelAgentsDisplayProps { toolCalls: readonly IndividualToolCallDisplay[]; @@ -108,7 +109,9 @@ function activityLabel(row: RowData): string { // registry's live array. const last = row.recentActivity; if (!last) return ''; - const display = TOOL_DISPLAY_BY_NAME[last.name] ?? last.name; + const display = localizeToolDisplayName( + TOOL_DISPLAY_BY_NAME[last.name] ?? last.name, + ); const desc = last.description?.replace(/\s*\n\s*/g, ' ').trim(); return desc ? `${display} ${desc}` : display; } diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index d9949cc851e..8056ac85e18 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -930,3 +930,44 @@ describe('', () => { expect(output).toContain('- Step 2'); }); }); + +describe(' localized badge', () => { + const localizedProps: ToolMessageProps = { + callId: 'tool-i18n', + name: 'ReadFile', + description: '', + resultDisplay: '', + status: ToolCallStatus.Success, + contentWidth: 80, + confirmationDetails: undefined, + emphasis: 'medium', + config: {} as Config, + }; + + afterEach(async () => { + const { setLanguageAsync } = await import('../../../i18n/index.js'); + await setLanguageAsync('en'); + }); + + it('shows the localized display name under the zh locale', async () => { + const { setLanguageAsync } = await import('../../../i18n/index.js'); + await setLanguageAsync('zh'); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + const output = lastFrame() ?? ''; + expect(output).toContain('读取文件'); + expect(output).not.toContain('ReadFile'); + }); + + it('keeps the English display name under the en locale', async () => { + const { setLanguageAsync } = await import('../../../i18n/index.js'); + await setLanguageAsync('en'); + const { lastFrame } = renderWithContext( + , + StreamingState.Idle, + ); + expect(lastFrame() ?? '').toContain('ReadFile'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index da4ee28a3a1..4d8ba15319b 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -28,6 +28,7 @@ import { ToolConfirmationMessage } from './ToolConfirmationMessage.js'; import { PlanSummaryDisplay } from '../PlanSummaryDisplay.js'; import { ShellInputPrompt } from '../ShellInputPrompt.js'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; +import { localizeToolDisplayName } from '../../../i18n/index.js'; import { formatDuration, formatTokenCount } from '../../utils/formatters.js'; import { theme } from '../../semantic-colors.js'; import { useSettings } from '../../contexts/SettingsContext.js'; @@ -800,7 +801,7 @@ const ToolInfo: React.FC = ({ strikethrough={status === ToolCallStatus.Canceled} > - {name} + {localizeToolDisplayName(name)} {' '} {description} diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.tsx b/packages/web-shell/client/components/messages/AskUserQuestion.tsx index 8524449a167..40398cc37d5 100644 --- a/packages/web-shell/client/components/messages/AskUserQuestion.tsx +++ b/packages/web-shell/client/components/messages/AskUserQuestion.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import type { PermissionRequest } from '../../adapters/types'; import { useI18n } from '../../i18n'; +import { localizeToolDisplayName } from './toolFormatting'; import styles from './AskUserQuestion.module.css'; interface Question { @@ -391,7 +392,9 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { {/* Header line like CLI */}
? - AskUserQuestion + + {localizeToolDisplayName('ask_user_question', t)} + {t('askUser.title', { count: questions.length })} diff --git a/packages/web-shell/client/components/messages/StatsMessage.tsx b/packages/web-shell/client/components/messages/StatsMessage.tsx index 4639d537bb8..d08cc9b2958 100644 --- a/packages/web-shell/client/components/messages/StatsMessage.tsx +++ b/packages/web-shell/client/components/messages/StatsMessage.tsx @@ -4,6 +4,7 @@ import type { DaemonSessionStatsToolByName, } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../../i18n'; +import { localizeToolDisplayName } from './toolFormatting'; import styles from './StatsMessage.module.css'; const SENTINEL = 'web-shell:session-stats:v1:'; @@ -463,7 +464,7 @@ function ToolStatsCard({ status }: { status: DaemonSessionStatsStatus }) { return (
- {e.name} + {localizeToolDisplayName(e.name, t)} {e.stats.count} ['t'], +) { + const display = localizeToolDisplayName(name, t); const singleLineDescription = description ? description.replace(/\s*\n\s*/g, ' ').trim() : ''; @@ -634,7 +638,7 @@ function TaskDetail({
{task.recentActivities.slice(-5).map((a, i, arr) => { const isLast = i === arr.length - 1; - const desc = formatActivityLabel(a.name, a.description); + const desc = formatActivityLabel(a.name, a.description, t); return (
): ACPToolCall { return { @@ -190,4 +194,61 @@ describe('toolFormatting', () => { expect(result.length).toBeLessThan(5000); expect(result.endsWith('...')).toBe(true); }); + + describe('localizeToolDisplayName', () => { + it('translates known tool names in Chinese', () => { + const t = getTranslator('zh-CN'); + expect(localizeToolDisplayName('todo_write', t)).toBe('任务清单'); + expect(localizeToolDisplayName('run_shell_command', t)).toBe('运行命令'); + expect(localizeToolDisplayName('read_file', t)).toBe('读取文件'); + }); + + it('keeps proper tool names / acronyms in English', () => { + const t = getTranslator('zh-CN'); + expect(localizeToolDisplayName('agent', t)).toBe('Agent'); + expect(localizeToolDisplayName('grep_search', t)).toBe('Grep'); + expect(localizeToolDisplayName('glob', t)).toBe('Glob'); + expect(localizeToolDisplayName('lsp', t)).toBe('LSP'); + }); + + it('falls back to the English display name when the locale has no entry', () => { + const t = getTranslator('en'); + expect(localizeToolDisplayName('todo_write', t)).toBe('TodoWrite'); + expect(localizeToolDisplayName('grep_search', t)).toBe('Grep'); + }); + + it('falls back to the raw wire name for unknown tools', () => { + expect( + localizeToolDisplayName('mystery_tool', getTranslator('zh-CN')), + ).toBe('mystery_tool'); + }); + + it('has a zh translation for every tool in the display-name map', () => { + const tZh = getTranslator('zh-CN'); + // Tools intentionally shown in English (proper names / acronyms). + const keepEnglish = new Set(['agent', 'grep_search', 'glob', 'search']); + const untranslated = Object.keys(TOOL_DISPLAY_NAMES).filter( + (wire) => + !keepEnglish.has(wire) && + localizeToolDisplayName(wire, tZh) === formatToolDisplayName(wire), + ); + expect(untranslated).toEqual([]); + }); + + it('localizes the tool name in the agent activity hint', () => { + const agent = tool({ + toolName: 'agent', + status: 'in_progress', + subTools: [ + tool({ toolName: 'run_shell_command', status: 'in_progress' }), + ], + }); + expect(getAgentCurrentToolHint(agent, getTranslator('zh-CN'))).toContain( + '运行命令', + ); + expect(getAgentCurrentToolHint(agent, getTranslator('en'))).toContain( + 'Shell', + ); + }); + }); }); diff --git a/packages/web-shell/client/components/messages/toolFormatting.ts b/packages/web-shell/client/components/messages/toolFormatting.ts index e18d956db8e..d05fa7e7be7 100644 --- a/packages/web-shell/client/components/messages/toolFormatting.ts +++ b/packages/web-shell/client/components/messages/toolFormatting.ts @@ -1,6 +1,6 @@ import type { ACPToolCall } from '../../adapters/types'; -const TOOL_DISPLAY_NAMES: Record = { +export const TOOL_DISPLAY_NAMES: Record = { edit: 'Edit', write_file: 'WriteFile', read_file: 'ReadFile', @@ -27,6 +27,13 @@ const TOOL_DISPLAY_NAMES: Record = { tool_search: 'ToolSearch', enter_worktree: 'EnterWorktree', exit_worktree: 'ExitWorktree', + enter_plan_mode: 'EnterPlanMode', + task_create: 'TaskCreate', + task_update: 'TaskUpdate', + task_list: 'TaskList', + team_create: 'TeamCreate', + team_delete: 'TeamDelete', + workflow: 'Workflow', web_search: 'WebSearch', bash: 'Shell', shell: 'Shell Command', @@ -39,6 +46,21 @@ export function formatToolDisplayName(toolName: string): string { return TOOL_DISPLAY_NAMES[toolName] ?? toolName; } +/** + * Locale-aware tool display name for chat-stream badges. Looks up the + * `toolName.` i18n key; when the active language has no entry the + * translator returns the key verbatim, in which case we fall back to the + * English {@link formatToolDisplayName}. Pass the `t` from `useI18n()`. + */ +export function localizeToolDisplayName( + toolName: string, + t: (key: string, vars?: Record) => string, +): string { + const key = `toolName.${toolName}`; + const translated = t(key); + return translated === key ? formatToolDisplayName(toolName) : translated; +} + export function isAskUserQuestionToolName(toolName: string): boolean { const normalized = toolName.toLowerCase(); return normalized === 'ask_user_question' || normalized === 'askuserquestion'; @@ -378,13 +400,16 @@ export function getAgentDescription(agent: ACPToolCall): string { return ''; } -export function getAgentCurrentToolHint(agent: ACPToolCall): string { +export function getAgentCurrentToolHint( + agent: ACPToolCall, + t: (key: string, vars?: Record) => string, +): string { if (agent.status !== 'in_progress') return ''; const subs = agent.subTools; if (!subs || subs.length === 0) return ''; const last = subs[subs.length - 1]; if (last.status !== 'in_progress' && last.status !== 'pending') return ''; - let hint = last.toolName; + let hint = localizeToolDisplayName(last.toolName ?? '', t); if (last.title) { const colonIdx = last.title.indexOf(': '); hint += ' ' + (colonIdx > 0 ? last.title.slice(colonIdx + 2) : last.title); diff --git a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx index 4bb44f6734e..baf12a501be 100644 --- a/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx +++ b/packages/web-shell/client/components/messages/tools/ParallelAgentsGroup.tsx @@ -104,7 +104,7 @@ export function ParallelAgentsGroup({ {agents.map((agent) => { const agentType = getAgentType(agent); const desc = getAgentDescription(agent); - const toolHint = getAgentCurrentToolHint(agent); + const toolHint = getAgentCurrentToolHint(agent, t); const stats = getAgentStats(agent, now); const status = getAgentDisplayStatus(agent); const isExpanded = expandedId === agent.callId; diff --git a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx index 3349fb0b47c..017b21ff9a0 100644 --- a/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx +++ b/packages/web-shell/client/components/messages/tools/SubAgentPanel.tsx @@ -26,6 +26,7 @@ import { formatTokenCount, getAgentType, getAgentDescription, + localizeToolDisplayName, } from '../toolFormatting'; import chromeStyles from './ToolChrome.module.css'; import styles from './SubAgentPanel.module.css'; @@ -109,12 +110,15 @@ const SubToolLine = memo(function SubToolLine({ tool }: { tool: ACPToolCall }) { }); function TaskToolCallLine({ tc }: { tc: TaskToolCall }) { + const { t } = useI18n(); const desc = tc.description || ''; return (
- {tc.name} + + {localizeToolDisplayName(tc.name, t)} + {desc && ( {truncateText(desc, 70)} )} diff --git a/packages/web-shell/client/components/messages/tools/toolDisplay.tsx b/packages/web-shell/client/components/messages/tools/toolDisplay.tsx index bab651267e3..fe79f724234 100644 --- a/packages/web-shell/client/components/messages/tools/toolDisplay.tsx +++ b/packages/web-shell/client/components/messages/tools/toolDisplay.tsx @@ -1,5 +1,9 @@ import styles from './ToolChrome.module.css'; -export { formatToolDisplayName, truncateText } from '../toolFormatting'; +export { + formatToolDisplayName, + localizeToolDisplayName, + truncateText, +} from '../toolFormatting'; export function StatusIcon({ status }: { status: string }) { switch (status) { diff --git a/packages/web-shell/client/i18n.tsx b/packages/web-shell/client/i18n.tsx index 2d445e007ee..303d32c18f8 100644 --- a/packages/web-shell/client/i18n.tsx +++ b/packages/web-shell/client/i18n.tsx @@ -890,6 +890,50 @@ const EN: Messages = { const ZH: Messages = { ...EN, + // Tool display names (chat-stream badge labels). Keyed by `toolName.`; + // a wire name with no entry here falls back to the English display name via + // `localizeToolDisplayName`. Proper tool names / acronyms stay in English + // (Agent, Grep, Glob, LSP); a product name (e.g. `Notebook`) stays verbatim. + 'toolName.edit': '编辑', + 'toolName.write_file': '写入文件', + 'toolName.read_file': '读取文件', + 'toolName.grep_search': 'Grep', + 'toolName.glob': 'Glob', + 'toolName.run_shell_command': '运行命令', + 'toolName.todo_write': '任务清单', + 'toolName.save_memory': '保存记忆', + 'toolName.agent': 'Agent', + 'toolName.skill': '技能', + 'toolName.enter_plan_mode': '进入计划模式', + 'toolName.exit_plan_mode': '退出计划模式', + 'toolName.web_fetch': '网络抓取', + 'toolName.web_search': '网络搜索', + 'toolName.list_directory': '列出文件', + 'toolName.lsp': 'LSP', + 'toolName.ask_user_question': '询问用户', + 'toolName.cron_create': '创建定时任务', + 'toolName.cron_list': '定时任务列表', + 'toolName.cron_delete': '删除定时任务', + 'toolName.task_create': '创建任务', + 'toolName.task_update': '更新任务', + 'toolName.task_list': '任务列表', + 'toolName.task_stop': '停止任务', + 'toolName.team_create': '创建团队', + 'toolName.team_delete': '删除团队', + 'toolName.send_message': '发送消息', + 'toolName.structured_output': '结构化输出', + 'toolName.monitor': '监控', + 'toolName.notebook_edit': '编辑 Notebook', + 'toolName.tool_search': '工具搜索', + 'toolName.enter_worktree': '进入 Worktree', + 'toolName.exit_worktree': '退出 Worktree', + 'toolName.workflow': '工作流', + // web-shell-only wire aliases (see TOOL_DISPLAY_NAMES in toolFormatting.ts) + 'toolName.bash': '运行命令', + 'toolName.shell': 'Shell 命令', + 'toolName.read': '读取文件', + 'toolName.write': '写入文件', + 'toolName.search': 'Grep', 'about.auth': '认证', 'about.baseUrl': 'Base URL', 'about.fastModel': '快速模型',