diff --git a/.changeset/refresh-slash-menu-on-lang-switch.md b/.changeset/refresh-slash-menu-on-lang-switch.md new file mode 100644 index 000000000..33b9f439d --- /dev/null +++ b/.changeset/refresh-slash-menu-on-lang-switch.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Refresh localized slash-command descriptions immediately after language switches or TUI config reloads, and update the Chinese thinking-mode footer label. diff --git a/.changeset/tui-i18n-language-footer.md b/.changeset/tui-i18n-language-footer.md new file mode 100644 index 000000000..6857cb50e --- /dev/null +++ b/.changeset/tui-i18n-language-footer.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a `language` setting to `tui.toml` (`auto` | `en` | `zh-CN`) and render the footer status bar in Simplified Chinese when Chinese is selected or detected from the system locale. diff --git a/.changeset/tui-i18n-language-selector.md b/.changeset/tui-i18n-language-selector.md new file mode 100644 index 000000000..151c5825b --- /dev/null +++ b/.changeset/tui-i18n-language-selector.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a Language entry to `/settings` for switching the UI language at runtime. Picking Auto / English / 简体中文 flips the interface immediately (no restart), persists to `tui.toml`, and is re-applied on startup and by `/reload`. diff --git a/.changeset/tui-i18n-reverse-rpc-panels.md b/.changeset/tui-i18n-reverse-rpc-panels.md new file mode 100644 index 000000000..f48f577b7 --- /dev/null +++ b/.changeset/tui-i18n-reverse-rpc-panels.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Translate the reverse-RPC approval and question panels through the i18n layer: headers, choice buttons, danger labels, prompts, and key hints now render in the active UI language, with `en` and `zh-CN` translations under the new `reverseRpc` namespace. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index def82d70f..b96fcec2d 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -35,6 +35,7 @@ "type": "module", "imports": { "#/tui/theme": "./src/tui/theme/index.ts", + "#/tui/i18n": "./src/tui/i18n/index.ts", "#/cli/sub/server": "./src/cli/sub/server/index.ts", "#/cli/sub/server/*": "./src/cli/sub/server/*.ts", "#/*": [ diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 25d6af369..677c88b6b 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -21,6 +21,7 @@ import { detectPendingMigration } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { i18n, resolveLocale } from '#/tui/i18n'; import { KimiTUI } from '#/tui/index'; import { currentTheme, getColorPalette } from '#/tui/theme'; import { combineStartupNotice } from '#/tui/utils/startup'; @@ -38,19 +39,29 @@ export async function runShell( const startedAt = Date.now(); const configStartedAt = startedAt; let tuiConfig: TuiConfig; - let configWarning: string | undefined; + // The config-parse notice is rendered through i18n *after* the locale is + // resolved below — config load runs before we know the language, so we only + // record that it failed here and translate once the locale is available. + let configParseFailed = false; try { tuiConfig = await loadTuiConfig(); } catch (error) { if (!(error instanceof TuiConfigParseError)) throw error; tuiConfig = error.fallback; - configWarning = error.message; + configParseFailed = true; } // Initialise the global Theme singleton before pi-tui grabs stdin. const palette = await getColorPalette(tuiConfig.theme); currentTheme.setPalette(palette); + // Initialise the global I18n singleton alongside the theme: resolve the + // configured language (including `'auto'`) to a concrete locale so the TUI + // renders in the right language from the first frame. + i18n.setLocale(resolveLocale(tuiConfig.language)); + + let configWarning = configParseFailed ? i18n.t('cli.config.invalidTuiConfig') : undefined; + const workDir = process.cwd(); const telemetryBootstrap = createCliTelemetryBootstrap(); const telemetryClient: TelemetryClient = { diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 9b91d4ba0..5b80dc26b 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -6,6 +6,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; +import { LanguageSelectorComponent } from '../components/dialogs/language-selector'; import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, @@ -16,6 +17,8 @@ import { SettingsSelectorComponent, type SettingsSelection } from '../components import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { saveTuiConfig } from '../config'; +import type { TuiLanguage } from '../config'; +import { i18n, resolveLocale } from '#/tui/i18n'; import type { ThemeName } from '#/tui/theme'; import { currentTheme, isBuiltInTheme, lightColors, loadCustomThemeMerged } from '#/tui/theme'; import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; @@ -274,6 +277,7 @@ async function applyEditorChoice(host: SlashCommandHost, value: string): Promise try { await saveTuiConfig({ theme: host.state.appState.theme, + language: host.state.appState.language, editorCommand, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, @@ -424,6 +428,7 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi try { await saveTuiConfig({ theme, + language: host.state.appState.language, editorCommand: host.state.appState.editorCommand, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, @@ -446,6 +451,56 @@ async function applyThemeChoice(host: SlashCommandHost, theme: ThemeName): Promi host.showStatus(`Theme set to "${theme}"${detail}.`); } +function showLanguagePicker(host: SlashCommandHost): void { + host.mountEditorReplacement( + new LanguageSelectorComponent({ + currentValue: host.state.appState.language, + onSelect: (value) => { + host.restoreEditor(); + void applyLanguageChoice(host, value); + }, + onCancel: () => { + host.restoreEditor(); + }, + }), + ); +} + +export async function applyLanguageChoice(host: LanguageHost, language: TuiLanguage): Promise { + if (language === host.state.appState.language) { + host.showStatus(`Language unchanged: "${language}".`); + return; + } + + try { + await saveTuiConfig({ + theme: host.state.appState.theme, + language, + editorCommand: host.state.appState.editorCommand, + notifications: host.state.appState.notifications, + upgrade: host.state.appState.upgrade, + }); + } catch (error) { + host.showStatus( + `Failed to save language: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.setAppState({ language }); + // Flip the live UI locale and repaint so every component — footer, chrome, + // and any open dialog — re-renders in the new language without a restart. + i18n.setLocale(resolveLocale(language)); + // The `/` autocomplete holds a one-time snapshot of localized command + // descriptions (see setupAutocomplete); rebuild it so the command menu + // follows the new locale immediately instead of only after a restart. + host.refreshSlashCommandAutocomplete(); + host.state.ui.requestRender(); + host.track('language_switch', { language }); + host.showStatus(`Language set to "${language}".`); +} + export function showPermissionPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new PermissionSelectorComponent({ @@ -542,11 +597,25 @@ function mountExperimentsPanel( ); } +type LanguageHost = { + readonly state: { + readonly appState: Pick< + SlashCommandHost['state']['appState'], + 'theme' | 'language' | 'editorCommand' | 'notifications' | 'upgrade' + >; + readonly ui: Pick; + }; + setAppState(patch: Pick): void; + showStatus(msg: string, color?: string): void; + track: SlashCommandHost['track']; + refreshSlashCommandAutocomplete: SlashCommandHost['refreshSlashCommandAutocomplete']; +}; + type UpdatePreferenceHost = { readonly state: { readonly appState: Pick< SlashCommandHost['state']['appState'], - 'theme' | 'editorCommand' | 'notifications' | 'upgrade' + 'theme' | 'language' | 'editorCommand' | 'notifications' | 'upgrade' >; }; setAppState(patch: Pick): void; @@ -567,6 +636,7 @@ export async function applyUpdatePreferenceChoice( try { await saveTuiConfig({ theme: host.state.appState.theme, + language: host.state.appState.language, editorCommand: host.state.appState.editorCommand, notifications: host.state.appState.notifications, upgrade, @@ -621,6 +691,7 @@ function handleSettingsSelection(host: SlashCommandHost, value: SettingsSelectio case 'model': showModelPicker(host); return; case 'permission': showPermissionPicker(host); return; case 'theme': showThemePicker(host); return; + case 'language': showLanguagePicker(host); return; case 'editor': showEditorPicker(host); return; case 'experiments': void showExperimentsPanel(host); return; case 'upgrade': showUpdatePreferencePicker(host); return; diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index 73cc99824..89326c614 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -18,6 +18,7 @@ import { } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; +import { i18n } from '#/tui/i18n'; import { openUrl } from '#/utils/open-url'; import { promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; @@ -125,7 +126,11 @@ export async function showStatusReport(host: SlashCommandHost): Promise { managedUsage: managedUsage?.usage, managedUsageError: managedUsage?.error, }; - const panel = new UsagePanelComponent(() => buildStatusReportLines(reportArgs), 'primary', ' Status '); + const panel = new UsagePanelComponent( + () => buildStatusReportLines(reportArgs), + 'primary', + i18n.t('components.status.panelTitle'), + ); host.state.transcriptContainer.addChild(panel); host.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index f6274f79a..16bf6e81f 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -1,44 +1,50 @@ import type { AutocompleteItem } from '@earendil-works/pi-tui'; +import { i18n } from '#/tui/i18n'; + import { completeLeadingArg, type ArgCompletionSpec } from './complete-args'; import type { KimiSlashCommand, SlashCommandAvailability } from './types'; -/** Subcommands offered when autocompleting `/goal <…>`. */ -const GOAL_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'status', description: 'Show the current goal' }, - { value: 'pause', description: 'Pause the active goal' }, - { value: 'resume', description: 'Resume a paused goal' }, - { value: 'cancel', description: 'Cancel and remove the current goal' }, - { value: 'replace', description: 'Replace the current goal with a new objective' }, - { value: 'next', description: 'Queue an upcoming goal' }, -]; +/** + * Subcommand argument-completion specs, built at call time so descriptions + * follow the active locale. `value` is an identifier and stays untranslated; + * only `description` is localized via `commands.args.*`. + */ +function goalArgCompletions(): ArgCompletionSpec[] { + return ['status', 'pause', 'resume', 'cancel', 'replace', 'next'].map((value) => ({ + value, + description: i18n.t(`commands.args.goal.${value}`), + })); +} -const GOAL_NEXT_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'manage', description: 'Manage upcoming goals' }, -]; +function goalNextArgCompletions(): ArgCompletionSpec[] { + return [{ value: 'manage', description: i18n.t('commands.args.goal.manage') }]; +} -const SWARM_ARG_COMPLETIONS: readonly ArgCompletionSpec[] = [ - { value: 'on', description: 'Turn swarm mode on' }, - { value: 'off', description: 'Turn swarm mode off' }, -]; +function swarmArgCompletions(): ArgCompletionSpec[] { + return ['on', 'off'].map((value) => ({ + value, + description: i18n.t(`commands.args.swarm.${value}`), + })); +} /** Argument autocompletion for the `/goal` command (subcommands). */ export function goalArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { const nextMatch = argumentPrefix.match(/^next\s+(\S*)$/i); if (nextMatch !== null) { return ( - completeLeadingArg(GOAL_NEXT_ARG_COMPLETIONS, nextMatch[1] ?? '')?.map((item) => ({ + completeLeadingArg(goalNextArgCompletions(), nextMatch[1] ?? '')?.map((item) => ({ ...item, value: `next ${item.value}`, })) ?? null ); } - return completeLeadingArg(GOAL_ARG_COMPLETIONS, argumentPrefix); + return completeLeadingArg(goalArgCompletions(), argumentPrefix); } /** Argument autocompletion for the `/swarm` command (subcommands). */ export function swarmArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null { - return completeLeadingArg(SWARM_ARG_COMPLETIONS, argumentPrefix); + return completeLeadingArg(swarmArgCompletions(), argumentPrefix); } export const BUILTIN_SLASH_COMMANDS = [ @@ -309,6 +315,23 @@ export function findBuiltInSlashCommand(commandName: string): BuiltinSlashComman ) as BuiltinSlashCommand | undefined; } +/** + * Builtin commands with their `description` resolved for the active locale. + * + * The static `description` strings on `BUILTIN_SLASH_COMMANDS` are the English + * source of truth; display surfaces (autocomplete + the `/help` panel) call + * this so the description follows `i18n.setLocale(...)` at render time, exactly + * as components call `i18n.t(...)`. Command names / aliases are identifiers and + * are never translated. Skill-provided commands keep their own descriptions — + * only the framework's own builtins are localized here. + */ +export function localizedBuiltinSlashCommands(): readonly KimiSlashCommand[] { + return BUILTIN_SLASH_COMMANDS.map((command) => ({ + ...command, + description: i18n.t(`commands.descriptions.${command.name}`), + })); +} + export function resolveSlashCommandAvailability( command: KimiSlashCommand, args: string, diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a8700d95e..f8e21f9bb 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -1,5 +1,6 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; +import { i18n, resolveLocale } from '#/tui/i18n'; import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; import type { SlashCommandHost } from './dispatch'; @@ -22,8 +23,9 @@ export async function handleReloadCommand(host: SlashCommandHost): Promise const config = await host.harness.getConfig({ reload: true }); setExperimentalFeatures(await host.harness.getExperimentalFeatures()); - host.refreshSlashCommandAutocomplete(); applyRuntimeConfig(host, config); + // applyReloadedTuiConfig rebuilds the slash-command autocomplete, picking up + // both the refreshed experimental-flag gating above and the reloaded locale. await applyReloadedTuiConfig(host, tuiConfig); if (session === undefined) { @@ -43,10 +45,19 @@ export async function applyReloadedTuiConfig( : undefined; await host.applyTheme(config.theme, resolved); host.refreshTerminalThemeTracking(); + // Re-apply the language alongside the theme: update the persisted preference + // in app state and flip the live i18n locale so a reloaded `language` takes + // effect without a process restart. + i18n.setLocale(resolveLocale(config.language)); + // Rebuild the `/` autocomplete snapshot so its localized command descriptions + // follow the reloaded locale. `/reload` already refreshes it separately, but + // `/reload-tui` reaches this helper directly, so refresh here too. + host.refreshSlashCommandAutocomplete(); host.setAppState({ editorCommand: config.editorCommand, notifications: config.notifications, upgrade: config.upgrade, + language: config.language, }); } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index 127009506..cda160cf1 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -11,6 +11,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { isRainbowDancing, renderDanceFooterModel } from '#/tui/easter-eggs/dance'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import type { ColorPalette } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -199,11 +200,12 @@ function safeUsage(usage: number): number { } function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string { + const label = i18n.t('components.footer.context'); const pct = `${(safeUsage(usage) * 100).toFixed(1)}%`; if (maxTokens && maxTokens > 0 && tokens !== undefined) { - return `context: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; + return `${label}: ${pct} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; } - return `context: ${pct}`; + return `${label}: ${pct}`; } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { @@ -294,7 +296,7 @@ export class FooterComponent implements Component { const model = modelDisplayName(state); if (model) { - const thinkingLabel = state.thinking ? ' thinking' : ''; + const thinkingLabel = state.thinking ? ` ${i18n.t('components.footer.thinking')}` : ''; const modelLabel = `${model}${thinkingLabel}`; let renderedModelLabel = chalk.hex(colors.text)(modelLabel); if (isRainbowDancing()) { @@ -307,15 +309,21 @@ export class FooterComponent implements Component { // (shell processes) and `agent-*` tasks (background subagents) get // separate badges so the user can distinguish them at a glance. if (this.backgroundBashTaskCount > 0) { - const noun = this.backgroundBashTaskCount === 1 ? 'task' : 'tasks'; + const key = + this.backgroundBashTaskCount === 1 + ? 'components.footer.taskRunning' + : 'components.footer.tasksRunning'; left.push( - chalk.hex(colors.primary)(`[${String(this.backgroundBashTaskCount)} ${noun} running]`), + chalk.hex(colors.primary)(`[${i18n.t(key, { count: this.backgroundBashTaskCount })}]`), ); } if (this.backgroundAgentCount > 0) { - const noun = this.backgroundAgentCount === 1 ? 'agent' : 'agents'; + const key = + this.backgroundAgentCount === 1 + ? 'components.footer.agentRunning' + : 'components.footer.agentsRunning'; left.push( - chalk.hex(colors.primary)(`[${String(this.backgroundAgentCount)} ${noun} running]`), + chalk.hex(colors.primary)(`[${i18n.t(key, { count: this.backgroundAgentCount })}]`), ); } diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 3db1de3cf..f7a6fa9a1 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -8,6 +8,7 @@ import { truncateToWidth, visibleWidth } from '@earendil-works/pi-tui'; import chalk from 'chalk'; import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; +import { i18n } from '#/tui/i18n'; import type { AppState } from '#/tui/types'; import { currentTheme } from '#/tui/theme'; @@ -27,14 +28,15 @@ export class WelcomeComponent implements Component { const activeModel = this.state.availableModels[this.state.model]; if (safeWidth < 24) { - const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); + const title = chalk.bold.hex(currentTheme.palette.primary)(i18n.t('components.welcome.title')); const prompt = isLoggedOut - ? chalk.hex(currentTheme.palette.warning)('Run /login or /provider to get started.') - : chalk.hex(currentTheme.palette.textDim)('Send /help for help information.'); + ? chalk.hex(currentTheme.palette.warning)(i18n.t('components.welcome.getStarted')) + : chalk.hex(currentTheme.palette.textDim)(i18n.t('components.welcome.helpHint')); const model = isLoggedOut - ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + ? chalk.hex(currentTheme.palette.warning)(i18n.t('components.welcome.modelNotSet')) : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); - return ['', title, prompt, `Model: ${model}`].map((line) => + const modelLabel = i18n.t('components.welcome.labels.model'); + return ['', title, prompt, `${modelLabel}: ${model}`].map((line) => truncateToWidth(line, safeWidth, '…'), ); } @@ -49,14 +51,18 @@ export class WelcomeComponent implements Component { const textWidth = Math.max(4, innerWidth - logoWidth - gap.length); const rightRow0 = truncateToWidth( - chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'), + chalk.bold.hex(currentTheme.palette.primary)(i18n.t('components.welcome.title')), textWidth, '…', ); const dim = chalk.hex(currentTheme.palette.textDim); const labelStyle = chalk.bold.hex(currentTheme.palette.textDim); const rightRow1 = truncateToWidth( - dim(isLoggedOut ? 'Run /login or /provider to get started.' : 'Send /help for help information.'), + dim( + isLoggedOut + ? i18n.t('components.welcome.getStarted') + : i18n.t('components.welcome.helpHint'), + ), textWidth, '…', ); @@ -70,20 +76,29 @@ export class WelcomeComponent implements Component { } const modelValue = isLoggedOut - ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + ? chalk.hex(currentTheme.palette.warning)(i18n.t('components.welcome.modelNotSet')) : (activeModel?.displayName ?? activeModel?.model ?? this.state.model); - const infoLines = [ - labelStyle('Directory: ') + this.state.workDir, - labelStyle('Session: ') + this.state.sessionId, - labelStyle('Model: ') + modelValue, - labelStyle('Version: ') + this.state.version, + const label = (key: string): string => `${i18n.t(`components.welcome.labels.${key}`)}:`; + const infoFields: Array<[label: string, value: string]> = [ + [label('directory'), this.state.workDir], + [label('session'), this.state.sessionId], + [label('model'), modelValue], + [label('version'), this.state.version], ]; - if (this.state.mcpServersSummary) { - infoLines.push(labelStyle('MCP: ') + this.state.mcpServersSummary); + infoFields.push([label('mcp'), this.state.mcpServersSummary]); } + // Pad each label to a common display width (not char count) plus one gap, + // so the value column lines up regardless of locale — CJK labels are + // double-width. + const labelColWidth = Math.max(...infoFields.map(([text]) => visibleWidth(text))) + 1; + const infoLines = infoFields.map(([text, value]) => { + const padding = ' '.repeat(Math.max(0, labelColWidth - visibleWidth(text))); + return labelStyle(text + padding) + value; + }); + const contentLines: string[] = [...renderedHeaderLines, '', ...infoLines]; const lines: string[] = [ diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts index 1f1a55403..a0e3d455b 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-panel.ts @@ -16,6 +16,7 @@ import { wrapTextWithAnsi, } from '@earendil-works/pi-tui'; import { currentTheme } from '#/tui/theme'; +import { i18n } from '#/tui/i18n'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; import type { @@ -84,10 +85,10 @@ function renderShellDisplayBlock( ): string[] { const lines: string[] = []; if (block.cwd !== undefined && block.cwd.length > 0) { - lines.push(s.dim(`cwd: ${block.cwd}`)); + lines.push(s.dim(i18n.t('reverseRpc.approval.cwd', { path: block.cwd }))); } if (block.danger !== undefined) { - lines.push(s.errorBold(`Dangerous: ${block.danger}`)); + lines.push(s.errorBold(i18n.t('reverseRpc.approval.dangerousPrefix', { label: block.danger }))); } const cmdLines = block.command.length > 0 ? block.command.split('\n') : ['']; cmdLines.forEach((cmdLine, idx) => { @@ -122,11 +123,11 @@ function renderDisplayBlock( } const remaining = allLines.length - shown.length; if (remaining > 0) { - lines.push( - s.dim( - ` … ${String(remaining)} more line${remaining > 1 ? 's' : ''} hidden (ctrl+e to preview)`, - ), - ); + const moreKey = + remaining > 1 + ? 'reverseRpc.approval.moreLinesHidden' + : 'reverseRpc.approval.moreLineHidden'; + lines.push(s.dim(` ${i18n.t(moreKey, { count: remaining })}`)); } return lines; } @@ -147,7 +148,7 @@ function renderDisplayBlock( case 'search': { const lines = [`${s.accent('search')} ${s.strong(block.query)}`]; if (block.scope !== undefined && block.scope.length > 0) { - lines.push(s.dim(`scope: ${block.scope}`)); + lines.push(s.dim(i18n.t('reverseRpc.approval.scope', { scope: block.scope }))); } return lines; } @@ -191,17 +192,17 @@ function isDuplicateBriefBlock(block: DisplayBlock, description: string): boolea function headerFor(toolName: string): string { switch (toolName) { case 'Bash': - return 'Run this command?'; + return i18n.t('reverseRpc.approval.header.bash'); case 'Write': - return 'Write this file?'; + return i18n.t('reverseRpc.approval.header.write'); case 'Edit': - return 'Apply these edits?'; + return i18n.t('reverseRpc.approval.header.edit'); case 'TaskStop': - return 'Stop this task?'; + return i18n.t('reverseRpc.approval.header.taskStop'); case 'ExitPlanMode': - return 'Ready to build with this plan?'; + return i18n.t('reverseRpc.approval.header.exitPlanMode'); default: - return `Approve ${toolName}?`; + return i18n.t('reverseRpc.approval.header.default', { tool: toolName }); } } @@ -383,13 +384,18 @@ export class ApprovalPanelComponent extends Container implements Focusable { lines.push(''); if (this.feedbackMode) { - lines.push(indent(dim('Type feedback · ↵ submit.'))); + lines.push(indent(dim(i18n.t('reverseRpc.approval.hint.feedback')))); } else { - const expandHint = hasPreviewable ? ' · ctrl+e preview' : ''; + const expandHint = hasPreviewable + ? ` · ${i18n.t('reverseRpc.approval.hint.preview')}` + : ''; + const select = i18n.t('reverseRpc.approval.hint.select'); + const choose = i18n.t('reverseRpc.approval.hint.choose'); + const confirm = i18n.t('reverseRpc.approval.hint.confirm'); lines.push( indent( dim( - `↑/↓ select · ${buildNumericHint(data.choices.length)} choose · ↵ confirm${expandHint}`, + `↑/↓ ${select} · ${buildNumericHint(data.choices.length)} ${choose} · ↵ ${confirm}${expandHint}`, ), ), ); diff --git a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts index 15974959f..167b2dcfb 100644 --- a/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts +++ b/apps/kimi-code/src/tui/components/dialogs/approval-preview.ts @@ -28,6 +28,7 @@ import { import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLines } from '#/tui/components/media/diff-preview'; +import { i18n } from '#/tui/i18n'; import type { DiffDisplayBlock, FileContentDisplayBlock } from '#/tui/reverse-rpc/types'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; @@ -150,7 +151,7 @@ export class ApprovalPreviewViewer extends Container implements Focusable { } private renderHeader(width: number): string { - const title = currentTheme.boldFg('primary', ' Preview '); + const title = currentTheme.boldFg('primary', ` ${i18n.t('reverseRpc.preview.title')} `); return fitExactly(title + this.headerTitle, width); } @@ -191,10 +192,10 @@ export class ApprovalPreviewViewer extends Container implements Focusable { ` ${String(lineFrom)}-${String(lineTo)} / ${String(total)} (${String(percent)}%) `, ); const keys = - `${key('↑↓')} ${dim('line')} ` + - `${key('PgUp/PgDn')} ${dim('page')} ` + - `${key('g/G')} ${dim('top/bot')} ` + - `${key('Q/Esc/Ctrl+E')} ${dim('cancel')}`; + `${key('↑↓')} ${dim(i18n.t('reverseRpc.preview.hint.line'))} ` + + `${key('PgUp/PgDn')} ${dim(i18n.t('reverseRpc.preview.hint.page'))} ` + + `${key('g/G')} ${dim(i18n.t('reverseRpc.preview.hint.topBot'))} ` + + `${key('Q/Esc/Ctrl+E')} ${dim(i18n.t('reverseRpc.preview.hint.cancel'))}`; const left = ` ${keys}`; const leftW = visibleWidth(left); const rightW = visibleWidth(position); diff --git a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts index c03444f9b..3cfc99235 100644 --- a/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/choice-picker.ts @@ -17,6 +17,7 @@ import { type Focusable, } from '@earendil-works/pi-tui'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -122,9 +123,9 @@ export class ChoicePickerComponent extends Container implements Focusable { // Header mirrors the model dialog (see model-selector.ts): border, title // with a "(type to search)" suffix until you type, the hint, a blank, then // the search line. Key vocabulary is lowercase to match every list dialog. - const navParts = ['↑↓ navigate']; + const navParts = [i18n.t('common.hints.navigate')]; if (view.page.pageCount > 1) navParts.push('←→ page'); - navParts.push('Enter select', 'Esc cancel'); + navParts.push(i18n.t('common.hints.select'), i18n.t('common.hints.cancel')); const hint = this.opts.hint ?? navParts.join(' · '); const titleSuffix = diff --git a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts index 44042f057..bc0bffb50 100644 --- a/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/experiments-selector.ts @@ -9,6 +9,7 @@ import { import type { ExperimentalFeatureState } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { printableChar } from '#/tui/utils/printable-key'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -67,10 +68,10 @@ export class ExperimentsSelectorComponent extends Container implements Focusable const view = this.list.view(); const titleSuffix = view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; - const hintParts = ['↑↓ navigate']; + const hintParts = [i18n.t('common.hints.navigate')]; if (view.page.pageCount > 1) hintParts.push('PgUp/PgDn page'); - hintParts.push('Space toggle', 'Enter apply', 'Esc cancel'); - if (view.query.length > 0) hintParts.push('Backspace clear'); + hintParts.push('Space toggle', 'Enter apply', i18n.t('common.hints.cancel')); + if (view.query.length > 0) hintParts.push(i18n.t('common.hints.clearSearch')); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts index 1bbb743a0..a3ee1ba30 100644 --- a/apps/kimi-code/src/tui/components/dialogs/help-panel.ts +++ b/apps/kimi-code/src/tui/components/dialogs/help-panel.ts @@ -16,6 +16,7 @@ import { type Focusable, truncateToWidth, } from '@earendil-works/pi-tui'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; export interface KeyboardShortcut { @@ -29,19 +30,26 @@ export interface HelpPanelCommand { readonly description: string; } -/** Static list — keep in sync with the global editor bindings. */ -export const DEFAULT_KEYBOARD_SHORTCUTS: readonly KeyboardShortcut[] = [ - { keys: 'Shift-Tab', description: 'Toggle plan mode' }, - { keys: 'Ctrl-G', description: 'Edit in external editor ($VISUAL / $EDITOR)' }, - { keys: 'Ctrl-O', description: 'Toggle tool output expansion' }, - { keys: 'Ctrl-S', description: 'Steer — inject a follow-up during streaming' }, - { keys: 'Shift-Enter / Ctrl-J', description: 'Insert newline' }, - { keys: 'Ctrl-C', description: 'Interrupt stream / clear input' }, - { keys: 'Ctrl-D', description: 'Exit (on empty input)' }, - { keys: 'Esc', description: 'Close dialogs / interrupt streaming' }, - { keys: '↑ / ↓', description: 'Browse input history' }, - { keys: 'Enter', description: 'Submit' }, -]; +/** + * Default keyboard shortcuts — keep in sync with the global editor bindings. + * + * Built at call time so the descriptions follow the active locale; the keys + * (`Shift-Tab`, `Ctrl-G`, …) are identifiers and stay verbatim in every locale. + */ +export function defaultKeyboardShortcuts(): readonly KeyboardShortcut[] { + return [ + { keys: 'Shift-Tab', description: i18n.t('commands.help.shortcuts.planMode') }, + { keys: 'Ctrl-G', description: i18n.t('commands.help.shortcuts.externalEditor') }, + { keys: 'Ctrl-O', description: i18n.t('commands.help.shortcuts.toolOutput') }, + { keys: 'Ctrl-S', description: i18n.t('commands.help.shortcuts.steer') }, + { keys: 'Shift-Enter / Ctrl-J', description: i18n.t('commands.help.shortcuts.newline') }, + { keys: 'Ctrl-C', description: i18n.t('commands.help.shortcuts.interrupt') }, + { keys: 'Ctrl-D', description: i18n.t('commands.help.shortcuts.exit') }, + { keys: 'Esc', description: i18n.t('commands.help.shortcuts.closeDialogs') }, + { keys: '↑ / ↓', description: i18n.t('commands.help.shortcuts.history') }, + { keys: 'Enter', description: i18n.t('commands.help.shortcuts.submit') }, + ]; +} export interface HelpPanelOptions { readonly commands: readonly HelpPanelCommand[]; @@ -96,7 +104,7 @@ export class HelpPanelComponent extends Container implements Focusable { const kbdColor = (text: string) => currentTheme.fg('warning', text); const slashColor = (text: string) => currentTheme.fg('primary', text); - const shortcuts = this.opts.shortcuts ?? DEFAULT_KEYBOARD_SHORTCUTS; + const shortcuts = this.opts.shortcuts ?? defaultKeyboardShortcuts(); const kbdWidth = Math.max(8, ...shortcuts.map((s) => s.keys.length)); const sortedCmds = [...this.opts.commands].toSorted(compareSlashCommandsForDisplay); const cmdLabels = sortedCmds.map((c) => { @@ -106,17 +114,18 @@ export class HelpPanelComponent extends Container implements Focusable { const cmdWidth = Math.max(12, ...cmdLabels.map((l) => l.length)); const lines: string[] = [ accent('─'.repeat(width)), - currentTheme.boldFg('primary', ' help ') + muted('· Esc / Enter / q to cancel · ↑↓ scroll'), + currentTheme.boldFg('primary', ` ${i18n.t('commands.help.title')} `) + + muted(i18n.t('commands.help.dismiss')), '', // Greeting - ` ${dim('Sure, Kimi is ready to help! Just send a message to get started.')}`, + ` ${dim(i18n.t('commands.help.greeting'))}`, '', // Section: keyboard shortcuts - ` ${currentTheme.bold('Keyboard shortcuts')}`, + ` ${currentTheme.bold(i18n.t('commands.help.keyboardShortcuts'))}`, ...shortcuts.map((s) => ` ${kbdColor(s.keys.padEnd(kbdWidth))} ${dim(s.description)}`), '', // Section: slash commands - ` ${currentTheme.bold('Slash commands')}`, + ` ${currentTheme.bold(i18n.t('commands.help.slashCommands'))}`, ...sortedCmds.map((cmd, i) => { const label = cmdLabels[i] ?? `/${cmd.name}`; return ` ${slashColor(label.padEnd(cmdWidth))} ${dim(cmd.description)}`; @@ -132,7 +141,12 @@ export class HelpPanelComponent extends Container implements Focusable { this.scrollTop = Math.max(0, Math.min(this.scrollTop, content.length - maxVisible)); const slice = content.slice(this.scrollTop, this.scrollTop + maxVisible); const scrollInfo = muted( - ` showing ${String(this.scrollTop + 1)}-${String(this.scrollTop + slice.length)} of ${String(content.length)}`, + ' ' + + i18n.t('commands.help.showing', { + start: this.scrollTop + 1, + end: this.scrollTop + slice.length, + total: content.length, + }), ); return [lines[0] ?? '', ...slice, scrollInfo, lines.at(-1) ?? ''].map((line) => truncateToWidth(line, width), diff --git a/apps/kimi-code/src/tui/components/dialogs/language-selector.ts b/apps/kimi-code/src/tui/components/dialogs/language-selector.ts new file mode 100644 index 000000000..2c2a0538d --- /dev/null +++ b/apps/kimi-code/src/tui/components/dialogs/language-selector.ts @@ -0,0 +1,31 @@ +import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; + +import type { TuiLanguage } from '#/tui/config'; + +// Language endonyms ("English", "简体中文") stay in their own script regardless of +// the active UI locale, so users always recognise their own language. +const LANGUAGE_OPTIONS: readonly ChoiceOption[] = [ + { value: 'auto', label: 'Auto (follow system)' }, + { value: 'en', label: 'English' }, + { value: 'zh-CN', label: '简体中文' }, +]; + +export interface LanguageSelectorOptions { + readonly currentValue: TuiLanguage; + readonly onSelect: (language: TuiLanguage) => void; + readonly onCancel: () => void; +} + +export class LanguageSelectorComponent extends ChoicePickerComponent { + constructor(opts: LanguageSelectorOptions) { + super({ + title: 'Language / 界面语言', + options: [...LANGUAGE_OPTIONS], + currentValue: opts.currentValue, + onSelect: (value) => { + opts.onSelect(value as TuiLanguage); + }, + onCancel: opts.onCancel, + }); + } +} diff --git a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts index f223ba50b..93d9dbb06 100644 --- a/apps/kimi-code/src/tui/components/dialogs/model-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/model-selector.ts @@ -10,6 +10,7 @@ import { import { DEFAULT_OAUTH_PROVIDER_NAME, PRODUCT_NAME } from '#/constant/app'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -177,9 +178,9 @@ export class ModelSelectorComponent extends Container implements Focusable { // surfaces the backspace shortcut once a query is active. const hintParts: string[] = []; if (this.opts.providerSwitchHint) hintParts.push('Tab toggle provider'); - hintParts.push('↑↓ navigate'); - if (searchable && view.query.length > 0) hintParts.push('Backspace clear'); - hintParts.push('Enter select', 'Esc cancel'); + hintParts.push(i18n.t('common.hints.navigate')); + if (searchable && view.query.length > 0) hintParts.push(i18n.t('common.hints.clearSearch')); + hintParts.push(i18n.t('common.hints.select'), i18n.t('common.hints.cancel')); const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts index d2bcc8620..77eeff4ba 100644 --- a/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/plugins-selector.ts @@ -9,6 +9,7 @@ import { import type { PluginInfo, PluginMcpServerInfo, PluginSummary } from '@moonshot-ai/kimi-code-sdk'; import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { formatPluginSourceLabel, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; import { printableChar } from '#/tui/utils/printable-key'; @@ -419,7 +420,7 @@ export class PluginRemoveConfirmComponent extends ChoicePickerComponent { options: [ { value: REMOVE_CONFIRM_CANCEL, - label: 'Cancel', + label: i18n.t('common.cancel'), description: 'Keep this plugin installed.', }, { diff --git a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts index ba14033f7..4fd34b085 100644 --- a/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts +++ b/apps/kimi-code/src/tui/components/dialogs/question-dialog.ts @@ -17,6 +17,7 @@ import { wrapTextWithAnsi, } from '@earendil-works/pi-tui'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import type { PendingQuestion, @@ -26,12 +27,12 @@ import type { const NUMBER_KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9']; const MAX_BODY_LINES = 12; -const DEFAULT_OTHER_LABEL = 'Other'; -const NOT_ANSWERED_LABEL = 'Not answered'; -const REVIEW_TITLE = 'Review your answer before submit'; -const SUBMIT_PROMPT = 'Ready to submit your answers?'; -const UNANSWERED_WARNING = 'Some questions are still unanswered.'; -const SUBMIT_ACTIONS = ['Submit', 'Cancel'] as const; +const SUBMIT_ACTION_COUNT = 2; + +/** Submit-tab action labels, resolved against the active locale at render time. */ +function submitActionLabels(): string[] { + return [i18n.t('reverseRpc.question.submit'), i18n.t('reverseRpc.question.cancel')]; +} interface DisplayOption { readonly label: string; @@ -225,12 +226,12 @@ export class QuestionDialogComponent extends Container implements Focusable { private handleSubmitInput(data: string): void { if (matchesKey(data, Key.up)) { this.submitActionIdx = - (this.submitActionIdx - 1 + SUBMIT_ACTIONS.length) % SUBMIT_ACTIONS.length; + (this.submitActionIdx - 1 + SUBMIT_ACTION_COUNT) % SUBMIT_ACTION_COUNT; this.reviewMessage = undefined; return; } if (matchesKey(data, Key.down)) { - this.submitActionIdx = (this.submitActionIdx + 1) % SUBMIT_ACTIONS.length; + this.submitActionIdx = (this.submitActionIdx + 1) % SUBMIT_ACTION_COUNT; this.reviewMessage = undefined; return; } @@ -446,13 +447,17 @@ export class QuestionDialogComponent extends Container implements Focusable { const success = (text: string) => currentTheme.fg('success', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; + const lines: string[] = [ + accent('─'.repeat(renderWidth)), + currentTheme.boldFg('primary', ` ${i18n.t('reverseRpc.question.heading')}`), + '', + ]; this.pushTabs(lines); lines.push(''); appendWrapped(lines, ' ? ', ' ', question.question, renderWidth, accent); if (this.isEditingOther()) { - lines.push(dim(' Type your answer, then press Enter to save.')); + lines.push(dim(` ${i18n.t('reverseRpc.question.otherInputHint')}`)); } if (question.body !== undefined && question.body.trim().length > 0) { @@ -463,7 +468,13 @@ export class QuestionDialogComponent extends Container implements Focusable { appendWrapped(lines, ' ', ' ', bodyLine, renderWidth, dim); } if (bodyLines.length > visibleBodyLines.length) { - lines.push(dim(` ... ${String(bodyLines.length - visibleBodyLines.length)} more lines`)); + lines.push( + dim( + ` ${i18n.t('reverseRpc.question.moreLines', { + count: bodyLines.length - visibleBodyLines.length, + })}`, + ), + ); } } @@ -525,7 +536,11 @@ export class QuestionDialogComponent extends Container implements Focusable { if (visibleEnd < options.length || visibleStart > 0) { lines.push( dim( - ` showing ${String(visibleStart + 1)}-${String(visibleEnd)} of ${String(options.length)}`, + ` ${i18n.t('reverseRpc.question.showing', { + start: visibleStart + 1, + end: visibleEnd, + total: options.length, + })}`, ), ); } @@ -544,12 +559,19 @@ export class QuestionDialogComponent extends Container implements Focusable { const warning = (text: string) => currentTheme.fg('warning', text); const renderWidth = Math.max(1, width); - const lines: string[] = [accent('─'.repeat(renderWidth)), currentTheme.boldFg('primary', ' question'), '']; + const lines: string[] = [ + accent('─'.repeat(renderWidth)), + currentTheme.boldFg('primary', ` ${i18n.t('reverseRpc.question.heading')}`), + '', + ]; this.pushTabs(lines); lines.push(''); - lines.push(currentTheme.boldFg('text', ` ${REVIEW_TITLE}`)); + lines.push(currentTheme.boldFg('text', ` ${i18n.t('reverseRpc.question.reviewTitle')}`)); const reviewWarning = - this.reviewMessage ?? (this.hasUnansweredQuestions() ? UNANSWERED_WARNING : undefined); + this.reviewMessage ?? + (this.hasUnansweredQuestions() + ? i18n.t('reverseRpc.question.unansweredWarning') + : undefined); if (reviewWarning !== undefined) { lines.push(warning(` ${reviewWarning}`)); } @@ -575,16 +597,17 @@ export class QuestionDialogComponent extends Container implements Focusable { renderWidth, ); } else { - lines.push(` ${dim('→')} ${dim(NOT_ANSWERED_LABEL)}`); + lines.push(` ${dim('→')} ${dim(i18n.t('reverseRpc.question.notAnswered'))}`); } } lines.push(''); - lines.push(text(` ${SUBMIT_PROMPT}`)); + lines.push(text(` ${i18n.t('reverseRpc.question.submitPrompt')}`)); lines.push(''); - for (let i = 0; i < SUBMIT_ACTIONS.length; i++) { - const label = SUBMIT_ACTIONS[i]; + const actionLabels = submitActionLabels(); + for (let i = 0; i < actionLabels.length; i++) { + const label = actionLabels[i]; if (label === undefined) continue; const num = i + 1; if (i === this.submitActionIdx) { @@ -619,7 +642,7 @@ export class QuestionDialogComponent extends Container implements Focusable { else tabs.push(dim(`(○) ${label}`)); } - const submitLabel = 'Submit'; + const submitLabel = i18n.t('reverseRpc.question.submit'); if (this.isSubmitTab()) tabs.push(active(` ${submitLabel} `)); else tabs.push(dim(` ${submitLabel} `)); @@ -629,10 +652,10 @@ export class QuestionDialogComponent extends Container implements Focusable { private buildQuestionHint(dim: (s: string) => string, questionIdx: number): string { if (this.isEditingOther()) { const parts: string[] = [ - 'type answer', - '↵ save', - ...(this.totalTabs() > 1 ? ['tab switch'] : []), - 'esc cancel', + i18n.t('reverseRpc.question.hint.typeAnswer'), + i18n.t('reverseRpc.question.hint.save'), + ...(this.totalTabs() > 1 ? [i18n.t('reverseRpc.question.hint.tabSwitch')] : []), + i18n.t('reverseRpc.question.hint.escCancel'), ]; return dim(` ${parts.join(' ')}`); } @@ -640,21 +663,28 @@ export class QuestionDialogComponent extends Container implements Focusable { const optionCount = Math.min(this.displayOptions(questionIdx).length, NUMBER_KEYS.length); const numberHint = optionCount <= 1 ? '1' : `1-${String(optionCount)}`; const question = this.request.data.questions[questionIdx]; - if (question === undefined) return dim(' esc cancel'); + if (question === undefined) return dim(` ${i18n.t('reverseRpc.question.hint.escCancel')}`); + const chooseWord = question.multi_select + ? i18n.t('reverseRpc.question.hint.toggle') + : i18n.t('reverseRpc.question.hint.choose'); const parts: string[] = [ - '↑↓ select', - `${numberHint} / ↵ ${question.multi_select ? 'toggle' : 'choose'}`, + i18n.t('reverseRpc.question.hint.select'), + `${numberHint} / ↵ ${chooseWord}`, ]; - if (this.totalTabs() > 1) parts.push('←/→/tab switch'); - parts.push('esc cancel'); + if (this.totalTabs() > 1) parts.push(i18n.t('reverseRpc.question.hint.tabSwitchArrows')); + parts.push(i18n.t('reverseRpc.question.hint.escCancel')); return dim(` ${parts.join(' ')}`); } private buildSubmitHint(dim: (s: string) => string): string { - const parts: string[] = ['↑↓ select', '1/2 choose', '↵ confirm']; - if (this.totalTabs() > 1) parts.push('←/→/tab switch'); - parts.push('esc cancel'); + const parts: string[] = [ + i18n.t('reverseRpc.question.hint.select'), + i18n.t('reverseRpc.question.hint.submitChoose'), + i18n.t('reverseRpc.question.hint.confirm'), + ]; + if (this.totalTabs() > 1) parts.push(i18n.t('reverseRpc.question.hint.tabSwitchArrows')); + parts.push(i18n.t('reverseRpc.question.hint.escCancel')); return dim(` ${parts.join(' ')}`); } @@ -704,7 +734,9 @@ export class QuestionDialogComponent extends Container implements Focusable { kind: 'preset' as const, })), { - label: question.other_label?.length ? question.other_label : DEFAULT_OTHER_LABEL, + label: question.other_label?.length + ? question.other_label + : i18n.t('reverseRpc.question.other'), description: question.other_description?.length ? question.other_description : undefined, kind: 'other' as const, }, diff --git a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts index bb5ec512f..ea1aef4b6 100644 --- a/apps/kimi-code/src/tui/components/dialogs/session-picker.ts +++ b/apps/kimi-code/src/tui/components/dialogs/session-picker.ts @@ -12,6 +12,7 @@ import { } from '@earendil-works/pi-tui'; import { formatSessionLabel } from '#/migration/index'; import { CURRENT_MARK, SELECT_POINTER } from '#/tui/constant/symbols'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -224,7 +225,7 @@ export class SessionPickerComponent extends Container implements Focusable { } if (this.sessions.length === 0) { - const hintParts = [scopeHint, 'Esc cancel'].filter( + const hintParts = [scopeHint, i18n.t('common.hints.cancel')].filter( (item): item is string => item !== undefined, ); lines.push(currentTheme.boldFg('primary', truncateToWidth(title, width, ELLIPSIS))); @@ -243,11 +244,11 @@ export class SessionPickerComponent extends Container implements Focusable { const titleSuffix = view.query.length === 0 ? currentTheme.fg('textMuted', ' (type to search)') : ''; const hintParts = [ - ...(view.query.length > 0 ? ['Backspace clear'] : []), - '↑↓ navigate', + ...(view.query.length > 0 ? [i18n.t('common.hints.clearSearch')] : []), + i18n.t('common.hints.navigate'), scopeHint, - 'Enter select', - 'Esc cancel', + i18n.t('common.hints.select'), + i18n.t('common.hints.cancel'), ].filter((item): item is string => item !== undefined); lines.push(currentTheme.boldFg('primary', title) + titleSuffix); diff --git a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts index 81e4b8d12..60544ccc7 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -3,6 +3,7 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; export type SettingsSelection = | 'model' | 'theme' + | 'language' | 'editor' | 'permission' | 'experiments' @@ -25,6 +26,11 @@ const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ label: 'Theme', description: 'Change the terminal UI theme.', }, + { + value: 'language', + label: 'Language', + description: 'Choose the interface language (Auto / English / 简体中文).', + }, { value: 'editor', label: 'Editor', @@ -51,6 +57,7 @@ function isSettingsSelection(value: string): value is SettingsSelection { return ( value === 'model' || value === 'theme' || + value === 'language' || value === 'editor' || value === 'permission' || value === 'experiments' || diff --git a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts index 77d82ccdd..cf1bdc784 100644 --- a/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/undo-selector.ts @@ -8,6 +8,7 @@ import { } from '@earendil-works/pi-tui'; import { SELECT_POINTER } from '#/tui/constant/symbols'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { SearchableList } from '#/tui/utils/searchable-list'; @@ -66,7 +67,11 @@ export class UndoSelectorComponent extends Container implements Focusable { override render(width: number): string[] { const view = this.list.view(); - const hintParts = ['↑↓ navigate', 'Enter select', 'Esc cancel']; + const hintParts = [ + i18n.t('common.hints.navigate'), + i18n.t('common.hints.select'), + i18n.t('common.hints.cancel'), + ]; const lines: string[] = [ currentTheme.fg('primary', '─'.repeat(width)), diff --git a/apps/kimi-code/src/tui/components/messages/field-column.ts b/apps/kimi-code/src/tui/components/messages/field-column.ts new file mode 100644 index 000000000..6a374536b --- /dev/null +++ b/apps/kimi-code/src/tui/components/messages/field-column.ts @@ -0,0 +1,17 @@ +/** + * Display-width-aware padding for the aligned label columns shared by the + * `/usage` and `/status` panels. + * + * `String.padEnd` pads by code-point count, which misaligns columns once a + * value contains double-width characters (CJK labels render two cells wide but + * count as one code point). Padding by *visible* width keeps the value column + * aligned regardless of locale. + */ + +import { visibleWidth } from '@earendil-works/pi-tui'; + +/** Pad `text` with trailing spaces until it occupies `width` display columns. */ +export function padEndToWidth(text: string, width: number): string { + const pad = Math.max(0, width - visibleWidth(text)); + return text + ' '.repeat(pad); +} diff --git a/apps/kimi-code/src/tui/components/messages/status-panel.ts b/apps/kimi-code/src/tui/components/messages/status-panel.ts index 9007b8f97..9242a0904 100644 --- a/apps/kimi-code/src/tui/components/messages/status-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/status-panel.ts @@ -5,9 +5,11 @@ * separate from the TUI orchestration layer. */ +import { visibleWidth } from '@earendil-works/pi-tui'; import type { ModelAlias, PermissionMode, SessionStatus } from '@moonshot-ai/kimi-code-sdk'; import { PRODUCT_NAME } from '#/constant/app'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { formatTokenCount, @@ -16,6 +18,7 @@ import { safeUsageRatio, } from '#/utils/usage/usage-format'; +import { padEndToWidth } from './field-column'; import { buildManagedUsageReportLines, type ManagedUsageReport } from './usage-panel'; interface FieldRow { @@ -52,12 +55,12 @@ function displayModelName(alias: string, models: Record): st function formatModelStatus(options: StatusReportOptions): string { const model = options.status?.model ?? options.model; - if (model.trim().length === 0) return 'not set'; + if (model.trim().length === 0) return i18n.t('components.status.values.notSet'); - const thinking = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) === 'off' - ? 'off' - : 'on'; - return `${displayModelName(model, options.availableModels)} (thinking ${thinking})`; + const thinkingOn = (options.status?.thinkingLevel ?? (options.thinking ? 'on' : 'off')) !== 'off'; + const thinkingLabel = i18n.t('components.status.values.thinking'); + const thinkingState = i18n.t(`components.status.values.${thinkingOn ? 'on' : 'off'}`); + return `${displayModelName(model, options.availableModels)} (${thinkingLabel} ${thinkingState})`; } function addFieldRows( @@ -67,10 +70,10 @@ function addFieldRows( value: Colorize, errorStyle: Colorize, ): void { - const labelWidth = Math.max(10, ...rows.map((row) => row.label.length)); + const labelWidth = Math.max(10, ...rows.map((row) => visibleWidth(row.label))); for (const row of rows) { const colorize = row.severity === 'error' ? errorStyle : value; - lines.push(` ${muted(row.label.padEnd(labelWidth, ' '))} ${colorize(row.value)}`); + lines.push(` ${muted(padEndToWidth(row.label, labelWidth))} ${colorize(row.value)}`); } } @@ -94,20 +97,31 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { const severityToken = (sev: 'ok' | 'warn' | 'danger'): 'error' | 'warning' | 'success' => sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; + const onOff = (flag: boolean): string => + i18n.t(`components.status.values.${flag ? 'on' : 'off'}`); const permission = options.status?.permission ?? options.permissionMode; const planMode = options.status?.planMode ?? options.planMode; - const sessionId = options.sessionId.trim().length > 0 ? options.sessionId : 'none'; + const sessionId = + options.sessionId.trim().length > 0 + ? options.sessionId + : i18n.t('components.status.values.none'); const rows: FieldRow[] = [ - { label: 'Model', value: formatModelStatus(options) }, - { label: 'Directory', value: options.workDir }, - { label: 'Permissions', value: permission }, - { label: 'Plan mode', value: planMode ? 'on' : 'off' }, - { label: 'Session', value: sessionId }, + { label: i18n.t('components.status.fields.model'), value: formatModelStatus(options) }, + { label: i18n.t('components.status.fields.directory'), value: options.workDir }, + { label: i18n.t('components.status.fields.permissions'), value: permission }, + { label: i18n.t('components.status.fields.planMode'), value: onOff(planMode) }, + { label: i18n.t('components.status.fields.session'), value: sessionId }, ]; const title = options.sessionTitle?.trim(); - if (title !== undefined && title.length > 0) rows.push({ label: 'Title', value: title }); + if (title !== undefined && title.length > 0) { + rows.push({ label: i18n.t('components.status.fields.title'), value: title }); + } if (options.statusError !== undefined) { - rows.push({ label: 'Warning', value: options.statusError, severity: 'error' }); + rows.push({ + label: i18n.t('components.status.fields.warning'), + value: options.statusError, + severity: 'error', + }); } const lines: string[] = [ @@ -118,7 +132,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { const { ratio, tokens, maxTokens } = contextValues(options); lines.push(''); - lines.push(accent('Context window')); + lines.push(accent(i18n.t('components.usage.contextWindow'))); if (maxTokens > 0) { const safeRatio = safeUsageRatio(ratio); const bar = renderProgressBar(safeRatio, 20); @@ -128,7 +142,7 @@ export function buildStatusReportLines(options: StatusReportOptions): string[] { muted(`(${formatTokenCount(tokens)} / ${formatTokenCount(maxTokens)})`), ); } else { - lines.push(` ${muted('No context window data available.')}`); + lines.push(` ${muted(i18n.t('components.status.noContextData'))}`); } const managedSection = buildManagedUsageReportLines({ diff --git a/apps/kimi-code/src/tui/components/messages/usage-panel.ts b/apps/kimi-code/src/tui/components/messages/usage-panel.ts index 1eeba55e2..7e2bbdb80 100644 --- a/apps/kimi-code/src/tui/components/messages/usage-panel.ts +++ b/apps/kimi-code/src/tui/components/messages/usage-panel.ts @@ -14,8 +14,10 @@ import { renderProgressBar, safeUsageRatio, } from '#/utils/usage/usage-format'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import type { ColorToken } from '#/tui/theme'; +import { padEndToWidth } from './field-column'; const LEFT_MARGIN = 2; const SIDE_PADDING = 1; @@ -73,7 +75,15 @@ function buildSessionUsageSection( const byModel = (usage as { readonly byModel?: Record } | undefined) ?.byModel; const entries = Object.entries(byModel ?? {}); - if (entries.length === 0) return [muted(' No token usage recorded yet.')]; + if (entries.length === 0) return [muted(` ${i18n.t('components.usage.noTokenUsage')}`)]; + + const inputLabel = i18n.t('components.usage.input'); + const outputLabel = i18n.t('components.usage.output'); + const totalLabel = i18n.t('components.usage.total'); + const formatRow = (label: string, input: number, output: number): string => + ` ${muted(label)} ${inputLabel} ${value(formatTokenCount(input))} ${outputLabel} ${value( + formatTokenCount(output), + )} ${totalLabel} ${value(formatTokenCount(input + output))}`; const lines: string[] = []; let totalInput = 0; @@ -83,18 +93,10 @@ function buildSessionUsageSection( const output = usageNumber(row.output); totalInput += input; totalOutput += output; - lines.push( - ` ${muted(model)} input ${value(formatTokenCount(input))} output ${value( - formatTokenCount(output), - )} total ${value(formatTokenCount(input + output))}`, - ); + lines.push(formatRow(model, input, output)); } if (entries.length > 1) { - lines.push( - ` ${muted('total')} input ${value(formatTokenCount(totalInput))} output ${value( - formatTokenCount(totalOutput), - )} total ${value(formatTokenCount(totalInput + totalOutput))}`, - ); + lines.push(formatRow(totalLabel, totalInput, totalOutput)); } return lines; } @@ -107,11 +109,12 @@ function buildManagedUsageSection( muted: Colorize, errorStyle: Colorize, ): string[] { - if (error !== undefined) return [accent('Plan usage'), errorStyle(` ${error}`)]; + const planUsage = i18n.t('components.usage.planUsage'); + if (error !== undefined) return [accent(planUsage), errorStyle(` ${error}`)]; if (usage === undefined) return []; const { summary, limits } = usage; if (summary === null && limits.length === 0) { - return [accent('Plan usage'), muted(' No usage data available.')]; + return [accent(planUsage), muted(` ${i18n.t('components.usage.noUsageData')}`)]; } const rows: ManagedUsageRow[] = []; @@ -119,19 +122,22 @@ function buildManagedUsageSection( rows.push(...limits); const usedRatio = (r: ManagedUsageRow): number => r.limit > 0 ? Math.max(0, Math.min(r.used / r.limit, 1)) : 0; - const labelWidth = Math.max(10, ...rows.map((r) => r.label.length)); - const pctWidth = Math.max(...rows.map((r) => `${Math.round(usedRatio(r) * 100)}% used`.length)); + const pctText = (r: ManagedUsageRow): string => + i18n.t('components.usage.percentUsed', { pct: Math.round(usedRatio(r) * 100) }); + const labelWidth = Math.max(10, ...rows.map((r) => visibleWidth(r.label))); + const pctWidth = Math.max(...rows.map((r) => visibleWidth(pctText(r)))); const severityColor = (sev: 'ok' | 'warn' | 'danger'): 'success' | 'warning' | 'error' => sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; - const out: string[] = [accent('Plan usage')]; + const out: string[] = [accent(planUsage)]; for (const row of rows) { const ratioUsed = usedRatio(row); const bar = renderProgressBar(ratioUsed, 20); - const pct = `${Math.round(ratioUsed * 100)}% used`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratioUsed)), bar); - const label = row.label.padEnd(labelWidth, ' '); + const label = padEndToWidth(row.label, labelWidth); const resetStr = row.resetHint ? ` ${muted(row.resetHint)}` : ''; - out.push(` ${muted(label)} ${barColoured} ${value(pct.padEnd(pctWidth, ' '))}${resetStr}`); + out.push( + ` ${muted(label)} ${barColoured} ${value(padEndToWidth(pctText(row), pctWidth))}${resetStr}`, + ); } return out; } @@ -161,7 +167,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { sev === 'danger' ? 'error' : sev === 'warn' ? 'warning' : 'success'; const lines: string[] = [ - accent('Session usage'), + accent(i18n.t('components.usage.sessionUsage')), ...buildSessionUsageSection( options.sessionUsage, options.sessionUsageError, @@ -177,7 +183,7 @@ export function buildUsageReportLines(options: UsageReportOptions): string[] { const pct = `${(ratio * 100).toFixed(1)}%`; const barColoured = currentTheme.fg(severityColor(ratioSeverity(ratio)), bar); lines.push(''); - lines.push(accent('Context window')); + lines.push(accent(i18n.t('components.usage.contextWindow'))); lines.push( ` ${barColoured} ${value(pct.padStart(6, ' '))} ` + muted( @@ -207,7 +213,7 @@ export class UsagePanelComponent implements Component { constructor( private readonly buildLines: () => readonly string[], private readonly borderToken: ColorToken, - private readonly title: string = ' Usage ', + private readonly title: string = i18n.t('components.usage.panelTitle'), ) { this.lines = buildLines(); } diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index fdcd8714e..a39dae781 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -19,6 +19,15 @@ export const INVALID_TUI_CONFIG_MESSAGE = export const TuiThemeSchema = z.string(); +/** + * User-facing language preference for the TUI. + * `'auto'` resolves a concrete locale from the system environment at startup; + * `'en'` / `'zh-CN'` pin the interface to that locale. + */ +export const TuiLanguageSchema = z.enum(['auto', 'en', 'zh-CN']); + +export type TuiLanguage = z.infer; + export const NotificationConditionSchema = z.enum(['unfocused', 'always']); export const NotificationsConfigSchema = z.object({ @@ -32,6 +41,7 @@ export const UpgradePreferencesSchema = z.object({ export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + language: TuiLanguageSchema.optional(), editor: z .object({ command: z.string().optional(), @@ -52,6 +62,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + language: TuiLanguageSchema, editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, upgrade: UpgradePreferencesSchema, @@ -73,6 +84,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + language: 'auto', editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, upgrade: DEFAULT_UPGRADE_PREFERENCES, @@ -132,6 +144,7 @@ export function normalizeTuiConfig(config: TuiConfigFileShape): TuiConfig { const command = config.editor?.command?.trim(); return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + language: config.language ?? DEFAULT_TUI_CONFIG.language, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { enabled: config.notifications?.enabled ?? DEFAULT_NOTIFICATIONS_CONFIG.enabled, @@ -150,6 +163,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.kimi-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +language = "${config.language}" # "auto" | "en" | "zh-CN" [editor] command = "${escapeTomlBasicString(config.editorCommand ?? '')}" # Empty uses $VISUAL / $EDITOR diff --git a/apps/kimi-code/src/tui/i18n/i18n.ts b/apps/kimi-code/src/tui/i18n/i18n.ts new file mode 100644 index 000000000..7db08d353 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/i18n.ts @@ -0,0 +1,85 @@ +/** + * I18n class + global singleton. + * + * Mirrors the `currentTheme` Theme singleton: components import `i18n` and call + * `i18n.t('module.submodule.phrase')` at render time. When the user switches + * languages we call `i18n.setLocale(locale)` — the same instance stays alive, so + * every component picks up the new locale on the next render frame. + * + * Lookups resolve `module.submodule.phrase` dotted keys against the active + * locale, falling back to the default locale (`en`) on a missing key, then to + * the raw key itself so a missing translation is visible rather than blank. + * `{param}` placeholders are interpolated from the optional params map. + */ + +import { locales } from './locales'; + +/** A nested tree of translation strings keyed by namespace segment. */ +export interface MessageTree { + readonly [segment: string]: string | MessageTree; +} + +export type Locale = 'en' | 'zh-CN'; + +export type LocaleMessages = Record; + +export type TranslateParams = Record; + +const DEFAULT_FALLBACK_LOCALE: Locale = 'en'; + +export class I18n { + private readonly locales: LocaleMessages; + private locale: string; + private readonly fallbackLocale: string; + + constructor( + locales: LocaleMessages, + locale: string, + fallbackLocale: string = DEFAULT_FALLBACK_LOCALE, + ) { + this.locales = locales; + this.locale = locale; + this.fallbackLocale = fallbackLocale; + } + + get currentLocale(): string { + return this.locale; + } + + setLocale(locale: string): void { + this.locale = locale; + } + + t(key: string, params?: TranslateParams): string { + const raw = + lookup(this.locales[this.locale], key) ?? + lookup(this.locales[this.fallbackLocale], key) ?? + key; + return interpolate(raw, params); + } +} + +/** + * Global singleton. Starts on the `en` locale (also the fallback); startup + * wiring calls `i18n.setLocale(resolveLocale(config.language))` once the TUI + * config is loaded, mirroring `currentTheme.setPalette(...)`. + */ +export const i18n = new I18n(locales, DEFAULT_FALLBACK_LOCALE); + +function lookup(tree: MessageTree | undefined, key: string): string | undefined { + if (tree === undefined) return undefined; + let node: string | MessageTree | undefined = tree; + for (const segment of key.split('.')) { + if (typeof node !== 'object') return undefined; + node = node[segment]; + } + return typeof node === 'string' ? node : undefined; +} + +function interpolate(template: string, params?: TranslateParams): string { + if (params === undefined) return template; + return template.replaceAll(/\{(\w+)\}/g, (match, name: string) => { + const value = params[name]; + return value === undefined ? match : String(value); + }); +} diff --git a/apps/kimi-code/src/tui/i18n/index.ts b/apps/kimi-code/src/tui/i18n/index.ts new file mode 100644 index 000000000..3b0b87cf2 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/index.ts @@ -0,0 +1,8 @@ +/** + * I18n system public API. + */ + +export { I18n, i18n } from './i18n'; +export type { Locale, LocaleMessages, MessageTree, TranslateParams } from './i18n'; +export { resolveLocale } from './resolve'; +export type { LocaleEnv } from './resolve'; diff --git a/apps/kimi-code/src/tui/i18n/locales/en/cli.ts b/apps/kimi-code/src/tui/i18n/locales/en/cli.ts new file mode 100644 index 000000000..631c2e4a5 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/cli.ts @@ -0,0 +1,17 @@ +/** + * English translations for CLI-facing notices. + * + * The `cli` namespace carries the highest-value command-line messages that may + * render before the TUI mounts — currently the `tui.toml` config-parse notice. + * Keep `cli.config.invalidTuiConfig` in sync with `INVALID_TUI_CONFIG_MESSAGE` + * in `#/tui/config`; that constant stays the English source used for logs and + * issue triage, while this entry is what the user sees in the active locale. + */ + +import type { MessageTree } from '../../i18n'; + +export const cli: MessageTree = { + config: { + invalidTuiConfig: 'Invalid TUI config in ~/.kimi-code/tui.toml; using defaults.', + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/en/commands.ts b/apps/kimi-code/src/tui/i18n/locales/en/commands.ts new file mode 100644 index 000000000..445573b68 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/commands.ts @@ -0,0 +1,88 @@ +/** + * English translations for slash commands. + * + * `commands.descriptions.` holds the palette/autocomplete description for + * each builtin command; `commands.args..` holds argument- + * autocomplete descriptions; `commands.help.*` holds the `/help` panel chrome. + * Command names / identifiers themselves are never translated — only the + * human-readable text lives here. Keep keys stable: they are the + * contributor-facing contract shared with every locale. + */ + +import type { MessageTree } from '../../i18n'; + +export const commands: MessageTree = { + descriptions: { + yolo: 'Toggle auto-approve mode', + auto: 'Toggle auto permission mode', + permission: 'Select permission mode', + settings: 'Open TUI settings', + plan: 'Toggle plan mode', + swarm: 'Toggle swarm mode or run one task in swarm mode', + model: 'Switch LLM model', + provider: 'Manage AI providers (add / delete / refresh)', + btw: 'Ask a forked side agent a question', + help: 'Show available commands and shortcuts', + new: 'Start a fresh session in the current workspace', + sessions: 'Browse and resume sessions', + tasks: 'Browse background tasks', + mcp: 'Show MCP server status', + plugins: 'Manage plugins', + experiments: 'Manage experimental features', + reload: 'Reload session and apply config.toml settings plus tui.toml UI preferences', + 'reload-tui': 'Reload only tui.toml UI preferences', + compact: 'Compact the conversation context', + goal: 'Start or manage an autonomous goal', + init: 'Analyze the codebase and generate AGENTS.md', + fork: 'Fork the current session', + title: 'Set or show session title', + usage: 'Show session tokens + context window + plan quotas', + status: 'Show current session and runtime status', + feedback: 'Send feedback to make Kimi Code better', + undo: 'Withdraw the last prompt from the transcript', + editor: 'Set the external editor for Ctrl-G', + theme: 'Set the terminal UI theme', + logout: 'Log out of a configured provider', + login: 'Select a platform and authenticate', + 'export-md': 'Export current session as a Markdown file', + 'export-debug-zip': 'Export current session as a debug ZIP archive', + web: 'Open the current session in the Web UI and exit the terminal', + exit: 'Exit the application', + version: 'Show version information', + }, + args: { + goal: { + status: 'Show the current goal', + pause: 'Pause the active goal', + resume: 'Resume a paused goal', + cancel: 'Cancel and remove the current goal', + replace: 'Replace the current goal with a new objective', + next: 'Queue an upcoming goal', + manage: 'Manage upcoming goals', + }, + swarm: { + on: 'Turn swarm mode on', + off: 'Turn swarm mode off', + }, + }, + help: { + title: 'help', + dismiss: '· Esc / Enter / q to cancel · ↑↓ scroll', + showing: 'showing {start}-{end} of {total}', + greeting: 'Sure, Kimi is ready to help! Just send a message to get started.', + keyboardShortcuts: 'Keyboard shortcuts', + slashCommands: 'Slash commands', + shortcuts: { + planMode: 'Toggle plan mode', + externalEditor: 'Edit in external editor ($VISUAL / $EDITOR)', + toolOutput: 'Toggle tool output expansion', + steer: 'Steer — inject a follow-up during streaming', + newline: 'Insert newline', + interrupt: 'Interrupt stream / clear input', + exit: 'Exit (on empty input)', + closeDialogs: 'Close dialogs / interrupt streaming', + history: 'Browse input history', + submit: 'Submit', + }, + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/en/common.ts b/apps/kimi-code/src/tui/i18n/locales/en/common.ts new file mode 100644 index 000000000..3c11aa094 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/common.ts @@ -0,0 +1,24 @@ +/** + * English translations for short phrases shared across components. + * + * The `common` namespace holds the generic dialog-control vocabulary + * (`common.submit` / `cancel` / `back`) plus the keyboard-hint phrases + * (`common.hints.*`) that recur in list-style dialog footers. Keeping them + * here means a shared phrase is translated once and reused everywhere, rather + * than duplicated per component. Keep keys stable — they are the + * contributor-facing contract shared with every locale. + */ + +import type { MessageTree } from '../../i18n'; + +export const common: MessageTree = { + submit: 'Submit', + cancel: 'Cancel', + back: 'Back', + hints: { + navigate: '↑↓ navigate', + select: 'Enter select', + cancel: 'Esc cancel', + clearSearch: 'Backspace clear', + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/en/components.ts b/apps/kimi-code/src/tui/i18n/locales/en/components.ts new file mode 100644 index 000000000..eadeeed51 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/components.ts @@ -0,0 +1,65 @@ +/** + * English translations for UI components. + * + * One namespace object per component module. Keys follow the + * `components..` convention used by `i18n.t(...)`. Keep keys + * stable — they are the contributor-facing contract shared with every locale. + */ + +import type { MessageTree } from '../../i18n'; + +export const components: MessageTree = { + welcome: { + title: 'Welcome to Kimi Code!', + getStarted: 'Run /login or /provider to get started.', + helpHint: 'Send /help for help information.', + modelNotSet: 'not set, run /login or /provider', + labels: { + directory: 'Directory', + session: 'Session', + model: 'Model', + version: 'Version', + mcp: 'MCP', + }, + }, + footer: { + context: 'context', + thinking: 'thinking', + taskRunning: '{count} task running', + tasksRunning: '{count} tasks running', + agentRunning: '{count} agent running', + agentsRunning: '{count} agents running', + }, + usage: { + panelTitle: ' Usage ', + sessionUsage: 'Session usage', + noTokenUsage: 'No token usage recorded yet.', + input: 'input', + output: 'output', + total: 'total', + contextWindow: 'Context window', + planUsage: 'Plan usage', + noUsageData: 'No usage data available.', + percentUsed: '{pct}% used', + }, + status: { + panelTitle: ' Status ', + noContextData: 'No context window data available.', + fields: { + model: 'Model', + directory: 'Directory', + permissions: 'Permissions', + planMode: 'Plan mode', + session: 'Session', + title: 'Title', + warning: 'Warning', + }, + values: { + notSet: 'not set', + none: 'none', + on: 'on', + off: 'off', + thinking: 'thinking', + }, + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/en/index.ts b/apps/kimi-code/src/tui/i18n/locales/en/index.ts new file mode 100644 index 000000000..463f01ad8 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/index.ts @@ -0,0 +1,22 @@ +/** + * English language pack. + * + * Merges the per-module namespace files into a single message tree for the + * `en` locale. + */ + +import type { MessageTree } from '../../i18n'; + +import { cli } from './cli'; +import { commands } from './commands'; +import { common } from './common'; +import { components } from './components'; +import { reverseRpc } from './reverse-rpc'; + +export const en: MessageTree = { + cli, + commands, + common, + components, + reverseRpc, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/en/reverse-rpc.ts b/apps/kimi-code/src/tui/i18n/locales/en/reverse-rpc.ts new file mode 100644 index 000000000..60e389b83 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/reverse-rpc.ts @@ -0,0 +1,86 @@ +/** + * English translations for the reverse-RPC approval & question panels. + * + * Keys follow the `reverseRpc..` convention used by + * `i18n.t(...)`. Keyboard symbols (↑/↓, ↵, ←/→) and numeric shortcuts stay in + * code — only the words around them live here. + */ + +import type { MessageTree } from '../../i18n'; + +export const reverseRpc: MessageTree = { + approval: { + header: { + bash: 'Run this command?', + write: 'Write this file?', + edit: 'Apply these edits?', + taskStop: 'Stop this task?', + exitPlanMode: 'Ready to build with this plan?', + default: 'Approve {tool}?', + }, + choice: { + approveOnce: 'Approve once', + approveSession: 'Approve for this session', + reject: 'Reject', + rejectWithFeedback: 'Reject with feedback', + approve: 'Approve', + revise: 'Revise', + }, + danger: { + recursiveDelete: 'recursive delete', + sudo: 'sudo', + pipeToShell: 'pipe to shell', + ddWrite: 'dd write', + mkfs: 'mkfs', + rawDevice: 'write to raw device', + chmod777: 'chmod 777', + forkBomb: 'fork bomb', + }, + dangerousPrefix: 'Dangerous: {label}', + cwd: 'cwd: {path}', + scope: 'scope: {scope}', + moreLineHidden: '… {count} more line hidden (ctrl+e to preview)', + moreLinesHidden: '… {count} more lines hidden (ctrl+e to preview)', + hint: { + select: 'select', + choose: 'choose', + confirm: 'confirm', + preview: 'ctrl+e preview', + feedback: 'Type feedback · ↵ submit.', + }, + }, + question: { + heading: 'question', + other: 'Other', + notAnswered: 'Not answered', + reviewTitle: 'Review your answer before submit', + submitPrompt: 'Ready to submit your answers?', + unansweredWarning: 'Some questions are still unanswered.', + submit: 'Submit', + cancel: 'Cancel', + otherInputHint: 'Type your answer, then press Enter to save.', + showing: 'showing {start}-{end} of {total}', + moreLines: '... {count} more lines', + hint: { + typeAnswer: 'type answer', + save: '↵ save', + tabSwitch: 'tab switch', + escCancel: 'esc cancel', + select: '↑↓ select', + toggle: 'toggle', + choose: 'choose', + tabSwitchArrows: '←/→/tab switch', + submitChoose: '1/2 choose', + confirm: '↵ confirm', + }, + }, + preview: { + title: 'Preview', + hint: { + line: 'line', + page: 'page', + topBot: 'top/bot', + cancel: 'cancel', + }, + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/index.ts b/apps/kimi-code/src/tui/i18n/locales/index.ts new file mode 100644 index 000000000..802c0d328 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/index.ts @@ -0,0 +1,13 @@ +/** + * Bundled language packs keyed by locale. + */ + +import type { LocaleMessages } from '../i18n'; + +import { en } from './en'; +import { zhCN } from './zh-CN'; + +export const locales: LocaleMessages = { + en, + 'zh-CN': zhCN, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/zh-CN/cli.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/cli.ts new file mode 100644 index 000000000..5887eb241 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/cli.ts @@ -0,0 +1,14 @@ +/** + * Simplified Chinese translations for CLI-facing notices. + * + * Mirrors the namespace structure and keys of `locales/en/cli.ts`. The file + * path stays in code; only the surrounding notice is translated. + */ + +import type { MessageTree } from '../../i18n'; + +export const cli: MessageTree = { + config: { + invalidTuiConfig: '~/.kimi-code/tui.toml 中的 TUI 配置无效,已使用默认值。', + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/zh-CN/commands.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/commands.ts new file mode 100644 index 000000000..ade9133c1 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/commands.ts @@ -0,0 +1,84 @@ +/** + * Simplified Chinese translations for slash commands. + * + * Mirrors the `en` `commands` namespace key-for-key. Command names / + * identifiers stay untranslated — only human-readable text is localized. + */ + +import type { MessageTree } from '../../i18n'; + +export const commands: MessageTree = { + descriptions: { + yolo: '切换自动批准模式', + auto: '切换自动权限模式', + permission: '选择权限模式', + settings: '打开 TUI 设置', + plan: '切换计划模式', + swarm: '切换蜂群模式,或在蜂群模式下运行单个任务', + model: '切换 LLM 模型', + provider: '管理 AI 提供方(添加 / 删除 / 刷新)', + btw: '向分叉的旁路智能体提问', + help: '显示可用命令和快捷键', + new: '在当前工作区开启全新会话', + sessions: '浏览并恢复会话', + tasks: '浏览后台任务', + mcp: '显示 MCP 服务器状态', + plugins: '管理插件', + experiments: '管理实验性功能', + reload: '重新加载会话,并应用 config.toml 设置及 tui.toml 界面偏好', + 'reload-tui': '仅重新加载 tui.toml 界面偏好', + compact: '压缩对话上下文', + goal: '启动或管理自主目标', + init: '分析代码库并生成 AGENTS.md', + fork: '分叉当前会话', + title: '设置或显示会话标题', + usage: '显示会话令牌 + 上下文窗口 + 套餐配额', + status: '显示当前会话和运行时状态', + feedback: '发送反馈以改进 Kimi Code', + undo: '从记录中撤回上一条提示', + editor: '设置 Ctrl-G 使用的外部编辑器', + theme: '设置终端界面主题', + logout: '登出已配置的提供方', + login: '选择平台并进行认证', + 'export-md': '将当前会话导出为 Markdown 文件', + 'export-debug-zip': '将当前会话导出为调试 ZIP 压缩包', + web: '在 Web UI 中打开当前会话并退出终端', + exit: '退出应用程序', + version: '显示版本信息', + }, + args: { + goal: { + status: '显示当前目标', + pause: '暂停当前目标', + resume: '恢复已暂停的目标', + cancel: '取消并移除当前目标', + replace: '用新目标替换当前目标', + next: '排入后续目标', + manage: '管理后续目标', + }, + swarm: { + on: '开启蜂群模式', + off: '关闭蜂群模式', + }, + }, + help: { + title: '帮助', + dismiss: '· Esc / Enter / q 取消 · ↑↓ 滚动', + showing: '显示第 {start}-{end} 项,共 {total} 项', + greeting: 'Kimi 已就绪,随时为你效劳!发送消息即可开始。', + keyboardShortcuts: '键盘快捷键', + slashCommands: '斜杠命令', + shortcuts: { + planMode: '切换计划模式', + externalEditor: '在外部编辑器中编辑($VISUAL / $EDITOR)', + toolOutput: '切换工具输出展开', + steer: '引导 — 在流式输出时插入后续消息', + newline: '插入换行', + interrupt: '中断流式输出 / 清空输入', + exit: '退出(输入为空时)', + closeDialogs: '关闭对话框 / 中断流式输出', + history: '浏览输入历史', + submit: '提交', + }, + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/zh-CN/common.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/common.ts new file mode 100644 index 000000000..897dab200 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/common.ts @@ -0,0 +1,21 @@ +/** + * Simplified Chinese translations for shared short phrases. + * + * Mirrors the namespace structure and keys of `locales/en/common.ts`. Keyboard + * tokens (↑↓ / Enter / Esc / Backspace) stay in code; only the surrounding + * action words are translated. + */ + +import type { MessageTree } from '../../i18n'; + +export const common: MessageTree = { + submit: '提交', + cancel: '取消', + back: '返回', + hints: { + navigate: '↑↓ 导航', + select: 'Enter 选择', + cancel: 'Esc 取消', + clearSearch: 'Backspace 清除', + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts new file mode 100644 index 000000000..891d1d4e6 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts @@ -0,0 +1,63 @@ +/** + * Simplified Chinese translations for UI components. + * + * Mirrors the namespace structure and keys of `locales/en/components.ts`. + */ + +import type { MessageTree } from '../../i18n'; + +export const components: MessageTree = { + welcome: { + title: '欢迎使用 Kimi Code!', + getStarted: '运行 /login 或 /provider 开始使用。', + helpHint: '发送 /help 获取帮助信息。', + modelNotSet: '未设置,运行 /login 或 /provider', + labels: { + directory: '目录', + session: '会话', + model: '模型', + version: '版本', + mcp: 'MCP', + }, + }, + footer: { + context: '上下文', + thinking: '思考模式', + taskRunning: '{count} 个后台任务运行中', + tasksRunning: '{count} 个后台任务运行中', + agentRunning: '{count} 个后台子代理运行中', + agentsRunning: '{count} 个后台子代理运行中', + }, + usage: { + panelTitle: ' 用量 ', + sessionUsage: '会话用量', + noTokenUsage: '尚未记录令牌用量。', + input: '输入', + output: '输出', + total: '合计', + contextWindow: '上下文窗口', + planUsage: '套餐用量', + noUsageData: '暂无用量数据。', + percentUsed: '已用 {pct}%', + }, + status: { + panelTitle: ' 状态 ', + noContextData: '暂无上下文窗口数据。', + fields: { + model: '模型', + directory: '目录', + permissions: '权限', + planMode: '计划模式', + session: '会话', + title: '标题', + warning: '警告', + }, + values: { + notSet: '未设置', + none: '无', + on: '开', + off: '关', + thinking: '思考', + }, + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts new file mode 100644 index 000000000..5ae7c26fd --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts @@ -0,0 +1,22 @@ +/** + * Simplified Chinese language pack. + * + * Merges the per-module namespace files into a single message tree for the + * `zh-CN` locale. + */ + +import type { MessageTree } from '../../i18n'; + +import { cli } from './cli'; +import { commands } from './commands'; +import { common } from './common'; +import { components } from './components'; +import { reverseRpc } from './reverse-rpc'; + +export const zhCN: MessageTree = { + cli, + commands, + common, + components, + reverseRpc, +}; diff --git a/apps/kimi-code/src/tui/i18n/locales/zh-CN/reverse-rpc.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/reverse-rpc.ts new file mode 100644 index 000000000..64c273ebd --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/reverse-rpc.ts @@ -0,0 +1,85 @@ +/** + * Simplified Chinese translations for the reverse-RPC approval & question + * panels. + * + * Mirrors the namespace structure and keys of `locales/en/reverse-rpc.ts`. + */ + +import type { MessageTree } from '../../i18n'; + +export const reverseRpc: MessageTree = { + approval: { + header: { + bash: '运行此命令?', + write: '写入此文件?', + edit: '应用这些修改?', + taskStop: '停止此任务?', + exitPlanMode: '准备好按此计划开始了吗?', + default: '批准 {tool}?', + }, + choice: { + approveOnce: '批准一次', + approveSession: '本次会话内批准', + reject: '拒绝', + rejectWithFeedback: '拒绝并反馈', + approve: '批准', + revise: '修订', + }, + danger: { + recursiveDelete: '递归删除', + sudo: 'sudo', + pipeToShell: '管道传给 shell', + ddWrite: 'dd 写入', + mkfs: '格式化文件系统', + rawDevice: '写入裸设备', + chmod777: 'chmod 777', + forkBomb: 'fork 炸弹', + }, + dangerousPrefix: '危险:{label}', + cwd: 'cwd:{path}', + scope: '范围:{scope}', + moreLineHidden: '… 还有 {count} 行已隐藏(ctrl+e 预览)', + moreLinesHidden: '… 还有 {count} 行已隐藏(ctrl+e 预览)', + hint: { + select: '选择', + choose: '选择', + confirm: '确认', + preview: 'ctrl+e 预览', + feedback: '输入反馈 · ↵ 提交。', + }, + }, + question: { + heading: '问题', + other: '其他', + notAnswered: '未回答', + reviewTitle: '提交前请检查你的回答', + submitPrompt: '确认提交你的回答?', + unansweredWarning: '仍有问题尚未回答。', + submit: '提交', + cancel: '取消', + otherInputHint: '输入你的回答,然后按回车保存。', + showing: '显示第 {start}-{end} 项,共 {total} 项', + moreLines: '... 还有 {count} 行', + hint: { + typeAnswer: '输入回答', + save: '↵ 保存', + tabSwitch: 'tab 切换', + escCancel: 'esc 取消', + select: '↑↓ 选择', + toggle: '切换', + choose: '选择', + tabSwitchArrows: '←/→/tab 切换', + submitChoose: '1/2 选择', + confirm: '↵ 确认', + }, + }, + preview: { + title: '预览', + hint: { + line: '行', + page: '翻页', + topBot: '顶部/底部', + cancel: '取消', + }, + }, +}; diff --git a/apps/kimi-code/src/tui/i18n/resolve.ts b/apps/kimi-code/src/tui/i18n/resolve.ts new file mode 100644 index 000000000..2ba46a49f --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/resolve.ts @@ -0,0 +1,42 @@ +/** + * Locale resolution. + * + * Turns a user `language` preference into a concrete `Locale`. Explicit + * preferences (`'en'` / `'zh-CN'`) pass straight through; `'auto'` inspects the + * POSIX locale environment variables (`LC_ALL` > `LC_MESSAGES` > `LANG`, matching + * standard precedence) against a small known-locale table. Anything unknown — + * including the `C` / `POSIX` locales and an empty environment — falls back to + * `'en'`. + */ + +import type { TuiLanguage } from '#/tui/config'; + +import type { Locale } from './i18n'; + +export type LocaleEnv = Partial>; + +const FALLBACK_LOCALE: Locale = 'en'; + +export function resolveLocale( + language: TuiLanguage, + env: LocaleEnv = process.env, +): Locale { + if (language === 'en') return 'en'; + if (language === 'zh-CN') return 'zh-CN'; + return detectLocaleFromEnv(env); +} + +function detectLocaleFromEnv(env: LocaleEnv): Locale { + // POSIX precedence: LC_ALL overrides LC_MESSAGES overrides LANG. Empty values + // are skipped, so an empty LC_ALL still defers to LANG. + const raw = [env.LC_ALL, env.LC_MESSAGES, env.LANG] + .find((value) => value !== undefined && value.trim().length > 0) + ?.trim(); + if (raw === undefined) return FALLBACK_LOCALE; + + // Normalise `zh_CN.UTF-8` / `zh-CN` / `zh_CN` down to the language+region + // stem before matching. + const normalized = raw.split('.')[0]!.replace('_', '-').toLowerCase(); + if (normalized === 'zh-cn' || normalized === 'zh') return 'zh-CN'; + return FALLBACK_LOCALE; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 79c818b70..370430d6f 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -34,9 +34,9 @@ import { quoteShellArg } from '#/utils/shell-quote'; import { BannerProvider } from './banner/banner-provider'; import { readBannerDisplayState, writeBannerDisplayState } from './banner/state'; import { - BUILTIN_SLASH_COMMANDS, buildSkillSlashCommands, isExperimentalFlagEnabled, + localizedBuiltinSlashCommands, setExperimentalFeatures, sortSlashCommands, type KimiSlashCommand, @@ -179,6 +179,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: input.tuiConfig.theme, + language: input.tuiConfig.language, version: input.version, editorCommand: input.tuiConfig.editorCommand, notifications: input.tuiConfig.notifications, @@ -311,7 +312,7 @@ export class KimiTUI { // ========================================================================= private getSlashCommands(): readonly KimiSlashCommand[] { - const builtins = sortSlashCommands(BUILTIN_SLASH_COMMANDS).filter((command) => + const builtins = sortSlashCommands(localizedBuiltinSlashCommands()).filter((command) => isExperimentalFlagEnabled(command.experimentalFlag), ); return [...builtins, ...this.skillCommands]; diff --git a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts index a17e60586..052bcef8c 100644 --- a/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts +++ b/apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts @@ -1,19 +1,40 @@ import type { ApprovalRequest, ApprovalResponse, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; +import { i18n } from '#/tui/i18n'; import type { ApprovalPanelResponse } from '#/tui/components/dialogs/approval-panel'; import type { ApprovalPanelChoice, ApprovalPanelData, DisplayBlock } from '#/tui/reverse-rpc/types'; -const DEFAULT_APPROVAL_CHOICES: ApprovalPanelChoice[] = [ - { label: 'Approve once', response: 'approved' }, - { label: 'Approve for this session', response: 'approved_for_session' }, - { label: 'Reject', response: 'rejected' }, - { label: 'Reject with feedback', response: 'rejected', requires_feedback: true }, -]; +// Choice labels are resolved at adapt time so a panel built while the active +// locale is `zh-CN` shows Chinese controls. `selected_label` stays a stable +// English identifier — it is reported upstream and must not shift with the UI +// language. +function defaultApprovalChoices(): ApprovalPanelChoice[] { + return [ + { label: i18n.t('reverseRpc.approval.choice.approveOnce'), response: 'approved' }, + { + label: i18n.t('reverseRpc.approval.choice.approveSession'), + response: 'approved_for_session', + }, + { label: i18n.t('reverseRpc.approval.choice.reject'), response: 'rejected' }, + { + label: i18n.t('reverseRpc.approval.choice.rejectWithFeedback'), + response: 'rejected', + requires_feedback: true, + }, + ]; +} -const PLAN_REJECT_CHOICES: ApprovalPanelChoice[] = [ - { label: 'Reject', response: 'rejected', selected_label: 'Reject' }, - { label: 'Revise', response: 'rejected', selected_label: 'Revise', requires_feedback: true }, -]; +function planRejectChoices(): ApprovalPanelChoice[] { + return [ + { label: i18n.t('reverseRpc.approval.choice.reject'), response: 'rejected', selected_label: 'Reject' }, + { + label: i18n.t('reverseRpc.approval.choice.revise'), + response: 'rejected', + selected_label: 'Revise', + requires_feedback: true, + }, + ]; +} export function adaptApprovalRequest(event: ApprovalRequest): ApprovalPanelData { const resolved = resolveDisplay(event.toolName, event.display, event.action); @@ -208,20 +229,22 @@ function describeApproval(display: ToolInputDisplay, action: string): string { } } -const DANGER_PATTERNS: Array<{ pattern: RegExp; label: string }> = [ - { pattern: /\brm\s+(-[a-zA-Z]*[rRfF][a-zA-Z]*|--recursive|--force)/i, label: 'recursive delete' }, - { pattern: /\bsudo\b/i, label: 'sudo' }, - { pattern: /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/i, label: 'pipe to shell' }, - { pattern: /\bdd\b[^|]*\bof=/i, label: 'dd write' }, - { pattern: /\bmkfs\b/i, label: 'mkfs' }, - { pattern: />\s*\/dev\/(sd|nvme|disk|hd)/i, label: 'write to raw device' }, - { pattern: /\bchmod\s+-R?\s*777\b/i, label: 'chmod 777' }, - { pattern: /:\(\)\s*\{\s*:\|:&\s*\}/i, label: 'fork bomb' }, +// `key` indexes `reverseRpc.approval.danger.*` so the label localizes with the +// active UI language; the pattern detection itself is language-agnostic. +const DANGER_PATTERNS: Array<{ pattern: RegExp; key: string }> = [ + { pattern: /\brm\s+(-[a-zA-Z]*[rRfF][a-zA-Z]*|--recursive|--force)/i, key: 'recursiveDelete' }, + { pattern: /\bsudo\b/i, key: 'sudo' }, + { pattern: /\b(curl|wget)\b[^|]*\|\s*(sh|bash|zsh)\b/i, key: 'pipeToShell' }, + { pattern: /\bdd\b[^|]*\bof=/i, key: 'ddWrite' }, + { pattern: /\bmkfs\b/i, key: 'mkfs' }, + { pattern: />\s*\/dev\/(sd|nvme|disk|hd)/i, key: 'rawDevice' }, + { pattern: /\bchmod\s+-R?\s*777\b/i, key: 'chmod777' }, + { pattern: /:\(\)\s*\{\s*:\|:&\s*\}/i, key: 'forkBomb' }, ]; function detectDanger(command: string): string | undefined { - for (const { pattern, label } of DANGER_PATTERNS) { - if (pattern.test(command)) return label; + for (const { pattern, key } of DANGER_PATTERNS) { + if (pattern.test(command)) return i18n.t(`reverseRpc.approval.danger.${key}`); } return undefined; } @@ -336,7 +359,7 @@ function adaptChoices(toolName: string, display: ToolInputDisplay): ApprovalPane return adaptPlanReviewChoices(display); } - return DEFAULT_APPROVAL_CHOICES.map((choice) => cloneChoice(choice)); + return defaultApprovalChoices(); } function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[] { @@ -347,10 +370,12 @@ function adaptPlanReviewChoices(display: ToolInputDisplay): ApprovalPanelChoice[ response: 'approved' as const, selected_label: option.label, })) - : [{ label: 'Approve', response: 'approved' as const, selected_label: 'Approve' }]; - return [...optionChoices, ...PLAN_REJECT_CHOICES].map((choice) => cloneChoice(choice)); -} - -function cloneChoice(choice: ApprovalPanelChoice): ApprovalPanelChoice { - return { ...choice }; + : [ + { + label: i18n.t('reverseRpc.approval.choice.approve'), + response: 'approved' as const, + selected_label: 'Approve', + }, + ]; + return [...optionChoices, ...planRejectChoices()]; } diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 6b407f777..377956164 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -8,7 +8,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, UpgradePreferences } from './config'; +import type { NotificationsConfig, TuiLanguage, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -39,6 +39,8 @@ export interface AppState { streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing'; streamingStartTime: number; theme: ThemeName; + /** Active language preference; drives the i18n locale for the session. */ + language: TuiLanguage; version: string; editorCommand: string | null; notifications: NotificationsConfig; diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 158d9edfa..af038ac7d 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -12,6 +12,7 @@ type CreateKimiDeviceId = typeof createKimiDeviceIdFn; const mocks = vi.hoisted(() => { type TuiConfigFallback = { theme: 'dark' | 'light' | 'auto'; + language?: 'auto' | 'en' | 'zh-CN'; editorCommand: string | null; notifications: { enabled: boolean; condition: 'unfocused' | 'always' }; }; @@ -451,6 +452,7 @@ describe('runShell', () => { mocks.loadTuiConfig.mockRejectedValue( new mocks.TuiConfigParseError({ theme: 'auto', + language: 'en', editorCommand: 'vim', notifications: { enabled: true, condition: 'always' }, }), @@ -485,6 +487,39 @@ describe('runShell', () => { }); }); + it('renders the config parse warning in the configured locale (zh-CN)', async () => { + mocks.loadTuiConfig.mockRejectedValue( + new mocks.TuiConfigParseError({ + theme: 'auto', + language: 'zh-CN', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + }), + ); + mocks.detectTerminalTheme.mockResolvedValue('light'); + mocks.tuiStart.mockResolvedValue(undefined); + + await runShell( + { + session: '', + continue: false, + yolo: false, + auto: false, + plan: false, + model: undefined, + outputFormat: undefined, + prompt: undefined, + skillsDirs: [], + }, + '1.2.3-test', + ); + + const [, , startupInput] = mocks.kimiTuiConstructor.mock.calls[0]!; + expect(startupInput).toMatchObject({ + startupNotice: '~/.kimi-code/tui.toml 中的 TUI 配置无效,已使用默认值。', + }); + }); + it('forwards config.toml diagnostics as startup notices', async () => { mocks.loadTuiConfig.mockResolvedValue({ theme: 'dark', diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index a96d1445b..d0ac9e4da 100644 --- a/apps/kimi-code/test/cli/update/preflight.test.ts +++ b/apps/kimi-code/test/cli/update/preflight.test.ts @@ -161,6 +161,7 @@ function installState(overrides: Partial = {}): UpdateInstal function tuiConfig(overrides: Partial = {}): TuiConfig { return { theme: 'auto', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index b719da163..479ad96f9 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -29,6 +29,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/commands/language.test.ts b/apps/kimi-code/test/tui/commands/language.test.ts new file mode 100644 index 000000000..2b62f4117 --- /dev/null +++ b/apps/kimi-code/test/tui/commands/language.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { applyLanguageChoice } from '#/tui/commands/config'; +import { i18n } from '#/tui/i18n'; +import { darkColors } from '#/tui/theme/colors'; + +const mocks = vi.hoisted(() => ({ + saveTuiConfig: vi.fn(), +})); + +vi.mock('../../../src/tui/config', async () => { + const actual = await vi.importActual( + '../../../src/tui/config.js', + ); + return { + ...actual, + saveTuiConfig: mocks.saveTuiConfig, + }; +}); + +function makeHost() { + const requestRender = vi.fn(); + const setAppState = vi.fn(); + const showStatus = vi.fn(); + const track = vi.fn(); + const refreshSlashCommandAutocomplete = vi.fn(); + const host = { + state: { + appState: { + theme: 'auto' as const, + language: 'auto' as const, + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + theme: { palette: darkColors }, + ui: { requestRender }, + }, + setAppState, + showStatus, + track, + refreshSlashCommandAutocomplete, + }; + return { host, requestRender, setAppState, showStatus, track, refreshSlashCommandAutocomplete }; +} + +describe('language commands', () => { + it('persists, switches runtime locale, and re-renders when the language changes', async () => { + mocks.saveTuiConfig.mockClear(); + const setLocale = vi.spyOn(i18n, 'setLocale'); + const { host, requestRender, setAppState, refreshSlashCommandAutocomplete } = makeHost(); + + await applyLanguageChoice(host, 'zh-CN'); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ + theme: 'auto', + language: 'zh-CN', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + }); + expect(setAppState).toHaveBeenCalledWith({ language: 'zh-CN' }); + expect(setLocale).toHaveBeenCalledWith('zh-CN'); + expect(requestRender).toHaveBeenCalled(); + // The slash-command autocomplete holds a snapshot of localized descriptions + // built once via setupAutocomplete(); flipping the locale must rebuild it so + // the `/` menu follows the new language without a restart. + expect(refreshSlashCommandAutocomplete).toHaveBeenCalled(); + + setLocale.mockRestore(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index edfeaa106..95e06e486 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -1,13 +1,16 @@ import { BUILTIN_SLASH_COMMANDS, findBuiltInSlashCommand, + goalArgumentCompletions, + localizedBuiltinSlashCommands, parseSlashInput, resolveSlashCommandAvailability, sortSlashCommands, swarmArgumentCompletions, type KimiSlashCommand, } from '#/tui/commands/index'; -import { describe, expect, it } from 'vitest'; +import { i18n } from '#/tui/i18n'; +import { afterEach, describe, expect, it } from 'vitest'; describe('parseSlashInput', () => { it('parses command names and trimmed args', () => { @@ -156,6 +159,73 @@ describe('built-in slash command registry', () => { ); }); + describe('localized argument-completion descriptions', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('keeps swarm subcommand values and labels untranslated under zh-CN', () => { + i18n.setLocale('zh-CN'); + const off = swarmArgumentCompletions('of'); + expect(off).toEqual([{ value: 'off', label: 'off', description: '关闭蜂群模式' }]); + }); + + it('localizes goal subcommand descriptions under zh-CN while keeping values', () => { + i18n.setLocale('zh-CN'); + const status = goalArgumentCompletions('')?.find((item) => item.value === 'status'); + expect(status?.value).toBe('status'); + expect(status?.description).toBe('显示当前目标'); + expect(status?.description).not.toBe('Show the current goal'); + }); + }); + + describe('localized descriptions', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('resolves builtin descriptions from the en locale by default', () => { + const byName = new Map(localizedBuiltinSlashCommands().map((c) => [c.name, c.description])); + expect(byName.get('settings')).toBe('Open TUI settings'); + expect(byName.get('help')).toBe('Show available commands and shortcuts'); + }); + + it('resolves builtin descriptions in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + const byName = new Map(localizedBuiltinSlashCommands().map((c) => [c.name, c.description])); + expect(byName.get('settings')).toBe('打开 TUI 设置'); + expect(byName.get('settings')).not.toBe('Open TUI settings'); + }); + + it('keeps command names and aliases untranslated under zh-CN', () => { + i18n.setLocale('zh-CN'); + const settings = localizedBuiltinSlashCommands().find((c) => c.name === 'settings'); + expect(settings?.name).toBe('settings'); + expect(settings?.aliases).toEqual(['config']); + }); + + it('has a description key for every builtin command in both locales', () => { + for (const locale of ['en', 'zh-CN'] as const) { + i18n.setLocale(locale); + for (const command of localizedBuiltinSlashCommands()) { + // A missing key falls back to the raw dotted key — assert it resolved. + expect(command.description).not.toContain('commands.descriptions.'); + expect(command.description.length).toBeGreaterThan(0); + } + } + }); + + it('translates no English description through into the zh-CN pack', () => { + i18n.setLocale('zh-CN'); + const englishByName = new Map( + BUILTIN_SLASH_COMMANDS.map((c) => [c.name, c.description]), + ); + for (const command of localizedBuiltinSlashCommands()) { + expect(command.description).not.toBe(englishByName.get(command.name)); + } + }); + }); + it('keeps TUI reload always available and full reload idle-only', () => { const reload = findBuiltInSlashCommand('reload'); const reloadTui = findBuiltInSlashCommand('reload-tui'); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index ab54a221b..7b95e1387 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -8,6 +8,7 @@ import { handleReloadCommand, handleReloadTuiCommand, } from '#/tui/commands/reload'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import type { SlashCommandHost } from '#/tui/commands'; import { @@ -87,6 +88,22 @@ auto_install = false }); }); + it('re-reads the language and re-applies it via setAppState + i18n.setLocale', async () => { + await writeTuiConfig('language = "zh-CN"\n'); + const setLocale = vi.spyOn(i18n, 'setLocale'); + const host = makeHost(); + + await handleReloadTuiCommand(host); + + expect(host.state.appState.language).toBe('zh-CN'); + expect(setLocale).toHaveBeenCalledWith('zh-CN'); + // Rebuild the autocomplete snapshot so the `/` command menu picks up the + // reloaded locale without a restart (see applyReloadedTuiConfig). + expect(host.refreshSlashCommandAutocomplete).toHaveBeenCalled(); + + setLocale.mockRestore(); + }); + it('awaits the async theme application before refreshing terminal tracking', async () => { await writeTuiConfig('theme = "auto"\n'); const host = makeHost(); @@ -129,6 +146,7 @@ function makeHost({ const state = { appState: { theme: 'dark', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index fdb64ce46..4dadf7ccd 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -26,6 +26,7 @@ describe('update preference commands', () => { state: { appState: { theme: 'auto' as const, + language: 'auto' as const, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' as const }, upgrade: { autoInstall: true }, @@ -41,6 +42,7 @@ describe('update preference commands', () => { expect(mocks.saveTuiConfig).toHaveBeenCalledWith({ theme: 'auto', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, diff --git a/apps/kimi-code/test/tui/components/chrome/footer.test.ts b/apps/kimi-code/test/tui/components/chrome/footer.test.ts index ab0878d6b..dd694ebb0 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -49,6 +49,7 @@ const appState: AppState = { planMode: false, swarmMode: false, theme: 'dark', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts index cc3a2ff21..a3b990d69 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { setRainbowDance, type RainbowDanceController } from '#/tui/easter-eggs/dance'; +import { i18n } from '#/tui/i18n'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -27,6 +28,7 @@ const appState: AppState = { planMode: false, swarmMode: false, theme: 'dark', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -101,3 +103,95 @@ describe('WelcomeComponent', () => { } }); }); + +function strip(text: string): string { + return text.replaceAll(/\[[0-9;]*m/g, ''); +} + +describe('WelcomeComponent — locale rendering', () => { + const previousChalkLevel = chalk.level; + + beforeEach(() => { + chalk.level = 3; + }); + + afterEach(() => { + chalk.level = previousChalkLevel; + i18n.setLocale('en'); + }); + + it('renders the welcome title in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + + const output = new WelcomeComponent(appState).render(80).map(strip).join('\n'); + + expect(output).toContain('欢迎使用 Kimi Code'); + expect(output).not.toContain('Welcome to Kimi Code'); + }); + + it('shows the help hint in Chinese when logged in', () => { + i18n.setLocale('zh-CN'); + + const output = new WelcomeComponent(appState).render(80).map(strip).join('\n'); + + expect(output).toContain('发送 /help 获取帮助信息'); + expect(output).not.toContain('Send /help for help information'); + }); + + it('shows the login hint and unset-model notice in Chinese when logged out', () => { + i18n.setLocale('zh-CN'); + const loggedOut: AppState = { ...appState, model: '' }; + + const output = new WelcomeComponent(loggedOut).render(80).map(strip).join('\n'); + + expect(output).toContain('运行 /login 或 /provider 开始使用'); + expect(output).toContain('未设置'); + expect(output).not.toContain('Run /login or /provider to get started'); + expect(output).not.toContain('not set, run /login or /provider'); + }); + + it('renders the info field labels in Chinese while keeping values verbatim', () => { + i18n.setLocale('zh-CN'); + const withMcp: AppState = { ...appState, mcpServersSummary: '2 servers' }; + + const output = new WelcomeComponent(withMcp).render(80).map(strip).join('\n'); + + expect(output).toContain('目录'); + expect(output).toContain('会话'); + expect(output).toContain('模型'); + expect(output).toContain('版本'); + // Values are not translated. + expect(output).toContain('/tmp/project'); + expect(output).toContain('ses-1'); + expect(output).toContain('1.2.3'); + expect(output).toContain('2 servers'); + // The MCP key stays as the acronym. + expect(output).toContain('MCP'); + expect(output).not.toContain('Directory'); + expect(output).not.toContain('Version'); + }); + + it('renders the welcome surface in English on the default locale', () => { + const withMcp: AppState = { ...appState, mcpServersSummary: '2 servers' }; + + const output = new WelcomeComponent(withMcp).render(80).map(strip).join('\n'); + + expect(output).toContain('Welcome to Kimi Code'); + expect(output).toContain('Send /help for help information.'); + expect(output).toContain('Directory'); + expect(output).toContain('Version'); + expect(output).not.toContain('欢迎'); + expect(output).not.toContain('目录'); + }); + + it('keeps every line within the requested width under zh-CN', () => { + i18n.setLocale('zh-CN'); + const withMcp: AppState = { ...appState, mcpServersSummary: '2 servers' }; + + for (const width of [0, 1, 2, 4, 10, 39, 80]) { + for (const line of new WelcomeComponent(withMcp).render(width)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts index 473f3261e..a9a60e6b4 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-panel.test.ts @@ -1,7 +1,8 @@ import { CURSOR_MARKER } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; +import { i18n } from '#/tui/i18n'; import type { DiffDisplayBlock, FileContentDisplayBlock, @@ -415,6 +416,47 @@ describe('ApprovalPanelComponent', () => { } }); + describe('locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + function makeBashPending(): PendingApproval { + return { + data: { + id: 'approval_bash', + tool_call_id: 'tool_bash', + tool_name: 'Bash', + action: 'run', + description: '', + display: [ + { type: 'shell', language: 'bash', command: 'ls', danger: 'recursive delete' }, + ], + choices: [{ label: 'Approve once', response: 'approved' }], + }, + }; + } + + it('renders the header and hints in English by default', () => { + const dialog = new ApprovalPanelComponent(makeBashPending(), () => {}); + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('Run this command?'); + expect(out).toContain('select'); + expect(out).toContain('choose'); + expect(out).toContain('confirm'); + }); + + it('renders the header and hints in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + const dialog = new ApprovalPanelComponent(makeBashPending(), () => {}); + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('运行此命令?'); + expect(out).toContain('确认'); + expect(out).not.toContain('Run this command?'); + expect(out).not.toContain('confirm'); + }); + }); + it('returns feedback for plan-review revise choice', () => { const responses: Array<{ response: string; diff --git a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts index d4ba77fa9..544d7655d 100644 --- a/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/approval-preview.test.ts @@ -1,10 +1,11 @@ import type { Terminal } from '@earendil-works/pi-tui'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { ApprovalPreviewViewer, type ApprovalPreviewBlock, } from '#/tui/components/dialogs/approval-preview'; +import { i18n } from '#/tui/i18n'; const ANSI_SGR = /\[[0-9;]*m/g; function strip(text: string): string { return text.replaceAll(ANSI_SGR, ''); @@ -138,6 +139,40 @@ describe('ApprovalPreviewViewer', () => { expect(text).toContain('BETA'); }); + describe('locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('renders the title and footer hints in English by default', () => { + const viewer = makeViewer({ + block: { type: 'file_content', path: 'a.ts', content: 'one\ntwo' }, + rows: 24, + }); + const out = strip(viewer.render(100).join('\n')); + expect(out).toContain('Preview'); + expect(out).toContain('line'); + expect(out).toContain('page'); + expect(out).toContain('top/bot'); + expect(out).toContain('cancel'); + }); + + it('renders the title and footer hints in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + const viewer = makeViewer({ + block: { type: 'file_content', path: 'a.ts', content: 'one\ntwo' }, + rows: 24, + }); + const out = strip(viewer.render(100).join('\n')); + expect(out).toContain('预览'); + expect(out).toContain('翻页'); + expect(out).toContain('顶部/底部'); + expect(out).toContain('取消'); + expect(out).not.toContain('Preview'); + expect(out).not.toContain('cancel'); + }); + }); + // Sanity: rendering is a pure slice — repeated render() calls without // input changes produce the same output, no incremental state drift. it('renders deterministically across repeated calls', () => { diff --git a/apps/kimi-code/test/tui/components/dialogs/language-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/language-selector.test.ts new file mode 100644 index 000000000..a8722731d --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/language-selector.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { LanguageSelectorComponent } from '#/tui/components/dialogs/language-selector'; + +const ANSI_SGR = /\[[0-9;]*m/g; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +describe('LanguageSelectorComponent', () => { + it('renders Auto / English / 简体中文 with the current value highlighted', () => { + const selector = new LanguageSelectorComponent({ + currentValue: 'zh-CN', + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + + const out = selector.render(120).map(strip); + + expect(out.some((l) => l.includes('Auto'))).toBe(true); + expect(out.some((l) => l.includes('English'))).toBe(true); + expect(out).toContain(' ❯ 简体中文 ← current'); + }); + + it('fires onSelect with the chosen language value on Enter', () => { + const onSelect = vi.fn(); + const selector = new LanguageSelectorComponent({ + currentValue: 'auto', + onSelect, + onCancel: vi.fn(), + }); + + // Cursor starts on the current value ('auto', the first option). Enter selects it. + selector.handleInput('\r'); + + expect(onSelect).toHaveBeenCalledWith('auto'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts index eec53eca4..1212c867c 100644 --- a/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/model-selector.test.ts @@ -1,8 +1,9 @@ import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { visibleWidth } from '@earendil-works/pi-tui'; -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; import { darkColors } from '#/tui/theme/colors'; @@ -255,3 +256,32 @@ describe('ModelSelectorComponent', () => { } }); }); + +describe('ModelSelectorComponent — locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('renders the shared footer hints in Chinese via the common namespace', () => { + i18n.setLocale('zh-CN'); + const picker = new ModelSelectorComponent({ + models: { kimi: model('Kimi K2') }, + currentValue: 'kimi', + currentThinking: true, + searchable: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + }); + // Type a query so the Backspace-clear hint surfaces too. + picker.handleInput('k'); + + const out = text(picker); + + expect(out).toContain('↑↓ 导航'); + expect(out).toContain('Enter 选择'); + expect(out).toContain('Esc 取消'); + expect(out).toContain('Backspace 清除'); + expect(out).not.toContain('navigate'); + expect(out).not.toContain('Esc cancel'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts index 12ca62ed7..0b16d23d8 100644 --- a/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/question-dialog.test.ts @@ -1,9 +1,10 @@ import { CURSOR_MARKER } from '@earendil-works/pi-tui'; import chalk from 'chalk'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; import { QuestionDialogComponent } from '#/tui/components/dialogs/question-dialog'; import type { PendingQuestion } from '#/tui/reverse-rpc/types'; +import { i18n } from '#/tui/i18n'; import { currentTheme } from '#/tui/theme'; function strip(text: string): string { @@ -430,6 +431,51 @@ describe('QuestionDialogComponent', () => { expect(collected).toEqual([]); }); + describe('locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + function singleQuestion(): PendingQuestion { + return makePending([ + { + question: 'Pick one?', + multi_select: false, + options: [{ label: 'A' }, { label: 'B' }], + }, + ]); + } + + it('renders the question tab heading and Other option in English by default', () => { + const { dialog } = makeDialog(singleQuestion()); + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('question'); + expect(out).toContain('Other'); + }); + + it('renders the question tab in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + const { dialog } = makeDialog(singleQuestion()); + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('问题'); + expect(out).toContain('其他'); + expect(out).not.toContain('Other'); + }); + + it('renders the submit/review tab in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + const { dialog } = makeDialog(singleQuestion()); + dialog.handleInput('\r'); // answer and auto-advance to the submit tab + const out = strip(dialog.render(80).join('\n')); + expect(out).toContain('提交前请检查你的回答'); + expect(out).toContain('确认提交你的回答?'); + expect(out).toContain('提交'); + expect(out).toContain('取消'); + expect(out).not.toContain('Submit'); + expect(out).not.toContain('Review your answer before submit'); + }); + }); + describe('long-content wrapping', () => { const longQuestion = 'Please confirm whether this dangerous shell command should really be executed in the current workspace, including all of its side effects on the filesystem and the network.'; diff --git a/apps/kimi-code/test/tui/components/dialogs/settings-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/settings-selector.test.ts new file mode 100644 index 000000000..f9fd23112 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/settings-selector.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { SettingsSelectorComponent } from '#/tui/components/dialogs/settings-selector'; + +const ANSI_SGR = /\[[0-9;]*m/g; +const DOWN_ARROW = ''; +const ENTER = '\r'; + +function strip(text: string): string { + return text.replaceAll(ANSI_SGR, ''); +} + +describe('SettingsSelectorComponent', () => { + it('lists a Language entry', () => { + const out = new SettingsSelectorComponent({ onSelect: vi.fn(), onCancel: vi.fn() }) + .render(120) + .map(strip); + + expect(out.some((l) => l.includes('Language'))).toBe(true); + }); + + it('routes the Language entry to onSelect("language")', () => { + const onSelect = vi.fn(); + const selector = new SettingsSelectorComponent({ onSelect, onCancel: vi.fn() }); + + // Navigate the cursor down to the Language row, then select it. + let lines = selector.render(120).map(strip); + for (let i = 0; i < 12 && !cursorOnLanguage(lines); i++) { + selector.handleInput(DOWN_ARROW); + lines = selector.render(120).map(strip); + } + selector.handleInput(ENTER); + + expect(onSelect).toHaveBeenCalledWith('language'); + }); +}); + +function cursorOnLanguage(lines: readonly string[]): boolean { + return lines.some((l) => l.includes('❯') && l.includes('Language')); +} diff --git a/apps/kimi-code/test/tui/components/dialogs/undo-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/undo-selector.test.ts new file mode 100644 index 000000000..355b92243 --- /dev/null +++ b/apps/kimi-code/test/tui/components/dialogs/undo-selector.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { UndoSelectorComponent } from '#/tui/components/dialogs/undo-selector'; +import { i18n } from '#/tui/i18n'; + +const ANSI = /\[[0-9;]*m/g; +const strip = (s: string): string => s.replaceAll(ANSI, ''); + +function selector(): UndoSelectorComponent { + return new UndoSelectorComponent({ + choices: [{ id: 'a', count: 1, input: 'do thing', label: 'do thing' }], + onSelect: vi.fn(), + onCancel: vi.fn(), + }); +} + +function text(component: UndoSelectorComponent, width = 80): string { + return component.render(width).map(strip).join('\n'); +} + +describe('UndoSelectorComponent — locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('renders the shared navigate/select/cancel hints in Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + + const out = text(selector()); + + expect(out).toContain('↑↓ 导航'); + expect(out).toContain('Enter 选择'); + expect(out).toContain('Esc 取消'); + expect(out).not.toContain('navigate'); + expect(out).not.toContain('select'); + expect(out).not.toContain('cancel'); + }); + + it('keeps the shared hints in English on the default locale', () => { + const out = text(selector()); + + expect(out).toContain('↑↓ navigate'); + expect(out).toContain('Enter select'); + expect(out).toContain('Esc cancel'); + }); +}); diff --git a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts index ca67aded7..b2d13b5c2 100644 --- a/apps/kimi-code/test/tui/components/messages/status-panel.test.ts +++ b/apps/kimi-code/test/tui/components/messages/status-panel.test.ts @@ -1,6 +1,8 @@ -import { describe, expect, it } from 'vitest'; +import { visibleWidth } from '@earendil-works/pi-tui'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildStatusReportLines } from '#/tui/components/messages/status-panel'; +import { i18n } from '#/tui/i18n'; function strip(text: string): string { return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); @@ -92,3 +94,81 @@ describe('status panel report lines', () => { expect(output).toContain('No context window data available.'); }); }); + +describe('status panel — locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + const richOptions = { + version: '1.2.3', + model: 'k2', + workDir: '/tmp/project', + sessionId: 'ses-1', + sessionTitle: 'Implement status', + thinking: true, + permissionMode: 'manual' as const, + planMode: false, + contextUsage: 0.25, + contextTokens: 2500, + maxContextTokens: 10000, + availableModels: { + k2: { + provider: 'managed:kimi-code', + model: 'kimi-k2', + maxContextSize: 10000, + displayName: 'Kimi K2', + }, + }, + }; + + it('renders status field labels and values in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + + const output = buildStatusReportLines(richOptions).map(strip).join('\n'); + + expect(output).toContain('模型'); + expect(output).toContain('目录'); + expect(output).toContain('权限'); + expect(output).toContain('计划模式'); + expect(output).toContain('会话'); + expect(output).toContain('标题'); + expect(output).toContain('上下文窗口'); + // Plan mode off + thinking on render with translated on/off + label. + expect(output).toContain('(思考 开)'); + expect(output).toContain('关'); + // Model display name stays untranslated. + expect(output).toContain('Kimi K2'); + expect(output).not.toContain('Model'); + expect(output).not.toContain('Context window'); + }); + + it('keeps status field labels in English on the default locale', () => { + const output = buildStatusReportLines(richOptions).map(strip).join('\n'); + + expect(output).toContain('Model'); + expect(output).toContain('Context window'); + expect(output).not.toContain('模型'); + }); + + it('keeps the status panel aligned and within width under zh-CN', () => { + i18n.setLocale('zh-CN'); + + const lines = buildStatusReportLines(richOptions).map(strip); + const width = 60; + for (const line of lines) { + expect(visibleWidth(line)).toBeLessThanOrEqual(width); + } + + // Field labels are padded to a common display width, so every value + // column starts at the same visible offset (measured as the display + // width of the "