From 399a4f3ab4e3be89f0c748fcd22da9abc076e013 Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 02:46:03 +0800 Subject: [PATCH 1/8] feat(tui): add i18n layer with language config and Chinese footer Introduce a zero-dependency I18n singleton (lookup, en fallback, param interpolation, runtime setLocale) modeled on the theme singleton, with per-module language packs under locales/en and locales/zh-CN. Add a language setting to tui.toml (auto | en | zh-CN), resolve auto from the system locale at startup, and migrate the footer status labels to translated strings. --- .changeset/tui-i18n-language-footer.md | 5 ++ apps/kimi-code/package.json | 1 + apps/kimi-code/src/cli/run-shell.ts | 6 ++ apps/kimi-code/src/tui/commands/config.ts | 5 +- .../src/tui/components/chrome/footer.ts | 22 +++-- apps/kimi-code/src/tui/config.ts | 14 +++ apps/kimi-code/src/tui/i18n/i18n.ts | 85 +++++++++++++++++++ apps/kimi-code/src/tui/i18n/index.ts | 8 ++ .../src/tui/i18n/locales/en/components.ts | 20 +++++ .../src/tui/i18n/locales/en/index.ts | 14 +++ apps/kimi-code/src/tui/i18n/locales/index.ts | 13 +++ .../src/tui/i18n/locales/zh-CN/components.ts | 18 ++++ .../src/tui/i18n/locales/zh-CN/index.ts | 14 +++ apps/kimi-code/src/tui/i18n/resolve.ts | 42 +++++++++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/types.ts | 4 +- .../test/cli/update/preflight.test.ts | 1 + apps/kimi-code/test/tui/activity-pane.test.ts | 1 + .../tui/commands/update-preferences.test.ts | 2 + .../test/tui/components/chrome/footer.test.ts | 1 + .../tui/components/chrome/welcome.test.ts | 1 + .../components/panels/footer-context.test.ts | 34 +++++++- apps/kimi-code/test/tui/config.test.ts | 22 +++++ .../test/tui/create-tui-state.test.ts | 1 + apps/kimi-code/test/tui/i18n.test.ts | 85 +++++++++++++++++++ .../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 + 29 files changed, 414 insertions(+), 10 deletions(-) create mode 100644 .changeset/tui-i18n-language-footer.md create mode 100644 apps/kimi-code/src/tui/i18n/i18n.ts create mode 100644 apps/kimi-code/src/tui/i18n/index.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/en/components.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/en/index.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/index.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts create mode 100644 apps/kimi-code/src/tui/i18n/resolve.ts create mode 100644 apps/kimi-code/test/tui/i18n.test.ts 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/apps/kimi-code/package.json b/apps/kimi-code/package.json index e6654f7a3..7595e04e0 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -34,6 +34,7 @@ "type": "module", "imports": { "#/tui/theme": "./src/tui/theme/index.ts", + "#/tui/i18n": "./src/tui/i18n/index.ts", "#/*": [ "./src/*.ts", "./src/*/index.ts", diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index e5bdfef24..51d6287df 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'; @@ -50,6 +51,11 @@ export async function runShell( 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)); + 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..42853a1ff 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -274,6 +274,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 +425,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, @@ -546,7 +548,7 @@ 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 +569,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, 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/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/components.ts b/apps/kimi-code/src/tui/i18n/locales/en/components.ts new file mode 100644 index 000000000..89f2dd346 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/components.ts @@ -0,0 +1,20 @@ +/** + * 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 = { + footer: { + context: 'context', + thinking: 'thinking', + taskRunning: '{count} task running', + tasksRunning: '{count} tasks running', + agentRunning: '{count} agent running', + agentsRunning: '{count} agents running', + }, +}; 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..b1f6a2053 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/index.ts @@ -0,0 +1,14 @@ +/** + * English language pack. + * + * Merges the per-module namespace files into a single message tree for the + * `en` locale. + */ + +import type { MessageTree } from '../../i18n'; + +import { components } from './components'; + +export const en: MessageTree = { + components, +}; 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/components.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts new file mode 100644 index 000000000..f901cfb02 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts @@ -0,0 +1,18 @@ +/** + * 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 = { + footer: { + context: '上下文', + thinking: '思考中', + taskRunning: '{count} 个后台任务运行中', + tasksRunning: '{count} 个后台任务运行中', + agentRunning: '{count} 个后台子代理运行中', + agentsRunning: '{count} 个后台子代理运行中', + }, +}; 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..16d8e1813 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts @@ -0,0 +1,14 @@ +/** + * 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 { components } from './components'; + +export const zhCN: MessageTree = { + components, +}; 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 330f9c7f1..db308d6f8 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -182,6 +182,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, 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/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/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..682824f24 100644 --- a/apps/kimi-code/test/tui/components/chrome/welcome.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/welcome.test.ts @@ -27,6 +27,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/panels/footer-context.test.ts b/apps/kimi-code/test/tui/components/panels/footer-context.test.ts index fb1cfd5fe..03a6ab6f0 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 @@ -1,7 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, afterEach } from 'vitest'; import chalk from 'chalk'; import { FooterComponent, formatFooterGitBadge, buildWeightedTips } from '#/tui/components/chrome/footer'; +import { i18n } from '#/tui/i18n'; import { darkColors } from '#/tui/theme/colors'; import type { AppState } from '#/tui/types'; @@ -145,6 +146,37 @@ describe('FooterComponent — context NaN resilience', () => { }); }); +describe('FooterComponent — locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('renders footer status labels in English by default', () => { + const fc = new FooterComponent(baseState({ contextUsage: 0.5, thinking: true })); + fc.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); + + const out = strip(fc.render(120).join('')); + + expect(out).toContain('context: 50.0%'); + expect(out).toContain('thinking'); + expect(out).toContain('[1 task running]'); + }); + + it('renders footer status labels in Simplified Chinese when the locale is zh-CN', () => { + i18n.setLocale('zh-CN'); + + const fc = new FooterComponent(baseState({ contextUsage: 0.5, thinking: true })); + fc.setBackgroundCounts({ bashTasks: 1, agentTasks: 0 }); + + const out = strip(fc.render(120).join('')); + + expect(out).toContain('上下文: 50.0%'); + expect(out).toContain('思考中'); + expect(out).toContain('个后台任务运行中'); + expect(out).not.toContain('context:'); + }); +}); + describe('buildWeightedTips — weighted rotation', () => { it('repeats higher-priority tips more often (length = sum of weights)', () => { const seq = buildWeightedTips([ diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 21c4f4b6c..5f9487f7f 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -42,9 +42,14 @@ describe('TUI config', () => { expect(text).toContain('notification_condition = "unfocused"'); }); + it('defaults language to auto', () => { + expect(DEFAULT_TUI_CONFIG.language).toBe('auto'); + }); + it('parses valid TOML', () => { const config = parseTuiConfig(` theme = "light" +language = "zh-CN" [editor] command = "code --wait" @@ -59,12 +64,25 @@ auto_install = false expect(config).toEqual({ theme: 'light', + language: 'zh-CN', editorCommand: 'code --wait', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, }); }); + it('defaults language to auto when the field is absent', () => { + const config = parseTuiConfig(`theme = "dark"`); + + expect(config.language).toBe('auto'); + }); + + it('round-trips language through save and reload without loss', async () => { + await saveTuiConfig({ ...DEFAULT_TUI_CONFIG, language: 'zh-CN' }, filePath); + + expect((await loadTuiConfig(filePath)).language).toBe('zh-CN'); + }); + it('normalizes an empty editor command to auto-detect', () => { const config = parseTuiConfig(` [editor] @@ -73,6 +91,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, @@ -104,6 +123,7 @@ command = " " await saveTuiConfig( { theme: 'light', + language: 'en', editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -113,6 +133,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + language: 'en', editorCommand: 'vim', notifications: { enabled: false, condition: 'always' }, upgrade: { autoInstall: false }, @@ -124,6 +145,7 @@ command = " " await saveTuiConfig( { theme, + language: 'auto', editorCommand: null, notifications: DEFAULT_TUI_CONFIG.notifications, upgrade: DEFAULT_TUI_CONFIG.upgrade, 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 8be57e91c..b969da53b 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -21,6 +21,7 @@ function fakeInitialAppState(): AppState { streamingPhase: 'idle', streamingStartTime: 0, theme: 'dark', + language: 'auto', 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 000000000..dca34eadc --- /dev/null +++ b/apps/kimi-code/test/tui/i18n.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from 'vitest'; + +import { I18n, resolveLocale } from '#/tui/i18n'; + +const messages = { + en: { + components: { + footer: { context: 'context', greeting: 'Hi {name}', thinking: 'thinking' }, + }, + }, + 'zh-CN': { + components: { + footer: { context: '上下文', greeting: '你好 {name}' }, + }, + }, +}; + +describe('I18n', () => { + it('looks up a key in the active locale', () => { + const i18n = new I18n(messages, 'en'); + + expect(i18n.t('components.footer.context')).toBe('context'); + }); + + it('resolves the active locale over the fallback', () => { + const i18n = new I18n(messages, 'zh-CN'); + + expect(i18n.t('components.footer.context')).toBe('上下文'); + }); + + it('falls back to the default locale when a key is missing in the active locale', () => { + const i18n = new I18n(messages, 'zh-CN'); + + // `thinking` only exists in the `en` pack. + expect(i18n.t('components.footer.thinking')).toBe('thinking'); + }); + + it('returns the raw key when it is missing in every locale', () => { + const i18n = new I18n(messages, 'en'); + + expect(i18n.t('components.footer.nope')).toBe('components.footer.nope'); + }); + + it('interpolates named params', () => { + const i18n = new I18n(messages, 'zh-CN'); + + expect(i18n.t('components.footer.greeting', { name: 'Kimi' })).toBe('你好 Kimi'); + }); + + it('switches the active locale at runtime via setLocale', () => { + const i18n = new I18n(messages, 'en'); + expect(i18n.t('components.footer.context')).toBe('context'); + + i18n.setLocale('zh-CN'); + + expect(i18n.t('components.footer.context')).toBe('上下文'); + }); +}); + +describe('resolveLocale', () => { + it('passes through an explicit locale preference without consulting the env', () => { + expect(resolveLocale('en', { LANG: 'zh_CN.UTF-8' })).toBe('en'); + expect(resolveLocale('zh-CN', { LANG: 'en_US.UTF-8' })).toBe('zh-CN'); + }); + + it('resolves auto to zh-CN for Chinese locales and their variants', () => { + expect(resolveLocale('auto', { LANG: 'zh_CN.UTF-8' })).toBe('zh-CN'); + expect(resolveLocale('auto', { LANG: 'zh_CN' })).toBe('zh-CN'); + expect(resolveLocale('auto', { LANG: 'zh-CN' })).toBe('zh-CN'); + }); + + it('resolves auto to en for English, C, and unknown locales', () => { + expect(resolveLocale('auto', { LANG: 'en_US.UTF-8' })).toBe('en'); + expect(resolveLocale('auto', { LANG: 'C.UTF-8' })).toBe('en'); + expect(resolveLocale('auto', { LANG: 'POSIX' })).toBe('en'); + expect(resolveLocale('auto', { LANG: 'fr_FR.UTF-8' })).toBe('en'); + expect(resolveLocale('auto', {})).toBe('en'); + }); + + it('honours LC_ALL over LC_MESSAGES over LANG', () => { + expect(resolveLocale('auto', { LC_ALL: 'zh_CN.UTF-8', LANG: 'en_US.UTF-8' })).toBe('zh-CN'); + expect(resolveLocale('auto', { LC_MESSAGES: 'zh_CN.UTF-8', LANG: 'en_US.UTF-8' })).toBe('zh-CN'); + expect(resolveLocale('auto', { LC_ALL: 'en_US.UTF-8', LC_MESSAGES: 'zh_CN.UTF-8' })).toBe('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 da8df93ce..23e8369e7 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 @@ -102,6 +102,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/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 8bc6f6fa5..e6d553883 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -87,6 +87,7 @@ function makeStartupInput( }, tuiConfig: { theme: 'dark', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b..e7a37a36e 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -52,6 +52,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/signal-handlers.test.ts b/apps/kimi-code/test/tui/signal-handlers.test.ts index 9d630a26d..898cbfc2e 100644 --- a/apps/kimi-code/test/tui/signal-handlers.test.ts +++ b/apps/kimi-code/test/tui/signal-handlers.test.ts @@ -25,6 +25,7 @@ function makeStartupInput(): KimiTUIStartupInput { }, tuiConfig: { theme: 'dark', + language: 'auto', editorCommand: null, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: true }, From 55a47e4dd0e6421cc6125d5001bc1d8d6989428f Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 07:11:15 +0800 Subject: [PATCH 2/8] feat(tui): add language selector to /settings with runtime switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the interactive language-switching path on top of the i18n skeleton (#1). A LanguageSelectorComponent (ChoicePicker subclass) offers Auto / English / 简体中文; /settings routes a new "Language" entry to it. Selecting a language persists to tui.toml, updates app state, flips the live i18n locale, and repaints so the UI switches language immediately without a restart. /reload re-reads the language and re-applies it via setAppState + i18n.setLocale alongside theme. Closes #2 Co-Authored-By: Claude Opus 4.8 --- .changeset/tui-i18n-language-selector.md | 5 ++ apps/kimi-code/src/tui/commands/config.ts | 63 ++++++++++++++++++ apps/kimi-code/src/tui/commands/reload.ts | 6 ++ .../components/dialogs/language-selector.ts | 31 +++++++++ .../components/dialogs/settings-selector.ts | 7 ++ .../test/tui/commands/language.test.ts | 66 +++++++++++++++++++ .../test/tui/commands/reload.test.ts | 15 +++++ .../dialogs/language-selector.test.ts | 39 +++++++++++ .../dialogs/settings-selector.test.ts | 40 +++++++++++ 9 files changed, 272 insertions(+) create mode 100644 .changeset/tui-i18n-language-selector.md create mode 100644 apps/kimi-code/src/tui/components/dialogs/language-selector.ts create mode 100644 apps/kimi-code/test/tui/commands/language.test.ts create mode 100644 apps/kimi-code/test/tui/components/dialogs/language-selector.test.ts create mode 100644 apps/kimi-code/test/tui/components/dialogs/settings-selector.test.ts 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/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index 42853a1ff..f3f2b7340 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'; @@ -448,6 +451,52 @@ 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)); + 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({ @@ -544,6 +593,19 @@ 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']; +}; + type UpdatePreferenceHost = { readonly state: { readonly appState: Pick< @@ -624,6 +686,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/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index a8700d95e..0d01e8374 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'; @@ -43,10 +44,15 @@ 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)); host.setAppState({ editorCommand: config.editorCommand, notifications: config.notifications, upgrade: config.upgrade, + language: config.language, }); } 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/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/test/tui/commands/language.test.ts b/apps/kimi-code/test/tui/commands/language.test.ts new file mode 100644 index 000000000..38ce4b38b --- /dev/null +++ b/apps/kimi-code/test/tui/commands/language.test.ts @@ -0,0 +1,66 @@ +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 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, + }; + return { host, requestRender, setAppState, showStatus, track }; +} + +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 } = 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(); + + setLocale.mockRestore(); + }); +}); diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index ab54a221b..7d9b8b524 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,19 @@ 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'); + + setLocale.mockRestore(); + }); + it('awaits the async theme application before refreshing terminal tracking', async () => { await writeTuiConfig('theme = "auto"\n'); const host = makeHost(); @@ -129,6 +143,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/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/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')); +} From a2794cadb94c91a31c32efcc313e201214cd174d Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 07:45:47 +0800 Subject: [PATCH 3/8] feat(tui): translate reverse-rpc approval & question panels (#3) Migrate the user-visible English strings in the reverse-RPC approval and question panels to the i18n layer, with en + zh-CN translations under a new `reverseRpc` namespace. - Approval panel headers, choice buttons, danger labels, cwd/scope/more-lines metadata, and key hints now resolve via `i18n.t('reverseRpc.approval.*')`. - Choice/danger labels are localized in the approval adapter at adapt time; `selected_label` stays a stable English identifier for the upstream contract. - Question dialog heading, review/submit prompts, Other/Not-answered labels, submit actions, and hints resolve via `i18n.t('reverseRpc.question.*')`. - Approval preview title and footer hints resolve via `i18n.t('reverseRpc.preview.*')`. - Strings stay colored via `currentTheme`; no chalk named colors, and no coloring / printableChar() / keyboard behavior changes. Locale-render tests assert the panels render in Simplified Chinese under `zh-CN` and English under `en`. Resolves #3. Co-Authored-By: Claude Opus 4.8 --- .changeset/tui-i18n-reverse-rpc-panels.md | 5 + .../tui/components/dialogs/approval-panel.ts | 40 ++++---- .../components/dialogs/approval-preview.ts | 11 ++- .../tui/components/dialogs/question-dialog.ts | 98 ++++++++++++------- .../src/tui/i18n/locales/en/index.ts | 2 + .../src/tui/i18n/locales/en/reverse-rpc.ts | 86 ++++++++++++++++ .../src/tui/i18n/locales/zh-CN/index.ts | 2 + .../src/tui/i18n/locales/zh-CN/reverse-rpc.ts | 85 ++++++++++++++++ .../src/tui/reverse-rpc/approval/adapter.ts | 81 +++++++++------ .../components/dialogs/approval-panel.test.ts | 44 ++++++++- .../dialogs/approval-preview.test.ts | 37 ++++++- .../dialogs/question-dialog.test.ts | 48 ++++++++- .../tui/reverse-rpc/approval-adapter.test.ts | 47 ++++++++- 13 files changed, 499 insertions(+), 87 deletions(-) create mode 100644 .changeset/tui-i18n-reverse-rpc-panels.md create mode 100644 apps/kimi-code/src/tui/i18n/locales/en/reverse-rpc.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/zh-CN/reverse-rpc.ts 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/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/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/i18n/locales/en/index.ts b/apps/kimi-code/src/tui/i18n/locales/en/index.ts index b1f6a2053..2ca4be03c 100644 --- a/apps/kimi-code/src/tui/i18n/locales/en/index.ts +++ b/apps/kimi-code/src/tui/i18n/locales/en/index.ts @@ -8,7 +8,9 @@ import type { MessageTree } from '../../i18n'; import { components } from './components'; +import { reverseRpc } from './reverse-rpc'; export const en: MessageTree = { 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/zh-CN/index.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts index 16d8e1813..50cf71f07 100644 --- a/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts @@ -8,7 +8,9 @@ import type { MessageTree } from '../../i18n'; import { components } from './components'; +import { reverseRpc } from './reverse-rpc'; export const zhCN: MessageTree = { 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/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/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/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/reverse-rpc/approval-adapter.test.ts b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts index 997230fec..eaf8c1841 100644 --- a/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts +++ b/apps/kimi-code/test/tui/reverse-rpc/approval-adapter.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { adaptApprovalRequest, adaptPanelResponse } from '#/tui/reverse-rpc/approval/adapter'; +import { i18n } from '#/tui/i18n'; describe('approval adapter', () => { it('adapts generic command displays into shell blocks with approval choices', () => { @@ -211,6 +212,50 @@ describe('approval adapter', () => { ]); }); + describe('locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('localizes default choice labels and danger labels under zh-CN', () => { + i18n.setLocale('zh-CN'); + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-zh', + toolName: 'Bash', + action: 'run', + display: { + kind: 'generic', + summary: 'run', + detail: { command: 'sudo rm -rf /tmp/cache' }, + }, + }); + + expect(adapted.choices.map((choice) => choice.label)).toEqual([ + '批准一次', + '本次会话内批准', + '拒绝', + '拒绝并反馈', + ]); + expect(adapted.display).toMatchObject([{ danger: '递归删除' }]); + }); + + it('localizes plan-review labels while keeping selected_label stable', () => { + i18n.setLocale('zh-CN'); + const adapted = adaptApprovalRequest({ + toolCallId: 'tc-plan-zh', + toolName: 'ExitPlanMode', + action: 'Review plan', + display: { kind: 'plan_review', plan: '# Plan', path: '/tmp/p.md' }, + }); + + expect(adapted.choices).toEqual([ + { label: '批准', response: 'approved', selected_label: 'Approve' }, + { label: '拒绝', response: 'rejected', selected_label: 'Reject' }, + { label: '修订', response: 'rejected', selected_label: 'Revise', requires_feedback: true }, + ]); + }); + }); + it('maps approved-for-session responses into core approval payloads', () => { expect( adaptPanelResponse({ From 7bd87e2a71ba524cad2eb1548f13722b0b53d883 Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 22:40:51 +0800 Subject: [PATCH 4/8] feat(tui): translate slash-command descriptions & /help panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate builtin slash-command descriptions, the /help panel chrome (title, dismiss hint, greeting, section headers, keyboard-shortcut descriptions, scroll tail), and goal/swarm argument-completion descriptions to the i18n layer, with en + zh-CN translations under a new `commands` namespace. Command names, aliases, and subcommand values stay untranslated — only human-readable descriptions follow the active locale. Skill command descriptions remain owned by the skill; only framework builtins are localized via `localizedBuiltinSlashCommands()`. Closes #4 Co-Authored-By: Claude Opus 4.8 --- apps/kimi-code/src/tui/commands/registry.ts | 61 +++++++++---- .../src/tui/components/dialogs/help-panel.ts | 52 +++++++---- .../src/tui/i18n/locales/en/commands.ts | 87 +++++++++++++++++++ .../src/tui/i18n/locales/en/index.ts | 2 + .../src/tui/i18n/locales/zh-CN/commands.ts | 83 ++++++++++++++++++ .../src/tui/i18n/locales/zh-CN/index.ts | 2 + apps/kimi-code/src/tui/kimi-tui.ts | 4 +- .../test/tui/commands/registry.test.ts | 72 ++++++++++++++- .../tui/components/panels/help-panel.test.ts | 64 +++++++++++++- 9 files changed, 385 insertions(+), 42 deletions(-) create mode 100644 apps/kimi-code/src/tui/i18n/locales/en/commands.ts create mode 100644 apps/kimi-code/src/tui/i18n/locales/zh-CN/commands.ts diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index 464cc770d..3ea588b21 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 = [ @@ -302,6 +308,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/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/i18n/locales/en/commands.ts b/apps/kimi-code/src/tui/i18n/locales/en/commands.ts new file mode 100644 index 000000000..034b287ae --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/en/commands.ts @@ -0,0 +1,87 @@ +/** + * 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', + 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/index.ts b/apps/kimi-code/src/tui/i18n/locales/en/index.ts index 2ca4be03c..d4bd0cb13 100644 --- a/apps/kimi-code/src/tui/i18n/locales/en/index.ts +++ b/apps/kimi-code/src/tui/i18n/locales/en/index.ts @@ -7,10 +7,12 @@ import type { MessageTree } from '../../i18n'; +import { commands } from './commands'; import { components } from './components'; import { reverseRpc } from './reverse-rpc'; export const en: MessageTree = { + commands, components, reverseRpc, }; 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..1e866d507 --- /dev/null +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/commands.ts @@ -0,0 +1,83 @@ +/** + * 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 压缩包', + 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/index.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts index 50cf71f07..ec1d4d56d 100644 --- a/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/index.ts @@ -7,10 +7,12 @@ import type { MessageTree } from '../../i18n'; +import { commands } from './commands'; import { components } from './components'; import { reverseRpc } from './reverse-rpc'; export const zhCN: MessageTree = { + commands, components, reverseRpc, }; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index db308d6f8..8ce6dd087 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, @@ -312,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/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/components/panels/help-panel.test.ts b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts index 52f94430b..449a263fe 100644 --- a/apps/kimi-code/test/tui/components/panels/help-panel.test.ts +++ b/apps/kimi-code/test/tui/components/panels/help-panel.test.ts @@ -1,7 +1,8 @@ -import { describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import type { KimiSlashCommand } from '#/tui/commands/index'; import { HelpPanelComponent } from '#/tui/components/dialogs/help-panel'; +import { i18n } from '#/tui/i18n'; function cmd(name: string, description: string, aliases: string[] = []): KimiSlashCommand { return { @@ -100,4 +101,65 @@ describe('HelpPanelComponent', () => { const out2 = strip(panel.render(80).join('\n')); expect(out2).toMatch(/showing 2-7 of/); }); + + describe('locale rendering', () => { + afterEach(() => { + i18n.setLocale('en'); + }); + + it('renders section headers and greeting in English by default', () => { + const panel = new HelpPanelComponent({ + commands: [cmd('exit', 'Exit', ['quit', 'q'])], + onClose: () => {}, + }); + const out = strip(panel.render(80).join('\n')); + expect(out).toContain('Keyboard shortcuts'); + expect(out).toContain('Slash commands'); + expect(out).toMatch(/ready to help/i); + }); + + it('renders section headers and greeting in Simplified Chinese under zh-CN', () => { + i18n.setLocale('zh-CN'); + const panel = new HelpPanelComponent({ + commands: [cmd('exit', 'Exit', ['quit', 'q'])], + onClose: () => {}, + }); + const out = strip(panel.render(80).join('\n')); + expect(out).toContain('键盘快捷键'); + expect(out).toContain('斜杠命令'); + expect(out).not.toContain('Keyboard shortcuts'); + expect(out).not.toContain('Slash commands'); + }); + + it('translates default keyboard-shortcut descriptions under zh-CN while keeping the keys', () => { + i18n.setLocale('zh-CN'); + const panel = new HelpPanelComponent({ + commands: [], + onClose: () => {}, + }); + const out = strip(panel.render(80).join('\n')); + // Keys (identifiers) stay verbatim … + expect(out).toContain('Shift-Tab'); + expect(out).toContain('Ctrl-G'); + // … but their descriptions are localized. + expect(out).toContain('切换计划模式'); + expect(out).not.toContain('Toggle plan mode'); + }); + + it('translates the dismiss hint and scroll tail under zh-CN', () => { + i18n.setLocale('zh-CN'); + const many = Array.from({ length: 30 }, (_, i) => cmd(`cmd${String(i)}`, 'd')); + const panel = new HelpPanelComponent({ + commands: many, + onClose: () => {}, + maxVisible: 6, + }); + const out = strip(panel.render(80).join('\n')); + expect(out).toContain('滚动'); + expect(out).not.toMatch(/to cancel/); + // Scroll tail keeps its numbers but localizes the surrounding words. + expect(out).toMatch(/显示第 1-6 项,共 \d+ 项/); + expect(out).not.toMatch(/showing 1-6 of/); + }); + }); }); From f5a5dbf83a422ef5301c9de661ba45f85075678c Mon Sep 17 00:00:00 2001 From: Yuming Chen Date: Wed, 17 Jun 2026 23:18:31 +0800 Subject: [PATCH 5/8] feat(tui): translate usage/status panels & chrome labels (#5) Migrate the `/usage` and `/status` panel labels to the i18n layer with `en` + `zh-CN` translations, extending the `components` namespace with `usage` and `status`. - usage-panel: session/context/plan section labels, input/output/total words, "% used", and empty-state strings now come from `i18n.t('components.usage.*')`. - status-panel: field labels (Model, Directory, Permissions, Plan mode, Session, Title, Warning) and rendered values (on/off, not set, none, thinking) come from `i18n.t('components.status.*')`. - Panel chrome titles (' Usage ' / ' Status ') are localized; model names and permission-mode identifiers stay untranslated. - Add a `padEndToWidth` helper so the aligned label columns pad by display width, keeping the panels aligned under double-width Chinese characters. Locale-render assertions verify both panels render Chinese under `zh-CN` and English under `en`, plus alignment/width under `zh-CN`. Co-Authored-By: Claude Opus 4.8 --- apps/kimi-code/src/tui/commands/info.ts | 7 +- .../tui/components/messages/field-column.ts | 17 ++++ .../tui/components/messages/status-panel.ts | 48 +++++++---- .../tui/components/messages/usage-panel.ts | 50 ++++++----- .../src/tui/i18n/locales/en/components.ts | 32 ++++++++ .../src/tui/i18n/locales/zh-CN/components.ts | 32 ++++++++ .../components/messages/status-panel.test.ts | 82 ++++++++++++++++++- .../components/messages/usage-panel.test.ts | 62 ++++++++++++++ 8 files changed, 289 insertions(+), 41 deletions(-) create mode 100644 apps/kimi-code/src/tui/components/messages/field-column.ts 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/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/i18n/locales/en/components.ts b/apps/kimi-code/src/tui/i18n/locales/en/components.ts index 89f2dd346..e386c7d3c 100644 --- a/apps/kimi-code/src/tui/i18n/locales/en/components.ts +++ b/apps/kimi-code/src/tui/i18n/locales/en/components.ts @@ -17,4 +17,36 @@ export const components: MessageTree = { 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/zh-CN/components.ts b/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts index f901cfb02..5c7e676a9 100644 --- a/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts +++ b/apps/kimi-code/src/tui/i18n/locales/zh-CN/components.ts @@ -15,4 +15,36 @@ export const components: MessageTree = { 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/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 "