diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 5509cc743..dc451ea40 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -9,4 +9,5 @@ export default { mcp: 'MCP', 'token-caching': 'Token Caching', sandbox: 'Sandboxing', + language: 'i18n', }; diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 716fc3926..333394631 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -48,7 +48,7 @@ Commands specifically for controlling interface and output language. | → `ui [language]` | Set UI interface language | `/language ui zh-CN` | | → `output [language]` | Set LLM output language | `/language output Chinese` | -- Available UI languages: `zh-CN` (Simplified Chinese), `en-US` (English) +- Available built-in UI languages: `zh-CN` (Simplified Chinese), `en-US` (English), `ru-RU` (Russian), `de-DE` (German) - Output language examples: `Chinese`, `English`, `Japanese`, etc. ### 1.4 Tool and Model Management @@ -72,17 +72,16 @@ Commands for managing AI tools and models. Commands for obtaining information and performing system settings. -| Command | Description | Usage Examples | -| --------------- | ----------------------------------------------- | ------------------------------------------------ | -| `/help` | Display help information for available commands | `/help` or `/?` | -| `/about` | Display version information | `/about` | -| `/stats` | Display detailed statistics for current session | `/stats` | -| `/settings` | Open settings editor | `/settings` | -| `/auth` | Change authentication method | `/auth` | -| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | -| `/copy` | Copy last output content to clipboard | `/copy` | -| `/quit-confirm` | Show confirmation dialog before quitting | `/quit-confirm` (shortcut: press `Ctrl+C` twice) | -| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | +| Command | Description | Usage Examples | +| ----------- | ----------------------------------------------- | -------------------------------- | +| `/help` | Display help information for available commands | `/help` or `/?` | +| `/about` | Display version information | `/about` | +| `/stats` | Display detailed statistics for current session | `/stats` | +| `/settings` | Open settings editor | `/settings` | +| `/auth` | Change authentication method | `/auth` | +| `/bug` | Submit issue about Qwen Code | `/bug Button click unresponsive` | +| `/copy` | Copy last output content to clipboard | `/copy` | +| `/quit` | Exit Qwen Code immediately | `/quit` or `/exit` | ### 1.6 Common Shortcuts diff --git a/docs/users/features/language.md b/docs/users/features/language.md index da6c9b330..e5067a319 100644 --- a/docs/users/features/language.md +++ b/docs/users/features/language.md @@ -1,18 +1,21 @@ -# Language Settings +# Internationalization (i18n) & Language -Qwen Code supports multiple languages for both the user interface and LLM responses. +Qwen Code is built for multilingual workflows: it supports UI localization (i18n/l10n) in the CLI, lets you choose the assistant output language, and allows custom UI language packs. ## Overview -Two separate language settings control different aspects of Qwen Code: +From a user point of view, Qwen Code’s “internationalization” spans multiple layers: -| Setting | What it controls | Where stored | -| ------------------ | -------------------------------------------------- | ---------------------------- | -| `/language ui` | Terminal UI text (menus, system messages, prompts) | `~/.qwen/settings.json` | -| `/language output` | Language the AI responds in | `~/.qwen/output-language.md` | +| Capability / Setting | What it controls | Where stored | +| ------------------------ | ---------------------------------------------------------------------- | ---------------------------- | +| `/language ui` | Terminal UI text (menus, system messages, prompts) | `~/.qwen/settings.json` | +| `/language output` | Language the AI responds in (an output preference, not UI translation) | `~/.qwen/output-language.md` | +| Custom UI language packs | Overrides/extends built-in UI translations | `~/.qwen/locales/*.js` | ## UI Language +This is the CLI’s UI localization layer (i18n/l10n): it controls the language of menus, prompts, and system messages. + ### Setting the UI Language Use the `/language ui` command: @@ -74,6 +77,10 @@ Use `/language output ` to change: Any language name works. The LLM will be instructed to respond in that language. +> [!note] +> +> After changing the output language, restart Qwen Code for the change to take effect. + ### File Location ``` @@ -94,7 +101,7 @@ Any language name works. The LLM will be instructed to respond in that language. export QWEN_CODE_LANG=zh ``` -This sets both the UI language detection and the LLM output language detection on first startup. +This influences auto-detection on first startup (if you haven’t set a UI language and no `output-language.md` file exists yet). ## Custom Language Packs @@ -105,6 +112,11 @@ For UI translations, you can create custom language packs in `~/.qwen/locales/`: User directory takes precedence over built-in translations. +> [!tip] +> +> Contributions are welcome! If you’d like to improve built-in translations or add new languages. +> For a concrete example, see [PR #1238: feat(i18n): add Russian language support](https://github.com/QwenLM/qwen-code/pull/1238). + ### Language Pack Format ```javascript diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index af3d24400..2bcc4223b 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Qwen + * Copyright 2025 Qwen team * SPDX-License-Identifier: Apache-2.0 */ @@ -8,8 +8,13 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { homedir } from 'node:os'; +import { + type SupportedLanguage, + getLanguageNameFromLocale, +} from './languages.js'; -export type SupportedLanguage = 'en' | 'zh' | 'ru' | string; // Allow custom language codes +export type { SupportedLanguage }; +export { getLanguageNameFromLocale }; // State let currentLanguage: SupportedLanguage = 'en'; @@ -46,14 +51,6 @@ const getLocalePath = ( return path.join(baseDir, `${lang}.js`); }; -// Supported locale codes mapped to English language names -const LOCALE_TO_LANGUAGE_NAME: Record = { - zh: 'Chinese', - en: 'English', - ru: 'Russian', - de: 'German', -}; - // Language detection export function detectSystemLanguage(): SupportedLanguage { const envLang = process.env['QWEN_CODE_LANG'] || process.env['LANG']; @@ -74,14 +71,6 @@ export function detectSystemLanguage(): SupportedLanguage { return 'en'; } -/** - * Maps a locale code to its English language name. - * Used for LLM output language instructions. - */ -export function getLanguageNameFromLocale(locale: SupportedLanguage): string { - return LOCALE_TO_LANGUAGE_NAME[locale] || 'English'; -} - // Translation loading async function loadTranslationsAsync( lang: SupportedLanguage, diff --git a/packages/cli/src/i18n/languages.ts b/packages/cli/src/i18n/languages.ts new file mode 100644 index 000000000..c0e57eefa --- /dev/null +++ b/packages/cli/src/i18n/languages.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +export type SupportedLanguage = 'en' | 'zh' | 'ru' | 'de' | string; + +export interface LanguageDefinition { + /** The internal locale code used by the i18n system (e.g., 'en', 'zh'). */ + code: SupportedLanguage; + /** The standard name used in UI settings (e.g., 'en-US', 'zh-CN'). */ + id: string; + /** The full English name of the language (e.g., 'English', 'Chinese'). */ + fullName: string; +} + +export const SUPPORTED_LANGUAGES: readonly LanguageDefinition[] = [ + { + code: 'en', + id: 'en-US', + fullName: 'English', + }, + { + code: 'zh', + id: 'zh-CN', + fullName: 'Chinese', + }, + { + code: 'ru', + id: 'ru-RU', + fullName: 'Russian', + }, + { + code: 'de', + id: 'de-DE', + fullName: 'German', + }, +]; + +/** + * Maps a locale code to its English language name. + * Used for LLM output language instructions. + */ +export function getLanguageNameFromLocale(locale: SupportedLanguage): string { + const lang = SUPPORTED_LANGUAGES.find((l) => l.code === locale); + return lang?.fullName || 'English'; +} diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 25f5e0a96..d9c1ba889 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -199,6 +199,39 @@ describe('languageCommand', () => { content: expect.stringContaining('Chinese'), }); }); + + it('should parse Unicode LLM output language from marker', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + [ + '# ⚠️ CRITICAL: 中文 Output Language Rule - HIGHEST PRIORITY ⚠️', + '', + '', + 'Some other content...', + ].join('\n'), + ); + + vi.mocked(i18n.t).mockImplementation( + (key: string, params?: Record) => { + if (params && key.includes('{{lang}}')) { + return key.replace('{{lang}}', params['lang'] || ''); + } + return key; + }, + ); + + if (!languageCommand.action) { + throw new Error('The language command must have an action.'); + } + + const result = await languageCommand.action(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('中文'), + }); + }); }); describe('main command action - config not available', () => { diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index f4fa1ceeb..14dc0945c 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Google LLC + * Copyright 2025 Qwen team * SPDX-License-Identifier: Apache-2.0 */ @@ -20,48 +20,67 @@ import { type SupportedLanguage, t, } from '../../i18n/index.js'; +import { + SUPPORTED_LANGUAGES, + type LanguageDefinition, +} from '../../i18n/languages.js'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { Storage } from '@qwen-code/qwen-code-core'; const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md'; +const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:'; + +function parseUiLanguageArg(input: string): SupportedLanguage | null { + const lowered = input.trim().toLowerCase(); + if (!lowered) return null; + for (const lang of SUPPORTED_LANGUAGES) { + if ( + lowered === lang.code || + lowered === lang.id.toLowerCase() || + lowered === lang.fullName.toLowerCase() + ) { + return lang.code; + } + } + return null; +} + +function formatUiLanguageDisplay(lang: SupportedLanguage): string { + const option = SUPPORTED_LANGUAGES.find((o) => o.code === lang); + return option ? `${option.fullName}(${option.id})` : lang; +} + +function sanitizeLanguageForMarker(language: string): string { + // HTML comments cannot contain "--" or end marker "-->" safely. + // Also avoid newlines to keep the marker single-line and robust to parsing. + return language + .replace(/[\r\n]/g, ' ') + .replace(/-->/g, '') + .replace(/--/g, ''); +} /** * Generates the LLM output language rule template based on the language name. */ function generateLlmOutputLanguageRule(language: string): string { - return `# ⚠️ CRITICAL: ${language} Output Language Rule - HIGHEST PRIORITY ⚠️ + const markerLanguage = sanitizeLanguageForMarker(language); + return `# Output language preference: ${language} + -## 🚨 MANDATORY RULE - NO EXCEPTIONS 🚨 +## Goal +Prefer responding in **${language}** for normal assistant messages and explanations. -**YOU MUST RESPOND IN ${language.toUpperCase()} FOR EVERY SINGLE OUTPUT, REGARDLESS OF THE USER'S INPUT LANGUAGE.** +## Keep technical artifacts unchanged +Do **not** translate or rewrite: +- Code blocks, CLI commands, file paths, stack traces, logs, JSON keys, identifiers +- Exact quoted text from the user (keep quotes verbatim) -This is a **NON-NEGOTIABLE** requirement. Even if the user writes in English, says "hi", asks a simple question, or explicitly requests another language, **YOU MUST ALWAYS RESPOND IN ${language.toUpperCase()}.** +## When a conflict exists +If higher-priority instructions (system/developer) require a different behavior, follow them. -## What Must Be in ${language} - -**EVERYTHING** you output: conversation replies, tool call descriptions, success/error messages, generated file content (comments, documentation), and all explanatory text. - -**Tool outputs**: All descriptive text from \`read_file\`, \`write_file\`, \`codebase_search\`, \`run_terminal_cmd\`, \`todo_write\`, \`web_search\`, etc. MUST be in ${language}. - -## Examples - -### ✅ CORRECT: -- User says "hi" → Respond in ${language} (e.g., "Bonjour" if ${language} is French) -- Tool result → "已成功读取文件 config.json" (if ${language} is Chinese) -- Error → "无法找到指定的文件" (if ${language} is Chinese) - -### ❌ WRONG: -- User says "hi" → "Hello" in English -- Tool result → "Successfully read file" in English -- Error → "File not found" in English - -## Notes - -- Code elements (variable/function names, syntax) can remain in English -- Comments, documentation, and all other text MUST be in ${language} - -**THIS RULE IS ACTIVE NOW. ALL OUTPUTS MUST BE IN ${language.toUpperCase()}. NO EXCEPTIONS.** +## Tool / system outputs +Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below. `; } @@ -92,6 +111,36 @@ function normalizeLanguageName(language: string): string { return language; } +function extractLlmOutputLanguageFromRuleFileContent( + content: string, +): string | null { + // Preferred: machine-readable marker that supports Unicode and spaces. + // Example: + const markerMatch = content.match( + new RegExp( + String.raw``, + 'i', + ), + ); + if (markerMatch?.[1]) { + const lang = markerMatch[1].trim(); + if (lang) return lang; + } + + // Backward compatibility: parse the heading line. + // Example: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY" + // Example: "# ⚠️ CRITICAL: 日本語 Output Language Rule - HIGHEST PRIORITY ⚠️" + const headingMatch = content.match( + /^#.*?CRITICAL:\s*(.*?)\s+Output Language Rule\b/im, + ); + if (headingMatch?.[1]) { + const lang = headingMatch[1].trim(); + if (lang) return lang; + } + + return null; +} + /** * Initializes the LLM output language rule file on first startup. * If the file already exists, it is not overwritten (respects user preference). @@ -127,12 +176,7 @@ function getCurrentLlmOutputLanguage(): string | null { if (fs.existsSync(filePath)) { try { const content = fs.readFileSync(filePath, 'utf-8'); - // Extract language name from the first line - // Template format: "# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY" - const match = content.match(/^#.*?(\w+)\s+Output Language Rule/i); - if (match) { - return match[1]; - } + return extractLlmOutputLanguageFromRuleFileContent(content); } catch { // Ignore errors } @@ -173,18 +217,11 @@ async function setUiLanguage( // Reload commands to update their descriptions with the new language context.ui.reloadCommands(); - // Map language codes to friendly display names - const langDisplayNames: Partial> = { - zh: '中文(zh-CN)', - en: 'English(en-US)', - ru: 'Русский (ru-RU)', - }; - return { type: 'message', messageType: 'info', content: t('UI language changed to {{lang}}', { - lang: langDisplayNames[lang] || lang, + lang: formatUiLanguageDisplay(lang), }), }; } @@ -243,16 +280,6 @@ export const languageCommand: SlashCommand = { context: CommandContext, args: string, ): Promise => { - const { services } = context; - - if (!services.config) { - return { - type: 'message', - messageType: 'error', - content: t('Configuration not available.'), - }; - } - const trimmedArgs = args.trim(); // If no arguments, show current language settings and usage @@ -260,13 +287,15 @@ export const languageCommand: SlashCommand = { const currentUiLang = getCurrentLanguage(); const currentLlmLang = getCurrentLlmOutputLanguage(); const message = [ - t('Current UI language: {{lang}}', { lang: currentUiLang }), + t('Current UI language: {{lang}}', { + lang: formatUiLanguageDisplay(currentUiLang as SupportedLanguage), + }), currentLlmLang ? t('Current LLM output language: {{lang}}', { lang: currentLlmLang }) : t('LLM output language not set'), '', t('Available subcommands:'), - ` /language ui [zh-CN|en-US|ru-RU|de-DE] - ${t('Set UI language')}`, + ` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`, ` /language output - ${t('Set LLM output language')}`, ].join('\n'); @@ -277,130 +306,21 @@ export const languageCommand: SlashCommand = { }; } - // Parse subcommand - const parts = trimmedArgs.split(/\s+/); - const subcommand = parts[0].toLowerCase(); - - if (subcommand === 'ui') { - // Handle /language ui [zh-CN|en-US|ru-RU|de-DE] - if (parts.length === 1) { - // Show UI language subcommand help - return { - type: 'message', - messageType: 'info', - content: [ - t('Set UI language'), - '', - t('Usage: /language ui [zh-CN|en-US|ru-RU|de-DE]'), - '', - t('Available options:'), - t(' - zh-CN: Simplified Chinese'), - t(' - en-US: English'), - t(' - ru-RU: Russian'), - '', - t( - 'To request additional UI language packs, please open an issue on GitHub.', - ), - ].join('\n'), - }; - } - - const langArg = parts[1].toLowerCase(); - let targetLang: SupportedLanguage | null = null; - - if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') { - targetLang = 'en'; - } else if ( - langArg === 'zh' || - langArg === 'chinese' || - langArg === '中文' || - langArg === 'zh-cn' - ) { - targetLang = 'zh'; - } else if ( - langArg === 'ru' || - langArg === 'ru-ru' || - langArg === 'russian' || - langArg === 'русский' - ) { - targetLang = 'ru'; - } else if ( - langArg === 'de' || - langArg === 'de-de' || - langArg === 'german' || - langArg === 'deutsch' - ) { - targetLang = 'de'; - } else { - return { - type: 'message', - messageType: 'error', - content: t('Invalid language. Available: en-US, zh-CN, ru-RU, de-DE'), - }; - } - - return setUiLanguage(context, targetLang); - } else if (subcommand === 'output') { - // Handle /language output - if (parts.length === 1) { - return { - type: 'message', - messageType: 'info', - content: [ - t('Set LLM output language'), - '', - t('Usage: /language output '), - ` ${t('Example: /language output 中文')}`, - ].join('\n'), - }; - } - - // Join all parts after "output" as the language name - const language = parts.slice(1).join(' '); - return generateLlmOutputLanguageRuleFile(language); - } else { - // Backward compatibility: treat as UI language - const langArg = trimmedArgs.toLowerCase(); - let targetLang: SupportedLanguage | null = null; - - if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') { - targetLang = 'en'; - } else if ( - langArg === 'zh' || - langArg === 'chinese' || - langArg === '中文' || - langArg === 'zh-cn' - ) { - targetLang = 'zh'; - } else if ( - langArg === 'ru' || - langArg === 'ru-ru' || - langArg === 'russian' || - langArg === 'русский' - ) { - targetLang = 'ru'; - } else if ( - langArg === 'de' || - langArg === 'de-de' || - langArg === 'german' || - langArg === 'deutsch' - ) { - targetLang = 'de'; - } else { - return { - type: 'message', - messageType: 'error', - content: [ - t('Invalid command. Available subcommands:'), - ' - /language ui [zh-CN|en-US|ru-RU|de-DE] - ' + - t('Set UI language'), - ' - /language output - ' + t('Set LLM output language'), - ].join('\n'), - }; - } - + // Handle backward compatibility for /language [lang] + const targetLang = parseUiLanguageArg(trimmedArgs); + if (targetLang) { return setUiLanguage(context, targetLang); } + + return { + type: 'message', + messageType: 'error', + content: [ + t('Invalid command. Available subcommands:'), + ` - /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`, + ' - /language output - ' + t('Set LLM output language'), + ].join('\n'), + }; }, subCommands: [ { @@ -421,13 +341,14 @@ export const languageCommand: SlashCommand = { content: [ t('Set UI language'), '', - t('Usage: /language ui [zh-CN|en-US|ru-RU|de-DE]'), + t('Usage: /language ui [{{options}}]', { + options: SUPPORTED_LANGUAGES.map((o) => o.id).join('|'), + }), '', t('Available options:'), - t(' - zh-CN: Simplified Chinese'), - t(' - en-US: English'), - t(' - ru-RU: Russian'), - t(' - de-DE: German'), + ...SUPPORTED_LANGUAGES.map( + (o) => ` - ${o.id}: ${t(o.fullName)}`, + ), '', t( 'To request additional UI language packs, please open an issue on GitHub.', @@ -436,138 +357,20 @@ export const languageCommand: SlashCommand = { }; } - const langArg = trimmedArgs.toLowerCase(); - let targetLang: SupportedLanguage | null = null; - - if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') { - targetLang = 'en'; - } else if ( - langArg === 'zh' || - langArg === 'chinese' || - langArg === '中文' || - langArg === 'zh-cn' - ) { - targetLang = 'zh'; - } else if ( - langArg === 'ru' || - langArg === 'ru-ru' || - langArg === 'russian' || - langArg === 'русский' - ) { - targetLang = 'ru'; - } else if ( - langArg === 'de' || - langArg === 'de-de' || - langArg === 'german' || - langArg === 'deutsch' - ) { - targetLang = 'de'; - } else { + const targetLang = parseUiLanguageArg(trimmedArgs); + if (!targetLang) { return { type: 'message', messageType: 'error', - content: t( - 'Invalid language. Available: en-US, zh-CN, ru-RU, de-DE', - ), + content: t('Invalid language. Available: {{options}}', { + options: SUPPORTED_LANGUAGES.map((o) => o.id).join(','), + }), }; } return setUiLanguage(context, targetLang); }, - subCommands: [ - { - name: 'zh-CN', - altNames: ['zh', 'chinese', '中文'], - get description() { - return t('Set UI language to Simplified Chinese (zh-CN)'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: t( - 'Language subcommands do not accept additional arguments.', - ), - }; - } - return setUiLanguage(context, 'zh'); - }, - }, - { - name: 'en-US', - altNames: ['en', 'english'], - get description() { - return t('Set UI language to English (en-US)'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: t( - 'Language subcommands do not accept additional arguments.', - ), - }; - } - return setUiLanguage(context, 'en'); - }, - }, - { - name: 'ru-RU', - altNames: ['ru', 'russian', 'русский'], - get description() { - return t('Set UI language to Russian (ru-RU)'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: t( - 'Language subcommands do not accept additional arguments.', - ), - }; - } - return setUiLanguage(context, 'ru'); - }, - }, - { - name: 'de-DE', - altNames: ['de', 'german', 'deutsch'], - get description() { - return t('Set UI language to German (de-DE)'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - if (args.trim().length > 0) { - return { - type: 'message', - messageType: 'error', - content: t( - 'Language subcommands do not accept additional arguments.', - ), - }; - } - return setUiLanguage(context, 'de'); - }, - }, - ], + subCommands: SUPPORTED_LANGUAGES.map(createUiLanguageSubCommand), }, { name: 'output', @@ -600,3 +403,28 @@ export const languageCommand: SlashCommand = { }, ], }; + +/** + * Helper to create a UI language subcommand. + */ +function createUiLanguageSubCommand(option: LanguageDefinition): SlashCommand { + return { + name: option.id, + get description() { + return t('Set UI language to {{name}}', { name: option.fullName }); + }, + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, option.code); + }, + }; +}