diff --git a/.gitignore b/.gitignore index 828c8cee..e4c8f5c1 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,7 @@ runtime/__pycache__ # Codex logs .codex-logs/ + +# Local AI assistant artifacts (graph, notes) +CLAUDE.md +graphify-out/ diff --git a/desktop/src/components/chat/ChatInput.tsx b/desktop/src/components/chat/ChatInput.tsx index 024a5c6e..d3996d38 100644 --- a/desktop/src/components/chat/ChatInput.tsx +++ b/desktop/src/components/chat/ChatInput.tsx @@ -24,7 +24,7 @@ import { FileSearchMenu, type FileSearchMenuHandle } from './FileSearchMenu' import { LocalSlashCommandPanel, type LocalSlashCommandName } from './LocalSlashCommandPanel' import { ContextUsageIndicator } from './ContextUsageIndicator' import { - FALLBACK_SLASH_COMMANDS, + getLocalizedFallbackCommands, filterSlashCommands, findSlashTrigger, mergeSlashCommands, @@ -400,8 +400,8 @@ export function ChatInput({ variant = 'default', compact = false }: ChatInputPro }, [fileSearchOpen]) const allSlashCommands = useMemo( - () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), - [slashCommands], + () => mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), + [slashCommands, t], ) const filteredCommands = useMemo(() => { diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 9a1ad7e4..32c3a057 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { filterSlashCommands, findSlashToken, + getLocalizedFallbackCommands, insertSlashTrigger, mergeSlashCommands, replaceSlashCommand, @@ -50,14 +51,30 @@ describe('composerUtils', () => { ) }) - it('keeps server-provided descriptions when they exist', () => { + it('keeps server-provided descriptions for non-built-in commands', () => { expect( mergeSlashCommands([ - { name: 'clear', description: 'Server description' }, + { name: 'team:lark', description: 'Team-provided description' }, ]), ).toEqual( expect.arrayContaining([ - { name: 'clear', description: 'Server description' }, + { name: 'team:lark', description: 'Team-provided description' }, + ]), + ) + }) + + it('prefers the localized fallback description for built-in commands', () => { + // For commands the desktop owns the copy for (e.g. /clear, /compact, /help), + // the localized description must win over whatever the CLI broadcasts so the + // i18n keys actually take effect at runtime. + expect( + mergeSlashCommands( + [{ name: 'clear', description: 'CLI English description' }], + [{ name: 'clear', description: 'Localized description' }], + ), + ).toEqual( + expect.arrayContaining([ + { name: 'clear', description: 'Localized description' }, ]), ) }) @@ -127,4 +144,38 @@ describe('composerUtils', () => { expect(resolveSlashUiAction('context')).toEqual({ type: 'panel', command: 'context' }) expect(resolveSlashUiAction('status')).toEqual({ type: 'panel', command: 'status' }) }) + + it('falls back to the static English description when a translation key is missing', () => { + // Simulate an i18n t() function that returns the raw key for missing entries + // (this is what the real translate() does via zh[key] ?? en[key] ?? key). + const mockT = (key: string) => key + + const commands = getLocalizedFallbackCommands(mockT) + const clearCmd = commands.find((c) => c.name === 'clear') + expect(clearCmd?.description).toBe('Clear conversation history') + expect(clearCmd?.description).not.toBe('slashCmd.clear.description') + + // Verify every command renders a human-readable description, never a raw key + for (const cmd of commands) { + expect(cmd.description).not.toMatch(/^slashCmd\./) + } + }) + + it('uses the localized description when the translation key resolves to a real string', () => { + const mockT = (key: string) => { + const map: Record = { + 'slashCmd.clear.description': '清空会话历史', + } + return map[key] ?? key + } + + const commands = getLocalizedFallbackCommands(mockT) + const clearCmd = commands.find((c) => c.name === 'clear') + expect(clearCmd?.description).toBe('清空会话历史') + + // A command without a translated key should still fall back to English + const mcpCmd = commands.find((c) => c.name === 'mcp') + expect(mcpCmd?.description).toBe('Open available MCP tools for the current chat context') + expect(mcpCmd?.description).not.toBe('slashCmd.mcp.description') + }) }) diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index 884903d9..517474cc 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -1,34 +1,70 @@ import type { SettingsTab } from '../../stores/uiStore' +import type { TranslationKey } from '../../i18n' + +/** Map from slash command name to its i18n description key */ +const SLASH_CMD_DESCRIPTION_KEYS: Record = { + mcp: 'slashCmd.mcp.description', + skills: 'slashCmd.skills.description', + help: 'slashCmd.help.description', + status: 'slashCmd.status.description', + cost: 'slashCmd.cost.description', + context: 'slashCmd.context.description', + plugin: 'slashCmd.plugin.description', + memory: 'slashCmd.memory.description', + doctor: 'slashCmd.doctor.description', + compact: 'slashCmd.compact.description', + clear: 'slashCmd.clear.description', + goal: 'slashCmd.goal.description', + review: 'slashCmd.review.description', + commit: 'slashCmd.commit.description', + pr: 'slashCmd.pr.description', + init: 'slashCmd.init.description', + bug: 'slashCmd.bug.description', + config: 'slashCmd.config.description', + login: 'slashCmd.login.description', + logout: 'slashCmd.logout.description', + model: 'slashCmd.model.description', + permissions: 'slashCmd.permissions.description', + 'terminal-setup': 'slashCmd.terminal-setup.description', + vim: 'slashCmd.vim.description', +} + +/** Names of commands the desktop owns the description for (i.e. localized in our locales). */ +const BUILT_IN_COMMAND_NAMES = new Set(Object.keys(SLASH_CMD_DESCRIPTION_KEYS)) export const PANEL_SLASH_COMMANDS = [ - { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, - { name: 'skills', description: 'Browse user-invocable skills for the current chat context' }, - { name: 'help', description: 'Show available desktop and agent commands' }, - { name: 'status', description: 'Show session status, usage, and context' }, - { name: 'cost', description: 'Show session usage and costs' }, - { name: 'context', description: 'Show current context usage' }, + { name: 'mcp' }, + { name: 'skills' }, + { name: 'help' }, + { name: 'status' }, + { name: 'cost' }, + { name: 'context' }, ] as const export const SETTINGS_SLASH_COMMANDS = [ - { name: 'plugin', description: 'Open desktop plugin controls in Settings', tab: 'plugins' as const }, - { name: 'memory', description: 'Open project memory files in Settings', tab: 'memory' as const }, - { name: 'doctor', description: 'Open Doctor in Diagnostics', tab: 'diagnostics' as const }, + { name: 'plugin', tab: 'plugins' as const }, + { name: 'memory', tab: 'memory' as const }, + { name: 'doctor', tab: 'diagnostics' as const }, ] as const export const SLASH_COMMAND_ALIASES = [ { name: 'plugins', target: 'plugin' }, ] as const -export const FALLBACK_SLASH_COMMANDS = [ - ...PANEL_SLASH_COMMANDS, - ...SETTINGS_SLASH_COMMANDS.map(({ name, description }) => ({ name, description })), +/** Static fallback with English descriptions (for non-React contexts) */ +export const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [ + { name: 'mcp', description: 'Open available MCP tools for the current chat context' }, + { name: 'skills', description: 'Browse user-invocable skills for the current chat context' }, + { name: 'help', description: 'Show available desktop and agent commands' }, + { name: 'status', description: 'Show session status, usage, and context' }, + { name: 'cost', description: 'Show session usage and costs' }, + { name: 'context', description: 'Show current context usage' }, + { name: 'plugin', description: 'Open desktop plugin controls in Settings' }, + { name: 'memory', description: 'Open project memory files in Settings' }, + { name: 'doctor', description: 'Open Doctor in Diagnostics' }, { name: 'compact', description: 'Compact conversation context' }, { name: 'clear', description: 'Clear conversation history' }, - { - name: 'goal', - description: 'Set a completion goal', - argumentHint: '[ | clear]', - }, + { name: 'goal', description: 'Set a completion goal', argumentHint: '[ | clear]' }, { name: 'review', description: 'Review code changes' }, { name: 'commit', description: 'Create a git commit' }, { name: 'pr', description: 'Create a pull request' }, @@ -43,6 +79,36 @@ export const FALLBACK_SLASH_COMMANDS = [ { name: 'vim', description: 'Toggle vim editing mode' }, ] +/** Build localized fallback commands using the current locale. + * + * Resolution order for each command's description: + * 1. Localized string from the i18n table (zh -> en) when a key is registered. + * 2. The static English description shipped in FALLBACK_SLASH_COMMANDS. + * + * This guarantees we never render a raw key (e.g. "slashCmd.foo.description") + * in the UI even if a command is missing from SLASH_CMD_DESCRIPTION_KEYS or + * its translation entry is absent. + */ +export function getLocalizedFallbackCommands(t: (key: TranslationKey) => string): SlashCommandOption[] { + return FALLBACK_SLASH_COMMANDS.map((cmd) => { + const key = SLASH_CMD_DESCRIPTION_KEYS[cmd.name] + let description = cmd.description + if (key) { + const translated = t(key) + // i18n returns the key itself when no translation is found; fall back to + // the static English description in that case. + if (translated && translated !== key) { + description = translated + } + } + return { + name: cmd.name, + description, + ...(cmd.argumentHint && { argumentHint: cmd.argumentHint }), + } + }) +} + export type SlashCommandOption = { name: string description: string @@ -78,30 +144,34 @@ export function mergeSlashCommands( preferred: ReadonlyArray, fallback: ReadonlyArray = FALLBACK_SLASH_COMMANDS, ): SlashCommandOption[] { + const fallbackByName = new Map() + for (const command of fallback) { + if (command?.name) fallbackByName.set(command.name, command) + } + const merged = new Map() for (const command of preferred) { if (!command?.name) continue + const localized = fallbackByName.get(command.name) + // For commands the desktop owns the copy for, prefer the localized fallback + // description so users see translated text instead of the CLI's English. + const useLocalDescription = + BUILT_IN_COMMAND_NAMES.has(command.name) && Boolean(localized?.description) + const description = useLocalDescription + ? localized!.description + : command.description?.trim() || localized?.description || '' + const argumentHint = command.argumentHint?.trim() || localized?.argumentHint merged.set(command.name, { name: command.name, - description: command.description?.trim() || '', - ...(command.argumentHint?.trim() && { argumentHint: command.argumentHint.trim() }), + description, + ...(argumentHint && { argumentHint }), }) } for (const command of fallback) { if (!command?.name) continue - const existing = merged.get(command.name) - if (existing) { - if ((!existing.description && command.description) || (!existing.argumentHint && command.argumentHint)) { - merged.set(command.name, { - ...existing, - description: existing.description || command.description, - argumentHint: existing.argumentHint || command.argumentHint, - }) - } - continue - } + if (merged.has(command.name)) continue merged.set(command.name, command) } diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index f9556700..2e97a4dc 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -1576,6 +1576,32 @@ export const en = { 'tabs.closeConfirmTitle': 'Session Running', 'tabs.closeConfirmMessage': 'This session is still running. What would you like to do?', 'tabs.closeConfirmKeep': 'Keep Running', + // ─── Slash Command Descriptions ────────────────────────────────────── + 'slashCmd.mcp.description': 'Open available MCP tools for the current chat context', + 'slashCmd.skills.description': 'Browse user-invocable skills for the current chat context', + 'slashCmd.help.description': 'Show available desktop and agent commands', + 'slashCmd.status.description': 'Show session status, usage, and context', + 'slashCmd.cost.description': 'Show session usage and costs', + 'slashCmd.context.description': 'Show current context usage', + 'slashCmd.plugin.description': 'Open desktop plugin controls in Settings', + 'slashCmd.memory.description': 'Open project memory files in Settings', + 'slashCmd.doctor.description': 'Open Doctor in Diagnostics', + 'slashCmd.compact.description': 'Compact conversation context', + 'slashCmd.clear.description': 'Clear conversation history', + 'slashCmd.goal.description': 'Set a completion goal', + 'slashCmd.review.description': 'Review code changes', + 'slashCmd.commit.description': 'Create a git commit', + 'slashCmd.pr.description': 'Create a pull request', + 'slashCmd.init.description': 'Initialize project CLAUDE.md', + 'slashCmd.bug.description': 'Report a bug', + 'slashCmd.config.description': 'Open configuration', + 'slashCmd.login.description': 'Switch Anthropic accounts', + 'slashCmd.logout.description': 'Sign out of current account', + 'slashCmd.model.description': 'Switch AI model', + 'slashCmd.permissions.description': 'View or manage tool permissions', + 'slashCmd.terminal-setup.description': 'Set up terminal integration', + 'slashCmd.vim.description': 'Toggle vim editing mode', + 'tabs.closeConfirmStop': 'Stop & Close', 'tabs.closeAllConfirmTitle': 'Sessions Running', 'tabs.closeAllConfirmMessage': '{count} sessions are still running. Stop them before closing these tabs?', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index a843cf1e..05a4ee33 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -1580,6 +1580,32 @@ export const zh: Record = { 'tabs.closeConfirmKeep': '保持运行', 'tabs.closeConfirmStop': '停止并关闭', 'tabs.closeAllConfirmTitle': '多个会话运行中', + // ─── Slash Command Descriptions ────────────────────────────────────── + 'slashCmd.mcp.description': '打开当前聊天上下文中可用的 MCP 工具', + 'slashCmd.skills.description': '浏览当前上下文中可直接调用的技能', + 'slashCmd.help.description': '查看可用的桌面端与 Agent 命令', + 'slashCmd.status.description': '查看会话状态、用量和上下文', + 'slashCmd.cost.description': '查看会话用量和费用', + 'slashCmd.context.description': '查看当前上下文用量', + 'slashCmd.plugin.description': '在设置中打开插件管理', + 'slashCmd.memory.description': '在设置中打开项目记忆文件', + 'slashCmd.doctor.description': '在诊断中打开 Doctor', + 'slashCmd.compact.description': '压缩会话上下文', + 'slashCmd.clear.description': '清空会话历史', + 'slashCmd.goal.description': '设定完成目标', + 'slashCmd.review.description': '审查代码变更', + 'slashCmd.commit.description': '创建 git 提交', + 'slashCmd.pr.description': '创建 Pull Request', + 'slashCmd.init.description': '初始化项目 CLAUDE.md', + 'slashCmd.bug.description': '报告 Bug', + 'slashCmd.config.description': '打开配置', + 'slashCmd.login.description': '切换 Anthropic 账户', + 'slashCmd.logout.description': '登出当前账户', + 'slashCmd.model.description': '切换 AI 模型', + 'slashCmd.permissions.description': '查看或管理工具权限', + 'slashCmd.terminal-setup.description': '设置终端集成', + 'slashCmd.vim.description': '切换 vim 编辑模式', + 'tabs.closeAllConfirmMessage': '仍有 {count} 个会话正在运行。关闭这些标签前是否停止它们?', 'tabs.closeAllConfirmStop': '全部停止并关闭', 'tabs.sessionRunning': '会话运行中', diff --git a/desktop/src/pages/EmptySession.tsx b/desktop/src/pages/EmptySession.tsx index 82469abb..ee57aa14 100644 --- a/desktop/src/pages/EmptySession.tsx +++ b/desktop/src/pages/EmptySession.tsx @@ -26,7 +26,7 @@ import { } from '../lib/composerAttachments' import { useComposerFileDrop } from '../components/chat/useComposerFileDrop' import { - FALLBACK_SLASH_COMMANDS, + getLocalizedFallbackCommands, filterSlashCommands, findSlashToken, insertSlashTrigger, @@ -207,8 +207,8 @@ export function EmptySession() { }, [workDir, lastPluginReloadSummary]) const allSlashCommands = useMemo( - () => mergeSlashCommands(slashCommands, FALLBACK_SLASH_COMMANDS), - [slashCommands], + () => mergeSlashCommands(slashCommands, getLocalizedFallbackCommands(t)), + [slashCommands, t], ) const handleWorkDirChange = (newWorkDir: string) => {