diff --git a/docs/users/features/language.md b/docs/users/features/language.md new file mode 100644 index 000000000..da6c9b330 --- /dev/null +++ b/docs/users/features/language.md @@ -0,0 +1,124 @@ +# Language Settings + +Qwen Code supports multiple languages for both the user interface and LLM responses. + +## Overview + +Two separate language settings control different aspects of Qwen Code: + +| 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` | + +## UI Language + +### Setting the UI Language + +Use the `/language ui` command: + +```bash +/language ui zh-CN # Chinese +/language ui en-US # English +/language ui ru-RU # Russian +/language ui de-DE # German +``` + +Aliases are also supported: + +```bash +/language ui zh # Chinese +/language ui en # English +/language ui ru # Russian +/language ui de # German +``` + +### Auto-detection + +On first startup, Qwen Code detects your system locale and sets the UI language automatically. + +Detection priority: + +1. `QWEN_CODE_LANG` environment variable +2. `LANG` environment variable +3. System locale via JavaScript Intl API +4. Default: English + +## LLM Output Language + +The LLM output language controls what language the AI assistant responds in, regardless of what language you type your questions in. + +### How It Works + +The LLM output language is controlled by a rule file at `~/.qwen/output-language.md`. This file is automatically included in the LLM's context during startup, instructing it to respond in the specified language. + +### Auto-detection + +On first startup, if no `output-language.md` file exists, Qwen Code automatically creates one based on your system locale. For example: + +- System locale `zh` creates a rule for Chinese responses +- System locale `en` creates a rule for English responses +- System locale `ru` creates a rule for Russian responses +- System locale `de` creates a rule for German responses + +### Manual Setting + +Use `/language output ` to change: + +```bash +/language output Chinese +/language output English +/language output Japanese +/language output German +``` + +Any language name works. The LLM will be instructed to respond in that language. + +### File Location + +``` +~/.qwen/output-language.md +``` + +## Configuration + +### Via Settings Dialog + +1. Run `/settings` +2. Find "Language" under General +3. Select your preferred UI language + +### Via Environment Variable + +```bash +export QWEN_CODE_LANG=zh +``` + +This sets both the UI language detection and the LLM output language detection on first startup. + +## Custom Language Packs + +For UI translations, you can create custom language packs in `~/.qwen/locales/`: + +- Example: `~/.qwen/locales/es.js` for Spanish +- Example: `~/.qwen/locales/fr.js` for French + +User directory takes precedence over built-in translations. + +### Language Pack Format + +```javascript +// ~/.qwen/locales/es.js +export default { + Hello: 'Hola', + Settings: 'Configuracion', + // ... more translations +}; +``` + +## Related Commands + +- `/language` - Show current language settings +- `/language ui [lang]` - Set UI language +- `/language output ` - Set LLM output language +- `/settings` - Open settings dialog diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 407dea447..5aa3d9e3b 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -15,6 +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'; export interface InitializationResult { authError: string | null; @@ -41,6 +42,9 @@ export async function initializeApp( 'auto'; await initializeI18n(languageSetting); + // Auto-detect and set LLM output language on first use + initializeLlmOutputLanguage(); + const authType = settings.merged.security?.auth?.selectedType; const authError = await performInitialAuth(config, authType); diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts index 7436336b3..af3d24400 100644 --- a/packages/cli/src/i18n/index.ts +++ b/packages/cli/src/i18n/index.ts @@ -46,17 +46,27 @@ 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']; if (envLang?.startsWith('zh')) return 'zh'; if (envLang?.startsWith('en')) return 'en'; if (envLang?.startsWith('ru')) return 'ru'; + if (envLang?.startsWith('de')) return 'de'; try { const locale = Intl.DateTimeFormat().resolvedOptions().locale; if (locale.startsWith('zh')) return 'zh'; if (locale.startsWith('ru')) return 'ru'; + if (locale.startsWith('de')) return 'de'; } catch { // Fallback to default } @@ -64,6 +74,14 @@ 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/ui/commands/languageCommand.test.ts b/packages/cli/src/ui/commands/languageCommand.test.ts index 5a4f395bd..96b1c3785 100644 --- a/packages/cli/src/ui/commands/languageCommand.test.ts +++ b/packages/cli/src/ui/commands/languageCommand.test.ts @@ -13,6 +13,16 @@ import { createMockCommandContext } from '../../test-utils/mockCommandContext.js vi.mock('../../i18n/index.js', () => ({ setLanguageAsync: vi.fn().mockResolvedValue(undefined), getCurrentLanguage: vi.fn().mockReturnValue('en'), + detectSystemLanguage: vi.fn().mockReturnValue('en'), + getLanguageNameFromLocale: vi.fn((locale: string) => { + const map: Record = { + zh: 'Chinese', + en: 'English', + ru: 'Russian', + de: 'German', + }; + return map[locale] || 'English'; + }), t: vi.fn((key: string) => key), })); @@ -61,7 +71,10 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { // Import modules after mocking import * as i18n from '../../i18n/index.js'; -import { languageCommand } from './languageCommand.js'; +import { + languageCommand, + initializeLlmOutputLanguage, +} from './languageCommand.js'; describe('languageCommand', () => { let mockContext: CommandContext; @@ -597,4 +610,74 @@ describe('languageCommand', () => { }); }); }); + + describe('initializeLlmOutputLanguage', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.mkdirSync).mockImplementation(() => undefined); + vi.mocked(fs.writeFileSync).mockImplementation(() => undefined); + }); + + 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 existing file', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + initializeLlmOutputLanguage(); + + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + 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', + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index 455465abc..1d3c439f9 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -15,6 +15,8 @@ import { SettingScope } from '../../config/settings.js'; import { setLanguageAsync, getCurrentLanguage, + detectSystemLanguage, + getLanguageNameFromLocale, type SupportedLanguage, t, } from '../../i18n/index.js'; @@ -73,6 +75,33 @@ function getLlmOutputLanguageRulePath(): string { ); } +/** + * 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. */