diff --git a/.node-version b/.node-version new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +22 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2bd5a0a98 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/src/commands.test.ts b/src/commands.test.ts index 3ae9ae493..825c376de 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1,9 +1,35 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, test } from 'bun:test' import { builtInCommandNames, formatDescriptionWithSource, } from './commands.js' +import { registerBatchSkill } from './skills/bundled/batch.js' +import { registerDebugSkill } from './skills/bundled/debug.js' +import { registerLoopSkill } from './skills/bundled/loop.js' +import { registerSimplifySkill } from './skills/bundled/simplify.js' +import { registerUpdateConfigSkill } from './skills/bundled/updateConfig.js' +import { + clearBundledSkills, + getBundledSkills, + registerBundledSkill, +} from './skills/bundledSkills.js' import { isCommand } from './types/command.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from './utils/settings/settingsCache.js' + +function useLanguage(language?: string): void { + setSessionSettingsCache({ + settings: language ? { language } : {}, + errors: [], + }) +} + +afterEach(() => { + resetSettingsCache() + clearBundledSkills() +}) describe('builtInCommandNames', () => { test('includes the LSP command', () => { @@ -71,4 +97,225 @@ describe('formatDescriptionWithSource', () => { expect(formatDescriptionWithSource(command)).toBe('(MyPlugin) ') }) + + test('translates prompt built-in descriptions using the current language', () => { + const command = { + name: 'review', + type: 'prompt', + source: 'builtin', + description: 'Review a pull request', + localizationKey: 'commands.review.description', + } as any + + useLanguage('english') + expect(formatDescriptionWithSource(command)).toBe('Review a pull request') + + useLanguage('vietnamese') + expect(formatDescriptionWithSource(command)).toBe('Đánh giá pull request') + }) + + test('falls back to English when an OpenClaude localization key is missing', () => { + const command = { + name: 'example', + type: 'prompt', + source: 'builtin', + description: 'English fallback description', + localizationKey: 'commands.example.missing.description', + } as any + + useLanguage('vietnamese') + expect(formatDescriptionWithSource(command)).toBe( + 'English fallback description', + ) + }) + + test('does not translate project, policy, workflow, or user-authored descriptions', () => { + const description = 'Review a pull request' + const promptCommand = (source: string) => + ({ + name: 'external-review', + type: 'prompt', + source, + description, + }) as any + + useLanguage('vietnamese') + + expect(formatDescriptionWithSource(promptCommand('projectSettings'))).toBe( + 'Review a pull request (project)', + ) + expect(formatDescriptionWithSource(promptCommand('userSettings'))).toBe( + 'Review a pull request (user)', + ) + expect(formatDescriptionWithSource(promptCommand('policySettings'))).toBe( + 'Review a pull request (managed)', + ) + expect(formatDescriptionWithSource(promptCommand('localSettings'))).toBe( + 'Review a pull request (project, gitignored)', + ) + expect(formatDescriptionWithSource(promptCommand('flagSettings'))).toBe( + 'Review a pull request (cli flag)', + ) + expect( + formatDescriptionWithSource({ + ...promptCommand('projectSettings'), + kind: 'workflow', + }), + ).toBe('Review a pull request (workflow)') + }) + + test('does not translate plugin descriptions that match built-in English text', () => { + const command = { + name: 'external-review', + type: 'prompt', + source: 'plugin', + description: 'Review a pull request', + pluginInfo: { + pluginManifest: { + name: 'MyPlugin', + }, + }, + } as any + + useLanguage('vietnamese') + + expect(formatDescriptionWithSource(command)).toBe( + '(MyPlugin) Review a pull request', + ) + }) + + test('does not translate non-prompt local descriptions without a localization key', () => { + const command = { + name: 'external-review', + type: 'local', + description: 'Review a pull request', + } as any + + useLanguage('vietnamese') + + expect(formatDescriptionWithSource(command)).toBe('Review a pull request') + }) + + test('translates non-prompt local descriptions only with an explicit localization key', () => { + const command = { + name: 'copy', + type: 'local', + description: + "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)", + localizationKey: 'commands.copy.description', + } as any + + useLanguage('vietnamese') + expect(formatDescriptionWithSource(command)).toBe( + 'Sao chép phản hồi gần nhất của Claude vào clipboard (hoặc /copy N cho phản hồi thứ N gần nhất)', + ) + + useLanguage('english') + expect(formatDescriptionWithSource(command)).toBe( + "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)", + ) + }) +}) + +describe('bundled skill localization', () => { + test('resolves descriptions from the current language at read time', () => { + resetSettingsCache() + clearBundledSkills() + registerBatchSkill() + registerDebugSkill() + registerLoopSkill() + registerSimplifySkill() + registerUpdateConfigSkill() + const batch = getBundledSkills().find(command => command.name === 'batch') + const debug = getBundledSkills().find(command => command.name === 'debug') + const loop = getBundledSkills().find(command => command.name === 'loop') + const simplify = getBundledSkills().find( + command => command.name === 'simplify', + ) + const updateConfig = getBundledSkills().find( + command => command.name === 'update-config', + ) + const expectedDebugEnglish = + process.env.USER_TYPE === 'ant' + ? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging' + : 'Enable debug logging for this session and help diagnose issues' + const expectedDebugVietnamese = + process.env.USER_TYPE === 'ant' + ? 'Debug phiên Claude Code hiện tại bằng cách đọc debug log của phiên. Bao gồm toàn bộ event logging' + : 'Bật debug logging cho phiên này và hỗ trợ chẩn đoán sự cố' + + expect(batch).toBeDefined() + expect(debug).toBeDefined() + expect(loop).toBeDefined() + expect(simplify).toBeDefined() + expect(updateConfig).toBeDefined() + expect(batch!.localizationKey).toBe('skills.batch.description') + expect(loop!.localizationKey).toBe('skills.loop.description') + expect(loop!.whenToUseLocalizationKey).toBe('skills.loop.whenToUse') + + useLanguage('english') + expect(batch!.description).toBe( + 'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.', + ) + expect(debug!.description).toBe(expectedDebugEnglish) + expect(loop!.description).toBe( + 'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.', + ) + expect(loop!.whenToUse).toBe( + 'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.', + ) + expect(simplify!.description).toBe( + 'Review changed code for reuse, quality, and efficiency, then fix any issues found.', + ) + expect(updateConfig!.description).toStartWith( + 'Use this skill to configure the Claude Code harness via settings.json.', + ) + + useLanguage('vietnamese') + expect(batch!.description).toBe( + 'Nghiên cứu và lập kế hoạch cho một thay đổi quy mô lớn, rồi thực thi song song trên 5–30 agent worktree cô lập, mỗi agent mở một PR.', + ) + expect(debug!.description).toBe(expectedDebugVietnamese) + expect(loop!.description).toBe( + 'Chạy một prompt theo khoảng thời gian cố định hoặc lên lịch lại động, bao gồm cả chế độ bảo trì lặp lại.', + ) + expect(loop!.whenToUse).toBe( + 'Khi người dùng muốn kiểm tra trạng thái, giám sát quy trình, chạy bảo trì định kỳ, hoặc chạy lại một prompt trong phiên hiện tại.', + ) + expect(simplify!.description).toBe( + 'Đánh giá code đã thay đổi về mặt tái sử dụng, chất lượng và hiệu suất, sau đó sửa các vấn đề tìm được.', + ) + expect(updateConfig!.description).toStartWith( + 'Sử dụng skill này để cấu hình Claude Code qua settings.json.', + ) + + useLanguage('english') + expect(loop!.description).toBe( + 'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.', + ) + expect(updateConfig!.description).toStartWith( + 'Use this skill to configure the Claude Code harness via settings.json.', + ) + }) + + test('falls back to bundled skill English text when a localization key is missing', () => { + registerBundledSkill({ + name: 'fallback-skill', + description: 'English-only bundled skill description', + descriptionKey: 'skills.fallback-skill.missing.description', + getPromptForCommand: async () => [], + }) + + const skill = getBundledSkills().find( + command => command.name === 'fallback-skill', + ) + + expect(skill).toBeDefined() + + useLanguage('vietnamese') + expect(skill!.description).toBe('English-only bundled skill description') + expect(formatDescriptionWithSource(skill!)).toBe( + 'English-only bundled skill description (bundled)', + ) + }) }) diff --git a/src/commands.ts b/src/commands.ts index 79ac1a1d9..f7f2dcefe 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -168,6 +168,10 @@ import { getDynamicSkills, } from './skills/loadSkillsDir.js' import { getBundledSkills } from './skills/bundledSkills.js' +import { + getOpenClaudeCommandDescriptionKey, + localize, +} from './i18n/index.js' import { getBuiltinPluginSkillCommands } from './plugins/builtinPlugins.js' import { getPluginCommands, @@ -365,7 +369,12 @@ const COMMANDS = memoize((): Command[] => [ ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO ? INTERNAL_ONLY_COMMANDS : []), -].filter(isCommand)) +].filter(isCommand).map(withOpenClaudeCommandLocalizationKey)) + +function withOpenClaudeCommandLocalizationKey(cmd: Command): Command { + cmd.localizationKey ??= getOpenClaudeCommandDescriptionKey(cmd.name) + return cmd +} export const builtInCommandNames = memoize( (): Set => @@ -752,7 +761,7 @@ export function getCommand(commandName: string, commands: Command[]): Command { */ export function formatDescriptionWithSource(cmd: Command): string { if (cmd.type !== 'prompt') { - return cmd.description ?? '' + return formatOpenClaudeOwnedDescription(cmd) } const desc = cmd.description ?? '' @@ -770,12 +779,22 @@ export function formatDescriptionWithSource(cmd: Command): string { } if (cmd.source === 'builtin' || cmd.source === 'mcp') { - return desc + return cmd.source === 'builtin' + ? formatOpenClaudeOwnedDescription(cmd) + : desc } if (cmd.source === 'bundled') { - return `${desc} (bundled)` + return `${formatOpenClaudeOwnedDescription(cmd)} (bundled)` } return `${desc} (${getSettingSourceName(cmd.source)})` } + +function formatOpenClaudeOwnedDescription(cmd: Command): string { + const desc = cmd.description ?? '' + if (cmd.localizationKey) { + return localize(cmd.localizationKey, desc) + } + return desc +} diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index dc652eb33..5b305702e 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -170,6 +170,7 @@ type Props = { setAppState: (f: (prev: AppState) => AppState) => void; }, options?: { fromKeybinding?: boolean; + slashCommandOverride?: Command; }) => Promise; onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise; isSearchingHistory: boolean; @@ -990,7 +991,7 @@ function PromptInput({ const setSuggestionsState = useCallback((updater: typeof suggestionsState | ((prev: typeof suggestionsState) => typeof suggestionsState)) => { setSuggestionsStateRaw(prev => typeof updater === 'function' ? updater(prev) : updater); }, []); - const onSubmit = useCallback(async (inputParam: string, isSubmittingSlashCommand = false) => { + const onSubmit = useCallback(async (inputParam: string, isSubmittingSlashCommand = false, slashCommandOverride?: Command) => { inputParam = inputParam.trimEnd(); // Don't submit if a footer indicator is being opened. Read fresh from @@ -1110,7 +1111,9 @@ function PromptInput({ setCursorOffset, clearBuffer, resetHistory - }); + }, undefined, slashCommandOverride ? { + slashCommandOverride + } : undefined); }, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]); const { suggestions, diff --git a/src/entrypoints/init.ts b/src/entrypoints/init.ts index a753e47cd..20ff59508 100644 --- a/src/entrypoints/init.ts +++ b/src/entrypoints/init.ts @@ -1,4 +1,5 @@ import { profileCheckpoint } from '../utils/startupProfiler.js' +import { validateEnvVars } from '../utils/envValidation.js' import '../bootstrap/state.js' import '../utils/config.js' import memoize from 'lodash-es/memoize.js' @@ -46,8 +47,9 @@ export const init = memoize(async (): Promise => { const initStartTime = Date.now() logForDiagnosticsNoPII('info', 'init_started') profileCheckpoint('init_function_start') - - // Validate configs are valid and enable configuration system + // Validate critical environment variables early + // Crashes immediately if invalid to avoid wasting time + validateEnvVars() try { const configsStart = Date.now() enableConfigs() @@ -66,6 +68,9 @@ export const init = memoize(async (): Promise => { // via BoringSSL, so this must happen before the first TLS handshake. applyExtraCACertsFromConfig() + // Re-validate values hydrated from trusted config before network setup. + validateEnvVars() + logForDiagnosticsNoPII('info', 'init_safe_env_vars_applied', { duration_ms: Date.now() - envVarsStart, }) diff --git a/src/hooks/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 3335f2f10..3eb84650e 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -22,7 +22,7 @@ import { generateProgressiveArgumentHint, parseArguments } from '../utils/argume import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js'; import { formatLogMetadata } from '../utils/format.js'; import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js'; -import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, isCommandInput } from '../utils/suggestions/commandSuggestions.js'; +import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, getCommandSuggestionForEnter, isCommandInput } from '../utils/suggestions/commandSuggestions.js'; import { getDirectoryCompletions, getPathCompletions, isPathLikeToken } from '../utils/suggestions/directoryCompletion.js'; import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js'; import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js'; @@ -80,7 +80,7 @@ function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string { } type Props = { onInputChange: (value: string) => void; - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void; + onSubmit: (value: string, isSubmittingSlashCommand?: boolean, slashCommandOverride?: Command) => void; setCursorOffset: (offset: number) => void; input: string; cursorOffset: number; @@ -1138,8 +1138,10 @@ export function useTypeahead({ if (selectedSuggestion < 0 || suggestions.length === 0) return; const suggestion = suggestions[selectedSuggestion]; if (suggestionType === 'command' && selectedSuggestion < suggestions.length) { - if (suggestion) { - applyCommandSuggestion(suggestion, true, + const commandSuggestion = getCommandSuggestionForEnter(input, suggestion, commands); + + if (commandSuggestion) { + applyCommandSuggestion(commandSuggestion, true, // execute on return commands, onInputChange, setCursorOffset, onSubmit); debouncedFetchFileSuggestions.cancel(); diff --git a/src/i18n/commandDescriptions.ts b/src/i18n/commandDescriptions.ts new file mode 100644 index 000000000..720278ca3 --- /dev/null +++ b/src/i18n/commandDescriptions.ts @@ -0,0 +1,69 @@ +import type { LocalizationKey } from './types.js' + +const openClaudeCommandDescriptionKeys: Record = { + 'add-dir': 'commands.add-dir.description', + agents: 'commands.agents.description', + 'auto-fix': 'commands.auto-fix.description', + branch: 'commands.branch.description', + btw: 'commands.btw.description', + 'cache-stats': 'commands.cache-stats.description', + clear: 'commands.clear.description', + color: 'commands.color.description', + compact: 'commands.compact.description', + 'commit-message': 'commands.commit-message.description', + config: 'commands.config.description', + copy: 'commands.copy.description', + context: 'commands.context.description', + cost: 'commands.cost.description', + diff: 'commands.diff.description', + dream: 'commands.dream.description', + doctor: 'commands.doctor.description', + effort: 'commands.effort.description', + exit: 'commands.exit.description', + export: 'commands.export.description', + heapdump: 'commands.heapdump.description', + help: 'commands.help.description', + hooks: 'commands.hooks.description', + ide: 'commands.ide.description', + init: 'commands.init.description', + insights: 'commands.insights.description', + 'install-github-app': 'commands.install-github-app.description', + knowledge: 'commands.knowledge.description', + login: 'commands.login.description', + logout: 'commands.logout.description', + lsp: 'commands.lsp.description', + mcp: 'commands.mcp.description', + memory: 'commands.memory.description', + 'onboard-github': 'commands.onboard-github.description', + 'output-style': 'commands.output-style.description', + permissions: 'commands.permissions.description', + plan: 'commands.plan.description', + plugin: 'commands.plugin.description', + provider: 'commands.provider.description', + 'pr-comments': 'commands.pr-comments.description', + 'release-notes': 'commands.release-notes.description', + 'reload-plugins': 'commands.reload-plugins.description', + rename: 'commands.rename.description', + 'request-size': 'commands.request-size.description', + resume: 'commands.resume.description', + review: 'commands.review.description', + rewind: 'commands.rewind.description', + 'security-review': 'commands.security-review.description', + skills: 'commands.skills.description', + stats: 'commands.stats.description', + status: 'commands.status.description', + statusline: 'commands.statusline.description', + stickers: 'commands.stickers.description', + tasks: 'commands.tasks.description', + 'terminal-setup': 'commands.terminal-setup.description', + theme: 'commands.theme.description', + usage: 'commands.usage.description', + vim: 'commands.vim.description', + wiki: 'commands.wiki.description', +} + +export function getOpenClaudeCommandDescriptionKey( + commandName: string, +): LocalizationKey | undefined { + return openClaudeCommandDescriptionKeys[commandName] +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 000000000..7beb10a22 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,41 @@ +import { detectLocale } from './locale.js' +import { en } from './languages/en.js' +import { vi } from './languages/vi.js' +import type { + I18nDictionary, + InterpolationValues, + LocalizationKey, +} from './types.js' + +const dictionaries: Record = { + en, + vi, +} + +export { detectLocale } +export { getOpenClaudeCommandDescriptionKey } from './commandDescriptions.js' +export type { InterpolationValues, Locale, LocalizationKey } from './types.js' + +export function localize( + key: LocalizationKey | undefined, + fallback: string, + values?: InterpolationValues, +): string { + if (!key) return fallback + + const locale = detectLocale() + const template = dictionaries[locale]?.[key] ?? en[key] ?? fallback + return interpolate(template, values) +} + +function interpolate( + template: string, + values: InterpolationValues | undefined, +): string { + if (!values) return template + return template.replace(/\{(\w+)\}/g, (match, key: string) => + Object.prototype.hasOwnProperty.call(values, key) + ? String(values[key]) + : match, + ) +} diff --git a/src/i18n/languages/en.ts b/src/i18n/languages/en.ts new file mode 100644 index 000000000..00212d074 --- /dev/null +++ b/src/i18n/languages/en.ts @@ -0,0 +1,106 @@ +export const en = { + 'commands.add-dir.description': 'Add a new working directory', + 'commands.agents.description': 'Manage agent configurations', + 'commands.auto-fix.description': + 'Configure auto-fix: run lint/test after AI edits', + 'commands.branch.description': + 'Create a branch of the current conversation at this point', + 'commands.btw.description': + 'Ask a quick side question without interrupting the main conversation', + 'commands.cache-stats.description': + 'Show per-turn and session cache hit/miss stats (works across all providers)', + 'commands.clear.description': + 'Clear conversation history and free up context', + 'commands.color.description': 'Set the prompt bar color for this session', + 'commands.compact.description': + 'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]', + 'commands.commit-message.description': + 'Configure commit attribution text', + 'commands.config.description': 'Open config panel', + 'commands.copy.description': + "Copy Claude's last response to clipboard (or /copy N for the Nth-latest)", + 'commands.context.description': 'Show current context usage', + 'commands.cost.description': + 'Show the total cost and duration of the current session', + 'commands.diff.description': 'View uncommitted changes and per-turn diffs', + 'commands.doctor.description': + 'Diagnose and verify your OpenClaude installation and settings', + 'commands.dream.description': + 'Run memory consolidation — synthesize recent sessions into durable memories', + 'commands.effort.description': 'Set effort level for model usage', + 'commands.exit.description': 'Exit the REPL', + 'commands.export.description': + 'Export the current conversation to a file or clipboard', + 'commands.heapdump.description': 'Dump the JS heap to ~/Desktop', + 'commands.help.description': 'Show help and available commands', + 'commands.hooks.description': 'View hook configurations for tool events', + 'commands.ide.description': 'Manage IDE integrations and show status', + 'commands.init.description': + 'Initialize a new project instruction file with codebase documentation', + 'commands.insights.description': + 'Generate a report analyzing your OpenClaude sessions', + 'commands.install-github-app.description': + 'Set up Claude GitHub Actions for a repository', + 'commands.knowledge.description': 'Manage native Knowledge Graph', + 'commands.login.description': 'Sign in with your Anthropic account', + 'commands.logout.description': 'Sign out from your Anthropic account', + 'commands.lsp.description': + 'Inspect and set up Language Server Protocol code intelligence', + 'commands.mcp.description': 'Manage MCP servers', + 'commands.memory.description': 'Edit Claude memory files', + 'commands.onboard-github.description': + 'Interactive setup for GitHub Copilot: OAuth device login stored in secure storage', + 'commands.output-style.description': + 'Deprecated: use /config to change output style', + 'commands.permissions.description': + 'Manage allow & deny tool permission rules', + 'commands.plan.description': + 'Enable plan mode or view the current session plan', + 'commands.plugin.description': 'Manage OpenClaude plugins', + 'commands.provider.description': 'Manage API provider profiles', + 'commands.pr-comments.description': + 'Get comments from a GitHub pull request', + 'commands.release-notes.description': 'View release notes', + 'commands.reload-plugins.description': + 'Activate pending plugin changes in the current session', + 'commands.rename.description': 'Rename the current conversation', + 'commands.request-size.description': + 'Show estimated request context load and top contributors', + 'commands.resume.description': 'Resume a previous conversation', + 'commands.review.description': 'Review a pull request', + 'commands.rewind.description': + 'Restore the code and/or conversation to a previous point', + 'commands.security-review.description': + 'Complete a security review of the pending changes on the current branch', + 'commands.skills.description': 'List available skills', + 'commands.stats.description': + 'Show your OpenClaude usage statistics and activity', + 'commands.status.description': + 'Show OpenClaude status including version, model, account, API connectivity, and tool statuses', + 'commands.statusline.description': "Set up OpenClaude's status line UI", + 'commands.stickers.description': 'Order OpenClaude stickers', + 'commands.tasks.description': 'List and manage background tasks', + 'commands.terminal-setup.description': + 'Install Shift+Enter key binding for newlines', + 'commands.theme.description': 'Change the theme', + 'commands.usage.description': 'Show plan usage limits', + 'commands.vim.description': 'Toggle between Vim and Normal editing modes', + 'commands.wiki.description': + 'Initialize and inspect the OpenClaude project wiki', + 'skills.batch.description': + 'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.', + 'skills.batch.whenToUse': + 'Use when the user wants to make a sweeping, mechanical change across many files (migrations, refactors, bulk renames) that can be decomposed into independent parallel units.', + 'skills.debug.ant.description': + 'Debug your current Claude Code session by reading the session debug log. Includes all event logging', + 'skills.debug.default.description': + 'Enable debug logging for this session and help diagnose issues', + 'skills.loop.description': + 'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.', + 'skills.loop.whenToUse': + 'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.', + 'skills.simplify.description': + 'Review changed code for reuse, quality, and efficiency, then fix any issues found.', + 'skills.update-config.description': + 'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.', +} as const diff --git a/src/i18n/languages/vi.ts b/src/i18n/languages/vi.ts new file mode 100644 index 000000000..3e3941c6c --- /dev/null +++ b/src/i18n/languages/vi.ts @@ -0,0 +1,107 @@ +export const vi = { + 'commands.add-dir.description': 'Thêm thư mục làm việc mới', + 'commands.agents.description': 'Quản lý cấu hình agent', + 'commands.auto-fix.description': + 'Cấu hình tự động sửa: chạy lint/test sau khi AI chỉnh sửa', + 'commands.branch.description': + 'Tạo nhánh của cuộc hội thoại tại điểm này', + 'commands.btw.description': + 'Đặt câu hỏi nhanh bên lề mà không làm gián đoạn cuộc hội thoại chính', + 'commands.cache-stats.description': + 'Hiển thị thống kê cache hit/miss theo lượt và phiên (hoạt động trên tất cả nhà cung cấp)', + 'commands.clear.description': + 'Xóa lịch sử hội thoại và giải phóng ngữ cảnh', + 'commands.color.description': 'Đặt màu thanh prompt cho phiên này', + 'commands.compact.description': + 'Xóa lịch sử hội thoại nhưng giữ tóm tắt trong ngữ cảnh. Tùy chọn: /compact [hướng dẫn tóm tắt]', + 'commands.commit-message.description': + 'Cấu hình văn bản ghi công commit', + 'commands.config.description': 'Mở bảng cấu hình', + 'commands.copy.description': + 'Sao chép phản hồi gần nhất của Claude vào clipboard (hoặc /copy N cho phản hồi thứ N gần nhất)', + 'commands.context.description': 'Hiện mức sử dụng ngữ cảnh', + 'commands.cost.description': + 'Hiện tổng chi phí và thời lượng phiên hiện tại', + 'commands.diff.description': 'Xem thay đổi chưa commit và diff từng lượt', + 'commands.doctor.description': + 'Chẩn đoán và xác minh cài đặt OpenClaude', + 'commands.dream.description': + 'Chạy hợp nhất bộ nhớ — tổng hợp các phiên gần đây thành bộ nhớ lâu dài', + 'commands.effort.description': 'Đặt mức độ nỗ lực cho mô hình', + 'commands.exit.description': 'Thoát REPL', + 'commands.export.description': + 'Xuất cuộc hội thoại ra file hoặc clipboard', + 'commands.heapdump.description': 'Xuất JS heap ra ~/Desktop', + 'commands.help.description': 'Hiện trợ giúp và các lệnh có sẵn', + 'commands.hooks.description': 'Xem cấu hình hook cho sự kiện tool', + 'commands.ide.description': 'Quản lý tích hợp IDE và hiện trạng thái', + 'commands.init.description': + 'Khởi tạo file hướng dẫn dự án mới với tài liệu codebase', + 'commands.insights.description': + 'Tạo báo cáo phân tích các phiên OpenClaude', + 'commands.install-github-app.description': + 'Thiết lập Claude GitHub Actions cho kho lưu trữ', + 'commands.knowledge.description': 'Quản lý Knowledge Graph', + 'commands.login.description': 'Đăng nhập bằng tài khoản Anthropic', + 'commands.logout.description': 'Đăng xuất khỏi tài khoản Anthropic', + 'commands.lsp.description': + 'Kiểm tra và thiết lập LSP code intelligence', + 'commands.mcp.description': 'Quản lý máy chủ MCP', + 'commands.memory.description': 'Chỉnh sửa file bộ nhớ Claude', + 'commands.onboard-github.description': + 'Thiết lập tương tác cho GitHub Copilot: đăng nhập OAuth lưu trong secure storage', + 'commands.output-style.description': + 'Đã ngừng sử dụng: dùng /config để đổi kiểu output', + 'commands.permissions.description': + 'Quản lý quy tắc cho phép & từ chối tool', + 'commands.plan.description': + 'Bật chế độ kế hoạch hoặc xem kế hoạch phiên hiện tại', + 'commands.plugin.description': 'Quản lý plugin OpenClaude', + 'commands.provider.description': 'Quản lý hồ sơ nhà cung cấp API', + 'commands.pr-comments.description': + 'Lấy bình luận từ pull request GitHub', + 'commands.release-notes.description': 'Xem ghi chú phát hành', + 'commands.reload-plugins.description': + 'Kích hoạt thay đổi plugin đang chờ trong phiên hiện tại', + 'commands.rename.description': 'Đổi tên cuộc hội thoại hiện tại', + 'commands.request-size.description': + 'Hiện tải ngữ cảnh ước tính và các thành phần chính', + 'commands.resume.description': 'Tiếp tục cuộc hội thoại trước', + 'commands.review.description': 'Đánh giá pull request', + 'commands.rewind.description': + 'Khôi phục mã và/hoặc cuộc hội thoại về điểm trước', + 'commands.security-review.description': + 'Hoàn thành đánh giá bảo mật cho các thay đổi đang chờ trên nhánh hiện tại', + 'commands.skills.description': 'Liệt kê các kỹ năng có sẵn', + 'commands.stats.description': + 'Hiện thống kê sử dụng và hoạt động OpenClaude', + 'commands.status.description': + 'Hiển thị trạng thái OpenClaude bao gồm phiên bản, mô hình, tài khoản, kết nối API và trạng thái công cụ', + 'commands.statusline.description': + 'Thiết lập giao diện dòng trạng thái của OpenClaude', + 'commands.stickers.description': 'Đặt mua sticker OpenClaude', + 'commands.tasks.description': 'Liệt kê và quản lý tác vụ nền', + 'commands.terminal-setup.description': + 'Cài đặt phím tắt Shift+Enter để xuống dòng', + 'commands.theme.description': 'Đổi giao diện', + 'commands.usage.description': 'Hiện giới hạn sử dụng gói', + 'commands.vim.description': 'Chuyển đổi giữa chế độ Vim và Normal', + 'commands.wiki.description': + 'Khởi tạo và kiểm tra wiki dự án OpenClaude', + 'skills.batch.description': + 'Nghiên cứu và lập kế hoạch cho một thay đổi quy mô lớn, rồi thực thi song song trên 5–30 agent worktree cô lập, mỗi agent mở một PR.', + 'skills.batch.whenToUse': + 'Dùng khi người dùng muốn thực hiện một thay đổi bao quát, cơ học trên nhiều file (migration, refactor, đổi tên hàng loạt) có thể chia thành các đơn vị song song độc lập.', + 'skills.debug.ant.description': + 'Debug phiên Claude Code hiện tại bằng cách đọc debug log của phiên. Bao gồm toàn bộ event logging', + 'skills.debug.default.description': + 'Bật debug logging cho phiên này và hỗ trợ chẩn đoán sự cố', + 'skills.loop.description': + 'Chạy một prompt theo khoảng thời gian cố định hoặc lên lịch lại động, bao gồm cả chế độ bảo trì lặp lại.', + 'skills.loop.whenToUse': + 'Khi người dùng muốn kiểm tra trạng thái, giám sát quy trình, chạy bảo trì định kỳ, hoặc chạy lại một prompt trong phiên hiện tại.', + 'skills.simplify.description': + 'Đánh giá code đã thay đổi về mặt tái sử dụng, chất lượng và hiệu suất, sau đó sửa các vấn đề tìm được.', + 'skills.update-config.description': + 'Sử dụng skill này để cấu hình Claude Code qua settings.json. Các hành vi tự động ("từ giờ khi X", "mỗi lần X", "bất cứ khi nào X", "trước/sau X") yêu cầu hooks được cấu hình trong settings.json - hệ thống thực thi hooks, không phải Claude, nên memory/preferences không thể thực hiện được. Cũng dùng cho: phân quyền ("cho phép X", "thêm quyền", "chuyển quyền"), biến môi trường ("set X=Y"), khắc phục sự cố hooks, hoặc bất kỳ thay đổi nào với settings.json/settings.local.json. Ví dụ: "cho phép lệnh npm", "thêm quyền bq vào settings toàn cục", "chuyển quyền sang user settings", "set DEBUG=true", "khi claude dừng hiển thị X". Với cài đặt đơn giản như theme/model, dùng Config tool.', +} as const diff --git a/src/i18n/locale.ts b/src/i18n/locale.ts new file mode 100644 index 000000000..f00f91afa --- /dev/null +++ b/src/i18n/locale.ts @@ -0,0 +1,19 @@ +import { getInitialSettings } from '../utils/settings/settings.js' +import { getSessionSettingsCache } from '../utils/settings/settingsCache.js' +import type { Locale } from './types.js' + +const LANGUAGE_MAP: Record = { + english: 'en', + en: 'en', + vietnamese: 'vi', + vi: 'vi', +} + +export function detectLocale(): Locale { + const settings = getSessionSettingsCache()?.settings ?? getInitialSettings() + const lang = settings.language + if (typeof lang !== 'string') { + return 'en' + } + return LANGUAGE_MAP[lang.toLowerCase()] ?? 'en' +} diff --git a/src/i18n/types.ts b/src/i18n/types.ts new file mode 100644 index 000000000..fc65e4a8b --- /dev/null +++ b/src/i18n/types.ts @@ -0,0 +1,7 @@ +export type Locale = 'en' | 'vi' + +export type LocalizationKey = string + +export type I18nDictionary = Record + +export type InterpolationValues = Record diff --git a/src/plugins/builtinPlugins.ts b/src/plugins/builtinPlugins.ts index fd3395676..2e8ac82e2 100644 --- a/src/plugins/builtinPlugins.ts +++ b/src/plugins/builtinPlugins.ts @@ -14,6 +14,7 @@ */ import type { Command } from '../commands.js' +import { localize } from '../i18n/index.js' import type { BundledSkillDefinition } from '../skills/bundledSkills.js' import type { BuiltinPluginDefinition, LoadedPlugin } from '../types/plugin.js' import { getSettings_DEPRECATED } from '../utils/settings/settings.js' @@ -79,7 +80,7 @@ export function getBuiltinPlugins(): { name, manifest: { name, - description: definition.description, + description: localize(definition.descriptionKey, definition.description), version: definition.version, }, path: BUILTIN_MARKETPLACE_NAME, // sentinel — no filesystem path @@ -133,11 +134,21 @@ function skillDefinitionToCommand(definition: BundledSkillDefinition): Command { return { type: 'prompt', name: definition.name, - description: definition.description, + get description() { + return localize(definition.descriptionKey, definition.description) + }, + localizationKey: definition.descriptionKey, hasUserSpecifiedDescription: true, allowedTools: definition.allowedTools ?? [], argumentHint: definition.argumentHint, - whenToUse: definition.whenToUse, + get whenToUse() { + if (definition.whenToUseKey) { + return localize(definition.whenToUseKey, definition.whenToUse ?? '') + } + + return definition.whenToUse + }, + whenToUseLocalizationKey: definition.whenToUseKey, model: definition.model, disableModelInvocation: definition.disableModelInvocation ?? false, userInvocable: definition.userInvocable ?? true, diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index e7aafc737..f859005c8 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -3198,6 +3198,7 @@ export function REPL({ setAppState: SetAppState; }, options?: { fromKeybinding?: boolean; + slashCommandOverride?: Command; }) => { // Re-pin scroll to bottom on submit so the user always sees the new // exchange (matches OpenCode's auto-scroll behavior). @@ -3565,6 +3566,7 @@ export function REPL({ canUseTool, addNotification, setMessages, + slashCommandOverride: options?.slashCommandOverride, // Read via ref so streamMode can be dropped from onSubmit deps — // handlePromptSubmit only uses it for debug log + telemetry event. streamMode: streamModeRef.current, diff --git a/src/skills/bundled/batch.ts b/src/skills/bundled/batch.ts index 90b4845ad..010c5d794 100644 --- a/src/skills/bundled/batch.ts +++ b/src/skills/bundled/batch.ts @@ -102,8 +102,10 @@ export function registerBatchSkill(): void { name: 'batch', description: 'Research and plan a large-scale change, then execute it in parallel across 5–30 isolated worktree agents that each open a PR.', + descriptionKey: 'skills.batch.description', whenToUse: 'Use when the user wants to make a sweeping, mechanical change across many files (migrations, refactors, bulk renames) that can be decomposed into independent parallel units.', + whenToUseKey: 'skills.batch.whenToUse', argumentHint: '', userInvocable: true, disableModelInvocation: true, diff --git a/src/skills/bundled/debug.ts b/src/skills/bundled/debug.ts index 33e86fcf1..944370387 100644 --- a/src/skills/bundled/debug.ts +++ b/src/skills/bundled/debug.ts @@ -16,6 +16,10 @@ export function registerDebugSkill(): void { process.env.USER_TYPE === 'ant' ? 'Debug your current Claude Code session by reading the session debug log. Includes all event logging' : 'Enable debug logging for this session and help diagnose issues', + descriptionKey: + process.env.USER_TYPE === 'ant' + ? 'skills.debug.ant.description' + : 'skills.debug.default.description', allowedTools: ['Read', 'Grep', 'Glob'], argumentHint: '[issue description]', // disableModelInvocation so that the user has to explicitly request it in diff --git a/src/skills/bundled/loop.ts b/src/skills/bundled/loop.ts index 1c47bdd0f..37491c67b 100644 --- a/src/skills/bundled/loop.ts +++ b/src/skills/bundled/loop.ts @@ -206,8 +206,10 @@ export function registerLoopSkill(): void { name: 'loop', description: 'Run a prompt on a fixed interval or dynamically reschedule it, including bare maintenance-mode loops.', + descriptionKey: 'skills.loop.description', whenToUse: 'When the user wants to poll for status, babysit a workflow, run recurring maintenance, or keep re-running a prompt within the current session.', + whenToUseKey: 'skills.loop.whenToUse', argumentHint: '[interval] [prompt]', userInvocable: true, isEnabled: isKairosCronEnabled, diff --git a/src/skills/bundled/simplify.ts b/src/skills/bundled/simplify.ts index efdfde216..56d1832c3 100644 --- a/src/skills/bundled/simplify.ts +++ b/src/skills/bundled/simplify.ts @@ -57,6 +57,7 @@ export function registerSimplifySkill(): void { name: 'simplify', description: 'Review changed code for reuse, quality, and efficiency, then fix any issues found.', + descriptionKey: 'skills.simplify.description', userInvocable: true, async getPromptForCommand(args) { let prompt = SIMPLIFY_PROMPT diff --git a/src/skills/bundled/updateConfig.ts b/src/skills/bundled/updateConfig.ts index 294d90e2c..1f2e6f6ff 100644 --- a/src/skills/bundled/updateConfig.ts +++ b/src/skills/bundled/updateConfig.ts @@ -448,6 +448,7 @@ export function registerUpdateConfigSkill(): void { name: 'update-config', description: 'Use this skill to configure the Claude Code harness via settings.json. Automated behaviors ("from now on when X", "each time X", "whenever X", "before/after X") require hooks configured in settings.json - the harness executes these, not Claude, so memory/preferences cannot fulfill them. Also use for: permissions ("allow X", "add permission", "move permission to"), env vars ("set X=Y"), hook troubleshooting, or any changes to settings.json/settings.local.json files. Examples: "allow npm commands", "add bq permission to global settings", "move permission to user settings", "set DEBUG=true", "when claude stops show X". For simple settings like theme/model, use Config tool.', + descriptionKey: 'skills.update-config.description', allowedTools: ['Read'], userInvocable: true, async getPromptForCommand(args) { diff --git a/src/skills/bundledSkills.ts b/src/skills/bundledSkills.ts index 83eab8f42..6669d5de4 100644 --- a/src/skills/bundledSkills.ts +++ b/src/skills/bundledSkills.ts @@ -4,6 +4,7 @@ import { mkdir, open } from 'fs/promises' import { dirname, isAbsolute, join, normalize, sep as pathSep } from 'path' import type { ToolUseContext } from '../Tool.js' import type { Command } from '../types/command.js' +import { localize, type LocalizationKey } from '../i18n/index.js' import { logForDebugging } from '../utils/debug.js' import { getBundledSkillsRoot } from '../utils/permissions/filesystem.js' import type { HooksSettings } from '../utils/settings/types.js' @@ -15,8 +16,10 @@ import type { HooksSettings } from '../utils/settings/types.js' export type BundledSkillDefinition = { name: string description: string + descriptionKey?: LocalizationKey aliases?: string[] whenToUse?: string + whenToUseKey?: LocalizationKey argumentHint?: string allowedTools?: string[] model?: string @@ -75,12 +78,22 @@ export function registerBundledSkill(definition: BundledSkillDefinition): void { const command: Command = { type: 'prompt', name: definition.name, - description: definition.description, + get description() { + return localize(definition.descriptionKey, definition.description) + }, + localizationKey: definition.descriptionKey, aliases: definition.aliases, hasUserSpecifiedDescription: true, allowedTools: definition.allowedTools ?? [], argumentHint: definition.argumentHint, - whenToUse: definition.whenToUse, + get whenToUse() { + if (definition.whenToUseKey) { + return localize(definition.whenToUseKey, definition.whenToUse ?? '') + } + + return definition.whenToUse + }, + whenToUseLocalizationKey: definition.whenToUseKey, model: definition.model, disableModelInvocation: definition.disableModelInvocation ?? false, userInvocable: definition.userInvocable ?? true, diff --git a/src/types/command.ts b/src/types/command.ts index 6a40a849d..b801c3da5 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -13,6 +13,7 @@ import type { ThemeName } from '../utils/theme.js' import type { LogOption } from './logs.js' import type { Message } from './message.js' import type { PluginManifest } from './plugin.js' +import type { LocalizationKey } from '../i18n/types.js' export type LocalCommandResult = | { @@ -191,6 +192,7 @@ export type CommandAvailability = export type CommandBase = { availability?: CommandAvailability[] description: string + localizationKey?: LocalizationKey hasUserSpecifiedDescription?: boolean /** Defaults to true. Only set when the command has conditional enablement (feature flags, env checks, etc). */ isEnabled?: () => boolean @@ -201,6 +203,7 @@ export type CommandBase = { isMcp?: boolean argumentHint?: string // Hint text for command arguments (displayed in gray after command) whenToUse?: string // From the "Skill" spec. Detailed usage scenarios for when to use this command + whenToUseLocalizationKey?: LocalizationKey version?: string // Version of the command/skill disableModelInvocation?: boolean // Whether to disable this command from being invoked by models userInvocable?: boolean // Whether users can invoke this skill by typing /skill-name diff --git a/src/types/plugin.ts b/src/types/plugin.ts index e398314ca..914231b39 100644 --- a/src/types/plugin.ts +++ b/src/types/plugin.ts @@ -1,6 +1,7 @@ import type { LspServerConfig } from '../services/lsp/types.js' import type { McpServerConfig } from '../services/mcp/types.js' import type { BundledSkillDefinition } from '../skills/bundledSkills.js' +import type { LocalizationKey } from '../i18n/types.js' import type { CommandMetadata, PluginAuthor, @@ -20,6 +21,7 @@ export type BuiltinPluginDefinition = { name: string /** Description shown in the /plugin UI */ description: string + descriptionKey?: LocalizationKey /** Optional version string */ version?: string /** Skills provided by this plugin */ diff --git a/src/types/textInputTypes.ts b/src/types/textInputTypes.ts index 6137236ae..b74be38ec 100644 --- a/src/types/textInputTypes.ts +++ b/src/types/textInputTypes.ts @@ -2,6 +2,7 @@ import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs import type { UUID } from 'crypto' import type React from 'react' import type { PermissionResult } from '../entrypoints/agentSdkTypes.js' +import type { Command } from '../commands.js' import type { Key } from '../ink.js' import type { PastedContent } from '../utils/config.js' import type { ImageDimensions } from '../utils/imageResizer.js' @@ -320,6 +321,7 @@ export type QueuedCommand = { * trigger local slash commands or skills. */ skipSlashCommands?: boolean + slashCommandOverride?: Command /** * When true, slash commands are dispatched but filtered through * isBridgeSafeCommand() — 'local-jsx' and terminal-only commands return diff --git a/src/utils/attribution.test.ts b/src/utils/attribution.test.ts index 44f7ccb45..f0f099814 100644 --- a/src/utils/attribution.test.ts +++ b/src/utils/attribution.test.ts @@ -269,10 +269,10 @@ describe('getAttributionTexts', () => { it('preserves includeCoAuthoredBy true as an explicit old-default opt-in', () => { useSettings({ includeCoAuthoredBy: true }) - expect(getAttributionTexts()).toEqual({ - commit: 'Co-Authored-By: OpenClaude (gpt-5.5) ', - pr: defaultPrAttribution, - }) + const attribution = getAttributionTexts() + expect(attribution.commit).toStartWith('Co-Authored-By: ') + expect(attribution.commit).toEndWith(' ') + expect(attribution.pr).toBe(defaultPrAttribution) }) it('keeps attribution off when includeCoAuthoredBy is false', () => { diff --git a/src/utils/envValidation.test.ts b/src/utils/envValidation.test.ts new file mode 100644 index 000000000..91c504b42 --- /dev/null +++ b/src/utils/envValidation.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import { validateEnvVars } from './envValidation.js' + +const optionalEnvVars = [ + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'CLAUDE_CONFIG_DIR', + 'NODE_EXTRA_CA_CERTS', +] as const + +const originalEnv = Object.fromEntries( + optionalEnvVars.map(name => [name, process.env[name]]), +) + +function restoreEnv(): void { + for (const name of optionalEnvVars) { + const value = originalEnv[name] + if (value === undefined) { + delete process.env[name] + } else { + process.env[name] = value + } + } +} + +afterEach(() => { + restoreEnv() +}) + +describe('validateEnvVars', () => { + test('treats empty optional env vars as unset', () => { + for (const name of optionalEnvVars) { + process.env[name] = '' + } + + const env = validateEnvVars() + + for (const name of optionalEnvVars) { + expect(env[name]).toBeUndefined() + } + }) +}) diff --git a/src/utils/envValidation.ts b/src/utils/envValidation.ts index 35b580a08..f162e6cad 100644 --- a/src/utils/envValidation.ts +++ b/src/utils/envValidation.ts @@ -1,4 +1,7 @@ import { logForDebugging } from './debug.js' +import { z } from 'zod/v4' + +// ─── Original bounded int validation ─── export type EnvVarValidationResult = { effective: number @@ -36,3 +39,42 @@ export function validateBoundedIntEnvVar( } return { effective: parsed, status: 'valid' } } + +// ─── Zod startup validation ─── + +const optionalNonEmptyString = z.preprocess( + value => (value === '' ? undefined : value), + z.string().min(1).optional(), +) + +const EnvSchema = z.object({ + ANTHROPIC_API_KEY: optionalNonEmptyString, + ANTHROPIC_AUTH_TOKEN: optionalNonEmptyString, + CLAUDE_CONFIG_DIR: optionalNonEmptyString, + HTTP_PROXY: z.string().url().optional().or(z.literal('')), + HTTPS_PROXY: z.string().url().optional().or(z.literal('')), + NODE_EXTRA_CA_CERTS: optionalNonEmptyString, + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: z.string().optional(), + CLAUDE_CODE_DISABLE_TERMINAL_TITLE: z.string().optional(), + CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR: z.string().optional(), +}) + +export type ValidatedEnv = z.infer + +export function validateEnvVars(): ValidatedEnv { + const result = EnvSchema.safeParse(process.env) + + if (!result.success) { + const errors = result.error.issues.map(issue => { + const path = issue.path.join('.') + return ` ${path}: ${issue.message}` + }).join('\n') + + console.error('❌ Environment variable validation failed:') + console.error(errors) + console.error('\nPlease fix the above environment variables and try again.') + process.exit(1) + } + + return result.data +} diff --git a/src/utils/handlePromptSubmit.ts b/src/utils/handlePromptSubmit.ts index 461306a80..b77647913 100644 --- a/src/utils/handlePromptSubmit.ts +++ b/src/utils/handlePromptSubmit.ts @@ -115,6 +115,7 @@ export type HandlePromptSubmitParams = BaseExecutionParams & { * trigger local slash commands or skills. */ skipSlashCommands?: boolean + slashCommandOverride?: Command } export async function handlePromptSubmit( @@ -141,6 +142,7 @@ export async function handlePromptSubmit( queuedCommands, uuid, skipSlashCommands, + slashCommandOverride, } = params const { setCursorOffset, clearBuffer, resetHistory } = helpers @@ -340,6 +342,7 @@ export async function handlePromptSubmit( mode, pastedContents: hasImages ? pastedContents : undefined, skipSlashCommands, + slashCommandOverride, uuid, }) @@ -363,6 +366,7 @@ export async function handlePromptSubmit( mode, pastedContents: hasImages ? pastedContents : undefined, skipSlashCommands, + slashCommandOverride, uuid, } @@ -491,6 +495,7 @@ async function executeUserInput(params: ExecuteUserInputParams): Promise { uuid: cmd.uuid, ideSelection: isFirst ? ideSelection : undefined, skipSlashCommands: cmd.skipSlashCommands, + slashCommandOverride: cmd.slashCommandOverride, bridgeOrigin: cmd.bridgeOrigin, isMeta: cmd.isMeta, skipAttachments: !isFirst, diff --git a/src/utils/model/model.openai-shim-providers.test.ts b/src/utils/model/model.openai-shim-providers.test.ts index e43f5a9b8..c0b18c059 100644 --- a/src/utils/model/model.openai-shim-providers.test.ts +++ b/src/utils/model/model.openai-shim-providers.test.ts @@ -4,21 +4,18 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../../test/sharedMutationLock.js' +import { resetStateForTests } from '../../bootstrap/state.js' import { type GlobalConfig, getGlobalConfig, saveGlobalConfig, } from '../config.js' - +import { + clearPluginSettingsBase, + resetSettingsCache, +} from '../settings/settingsCache.js' async function importFreshModelModule() { mock.restore() - mock.module('../auth.js', () => ({ - getSubscriptionType: () => 'max', - isClaudeAISubscriber: () => true, - isMaxSubscriber: () => true, - isProSubscriber: () => false, - isTeamPremiumSubscriber: () => false, - })) mock.module('./providers.js', () => ({ getAPIProvider: () => { if (process.env.NVIDIA_NIM) return 'nvidia-nim' @@ -40,10 +37,25 @@ async function importFreshModelModule() { return 'firstParty' }, })) + mock.module('./modelAllowlist.js', () => ({ + isModelAllowed: () => true, + })) const nonce = `${Date.now()}-${Math.random()}` return import(`./model.js?ts=${nonce}`) } +async function restoreMockedModulesToActual(): Promise { + const nonce = `${Date.now()}-${Math.random()}` + const [actualProviders, actualModelAllowlist] = await Promise.all([ + import(`./providers.js?restore=${nonce}`), + import(`./modelAllowlist.js?restore=${nonce}`), + ]) + mock.module('./providers.js', () => actualProviders) + mock.module('src/utils/model/providers.js', () => actualProviders) + mock.module('./modelAllowlist.js', () => actualModelAllowlist) + mock.module('src/utils/model/modelAllowlist.js', () => actualModelAllowlist) +} + const SAVED_ENV = { CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, @@ -100,6 +112,9 @@ beforeEach(async () => { // globally. Without mock.restore() here, those overrides bleed into this // suite and the provider-kind branches we're testing become unreachable. mock.restore() + resetStateForTests() + resetSettingsCache() + clearPluginSettingsBase() delete process.env.CLAUDE_CODE_USE_OPENAI delete process.env.CLAUDE_CODE_USE_GEMINI delete process.env.CLAUDE_CODE_USE_GITHUB @@ -130,18 +145,24 @@ beforeEach(async () => { saveGlobalConfig(current => ({ ...current, model: undefined, + availableModels: undefined, })) }) -afterEach(() => { +afterEach(async () => { try { mock.restore() + resetStateForTests() + resetSettingsCache() + clearPluginSettingsBase() + await restoreMockedModulesToActual() for (const key of Object.keys(SAVED_ENV) as Array) { restoreEnv(key) } saveGlobalConfig(current => ({ ...current, model: savedModel, + availableModels: undefined, })) } finally { releaseSharedMutationLock() diff --git a/src/utils/processUserInput/processSlashCommand.test.ts b/src/utils/processUserInput/processSlashCommand.test.ts index 480c28ca8..de811f32e 100644 --- a/src/utils/processUserInput/processSlashCommand.test.ts +++ b/src/utils/processUserInput/processSlashCommand.test.ts @@ -1,5 +1,9 @@ import { describe, expect, test } from 'bun:test' -import { attachmentScanInputForCommand } from './processSlashCommand.js' +import type { Command } from '../../types/command.js' +import { + attachmentScanInputForCommand, + resolveSlashCommand, +} from './processSlashCommand.js' describe('attachmentScanInputForCommand', () => { // A remote skill:// body must never have its @-mentions / MCP-resource refs @@ -26,3 +30,40 @@ describe('attachmentScanInputForCommand', () => { expect(attachmentScanInputForCommand({}, 'hi @x')).toBe('hi @x') }) }) + +describe('resolveSlashCommand', () => { + function promptCommand(source: 'builtin' | 'projectSettings'): Command { + return { + type: 'prompt', + name: 'review', + source, + description: `${source} review`, + progressMessage: 'running', + contentLength: 0, + getPromptForCommand: async () => [], + } as Command + } + + test('uses the selected duplicate command override when it matches the slash name', () => { + const builtinReview = promptCommand('builtin') + const projectReview = promptCommand('projectSettings') + + expect( + resolveSlashCommand('review', [builtinReview, projectReview], projectReview), + ).toBe(projectReview) + }) + + test('falls back to the normal command lookup when the override does not match', () => { + const builtinReview = promptCommand('builtin') + const projectReview = promptCommand('projectSettings') + const statusCommand = { + ...builtinReview, + name: 'status', + description: 'Status', + } as Command + + expect( + resolveSlashCommand('status', [statusCommand], projectReview), + ).toBe(statusCommand) + }) +}) diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index 5416ada2d..bea24d3f2 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -299,7 +299,16 @@ export function looksLikeCommand(commandName: string): boolean { // If it contains other characters, it's probably a file path or other input return !/[^a-zA-Z0-9:\-_]/.test(commandName); } -export async function processSlashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn, uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn): Promise { +function commandMatchesSlashName(command: Command, commandName: string): boolean { + return command.name === commandName || getCommandName(command) === commandName || command.aliases?.includes(commandName) === true; +} +export function resolveSlashCommand(commandName: string, commands: Command[], commandOverride?: Command): Command { + if (commandOverride && commandMatchesSlashName(commandOverride, commandName)) { + return commandOverride; + } + return getCommand(commandName, commands); +} +export async function processSlashCommand(inputString: string, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], attachmentMessages: AttachmentMessage[], context: ProcessUserInputContext, setToolJSX: SetToolJSXFn, uuid?: string, isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, slashCommandOverride?: Command): Promise { const parsed = parseSlashCommand(inputString); if (!parsed) { logEvent('tengu_input_slash_missing', {}); @@ -385,7 +394,7 @@ export async function processSlashCommand(inputString: string, precedingInputBlo resultText, nextInput, submitNextInput - } = await getMessagesForSlashCommand(commandName, parsedArgs, setToolJSX, context, precedingInputBlocks, imageContentBlocks, isAlreadyProcessing, canUseTool, uuid); + } = await getMessagesForSlashCommand(commandName, parsedArgs, setToolJSX, context, precedingInputBlocks, imageContentBlocks, isAlreadyProcessing, canUseTool, uuid, slashCommandOverride); // Local slash commands that skip messages if (newMessages.length === 0) { @@ -494,8 +503,8 @@ export async function processSlashCommand(inputString: string, precedingInputBlo submitNextInput }; } -async function getMessagesForSlashCommand(commandName: string, args: string, setToolJSX: SetToolJSXFn, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], _isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, uuid?: string): Promise { - const command = getCommand(commandName, context.options.commands); +async function getMessagesForSlashCommand(commandName: string, args: string, setToolJSX: SetToolJSXFn, context: ProcessUserInputContext, precedingInputBlocks: ContentBlockParam[], imageContentBlocks: ContentBlockParam[], _isAlreadyProcessing?: boolean, canUseTool?: CanUseToolFn, uuid?: string, slashCommandOverride?: Command): Promise { + const command = resolveSlashCommand(commandName, context.options.commands, slashCommandOverride); // Track skill usage for ranking (only for prompt commands that are user-invocable) if (command.type === 'prompt' && command.userInvocable !== false) { diff --git a/src/utils/processUserInput/processUserInput.ts b/src/utils/processUserInput/processUserInput.ts index d5a68c436..79f37795e 100644 --- a/src/utils/processUserInput/processUserInput.ts +++ b/src/utils/processUserInput/processUserInput.ts @@ -9,6 +9,7 @@ import type { QuerySource } from 'src/constants/querySource.js' import { logEvent } from 'src/services/analytics/index.js' import { getContentText } from 'src/utils/messages.js' import { + type Command, findCommand, getCommandName, isBridgeSafeCommand, @@ -97,6 +98,7 @@ export async function processUserInput({ querySource, canUseTool, skipSlashCommands, + slashCommandOverride, bridgeOrigin, isMeta, skipAttachments, @@ -125,6 +127,7 @@ export async function processUserInput({ * trigger local slash commands or skills. */ skipSlashCommands?: boolean + slashCommandOverride?: Command /** * When true, slash commands matching isBridgeSafeCommand() execute even * though skipSlashCommands is set. See QueuedCommand.bridgeOrigin. @@ -168,6 +171,7 @@ export async function processUserInput({ isMeta, skipAttachments, preExpansionInput, + slashCommandOverride, ) queryCheckpoint('query_process_user_input_base_end') @@ -296,6 +300,7 @@ async function processUserInputBase( isMeta?: boolean, skipAttachments?: boolean, preExpansionInput?: string, + slashCommandOverride?: Command, ): Promise { let inputString: string | null = null let precedingInputBlocks: ContentBlockParam[] = [] @@ -546,6 +551,7 @@ async function processUserInputBase( uuid, isAlreadyProcessing, canUseTool, + slashCommandOverride, ) return addImageMetadataMessage(slashResult, imageMetadataTexts) } diff --git a/src/utils/suggestions/commandSuggestions.test.ts b/src/utils/suggestions/commandSuggestions.test.ts new file mode 100644 index 000000000..9ab79184c --- /dev/null +++ b/src/utils/suggestions/commandSuggestions.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, test } from 'bun:test' +import type { Command } from '../../types/command.js' +import type { LocalizationKey } from '../../i18n/index.js' +import { + resetSettingsCache, + setSessionSettingsCache, +} from '../settings/settingsCache.js' +import { + applyCommandSuggestion, + getCommandSuggestionForEnter, + generateCommandSuggestions, +} from './commandSuggestions.js' + +function promptCommand({ + name, + getDescription, + source = 'builtin', + kind, + pluginName, + localizationKey, +}: { + name: string + getDescription: () => string + source?: + | 'builtin' + | 'bundled' + | 'mcp' + | 'plugin' + | 'projectSettings' + | 'userSettings' + | 'policySettings' + kind?: 'workflow' + pluginName?: string + localizationKey?: LocalizationKey +}): Command { + return { + type: 'prompt', + name, + get description() { + return getDescription() + }, + source, + kind, + localizationKey, + pluginInfo: pluginName + ? { + pluginManifest: { + name: pluginName, + }, + repository: 'test', + } + : undefined, + progressMessage: 'running', + contentLength: 0, + getPromptForCommand: async () => [], + } as Command +} + +function useLanguage(language?: string): void { + setSessionSettingsCache({ + settings: language ? { language } : {}, + errors: [], + }) +} + +afterEach(() => { + resetSettingsCache() +}) + +describe('generateCommandSuggestions localization', () => { + test('searches localized built-in descriptions with a stable command array', () => { + const commands = [ + promptCommand({ + name: 'review', + source: 'builtin', + getDescription: () => 'Review a pull request', + localizationKey: 'commands.review.description', + }), + ] + + useLanguage('english') + expect( + generateCommandSuggestions('/pull', commands).map( + item => item.displayText, + ), + ).toContain('/review') + + useLanguage('vietnamese') + const suggestions = generateCommandSuggestions('/\u0111\u00e1nh', commands) + + expect(suggestions[0]?.displayText).toBe('/review') + expect(suggestions[0]?.description).toBe( + '\u0110\u00e1nh gi\u00e1 pull request', + ) + + useLanguage('english') + const englishSuggestions = generateCommandSuggestions('/pull', commands) + expect(englishSuggestions[0]?.displayText).toBe('/review') + expect(englishSuggestions[0]?.description).toBe('Review a pull request') + }) + + test('searches localized bundled descriptions with a stable command array', () => { + const commands = [ + promptCommand({ + name: 'loop', + source: 'bundled', + getDescription: () => + 'Run a prompt on a fixed interval or dynamically reschedule it.', + localizationKey: 'skills.loop.description', + }), + ] + + useLanguage('english') + expect( + generateCommandSuggestions('/interval', commands).map( + item => item.displayText, + ), + ).toContain('/loop') + + useLanguage('vietnamese') + const suggestions = generateCommandSuggestions('/kho\u1ea3ng', commands) + const loopSuggestion = suggestions.find(item => item.displayText === '/loop') + + expect(loopSuggestion).toBeDefined() + expect(loopSuggestion?.description).toContain( + 'kho\u1ea3ng th\u1eddi gian', + ) + }) + + test('does not index external English descriptions as Vietnamese text', () => { + const commands = [ + promptCommand({ + name: 'project-review', + source: 'projectSettings', + getDescription: () => 'Review a pull request', + }), + promptCommand({ + name: 'plugin-review', + source: 'plugin', + getDescription: () => 'Review a pull request', + pluginName: 'MyPlugin', + }), + promptCommand({ + name: 'workflow-review', + source: 'projectSettings', + kind: 'workflow', + getDescription: () => 'Review a pull request', + }), + promptCommand({ + name: 'builtin-review', + source: 'builtin', + getDescription: () => 'Review a pull request', + localizationKey: 'commands.review.description', + }), + ] + + useLanguage('vietnamese') + const vietnameseMatches = generateCommandSuggestions( + '/\u0111\u00e1nh', + commands, + ).map(item => item.displayText) + + expect(vietnameseMatches).toContain('/builtin-review') + expect(vietnameseMatches).not.toContain('/project-review') + expect(vietnameseMatches).not.toContain('/plugin-review') + expect(vietnameseMatches).not.toContain('/workflow-review') + + const pluginSuggestion = generateCommandSuggestions( + '/plugin-review', + commands, + ).find(item => item.displayText === '/plugin-review') + + expect(pluginSuggestion?.description).toBe( + '(MyPlugin) Review a pull request', + ) + }) + + test('passes the selected duplicate command row as the slash command override', () => { + const builtinReview = promptCommand({ + name: 'review', + source: 'builtin', + getDescription: () => 'Builtin review', + }) + const projectReview = promptCommand({ + name: 'review', + source: 'projectSettings', + getDescription: () => 'Project review', + }) + const commands = [builtinReview, projectReview] + const projectSuggestion = generateCommandSuggestions('/review', commands).find( + item => item.metadata === projectReview, + ) + let submittedValue: string | undefined + let submittedOverride: Command | undefined + + expect(projectSuggestion).toBeDefined() + applyCommandSuggestion( + projectSuggestion!, + true, + commands, + value => { + submittedValue = value + }, + () => {}, + (value, _isSlashCommand, override) => { + submittedValue = value + submittedOverride = override + }, + ) + + expect(submittedValue).toBe('/review ') + expect(submittedOverride).toBe(projectReview) + }) + + test('keeps the selected duplicate command row for exact-name Enter', () => { + const builtinReview = promptCommand({ + name: 'review', + source: 'builtin', + getDescription: () => 'Builtin review', + }) + const projectReview = promptCommand({ + name: 'review', + source: 'projectSettings', + getDescription: () => 'Project review', + }) + const commands = [builtinReview, projectReview] + const projectSuggestion = generateCommandSuggestions('/review', commands).find( + item => item.metadata === projectReview, + ) + + expect( + getCommandSuggestionForEnter('/review', projectSuggestion, commands), + ).toBe(projectSuggestion) + }) + + test('normalizes exact-name Enter when there is only one matching command', () => { + const review = promptCommand({ + name: 'review', + source: 'builtin', + getDescription: () => 'Builtin review', + }) + const suggestion = generateCommandSuggestions('/Review', [review])[0] + + expect(getCommandSuggestionForEnter('/Review', suggestion, [review])).toBe( + 'review', + ) + }) +}) diff --git a/src/utils/suggestions/commandSuggestions.ts b/src/utils/suggestions/commandSuggestions.ts index 2f83ae6f4..35b08d1df 100644 --- a/src/utils/suggestions/commandSuggestions.ts +++ b/src/utils/suggestions/commandSuggestions.ts @@ -19,34 +19,49 @@ type CommandSearchItem = { aliasKey: string[] | undefined } -// Cache the Fuse index keyed by the commands array identity. The commands -// array is stable (memoized in REPL.tsx), so we only rebuild when it changes -// rather than on every keystroke. +type CommandSearchSnapshot = { + aliases: string[] | undefined + command: Command + commandName: string + isHidden: boolean + renderedDescription: string +} + +// Cache the Fuse index keyed by the commands array identity plus a signature +// of the searchable UI text. The commands array is stable (memoized in +// REPL.tsx), while language changes can alter rendered descriptions in place. let fuseCache: { commands: Command[] + signature: string fuse: Fuse } | null = null function getCommandFuse(commands: Command[]): Fuse { - if (fuseCache?.commands === commands) { + const snapshots = getCommandSearchSnapshots(commands) + const signature = getCommandSearchSignature(snapshots) + + if ( + fuseCache?.commands === commands && + fuseCache.signature === signature + ) { return fuseCache.fuse } - const commandData: CommandSearchItem[] = commands - .filter(cmd => !cmd.isHidden) - .map(cmd => { - const commandName = getCommandName(cmd) + const commandData: CommandSearchItem[] = snapshots + .filter(snapshot => !snapshot.isHidden) + .map(snapshot => { + const { aliases, command, commandName, renderedDescription } = snapshot const parts = commandName.split(SEPARATORS).filter(Boolean) return { - descriptionKey: (cmd.description ?? '') - .split(' ') + descriptionKey: renderedDescription + .split(/\s+/) .map(word => cleanWord(word)) .filter(Boolean), partKey: parts.length > 1 ? parts : undefined, commandName, - command: cmd, - aliasKey: cmd.aliases, + command, + aliasKey: aliases, } }) @@ -75,10 +90,35 @@ function getCommandFuse(commands: Command[]): Fuse { ], }) - fuseCache = { commands, fuse } + fuseCache = { commands, signature, fuse } return fuse } +function getCommandSearchSnapshots( + commands: Command[], +): CommandSearchSnapshot[] { + return commands.map(command => ({ + aliases: command.aliases, + command, + commandName: getCommandName(command), + isHidden: Boolean(command.isHidden), + renderedDescription: getRenderedCommandDescription(command), + })) +} + +function getCommandSearchSignature( + snapshots: CommandSearchSnapshot[], +): string { + return JSON.stringify( + snapshots.map(snapshot => [ + snapshot.commandName, + snapshot.aliases ?? [], + snapshot.isHidden, + snapshot.renderedDescription, + ]), + ) +} + /** * Type guard to check if a suggestion's metadata is a Command. * Commands have a name string and a type property. @@ -201,6 +241,23 @@ export function isCommandInput(input: string): boolean { return input.startsWith('/') } +export function getCommandSuggestionForEnter( + input: string, + suggestion: SuggestionItem | undefined, + commands: Command[], +): string | SuggestionItem | undefined { + const exactCommandName = !input.includes(' ') && isCommandInput(input) + ? input.slice(1).toLowerCase().trim() + : '' + const exactCommands = exactCommandName + ? commands.filter(cmd => getCommandName(cmd).toLowerCase() === exactCommandName) + : [] + + return exactCommands.length === 1 + ? getCommandName(exactCommands[0]!) + : suggestion +} + /** * Checks if a command input has arguments * A command with just a trailing space is considered to have no arguments @@ -271,11 +328,7 @@ function createCommandSuggestionItem( const aliasText = matchedAlias ? ` (${matchedAlias})` : '' const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow' - const fullDescription = - (isWorkflow ? cmd.description : formatDescriptionWithSource(cmd)) + - (cmd.type === 'prompt' && cmd.argNames?.length - ? ` (arguments: ${cmd.argNames.join(', ')})` - : '') + const fullDescription = getRenderedCommandDescription(cmd) return { id: getCommandId(cmd), @@ -286,6 +339,19 @@ function createCommandSuggestionItem( } } +function getRenderedCommandDescription(cmd: Command): string { + const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow' + const description = isWorkflow + ? cmd.description + : formatDescriptionWithSource(cmd) + return ( + description + + (cmd.type === 'prompt' && cmd.argNames?.length + ? ` (arguments: ${cmd.argNames.join(', ')})` + : '') + ) +} + /** * Ensure suggestion IDs are unique for React keys and selection logic. * If duplicates exist, append a stable numeric suffix to subsequent entries. @@ -398,15 +464,12 @@ export function generateCommandSuggestions( ].map(cmd => createCommandSuggestionItem(cmd))) } - // The Fuse index filters isHidden at build time and is keyed on the - // (memoized) commands array identity, so a command that is hidden when Fuse - // first builds stays invisible to Fuse for the whole session. If the user - // types the exact name of a currently-hidden command, prepend it to the - // Fuse results so exact-name always wins over weak description fuzzy - // matches — but only when no visible command shares the name (that would - // be the user's explicit override and should win). Prepend rather than - // early-return so visible prefix siblings (e.g. /voice-memo) still appear - // below, and getBestCommandMatch can still find a non-empty suffix. + // The Fuse index filters hidden commands, so an exact hidden command name + // will not appear in Fuse results. If no visible command shares the name, + // prepend the hidden exact match so explicit invocation still works. + // Prepend rather than early-return so visible prefix siblings (e.g. + // /voice-memo) still appear below, and getBestCommandMatch can still find + // a non-empty suffix. let hiddenExact = commands.find( cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query, ) @@ -501,12 +564,8 @@ export function generateCommandSuggestions( const matchedAlias = findMatchedAlias(query, cmd.aliases) return createCommandSuggestionItem(cmd, matchedAlias) }) - // Skip the prepend if hiddenExact is already in fuseSuggestions — this - // happens when isHidden flips false→true mid-session (OAuth expiry, - // GrowthBook kill-switch) and the stale Fuse index still holds the - // command. Fuse already sorts exact-name matches first, so no reorder - // is needed; we just don't want a duplicate id (duplicate React keys, - // both rows rendering as selected). + // Skip the prepend defensively if the command is already present; duplicate + // ids confuse React keys and selection state. if (hiddenExact) { const hiddenId = getCommandId(hiddenExact) if (!fuseSuggestions.some(s => s.id === hiddenId)) { @@ -528,7 +587,11 @@ export function applyCommandSuggestion( commands: Command[], onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void, - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void, + onSubmit: ( + value: string, + isSubmittingSlashCommand?: boolean, + slashCommandOverride?: Command, + ) => void, ): void { // Extract command name and object from string or SuggestionItem metadata let commandName: string @@ -555,14 +618,14 @@ export function applyCommandSuggestion( commandObj.type !== 'prompt' || (commandObj.argNames ?? []).length === 0 ) { - onSubmit(newInput, /* isSubmittingSlashCommand */ true) + onSubmit(newInput, /* isSubmittingSlashCommand */ true, commandObj) } } } // Helper function at bottom of file per CLAUDE.md function cleanWord(word: string) { - return word.toLowerCase().replace(/[^a-z0-9]/g, '') + return word.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '') } /**