diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 09a01c29f..116183216 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -163,7 +163,7 @@ const SETTINGS_SCHEMA = { }, gitCoAuthor: { type: 'boolean', - label: 'Add AI Co-Author to Commits', + label: 'Attribution: commit', category: 'General', requiresRestart: false, default: true, @@ -202,7 +202,7 @@ const SETTINGS_SCHEMA = { }, language: { type: 'enum', - label: 'Language', + label: 'Language: UI', category: 'General', requiresRestart: true, default: 'auto', @@ -219,6 +219,17 @@ const SETTINGS_SCHEMA = { { value: 'de', label: 'Deutsch (German)' }, ], }, + outputLanguage: { + type: 'string', + label: 'Language: Model', + category: 'General', + requiresRestart: true, + default: 'auto', + description: + 'The language for LLM output. Use "auto" to detect from system settings, ' + + 'or set a specific language (e.g., "English", "中文", "日本語").', + showInDialog: true, + }, terminalBell: { type: 'boolean', label: 'Terminal Bell Notification', @@ -1143,7 +1154,7 @@ const SETTINGS_SCHEMA = { properties: { skills: { type: 'boolean', - label: 'Skills', + label: 'Experimental: Skills', category: 'Experimental', requiresRestart: true, default: false, diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 56f65b1c5..c21d637e3 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -15,7 +15,7 @@ import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; import { initializeI18n } from '../i18n/index.js'; -import { initializeLlmOutputLanguage } from '../ui/commands/languageCommand.js'; +import { initializeLlmOutputLanguage } from '../utils/languageUtils.js'; export interface InitializationResult { authError: string | null; @@ -43,7 +43,7 @@ export async function initializeApp( await initializeI18n(languageSetting); // Auto-detect and set LLM output language on first use - initializeLlmOutputLanguage(); + initializeLlmOutputLanguage(settings.merged.general?.outputLanguage); // Use authType from modelsConfig which respects CLI --auth-type argument // over settings.security.auth.selectedType diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a7d9e2450..cf383708b 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -275,7 +275,7 @@ export default { // ============================================================================ 'Vim Mode': 'Vim-Modus', 'Disable Auto Update': 'Automatische Updates deaktivieren', - 'Add AI Co-Author to Commits': 'KI als Co-Autor zu Commits hinzufügen', + 'Attribution: commit': 'Attribution: Commit', 'Terminal Bell Notification': 'Terminal-Signalton', 'Enable Usage Statistics': 'Nutzungsstatistiken aktivieren', Theme: 'Farbschema', @@ -283,7 +283,8 @@ export default { 'Auto-connect to IDE': 'Automatische Verbindung zur IDE', 'Enable Prompt Completion': 'Eingabevervollständigung aktivieren', 'Debug Keystroke Logging': 'Debug-Protokollierung von Tastatureingaben', - Language: 'Sprache', + 'Language: UI': 'Sprache: Benutzeroberfläche', + 'Language: Model': 'Sprache: Modell', 'Output Format': 'Ausgabeformat', 'Hide Window Title': 'Fenstertitel ausblenden', 'Show Status in Title': 'Status im Titel anzeigen', @@ -330,6 +331,7 @@ export default { 'Folder Trust': 'Ordnervertrauen', 'Vision Model Preview': 'Vision-Modell-Vorschau', 'Tool Schema Compliance': 'Werkzeug-Schema-Konformität', + 'Experimental: Skills': 'Experimentell: Fähigkeiten', // Settings enum options 'Auto (detect from system)': 'Automatisch (vom System erkennen)', Text: 'Text', @@ -427,6 +429,8 @@ export default { 'Example: /language output English': 'Beispiel: /language output English', 'Example: /language output 日本語': 'Beispiel: /language output Japanisch', 'UI language changed to {{lang}}': 'UI-Sprache geändert zu {{lang}}', + 'LLM output language set to {{lang}}': + 'LLM-Ausgabesprache auf {{lang}} gesetzt', 'LLM output language rule file generated at {{path}}': 'LLM-Ausgabesprach-Regeldatei generiert unter {{path}}', 'Please restart the application for the changes to take effect.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 9bbb654e5..475d19d61 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -292,7 +292,7 @@ export default { // ============================================================================ 'Vim Mode': 'Vim Mode', 'Disable Auto Update': 'Disable Auto Update', - 'Add AI Co-Author to Commits': 'Add AI Co-Author to Commits', + 'Attribution: commit': 'Attribution: commit', 'Terminal Bell Notification': 'Terminal Bell Notification', 'Enable Usage Statistics': 'Enable Usage Statistics', Theme: 'Theme', @@ -300,7 +300,8 @@ export default { 'Auto-connect to IDE': 'Auto-connect to IDE', 'Enable Prompt Completion': 'Enable Prompt Completion', 'Debug Keystroke Logging': 'Debug Keystroke Logging', - Language: 'Language', + 'Language: UI': 'Language: UI', + 'Language: Model': 'Language: Model', 'Output Format': 'Output Format', 'Hide Window Title': 'Hide Window Title', 'Show Status in Title': 'Show Status in Title', @@ -346,6 +347,7 @@ export default { 'Folder Trust': 'Folder Trust', 'Vision Model Preview': 'Vision Model Preview', 'Tool Schema Compliance': 'Tool Schema Compliance', + 'Experimental: Skills': 'Experimental: Skills', // Settings enum options 'Auto (detect from system)': 'Auto (detect from system)', Text: 'Text', @@ -441,6 +443,7 @@ export default { 'Example: /language output English': 'Example: /language output English', 'Example: /language output 日本語': 'Example: /language output 日本語', 'UI language changed to {{lang}}': 'UI language changed to {{lang}}', + 'LLM output language set to {{lang}}': 'LLM output language set to {{lang}}', 'LLM output language rule file generated at {{path}}': 'LLM output language rule file generated at {{path}}', 'Please restart the application for the changes to take effect.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 4a2a8b142..e20422474 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -296,7 +296,7 @@ export default { // ============================================================================ 'Vim Mode': 'Режим Vim', 'Disable Auto Update': 'Отключить автообновление', - 'Add AI Co-Author to Commits': 'Добавлять ИИ как соавтора в коммиты', + 'Attribution: commit': 'Атрибуция: коммит', 'Terminal Bell Notification': 'Звуковое уведомление терминала', 'Enable Usage Statistics': 'Включить сбор статистики использования', Theme: 'Тема', @@ -304,7 +304,8 @@ export default { 'Auto-connect to IDE': 'Автоподключение к IDE', 'Enable Prompt Completion': 'Включить автодополнение промптов', 'Debug Keystroke Logging': 'Логирование нажатий клавиш для отладки', - Language: 'Язык', + 'Language: UI': 'Язык: интерфейс', + 'Language: Model': 'Язык: модель', 'Output Format': 'Формат вывода', 'Hide Window Title': 'Скрыть заголовок окна', 'Show Status in Title': 'Показывать статус в заголовке', @@ -350,6 +351,7 @@ export default { 'Folder Trust': 'Доверие к папке', 'Vision Model Preview': 'Визуальная модель (предпросмотр)', 'Tool Schema Compliance': 'Соответствие схеме инструмента', + 'Experimental: Skills': 'Экспериментальное: Навыки', // Варианты перечислений настроек 'Auto (detect from system)': 'Авто (определить из системы)', Text: 'Текст', @@ -448,6 +450,8 @@ export default { 'Example: /language output English': 'Пример: /language output English', 'Example: /language output 日本語': 'Пример: /language output 日本語', 'UI language changed to {{lang}}': 'Язык интерфейса изменен на {{lang}}', + 'LLM output language set to {{lang}}': + 'Язык вывода LLM установлен на {{lang}}', 'LLM output language rule file generated at {{path}}': 'Файл правил языка вывода LLM создан в {{path}}', 'Please restart the application for the changes to take effect.': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 1ff8653e5..2a0d5a368 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -283,7 +283,7 @@ export default { // ============================================================================ 'Vim Mode': 'Vim 模式', 'Disable Auto Update': '禁用自动更新', - 'Add AI Co-Author to Commits': '在提交中添加 AI 协作者', + 'Attribution: commit': '署名:提交', 'Terminal Bell Notification': '终端响铃通知', 'Enable Usage Statistics': '启用使用统计', Theme: '主题', @@ -291,7 +291,8 @@ export default { 'Auto-connect to IDE': '自动连接到 IDE', 'Enable Prompt Completion': '启用提示补全', 'Debug Keystroke Logging': '调试按键记录', - Language: '语言', + 'Language: UI': '语言:界面', + 'Language: Model': '语言:模型', 'Output Format': '输出格式', 'Hide Window Title': '隐藏窗口标题', 'Show Status in Title': '在标题中显示状态', @@ -335,6 +336,7 @@ export default { 'Folder Trust': '文件夹信任', 'Vision Model Preview': '视觉模型预览', 'Tool Schema Compliance': '工具 Schema 兼容性', + 'Experimental: Skills': '实验性: 技能', // Settings enum options 'Auto (detect from system)': '自动(从系统检测)', Text: '文本', @@ -424,6 +426,7 @@ export default { 'Example: /language output English': '示例:/language output English', 'Example: /language output 日本語': '示例:/language output 日本語', 'UI language changed to {{lang}}': 'UI 语言已更改为 {{lang}}', + 'LLM output language set to {{lang}}': 'LLM 输出语言已设置为 {{lang}}', 'LLM output language rule file generated at {{path}}': 'LLM 输出语言规则文件已生成于 {{path}}', 'Please restart the application for the changes to take effect.': diff --git a/packages/cli/src/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 719b1780c..9234773eb 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import * as fs from 'node:fs'; import { type CommandContext, CommandKind } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import type { LoadedSettings } from '../../config/settings.js'; // Mock i18n module vi.mock('../../i18n/index.js', () => ({ @@ -71,10 +72,8 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { // Import modules after mocking import * as i18n from '../../i18n/index.js'; -import { - languageCommand, - initializeLlmOutputLanguage, -} from './languageCommand.js'; +import { languageCommand } from './languageCommand.js'; +import { initializeLlmOutputLanguage } from '../../utils/languageUtils.js'; describe('languageCommand', () => { let mockContext: CommandContext; @@ -165,11 +164,13 @@ describe('languageCommand', () => { }); }); - it('should show LLM output language when set', async () => { - vi.mocked(fs.existsSync).mockReturnValue(true); - vi.mocked(fs.readFileSync).mockReturnValue( - '# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY', - ); + it('should show LLM output language when explicitly set', async () => { + // Set the outputLanguage setting explicitly + mockContext.services.settings = { + ...mockContext.services.settings, + merged: { general: { outputLanguage: 'Chinese' } }, + setValue: vi.fn(), + } as unknown as LoadedSettings; // Make t() function handle interpolation for this test vi.mocked(i18n.t).mockImplementation( @@ -192,7 +193,7 @@ describe('languageCommand', () => { messageType: 'info', content: expect.stringContaining('Current UI language:'), }); - // Verify it correctly parses "Chinese" from the template format + // Verify it shows "Chinese" for the explicitly set language expect(result).toEqual({ type: 'message', messageType: 'info', @@ -200,16 +201,14 @@ describe('languageCommand', () => { }); }); - 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'), - ); + it('should show auto-detected language when set to auto', async () => { + // Set the outputLanguage setting to 'auto' + mockContext.services.settings = { + ...mockContext.services.settings, + merged: { general: { outputLanguage: 'auto' } }, + setValue: vi.fn(), + } as unknown as LoadedSettings; + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); vi.mocked(i18n.t).mockImplementation( (key: string, params?: Record) => { @@ -226,10 +225,16 @@ describe('languageCommand', () => { const result = await languageCommand.action(mockContext, ''); + // Verify it shows "Auto (detect from system) → Chinese" expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining('中文'), + content: expect.stringContaining('Auto (detect from system)'), + }); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('Chinese'), }); }); }); @@ -404,7 +409,7 @@ describe('languageCommand', () => { }); }); - it('should create LLM output language rule file', async () => { + it('should save LLM output language setting', async () => { if (!languageCommand.action) { throw new Error('The language command must have an action.'); } @@ -414,18 +419,16 @@ describe('languageCommand', () => { 'output Chinese', ); - expect(fs.mkdirSync).toHaveBeenCalled(); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('output-language.md'), - expect.stringContaining('Chinese'), - 'utf-8', + // Verify setting was saved (rule file is updated on restart) + expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( + expect.anything(), // SettingScope.User + 'general.outputLanguage', + 'Chinese', ); expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining( - 'LLM output language rule file generated', - ), + content: expect.stringContaining('LLM output language set to'), }); }); @@ -453,10 +456,11 @@ describe('languageCommand', () => { await languageCommand.action(mockContext, 'output ru'); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('output-language.md'), - expect.stringContaining('Russian'), - 'utf-8', + // Verify setting was saved with normalized value + expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( + expect.anything(), + 'general.outputLanguage', + 'Russian', ); }); @@ -467,28 +471,36 @@ describe('languageCommand', () => { await languageCommand.action(mockContext, 'output de'); - expect(fs.writeFileSync).toHaveBeenCalledWith( - expect.stringContaining('output-language.md'), - expect.stringContaining('German'), - 'utf-8', + // Verify setting was saved with normalized value + expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( + expect.anything(), + 'general.outputLanguage', + 'German', ); }); - it('should handle file write errors gracefully', async () => { - vi.mocked(fs.writeFileSync).mockImplementation(() => { - throw new Error('Permission denied'); - }); - + it('should save setting without immediate rule file update', async () => { + // Even though rule file updates happen on restart, the setting should still be saved if (!languageCommand.action) { throw new Error('The language command must have an action.'); } - const result = await languageCommand.action(mockContext, 'output German'); + const result = await languageCommand.action( + mockContext, + 'output Spanish', + ); + // Verify setting was saved + expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( + expect.anything(), + 'general.outputLanguage', + 'Spanish', + ); + // Verify success message (no error about file generation) expect(result).toEqual({ type: 'message', - messageType: 'error', - content: expect.stringContaining('Failed to generate'), + messageType: 'info', + content: expect.stringContaining('LLM output language set to'), }); }); }); @@ -586,24 +598,23 @@ describe('languageCommand', () => { expect(outputSubcommand?.kind).toBe(CommandKind.BUILT_IN); }); - it('should have action that generates rule file', async () => { + it('should have action that saves setting', async () => { if (!outputSubcommand?.action) { throw new Error('Output subcommand must have an action.'); } - // Ensure mocks are properly set for this test - vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); - vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); - const result = await outputSubcommand.action(mockContext, 'French'); - expect(fs.writeFileSync).toHaveBeenCalled(); + // Verify setting was saved (rule file is updated on restart) + expect(mockContext.services.settings?.setValue).toHaveBeenCalledWith( + expect.anything(), + 'general.outputLanguage', + 'French', + ); expect(result).toEqual({ type: 'message', messageType: 'info', - content: expect.stringContaining( - 'LLM output language rule file generated', - ), + content: expect.stringContaining('LLM output language set to'), }); }); }); @@ -688,6 +699,7 @@ describe('languageCommand', () => { vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + vi.mocked(fs.readFileSync).mockImplementation(() => ''); }); it('should create file when it does not exist', () => { @@ -704,14 +716,50 @@ describe('languageCommand', () => { ); }); - it('should NOT overwrite existing file', () => { + it('should NOT overwrite existing file when content matches resolved language', () => { vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en'); + vi.mocked(fs.readFileSync).mockReturnValue( + `# Output language preference: English + +`, + ); initializeLlmOutputLanguage(); expect(fs.writeFileSync).not.toHaveBeenCalled(); }); + it('should overwrite existing file when output language setting differs', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + `# Output language preference: English + +`, + ); + + initializeLlmOutputLanguage('Japanese'); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Japanese'), + 'utf-8', + ); + }); + + it('should resolve auto setting to detected system language', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); + + initializeLlmOutputLanguage('auto'); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Chinese'), + 'utf-8', + ); + }); + it('should detect Chinese locale and create Chinese rule file', () => { vi.mocked(fs.existsSync).mockReturnValue(false); vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index fff4a693a..e4158ce5c 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -15,25 +15,40 @@ import { SettingScope } from '../../config/settings.js'; import { setLanguageAsync, getCurrentLanguage, - detectSystemLanguage, - getLanguageNameFromLocale, type SupportedLanguage, t, } from '../../i18n/index.js'; +import { SUPPORTED_LANGUAGES } from '../../i18n/languages.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'; + OUTPUT_LANGUAGE_AUTO, + isAutoLanguage, + resolveOutputLanguage, + updateOutputLanguageFile, +} from '../../utils/languageUtils.js'; -const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md'; -const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:'; +/** + * Gets the current LLM output language setting and its resolved value. + * Returns an object with both the raw setting and the resolved language. + */ +function getCurrentOutputLanguage(context?: CommandContext): { + setting: string; + resolved: string; +} { + const settingValue = + context?.services?.settings?.merged?.general?.outputLanguage || + OUTPUT_LANGUAGE_AUTO; + const resolved = resolveOutputLanguage(settingValue); + return { setting: settingValue, resolved }; +} +/** + * Parses user input to find a matching supported UI language. + * Accepts locale codes (e.g., "zh"), IDs (e.g., "zh-CN"), or full names (e.g., "Chinese"). + */ 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 || @@ -46,153 +61,22 @@ function parseUiLanguageArg(input: string): SupportedLanguage | null { return null; } +/** + * Formats a UI language code for display (e.g., "zh" -> "Chinese(zh-CN)"). + */ 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 markers like "-->" or "--!>" 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 { - const markerLanguage = sanitizeLanguageForMarker(language); - return `# Output language preference: ${language} - - -## Goal -Prefer responding in **${language}** for normal assistant messages and explanations. - -## 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) - -## When a conflict exists -If higher-priority instructions (system/developer) require a different behavior, follow them. - -## Tool / system outputs -Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below. -`; -} - -/** - * Gets the path to the LLM output language rule file. - */ -function getLlmOutputLanguageRulePath(): string { - return path.join( - Storage.getGlobalQwenDir(), - LLM_OUTPUT_LANGUAGE_RULE_FILENAME, - ); -} - -/** - * Normalizes a language input to its full English name. - * If the input is a known locale code (e.g., "ru", "zh"), converts it to the full name. - * Otherwise, returns the input as-is (e.g., "Japanese" stays "Japanese"). - */ -function normalizeLanguageName(language: string): string { - const lowered = language.toLowerCase(); - // Check if it's a known locale code and convert to full name - const fullName = getLanguageNameFromLocale(lowered); - // If getLanguageNameFromLocale returned a different value, use it - // Otherwise, use the original input (preserves case for unknown languages) - if (fullName !== 'English' || lowered === 'en') { - return fullName; - } - 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). - */ -export function initializeLlmOutputLanguage(): void { - const filePath = getLlmOutputLanguageRulePath(); - - // Skip if file already exists (user preference) - if (fs.existsSync(filePath)) { - return; - } - - // Detect system language and map to language name - const detectedLocale = detectSystemLanguage(); - const languageName = getLanguageNameFromLocale(detectedLocale); - - // Generate the rule file - const content = generateLlmOutputLanguageRule(languageName); - - // Ensure directory exists - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); - - // Write file - fs.writeFileSync(filePath, content, 'utf-8'); -} - -/** - * Gets the current LLM output language from the rule file if it exists. - */ -function getCurrentLlmOutputLanguage(): string | null { - const filePath = getLlmOutputLanguageRulePath(); - if (fs.existsSync(filePath)) { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - return extractLlmOutputLanguageFromRuleFileContent(content); - } catch { - // Ignore errors - } - } - return null; -} - -/** - * Sets the UI language and persists it to settings. + * Sets the UI language and persists it to user settings. */ async function setUiLanguage( context: CommandContext, lang: SupportedLanguage, ): Promise { const { services } = context; - const { settings } = services; if (!services.config) { return { @@ -202,19 +86,19 @@ async function setUiLanguage( }; } - // Set language in i18n system (async to support JS translation files) + // Update i18n system await setLanguageAsync(lang); - // Persist to settings (user scope) - if (settings && typeof settings.setValue === 'function') { + // Persist to settings + if (services.settings?.setValue) { try { - settings.setValue(SettingScope.User, 'general.language', lang); + services.settings.setValue(SettingScope.User, 'general.language', lang); } catch (error) { console.warn('Failed to save language setting:', error); } } - // Reload commands to update their descriptions with the new language + // Reload commands to update localized descriptions context.ui.reloadCommands(); return { @@ -227,37 +111,51 @@ async function setUiLanguage( } /** - * Generates the LLM output language rule file. + * Handles the /language output command, updating both the setting and the rule file. + * 'auto' is preserved in settings but resolved to the detected language for the rule file. */ -function generateLlmOutputLanguageRuleFile( +async function setOutputLanguage( + context: CommandContext, language: string, ): Promise { try { - const filePath = getLlmOutputLanguageRulePath(); - // Normalize locale codes (e.g., "ru" -> "Russian") to full language names - const normalizedLanguage = normalizeLanguageName(language); - const content = generateLlmOutputLanguageRule(normalizedLanguage); + const isAuto = isAutoLanguage(language); + const resolved = resolveOutputLanguage(language); + // Save 'auto' as-is to settings, or normalize other values + const settingValue = isAuto ? OUTPUT_LANGUAGE_AUTO : resolved; - // Ensure directory exists - const dir = path.dirname(filePath); - fs.mkdirSync(dir, { recursive: true }); + // Update the rule file with the resolved language + updateOutputLanguageFile(settingValue); - // Write file (overwrite if exists) - fs.writeFileSync(filePath, content, 'utf-8'); + // Save to settings + if (context.services.settings?.setValue) { + try { + context.services.settings.setValue( + SettingScope.User, + 'general.outputLanguage', + settingValue, + ); + } catch (error) { + console.warn('Failed to save output language setting:', error); + } + } - return Promise.resolve({ + // Format display message + const displayLang = isAuto + ? `${t('Auto (detect from system)')} → ${resolved}` + : resolved; + + return { type: 'message', messageType: 'info', content: [ - t('LLM output language rule file generated at {{path}}', { - path: filePath, - }), + t('LLM output language set to {{lang}}', { lang: displayLang }), '', t('Please restart the application for the changes to take effect.'), ].join('\n'), - }); + }; } catch (error) { - return Promise.resolve({ + return { type: 'message', messageType: 'error', content: t( @@ -266,7 +164,7 @@ function generateLlmOutputLanguageRuleFile( error: error instanceof Error ? error.message : String(error), }, ), - }); + }; } } @@ -276,12 +174,12 @@ export const languageCommand: SlashCommand = { return t('View or change the language setting'); }, kind: CommandKind.BUILT_IN, + action: async ( context: CommandContext, args: string, ): Promise => { - const { services } = context; - if (!services.config) { + if (!context.services.config) { return { type: 'message', messageType: 'error', @@ -291,75 +189,83 @@ export const languageCommand: SlashCommand = { const trimmedArgs = args.trim(); - // Handle subcommands if called directly via action (for tests/backward compatibility) - const parts = trimmedArgs.split(/\s+/); - const firstArg = parts[0].toLowerCase(); - const subArgs = parts.slice(1).join(' '); + // Route to subcommands if specified + if (trimmedArgs) { + const [firstArg, ...rest] = trimmedArgs.split(/\s+/); + const subCommandName = firstArg.toLowerCase(); + const subArgs = rest.join(' '); - if (firstArg === 'ui' || firstArg === 'output') { - const subCommand = languageCommand.subCommands?.find( - (s) => s.name === firstArg, - ); - if (subCommand?.action) { - return subCommand.action( - context, - subArgs, - ) as Promise; + if (subCommandName === 'ui' || subCommandName === 'output') { + const subCommand = languageCommand.subCommands?.find( + (s) => s.name === subCommandName, + ); + if (subCommand?.action) { + return subCommand.action( + context, + subArgs, + ) as Promise; + } } + + // Backward compatibility: direct language code (e.g., /language zh) + const targetLang = parseUiLanguageArg(trimmedArgs); + if (targetLang) { + return setUiLanguage(context, targetLang); + } + + // Unknown argument + 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'), + }; } - // If no arguments, show current language settings and usage - if (!trimmedArgs) { - const currentUiLang = getCurrentLanguage(); - const currentLlmLang = getCurrentLlmOutputLanguage(); - const message = [ + // No arguments: show current status + const currentUiLang = getCurrentLanguage(); + const { setting: outputSetting, resolved: outputResolved } = + getCurrentOutputLanguage(context); + + // Format output language display: show "Auto → English" or just "English" + const outputLangDisplay = isAutoLanguage(outputSetting) + ? `${t('Auto (detect from system)')} → ${outputResolved}` + : outputResolved; + + return { + type: 'message', + messageType: 'info', + content: [ 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('Current LLM output language: {{lang}}', { lang: outputLangDisplay }), '', t('Available subcommands:'), ` /language ui [${SUPPORTED_LANGUAGES.map((o) => o.id).join('|')}] - ${t('Set UI language')}`, ` /language output - ${t('Set LLM output language')}`, - ].join('\n'); - - return { - type: 'message', - messageType: 'info', - content: message, - }; - } - - // 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: [ + // /language ui subcommand { name: 'ui', get description() { return t('Set UI language'); }, kind: CommandKind.BUILT_IN, + action: async ( context: CommandContext, args: string, ): Promise => { const trimmedArgs = args.trim(); + if (!trimmedArgs) { return { type: 'message', @@ -396,19 +302,45 @@ export const languageCommand: SlashCommand = { return setUiLanguage(context, targetLang); }, - subCommands: SUPPORTED_LANGUAGES.map(createUiLanguageSubCommand), + + // Nested subcommands for each supported language (e.g., /language ui zh-CN) + subCommands: SUPPORTED_LANGUAGES.map( + (lang): SlashCommand => ({ + name: lang.id, + get description() { + return t('Set UI language to {{name}}', { name: lang.fullName }); + }, + kind: CommandKind.BUILT_IN, + action: async (context, args) => { + if (args.trim()) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, lang.code); + }, + }), + ), }, + + // /language output subcommand { name: 'output', get description() { return t('Set LLM output language'); }, kind: CommandKind.BUILT_IN, + action: async ( context: CommandContext, args: string, ): Promise => { const trimmedArgs = args.trim(); + if (!trimmedArgs) { return { type: 'message', @@ -424,33 +356,8 @@ export const languageCommand: SlashCommand = { }; } - return generateLlmOutputLanguageRuleFile(trimmedArgs); + return setOutputLanguage(context, trimmedArgs); }, }, ], }; - -/** - * 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); - }, - }; -} diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 58f2108ba..64f6b85e5 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -34,6 +34,7 @@ import { saveModifiedSettings, TEST_ONLY, } from '../../utils/settingsUtils.js'; +import { OUTPUT_LANGUAGE_AUTO } from '../../utils/languageUtils.js'; // Mock the VimModeContext const mockToggleVimEnabled = vi.fn(); @@ -282,7 +283,12 @@ describe('SettingsDialog', () => { stdin.write(TerminalKeys.DOWN_ARROW as string); // Down arrow }); - expect(lastFrame()).toContain('● Language'); + const secondKey = getDialogSettingKeys()[1]; + expect(secondKey).toBeDefined(); + const secondLabel = secondKey + ? (getSettingDefinition(secondKey)?.label ?? secondKey) + : ''; + expect(lastFrame()).toContain(`● ${secondLabel}`); // The active index should have changed (tested indirectly through behavior) unmount(); @@ -375,14 +381,17 @@ describe('SettingsDialog', () => { expect(lastFrame()).toContain('● Tool Approval Mode'); }); - // Navigate to Vim Mode setting (third setting - a boolean) and verify we're there - act(() => { - stdin.write(TerminalKeys.DOWN_ARROW as string); // -> Language - }); - await wait(); - act(() => { - stdin.write(TerminalKeys.DOWN_ARROW as string); // -> Vim Mode - }); + const dialogKeys = getDialogSettingKeys(); + const targetIndex = dialogKeys.indexOf('general.vimMode'); + expect(targetIndex).toBeGreaterThan(0); + + // Navigate to Vim Mode setting and verify we're there + for (let i = 0; i < targetIndex; i++) { + act(() => { + stdin.write(TerminalKeys.DOWN_ARROW as string); + }); + await wait(); + } await waitFor(() => { expect(lastFrame()).toContain('● Vim Mode'); }); @@ -579,7 +588,7 @@ describe('SettingsDialog', () => { // Wait for initial render await waitFor(() => { - expect(lastFrame()).toContain('Vim Mode'); + expect(lastFrame()).toContain('Tool Approval Mode'); }); // The UI should show settings mode is active (scope is in separate view) @@ -651,7 +660,7 @@ describe('SettingsDialog', () => { // Wait for initial render await waitFor(() => { - expect(lastFrame()).toContain('Vim Mode'); + expect(lastFrame()).toContain('Tool Approval Mode'); }); // Verify the dialog is rendered properly (scope is in separate view) @@ -857,17 +866,40 @@ describe('SettingsDialog', () => { unmount(); }); - it('should clear restart prompt when switching scopes', async () => { + it('should keep restart prompt when switching scopes', async () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { unmount } = render( + const { stdin, lastFrame, unmount } = render( , ); - // Restart prompt should be cleared when switching scopes + // Trigger a restart-required setting change: navigate to "Language: UI" (2nd item) and toggle it. + stdin.write(TerminalKeys.DOWN_ARROW as string); + await wait(); + stdin.write(TerminalKeys.ENTER as string); + await wait(); + + await waitFor(() => { + expect(lastFrame()).toContain( + 'To see changes, Qwen Code must be restarted', + ); + }); + + // Switch scopes; restart prompt should remain visible. + stdin.write(TerminalKeys.TAB as string); + await wait(); + stdin.write('2'); + await wait(); + + await waitFor(() => { + expect(lastFrame()).toContain( + 'To see changes, Qwen Code must be restarted', + ); + }); + unmount(); }); }); @@ -912,6 +944,44 @@ describe('SettingsDialog', () => { }); }); + describe('Output Language', () => { + it('treats empty output language as auto', async () => { + const settings = createMockSettings({ + general: { outputLanguage: 'en' }, + }); + + const { stdin, unmount } = render( + + {}} /> + , + ); + + // Navigate to "Language: Model" (3rd item), start editing, then commit empty. + stdin.write(TerminalKeys.DOWN_ARROW as string); + await wait(); + stdin.write(TerminalKeys.DOWN_ARROW as string); + await wait(); + stdin.write(TerminalKeys.ENTER as string); + await wait(); + stdin.write(TerminalKeys.ENTER as string); + await wait(); + + // Empty input should set 'auto' in settings (rule file is updated on restart) + const outputLanguageCall = vi + .mocked(saveModifiedSettings) + .mock.calls.find((call) => + (call[0] as Set).has('general.outputLanguage'), + ); + expect(outputLanguageCall).toBeTruthy(); + // Should save 'auto' to settings + expect(outputLanguageCall?.[1]).toMatchObject({ + general: { outputLanguage: OUTPUT_LANGUAGE_AUTO }, + }); + + unmount(); + }); + }); + describe('Keyboard Shortcuts Edge Cases', () => { it('should handle rapid key presses gracefully', async () => { const settings = createMockSettings(); @@ -1001,7 +1071,7 @@ describe('SettingsDialog', () => { // Wait for initial render await waitFor(() => { - expect(lastFrame()).toContain('Vim Mode'); + expect(lastFrame()).toContain('Tool Approval Mode'); }); // Verify initial state: settings mode active (scope is in separate view) @@ -1063,7 +1133,7 @@ describe('SettingsDialog', () => { // Wait for initial render await waitFor(() => { - expect(lastFrame()).toContain('Vim Mode'); + expect(lastFrame()).toContain('Tool Approval Mode'); }); // Verify the complete UI is rendered (scope is in separate view) diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 8608fde7b..b3bd1c270 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -17,7 +17,6 @@ import { getDialogSettingKeys, setPendingSettingValue, getDisplayValue, - hasRestartRequiredSettings, saveModifiedSettings, getSettingDefinition, isDefaultValue, @@ -28,6 +27,7 @@ import { getNestedValue, getEffectiveValue, } from '../../utils/settingsUtils.js'; +import { updateOutputLanguageFile } from '../../utils/languageUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { type Config } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; @@ -68,7 +68,6 @@ export function SettingsDialog({ const [activeSettingIndex, setActiveSettingIndex] = useState(0); // Scroll offset for settings const [scrollOffset, setScrollOffset] = useState(0); - const [showRestartPrompt, setShowRestartPrompt] = useState(false); // Local pending settings state for the selected scope const [pendingSettings, setPendingSettings] = useState(() => @@ -88,16 +87,17 @@ export function SettingsDialog({ >(new Map()); // Track restart-required settings across scope changes - const [_restartRequiredSettings, setRestartRequiredSettings] = useState< + const [restartRequiredSettings, setRestartRequiredSettings] = useState< Set >(new Set()); + const showRestartPrompt = restartRequiredSettings.size > 0; + useEffect(() => { // Base settings for selected scope let updated = structuredClone(settings.forScope(selectedScope).settings); // Overlay globally pending (unsaved) changes so user sees their modifications in any scope const newModified = new Set(); - const newRestartRequired = new Set(); for (const [key, value] of globalPendingChanges.entries()) { const def = getSettingDefinition(key); if (def?.type === 'boolean' && typeof value === 'boolean') { @@ -111,12 +111,9 @@ export function SettingsDialog({ updated = setPendingSettingValueAny(key, value, updated); } newModified.add(key); - if (requiresRestart(key)) newRestartRequired.add(key); } setPendingSettings(updated); setModifiedSettings(newModified); - setRestartRequiredSettings(newRestartRequired); - setShowRestartPrompt(newRestartRequired.size > 0); }, [selectedScope, settings, globalPendingChanges]); const generateSettingsItems = () => { @@ -226,31 +223,22 @@ export function SettingsDialog({ structuredClone(settings.forScope(selectedScope).settings), ); } else { - // For restart-required settings, track as modified - setModifiedSettings((prev) => { - const updated = new Set(prev).add(key); - const needsRestart = hasRestartRequiredSettings(updated); - console.log( - `[DEBUG SettingsDialog] Modified settings:`, - Array.from(updated), - 'Needs restart:', - needsRestart, - ); - if (needsRestart) { - setShowRestartPrompt(true); - setRestartRequiredSettings((prevRestart) => - new Set(prevRestart).add(key), - ); - } - return updated; - }); + // For restart-required settings, save immediately but show restart prompt + const immediateSettings = new Set([key]); + const immediateSettingsObject = setPendingSettingValueAny( + key, + newValue, + {} as Settings, + ); + saveModifiedSettings( + immediateSettings, + immediateSettingsObject, + settings, + selectedScope, + ); - // Add/update pending change globally so it persists across scopes - setGlobalPendingChanges((prev) => { - const next = new Map(prev); - next.set(key, newValue as PendingValue); - return next; - }); + // Mark as needing restart and show prompt + setRestartRequiredSettings((prev) => new Set(prev).add(key)); } }, }; @@ -293,7 +281,7 @@ export function SettingsDialog({ return; } - let parsed: string | number; + let parsed: string | number | undefined; if (type === 'number') { const numParsed = Number(editBuffer.trim()); if (Number.isNaN(numParsed)) { @@ -306,19 +294,32 @@ export function SettingsDialog({ parsed = numParsed; } else { // For strings, use the buffer as is. - parsed = editBuffer; + // Special handling for outputLanguage: empty input means 'auto' + if (key === 'general.outputLanguage') { + const trimmed = editBuffer.trim(); + parsed = trimmed === '' ? 'auto' : trimmed; + } else { + parsed = editBuffer; + } } // Update pending - setPendingSettings((prev) => setPendingSettingValueAny(key, parsed, prev)); + setPendingSettings((prev) => + parsed === undefined + ? setPendingSettingValueAny( + key, + undefined as unknown as SettingsValue, + prev, + ) + : setPendingSettingValueAny(key, parsed, prev), + ); if (!requiresRestart(key)) { const immediateSettings = new Set([key]); - const immediateSettingsObject = setPendingSettingValueAny( - key, - parsed, - {} as Settings, - ); + const immediateSettingsObject = + parsed === undefined + ? ({} as Settings) + : setPendingSettingValueAny(key, parsed, {} as Settings); saveModifiedSettings( immediateSettings, immediateSettingsObject, @@ -346,25 +347,26 @@ export function SettingsDialog({ return next; }); } else { - // Mark as modified and needing restart - setModifiedSettings((prev) => { - const updated = new Set(prev).add(key); - const needsRestart = hasRestartRequiredSettings(updated); - if (needsRestart) { - setShowRestartPrompt(true); - setRestartRequiredSettings((prevRestart) => - new Set(prevRestart).add(key), - ); - } - return updated; - }); + // For restart-required settings, save immediately but show restart prompt + const immediateSettings = new Set([key]); + const immediateSettingsObject = + parsed === undefined + ? ({} as Settings) + : setPendingSettingValueAny(key, parsed, {} as Settings); + saveModifiedSettings( + immediateSettings, + immediateSettingsObject, + settings, + selectedScope, + ); - // Record pending change globally for persistence across scopes - setGlobalPendingChanges((prev) => { - const next = new Map(prev); - next.set(key, parsed as PendingValue); - return next; - }); + // Update output language rule file immediately (no restart needed for LLM effect) + if (key === 'general.outputLanguage' && typeof parsed === 'string') { + updateOutputLanguageFile(parsed); + } + + // Mark as needing restart and show prompt + setRestartRequiredSettings((prev) => new Set(prev).add(key)); } setEditingKey(null); @@ -691,6 +693,9 @@ export function SettingsDialog({ return next; }); } + setRestartRequiredSettings((prev) => + new Set(prev).add(currentSetting.value), + ); } } } @@ -720,7 +725,6 @@ export function SettingsDialog({ }); } - setShowRestartPrompt(false); setRestartRequiredSettings(new Set()); // Clear restart-required settings if (onRestartRequest) onRestartRequest(); } @@ -837,9 +841,10 @@ export function SettingsDialog({ {isActive ? '●' : ''} - + {item.label} {scopeMessage && ( @@ -847,18 +852,20 @@ export function SettingsDialog({ )} - - - {displayValue} - + + + {displayValue} + + ); })} diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 722751231..d6cf8d2f8 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -6,14 +6,14 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode false │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode false │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -27,14 +27,14 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode false │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode false │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -48,14 +48,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode true* │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode true* │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code true* │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -69,14 +69,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode false* │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode false* │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false* │ -│ Show Line Numbers in Code false* │ +│ Auto-connect to IDE false* │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -90,14 +90,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode (Modified in System) false │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode (Modified in System) false │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -111,14 +111,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode (Modified in Workspace) false │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode (Modified in Workspace) false │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -132,14 +132,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode false │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode false │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -153,14 +153,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode false* │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode false* │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -174,14 +174,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode false │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode false │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE false │ -│ Show Line Numbers in Code false │ +│ Auto-connect to IDE false │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ @@ -195,14 +195,14 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ > Settings │ │ │ │ ▲ │ -│ ● Tool Approval Mode Default │ -│ Language Auto (detect from system) │ -│ Vim Mode true* │ -│ Interactive Shell (PTY) false │ -│ Theme Qwen Dark │ +│ ● Tool Approval Mode Default │ +│ Language: UI Auto (detect from system) │ +│ Language: Model auto │ +│ Theme Qwen Dark │ +│ Vim Mode true* │ +│ Interactive Shell (PTY) false │ │ Preferred Editor │ -│ Auto-connect to IDE true* │ -│ Show Line Numbers in Code true* │ +│ Auto-connect to IDE true* │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index f6d2380b9..9ccca7b6c 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -8,6 +8,8 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { execCommand } from '@qwen-code/qwen-code-core'; +const MACOS_CLIPBOARD_TIMEOUT_MS = 1500; + /** * Checks if the system clipboard contains an image (macOS only for now) * @returns true if clipboard contains an image @@ -19,7 +21,13 @@ export async function clipboardHasImage(): Promise { try { // Use osascript to check clipboard type - const { stdout } = await execCommand('osascript', ['-e', 'clipboard info']); + const { stdout } = await execCommand( + 'osascript', + ['-e', 'clipboard info'], + { + timeout: MACOS_CLIPBOARD_TIMEOUT_MS, + }, + ); const imageRegex = /«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/; return imageRegex.test(stdout); @@ -80,7 +88,9 @@ export async function saveClipboardImage( end try `; - const { stdout } = await execCommand('osascript', ['-e', script]); + const { stdout } = await execCommand('osascript', ['-e', script], { + timeout: MACOS_CLIPBOARD_TIMEOUT_MS, + }); if (stdout.trim() === 'success') { // Verify the file was created and has content diff --git a/packages/cli/src/utils/languageUtils.test.ts b/packages/cli/src/utils/languageUtils.test.ts new file mode 100644 index 000000000..6066bbf3f --- /dev/null +++ b/packages/cli/src/utils/languageUtils.test.ts @@ -0,0 +1,378 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as fs from 'node:fs'; + +// Mock fs module +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), + mkdirSync: vi.fn(), + writeFileSync: vi.fn(), + readFileSync: vi.fn(), +})); + +// Mock i18n module +vi.mock('../i18n/index.js', () => ({ + detectSystemLanguage: vi.fn(), + getLanguageNameFromLocale: vi.fn((locale: string) => { + const map: Record = { + en: 'English', + zh: 'Chinese', + ru: 'Russian', + de: 'German', + ja: 'Japanese', + ko: 'Korean', + fr: 'French', + es: 'Spanish', + }; + return map[locale] || 'English'; + }), +})); + +// Mock @qwen-code/qwen-code-core +vi.mock('@qwen-code/qwen-code-core', () => ({ + Storage: { + getGlobalQwenDir: vi.fn(() => '/mock/home/.qwen'), + }, +})); + +import * as i18n from '../i18n/index.js'; +import { + OUTPUT_LANGUAGE_AUTO, + isAutoLanguage, + normalizeOutputLanguage, + resolveOutputLanguage, + writeOutputLanguageFile, + updateOutputLanguageFile, + initializeLlmOutputLanguage, +} from './languageUtils.js'; + +describe('languageUtils', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('OUTPUT_LANGUAGE_AUTO', () => { + it('should be "auto"', () => { + expect(OUTPUT_LANGUAGE_AUTO).toBe('auto'); + }); + }); + + describe('isAutoLanguage', () => { + it('should return true for "auto"', () => { + expect(isAutoLanguage('auto')).toBe(true); + }); + + it('should return true for "AUTO" (case insensitive)', () => { + expect(isAutoLanguage('AUTO')).toBe(true); + }); + + it('should return true for "Auto" (case insensitive)', () => { + expect(isAutoLanguage('Auto')).toBe(true); + }); + + it('should return true for undefined', () => { + expect(isAutoLanguage(undefined)).toBe(true); + }); + + it('should return true for null', () => { + expect(isAutoLanguage(null)).toBe(true); + }); + + it('should return true for empty string', () => { + expect(isAutoLanguage('')).toBe(true); + }); + + it('should return false for explicit language', () => { + expect(isAutoLanguage('Chinese')).toBe(false); + }); + + it('should return false for locale code', () => { + expect(isAutoLanguage('zh')).toBe(false); + }); + }); + + describe('normalizeOutputLanguage', () => { + it('should convert "en" to "English"', () => { + expect(normalizeOutputLanguage('en')).toBe('English'); + }); + + it('should convert "zh" to "Chinese"', () => { + expect(normalizeOutputLanguage('zh')).toBe('Chinese'); + }); + + it('should convert "ru" to "Russian"', () => { + expect(normalizeOutputLanguage('ru')).toBe('Russian'); + }); + + it('should convert "de" to "German"', () => { + expect(normalizeOutputLanguage('de')).toBe('German'); + }); + + it('should convert "ja" to "Japanese"', () => { + expect(normalizeOutputLanguage('ja')).toBe('Japanese'); + }); + + it('should be case insensitive for locale codes', () => { + expect(normalizeOutputLanguage('ZH')).toBe('Chinese'); + expect(normalizeOutputLanguage('Ru')).toBe('Russian'); + }); + + it('should preserve explicit language names as-is', () => { + expect(normalizeOutputLanguage('Japanese')).toBe('Japanese'); + expect(normalizeOutputLanguage('French')).toBe('French'); + }); + + it('should preserve unknown language names as-is', () => { + expect(normalizeOutputLanguage('CustomLanguage')).toBe('CustomLanguage'); + expect(normalizeOutputLanguage('日本語')).toBe('日本語'); + }); + }); + + describe('resolveOutputLanguage', () => { + it('should resolve "auto" to detected system language', () => { + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); + + expect(resolveOutputLanguage('auto')).toBe('Chinese'); + expect(i18n.detectSystemLanguage).toHaveBeenCalled(); + }); + + it('should resolve undefined to detected system language', () => { + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ru'); + + expect(resolveOutputLanguage(undefined)).toBe('Russian'); + }); + + it('should resolve null to detected system language', () => { + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('de'); + + expect(resolveOutputLanguage(null)).toBe('German'); + }); + + it('should normalize explicit locale codes', () => { + expect(resolveOutputLanguage('zh')).toBe('Chinese'); + expect(i18n.detectSystemLanguage).not.toHaveBeenCalled(); + }); + + it('should preserve explicit language names', () => { + expect(resolveOutputLanguage('Japanese')).toBe('Japanese'); + }); + }); + + describe('writeOutputLanguageFile', () => { + beforeEach(() => { + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + }); + + it('should create directory and write file', () => { + writeOutputLanguageFile('Chinese'); + + expect(fs.mkdirSync).toHaveBeenCalledWith('/mock/home/.qwen', { + recursive: true, + }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + '/mock/home/.qwen/output-language.md', + expect.any(String), + 'utf-8', + ); + }); + + it('should include language in file content', () => { + writeOutputLanguageFile('Japanese'); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + expect(writtenContent).toContain('Japanese'); + expect(writtenContent).toContain( + '# Output language preference: Japanese', + ); + }); + + it('should include machine-readable marker', () => { + writeOutputLanguageFile('Chinese'); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + expect(writtenContent).toContain( + '', + ); + }); + + it('should sanitize language for marker (remove dangerous characters)', () => { + writeOutputLanguageFile('Test--Language'); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + // The marker should have -- removed, but the heading preserves original + expect(writtenContent).toContain( + '# Output language preference: Test--Language', + ); + expect(writtenContent).toContain( + '', + ); + }); + }); + + describe('updateOutputLanguageFile', () => { + beforeEach(() => { + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + }); + + it('should resolve "auto" and write resolved language', () => { + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); + + updateOutputLanguageFile('auto'); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + expect(writtenContent).toContain('Chinese'); + }); + + it('should normalize locale codes and write full name', () => { + updateOutputLanguageFile('ja'); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + expect(writtenContent).toContain('Japanese'); + }); + + it('should write explicit language names directly', () => { + updateOutputLanguageFile('French'); + + const writtenContent = vi.mocked(fs.writeFileSync).mock.calls[0][1]; + expect(writtenContent).toContain('French'); + }); + }); + + describe('initializeLlmOutputLanguage', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + vi.mocked(fs.readFileSync).mockReturnValue(''); + }); + + it('should create file when it does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en'); + + initializeLlmOutputLanguage(); + + expect(fs.mkdirSync).toHaveBeenCalled(); + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('English'), + 'utf-8', + ); + }); + + it('should NOT overwrite file when content matches resolved language', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en'); + vi.mocked(fs.readFileSync).mockReturnValue( + `# Output language preference: English + +`, + ); + + initializeLlmOutputLanguage(); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + it('should overwrite file when language setting differs', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + `# Output language preference: English + +`, + ); + + initializeLlmOutputLanguage('Japanese'); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Japanese'), + 'utf-8', + ); + }); + + it('should resolve "auto" to detected system language', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); + + initializeLlmOutputLanguage('auto'); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Chinese'), + 'utf-8', + ); + }); + + it('should detect Chinese locale and create Chinese rule file', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); + + initializeLlmOutputLanguage(); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Chinese'), + 'utf-8', + ); + }); + + it('should detect Russian locale and create Russian rule file', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('ru'); + + initializeLlmOutputLanguage(); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('Russian'), + 'utf-8', + ); + }); + + it('should detect German locale and create German rule file', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('de'); + + initializeLlmOutputLanguage(); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + expect.stringContaining('output-language.md'), + expect.stringContaining('German'), + 'utf-8', + ); + }); + + it('should handle file read errors gracefully', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('Read error'); + }); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('en'); + + // Should not throw, and should create new file + expect(() => initializeLlmOutputLanguage()).not.toThrow(); + expect(fs.writeFileSync).toHaveBeenCalled(); + }); + + it('should parse legacy heading format', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue( + '# CRITICAL: Chinese Output Language Rule - HIGHEST PRIORITY', + ); + vi.mocked(i18n.detectSystemLanguage).mockReturnValue('zh'); + + initializeLlmOutputLanguage(); + + // Should not overwrite since file already has Chinese + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/languageUtils.ts b/packages/cli/src/utils/languageUtils.ts new file mode 100644 index 000000000..e9b61923d --- /dev/null +++ b/packages/cli/src/utils/languageUtils.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Qwen team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Utilities for managing the LLM output language rule file. + * This file handles the creation and maintenance of ~/.qwen/output-language.md + * which instructs the LLM to respond in the user's preferred language. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Storage } from '@qwen-code/qwen-code-core'; +import { + detectSystemLanguage, + getLanguageNameFromLocale, +} from '../i18n/index.js'; + +const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md'; +const LLM_OUTPUT_LANGUAGE_MARKER_PREFIX = 'qwen-code:llm-output-language:'; + +/** Special value meaning "detect from system settings" */ +export const OUTPUT_LANGUAGE_AUTO = 'auto'; + +/** + * Checks if a value represents the "auto" setting. + */ +export function isAutoLanguage(value: string | undefined | null): boolean { + return !value || value.toLowerCase() === OUTPUT_LANGUAGE_AUTO; +} + +/** + * Normalizes a language input to its canonical form. + * Converts known locale codes (e.g., "zh", "ru") to full names (e.g., "Chinese", "Russian"). + * Unknown inputs are returned as-is to support any language name. + */ +export function normalizeOutputLanguage(language: string): string { + const lowered = language.toLowerCase(); + const fullName = getLanguageNameFromLocale(lowered); + // getLanguageNameFromLocale returns 'English' as default for unknown codes. + // Only use the result if it's a known code or explicitly 'en'. + if (fullName !== 'English' || lowered === 'en') { + return fullName; + } + return language; +} + +/** + * Resolves the output language, converting 'auto' to the detected system language. + */ +export function resolveOutputLanguage( + value: string | undefined | null, +): string { + if (isAutoLanguage(value)) { + const detectedLocale = detectSystemLanguage(); + return getLanguageNameFromLocale(detectedLocale); + } + return normalizeOutputLanguage(value!); +} + +/** + * Returns the path to the LLM output language rule file (~/.qwen/output-language.md). + */ +function getOutputLanguageFilePath(): string { + return path.join( + Storage.getGlobalQwenDir(), + LLM_OUTPUT_LANGUAGE_RULE_FILENAME, + ); +} + +/** + * Sanitizes a language string for use in an HTML comment marker. + * Removes characters that could break HTML comment syntax. + */ +function sanitizeForMarker(language: string): string { + return language + .replace(/[\r\n]/g, ' ') + .replace(/--!?>/g, '') + .replace(/--/g, ''); +} + +/** + * Generates the content for the LLM output language rule file. + */ +function generateOutputLanguageFileContent(language: string): string { + const safeLanguage = sanitizeForMarker(language); + return `# Output language preference: ${language} + + +## Goal +Prefer responding in **${language}** for normal assistant messages and explanations. + +## 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) + +## When a conflict exists +If higher-priority instructions (system/developer) require a different behavior, follow them. + +## Tool / system outputs +Raw tool/system outputs may contain fixed-format English. Preserve them verbatim, and if needed, add a short **${language}** explanation below. +`; +} + +/** + * Extracts the language from the content of an output language rule file. + * Supports both the new marker format and legacy heading format. + */ +function parseOutputLanguageFromContent(content: string): string | null { + // Primary: machine-readable marker (e.g., ) + const markerRegex = new RegExp( + String.raw``, + 'i', + ); + const markerMatch = content.match(markerRegex); + if (markerMatch?.[1]?.trim()) { + return markerMatch[1].trim(); + } + + // Fallback: legacy heading format (e.g., # CRITICAL: Chinese Output Language Rule) + const headingMatch = content.match( + /^#.*?CRITICAL:\s*(.*?)\s+Output Language Rule\b/im, + ); + if (headingMatch?.[1]?.trim()) { + return headingMatch[1].trim(); + } + + return null; +} + +/** + * Reads the current output language from the rule file. + * Returns null if the file doesn't exist or can't be parsed. + */ +function readOutputLanguageFromFile(): string | null { + const filePath = getOutputLanguageFilePath(); + if (!fs.existsSync(filePath)) { + return null; + } + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return parseOutputLanguageFromContent(content); + } catch { + return null; + } +} + +/** + * Writes the output language rule file with the given language. + */ +export function writeOutputLanguageFile(language: string): void { + const filePath = getOutputLanguageFilePath(); + const content = generateOutputLanguageFileContent(language); + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(filePath, content, 'utf-8'); +} + +/** + * Updates the LLM output language rule file based on the setting value. + * Resolves 'auto' to the detected system language before writing. + */ +export function updateOutputLanguageFile(settingValue: string): void { + const resolved = resolveOutputLanguage(settingValue); + writeOutputLanguageFile(resolved); +} + +/** + * Initializes the LLM output language rule file on application startup. + * + * @param outputLanguage - The output language setting value (e.g., 'auto', 'Chinese', etc.) + * + * Behavior: + * - Resolves the setting value ('auto' -> detected system language, or use as-is) + * - Ensures the rule file matches the resolved language + * - Creates the file if it doesn't exist + */ +export function initializeLlmOutputLanguage(outputLanguage?: string): void { + // Resolve 'auto' or undefined to the detected system language + const resolved = resolveOutputLanguage(outputLanguage); + const currentFileLanguage = readOutputLanguageFromFile(); + + // Only write if the file doesn't match the resolved language + if (currentFileLanguage !== resolved) { + writeOutputLanguageFile(resolved); + } +} diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index a17cb6bcf..fe3b6df8d 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -17,6 +17,7 @@ import type { } from '../config/settingsSchema.js'; import { getSettingsSchema } from '../config/settingsSchema.js'; import { t } from '../i18n/index.js'; +import { isAutoLanguage } from './languageUtils.js'; // The schema is now nested, but many parts of the UI and logic work better // with a flattened structure and dot-notation keys. This section flattens the @@ -268,13 +269,16 @@ const SETTINGS_DIALOG_ORDER: readonly string[] = [ // Localization - users often set this first 'general.language', + 'general.outputLanguage', + + // Theme + 'ui.theme', // Editor/Shell Experience 'general.vimMode', 'tools.shell.enableInteractiveShell', // Display Preferences - 'ui.theme', 'general.preferredEditor', 'ide.enabled', 'ui.showLineNumbers', @@ -465,15 +469,21 @@ export function saveModifiedSettings( path, ); - if (value === undefined) { - return; - } - const existsInOriginalFile = settingExistsInScope( settingKey, loadedSettings.forScope(scope).settings, ); + if (value === undefined) { + // Treat `undefined` as "unset" when the key exists in the scope file. + // LoadedSettings.setValue(..., undefined) is used elsewhere in the codebase + // to remove optional settings from disk. + if (existsInOriginalFile) { + loadedSettings.setValue(scope, settingKey, undefined); + } + return; + } + const isDefaultValue = value === getDefaultValue(settingKey); if (existsInOriginalFile || !isDefaultValue) { @@ -509,7 +519,10 @@ export function getDisplayValue( let valueString = String(value); - if (definition?.type === 'enum' && definition.options) { + // Special handling for outputLanguage 'auto' value + if (key === 'general.outputLanguage' && isAutoLanguage(value as string)) { + valueString = t('Auto (detect from system)'); + } else if (definition?.type === 'enum' && definition.options) { const option = definition.options?.find((option) => option.value === value); if (option?.label) { valueString = t(option.label) || option.label;