Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions docs/users/features/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,14 @@ Commands for adjusting interface appearance and work environment.

Commands specifically for controlling interface and output language.

| Command | Description | Usage Examples |
| --------------------- | -------------------------------- | -------------------------- |
| `/language` | View or change language settings | `/language` |
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
| → `output [language]` | Set LLM output language | `/language output Chinese` |

- Available built-in UI languages: `zh-CN` (Simplified Chinese), `en-US` (English), `ru-RU` (Russian), `de-DE` (German)
| Command | Description | Usage Examples |
| --------------------- | --------------------------------------------------------------------------- | -------------------------- |
| `/language` | View or change language settings | `/language` |
| → `ui [language]` | Set UI interface language | `/language ui zh-CN` |
| → `output [language]` | Set LLM output language | `/language output Chinese` |
| → `translate on/off` | Toggle AI translation for dynamic slash command descriptions (default: off) | `/language translate on` |

- Available built-in UI languages: `zh-CN` (Simplified Chinese), `en-US` (English), `ru-RU` (Russian), `de-DE` (German), `ja-JP` (Japanese), `pt-BR` (Portuguese - Brazil), `fr-FR` (French), `ca-ES` (Catalan)
- Output language examples: `Chinese`, `English`, `Japanese`, etc.

### 1.4 Tool and Model Management
Expand Down
25 changes: 25 additions & 0 deletions docs/users/features/language.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ Use the `/language ui` command:
/language ui ru-RU # Russian
/language ui de-DE # German
/language ui ja-JP # Japanese
/language ui pt-BR # Portuguese (Brazil)
/language ui fr-FR # French
/language ui ca-ES # Catalan
```

Aliases are also supported:
Expand All @@ -36,6 +39,9 @@ Aliases are also supported:
/language ui ru # Russian
/language ui de # German
/language ui ja # Japanese
/language ui pt # Portuguese
/language ui fr # French
/language ui ca # Catalan
```

### Auto-detection
Expand All @@ -49,6 +55,22 @@ Detection priority:
3. System locale via JavaScript Intl API
4. Default: English

### Dynamic Command Translation

Dynamic slash command descriptions from skills, extensions, file commands, and
MCP prompts can be translated with AI. This is **off by default** to avoid
unexpected model calls, latency, and token usage.

```bash
/language translate status # Show current status
/language translate on # Enable AI translation for dynamic descriptions
/language translate off # Disable AI translation
```

Use `/language translate cache refresh` to re-translate cached dynamic
descriptions after enabling translation, or `/language translate cache clear` to
remove cached translations.

## LLM Output Language

The LLM output language controls what language the AI assistant responds in, regardless of what language you type your questions in.
Expand All @@ -66,6 +88,9 @@ On first startup, if no `output-language.md` file exists, Qwen Code automaticall
- System locale `ru` creates a rule for Russian responses
- System locale `de` creates a rule for German responses
- System locale `ja` creates a rule for Japanese responses
- System locale `pt` creates a rule for Portuguese responses
- System locale `fr` creates a rule for French responses
- System locale `ca` creates a rule for Catalan responses

### Manual Setting

Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/acp-integration/session/Session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ describe('Session', () => {
mockConfig,
expect.any(AbortSignal),
'acp',
mockSettings,
);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
Expand All @@ -479,6 +480,50 @@ describe('Session', () => {
});
});

it('forwards localized command descriptions from getAvailableCommands()', async () => {
getAvailableCommandsSpy.mockResolvedValueOnce([
{
name: 'review',
description: '审查代码变更',
kind: CommandKind.SKILL,
source: 'skill-dir-command',
sourceLabel: '用户',
sourceDetail: 'user',
supportedModes: ['acp'],
},
]);

await session.sendAvailableCommandsUpdate();

expect(getAvailableCommandsSpy).toHaveBeenCalledWith(
mockConfig,
expect.any(AbortSignal),
'acp',
mockSettings,
);
expect(mockClient.sessionUpdate).toHaveBeenCalledWith({
sessionId: 'test-session-id',
update: {
sessionUpdate: 'available_commands_update',
availableCommands: [
{
name: 'review',
description: '审查代码变更',
input: { hint: '' },
_meta: {
argumentHint: undefined,
source: 'skill-dir-command',
sourceLabel: '用户',
supportedModes: ['acp'],
subcommands: [],
modelInvocable: false,
},
},
],
},
});
});

it('sets input for built-in commands with subCommands', async () => {
getAvailableCommandsSpy.mockResolvedValueOnce([
{
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/acp-integration/session/Session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1362,6 +1362,7 @@ export class Session implements SessionContext {
this.config,
abortController.signal,
'acp',
this.settings,
);

// Convert SlashCommand[] to AvailableCommand[] format for ACP protocol.
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/commands/auth/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -485,10 +485,13 @@ export async function handleApiKeyAuth() {
* Handles Custom API Key - prints docs link
*/
function handleCustomApiKeyAuth(): void {
writeStdoutLine('');
writeStdoutLine(
t(
'\nYou can configure your API key and models in settings.json.\nRefer to the documentation for setup instructions:\n https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/\n',
),
t('You can configure your API key and models in settings.json'),
);
writeStdoutLine(t('Refer to the documentation for setup instructions'));
writeStdoutLine(
' https://qwenlm.github.io/qwen-code-docs/en/users/configuration/model-providers/\n',
);
process.exit(0);
}
Expand Down Expand Up @@ -522,9 +525,10 @@ export async function showAuthStatus(): Promise<void> {
);
writeStdoutLine(
t(
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (discontinued)',
),
);
writeStdoutLine('');
writeStdoutLine(t('Or simply run:'));
writeStdoutLine(
t(' qwen auth - Interactive authentication setup\n'),
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/config/settingsSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,17 @@ const SETTINGS_SCHEMA = {
'or set a specific language.',
showInDialog: true,
},
dynamicCommandTranslation: {
type: 'boolean',
label: 'Language: Dynamic Command Translation',
category: 'General',
requiresRestart: false,
default: false,
description:
'Enable AI translation for dynamic slash command descriptions. ' +
'When disabled, dynamic commands use their original descriptions and do not trigger translation model calls.',
showInDialog: true,
},
terminalBell: {
type: 'boolean',
label: 'Terminal Bell Notification',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/gemini.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ export async function main() {
await runNonInteractiveStreamJson(
nonInteractiveConfig,
trimmedInput.length > 0 ? trimmedInput : '',
settings,
);
await runExitCleanup();
process.exit(0);
Expand Down
155 changes: 155 additions & 0 deletions packages/cli/src/i18n/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { Storage } from '@qwen-code/qwen-code-core';

describe('bundled locale fallback', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.doUnmock('node:fs');
vi.restoreAllMocks();
});

it('loads bundled builtin translations when locale files are absent on disk', async () => {
const qwenLocalePathPattern =
/([\\/]\.qwen|[\\/]i18n)[\\/]locales([\\/]|$)/;

vi.doMock('node:fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof import('node:fs')>();
return {
...actualFs,
existsSync: (target: Parameters<typeof actualFs.existsSync>[0]) => {
if (qwenLocalePathPattern.test(String(target))) {
return false;
}
return actualFs.existsSync(target);
},
};
});

const { setLanguageAsync, t } = await import('./index.js');
const { languageCommand } = await import(
'../ui/commands/languageCommand.js'
);

await setLanguageAsync('zh');

expect(t('show version info')).toBe('显示版本信息');
expect(languageCommand.description).not.toBe(
'View or change the language setting',
);
}, 20000);

it('falls back to bundled translations when a user locale default export is null', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-i18n-null-locale-'),
);
const localesDir = path.join(tempDir, 'locales');
await fs.mkdir(localesDir, { recursive: true });
await fs.writeFile(
path.join(localesDir, 'zh.js'),
'export default null;\n',
'utf-8',
);

vi.spyOn(Storage, 'getGlobalQwenDir').mockReturnValue(tempDir);

const { setLanguageAsync, t } = await import('./index.js');
await setLanguageAsync('zh');

expect(t('show version info')).toBe('显示版本信息');

await fs.rm(tempDir, { recursive: true, force: true });
}, 20000);

it('falls back to bundled translations when a user locale default export is an array', async () => {
const tempDir = await fs.mkdtemp(
path.join(os.tmpdir(), 'qwen-i18n-array-locale-'),
);
const localesDir = path.join(tempDir, 'locales');
await fs.mkdir(localesDir, { recursive: true });
await fs.writeFile(
path.join(localesDir, 'zh.js'),
"export default ['show version info'];\n",
'utf-8',
);

vi.spyOn(Storage, 'getGlobalQwenDir').mockReturnValue(tempDir);

const { setLanguageAsync, t } = await import('./index.js');
await setLanguageAsync('zh');

expect(t('show version info')).toBe('显示版本信息');

await fs.rm(tempDir, { recursive: true, force: true });
}, 20000);
});

describe('public i18n exports', () => {
it('re-exports supported languages and required translation keys', async () => {
const i18n = await import('./index.js');

expect(i18n.SUPPORTED_LANGUAGES.length).toBeGreaterThan(0);
expect(i18n.MUST_TRANSLATE_KEYS.length).toBeGreaterThan(0);
});
});

describe('language normalization', () => {
beforeEach(() => {
vi.resetModules();
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

it('normalizes explicit locale IDs before loading translations', async () => {
const { setLanguageAsync, getCurrentLanguage, t } = await import(
'./index.js'
);

await setLanguageAsync('zh-CN');

expect(getCurrentLanguage()).toBe('zh');
expect(t('show version info')).toBe('显示版本信息');
});

it('normalizes explicit POSIX locale strings before loading translations', async () => {
const { initializeI18n, getCurrentLanguage, t } = await import(
'./index.js'
);

await initializeI18n('pt_BR.UTF-8');

expect(getCurrentLanguage()).toBe('pt');
expect(t('show version info')).toBe('mostrar informações de versão');
});
});

describe('supported language resolution', () => {
it('prefers the longest supported locale match', async () => {
const { resolveSupportedLanguage } = await import('./languages.js');

expect(resolveSupportedLanguage('zh-TW')).toBe('zh-TW');
expect(resolveSupportedLanguage('zh_TW')).toBe('zh-TW');
expect(resolveSupportedLanguage('zh-TW.UTF-8')).toBe('zh-TW');
});

it('keeps existing fallback behavior for generic Chinese locales', async () => {
const { resolveSupportedLanguage } = await import('./languages.js');

expect(resolveSupportedLanguage('zh-CN')).toBe('zh');
expect(resolveSupportedLanguage('zh-HK')).toBe('zh');
});
});
Loading
Loading