From 4fcc7e37979ea9152c55e1eb3739da1cd0987307 Mon Sep 17 00:00:00 2001 From: Fyihang Date: Wed, 22 Jul 2026 11:58:50 +0800 Subject: [PATCH] feat(tui): add Simplified Chinese UI translations and language selector Add an i18n layer, en/zh translation files, and a /language command that lets users switch between English and Chinese. Language choice is persisted in the TUI config and applied on startup and reload. --- .changeset/tui-chinese-language-support.md | 5 ++ apps/kimi-code/package.json | 1 + apps/kimi-code/src/tui/commands/config.ts | 57 ++++++++++++ apps/kimi-code/src/tui/commands/dispatch.ts | 9 ++ apps/kimi-code/src/tui/commands/index.ts | 1 + apps/kimi-code/src/tui/commands/registry.ts | 7 ++ apps/kimi-code/src/tui/commands/reload.ts | 1 + .../src/tui/components/chrome/footer.ts | 5 +- .../src/tui/components/chrome/welcome.ts | 27 +++--- .../components/dialogs/language-selector.ts | 31 +++++++ .../components/dialogs/settings-selector.ts | 89 +++++++++++-------- .../tui/components/dialogs/theme-selector.ts | 19 ++-- apps/kimi-code/src/tui/config.ts | 7 ++ apps/kimi-code/src/tui/i18n/en.ts | 58 ++++++++++++ apps/kimi-code/src/tui/i18n/index.ts | 45 ++++++++++ apps/kimi-code/src/tui/i18n/types.ts | 66 ++++++++++++++ apps/kimi-code/src/tui/i18n/zh.ts | 58 ++++++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 12 +++ apps/kimi-code/src/tui/types.ts | 2 + .../test/cli/update/preflight.test.ts | 1 + apps/kimi-code/test/tui/activity-pane.test.ts | 1 + .../test/tui/commands/reload.test.ts | 4 + .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../panels/footer-bg-agents.test.ts | 1 + .../components/panels/footer-context.test.ts | 1 + .../panels/footer-goal-badge.test.ts | 1 + apps/kimi-code/test/tui/config.test.ts | 6 ++ .../test/tui/create-tui-state.test.ts | 1 + apps/kimi-code/test/tui/i18n.test.ts | 35 ++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 1 + .../test/tui/kimi-tui-startup.test.ts | 1 + .../kimi-code/test/tui/message-replay.test.ts | 1 + .../test/tui/signal-handlers.test.ts | 1 + 34 files changed, 495 insertions(+), 62 deletions(-) create mode 100644 .changeset/tui-chinese-language-support.md create mode 100644 apps/kimi-code/src/tui/components/dialogs/language-selector.ts create mode 100644 apps/kimi-code/src/tui/i18n/en.ts create mode 100644 apps/kimi-code/src/tui/i18n/index.ts create mode 100644 apps/kimi-code/src/tui/i18n/types.ts create mode 100644 apps/kimi-code/src/tui/i18n/zh.ts create mode 100644 apps/kimi-code/test/tui/i18n.test.ts diff --git a/.changeset/tui-chinese-language-support.md b/.changeset/tui-chinese-language-support.md new file mode 100644 index 0000000000..1806420247 --- /dev/null +++ b/.changeset/tui-chinese-language-support.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add Simplified Chinese UI translations and a language selector. Run /language or open Settings to switch between English and Chinese. diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 56c2f9cbae..cd0fbde554 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -37,6 +37,7 @@ "imports": { "#/tui/theme": "./src/tui/theme/index.ts", "#/tui/commands": "./src/tui/commands/index.ts", + "#/tui/i18n": "./src/tui/i18n/index.ts", "#/cli/sub/web": "./src/cli/sub/web/index.ts", "#/cli/sub/web/*": "./src/cli/sub/web/*.ts", "#/generated/vis-web-asset": [ diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 58f1c27acb..4e35d82bc4 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -9,6 +9,7 @@ import { import { EditorSelectorComponent } from '../components/dialogs/editor-selector'; import { EffortSelectorComponent } from '../components/dialogs/effort-selector'; +import { LanguageSelectorComponent } from '../components/dialogs/language-selector'; import { ExperimentsSelectorComponent, type ExperimentalFeatureDraftChange, @@ -20,6 +21,8 @@ import { SettingsSelectorComponent, type SettingsSelection } from '../components import { ThemeSelectorComponent } from '../components/dialogs/theme-selector'; import { UpdatePreferenceSelectorComponent } from '../components/dialogs/update-preference-selector'; import { DEFAULT_TUI_CONFIG, saveTuiConfig, type TuiConfig } from '../config'; +import { isLanguage, type Language } from '#/tui/i18n'; +import { t } 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'; @@ -53,6 +56,7 @@ function hasConversationHistory(host: SlashCommandHost): boolean { function currentTuiConfig(host: SlashCommandHost): TuiConfig { return { theme: host.state.appState.theme, + language: host.state.appState.language, editorCommand: host.state.appState.editorCommand, disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, notifications: host.state.appState.notifications, @@ -236,6 +240,19 @@ export async function handleThemeCommand(host: SlashCommandHost, args: string): await applyThemeChoice(host, theme); } +export async function handleLanguageCommand(host: SlashCommandHost, args: string): Promise { + const language = args.trim(); + if (language.length === 0) { + showLanguagePicker(host); + return; + } + if (!isLanguage(language)) { + host.showError(`Unknown language: ${language}`); + return; + } + await applyLanguageChoice(host, language); +} + export async function handleModelCommand(host: SlashCommandHost, args: string): Promise { const alias = args.trim(); await refreshModelsForPicker(host); @@ -609,6 +626,45 @@ 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(); + }, + }), + ); +} + +async function applyLanguageChoice(host: SlashCommandHost, language: Language): Promise { + if (language === host.state.appState.language) { + host.showStatus(`Language already set to "${language}".`); + return; + } + + try { + await saveTuiConfig({ + ...currentTuiConfig(host), + language, + }); + } catch (error) { + host.showStatus( + `Failed to save language: ${formatErrorMessage(error)}`, + 'error', + ); + return; + } + + host.applyLanguage(language); + host.track('language_switch', { language }); + host.showStatus(`Language set to "${language}".`); +} + export function showPermissionPicker(host: SlashCommandHost): void { host.mountEditorReplacement( new PermissionSelectorComponent({ @@ -782,6 +838,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/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index dcfb904733..a572692375 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -3,6 +3,7 @@ import type { DeviceAuthorization } from '@moonshot-ai/kimi-code-oauth'; import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; import type { ColorToken, ThemeName } from '#/tui/theme'; +import type { Language } from '#/tui/i18n'; import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; import type { AuthFlowController } from '../controllers/auth-flow'; @@ -27,6 +28,7 @@ import { handleCompactCommand, handleEditorCommand, handleEffortCommand, + handleLanguageCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -69,6 +71,7 @@ export { handleCompactCommand, handleEditorCommand, handleEffortCommand, + handleLanguageCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, @@ -134,6 +137,9 @@ export interface SlashCommandHost { applyTheme(theme: ThemeName, resolved?: ResolvedTheme): Promise; refreshTerminalThemeTracking(): void; + // Language + applyLanguage(language: Language): void; + // Dispatch stop(exitCode?: number): Promise; setExitOpenUrl(url: string): void; @@ -300,6 +306,9 @@ async function handleBuiltInSlashCommand( case 'theme': await handleThemeCommand(host, args); return; + case 'language': + await handleLanguageCommand(host, args); + return; case 'model': await handleModelCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index 7449dba9bc..615eb70f86 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -13,6 +13,7 @@ export { handleCopyCommand } from './copy'; export { handleCompactCommand, handleEditorCommand, + handleLanguageCommand, handleModelCommand, handlePlanCommand, handleThemeCommand, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 063bcd7bfe..684eb1bbe7 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -360,6 +360,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 60, availability: 'always', }, + { + name: 'language', + aliases: ['lang'], + description: 'Set the TUI display language', + priority: 60, + availability: 'always', + }, { name: 'logout', aliases: ['disconnect'], diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 6f28a16da5..219b36f6b8 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -49,6 +49,7 @@ export async function applyReloadedTuiConfig( notifications: config.notifications, upgrade: config.upgrade, }); + host.applyLanguage(config.language); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); } diff --git a/apps/kimi-code/src/tui/components/chrome/footer.ts b/apps/kimi-code/src/tui/components/chrome/footer.ts index a2e8d7adee..6470f12bcd 100644 --- a/apps/kimi-code/src/tui/components/chrome/footer.ts +++ b/apps/kimi-code/src/tui/components/chrome/footer.ts @@ -28,6 +28,7 @@ import { usagePercent, usagePercentFromRatio, } from '#/utils/usage/usage-format'; +import { t } from '#/tui/i18n'; const MAX_CWD_SEGMENTS = 3; const GOAL_TIMER_INTERVAL_MS = 1_000; @@ -167,9 +168,9 @@ function shortenCwd(path: string): string { function formatContextStatus(usage: number, tokens?: number, maxTokens?: number): string { if (maxTokens !== undefined && maxTokens > 0 && tokens !== undefined) { const pct = String(usagePercent(tokens, maxTokens)); - return `context: ${pct}% (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; + return `${t('footer.context', { pct })} (${formatTokenCount(tokens)}/${formatTokenCount(maxTokens)})`; } - return `context: ${String(usagePercentFromRatio(usage))}%`; + return `${t('footer.context', { pct: String(usagePercentFromRatio(usage)) })}`; } export function formatFooterGitBadge(status: GitStatus, colors: ColorPalette): string { diff --git a/apps/kimi-code/src/tui/components/chrome/welcome.ts b/apps/kimi-code/src/tui/components/chrome/welcome.ts index 10cdecbdb2..c77839a0e5 100644 --- a/apps/kimi-code/src/tui/components/chrome/welcome.ts +++ b/apps/kimi-code/src/tui/components/chrome/welcome.ts @@ -10,6 +10,7 @@ import chalk from 'chalk'; import { effectiveModelAlias } from '@moonshot-ai/kimi-code-sdk'; import { isRainbowDancing, renderDanceWelcomeHeader } from '#/tui/easter-eggs/dance'; +import { t } from '#/tui/i18n'; import type { AppState } from '#/tui/types'; import { currentTheme } from '#/tui/theme'; @@ -30,14 +31,14 @@ export class WelcomeComponent implements Component { const effectiveActiveModel = activeModel === undefined ? undefined : effectiveModelAlias(activeModel); if (safeWidth < 24) { - const title = chalk.bold.hex(currentTheme.palette.primary)('Welcome to Kimi Code!'); + const title = chalk.bold.hex(currentTheme.palette.primary)(t('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)(t('welcome.loggedOutHint')) + : chalk.hex(currentTheme.palette.textDim)(t('welcome.loggedInHint')); const model = isLoggedOut - ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + ? chalk.hex(currentTheme.palette.warning)(t('welcome.notSet')) : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); - return ['', title, prompt, `Model: ${model}`].map((line) => + return ['', title, prompt, `${t('welcome.model')} ${model}`].map((line) => truncateToWidth(line, safeWidth, '…'), ); } @@ -52,14 +53,14 @@ 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)(t('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 ? t('welcome.loggedOutHint') : t('welcome.loggedInHint')), textWidth, '…', ); @@ -73,18 +74,18 @@ export class WelcomeComponent implements Component { } const modelValue = isLoggedOut - ? chalk.hex(currentTheme.palette.warning)('not set, run /login or /provider') + ? chalk.hex(currentTheme.palette.warning)(t('welcome.notSet')) : (effectiveActiveModel?.displayName ?? effectiveActiveModel?.model ?? this.state.model); const infoLines = [ - labelStyle('Directory: ') + this.state.workDir, - labelStyle('Session: ') + this.state.sessionId, - labelStyle('Model: ') + modelValue, - labelStyle('Version: ') + this.state.version, + labelStyle(t('welcome.directory')) + ' ' + this.state.workDir, + labelStyle(t('welcome.session')) + ' ' + this.state.sessionId, + labelStyle(t('welcome.model')) + ' ' + modelValue, + labelStyle(t('welcome.version')) + ' ' + this.state.version, ]; if (this.state.mcpServersSummary) { - infoLines.push(labelStyle('MCP: ') + this.state.mcpServersSummary); + infoLines.push(labelStyle(t('welcome.mcp')) + ' ' + this.state.mcpServersSummary); } const contentLines: string[] = [...renderedHeaderLines, '', ...infoLines]; 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 0000000000..ae36a6735c --- /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 { Language } from '#/tui/i18n'; +import { t } from '#/tui/i18n'; + +function languageOptions(): readonly ChoiceOption[] { + return [ + { value: 'en', label: 'English' }, + { value: 'zh', label: '中文' }, + ]; +} + +export interface LanguageSelectorOptions { + readonly currentValue: Language; + readonly onSelect: (language: Language) => void; + readonly onCancel: () => void; +} + +export class LanguageSelectorComponent extends ChoicePickerComponent { + constructor(opts: LanguageSelectorOptions) { + super({ + title: t('language.label'), + options: [...languageOptions()], + currentValue: opts.currentValue, + onSelect: (value) => { + opts.onSelect(value as Language); + }, + onCancel: opts.onCancel, + }); + } +} 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 81e4b8d125..8671ae997d 100644 --- a/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/settings-selector.ts @@ -1,56 +1,67 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; +import { t } from '#/tui/i18n'; + export type SettingsSelection = | 'model' | 'theme' + | 'language' | 'editor' | 'permission' | 'experiments' | 'upgrade' | 'usage'; -const SETTINGS_OPTIONS: readonly ChoiceOption[] = [ - { - value: 'model', - label: 'Model', - description: 'Switch the active model and thinking mode.', - }, - { - value: 'permission', - label: 'Permission', - description: 'Choose how tool actions are approved.', - }, - { - value: 'theme', - label: 'Theme', - description: 'Change the terminal UI theme.', - }, - { - value: 'editor', - label: 'Editor', - description: 'Set the external editor command.', - }, - { - value: 'experiments', - label: 'Experiments', - description: 'Turn experimental features on or off.', - }, - { - value: 'upgrade', - label: 'Automatic updates', - description: 'Turn automatic CLI updates on or off.', - }, - { - value: 'usage', - label: 'Usage', - description: 'Show session tokens, context window, and plan quotas.', - }, -]; +function settingsOptions(): readonly ChoiceOption[] { + return [ + { + value: 'model', + label: t('settings.model'), + description: t('settings.modelDescription'), + }, + { + value: 'permission', + label: t('settings.permission'), + description: t('settings.permissionDescription'), + }, + { + value: 'theme', + label: t('settings.theme'), + description: t('settings.themeDescription'), + }, + { + value: 'language', + label: t('settings.language'), + description: t('settings.languageDescription'), + }, + { + value: 'editor', + label: t('settings.editor'), + description: t('settings.editorDescription'), + }, + { + value: 'experiments', + label: t('settings.experiments'), + description: t('settings.experimentsDescription'), + }, + { + value: 'upgrade', + label: t('settings.upgrade'), + description: t('settings.upgradeDescription'), + }, + { + value: 'usage', + label: t('settings.usage'), + description: t('settings.usageDescription'), + }, + ]; +} function isSettingsSelection(value: string): value is SettingsSelection { return ( value === 'model' || value === 'theme' || + value === 'language' || value === 'editor' || value === 'permission' || value === 'experiments' || @@ -67,8 +78,8 @@ export interface SettingsSelectorOptions { export class SettingsSelectorComponent extends ChoicePickerComponent { constructor(opts: SettingsSelectorOptions) { super({ - title: 'Settings', - options: [...SETTINGS_OPTIONS], + title: t('settings.title'), + options: [...settingsOptions()], onSelect: (value) => { if (isSettingsSelection(value)) opts.onSelect(value); }, diff --git a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts index ecd3953fd8..b7c69e6018 100644 --- a/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts +++ b/apps/kimi-code/src/tui/components/dialogs/theme-selector.ts @@ -1,13 +1,16 @@ import { ChoicePickerComponent, type ChoiceOption } from './choice-picker'; +import { t } from '#/tui/i18n'; import { listCustomThemesSync } from '#/tui/theme/custom-theme-loader'; import type { ThemeName } from '#/tui/theme/index'; -const THEME_OPTIONS: readonly ChoiceOption[] = [ - { value: 'auto', label: 'Auto (match terminal)' }, - { value: 'dark', label: 'Dark' }, - { value: 'light', label: 'Light' }, -]; +function themeOptions(): readonly ChoiceOption[] { + return [ + { value: 'auto', label: t('theme.auto') }, + { value: 'dark', label: t('theme.dark') }, + { value: 'light', label: t('theme.light') }, + ]; +} export interface ThemeSelectorOptions { readonly currentValue: ThemeName; @@ -19,11 +22,11 @@ export class ThemeSelectorComponent extends ChoicePickerComponent { constructor(opts: ThemeSelectorOptions) { const customThemes = listCustomThemesSync(); const options: ChoiceOption[] = [ - ...THEME_OPTIONS, - ...customThemes.map((name) => ({ value: name, label: `Custom: ${name}` })), + ...themeOptions(), + ...customThemes.map((name) => ({ value: name, label: t('theme.custom', { name }) })), ]; super({ - title: 'Select theme', + title: t('theme.title'), options, currentValue: opts.currentValue, onSelect: (value) => { diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index cd3329b552..73fd43304b 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -19,6 +19,8 @@ export const INVALID_TUI_CONFIG_MESSAGE = export const TuiThemeSchema = z.string(); +export const TuiLanguageSchema = z.enum(['en', 'zh']); + export const NotificationConditionSchema = z.enum(['unfocused', 'always']); export const NotificationsConfigSchema = z.object({ @@ -32,6 +34,7 @@ export const UpgradePreferencesSchema = z.object({ export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + language: TuiLanguageSchema.optional(), disable_paste_burst: z.boolean().optional(), editor: z .object({ @@ -53,6 +56,7 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + language: TuiLanguageSchema, disablePasteBurst: z.boolean(), editorCommand: z.string().nullable(), notifications: NotificationsConfigSchema, @@ -75,6 +79,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: DEFAULT_NOTIFICATIONS_CONFIG, @@ -135,6 +140,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, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: command === undefined || command.length === 0 ? null : command, notifications: { @@ -154,6 +160,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}" # "en" | "zh" disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback [editor] diff --git a/apps/kimi-code/src/tui/i18n/en.ts b/apps/kimi-code/src/tui/i18n/en.ts new file mode 100644 index 0000000000..154b92aa6f --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/en.ts @@ -0,0 +1,58 @@ +import type { Translations } from './types'; + +export const en: Translations = { + language: { + label: 'Language', + en: 'English', + zh: '中文', + }, + settings: { + title: 'Settings', + model: 'Model', + modelDescription: 'Switch the active model and thinking mode.', + permission: 'Permission', + permissionDescription: 'Choose how tool actions are approved.', + theme: 'Theme', + themeDescription: 'Change the terminal UI theme.', + editor: 'Editor', + editorDescription: 'Set the external editor command.', + experiments: 'Experiments', + experimentsDescription: 'Turn experimental features on or off.', + upgrade: 'Automatic updates', + upgradeDescription: 'Turn automatic CLI updates on or off.', + usage: 'Usage', + usageDescription: 'Show session tokens, context window, and plan quotas.', + language: 'Language', + languageDescription: 'Change the TUI display language.', + }, + theme: { + title: 'Select theme', + auto: 'Auto (match terminal)', + dark: 'Dark', + light: 'Light', + custom: 'Custom: {{name}}', + }, + footer: { + context: 'context: {{pct}}%', + }, + welcome: { + title: 'Welcome to Kimi Code!', + loggedOutHint: 'Run /login or /provider to get started.', + loggedInHint: 'Send /help for help information.', + notSet: 'not set, run /login or /provider', + directory: 'Directory:', + session: 'Session:', + model: 'Model:', + version: 'Version:', + mcp: 'MCP:', + }, + common: { + navigate: 'navigate', + page: 'page', + select: 'select', + cancel: 'cancel', + typeToSearch: 'type to search', + noMatches: 'No matches', + current: 'current', + }, +}; 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 0000000000..9f56177436 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/index.ts @@ -0,0 +1,45 @@ +import { en } from './en'; +import type { Language, TranslationKey, Translations } from './types'; +import { zh } from './zh'; + +const TRANSLATIONS: Record = { + en, + zh, +}; + +let currentLanguage: Language = 'en'; + +export function setLanguage(language: Language): void { + currentLanguage = language; +} + +export function getLanguage(): Language { + return currentLanguage; +} + +export function isLanguage(value: string): value is Language { + return value === 'en' || value === 'zh'; +} + +export function t(key: TranslationKey, vars?: Record): string { + const value = getNestedValue(TRANSLATIONS[currentLanguage], key); + if (vars === undefined) return value; + return value.replace(/\{\{(\w+)\}\}/g, (_, name) => vars[name] ?? `{{${name}}}`); +} + +function getNestedValue(obj: Translations, key: string): string { + const parts = key.split('.'); + let current: unknown = obj; + for (const part of parts) { + if (current === null || typeof current !== 'object') { + throw new Error(`Missing translation key: ${key}`); + } + current = (current as Record)[part]; + } + if (typeof current !== 'string') { + throw new Error(`Missing translation key: ${key}`); + } + return current; +} + +export type { Language, TranslationKey, Translations } from './types'; diff --git a/apps/kimi-code/src/tui/i18n/types.ts b/apps/kimi-code/src/tui/i18n/types.ts new file mode 100644 index 0000000000..22f87356e4 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/types.ts @@ -0,0 +1,66 @@ +export type Language = 'en' | 'zh'; + +export interface Translations { + readonly language: { + readonly label: string; + readonly en: string; + readonly zh: string; + }; + readonly settings: { + readonly title: string; + readonly model: string; + readonly modelDescription: string; + readonly permission: string; + readonly permissionDescription: string; + readonly theme: string; + readonly themeDescription: string; + readonly editor: string; + readonly editorDescription: string; + readonly experiments: string; + readonly experimentsDescription: string; + readonly upgrade: string; + readonly upgradeDescription: string; + readonly usage: string; + readonly usageDescription: string; + readonly language: string; + readonly languageDescription: string; + }; + readonly theme: { + readonly title: string; + readonly auto: string; + readonly dark: string; + readonly light: string; + readonly custom: string; + }; + readonly footer: { + readonly context: string; + }; + readonly welcome: { + readonly title: string; + readonly loggedOutHint: string; + readonly loggedInHint: string; + readonly notSet: string; + readonly directory: string; + readonly session: string; + readonly model: string; + readonly version: string; + readonly mcp: string; + }; + readonly common: { + readonly navigate: string; + readonly page: string; + readonly select: string; + readonly cancel: string; + readonly typeToSearch: string; + readonly noMatches: string; + readonly current: string; + }; +} + +export type TranslationKey = FlattenKeys; + +type FlattenKeys = { + [K in keyof T & string]: T[K] extends Record + ? FlattenKeys + : `${Prefix}${Prefix extends '' ? '' : '.'}${K}`; +}[keyof T & string]; diff --git a/apps/kimi-code/src/tui/i18n/zh.ts b/apps/kimi-code/src/tui/i18n/zh.ts new file mode 100644 index 0000000000..9ba3d8c41f --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/zh.ts @@ -0,0 +1,58 @@ +import type { Translations } from './types'; + +export const zh: Translations = { + language: { + label: '语言', + en: 'English', + zh: '中文', + }, + settings: { + title: '设置', + model: '模型', + modelDescription: '切换当前模型和思考模式。', + permission: '权限', + permissionDescription: '选择工具操作的审批方式。', + theme: '主题', + themeDescription: '更改终端 UI 主题。', + editor: '编辑器', + editorDescription: '设置外部编辑器命令。', + experiments: '实验功能', + experimentsDescription: '开启或关闭实验功能。', + upgrade: '自动更新', + upgradeDescription: '开启或关闭 CLI 自动更新。', + usage: '用量', + usageDescription: '显示会话 Token、上下文窗口和计划配额。', + language: '语言', + languageDescription: '更改 TUI 显示语言。', + }, + theme: { + title: '选择主题', + auto: '自动(跟随终端)', + dark: '深色', + light: '浅色', + custom: '自定义:{{name}}', + }, + footer: { + context: '上下文:{{pct}}%', + }, + welcome: { + title: '欢迎使用 Kimi Code!', + loggedOutHint: '运行 /login 或 /provider 开始使用。', + loggedInHint: '发送 /help 查看帮助信息。', + notSet: '未设置,运行 /login 或 /provider', + directory: '目录:', + session: '会话:', + model: '模型:', + version: '版本:', + mcp: 'MCP:', + }, + common: { + navigate: '上下移动', + page: '翻页', + select: '选择', + cancel: '取消', + typeToSearch: '输入以搜索', + noMatches: '无匹配项', + current: '当前', + }, +}; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 464c7237aa..bc21cc3784 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -117,8 +117,10 @@ import { QuestionController } from './reverse-rpc/question/controller'; import { createQuestionAskHandler } from './reverse-rpc/question/handler'; import type { ApprovalPanelData, QuestionPanelData } from './reverse-rpc/types'; import { currentTheme, getColorPalette, getBuiltInPalette, isBuiltInTheme } from './theme'; +import { DEFAULT_TUI_CONFIG } from './config'; import type { ColorToken, ResolvedTheme, ThemeName } from './theme'; import { createTUIState, type TUIState } from './tui-state'; +import { setLanguage, type Language } from './i18n'; import { INITIAL_LIVE_PANE, type AppState, @@ -225,6 +227,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: input.tuiConfig.theme, + language: input.tuiConfig.language ?? DEFAULT_TUI_CONFIG.language, version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, @@ -391,6 +394,7 @@ export class KimiTUI { this.migrationPlan = startupInput.migrationPlan ?? null; this.migrateOnly = startupInput.migrateOnly ?? false; this.startupNotice = startupInput.startupNotice; + setLanguage(tuiOptions.initialAppState.language); this.state = createTUIState(tuiOptions); this.uninstallRainbowDance = installRainbowDance(() => { this.state.ui.requestRender(); @@ -2653,6 +2657,14 @@ export class KimiTUI { this.state.ui.requestRender(true); } + applyLanguage(language: Language): void { + setLanguage(language); + this.setAppState({ language }); + // Re-render chrome and transcript so translated labels appear immediately. + this.state.transcriptContainer.invalidate(); + this.state.ui.requestRender(true); + } + refreshTerminalThemeTracking(): void { this.stopTerminalThemeTracking(); if (!isBuiltInTheme(this.state.appState.theme) || this.state.appState.theme !== 'auto') return; diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d895b11e12..597c5f385b 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -10,6 +10,7 @@ import type { } from '@moonshot-ai/kimi-code-sdk'; import type { NotificationsConfig, UpgradePreferences } from './config'; +import type { Language } from './i18n'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -46,6 +47,7 @@ export interface AppState { streamingPhase: 'idle' | 'waiting' | 'thinking' | 'composing' | 'shell'; streamingStartTime: number; theme: ThemeName; + language: Language; version: string; editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ diff --git a/apps/kimi-code/test/cli/update/preflight.test.ts b/apps/kimi-code/test/cli/update/preflight.test.ts index 2f7439f51b..b01092d509 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: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/activity-pane.test.ts b/apps/kimi-code/test/tui/activity-pane.test.ts index 3d8b2ed382..f0641c43be 100644 --- a/apps/kimi-code/test/tui/activity-pane.test.ts +++ b/apps/kimi-code/test/tui/activity-pane.test.ts @@ -31,6 +31,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index 77f582a1b9..751c58b703 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -131,6 +131,7 @@ function makeHost({ const state = { appState: { theme: 'dark', + language: 'en', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -166,6 +167,9 @@ function makeHost({ applyTheme: vi.fn((theme: string) => { state.appState.theme = theme; }), + applyLanguage: vi.fn((language: string) => { + state.appState.language = language; + }), refreshTerminalThemeTracking: vi.fn(), refreshSlashCommandAutocomplete: vi.fn(), reloadCurrentSessionView: vi.fn(async () => {}), 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 2fe6f3e52e..c6a462c1df 100644 --- a/apps/kimi-code/test/tui/components/chrome/footer.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/footer.test.ts @@ -52,6 +52,7 @@ const appState: AppState = { inputMode: 'prompt', swarmMode: false, theme: 'dark', + language: 'en', 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 bc1b754fb6..e858b721d8 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -29,6 +29,7 @@ const appState: AppState = { inputMode: 'prompt', swarmMode: false, theme: 'dark', + language: 'en', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts index 101cd19116..fe03b6e589 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-bg-agents.test.ts @@ -25,6 +25,7 @@ function baseState(overrides: Partial = {}): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: 'dark', + language: 'en', version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index 100f5f6a3b..caa65adc12 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-context.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts @@ -35,6 +35,7 @@ function baseState(overrides: Partial = {}): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: 'dark', + language: 'en', version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts index 982be06579..ae34aa0541 100644 --- a/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts +++ b/apps/kimi-code/test/tui/components/panels/footer-goal-badge.test.ts @@ -26,6 +26,7 @@ function baseState(overrides: Partial = {}): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: 'dark', + language: 'en', version: 'test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index c4c5035cdb..318fe1ceb8 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -34,6 +34,7 @@ describe('TUI config', () => { const text = readFileSync(filePath, 'utf-8'); expect(text).toContain('Client preferences for kimi-code.'); expect(text).toContain('theme = "auto"'); + expect(text).toContain('language = "en"'); expect(text).toContain('command = ""'); expect(text).toContain('[upgrade]'); expect(text).toContain('auto_install = true'); @@ -59,6 +60,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', + language: 'en', disablePasteBurst: false, editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, @@ -83,6 +85,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, @@ -115,6 +118,7 @@ command = " " await saveTuiConfig( { theme: 'light', + language: 'en', disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, @@ -125,6 +129,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + language: 'en', disablePasteBurst: false, editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, @@ -137,6 +142,7 @@ command = " " await saveTuiConfig( { theme, + language: DEFAULT_TUI_CONFIG.language, disablePasteBurst: DEFAULT_TUI_CONFIG.disablePasteBurst, editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf0702..0fc9547dfc 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -23,6 +23,7 @@ function fakeInitialAppState(): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: 'dark', + language: 'en', version: '0.0.0-test', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/i18n.test.ts b/apps/kimi-code/test/tui/i18n.test.ts new file mode 100644 index 0000000000..e02bf4c898 --- /dev/null +++ b/apps/kimi-code/test/tui/i18n.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { getLanguage, setLanguage, t } from '#/tui/i18n'; + +describe('i18n', () => { + it('defaults to English', () => { + expect(getLanguage()).toBe('en'); + }); + + it('returns English strings by default', () => { + expect(t('settings.title')).toBe('Settings'); + expect(t('welcome.title')).toBe('Welcome to Kimi Code!'); + }); + + it('switches to Chinese', () => { + setLanguage('zh'); + try { + expect(getLanguage()).toBe('zh'); + expect(t('settings.title')).toBe('设置'); + expect(t('welcome.title')).toBe('欢迎使用 Kimi Code!'); + } finally { + setLanguage('en'); + } + }); + + it('interpolates variables', () => { + expect(t('footer.context', { pct: '42' })).toBe('context: 42%'); + setLanguage('zh'); + try { + expect(t('footer.context', { pct: '42' })).toBe('上下文:42%'); + } finally { + setLanguage('en'); + } + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 87c0735501..6e87e3fc6c 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -140,6 +140,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 79f8cfb552..a3d2710c4a 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -91,6 +91,7 @@ function makeStartupInput( }, tuiConfig: { theme: 'dark', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index 20d825ce04..bd4fc8b7dd 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -61,6 +61,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, diff --git a/apps/kimi-code/test/tui/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 91dc5beacf..c9e44dea00 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -27,6 +27,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + language: 'en', disablePasteBurst: false, editorCommand: null, notifications: { enabled: true, condition: 'unfocused' },