mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 20:20:57 +00:00
Merge pull request #1513 from QwenLM/feat/cli-welcome-screen
feat: Redesign CLI welcome screen and settings dialog
This commit is contained in:
commit
ae9ba8be18
111 changed files with 3776 additions and 2660 deletions
383
packages/cli/src/utils/languageUtils.test.ts
Normal file
383
packages/cli/src/utils/languageUtils.test.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
/**
|
||||
* @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';
|
||||
import * as path from 'node:path';
|
||||
|
||||
// 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<string, string> = {
|
||||
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');
|
||||
|
||||
const globalDir = '/mock/home/.qwen';
|
||||
const expectedDir = path.join(globalDir);
|
||||
const expectedFilePath = path.join(globalDir, 'output-language.md');
|
||||
|
||||
expect(fs.mkdirSync).toHaveBeenCalledWith(expectedDir, {
|
||||
recursive: true,
|
||||
});
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expectedFilePath,
|
||||
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(
|
||||
'<!-- qwen-code:llm-output-language: Chinese -->',
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
'<!-- qwen-code:llm-output-language: TestLanguage -->',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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
|
||||
<!-- qwen-code:llm-output-language: 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
|
||||
<!-- qwen-code:llm-output-language: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
190
packages/cli/src/utils/languageUtils.ts
Normal file
190
packages/cli/src/utils/languageUtils.ts
Normal file
|
|
@ -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}
|
||||
<!-- ${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX} ${safeLanguage} -->
|
||||
|
||||
## 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., <!-- qwen-code:llm-output-language: 中文 -->)
|
||||
const markerRegex = new RegExp(
|
||||
String.raw`<!--\s*${LLM_OUTPUT_LANGUAGE_MARKER_PREFIX}\s*(.*?)\s*-->`,
|
||||
'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);
|
||||
}
|
||||
}
|
||||
|
|
@ -110,6 +110,7 @@ describe('SettingsUtils', () => {
|
|||
category: 'UI',
|
||||
default: false,
|
||||
requiresRestart: true,
|
||||
showInDialog: true,
|
||||
},
|
||||
accessibility: {
|
||||
type: 'object',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -249,12 +250,79 @@ export function getDialogSettingsByType(
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all setting keys that should be shown in the dialog
|
||||
* Explicit display order for settings shown in the Settings Dialog.
|
||||
* Settings are ordered by importance and logical grouping:
|
||||
* 1. Workflow control (most impactful)
|
||||
* 2. Localization
|
||||
* 3. Editor/Shell experience
|
||||
* 4. Display preferences
|
||||
* 5. Git behavior
|
||||
* 6. File filtering
|
||||
* 7. System settings (rarely changed)
|
||||
*
|
||||
* New settings with showInDialog: true that are not listed here
|
||||
* will appear at the end of the list.
|
||||
*/
|
||||
const SETTINGS_DIALOG_ORDER: readonly string[] = [
|
||||
// Workflow Control - most impactful setting
|
||||
'tools.approvalMode',
|
||||
|
||||
// Localization - users often set this first
|
||||
'general.language',
|
||||
'general.outputLanguage',
|
||||
|
||||
// Theme
|
||||
'ui.theme',
|
||||
|
||||
// Editor/Shell Experience
|
||||
'general.vimMode',
|
||||
'tools.shell.enableInteractiveShell',
|
||||
|
||||
// Display Preferences
|
||||
'general.preferredEditor',
|
||||
'ide.enabled',
|
||||
'ui.showLineNumbers',
|
||||
'ui.hideTips',
|
||||
'general.terminalBell',
|
||||
'ui.enableWelcomeBack',
|
||||
|
||||
// Git Behavior
|
||||
'general.gitCoAuthor',
|
||||
|
||||
// File Filtering
|
||||
'context.fileFiltering.respectGitIgnore',
|
||||
'context.fileFiltering.respectQwenIgnore',
|
||||
|
||||
// System Settings - rarely changed
|
||||
'general.disableAutoUpdate',
|
||||
|
||||
// Privacy
|
||||
'privacy.usageStatisticsEnabled',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Get all setting keys that should be shown in the dialog, sorted by display order
|
||||
*/
|
||||
export function getDialogSettingKeys(): string[] {
|
||||
return Object.values(getFlattenedSchema())
|
||||
.filter((definition) => definition.showInDialog !== false)
|
||||
const dialogSettings = Object.values(getFlattenedSchema())
|
||||
.filter((definition) => definition.showInDialog === true)
|
||||
.map((definition) => definition.key);
|
||||
|
||||
// Sort by explicit order; settings not in the order array appear at the end
|
||||
return dialogSettings.sort((a, b) => {
|
||||
const indexA = SETTINGS_DIALOG_ORDER.indexOf(a);
|
||||
const indexB = SETTINGS_DIALOG_ORDER.indexOf(b);
|
||||
|
||||
// If both are in the order array, sort by their position
|
||||
if (indexA !== -1 && indexB !== -1) {
|
||||
return indexA - indexB;
|
||||
}
|
||||
// If only one is in the array, prioritize the one in the array
|
||||
if (indexA !== -1) return -1;
|
||||
if (indexB !== -1) return 1;
|
||||
// If neither is in the array, maintain original order
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
|
@ -401,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) {
|
||||
|
|
@ -445,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;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ describe('systemInfo', () => {
|
|||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
getSessionId: vi.fn().mockReturnValue('test-session-id'),
|
||||
getAuthType: vi.fn().mockReturnValue('test-auth'),
|
||||
getProxy: vi.fn().mockReturnValue(undefined),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({
|
||||
baseUrl: 'https://api.openai.com',
|
||||
}),
|
||||
|
|
@ -235,6 +236,7 @@ describe('systemInfo', () => {
|
|||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
proxy: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export interface SystemInfo {
|
|||
selectedAuthType: string;
|
||||
ideClient: string;
|
||||
sessionId: string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -38,6 +39,7 @@ export interface ExtendedSystemInfo extends SystemInfo {
|
|||
memoryUsage: string;
|
||||
baseUrl?: string;
|
||||
gitCommit?: string;
|
||||
proxy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -118,6 +120,7 @@ export async function getSystemInfo(
|
|||
const selectedAuthType = context.services.config?.getAuthType() || '';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
const sessionId = context.services.config?.getSessionId() || 'unknown';
|
||||
const proxy = context.services.config?.getProxy();
|
||||
|
||||
return {
|
||||
cliVersion,
|
||||
|
|
@ -131,6 +134,7 @@ export async function getSystemInfo(
|
|||
selectedAuthType,
|
||||
ideClient,
|
||||
sessionId,
|
||||
proxy,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
79
packages/cli/src/utils/systemInfoFields.test.ts
Normal file
79
packages/cli/src/utils/systemInfoFields.test.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSystemInfoFields } from './systemInfoFields.js';
|
||||
import type { ExtendedSystemInfo } from './systemInfo.js';
|
||||
|
||||
describe('getAboutSystemInfoFields', () => {
|
||||
it('orders sandbox/proxy after session id', () => {
|
||||
const info: ExtendedSystemInfo = {
|
||||
cliVersion: '1.0.0',
|
||||
osPlatform: 'darwin',
|
||||
osArch: 'arm64',
|
||||
osRelease: '23.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
gitCommit: undefined,
|
||||
proxy: 'http://user:pass@localhost:7890',
|
||||
};
|
||||
|
||||
const fields = getSystemInfoFields(info);
|
||||
const labels = fields.map((f) => f.label);
|
||||
|
||||
expect(labels).toEqual([
|
||||
'Qwen Code',
|
||||
'Runtime',
|
||||
'IDE Client',
|
||||
'OS',
|
||||
'Auth',
|
||||
'Model',
|
||||
'Session ID',
|
||||
'Sandbox',
|
||||
'Proxy',
|
||||
'Memory Usage',
|
||||
]);
|
||||
|
||||
expect(labels.indexOf('Session ID')).toBeLessThan(
|
||||
labels.indexOf('Sandbox'),
|
||||
);
|
||||
expect(labels.indexOf('Session ID')).toBeLessThan(labels.indexOf('Proxy'));
|
||||
|
||||
const proxyField = fields.find((f) => f.label === 'Proxy');
|
||||
expect(proxyField?.value).toBe('http://***:***@localhost:7890/');
|
||||
});
|
||||
|
||||
it('always includes Proxy with "no proxy" when unset', () => {
|
||||
const info: ExtendedSystemInfo = {
|
||||
cliVersion: '1.0.0',
|
||||
osPlatform: 'darwin',
|
||||
osArch: 'arm64',
|
||||
osRelease: '23.0.0',
|
||||
nodeVersion: 'v20.0.0',
|
||||
npmVersion: '10.0.0',
|
||||
sandboxEnv: 'no sandbox',
|
||||
modelVersion: 'test-model',
|
||||
selectedAuthType: 'test-auth',
|
||||
ideClient: 'test-ide',
|
||||
sessionId: 'test-session-id',
|
||||
memoryUsage: '100 MB',
|
||||
baseUrl: undefined,
|
||||
gitCommit: undefined,
|
||||
proxy: undefined,
|
||||
};
|
||||
|
||||
const fields = getSystemInfoFields(info);
|
||||
const proxyField = fields.find((f) => f.label === 'Proxy');
|
||||
expect(proxyField?.value).toBe('no proxy');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,104 +15,108 @@ export interface SystemInfoField {
|
|||
key: keyof ExtendedSystemInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified field configuration for system information display.
|
||||
* This ensures consistent labeling between /about and /bug commands.
|
||||
*/
|
||||
export interface SystemInfoDisplayField {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export function getSystemInfoFields(
|
||||
info: ExtendedSystemInfo,
|
||||
): SystemInfoField[] {
|
||||
const allFields: SystemInfoField[] = [
|
||||
{
|
||||
label: t('CLI Version'),
|
||||
key: 'cliVersion',
|
||||
},
|
||||
{
|
||||
label: t('Git Commit'),
|
||||
key: 'gitCommit',
|
||||
},
|
||||
{
|
||||
label: t('Model'),
|
||||
key: 'modelVersion',
|
||||
},
|
||||
{
|
||||
label: t('Sandbox'),
|
||||
key: 'sandboxEnv',
|
||||
},
|
||||
{
|
||||
label: t('OS Platform'),
|
||||
key: 'osPlatform',
|
||||
},
|
||||
{
|
||||
label: t('OS Arch'),
|
||||
key: 'osArch',
|
||||
},
|
||||
{
|
||||
label: t('OS Release'),
|
||||
key: 'osRelease',
|
||||
},
|
||||
{
|
||||
label: t('Node.js Version'),
|
||||
key: 'nodeVersion',
|
||||
},
|
||||
{
|
||||
label: t('NPM Version'),
|
||||
key: 'npmVersion',
|
||||
},
|
||||
{
|
||||
label: t('Session ID'),
|
||||
key: 'sessionId',
|
||||
},
|
||||
{
|
||||
label: t('Auth Method'),
|
||||
key: 'selectedAuthType',
|
||||
},
|
||||
{
|
||||
label: t('Base URL'),
|
||||
key: 'baseUrl',
|
||||
},
|
||||
{
|
||||
label: t('Memory Usage'),
|
||||
key: 'memoryUsage',
|
||||
},
|
||||
{
|
||||
label: t('IDE Client'),
|
||||
key: 'ideClient',
|
||||
},
|
||||
];
|
||||
): SystemInfoDisplayField[] {
|
||||
const fields: SystemInfoDisplayField[] = [];
|
||||
|
||||
// Filter out optional fields that are not present
|
||||
return allFields.filter((field) => {
|
||||
const value = info[field.key];
|
||||
// Optional fields: only show if they exist and are non-empty
|
||||
if (
|
||||
field.key === 'baseUrl' ||
|
||||
field.key === 'gitCommit' ||
|
||||
field.key === 'ideClient'
|
||||
) {
|
||||
return Boolean(value);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
addField(fields, t('Qwen Code'), formatCliVersion(info));
|
||||
addField(fields, t('Runtime'), formatRuntime(info));
|
||||
addField(fields, t('IDE Client'), info.ideClient);
|
||||
addField(fields, t('OS'), formatOs(info));
|
||||
addField(fields, t('Auth'), formatAuth(info));
|
||||
addField(fields, t('Model'), info.modelVersion);
|
||||
addField(fields, t('Session ID'), info.sessionId);
|
||||
addField(fields, t('Sandbox'), info.sandboxEnv);
|
||||
addField(fields, t('Proxy'), formatProxy(info.proxy));
|
||||
addField(fields, t('Memory Usage'), info.memoryUsage);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value for a field from system info
|
||||
*/
|
||||
export function getFieldValue(
|
||||
field: SystemInfoField,
|
||||
info: ExtendedSystemInfo,
|
||||
): string {
|
||||
const value = info[field.key];
|
||||
function addField(
|
||||
fields: SystemInfoDisplayField[],
|
||||
label: string,
|
||||
value: string,
|
||||
): void {
|
||||
if (value) {
|
||||
fields.push({ label, value });
|
||||
}
|
||||
}
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
function formatCliVersion(info: ExtendedSystemInfo): string {
|
||||
if (!info.cliVersion) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Special formatting for selectedAuthType
|
||||
if (field.key === 'selectedAuthType') {
|
||||
return String(value).startsWith('oauth') ? 'OAuth' : String(value);
|
||||
if (!info.gitCommit) {
|
||||
return info.cliVersion;
|
||||
}
|
||||
|
||||
return String(value);
|
||||
return `${info.cliVersion} (${info.gitCommit})`;
|
||||
}
|
||||
|
||||
function formatRuntime(info: ExtendedSystemInfo): string {
|
||||
if (!info.nodeVersion && !info.npmVersion) {
|
||||
return '';
|
||||
}
|
||||
const node = info.nodeVersion ? `Node.js ${info.nodeVersion}` : '';
|
||||
const npm = info.npmVersion ? `npm ${info.npmVersion}` : '';
|
||||
return joinParts([node, npm], ' / ');
|
||||
}
|
||||
|
||||
function formatOs(info: ExtendedSystemInfo): string {
|
||||
return joinParts(
|
||||
[info.osPlatform, info.osArch, formatOsRelease(info.osRelease)],
|
||||
' ',
|
||||
).trim();
|
||||
}
|
||||
|
||||
function formatOsRelease(release: string): string {
|
||||
if (!release) {
|
||||
return '';
|
||||
}
|
||||
return `(${release})`;
|
||||
}
|
||||
|
||||
function formatAuth(info: ExtendedSystemInfo): string {
|
||||
if (!info.selectedAuthType) {
|
||||
return '';
|
||||
}
|
||||
const authType = formatAuthType(info.selectedAuthType);
|
||||
if (!info.baseUrl) {
|
||||
return authType;
|
||||
}
|
||||
return `${authType} (${info.baseUrl})`;
|
||||
}
|
||||
|
||||
function formatAuthType(authType: string): string {
|
||||
return authType.startsWith('oauth') ? 'OAuth' : authType;
|
||||
}
|
||||
|
||||
function formatProxy(proxy?: string): string {
|
||||
if (!proxy) {
|
||||
return 'no proxy';
|
||||
}
|
||||
return redactProxy(proxy);
|
||||
}
|
||||
|
||||
function redactProxy(proxy: string): string {
|
||||
try {
|
||||
const url = new URL(proxy);
|
||||
if (url.username || url.password) {
|
||||
url.username = url.username ? '***' : '';
|
||||
url.password = url.password ? '***' : '';
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return proxy.replace(/\/\/[^/]*@/, '//***@');
|
||||
}
|
||||
}
|
||||
|
||||
function joinParts(parts: string[], separator: string): string {
|
||||
return parts.filter((part) => part).join(separator);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue