diff --git a/README.md b/README.md index 4c4396ec..c6230b96 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ -Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli) ([details](./README.gemini.md)), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance. +Qwen Code is a powerful command-line AI workflow tool adapted from [**Gemini CLI**](https://github.com/google-gemini/gemini-cli), specifically optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder) models. It enhances your development workflow with advanced code understanding, automated tasks, and intelligent assistance. ## 💡 Free Options Available diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 82342e0a..983c6e5e 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -195,6 +195,16 @@ Slash commands provide meta-level control over the CLI itself. - **`/init`** - **Description:** Analyzes the current directory and creates a `QWEN.md` context file by default (or the filename specified by `contextFileName`). If a non-empty file already exists, no changes are made. The command seeds an empty file and prompts the model to populate it with project-specific instructions. +- [**`/language`**](./language.md) + - **Description:** View or change the language setting for both UI and LLM output. + - **Sub-commands:** + - **`ui`**: Set the UI language (zh-CN or en-US) + - **`output`**: Set the LLM output language + - **Usage:** `/language [ui|output] [language]` + - **Examples:** + - `/language ui zh-CN` (set UI language to Simplified Chinese) + - `/language output English` (set LLM output language to English) + ### Custom Commands For a quick start, see the [example](#example-a-pure-function-refactoring-command) below. diff --git a/docs/cli/language.md b/docs/cli/language.md new file mode 100644 index 00000000..7fb1e7f0 --- /dev/null +++ b/docs/cli/language.md @@ -0,0 +1,71 @@ +# Language Command + +The `/language` command allows you to customize the language settings for both the Qwen Code user interface (UI) and the language model's output. This command supports two distinct functionalities: + +1. Setting the UI language for the Qwen Code interface +2. Setting the output language for the language model (LLM) + +## UI Language Settings + +To change the UI language of Qwen Code, use the `ui` subcommand: + +``` +/language ui [zh-CN|en-US] +``` + +### Available UI Languages + +- **zh-CN**: Simplified Chinese (įŽ€äŊ“中文) +- **en-US**: English + +### Examples + +``` +/language ui zh-CN # Set UI language to Simplified Chinese +/language ui en-US # Set UI language to English +``` + +### UI Language Subcommands + +You can also use direct subcommands for convenience: + +- `/language ui zh-CN` or `/language ui zh` or `/language ui 中文` +- `/language ui en-US` or `/language ui en` or `/language ui english` + +## LLM Output Language Settings + +To set the language for the language model's responses, use the `output` subcommand: + +``` +/language output +``` + +This command generates a language rule file that instructs the LLM to respond in the specified language. The rule file is saved to `~/.qwen/output-language.md`. + +### Examples + +``` +/language output 中文 # Set LLM output language to Chinese +/language output English # Set LLM output language to English +/language output æ—ĨæœŦčĒž # Set LLM output language to Japanese +``` + +## Viewing Current Settings + +When used without arguments, the `/language` command displays the current language settings: + +``` +/language +``` + +This will show: + +- Current UI language +- Current LLM output language (if set) +- Available subcommands + +## Notes + +- UI language changes take effect immediately and reload all command descriptions +- LLM output language settings are persisted in a rule file that is automatically included in the model's context +- To request additional UI language packs, please open an issue on GitHub diff --git a/package-lock.json b/package-lock.json index 296fc29b..ae277057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.3.0", "workspaces": [ "packages/*" ], @@ -16024,7 +16024,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.3.0", "dependencies": { "@google/genai": "1.16.0", "@iarna/toml": "^2.2.5", @@ -16139,7 +16139,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.2.2", + "version": "0.3.0", "hasInstallScript": true, "dependencies": { "@google/genai": "1.16.0", @@ -16278,7 +16278,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.2.2", + "version": "0.3.0", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -16290,7 +16290,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.2.2", + "version": "0.3.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index a8b69061..c96865aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.3.0", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0" }, "scripts": { "start": "cross-env node scripts/start.js", @@ -46,6 +46,7 @@ "lint:all": "node scripts/lint.js", "format": "prettier --experimental-cli --write .", "typecheck": "npm run typecheck --workspaces --if-present", + "check-i18n": "npm run check-i18n --workspace=packages/cli", "preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci", "prepare": "husky && npm run bundle", "prepare:package": "node scripts/prepare-package.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 33f9596d..6d3e7f51 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.3.0", "description": "Qwen Code", "repository": { "type": "git", @@ -26,13 +26,14 @@ "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check-i18n": "tsx ../../scripts/check-i18n.ts" }, "files": [ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6fa72c57..907de3b4 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -24,6 +24,7 @@ import { WriteFileTool, resolveTelemetrySettings, FatalConfigError, + Storage, InputFormat, OutputFormat, } from '@qwen-code/qwen-code-core'; @@ -632,6 +633,20 @@ export async function loadCliConfig( (e) => e.contextFiles, ); + // Automatically load output-language.md if it exists + const outputLanguageFilePath = path.join( + Storage.getGlobalQwenDir(), + 'output-language.md', + ); + if (fs.existsSync(outputLanguageFilePath)) { + extensionContextFilePaths.push(outputLanguageFilePath); + if (debugMode) { + logger.debug( + `Found output-language.md, adding to context files: ${outputLanguageFilePath}`, + ); + } + } + const fileService = new FileDiscoveryService(cwd); const fileFiltering = { @@ -904,7 +919,6 @@ export async function loadCliConfig( useBuiltinRipgrep: settings.tools?.useBuiltinRipgrep, shouldUseNodePtyShell: settings.tools?.shell?.enableInteractiveShell, skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck, - enablePromptCompletion: settings.general?.enablePromptCompletion ?? false, skipLoopDetection: settings.model?.skipLoopDetection ?? false, skipStartupContext: settings.model?.skipStartupContext ?? false, vlmSwitchMode, diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 8d474758..ae29074b 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -77,7 +77,6 @@ const MIGRATION_MAP: Record = { disableAutoUpdate: 'general.disableAutoUpdate', disableUpdateNag: 'general.disableUpdateNag', dnsResolutionOrder: 'advanced.dnsResolutionOrder', - enablePromptCompletion: 'general.enablePromptCompletion', enforcedAuthType: 'security.auth.enforcedType', excludeTools: 'tools.exclude', excludeMCPServers: 'mcp.excluded', diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 70037dfd..d95f4dbb 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -167,16 +167,6 @@ const SETTINGS_SCHEMA = { }, }, }, - enablePromptCompletion: { - type: 'boolean', - label: 'Enable Prompt Completion', - category: 'General', - requiresRestart: true, - default: false, - description: - 'Enable AI-powered prompt completion suggestions while typing.', - showInDialog: true, - }, debugKeystrokeLogging: { type: 'boolean', label: 'Debug Keystroke Logging', @@ -186,6 +176,23 @@ const SETTINGS_SCHEMA = { description: 'Enable debug logging of keystrokes to the console.', showInDialog: true, }, + language: { + type: 'enum', + label: 'Language', + category: 'General', + requiresRestart: false, + default: 'auto', + description: + 'The language for the user interface. Use "auto" to detect from system settings. ' + + 'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' + + 'in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js).', + showInDialog: true, + options: [ + { value: 'auto', label: 'Auto (detect from system)' }, + { value: 'en', label: 'English' }, + { value: 'zh', label: '中文 (Chinese)' }, + ], + }, }, }, output: { diff --git a/packages/cli/src/core/initializer.ts b/packages/cli/src/core/initializer.ts index 870632d7..407dea44 100644 --- a/packages/cli/src/core/initializer.ts +++ b/packages/cli/src/core/initializer.ts @@ -14,6 +14,7 @@ import { import { type LoadedSettings, SettingScope } from '../config/settings.js'; import { performInitialAuth } from './auth.js'; import { validateTheme } from './theme.js'; +import { initializeI18n } from '../i18n/index.js'; export interface InitializationResult { authError: string | null; @@ -33,6 +34,13 @@ export async function initializeApp( config: Config, settings: LoadedSettings, ): Promise { + // Initialize i18n system + const languageSetting = + process.env['QWEN_CODE_LANG'] || + settings.merged.general?.language || + 'auto'; + await initializeI18n(languageSetting); + const authType = settings.merged.security?.auth?.selectedType; const authError = await performInitialAuth(config, authType); @@ -44,7 +52,6 @@ export async function initializeApp( undefined, ); } - const themeError = validateTheme(settings); const shouldOpenAuthDialog = diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index ed2805a5..7acb4abd 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -6,6 +6,7 @@ import { themeManager } from '../ui/themes/theme-manager.js'; import { type LoadedSettings } from '../config/settings.js'; +import { t } from '../i18n/index.js'; /** * Validates the configured theme. @@ -15,7 +16,9 @@ import { type LoadedSettings } from '../config/settings.js'; export function validateTheme(settings: LoadedSettings): string | null { const effectiveTheme = settings.merged.ui?.theme; if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { - return `Theme "${effectiveTheme}" not found.`; + return t('Theme "{{themeName}}" not found.', { + themeName: effectiveTheme, + }); } return null; } diff --git a/packages/cli/src/i18n/index.ts b/packages/cli/src/i18n/index.ts new file mode 100644 index 00000000..2cad8dec --- /dev/null +++ b/packages/cli/src/i18n/index.ts @@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { homedir } from 'node:os'; + +export type SupportedLanguage = 'en' | 'zh' | string; // Allow custom language codes + +// State +let currentLanguage: SupportedLanguage = 'en'; +let translations: Record = {}; + +// Cache +type TranslationDict = Record; +const translationCache: Record = {}; +const loadingPromises: Record> = {}; + +// Path helpers +const getBuiltinLocalesDir = (): string => { + const __filename = fileURLToPath(import.meta.url); + return path.join(path.dirname(__filename), 'locales'); +}; + +const getUserLocalesDir = (): string => + path.join(homedir(), '.qwen', 'locales'); + +/** + * Get the path to the user's custom locales directory. + * Users can place custom language packs (e.g., es.js, fr.js) in this directory. + * @returns The path to ~/.qwen/locales + */ +export function getUserLocalesDirectory(): string { + return getUserLocalesDir(); +} + +const getLocalePath = ( + lang: SupportedLanguage, + useUserDir: boolean = false, +): string => { + const baseDir = useUserDir ? getUserLocalesDir() : getBuiltinLocalesDir(); + return path.join(baseDir, `${lang}.js`); +}; + +// 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'; + + try { + const locale = Intl.DateTimeFormat().resolvedOptions().locale; + if (locale.startsWith('zh')) return 'zh'; + } catch { + // Fallback to default + } + + return 'en'; +} + +// Translation loading +async function loadTranslationsAsync( + lang: SupportedLanguage, +): Promise { + if (translationCache[lang]) { + return translationCache[lang]; + } + + const existingPromise = loadingPromises[lang]; + if (existingPromise) { + return existingPromise; + } + + const loadPromise = (async () => { + // Try user directory first (for custom language packs), then builtin directory + const searchDirs = [ + { dir: getUserLocalesDir(), isUser: true }, + { dir: getBuiltinLocalesDir(), isUser: false }, + ]; + + for (const { dir, isUser } of searchDirs) { + // Ensure directory exists + if (!fs.existsSync(dir)) { + continue; + } + + const jsPath = getLocalePath(lang, isUser); + if (!fs.existsSync(jsPath)) { + continue; + } + + try { + // Convert file path to file:// URL for cross-platform compatibility + const fileUrl = pathToFileURL(jsPath).href; + try { + const module = await import(fileUrl); + const result = module.default || module; + if ( + result && + typeof result === 'object' && + Object.keys(result).length > 0 + ) { + translationCache[lang] = result; + return result; + } else { + throw new Error('Module loaded but result is empty or invalid'); + } + } catch { + // For builtin locales, try alternative import method (relative path) + if (!isUser) { + try { + const module = await import(`./locales/${lang}.js`); + const result = module.default || module; + if ( + result && + typeof result === 'object' && + Object.keys(result).length > 0 + ) { + translationCache[lang] = result; + return result; + } + } catch { + // Continue to next directory + } + } + // If import failed, continue to next directory + continue; + } + } catch (error) { + // Log warning but continue to next directory + if (isUser) { + console.warn( + `Failed to load translations from user directory for ${lang}:`, + error, + ); + } else { + console.warn(`Failed to load JS translations for ${lang}:`, error); + if (error instanceof Error) { + console.warn(`Error details: ${error.message}`); + console.warn(`Stack: ${error.stack}`); + } + } + // Continue to next directory + continue; + } + } + + // Return empty object if both directories fail + // Cache it to avoid repeated failed attempts + translationCache[lang] = {}; + return {}; + })(); + + loadingPromises[lang] = loadPromise; + + // Clean up promise after completion to allow retry on next call if needed + loadPromise.finally(() => { + delete loadingPromises[lang]; + }); + + return loadPromise; +} + +function loadTranslations(lang: SupportedLanguage): TranslationDict { + // Only return from cache (JS files require async loading) + return translationCache[lang] || {}; +} + +// String interpolation +function interpolate( + template: string, + params?: Record, +): string { + if (!params) return template; + return template.replace( + /\{\{(\w+)\}\}/g, + (match, key) => params[key] ?? match, + ); +} + +// Language setting helpers +function resolveLanguage(lang: SupportedLanguage | 'auto'): SupportedLanguage { + return lang === 'auto' ? detectSystemLanguage() : lang; +} + +// Public API +export function setLanguage(lang: SupportedLanguage | 'auto'): void { + const resolvedLang = resolveLanguage(lang); + currentLanguage = resolvedLang; + + // Try to load translations synchronously (from cache only) + const loaded = loadTranslations(resolvedLang); + translations = loaded; + + // Warn if translations are empty and JS file exists (requires async loading) + if (Object.keys(loaded).length === 0) { + const userJsPath = getLocalePath(resolvedLang, true); + const builtinJsPath = getLocalePath(resolvedLang, false); + if (fs.existsSync(userJsPath) || fs.existsSync(builtinJsPath)) { + console.warn( + `Language file for ${resolvedLang} requires async loading. ` + + `Use setLanguageAsync() instead, or call initializeI18n() first.`, + ); + } + } +} + +export async function setLanguageAsync( + lang: SupportedLanguage | 'auto', +): Promise { + currentLanguage = resolveLanguage(lang); + translations = await loadTranslationsAsync(currentLanguage); +} + +export function getCurrentLanguage(): SupportedLanguage { + return currentLanguage; +} + +export function t(key: string, params?: Record): string { + const translation = translations[key] ?? key; + return interpolate(translation, params); +} + +export async function initializeI18n( + lang?: SupportedLanguage | 'auto', +): Promise { + await setLanguageAsync(lang ?? 'auto'); +} diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js new file mode 100644 index 00000000..3ab57edb --- /dev/null +++ b/packages/cli/src/i18n/locales/en.js @@ -0,0 +1,1129 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// English translations for Qwen Code CLI +// The key serves as both the translation key and the default English text + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + 'Basics:': 'Basics:', + 'Add context': 'Add context', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell mode', + 'YOLO mode': 'YOLO mode', + 'plan mode': 'plan mode', + 'auto-accept edits': 'auto-accept edits', + 'Accepting edits': 'Accepting edits', + '(shift + tab to cycle)': '(shift + tab to cycle)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'Commands:', + 'shell command': 'shell command', + 'Model Context Protocol command (from external servers)': + 'Model Context Protocol command (from external servers)', + 'Keyboard Shortcuts:': 'Keyboard Shortcuts:', + 'Jump through words in the input': 'Jump through words in the input', + 'Close dialogs, cancel requests, or quit application': + 'Close dialogs, cancel requests, or quit application', + 'New line': 'New line', + 'New line (Alt+Enter works for certain linux distros)': + 'New line (Alt+Enter works for certain linux distros)', + 'Clear the screen': 'Clear the screen', + 'Open input in external editor': 'Open input in external editor', + 'Send message': 'Send message', + 'Initializing...': 'Initializing...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'Connecting to MCP servers... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': 'Type your message or @path/to/file', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.", + 'Cancel operation / Clear input (double press)': + 'Cancel operation / Clear input (double press)', + 'Cycle approval modes': 'Cycle approval modes', + 'Cycle through your prompt history': 'Cycle through your prompt history', + 'For a full list of shortcuts, see {{docPath}}': + 'For a full list of shortcuts, see {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'for help on Qwen Code', + 'show version info': 'show version info', + 'submit a bug report': 'submit a bug report', + 'About Qwen Code': 'About Qwen Code', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'CLI Version': 'CLI Version', + 'Git Commit': 'Git Commit', + Model: 'Model', + Sandbox: 'Sandbox', + 'OS Platform': 'OS Platform', + 'OS Arch': 'OS Arch', + 'OS Release': 'OS Release', + 'Node.js Version': 'Node.js Version', + 'NPM Version': 'NPM Version', + 'Session ID': 'Session ID', + 'Auth Method': 'Auth Method', + 'Base URL': 'Base URL', + 'Memory Usage': 'Memory Usage', + 'IDE Client': 'IDE Client', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'Analyzes the project and creates a tailored QWEN.md file.', + 'list available Qwen Code tools. Usage: /tools [desc]': + 'list available Qwen Code tools. Usage: /tools [desc]', + 'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:', + 'No tools available': 'No tools available', + 'View or change the approval mode for tool usage': + 'View or change the approval mode for tool usage', + 'View or change the language setting': 'View or change the language setting', + 'change the theme': 'change the theme', + 'Select Theme': 'Select Theme', + Preview: 'Preview', + '(Use Enter to select, Tab to configure scope)': + '(Use Enter to select, Tab to configure scope)', + '(Use Enter to apply scope, Tab to select theme)': + '(Use Enter to apply scope, Tab to select theme)', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'Theme configuration unavailable due to NO_COLOR env variable.', + 'Theme "{{themeName}}" not found.': 'Theme "{{themeName}}" not found.', + 'Theme "{{themeName}}" not found in selected scope.': + 'Theme "{{themeName}}" not found in selected scope.', + 'clear the screen and conversation history': + 'clear the screen and conversation history', + 'Compresses the context by replacing it with a summary.': + 'Compresses the context by replacing it with a summary.', + 'open full Qwen Code documentation in your browser': + 'open full Qwen Code documentation in your browser', + 'Configuration not available.': 'Configuration not available.', + 'change the auth method': 'change the auth method', + 'Show quit confirmation dialog': 'Show quit confirmation dialog', + 'Copy the last result or code snippet to clipboard': + 'Copy the last result or code snippet to clipboard', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'Manage subagents for specialized task delegation.', + 'Manage existing subagents (view, edit, delete).': + 'Manage existing subagents (view, edit, delete).', + 'Create a new subagent with guided setup.': + 'Create a new subagent with guided setup.', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'Agents', + 'Choose Action': 'Choose Action', + 'Edit {{name}}': 'Edit {{name}}', + 'Edit Tools: {{name}}': 'Edit Tools: {{name}}', + 'Edit Color: {{name}}': 'Edit Color: {{name}}', + 'Delete {{name}}': 'Delete {{name}}', + 'Unknown Step': 'Unknown Step', + 'Esc to close': 'Esc to close', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter to select, ↑↓ to navigate, Esc to close', + 'Esc to go back': 'Esc to go back', + 'Enter to confirm, Esc to cancel': 'Enter to confirm, Esc to cancel', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter to select, ↑↓ to navigate, Esc to go back', + 'Invalid step: {{step}}': 'Invalid step: {{step}}', + 'No subagents found.': 'No subagents found.', + "Use '/agents create' to create your first subagent.": + "Use '/agents create' to create your first subagent.", + '(built-in)': '(built-in)', + '(overridden by project level agent)': '(overridden by project level agent)', + 'Project Level ({{path}})': 'Project Level ({{path}})', + 'User Level ({{path}})': 'User Level ({{path}})', + 'Built-in Agents': 'Built-in Agents', + 'Using: {{count}} agents': 'Using: {{count}} agents', + 'View Agent': 'View Agent', + 'Edit Agent': 'Edit Agent', + 'Delete Agent': 'Delete Agent', + Back: 'Back', + 'No agent selected': 'No agent selected', + 'File Path: ': 'File Path: ', + 'Tools: ': 'Tools: ', + 'Color: ': 'Color: ', + 'Description:': 'Description:', + 'System Prompt:': 'System Prompt:', + 'Open in editor': 'Open in editor', + 'Edit tools': 'Edit tools', + 'Edit color': 'Edit color', + '❌ Error:': '❌ Error:', + 'Are you sure you want to delete agent "{{name}}"?': + 'Are you sure you want to delete agent "{{name}}"?', + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': 'Project Level (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'User Level (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ Subagent Created Successfully!', + 'Subagent "{{name}}" has been saved to {{level}} level.': + 'Subagent "{{name}}" has been saved to {{level}} level.', + 'Name: ': 'Name: ', + 'Location: ': 'Location: ', + '❌ Error saving subagent:': '❌ Error saving subagent:', + 'Warnings:': 'Warnings:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'Name "{{name}}" exists at user level - project level will take precedence', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'Name "{{name}}" exists at project level - existing subagent will take precedence', + 'Description is over {{length}} characters': + 'Description is over {{length}} characters', + 'System prompt is over {{length}} characters': + 'System prompt is over {{length}} characters', + // Agents - Creation Wizard Steps + 'Step {{n}}: Choose Location': 'Step {{n}}: Choose Location', + 'Step {{n}}: Choose Generation Method': + 'Step {{n}}: Choose Generation Method', + 'Generate with Qwen Code (Recommended)': + 'Generate with Qwen Code (Recommended)', + 'Manual Creation': 'Manual Creation', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)', + 'e.g., Expert code reviewer that reviews code based on best practices...': + 'e.g., Expert code reviewer that reviews code based on best practices...', + 'Generating subagent configuration...': + 'Generating subagent configuration...', + 'Failed to generate subagent: {{error}}': + 'Failed to generate subagent: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'Step {{n}}: Describe Your Subagent', + 'Step {{n}}: Enter Subagent Name': 'Step {{n}}: Enter Subagent Name', + 'Step {{n}}: Enter System Prompt': 'Step {{n}}: Enter System Prompt', + 'Step {{n}}: Enter Description': 'Step {{n}}: Enter Description', + // Agents - Tool Selection + 'Step {{n}}: Select Tools': 'Step {{n}}: Select Tools', + 'All Tools (Default)': 'All Tools (Default)', + 'All Tools': 'All Tools', + 'Read-only Tools': 'Read-only Tools', + 'Read & Edit Tools': 'Read & Edit Tools', + 'Read & Edit & Execution Tools': 'Read & Edit & Execution Tools', + 'All tools selected, including MCP tools': + 'All tools selected, including MCP tools', + 'Selected tools:': 'Selected tools:', + 'Read-only tools:': 'Read-only tools:', + 'Edit tools:': 'Edit tools:', + 'Execution tools:': 'Execution tools:', + 'Step {{n}}: Choose Background Color': 'Step {{n}}: Choose Background Color', + 'Step {{n}}: Confirm and Save': 'Step {{n}}: Confirm and Save', + // Agents - Navigation & Instructions + 'Esc to cancel': 'Esc to cancel', + 'Press Enter to save, e to save and edit, Esc to go back': + 'Press Enter to save, e to save and edit, Esc to go back', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + 'Press Enter to continue, {{navigation}}Esc to {{action}}', + cancel: 'cancel', + 'go back': 'go back', + '↑↓ to navigate, ': '↑↓ to navigate, ', + 'Enter a clear, unique name for this subagent.': + 'Enter a clear, unique name for this subagent.', + 'e.g., Code Reviewer': 'e.g., Code Reviewer', + 'Name cannot be empty.': 'Name cannot be empty.', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.", + 'e.g., You are an expert code reviewer...': + 'e.g., You are an expert code reviewer...', + 'System prompt cannot be empty.': 'System prompt cannot be empty.', + 'Describe when and how this subagent should be used.': + 'Describe when and how this subagent should be used.', + 'e.g., Reviews code for best practices and potential bugs.': + 'e.g., Reviews code for best practices and potential bugs.', + 'Description cannot be empty.': 'Description cannot be empty.', + 'Failed to launch editor: {{error}}': 'Failed to launch editor: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'Failed to save and edit subagent: {{error}}', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': 'View and edit Qwen Code settings', + Settings: 'Settings', + '(Use Enter to select{{tabText}})': '(Use Enter to select{{tabText}})', + ', Tab to change focus': ', Tab to change focus', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.', + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Vim Mode', + 'Disable Auto Update': 'Disable Auto Update', + 'Enable Prompt Completion': 'Enable Prompt Completion', + 'Debug Keystroke Logging': 'Debug Keystroke Logging', + Language: 'Language', + 'Output Format': 'Output Format', + 'Hide Window Title': 'Hide Window Title', + 'Show Status in Title': 'Show Status in Title', + 'Hide Tips': 'Hide Tips', + 'Hide Banner': 'Hide Banner', + 'Hide Context Summary': 'Hide Context Summary', + 'Hide CWD': 'Hide CWD', + 'Hide Sandbox Status': 'Hide Sandbox Status', + 'Hide Model Info': 'Hide Model Info', + 'Hide Footer': 'Hide Footer', + 'Show Memory Usage': 'Show Memory Usage', + 'Show Line Numbers': 'Show Line Numbers', + 'Show Citations': 'Show Citations', + 'Custom Witty Phrases': 'Custom Witty Phrases', + 'Enable Welcome Back': 'Enable Welcome Back', + 'Disable Loading Phrases': 'Disable Loading Phrases', + 'Screen Reader Mode': 'Screen Reader Mode', + 'IDE Mode': 'IDE Mode', + 'Max Session Turns': 'Max Session Turns', + 'Skip Next Speaker Check': 'Skip Next Speaker Check', + 'Skip Loop Detection': 'Skip Loop Detection', + 'Skip Startup Context': 'Skip Startup Context', + 'Enable OpenAI Logging': 'Enable OpenAI Logging', + 'OpenAI Logging Directory': 'OpenAI Logging Directory', + Timeout: 'Timeout', + 'Max Retries': 'Max Retries', + 'Disable Cache Control': 'Disable Cache Control', + 'Memory Discovery Max Dirs': 'Memory Discovery Max Dirs', + 'Load Memory From Include Directories': + 'Load Memory From Include Directories', + 'Respect .gitignore': 'Respect .gitignore', + 'Respect .qwenignore': 'Respect .qwenignore', + 'Enable Recursive File Search': 'Enable Recursive File Search', + 'Disable Fuzzy Search': 'Disable Fuzzy Search', + 'Enable Interactive Shell': 'Enable Interactive Shell', + 'Show Color': 'Show Color', + 'Auto Accept': 'Auto Accept', + 'Use Ripgrep': 'Use Ripgrep', + 'Use Builtin Ripgrep': 'Use Builtin Ripgrep', + 'Enable Tool Output Truncation': 'Enable Tool Output Truncation', + 'Tool Output Truncation Threshold': 'Tool Output Truncation Threshold', + 'Tool Output Truncation Lines': 'Tool Output Truncation Lines', + 'Folder Trust': 'Folder Trust', + 'Vision Model Preview': 'Vision Model Preview', + // Settings enum options + 'Auto (detect from system)': 'Auto (detect from system)', + Text: 'Text', + JSON: 'JSON', + Plan: 'Plan', + Default: 'Default', + 'Auto Edit': 'Auto Edit', + YOLO: 'YOLO', + 'toggle vim mode on/off': 'toggle vim mode on/off', + 'check session stats. Usage: /stats [model|tools]': + 'check session stats. Usage: /stats [model|tools]', + 'Show model-specific usage statistics.': + 'Show model-specific usage statistics.', + 'Show tool-specific usage statistics.': + 'Show tool-specific usage statistics.', + 'exit the cli': 'exit the cli', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + 'Manage workspace directories': 'Manage workspace directories', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'Add directories to the workspace. Use comma to separate multiple paths', + 'Show all directories in the workspace': + 'Show all directories in the workspace', + 'set external editor preference': 'set external editor preference', + 'Manage extensions': 'Manage extensions', + 'List active extensions': 'List active extensions', + 'Update extensions. Usage: update |--all': + 'Update extensions. Usage: update |--all', + 'manage IDE integration': 'manage IDE integration', + 'check status of IDE integration': 'check status of IDE integration', + 'install required IDE companion for {{ideName}}': + 'install required IDE companion for {{ideName}}', + 'enable IDE integration': 'enable IDE integration', + 'disable IDE integration': 'disable IDE integration', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.', + 'Set up GitHub Actions': 'Set up GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)', + 'Please restart your terminal for the changes to take effect.': + 'Please restart your terminal for the changes to take effect.', + 'Failed to configure terminal: {{error}}': + 'Failed to configure terminal: {{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.', + 'File: {{file}}': 'File: {{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.', + 'Error: {{error}}': 'Error: {{error}}', + 'Shift+Enter binding already exists': 'Shift+Enter binding already exists', + 'Ctrl+Enter binding already exists': 'Ctrl+Enter binding already exists', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'Existing keybindings detected. Will not modify to avoid conflicts.', + 'Please check and modify manually if needed: {{file}}': + 'Please check and modify manually if needed: {{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.', + 'Modified: {{file}}': 'Modified: {{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}} keybindings already configured.', + 'Failed to configure {{terminalName}}.': + 'Failed to configure {{terminalName}}.', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.', + 'Terminal "{{terminal}}" is not supported yet.': + 'Terminal "{{terminal}}" is not supported yet.', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'Invalid language. Available: en-US, zh-CN', + 'Language subcommands do not accept additional arguments.': + 'Language subcommands do not accept additional arguments.', + 'Current UI language: {{lang}}': 'Current UI language: {{lang}}', + 'Current LLM output language: {{lang}}': + 'Current LLM output language: {{lang}}', + 'LLM output language not set': 'LLM output language not set', + 'Set UI language': 'Set UI language', + 'Set LLM output language': 'Set LLM output language', + 'Usage: /language ui [zh-CN|en-US]': 'Usage: /language ui [zh-CN|en-US]', + 'Usage: /language output ': 'Usage: /language output ', + 'Example: /language output 中文': 'Example: /language output 中文', + '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 rule file generated at {{path}}': + 'LLM output language rule file generated at {{path}}', + 'Please restart the application for the changes to take effect.': + 'Please restart the application for the changes to take effect.', + 'Failed to generate LLM output language rule file: {{error}}': + 'Failed to generate LLM output language rule file: {{error}}', + 'Invalid command. Available subcommands:': + 'Invalid command. Available subcommands:', + 'Available subcommands:': 'Available subcommands:', + 'To request additional UI language packs, please open an issue on GitHub.': + 'To request additional UI language packs, please open an issue on GitHub.', + 'Available options:': 'Available options:', + ' - zh-CN: Simplified Chinese': ' - zh-CN: Simplified Chinese', + ' - en-US: English': ' - en-US: English', + 'Set UI language to Simplified Chinese (zh-CN)': + 'Set UI language to Simplified Chinese (zh-CN)', + 'Set UI language to English (en-US)': 'Set UI language to English (en-US)', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Approval Mode': 'Approval Mode', + 'Current approval mode: {{mode}}': 'Current approval mode: {{mode}}', + 'Available approval modes:': 'Available approval modes:', + 'Approval mode changed to: {{mode}}': 'Approval mode changed to: {{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})', + 'Usage: /approval-mode [--session|--user|--project]': + 'Usage: /approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + 'Scope subcommands do not accept additional arguments.', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'Plan mode - Analyze only, do not modify files or execute commands', + 'Default mode - Require approval for file edits or shell commands': + 'Default mode - Require approval for file edits or shell commands', + 'Auto-edit mode - Automatically approve file edits': + 'Auto-edit mode - Automatically approve file edits', + 'YOLO mode - Automatically approve all tools': + 'YOLO mode - Automatically approve all tools', + '{{mode}} mode': '{{mode}} mode', + 'Settings service is not available; unable to persist the approval mode.': + 'Settings service is not available; unable to persist the approval mode.', + 'Failed to save approval mode: {{error}}': + 'Failed to save approval mode: {{error}}', + 'Failed to change approval mode: {{error}}': + 'Failed to change approval mode: {{error}}', + 'Apply to current session only (temporary)': + 'Apply to current session only (temporary)', + 'Persist for this project/workspace': 'Persist for this project/workspace', + 'Persist for this user on this machine': + 'Persist for this user on this machine', + 'Analyze only, do not modify files or execute commands': + 'Analyze only, do not modify files or execute commands', + 'Require approval for file edits or shell commands': + 'Require approval for file edits or shell commands', + 'Automatically approve file edits': 'Automatically approve file edits', + 'Automatically approve all tools': 'Automatically approve all tools', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'Workspace approval mode exists and takes priority. User-level change will have no effect.', + '(Use Enter to select, Tab to change focus)': + '(Use Enter to select, Tab to change focus)', + 'Apply To': 'Apply To', + 'User Settings': 'User Settings', + 'Workspace Settings': 'Workspace Settings', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': + 'Commands for interacting with memory.', + 'Show the current memory contents.': 'Show the current memory contents.', + 'Show project-level memory contents.': 'Show project-level memory contents.', + 'Show global memory contents.': 'Show global memory contents.', + 'Add content to project-level memory.': + 'Add content to project-level memory.', + 'Add content to global memory.': 'Add content to global memory.', + 'Refresh the memory from the source.': 'Refresh the memory from the source.', + 'Usage: /memory add --project ': + 'Usage: /memory add --project ', + 'Usage: /memory add --global ': + 'Usage: /memory add --global ', + 'Attempting to save to project memory: "{{text}}"': + 'Attempting to save to project memory: "{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'Attempting to save to global memory: "{{text}}"', + 'Current memory content from {{count}} file(s):': + 'Current memory content from {{count}} file(s):', + 'Memory is currently empty.': 'Memory is currently empty.', + 'Project memory file not found or is currently empty.': + 'Project memory file not found or is currently empty.', + 'Global memory file not found or is currently empty.': + 'Global memory file not found or is currently empty.', + 'Global memory is currently empty.': 'Global memory is currently empty.', + 'Global memory content:\n\n---\n{{content}}\n---': + 'Global memory content:\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': 'Project memory is currently empty.', + 'Refreshing memory from source files...': + 'Refreshing memory from source files...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'Add content to the memory. Use --global for global memory or --project for project memory.', + 'Usage: /memory add [--global|--project] ': + 'Usage: /memory add [--global|--project] ', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'Attempting to save to memory {{scope}}: "{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'Authenticate with an OAuth-enabled MCP server', + 'List configured MCP servers and tools': + 'List configured MCP servers and tools', + 'Restarts MCP servers.': 'Restarts MCP servers.', + 'Config not loaded.': 'Config not loaded.', + 'Could not retrieve tool registry.': 'Could not retrieve tool registry.', + 'No MCP servers configured with OAuth authentication.': + 'No MCP servers configured with OAuth authentication.', + 'MCP servers with OAuth authentication:': + 'MCP servers with OAuth authentication:', + 'Use /mcp auth to authenticate.': + 'Use /mcp auth to authenticate.', + "MCP server '{{name}}' not found.": "MCP server '{{name}}' not found.", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "Successfully authenticated and refreshed tools for '{{name}}'.", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "Failed to authenticate with MCP server '{{name}}': {{error}}", + "Re-discovering tools from '{{name}}'...": + "Re-discovering tools from '{{name}}'...", + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': 'Manage conversation history.', + 'List saved conversation checkpoints': 'List saved conversation checkpoints', + 'No saved conversation checkpoints found.': + 'No saved conversation checkpoints found.', + 'List of saved conversations:': 'List of saved conversations:', + 'Note: Newest last, oldest first': 'Note: Newest last, oldest first', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + 'Save the current conversation as a checkpoint. Usage: /chat save ', + 'Missing tag. Usage: /chat save ': + 'Missing tag. Usage: /chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'Delete a conversation checkpoint. Usage: /chat delete ', + 'Missing tag. Usage: /chat delete ': + 'Missing tag. Usage: /chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "Conversation checkpoint '{{tag}}' has been deleted.", + "Error: No checkpoint found with tag '{{tag}}'.": + "Error: No checkpoint found with tag '{{tag}}'.", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'Resume a conversation from a checkpoint. Usage: /chat resume ', + 'Missing tag. Usage: /chat resume ': + 'Missing tag. Usage: /chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + 'No saved checkpoint found with tag: {{tag}}.', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?', + 'No chat client available to save conversation.': + 'No chat client available to save conversation.', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'Conversation checkpoint saved with tag: {{tag}}.', + 'No conversation found to save.': 'No conversation found to save.', + 'No chat client available to share conversation.': + 'No chat client available to share conversation.', + 'Invalid file format. Only .md and .json are supported.': + 'Invalid file format. Only .md and .json are supported.', + 'Error sharing conversation: {{error}}': + 'Error sharing conversation: {{error}}', + 'Conversation shared to {{filePath}}': 'Conversation shared to {{filePath}}', + 'No conversation found to share.': 'No conversation found to share.', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + 'Share the current conversation to a markdown or json file. Usage: /chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'No chat client available to generate summary.', + 'Already generating summary, wait for previous request to complete': + 'Already generating summary, wait for previous request to complete', + 'No conversation found to summarize.': 'No conversation found to summarize.', + 'Failed to generate project context summary: {{error}}': + 'Failed to generate project context summary: {{error}}', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': 'Switch the model for this session', + 'Content generator configuration not available.': + 'Content generator configuration not available.', + 'Authentication type not available.': 'Authentication type not available.', + 'No models available for the current authentication type ({{authType}}).': + 'No models available for the current authentication type ({{authType}}).', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Clearing terminal and resetting chat.': + 'Clearing terminal and resetting chat.', + 'Clearing terminal.': 'Clearing terminal.', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'Already compressing, wait for previous request to complete', + 'Failed to compress chat history.': 'Failed to compress chat history.', + 'Failed to compress chat history: {{error}}': + 'Failed to compress chat history: {{error}}', + 'Compressing chat history': 'Compressing chat history', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.', + 'Compression was not beneficial for this history size.': + 'Compression was not beneficial for this history size.', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.', + 'Could not compress chat history due to a token counting error.': + 'Could not compress chat history due to a token counting error.', + 'Chat history is already compressed.': 'Chat history is already compressed.', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': 'Configuration is not available.', + 'Please provide at least one path to add.': + 'Please provide at least one path to add.', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + "Error adding '{{path}}': {{error}}": "Error adding '{{path}}': {{error}}", + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}', + 'Error refreshing memory: {{error}}': 'Error refreshing memory: {{error}}', + 'Successfully added directories:\n- {{directories}}': + 'Successfully added directories:\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'Current workspace directories:\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + 'Please open the following URL in your browser to view the documentation:\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'Opening documentation in your browser: {{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': 'Do you want to proceed?', + 'Yes, allow once': 'Yes, allow once', + 'Allow always': 'Allow always', + No: 'No', + 'No (esc)': 'No (esc)', + 'Yes, allow always for this session': 'Yes, allow always for this session', + 'Modify in progress:': 'Modify in progress:', + 'Save and close external editor to continue': + 'Save and close external editor to continue', + 'Apply this change?': 'Apply this change?', + 'Yes, allow always': 'Yes, allow always', + 'Modify with external editor': 'Modify with external editor', + 'No, suggest changes (esc)': 'No, suggest changes (esc)', + "Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?", + 'Yes, allow always ...': 'Yes, allow always ...', + 'Yes, and auto-accept edits': 'Yes, and auto-accept edits', + 'Yes, and manually approve edits': 'Yes, and manually approve edits', + 'No, keep planning (esc)': 'No, keep planning (esc)', + 'URLs to fetch:': 'URLs to fetch:', + 'MCP Server: {{server}}': 'MCP Server: {{server}}', + 'Tool: {{tool}}': 'Tool: {{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + 'Yes, always allow tool "{{tool}}" from server "{{server}}"', + 'Yes, always allow all tools from server "{{server}}"': + 'Yes, always allow all tools from server "{{server}}"', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Shell Command Execution', + 'A custom command wants to run the following shell commands:': + 'A custom command wants to run the following shell commands:', + + // ============================================================================ + // Dialogs - Quit Confirmation + // ============================================================================ + 'What would you like to do before exiting?': + 'What would you like to do before exiting?', + 'Quit immediately (/quit)': 'Quit immediately (/quit)', + 'Generate summary and quit (/summary)': + 'Generate summary and quit (/summary)', + 'Save conversation and quit (/chat save)': + 'Save conversation and quit (/chat save)', + 'Cancel (stay in application)': 'Cancel (stay in application)', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': + 'Pro quota limit reached for {{model}}.', + 'Change auth (executes the /auth command)': + 'Change auth (executes the /auth command)', + 'Continue with {{model}}': 'Continue with {{model}}', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': 'Current Plan:', + 'Progress: {{done}}/{{total}} tasks completed': + 'Progress: {{done}}/{{total}} tasks completed', + ', {{inProgress}} in progress': ', {{inProgress}} in progress', + 'Pending Tasks:': 'Pending Tasks:', + 'What would you like to do?': 'What would you like to do?', + 'Choose how to proceed with your session:': + 'Choose how to proceed with your session:', + 'Start new chat session': 'Start new chat session', + 'Continue previous conversation': 'Continue previous conversation', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 Welcome back! (Last updated: {{timeAgo}})', + 'đŸŽ¯ Overall Goal:': 'đŸŽ¯ Overall Goal:', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': 'Get started', + 'How would you like to authenticate for this project?': + 'How would you like to authenticate for this project?', + 'OpenAI API key is required to use OpenAI authentication.': + 'OpenAI API key is required to use OpenAI authentication.', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + 'You must select an auth method to proceed. Press Ctrl+C again to exit.', + '(Use Enter to Set Auth)': '(Use Enter to Set Auth)', + 'Terms of Services and Privacy Notice for Qwen Code': + 'Terms of Services and Privacy Notice for Qwen Code', + 'Qwen OAuth': 'Qwen OAuth', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': + 'Failed to login. Message: {{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', + 'Qwen OAuth authentication timed out. Please try again.': + 'Qwen OAuth authentication timed out. Please try again.', + 'Qwen OAuth authentication cancelled.': + 'Qwen OAuth authentication cancelled.', + 'Qwen OAuth Authentication': 'Qwen OAuth Authentication', + 'Please visit this URL to authorize:': 'Please visit this URL to authorize:', + 'Or scan the QR code below:': 'Or scan the QR code below:', + 'Waiting for authorization': 'Waiting for authorization', + 'Time remaining:': 'Time remaining:', + '(Press ESC or CTRL+C to cancel)': '(Press ESC or CTRL+C to cancel)', + 'Qwen OAuth Authentication Timeout': 'Qwen OAuth Authentication Timeout', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.', + 'Press any key to return to authentication type selection.': + 'Press any key to return to authentication type selection.', + 'Waiting for Qwen OAuth authentication...': + 'Waiting for Qwen OAuth authentication...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', + 'Authentication timed out. Please try again.': + 'Authentication timed out. Please try again.', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'Waiting for auth... (Press ESC or CTRL+C to cancel)', + 'Failed to authenticate. Message: {{message}}': + 'Failed to authenticate. Message: {{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'Authenticated successfully with {{authType}} credentials.', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}', + 'OpenAI Configuration Required': 'OpenAI Configuration Required', + 'Please enter your OpenAI configuration. You can get an API key from': + 'Please enter your OpenAI configuration. You can get an API key from', + 'API Key:': 'API Key:', + 'Invalid credentials: {{errorMessage}}': + 'Invalid credentials: {{errorMessage}}', + 'Failed to validate credentials': 'Failed to validate credentials', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': 'Select Model', + '(Press Esc to close)': '(Press Esc to close)', + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': 'Manage folder trust settings', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': 'Using:', + '{{count}} open file': '{{count}} open file', + '{{count}} open files': '{{count}} open files', + '(ctrl+g to view)': '(ctrl+g to view)', + '{{count}} {{name}} file': '{{count}} {{name}} file', + '{{count}} {{name}} files': '{{count}} {{name}} files', + '{{count}} MCP server': '{{count}} MCP server', + '{{count}} MCP servers': '{{count}} MCP servers', + '{{count}} Blocked': '{{count}} Blocked', + '(ctrl+t to view)': '(ctrl+t to view)', + '(ctrl+t to toggle)': '(ctrl+t to toggle)', + 'Press Ctrl+C again to exit.': 'Press Ctrl+C again to exit.', + 'Press Ctrl+D again to exit.': 'Press Ctrl+D again to exit.', + 'Press Esc again to clear.': 'Press Esc again to clear.', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': 'No MCP servers configured.', + 'Please view MCP documentation in your browser:': + 'Please view MCP documentation in your browser:', + 'or use the cli /docs command': 'or use the cli /docs command', + 'âŗ MCP servers are starting up ({{count}} initializing)...': + 'âŗ MCP servers are starting up ({{count}} initializing)...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'Note: First startup may take longer. Tool availability will update automatically.', + 'Configured MCP servers:': 'Configured MCP servers:', + Ready: 'Ready', + 'Starting... (first startup may take longer)': + 'Starting... (first startup may take longer)', + Disconnected: 'Disconnected', + '{{count}} tool': '{{count}} tool', + '{{count}} tools': '{{count}} tools', + '{{count}} prompt': '{{count}} prompt', + '{{count}} prompts': '{{count}} prompts', + '(from {{extensionName}})': '(from {{extensionName}})', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth expired', + 'OAuth not authenticated': 'OAuth not authenticated', + 'tools and prompts will appear when ready': + 'tools and prompts will appear when ready', + '{{count}} tools cached': '{{count}} tools cached', + 'Tools:': 'Tools:', + 'Parameters:': 'Parameters:', + 'Prompts:': 'Prompts:', + Blocked: 'Blocked', + '💡 Tips:': '💡 Tips:', + Use: 'Use', + 'to show server and tool descriptions': + 'to show server and tool descriptions', + 'to show tool parameter schemas': 'to show tool parameter schemas', + 'to hide descriptions': 'to hide descriptions', + 'to authenticate with OAuth-enabled servers': + 'to authenticate with OAuth-enabled servers', + Press: 'Press', + 'to toggle tool descriptions on/off': 'to toggle tool descriptions on/off', + "Starting OAuth authentication for MCP server '{{name}}'...": + "Starting OAuth authentication for MCP server '{{name}}'...", + 'Restarting MCP servers...': 'Restarting MCP servers...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips for getting started:': 'Tips for getting started:', + '1. Ask questions, edit files, or run commands.': + '1. Ask questions, edit files, or run commands.', + '2. Be specific for the best results.': + '2. Be specific for the best results.', + 'files to customize your interactions with Qwen Code.': + 'files to customize your interactions with Qwen Code.', + 'for more information.': 'for more information.', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': 'Agent powering down. Goodbye!', + 'Interaction Summary': 'Interaction Summary', + 'Session ID:': 'Session ID:', + 'Tool Calls:': 'Tool Calls:', + 'Success Rate:': 'Success Rate:', + 'User Agreement:': 'User Agreement:', + reviewed: 'reviewed', + 'Code Changes:': 'Code Changes:', + Performance: 'Performance', + 'Wall Time:': 'Wall Time:', + 'Agent Active:': 'Agent Active:', + 'API Time:': 'API Time:', + 'Tool Time:': 'Tool Time:', + 'Session Stats': 'Session Stats', + 'Model Usage': 'Model Usage', + Reqs: 'Reqs', + 'Input Tokens': 'Input Tokens', + 'Output Tokens': 'Output Tokens', + 'Savings Highlight:': 'Savings Highlight:', + 'of input tokens were served from the cache, reducing costs.': + 'of input tokens were served from the cache, reducing costs.', + 'Tip: For a full token breakdown, run `/stats model`.': + 'Tip: For a full token breakdown, run `/stats model`.', + 'Model Stats For Nerds': 'Model Stats For Nerds', + 'Tool Stats For Nerds': 'Tool Stats For Nerds', + Metric: 'Metric', + API: 'API', + Requests: 'Requests', + Errors: 'Errors', + 'Avg Latency': 'Avg Latency', + Tokens: 'Tokens', + Total: 'Total', + Prompt: 'Prompt', + Cached: 'Cached', + Thoughts: 'Thoughts', + Tool: 'Tool', + Output: 'Output', + 'No API calls have been made in this session.': + 'No API calls have been made in this session.', + 'Tool Name': 'Tool Name', + Calls: 'Calls', + 'Success Rate': 'Success Rate', + 'Avg Duration': 'Avg Duration', + 'User Decision Summary': 'User Decision Summary', + 'Total Reviewed Suggestions:': 'Total Reviewed Suggestions:', + ' Âģ Accepted:': ' Âģ Accepted:', + ' Âģ Rejected:': ' Âģ Rejected:', + ' Âģ Modified:': ' Âģ Modified:', + ' Overall Agreement Rate:': ' Overall Agreement Rate:', + 'No tool calls have been made in this session.': + 'No tool calls have been made in this session.', + 'Session start time is unavailable, cannot calculate stats.': + 'Session start time is unavailable, cannot calculate stats.', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': 'Waiting for user confirmation...', + '(esc to cancel, {{time}})': '(esc to cancel, {{time}})', + "I'm Feeling Lucky": "I'm Feeling Lucky", + 'Shipping awesomeness... ': 'Shipping awesomeness... ', + 'Painting the serifs back on...': 'Painting the serifs back on...', + 'Navigating the slime mold...': 'Navigating the slime mold...', + 'Consulting the digital spirits...': 'Consulting the digital spirits...', + 'Reticulating splines...': 'Reticulating splines...', + 'Warming up the AI hamsters...': 'Warming up the AI hamsters...', + 'Asking the magic conch shell...': 'Asking the magic conch shell...', + 'Generating witty retort...': 'Generating witty retort...', + 'Polishing the algorithms...': 'Polishing the algorithms...', + "Don't rush perfection (or my code)...": + "Don't rush perfection (or my code)...", + 'Brewing fresh bytes...': 'Brewing fresh bytes...', + 'Counting electrons...': 'Counting electrons...', + 'Engaging cognitive processors...': 'Engaging cognitive processors...', + 'Checking for syntax errors in the universe...': + 'Checking for syntax errors in the universe...', + 'One moment, optimizing humor...': 'One moment, optimizing humor...', + 'Shuffling punchlines...': 'Shuffling punchlines...', + 'Untangling neural nets...': 'Untangling neural nets...', + 'Compiling brilliance...': 'Compiling brilliance...', + 'Loading wit.exe...': 'Loading wit.exe...', + 'Summoning the cloud of wisdom...': 'Summoning the cloud of wisdom...', + 'Preparing a witty response...': 'Preparing a witty response...', + "Just a sec, I'm debugging reality...": + "Just a sec, I'm debugging reality...", + 'Confuzzling the options...': 'Confuzzling the options...', + 'Tuning the cosmic frequencies...': 'Tuning the cosmic frequencies...', + 'Crafting a response worthy of your patience...': + 'Crafting a response worthy of your patience...', + 'Compiling the 1s and 0s...': 'Compiling the 1s and 0s...', + 'Resolving dependencies... and existential crises...': + 'Resolving dependencies... and existential crises...', + 'Defragmenting memories... both RAM and personal...': + 'Defragmenting memories... both RAM and personal...', + 'Rebooting the humor module...': 'Rebooting the humor module...', + 'Caching the essentials (mostly cat memes)...': + 'Caching the essentials (mostly cat memes)...', + 'Optimizing for ludicrous speed': 'Optimizing for ludicrous speed', + "Swapping bits... don't tell the bytes...": + "Swapping bits... don't tell the bytes...", + 'Garbage collecting... be right back...': + 'Garbage collecting... be right back...', + 'Assembling the interwebs...': 'Assembling the interwebs...', + 'Converting coffee into code...': 'Converting coffee into code...', + 'Updating the syntax for reality...': 'Updating the syntax for reality...', + 'Rewiring the synapses...': 'Rewiring the synapses...', + 'Looking for a misplaced semicolon...': + 'Looking for a misplaced semicolon...', + "Greasin' the cogs of the machine...": "Greasin' the cogs of the machine...", + 'Pre-heating the servers...': 'Pre-heating the servers...', + 'Calibrating the flux capacitor...': 'Calibrating the flux capacitor...', + 'Engaging the improbability drive...': 'Engaging the improbability drive...', + 'Channeling the Force...': 'Channeling the Force...', + 'Aligning the stars for optimal response...': + 'Aligning the stars for optimal response...', + 'So say we all...': 'So say we all...', + 'Loading the next great idea...': 'Loading the next great idea...', + "Just a moment, I'm in the zone...": "Just a moment, I'm in the zone...", + 'Preparing to dazzle you with brilliance...': + 'Preparing to dazzle you with brilliance...', + "Just a tick, I'm polishing my wit...": + "Just a tick, I'm polishing my wit...", + "Hold tight, I'm crafting a masterpiece...": + "Hold tight, I'm crafting a masterpiece...", + "Just a jiffy, I'm debugging the universe...": + "Just a jiffy, I'm debugging the universe...", + "Just a moment, I'm aligning the pixels...": + "Just a moment, I'm aligning the pixels...", + "Just a sec, I'm optimizing the humor...": + "Just a sec, I'm optimizing the humor...", + "Just a moment, I'm tuning the algorithms...": + "Just a moment, I'm tuning the algorithms...", + 'Warp speed engaged...': 'Warp speed engaged...', + 'Mining for more Dilithium crystals...': + 'Mining for more Dilithium crystals...', + "Don't panic...": "Don't panic...", + 'Following the white rabbit...': 'Following the white rabbit...', + 'The truth is in here... somewhere...': + 'The truth is in here... somewhere...', + 'Blowing on the cartridge...': 'Blowing on the cartridge...', + 'Loading... Do a barrel roll!': 'Loading... Do a barrel roll!', + 'Waiting for the respawn...': 'Waiting for the respawn...', + 'Finishing the Kessel Run in less than 12 parsecs...': + 'Finishing the Kessel Run in less than 12 parsecs...', + "The cake is not a lie, it's just still loading...": + "The cake is not a lie, it's just still loading...", + 'Fiddling with the character creation screen...': + 'Fiddling with the character creation screen...', + "Just a moment, I'm finding the right meme...": + "Just a moment, I'm finding the right meme...", + "Pressing 'A' to continue...": "Pressing 'A' to continue...", + 'Herding digital cats...': 'Herding digital cats...', + 'Polishing the pixels...': 'Polishing the pixels...', + 'Finding a suitable loading screen pun...': + 'Finding a suitable loading screen pun...', + 'Distracting you with this witty phrase...': + 'Distracting you with this witty phrase...', + 'Almost there... probably...': 'Almost there... probably...', + 'Our hamsters are working as fast as they can...': + 'Our hamsters are working as fast as they can...', + 'Giving Cloudy a pat on the head...': 'Giving Cloudy a pat on the head...', + 'Petting the cat...': 'Petting the cat...', + 'Rickrolling my boss...': 'Rickrolling my boss...', + 'Never gonna give you up, never gonna let you down...': + 'Never gonna give you up, never gonna let you down...', + 'Slapping the bass...': 'Slapping the bass...', + 'Tasting the snozberries...': 'Tasting the snozberries...', + "I'm going the distance, I'm going for speed...": + "I'm going the distance, I'm going for speed...", + 'Is this the real life? Is this just fantasy?...': + 'Is this the real life? Is this just fantasy?...', + "I've got a good feeling about this...": + "I've got a good feeling about this...", + 'Poking the bear...': 'Poking the bear...', + 'Doing research on the latest memes...': + 'Doing research on the latest memes...', + 'Figuring out how to make this more witty...': + 'Figuring out how to make this more witty...', + 'Hmmm... let me think...': 'Hmmm... let me think...', + 'What do you call a fish with no eyes? A fsh...': + 'What do you call a fish with no eyes? A fsh...', + 'Why did the computer go to therapy? It had too many bytes...': + 'Why did the computer go to therapy? It had too many bytes...', + "Why don't programmers like nature? It has too many bugs...": + "Why don't programmers like nature? It has too many bugs...", + 'Why do programmers prefer dark mode? Because light attracts bugs...': + 'Why do programmers prefer dark mode? Because light attracts bugs...', + 'Why did the developer go broke? Because they used up all their cache...': + 'Why did the developer go broke? Because they used up all their cache...', + "What can you do with a broken pencil? Nothing, it's pointless...": + "What can you do with a broken pencil? Nothing, it's pointless...", + 'Applying percussive maintenance...': 'Applying percussive maintenance...', + 'Searching for the correct USB orientation...': + 'Searching for the correct USB orientation...', + 'Ensuring the magic smoke stays inside the wires...': + 'Ensuring the magic smoke stays inside the wires...', + 'Rewriting in Rust for no particular reason...': + 'Rewriting in Rust for no particular reason...', + 'Trying to exit Vim...': 'Trying to exit Vim...', + 'Spinning up the hamster wheel...': 'Spinning up the hamster wheel...', + "That's not a bug, it's an undocumented feature...": + "That's not a bug, it's an undocumented feature...", + 'Engage.': 'Engage.', + "I'll be back... with an answer.": "I'll be back... with an answer.", + 'My other process is a TARDIS...': 'My other process is a TARDIS...', + 'Communing with the machine spirit...': + 'Communing with the machine spirit...', + 'Letting the thoughts marinate...': 'Letting the thoughts marinate...', + 'Just remembered where I put my keys...': + 'Just remembered where I put my keys...', + 'Pondering the orb...': 'Pondering the orb...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.": + "I've seen things you people wouldn't believe... like a user who reads loading messages.", + 'Initiating thoughtful gaze...': 'Initiating thoughtful gaze...', + "What's a computer's favorite snack? Microchips.": + "What's a computer's favorite snack? Microchips.", + "Why do Java developers wear glasses? Because they don't C#.": + "Why do Java developers wear glasses? Because they don't C#.", + 'Charging the laser... pew pew!': 'Charging the laser... pew pew!', + 'Dividing by zero... just kidding!': 'Dividing by zero... just kidding!', + 'Looking for an adult superviso... I mean, processing.': + 'Looking for an adult superviso... I mean, processing.', + 'Making it go beep boop.': 'Making it go beep boop.', + 'Buffering... because even AIs need a moment.': + 'Buffering... because even AIs need a moment.', + 'Entangling quantum particles for a faster response...': + 'Entangling quantum particles for a faster response...', + 'Polishing the chrome... on the algorithms.': + 'Polishing the chrome... on the algorithms.', + 'Are you not entertained? (Working on it!)': + 'Are you not entertained? (Working on it!)', + 'Summoning the code gremlins... to help, of course.': + 'Summoning the code gremlins... to help, of course.', + 'Just waiting for the dial-up tone to finish...': + 'Just waiting for the dial-up tone to finish...', + 'Recalibrating the humor-o-meter.': 'Recalibrating the humor-o-meter.', + 'My other loading screen is even funnier.': + 'My other loading screen is even funnier.', + "Pretty sure there's a cat walking on the keyboard somewhere...": + "Pretty sure there's a cat walking on the keyboard somewhere...", + 'Enhancing... Enhancing... Still loading.': + 'Enhancing... Enhancing... Still loading.', + "It's not a bug, it's a feature... of this loading screen.": + "It's not a bug, it's a feature... of this loading screen.", + 'Have you tried turning it off and on again? (The loading screen, not me.)': + 'Have you tried turning it off and on again? (The loading screen, not me.)', + 'Constructing additional pylons...': 'Constructing additional pylons...', +}; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js new file mode 100644 index 00000000..474753ae --- /dev/null +++ b/packages/cli/src/i18n/locales/zh.js @@ -0,0 +1,1052 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +// Chinese translations for Qwen Code CLI + +export default { + // ============================================================================ + // Help / UI Components + // ============================================================================ + 'Basics:': 'åŸēįĄ€åŠŸčƒŊīŧš', + 'Add context': 'æˇģ加上下文', + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.': + 'äŊŋᔍ {{symbol}} 指厚文äģļäŊœä¸ē上下文īŧˆäž‹åĻ‚īŧŒ{{example}}īŧ‰īŧŒį”¨äēŽåޚäŊį‰šåŽšæ–‡äģ￈–æ–‡äģļ多', + '@': '@', + '@src/myFile.ts': '@src/myFile.ts', + 'Shell mode': 'Shell æ¨Ąåŧ', + 'YOLO mode': 'YOLO æ¨Ąåŧ', + 'plan mode': 'č§„åˆ’æ¨Ąåŧ', + 'auto-accept edits': 'č‡Ē动æŽĨ受įŧ–čž‘', + 'Accepting edits': 'æŽĨ受įŧ–čž‘', + '(shift + tab to cycle)': '(shift + tab 切æĸ)', + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).': + '通čŋ‡ {{symbol}} æ‰§čĄŒ shell å‘Ŋäģ¤īŧˆäž‹åĻ‚īŧŒ{{example1}}īŧ‰æˆ–äŊŋᔍč‡Ēį„ļ蝭荀īŧˆäž‹åĻ‚īŧŒ{{example2}}īŧ‰', + '!': '!', + '!npm run start': '!npm run start', + 'start server': 'start server', + 'Commands:': 'å‘Ŋäģ¤:', + 'shell command': 'shell å‘Ŋäģ¤', + 'Model Context Protocol command (from external servers)': + 'æ¨Ąåž‹ä¸Šä¸‹æ–‡åčŽŽå‘Ŋäģ¤īŧˆæĨč‡Ēå¤–éƒ¨æœåŠĄå™¨īŧ‰', + 'Keyboard Shortcuts:': 'é”Žį›˜åŋĢæˇé”Žīŧš', + 'Jump through words in the input': 'åœ¨čž“å…Ĩä¸­æŒ‰å•č¯čˇŗčŊŦ', + 'Close dialogs, cancel requests, or quit application': + 'å…ŗé—­å¯šč¯æĄ†ã€å–æļˆč¯ˇæą‚或退å‡ēåē”ᔍፋåē', + 'New line': 'æĸ行', + 'New line (Alt+Enter works for certain linux distros)': + 'æĸ行īŧˆæŸäē› Linux å‘čĄŒį‰ˆæ”¯æŒ Alt+Enterīŧ‰', + 'Clear the screen': 'æ¸…åą', + 'Open input in external editor': '在外部įŧ–čž‘å™¨ä¸­æ‰“åŧ€čž“å…Ĩ', + 'Send message': '发送æļˆæ¯', + 'Initializing...': 'æ­Ŗåœ¨åˆå§‹åŒ–...', + 'Connecting to MCP servers... ({{connected}}/{{total}})': + 'æ­Ŗåœ¨čŋžæŽĨ到 MCP æœåŠĄå™¨... ({{connected}}/{{total}})', + 'Type your message or @path/to/file': '输å…Ĩæ‚¨įš„æļˆæ¯æˆ– @ 文äģļčˇ¯åž„', + "Press 'i' for INSERT mode and 'Esc' for NORMAL mode.": + "按 'i' čŋ›å…Ĩ插å…Ĩæ¨ĄåŧīŧŒæŒ‰ 'Esc' čŋ›å…Ĩæ™Žé€šæ¨Ąåŧ", + 'Cancel operation / Clear input (double press)': + '取æļˆæ“äŊœ / 清įŠē输å…ĨīŧˆåŒå‡ģīŧ‰', + 'Cycle approval modes': 'åžĒįŽ¯åˆ‡æĸåŽĄæ‰šæ¨Ąåŧ', + 'Cycle through your prompt history': 'åžĒįŽ¯æĩč§ˆæį¤ēåŽ†å˛', + 'For a full list of shortcuts, see {{docPath}}': + '厌整åŋĢæˇé”Žåˆ—襨īŧŒč¯ˇå‚阅 {{docPath}}', + 'docs/keyboard-shortcuts.md': 'docs/keyboard-shortcuts.md', + 'for help on Qwen Code': 'čŽˇå– Qwen Code 帎劊', + 'show version info': '昞į¤ēį‰ˆæœŦäŋĄæ¯', + 'submit a bug report': '提äē¤é”™č¯¯æŠĨ告', + 'About Qwen Code': 'å…ŗäēŽ Qwen Code', + + // ============================================================================ + // System Information Fields + // ============================================================================ + 'CLI Version': 'CLI į‰ˆæœŦ', + 'Git Commit': 'Git 提äē¤', + Model: 'æ¨Ąåž‹', + Sandbox: 'æ˛™įŽą', + 'OS Platform': '操äŊœįŗģįģŸåšŗå°', + 'OS Arch': '操äŊœįŗģį쟿žļ构', + 'OS Release': '操äŊœįŗģįģŸį‰ˆæœŦ', + 'Node.js Version': 'Node.js į‰ˆæœŦ', + 'NPM Version': 'NPM į‰ˆæœŦ', + 'Session ID': 'äŧšč¯ ID', + 'Auth Method': 'čŽ¤č¯æ–šåŧ', + 'Base URL': 'åŸēįĄ€ URL', + 'Memory Usage': '内存äŊŋᔍ', + 'IDE Client': 'IDE åŽĸæˆˇį̝', + + // ============================================================================ + // Commands - General + // ============================================================================ + 'Analyzes the project and creates a tailored QWEN.md file.': + 'åˆ†æžéĄšį›Žåšļ创åģē厚åˆļįš„ QWEN.md 文äģļ', + 'list available Qwen Code tools. Usage: /tools [desc]': + '列å‡ēå¯į”¨įš„ Qwen Code åˇĨå…ˇã€‚į”¨æŗ•īŧš/tools [desc]', + 'Available Qwen Code CLI tools:': 'å¯į”¨įš„ Qwen Code CLI åˇĨå…ˇīŧš', + 'No tools available': 'æ˛Ąæœ‰å¯į”¨åˇĨå…ˇ', + 'View or change the approval mode for tool usage': + 'æŸĨįœ‹æˆ–æ›´æ”šåˇĨå…ˇäŊŋį”¨įš„åŽĄæ‰šæ¨Ąåŧ', + 'View or change the language setting': 'æŸĨįœ‹æˆ–æ›´æ”šč¯­č¨€čŽžįŊŽ', + 'change the theme': '更攚ä¸ģéĸ˜', + 'Select Theme': '选拊ä¸ģéĸ˜', + Preview: 'éĸ„č§ˆ', + '(Use Enter to select, Tab to configure scope)': + 'īŧˆäŊŋᔍ Enter 选拊īŧŒTab 配įŊŽäŊœį”¨åŸŸīŧ‰', + '(Use Enter to apply scope, Tab to select theme)': + 'īŧˆäŊŋᔍ Enter åē”ᔍäŊœį”¨åŸŸīŧŒTab 选拊ä¸ģéĸ˜īŧ‰', + 'Theme configuration unavailable due to NO_COLOR env variable.': + 'į”ąäēŽ NO_COLOR įŽ¯åĸƒå˜é‡īŧŒä¸ģéĸ˜é…įŊŽä¸å¯į”¨ã€‚', + 'Theme "{{themeName}}" not found.': 'æœĒ扞到ä¸ģéĸ˜ "{{themeName}}"。', + 'Theme "{{themeName}}" not found in selected scope.': + '在所选äŊœį”¨åŸŸä¸­æœĒ扞到ä¸ģéĸ˜ "{{themeName}}"。', + 'clear the screen and conversation history': 'æ¸…åąåšļæ¸…é™¤å¯šč¯åŽ†å˛', + 'Compresses the context by replacing it with a summary.': + '通čŋ‡į”¨æ‘˜čρæ›ŋæĸæĨ压įŧŠä¸Šä¸‹æ–‡', + 'open full Qwen Code documentation in your browser': + '在æĩč§ˆå™¨ä¸­æ‰“åŧ€åŽŒæ•´įš„ Qwen Code æ–‡æĄŖ', + 'Configuration not available.': '配įŊŽä¸å¯į”¨', + 'change the auth method': 'æ›´æ”ščŽ¤č¯æ–šæŗ•', + 'Show quit confirmation dialog': '昞į¤ē退å‡ēįĄŽčŽ¤å¯šč¯æĄ†', + 'Copy the last result or code snippet to clipboard': + 'å°†æœ€åŽįš„į쓿žœæˆ–äģŖį į‰‡æŽĩ复åˆļ到å‰Ēč´´æŋ', + + // ============================================================================ + // Commands - Agents + // ============================================================================ + 'Manage subagents for specialized task delegation.': + 'įŽĄį†į”¨äēŽä¸“é—¨äģģåŠĄå§”æ´žįš„å­äģŖį†', + 'Manage existing subagents (view, edit, delete).': + 'įŽĄį†įŽ°æœ‰å­äģŖį†īŧˆæŸĨįœ‹ã€įŧ–čž‘ã€åˆ é™¤īŧ‰', + 'Create a new subagent with guided setup.': '通čŋ‡åŧ•å¯ŧåŧčŽžįŊŽåˆ›åģēæ–°įš„子äģŖį†', + + // ============================================================================ + // Agents - Management Dialog + // ============================================================================ + Agents: 'äģŖį†', + 'Choose Action': '选拊操äŊœ', + 'Edit {{name}}': 'įŧ–čž‘ {{name}}', + 'Edit Tools: {{name}}': 'įŧ–čž‘åˇĨå…ˇ: {{name}}', + 'Edit Color: {{name}}': 'įŧ–čž‘éĸœč‰˛: {{name}}', + 'Delete {{name}}': '删除 {{name}}', + 'Unknown Step': 'æœĒįŸĨæ­ĨéǤ', + 'Esc to close': '按 Esc å…ŗé—­', + 'Enter to select, ↑↓ to navigate, Esc to close': + 'Enter 选拊īŧŒâ†‘↓ å¯ŧčˆĒīŧŒEsc å…ŗé—­', + 'Esc to go back': '按 Esc čŋ”回', + 'Enter to confirm, Esc to cancel': 'Enter įĄŽčŽ¤īŧŒEsc 取æļˆ', + 'Enter to select, ↑↓ to navigate, Esc to go back': + 'Enter 选拊īŧŒâ†‘↓ å¯ŧčˆĒīŧŒEsc čŋ”回', + 'Invalid step: {{step}}': '无效æ­ĨéǤ: {{step}}', + 'No subagents found.': 'æœĒ扞到子äģŖį†ã€‚', + "Use '/agents create' to create your first subagent.": + "äŊŋᔍ '/agents create' 创åģēæ‚¨įš„įŦŦ一ä¸Ē子äģŖį†ã€‚", + '(built-in)': 'īŧˆå†…įŊŽīŧ‰', + '(overridden by project level agent)': 'īŧˆåˇ˛čĸĢéĄšį›Žįē§äģŖį†čφᛖīŧ‰', + 'Project Level ({{path}})': 'éĄšį›Žįē§ ({{path}})', + 'User Level ({{path}})': 'į”¨æˆˇįē§ ({{path}})', + 'Built-in Agents': '内įŊŽäģŖį†', + 'Using: {{count}} agents': 'äŊŋᔍ䏭: {{count}} ä¸ĒäģŖį†', + 'View Agent': 'æŸĨįœ‹äģŖį†', + 'Edit Agent': 'įŧ–čž‘äģŖį†', + 'Delete Agent': '删除äģŖį†', + Back: 'čŋ”回', + 'No agent selected': 'æœĒ选拊äģŖį†', + 'File Path: ': '文äģļčˇ¯åž„: ', + 'Tools: ': 'åˇĨå…ˇ: ', + 'Color: ': 'éĸœč‰˛: ', + 'Description:': '描čŋ°:', + 'System Prompt:': 'įŗģį쟿į¤ē:', + 'Open in editor': '在įŧ–čž‘å™¨ä¸­æ‰“åŧ€', + 'Edit tools': 'įŧ–čž‘åˇĨå…ˇ', + 'Edit color': 'įŧ–čž‘éĸœč‰˛', + '❌ Error:': '❌ 错蝝:', + 'Are you sure you want to delete agent "{{name}}"?': + 'æ‚¨įĄŽåŽščĻåˆ é™¤äģŖį† "{{name}}" 吗īŧŸ', + // ============================================================================ + // Agents - Creation Wizard + // ============================================================================ + 'Project Level (.qwen/agents/)': 'éĄšį›Žįē§ (.qwen/agents/)', + 'User Level (~/.qwen/agents/)': 'į”¨æˆˇįē§ (~/.qwen/agents/)', + '✅ Subagent Created Successfully!': '✅ 子äģŖį†åˆ›åģ翈åŠŸīŧ', + 'Subagent "{{name}}" has been saved to {{level}} level.': + '子äģŖį† "{{name}}" 厞äŋå­˜åˆ° {{level}} įē§åˆĢ。', + 'Name: ': 'åį§°: ', + 'Location: ': 'äŊįŊŽ: ', + '❌ Error saving subagent:': '❌ äŋå­˜å­äģŖį†æ—ļå‡ē错:', + 'Warnings:': 'č­Ļ告:', + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent': + 'åį§° "{{name}}" 在 {{level}} įē§åˆĢ厞存圍 - 将čĻ†į›–įŽ°æœ‰å­äģŖį†', + 'Name "{{name}}" exists at user level - project level will take precedence': + 'åį§° "{{name}}" åœ¨į”¨æˆˇįē§åˆĢ存在 - éĄšį›Žįē§åˆĢ将äŧ˜å…ˆ', + 'Name "{{name}}" exists at project level - existing subagent will take precedence': + 'åį§° "{{name}}" åœ¨éĄšį›Žįē§åˆĢ存在 - įŽ°æœ‰å­äģŖį†å°†äŧ˜å…ˆ', + 'Description is over {{length}} characters': '描čŋ°čļ…čŋ‡ {{length}} ä¸Ē字įŦĻ', + 'System prompt is over {{length}} characters': + 'įŗģį쟿į¤ēčļ…čŋ‡ {{length}} ä¸Ē字įŦĻ', + // Agents - Creation Wizard Steps + 'Step {{n}}: Choose Location': 'æ­ĨéǤ {{n}}: 选拊äŊįŊŽ', + 'Step {{n}}: Choose Generation Method': 'æ­ĨéǤ {{n}}: é€‰æ‹Šį”Ÿæˆæ–šåŧ', + 'Generate with Qwen Code (Recommended)': 'äŊŋᔍ Qwen Code į”ŸæˆīŧˆæŽ¨čīŧ‰', + 'Manual Creation': '手动创åģē', + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)': + '描čŋ°æ­¤å­äģŖį†åē”č¯Ĩ做äģ€äšˆäģĨ及äŊ•æ—ļäŊŋį”¨åŽƒã€‚īŧˆä¸ēäē†čŽˇåž—æœ€äŊŗæ•ˆæžœīŧŒč¯ˇå…¨éĸ描čŋ°īŧ‰', + 'e.g., Expert code reviewer that reviews code based on best practices...': + '例åĻ‚īŧšä¸“ä¸šįš„äģŖį åŽĄæŸĨ员īŧŒæ šæŽæœ€äŊŗåŽžčˇĩåŽĄæŸĨäģŖį ...', + 'Generating subagent configuration...': 'æ­Ŗåœ¨į”Ÿæˆå­äģŖį†é…įŊŽ...', + 'Failed to generate subagent: {{error}}': 'į”Ÿæˆå­äģŖį†å¤ąč´Ĩ: {{error}}', + 'Step {{n}}: Describe Your Subagent': 'æ­ĨéǤ {{n}}: 描čŋ°æ‚¨įš„子äģŖį†', + 'Step {{n}}: Enter Subagent Name': 'æ­ĨéǤ {{n}}: 输å…Ĩ子äģŖį†åį§°', + 'Step {{n}}: Enter System Prompt': 'æ­ĨéǤ {{n}}: 输å…Ĩįŗģį쟿į¤ē', + 'Step {{n}}: Enter Description': 'æ­ĨéǤ {{n}}: 输å…Ĩ描čŋ°', + // Agents - Tool Selection + 'Step {{n}}: Select Tools': 'æ­ĨéǤ {{n}}: 选拊åˇĨå…ˇ', + 'All Tools (Default)': '所有åˇĨå…ˇīŧˆéģ˜čޤīŧ‰', + 'All Tools': '所有åˇĨå…ˇ', + 'Read-only Tools': 'åĒč¯ģåˇĨå…ˇ', + 'Read & Edit Tools': 'č¯ģ取和įŧ–čž‘åˇĨå…ˇ', + 'Read & Edit & Execution Tools': 'č¯ģ取、įŧ–čž‘å’Œæ‰§čĄŒåˇĨå…ˇ', + 'All tools selected, including MCP tools': 'åˇ˛é€‰æ‹Šæ‰€æœ‰åˇĨå…ˇīŧŒåŒ…æ‹Ŧ MCP åˇĨå…ˇ', + 'Selected tools:': 'åˇ˛é€‰æ‹Šįš„åˇĨå…ˇ:', + 'Read-only tools:': 'åĒč¯ģåˇĨå…ˇ:', + 'Edit tools:': 'įŧ–čž‘åˇĨå…ˇ:', + 'Execution tools:': 'æ‰§čĄŒåˇĨå…ˇ:', + 'Step {{n}}: Choose Background Color': 'æ­ĨéǤ {{n}}: é€‰æ‹ŠčƒŒæ™¯éĸœč‰˛', + 'Step {{n}}: Confirm and Save': 'æ­ĨéǤ {{n}}: įĄŽčŽ¤åšļäŋå­˜', + // Agents - Navigation & Instructions + 'Esc to cancel': '按 Esc 取æļˆ', + 'Press Enter to save, e to save and edit, Esc to go back': + '按 Enter äŋå­˜īŧŒe äŋå­˜åšļįŧ–čž‘īŧŒEsc čŋ”回', + 'Press Enter to continue, {{navigation}}Esc to {{action}}': + '按 Enter įģ§įģ­īŧŒ{{navigation}}Esc {{action}}', + cancel: '取æļˆ', + 'go back': 'čŋ”回', + '↑↓ to navigate, ': '↑↓ å¯ŧčˆĒīŧŒ', + 'Enter a clear, unique name for this subagent.': + 'ä¸ē此子äģŖį†čž“å…Ĩ一ä¸Ēæ¸…æ™°ã€å”¯ä¸€įš„åį§°ã€‚', + 'e.g., Code Reviewer': '例åĻ‚īŧšäģŖį åŽĄæŸĨ员', + 'Name cannot be empty.': 'åį§°ä¸čƒŊä¸ēįŠē。', + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.": + 'įŧ–写厚䚉此子äģŖį†čĄŒä¸ēįš„įŗģį쟿į¤ē。ä¸ēäē†čŽˇåž—æœ€äŊŗæ•ˆæžœīŧŒč¯ˇå…¨éĸ描čŋ°ã€‚', + 'e.g., You are an expert code reviewer...': + '例åĻ‚īŧšæ‚¨æ˜¯ä¸€äŊä¸“ä¸šįš„äģŖį åŽĄæŸĨ员...', + 'System prompt cannot be empty.': 'įŗģį쟿į¤ē不čƒŊä¸ēįŠē。', + 'Describe when and how this subagent should be used.': + '描čŋ°äŊ•æ—ļäģĨ及åĻ‚äŊ•äŊŋį”¨æ­¤å­äģŖį†ã€‚', + 'e.g., Reviews code for best practices and potential bugs.': + '例åĻ‚īŧšåŽĄæŸĨäģŖį äģĨæŸĨ扞最äŊŗåŽžčˇĩ和æŊœåœ¨é”™č¯¯ã€‚', + 'Description cannot be empty.': '描čŋ°ä¸čƒŊä¸ēįŠē。', + 'Failed to launch editor: {{error}}': '启动įŧ–čž‘å™¨å¤ąč´Ĩ: {{error}}', + 'Failed to save and edit subagent: {{error}}': + 'äŋå­˜åšļįŧ–čž‘å­äģŖį†å¤ąč´Ĩ: {{error}}', + + // ============================================================================ + // Commands - General (continued) + // ============================================================================ + 'View and edit Qwen Code settings': 'æŸĨįœ‹å’Œįŧ–čž‘ Qwen Code 莞įŊŽ', + Settings: '莞įŊŽ', + '(Use Enter to select{{tabText}})': 'īŧˆäŊŋᔍ Enter 选拊{{tabText}}īŧ‰', + ', Tab to change focus': 'īŧŒTab 切æĸį„Ļį‚š', + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.': + 'čρæŸĨįœ‹æ›´æ”šīŧŒåŋ…éĄģ重启 Qwen Code。按 r 退å‡ēåšļįĢ‹åŗåē”į”¨æ›´æ”šã€‚', + // ============================================================================ + // Settings Labels + // ============================================================================ + 'Vim Mode': 'Vim æ¨Ąåŧ', + 'Disable Auto Update': 'įρᔍč‡Ē动更新', + 'Enable Prompt Completion': 'å¯į”¨æį¤ēčĄĨ全', + 'Debug Keystroke Logging': 'č°ƒč¯•æŒ‰é”ŽčŽ°åŊ•', + Language: '蝭荀', + 'Output Format': '输å‡ēæ ŧåŧ', + 'Hide Window Title': '隐藏įĒ—åŖæ ‡éĸ˜', + 'Show Status in Title': '在标éĸ˜ä¸­æ˜žį¤ēįŠļ态', + 'Hide Tips': 'éšč—æį¤ē', + 'Hide Banner': '隐藏æ¨Ēåš…', + 'Hide Context Summary': 'éšč—ä¸Šä¸‹æ–‡æ‘˜čρ', + 'Hide CWD': '隐藏åŊ“前åˇĨäŊœį›ŽåŊ•', + 'Hide Sandbox Status': 'éšč—æ˛™įŽąįŠļ态', + 'Hide Model Info': 'éšč—æ¨Ąåž‹äŋĄæ¯', + 'Hide Footer': '隐藏éĄĩ脚', + 'Show Memory Usage': '昞į¤ē内存äŊŋᔍ', + 'Show Line Numbers': '昞į¤ēčĄŒåˇ', + 'Show Citations': '昞į¤ēåŧ•ᔍ', + 'Custom Witty Phrases': 'č‡ĒåŽšäš‰č¯™č°įŸ­č¯­', + 'Enable Welcome Back': '吝ᔍæŦĸčŋŽå›žæĨ', + 'Disable Loading Phrases': 'įĻį”¨åŠ čŊŊ៭蝭', + 'Screen Reader Mode': 'åąåš•é˜…č¯ģå™¨æ¨Ąåŧ', + 'IDE Mode': 'IDE æ¨Ąåŧ', + 'Max Session Turns': '最大äŧšč¯čŊŽæŦĄ', + 'Skip Next Speaker Check': '莺čŋ‡ä¸‹ä¸€ä¸Ēč¯´č¯č€…æŖ€æŸĨ', + 'Skip Loop Detection': '莺čŋ‡åžĒįŽ¯æŖ€æĩ‹', + 'Skip Startup Context': '莺čŋ‡å¯åŠ¨ä¸Šä¸‹æ–‡', + 'Enable OpenAI Logging': '吝ᔍ OpenAI æ—Ĩåŋ—', + 'OpenAI Logging Directory': 'OpenAI æ—Ĩåŋ—į›ŽåŊ•', + Timeout: 'čļ…æ—ļ', + 'Max Retries': 'æœ€å¤§é‡č¯•æŦĄæ•°', + 'Disable Cache Control': 'įρᔍįŧ“存控åˆļ', + 'Memory Discovery Max Dirs': 'å†…å­˜å‘įŽ°æœ€å¤§į›ŽåŊ•æ•°', + 'Load Memory From Include Directories': 'äģŽåŒ…åĢį›ŽåŊ•加čŊŊ内存', + 'Respect .gitignore': 'éĩ厈 .gitignore', + 'Respect .qwenignore': 'éĩ厈 .qwenignore', + 'Enable Recursive File Search': '吝ᔍ递åŊ’æ–‡äģ￐œį´ĸ', + 'Disable Fuzzy Search': 'įĻį”¨æ¨ĄįŗŠæœį´ĸ', + 'Enable Interactive Shell': '吝ᔍäē¤äē’åŧ Shell', + 'Show Color': '昞į¤ēéĸœč‰˛', + 'Auto Accept': 'č‡Ē动æŽĨ受', + 'Use Ripgrep': 'äŊŋᔍ Ripgrep', + 'Use Builtin Ripgrep': 'äŊŋᔍ内įŊŽ Ripgrep', + 'Enable Tool Output Truncation': '吝ᔍåˇĨå…ˇčž“å‡ēæˆĒ断', + 'Tool Output Truncation Threshold': 'åˇĨå…ˇčž“å‡ēæˆĒ断阈å€ŧ', + 'Tool Output Truncation Lines': 'åˇĨå…ˇčž“å‡ēæˆĒæ–­čĄŒæ•°', + 'Folder Trust': '文äģļ多äŋĄäģģ', + 'Vision Model Preview': 'č§†č§‰æ¨Ąåž‹éĸ„č§ˆ', + // Settings enum options + 'Auto (detect from system)': 'č‡Ē动īŧˆäģŽįŗģį쟿Ŗ€æĩ‹īŧ‰', + Text: '文æœŦ', + JSON: 'JSON', + Plan: 'č§„åˆ’', + Default: 'éģ˜čޤ', + 'Auto Edit': 'č‡Ē动įŧ–čž‘', + YOLO: 'YOLO', + 'toggle vim mode on/off': '切æĸ vim æ¨Ąåŧåŧ€å…ŗ', + 'check session stats. Usage: /stats [model|tools]': + 'æŖ€æŸĨäŧšč¯įģŸčŽĄäŋĄæ¯ã€‚į”¨æŗ•īŧš/stats [model|tools]', + 'Show model-specific usage statistics.': '昞į¤ēæ¨Ąåž‹į›¸å…ŗįš„äŊŋᔍįģŸčŽĄäŋĄæ¯', + 'Show tool-specific usage statistics.': '昞į¤ēåˇĨå…ˇį›¸å…ŗįš„äŊŋᔍįģŸčŽĄäŋĄæ¯', + 'exit the cli': '退å‡ēå‘Ŋäģ¤čĄŒį•Œéĸ', + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers': + '列å‡ēåˇ˛é…įŊŽįš„ MCP æœåŠĄå™¨å’ŒåˇĨå…ˇīŧŒæˆ–äŊŋį”¨æ”¯æŒ OAuth įš„æœåŠĄå™¨čŋ›čĄŒčēĢäģŊénj蝁', + 'Manage workspace directories': 'įŽĄį†åˇĨäŊœåŒēį›ŽåŊ•', + 'Add directories to the workspace. Use comma to separate multiple paths': + 'å°†į›ŽåŊ•æˇģ加到åˇĨäŊœåŒē。äŊŋį”¨é€—åˇåˆ†éš”å¤šä¸Ēčˇ¯åž„', + 'Show all directories in the workspace': '昞į¤ēåˇĨäŊœåŒēä¸­įš„æ‰€æœ‰į›ŽåŊ•', + 'set external editor preference': '莞įŊŽå¤–部įŧ–čž‘å™¨éĻ–é€‰éĄš', + 'Manage extensions': 'įŽĄį†æ‰Šåą•', + 'List active extensions': '列å‡ēæ´ģåŠ¨æ‰Šåą•', + 'Update extensions. Usage: update |--all': + 'æ›´æ–°æ‰Šåą•ã€‚į”¨æŗ•īŧšupdate |--all', + 'manage IDE integration': 'įŽĄį† IDE 集成', + 'check status of IDE integration': 'æŖ€æŸĨ IDE 集成įŠļ态', + 'install required IDE companion for {{ideName}}': + 'åŽ‰čŖ… {{ideName}} æ‰€éœ€įš„ IDE 配åĨ—åˇĨå…ˇ', + 'enable IDE integration': '吝ᔍ IDE 集成', + 'disable IDE integration': 'įρᔍ IDE 集成', + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.': + '您åŊ“å‰įŽ¯åĸƒä¸æ”¯æŒ IDE 集成。čρäŊŋį”¨æ­¤åŠŸčƒŊīŧŒč¯ˇåœ¨äģĨä¸‹æ”¯æŒįš„ IDE 之一中čŋčĄŒ Qwen CodeīŧšVS Code 或 VS Code åˆ†æ”¯į‰ˆæœŦ。', + 'Set up GitHub Actions': '莞įŊŽ GitHub Actions', + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)': + '配įŊŽįģˆįĢ¯æŒ‰é”Žįģ‘åޚäģĨæ”¯æŒå¤ščĄŒčž“å…ĨīŧˆVS Code、Cursor、Windsurf、Traeīŧ‰', + 'Please restart your terminal for the changes to take effect.': + 'č¯ˇé‡å¯įģˆį̝äģĨäŊŋæ›´æ”šį”Ÿæ•ˆã€‚', + 'Failed to configure terminal: {{error}}': '配įŊŽįģˆįĢ¯å¤ąč´Ĩīŧš{{error}}', + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.': + 'æ— æŗ•įĄŽåŽš {{terminalName}} 在 Windows ä¸Šįš„é…įŊŽčˇ¯åž„īŧšæœĒ莞įŊŽ APPDATA įŽ¯åĸƒå˜é‡ã€‚', + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.': + '{{terminalName}} keybindings.json 存在äŊ†ä¸æ˜¯æœ‰æ•ˆįš„ JSON 数įģ„ã€‚č¯ˇæ‰‹åŠ¨äŋŽå¤æ–‡äģ￈–删除åރäģĨå…čŽ¸č‡Ē动配įŊŽã€‚', + 'File: {{file}}': '文äģļīŧš{{file}}', + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.': + 'č§Ŗæž {{terminalName}} keybindings.json å¤ąč´Ĩ。文äģļ包åĢæ— æ•ˆįš„ JSONã€‚č¯ˇæ‰‹åŠ¨äŋŽå¤æ–‡äģ￈–删除åރäģĨå…čŽ¸č‡Ē动配įŊŽã€‚', + 'Error: {{error}}': '错蝝īŧš{{error}}', + 'Shift+Enter binding already exists': 'Shift+Enter įģ‘åޚ厞存圍', + 'Ctrl+Enter binding already exists': 'Ctrl+Enter įģ‘åޚ厞存圍', + 'Existing keybindings detected. Will not modify to avoid conflicts.': + 'æŖ€æĩ‹åˆ°įŽ°æœ‰æŒ‰é”Žįģ‘厚。ä¸ēéŋå…å†˛įǁīŧŒä¸äŧšäŋŽæ”šã€‚', + 'Please check and modify manually if needed: {{file}}': + 'åĻ‚æœ‰éœ€čρīŧŒč¯ˇæ‰‹åŠ¨æŖ€æŸĨåšļäŋŽæ”šīŧš{{file}}', + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.': + '厞ä¸ē {{terminalName}} æˇģ加 Shift+Enter 和 Ctrl+Enter 按锎įģ‘厚。', + 'Modified: {{file}}': '厞äŋŽæ”šīŧš{{file}}', + '{{terminalName}} keybindings already configured.': + '{{terminalName}} 按锎įģ‘åŽšåˇ˛é…įŊŽã€‚', + 'Failed to configure {{terminalName}}.': '配įŊŽ {{terminalName}} å¤ąč´Ĩ。', + 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).': + 'æ‚¨įš„įģˆįĢ¯åˇ˛é…įŊŽä¸ēæ”¯æŒå¤ščĄŒčž“å…ĨīŧˆShift+Enter 和 Ctrl+Enterīŧ‰įš„æœ€äŊŗäŊ“éĒŒã€‚', + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.': + 'æ— æŗ•æŖ€æĩ‹įģˆį̝įąģåž‹ã€‚æ”¯æŒįš„įģˆį̝īŧšVS Code、Cursor、Windsurf 和 Trae。', + 'Terminal "{{terminal}}" is not supported yet.': + 'įģˆį̝ "{{terminal}}" 尚æœĒ支持。', + + // ============================================================================ + // Commands - Language + // ============================================================================ + 'Invalid language. Available: en-US, zh-CN': + 'æ— æ•ˆįš„č¯­č¨€ã€‚å¯į”¨é€‰éĄšīŧšen-US, zh-CN', + 'Language subcommands do not accept additional arguments.': + '蝭荀子å‘Ŋäģ¤ä¸æŽĨ受éĸå¤–参数', + 'Current UI language: {{lang}}': 'åŊ“前 UI 蝭荀īŧš{{lang}}', + 'Current LLM output language: {{lang}}': 'åŊ“前 LLM 输å‡ē蝭荀īŧš{{lang}}', + 'LLM output language not set': 'æœĒ莞įŊŽ LLM 输å‡ē蝭荀', + 'Set UI language': '莞įŊŽ UI 蝭荀', + 'Set LLM output language': '莞įŊŽ LLM 输å‡ē蝭荀', + 'Usage: /language ui [zh-CN|en-US]': 'į”¨æŗ•īŧš/language ui [zh-CN|en-US]', + 'Usage: /language output ': 'į”¨æŗ•īŧš/language output <蝭荀>', + 'Example: /language output 中文': 'į¤ē例īŧš/language output 中文', + 'Example: /language output English': 'į¤ē例īŧš/language output English', + 'Example: /language output æ—ĨæœŦčĒž': 'į¤ē例īŧš/language output æ—ĨæœŦčĒž', + 'UI language changed to {{lang}}': 'UI č¯­č¨€åˇ˛æ›´æ”šä¸ē {{lang}}', + 'LLM output language rule file generated at {{path}}': + 'LLM 输å‡ēč¯­č¨€č§„åˆ™æ–‡äģļåˇ˛į”ŸæˆäēŽ {{path}}', + 'Please restart the application for the changes to take effect.': + 'č¯ˇé‡å¯åē”ᔍፋåēäģĨäŊŋæ›´æ”šį”Ÿæ•ˆã€‚', + 'Failed to generate LLM output language rule file: {{error}}': + 'į”Ÿæˆ LLM 输å‡ēč¯­č¨€č§„åˆ™æ–‡äģļå¤ąč´Ĩīŧš{{error}}', + 'Invalid command. Available subcommands:': 'æ— æ•ˆįš„å‘Ŋäģ¤ã€‚å¯į”¨įš„å­å‘Ŋäģ¤īŧš', + 'Available subcommands:': 'å¯į”¨įš„å­å‘Ŋäģ¤īŧš', + 'To request additional UI language packs, please open an issue on GitHub.': + 'åĻ‚éœ€č¯ˇæą‚å…ļäģ– UI č¯­č¨€åŒ…īŧŒč¯ˇåœ¨ GitHub 上提äē¤ issue', + 'Available options:': 'å¯į”¨é€‰éĄšīŧš', + ' - zh-CN: Simplified Chinese': ' - zh-CN: įŽ€äŊ“中文', + ' - en-US: English': ' - en-US: English', + 'Set UI language to Simplified Chinese (zh-CN)': + '将 UI č¯­č¨€čŽžįŊŽä¸ēįŽ€äŊ“中文 (zh-CN)', + 'Set UI language to English (en-US)': '将 UI č¯­č¨€čŽžįŊŽä¸ēč‹ąč¯­ (en-US)', + + // ============================================================================ + // Commands - Approval Mode + // ============================================================================ + 'Approval Mode': 'åŽĄæ‰šæ¨Ąåŧ', + 'Current approval mode: {{mode}}': 'åŊ“å‰åŽĄæ‰šæ¨Ąåŧīŧš{{mode}}', + 'Available approval modes:': 'å¯į”¨įš„åŽĄæ‰šæ¨Ąåŧīŧš', + 'Approval mode changed to: {{mode}}': 'åŽĄæ‰šæ¨Ąåŧåˇ˛æ›´æ”šä¸ēīŧš{{mode}}', + 'Approval mode changed to: {{mode}} (saved to {{scope}} settings{{location}})': + 'åŽĄæ‰šæ¨Ąåŧåˇ˛æ›´æ”šä¸ēīŧš{{mode}}īŧˆåˇ˛äŋå­˜åˆ°{{scope}}莞įŊŽ{{location}}īŧ‰', + 'Usage: /approval-mode [--session|--user|--project]': + 'į”¨æŗ•īŧš/approval-mode [--session|--user|--project]', + + 'Scope subcommands do not accept additional arguments.': + 'äŊœį”¨åŸŸå­å‘Ŋäģ¤ä¸æŽĨ受éĸå¤–参数', + 'Plan mode - Analyze only, do not modify files or execute commands': + 'č§„åˆ’æ¨Ąåŧ - äģ…分析īŧŒä¸äŋŽæ”šæ–‡äģ￈–æ‰§čĄŒå‘Ŋäģ¤', + 'Default mode - Require approval for file edits or shell commands': + 'éģ˜čŽ¤æ¨Ąåŧ - 需čĻæ‰šå‡†æ–‡äģļįŧ–čž‘æˆ– shell å‘Ŋäģ¤', + 'Auto-edit mode - Automatically approve file edits': + 'č‡Ē动įŧ–čž‘æ¨Ąåŧ - č‡Ē动扚准文äģļįŧ–čž‘', + 'YOLO mode - Automatically approve all tools': 'YOLO æ¨Ąåŧ - č‡Ē动扚准所有åˇĨå…ˇ', + '{{mode}} mode': '{{mode}} æ¨Ąåŧ', + 'Settings service is not available; unable to persist the approval mode.': + '莞įŊŽæœåŠĄä¸å¯į”¨īŧ›æ— æŗ•æŒäš…åŒ–åŽĄæ‰šæ¨Ąåŧã€‚', + 'Failed to save approval mode: {{error}}': 'äŋå­˜åŽĄæ‰šæ¨Ąåŧå¤ąč´Ĩīŧš{{error}}', + 'Failed to change approval mode: {{error}}': 'æ›´æ”šåŽĄæ‰šæ¨Ąåŧå¤ąč´Ĩīŧš{{error}}', + 'Apply to current session only (temporary)': 'äģ…åē”ᔍäēŽåŊ“前äŧšč¯īŧˆä¸´æ—ļīŧ‰', + 'Persist for this project/workspace': 'æŒäš…åŒ–åˆ°æ­¤éĄšį›Ž/åˇĨäŊœåŒē', + 'Persist for this user on this machine': '持䚅化到此æœēå™¨ä¸Šįš„æ­¤į”¨æˆˇ', + 'Analyze only, do not modify files or execute commands': + 'äģ…分析īŧŒä¸äŋŽæ”šæ–‡äģ￈–æ‰§čĄŒå‘Ŋäģ¤', + 'Require approval for file edits or shell commands': + '需čĻæ‰šå‡†æ–‡äģļįŧ–čž‘æˆ– shell å‘Ŋäģ¤', + 'Automatically approve file edits': 'č‡Ē动扚准文äģļįŧ–čž‘', + 'Automatically approve all tools': 'č‡Ē动扚准所有åˇĨå…ˇ', + 'Workspace approval mode exists and takes priority. User-level change will have no effect.': + 'åˇĨäŊœåŒēåŽĄæ‰šæ¨Ąåŧåˇ˛å­˜åœ¨åšļå…ˇæœ‰äŧ˜å…ˆįē§ã€‚į”¨æˆˇįē§åˆĢįš„æ›´æ”šå°†æ— æ•ˆã€‚', + '(Use Enter to select, Tab to change focus)': + 'īŧˆäŊŋᔍ Enter 选拊īŧŒTab 切æĸį„Ļį‚šīŧ‰', + 'Apply To': 'åē”ᔍäēŽ', + 'User Settings': 'į”¨æˆˇčŽžįŊŽ', + 'Workspace Settings': 'åˇĨäŊœåŒē莞įŊŽ', + + // ============================================================================ + // Commands - Memory + // ============================================================================ + 'Commands for interacting with memory.': 'ᔍäēŽä¸Žčްåŋ†äē¤äē’įš„å‘Ŋäģ¤', + 'Show the current memory contents.': '昞į¤ēåŊ“前čްåŋ†å†…厚', + 'Show project-level memory contents.': '昞į¤ēéĄšį›Žįē§čްåŋ†å†…厚', + 'Show global memory contents.': '昞į¤ēå…¨åą€čŽ°åŋ†å†…厚', + 'Add content to project-level memory.': 'æˇģåŠ å†…åŽšåˆ°éĄšį›Žįē§čްåŋ†', + 'Add content to global memory.': 'æˇģåŠ å†…åŽšåˆ°å…¨åą€čŽ°åŋ†', + 'Refresh the memory from the source.': 'äģŽæēåˆˇæ–°čްåŋ†', + 'Usage: /memory add --project ': + 'į”¨æŗ•īŧš/memory add --project <čĻčŽ°äŊįš„æ–‡æœŦ>', + 'Usage: /memory add --global ': + 'į”¨æŗ•īŧš/memory add --global <čĻčŽ°äŊįš„æ–‡æœŦ>', + 'Attempting to save to project memory: "{{text}}"': + 'æ­Ŗåœ¨å°č¯•äŋå­˜åˆ°éĄšį›Žčްåŋ†īŧš"{{text}}"', + 'Attempting to save to global memory: "{{text}}"': + 'æ­Ŗåœ¨å°č¯•äŋå­˜åˆ°å…¨åą€čްåŋ†īŧš"{{text}}"', + 'Current memory content from {{count}} file(s):': + 'æĨč‡Ē {{count}} ä¸Ē文äģļįš„åŊ“前čްåŋ†å†…厚īŧš', + 'Memory is currently empty.': '莰åŋ†åŊ“前ä¸ēįŠē', + 'Project memory file not found or is currently empty.': + 'éĄšį›ŽčŽ°åŋ†æ–‡äģᅵǿ‰žåˆ°æˆ–åŊ“前ä¸ēįŠē', + 'Global memory file not found or is currently empty.': + 'å…¨åą€čŽ°åŋ†æ–‡äģᅵǿ‰žåˆ°æˆ–åŊ“前ä¸ēįŠē', + 'Global memory is currently empty.': 'å…¨åą€čŽ°åŋ†åŊ“前ä¸ēįŠē', + 'Global memory content:\n\n---\n{{content}}\n---': + 'å…¨åą€čŽ°åŋ†å†…厚īŧš\n\n---\n{{content}}\n---', + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---': + 'éĄšį›ŽčŽ°åŋ†å†…厚æĨč‡Ē {{path}}īŧš\n\n---\n{{content}}\n---', + 'Project memory is currently empty.': 'éĄšį›ŽčŽ°åŋ†åŊ“前ä¸ēįŠē', + 'Refreshing memory from source files...': 'æ­Ŗåœ¨äģŽæēæ–‡äģļåˆˇæ–°čŽ°åŋ†...', + 'Add content to the memory. Use --global for global memory or --project for project memory.': + 'æˇģåŠ å†…åŽšåˆ°čŽ°åŋ†ã€‚äŊŋᔍ --global 襨į¤ēå…¨åą€čŽ°åŋ†īŧŒäŊŋᔍ --project 襨į¤ēéĄšį›ŽčŽ°åŋ†', + 'Usage: /memory add [--global|--project] ': + 'į”¨æŗ•īŧš/memory add [--global|--project] <čĻčŽ°äŊįš„æ–‡æœŦ>', + 'Attempting to save to memory {{scope}}: "{{fact}}"': + 'æ­Ŗåœ¨å°č¯•äŋå­˜åˆ°čްåŋ† {{scope}}īŧš"{{fact}}"', + + // ============================================================================ + // Commands - MCP + // ============================================================================ + 'Authenticate with an OAuth-enabled MCP server': + 'äŊŋį”¨æ”¯æŒ OAuth įš„ MCP æœåŠĄå™¨čŋ›čĄŒčޤ蝁', + 'List configured MCP servers and tools': '列å‡ēåˇ˛é…įŊŽįš„ MCP æœåŠĄå™¨å’ŒåˇĨå…ˇ', + 'Restarts MCP servers.': '重启 MCP æœåŠĄå™¨', + 'Config not loaded.': '配įŊŽæœĒ加čŊŊ', + 'Could not retrieve tool registry.': 'æ— æŗ•æŖ€į´ĸåˇĨå…ˇæŗ¨å†ŒčĄ¨', + 'No MCP servers configured with OAuth authentication.': + 'æœĒ配įŊŽæ”¯æŒ OAuth čŽ¤č¯įš„ MCP æœåŠĄå™¨', + 'MCP servers with OAuth authentication:': '支持 OAuth čŽ¤č¯įš„ MCP æœåŠĄå™¨īŧš', + 'Use /mcp auth to authenticate.': + 'äŊŋᔍ /mcp auth čŋ›čĄŒčޤ蝁', + "MCP server '{{name}}' not found.": "æœĒ扞到 MCP æœåŠĄå™¨ '{{name}}'", + "Successfully authenticated and refreshed tools for '{{name}}'.": + "æˆåŠŸčŽ¤č¯åšļåˆˇæ–°äē† '{{name}}' įš„åˇĨå…ˇ", + "Failed to authenticate with MCP server '{{name}}': {{error}}": + "čŽ¤č¯ MCP æœåŠĄå™¨ '{{name}}' å¤ąč´Ĩīŧš{{error}}", + "Re-discovering tools from '{{name}}'...": + "æ­Ŗåœ¨é‡æ–°å‘įŽ° '{{name}}' įš„åˇĨå…ˇ...", + + // ============================================================================ + // Commands - Chat + // ============================================================================ + 'Manage conversation history.': 'įŽĄį†å¯šč¯åŽ†å˛', + 'List saved conversation checkpoints': '列å‡ē厞äŋå­˜įš„å¯šč¯æŖ€æŸĨį‚š', + 'No saved conversation checkpoints found.': 'æœĒæ‰žåˆ°åˇ˛äŋå­˜įš„å¯šč¯æŖ€æŸĨį‚š', + 'List of saved conversations:': '厞äŋå­˜įš„å¯šč¯åˆ—čĄ¨īŧš', + 'Note: Newest last, oldest first': 'æŗ¨æ„īŧšæœ€æ–°įš„åœ¨æœ€åŽīŧŒæœ€æ—§įš„在最前', + 'Save the current conversation as a checkpoint. Usage: /chat save ': + '将åŊ“å‰å¯šč¯äŋå­˜ä¸ēæŖ€æŸĨį‚šã€‚į”¨æŗ•īŧš/chat save ', + 'Missing tag. Usage: /chat save ': 'įŧēå°‘æ ‡į­žã€‚į”¨æŗ•īŧš/chat save ', + 'Delete a conversation checkpoint. Usage: /chat delete ': + 'åˆ é™¤å¯šč¯æŖ€æŸĨį‚šã€‚į”¨æŗ•īŧš/chat delete ', + 'Missing tag. Usage: /chat delete ': + 'įŧēå°‘æ ‡į­žã€‚į”¨æŗ•īŧš/chat delete ', + "Conversation checkpoint '{{tag}}' has been deleted.": + "å¯šč¯æŖ€æŸĨį‚š '{{tag}}' åˇ˛åˆ é™¤", + "Error: No checkpoint found with tag '{{tag}}'.": + "错蝝īŧšæœĒæ‰žåˆ°æ ‡į­žä¸ē '{{tag}}' įš„æŖ€æŸĨį‚š", + 'Resume a conversation from a checkpoint. Usage: /chat resume ': + 'äģŽæŖ€æŸĨį‚šæĸå¤å¯šč¯ã€‚į”¨æŗ•īŧš/chat resume ', + 'Missing tag. Usage: /chat resume ': + 'įŧēå°‘æ ‡į­žã€‚į”¨æŗ•īŧš/chat resume ', + 'No saved checkpoint found with tag: {{tag}}.': + 'æœĒæ‰žåˆ°æ ‡į­žä¸ē {{tag}} įš„åˇ˛äŋå­˜æŖ€æŸĨį‚š', + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?': + 'æ ‡į­žä¸ē {{tag}} įš„æŖ€æŸĨį‚šåˇ˛å­˜åœ¨ã€‚æ‚¨čρčĻ†į›–åŽƒå—īŧŸ', + 'No chat client available to save conversation.': + 'æ˛Ąæœ‰å¯į”¨įš„čŠå¤ŠåŽĸæˆˇį̝æĨäŋå­˜å¯šč¯', + 'Conversation checkpoint saved with tag: {{tag}}.': + 'å¯šč¯æŖ€æŸĨį‚šåˇ˛äŋå­˜īŧŒæ ‡į­žīŧš{{tag}}', + 'No conversation found to save.': 'æœĒ扞到čρäŋå­˜įš„å¯šč¯', + 'No chat client available to share conversation.': + 'æ˛Ąæœ‰å¯į”¨įš„čŠå¤ŠåŽĸæˆˇį̝æĨ分äēĢå¯šč¯', + 'Invalid file format. Only .md and .json are supported.': + 'æ— æ•ˆįš„æ–‡äģļæ ŧåŧã€‚äģ…æ”¯æŒ .md 和 .json 文äģļ', + 'Error sharing conversation: {{error}}': '分äēĢå¯šč¯æ—ļå‡ē错īŧš{{error}}', + 'Conversation shared to {{filePath}}': 'å¯šč¯åˇ˛åˆ†äēĢ到 {{filePath}}', + 'No conversation found to share.': 'æœĒ扞到čĻåˆ†äēĢįš„å¯šč¯', + 'Share the current conversation to a markdown or json file. Usage: /chat share ': + '将åŊ“å‰å¯šč¯åˆ†äēĢ到 markdown 或 json 文äģļã€‚į”¨æŗ•īŧš/chat share ', + + // ============================================================================ + // Commands - Summary + // ============================================================================ + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md': + 'į”ŸæˆéĄšį›Žæ‘˜čρåšļäŋå­˜åˆ° .qwen/PROJECT_SUMMARY.md', + 'No chat client available to generate summary.': + 'æ˛Ąæœ‰å¯į”¨įš„čŠå¤ŠåŽĸæˆˇį̝æĨį”Ÿæˆæ‘˜čρ', + 'Already generating summary, wait for previous request to complete': + 'æ­Ŗåœ¨į”Ÿæˆæ‘˜čρīŧŒč¯ˇį­‰åž…上一ä¸Ēč¯ˇæą‚åŽŒæˆ', + 'No conversation found to summarize.': 'æœĒ扞到čρæ€ģįģ“įš„å¯šč¯', + 'Failed to generate project context summary: {{error}}': + 'į”ŸæˆéĄšį›Žä¸Šä¸‹æ–‡æ‘˜čĻå¤ąč´Ĩīŧš{{error}}', + + // ============================================================================ + // Commands - Model + // ============================================================================ + 'Switch the model for this session': '切æĸæ­¤äŧšč¯įš„æ¨Ąåž‹', + 'Content generator configuration not available.': 'å†…åŽšį”Ÿæˆå™¨é…įŊŽä¸å¯į”¨', + 'Authentication type not available.': 'čŽ¤č¯įąģåž‹ä¸å¯į”¨', + 'No models available for the current authentication type ({{authType}}).': + 'åŊ“前čޤ蝁įąģ型 ({{authType}}) æ˛Ąæœ‰å¯į”¨įš„æ¨Ąåž‹', + + // ============================================================================ + // Commands - Clear + // ============================================================================ + 'Clearing terminal and resetting chat.': 'æ­Ŗåœ¨æ¸…åąåšļ重įŊŽčŠå¤Š', + 'Clearing terminal.': 'æ­Ŗåœ¨æ¸…åą', + + // ============================================================================ + // Commands - Compress + // ============================================================================ + 'Already compressing, wait for previous request to complete': + 'æ­Ŗåœ¨åŽ‹įŧŠä¸­īŧŒč¯ˇį­‰åž…上一ä¸Ēč¯ˇæą‚åŽŒæˆ', + 'Failed to compress chat history.': '压įŧŠčŠå¤ŠåŽ†å˛å¤ąč´Ĩ', + 'Failed to compress chat history: {{error}}': '压įŧŠčŠå¤ŠåŽ†å˛å¤ąč´Ĩīŧš{{error}}', + 'Compressing chat history': 'æ­Ŗåœ¨åŽ‹įŧŠčŠå¤ŠåŽ†å˛', + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.': + 'čŠå¤ŠåŽ†å˛åˇ˛äģŽ {{originalTokens}} ä¸Ē token 压įŧŠåˆ° {{newTokens}} ä¸Ē token。', + 'Compression was not beneficial for this history size.': + '寚äēŽæ­¤åŽ†å˛čްåŊ•大小īŧŒåŽ‹įŧŠæ˛Ąæœ‰į›Šå¤„。', + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.': + 'čŠå¤ŠåŽ†å˛åŽ‹įŧŠæœĒčƒŊ减小大小。čŋ™å¯čƒŊčĄ¨æ˜ŽåŽ‹įŧŠæį¤ē存在闎éĸ˜ã€‚', + 'Could not compress chat history due to a token counting error.': + 'į”ąäēŽ token čŽĄæ•°é”™č¯¯īŧŒæ— æŗ•压įŧŠčŠå¤ŠåŽ†å˛ã€‚', + 'Chat history is already compressed.': 'čŠå¤ŠåŽ†å˛åˇ˛įģåŽ‹įŧŠã€‚', + + // ============================================================================ + // Commands - Directory + // ============================================================================ + 'Configuration is not available.': '配įŊŽä¸å¯į”¨ã€‚', + 'Please provide at least one path to add.': 'č¯ˇæäž›č‡ŗå°‘ä¸€ä¸ĒčρæˇģåŠ įš„čˇ¯åž„ã€‚', + 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.': + '/directory add å‘Ŋäģ¤åœ¨é™åˆļæ€§æ˛™įŽąé…įŊŽæ–‡äģļä¸­ä¸å—æ”¯æŒã€‚č¯ˇæ”šä¸ē在启动äŧšč¯æ—ļäŊŋᔍ --include-directories。', + "Error adding '{{path}}': {{error}}": "æˇģ加 '{{path}}' æ—ļå‡ē错īŧš{{error}}", + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}': + 'åĻ‚æžœå­˜åœ¨īŧŒåˇ˛æˆåŠŸäģŽäģĨä¸‹į›ŽåŊ•æˇģ加 GEMINI.md 文äģļīŧš\n- {{directories}}', + 'Error refreshing memory: {{error}}': 'åˆˇæ–°å†…å­˜æ—ļå‡ē错īŧš{{error}}', + 'Successfully added directories:\n- {{directories}}': + '成功æˇģåŠ į›ŽåŊ•īŧš\n- {{directories}}', + 'Current workspace directories:\n{{directories}}': + 'åŊ“前åˇĨäŊœåŒēį›ŽåŊ•īŧš\n{{directories}}', + + // ============================================================================ + // Commands - Docs + // ============================================================================ + 'Please open the following URL in your browser to view the documentation:\n{{url}}': + '蝎圍æĩč§ˆå™¨ä¸­æ‰“åŧ€äģĨ下 URL äģĨæŸĨįœ‹æ–‡æĄŖīŧš\n{{url}}', + 'Opening documentation in your browser: {{url}}': + 'æ­Ŗåœ¨æĩč§ˆå™¨ä¸­æ‰“åŧ€æ–‡æĄŖīŧš{{url}}', + + // ============================================================================ + // Dialogs - Tool Confirmation + // ============================================================================ + 'Do you want to proceed?': '是åĻįģ§įģ­īŧŸ', + 'Yes, allow once': '是īŧŒå…čޏ䏀æŦĄ', + 'Allow always': 'æ€ģæ˜¯å…čŽ¸', + No: 'åĻ', + 'No (esc)': 'åĻ (esc)', + 'Yes, allow always for this session': '是īŧŒæœŦæŦĄäŧšč¯æ€ģæ˜¯å…čŽ¸', + 'Modify in progress:': 'æ­Ŗåœ¨äŋŽæ”šīŧš', + 'Save and close external editor to continue': 'äŋå­˜åšļå…ŗé—­å¤–éƒ¨įŧ–čž‘å™¨äģĨįģ§įģ­', + 'Apply this change?': '是åĻåē”į”¨æ­¤æ›´æ”šīŧŸ', + 'Yes, allow always': '是īŧŒæ€ģæ˜¯å…čŽ¸', + 'Modify with external editor': 'äŊŋį”¨å¤–éƒ¨įŧ–čž‘å™¨äŋŽæ”š', + 'No, suggest changes (esc)': 'åĻīŧŒåģēčŽŽæ›´æ”š (esc)', + "Allow execution of: '{{command}}'?": "å…čŽ¸æ‰§čĄŒīŧš'{{command}}'īŧŸ", + 'Yes, allow always ...': '是īŧŒæ€ģæ˜¯å…čŽ¸ ...', + 'Yes, and auto-accept edits': '是īŧŒåšļč‡Ē动æŽĨ受įŧ–čž‘', + 'Yes, and manually approve edits': '是īŧŒåšļ手动扚准įŧ–čž‘', + 'No, keep planning (esc)': 'åĻīŧŒįģ§įģ­č§„划 (esc)', + 'URLs to fetch:': 'čĻčŽˇå–įš„ URLīŧš', + 'MCP Server: {{server}}': 'MCP æœåŠĄå™¨īŧš{{server}}', + 'Tool: {{tool}}': 'åˇĨå…ˇīŧš{{tool}}', + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?': + 'å…čŽ¸æ‰§čĄŒæĨč‡ĒæœåŠĄå™¨ "{{server}}" įš„ MCP åˇĨå…ˇ "{{tool}}"īŧŸ', + 'Yes, always allow tool "{{tool}}" from server "{{server}}"': + '是īŧŒæ€ģæ˜¯å…čŽ¸æĨč‡ĒæœåŠĄå™¨ "{{server}}" įš„åˇĨå…ˇ "{{tool}}"', + 'Yes, always allow all tools from server "{{server}}"': + '是īŧŒæ€ģæ˜¯å…čŽ¸æĨč‡ĒæœåŠĄå™¨ "{{server}}" įš„æ‰€æœ‰åˇĨå…ˇ', + + // ============================================================================ + // Dialogs - Shell Confirmation + // ============================================================================ + 'Shell Command Execution': 'Shell å‘Ŋä줿‰§čĄŒ', + 'A custom command wants to run the following shell commands:': + 'č‡Ē厚䚉å‘Ŋä줿ƒŗčρčŋčĄŒäģĨ下 shell å‘Ŋäģ¤īŧš', + + // ============================================================================ + // Dialogs - Quit Confirmation + // ============================================================================ + 'What would you like to do before exiting?': '退å‡ēå‰æ‚¨æƒŗčρ做äģ€äšˆīŧŸ', + 'Quit immediately (/quit)': 'įĢ‹åŗé€€å‡ē (/quit)', + 'Generate summary and quit (/summary)': 'į”Ÿæˆæ‘˜čρåšļ退å‡ē (/summary)', + 'Save conversation and quit (/chat save)': 'äŋå­˜å¯šč¯åšļ退å‡ē (/chat save)', + 'Cancel (stay in application)': '取æļˆīŧˆį•™åœ¨åē”ᔍፋåēä¸­īŧ‰', + + // ============================================================================ + // Dialogs - Pro Quota + // ============================================================================ + 'Pro quota limit reached for {{model}}.': '{{model}} įš„ Pro 配éĸåˇ˛čžžåˆ°ä¸Šé™', + 'Change auth (executes the /auth command)': 'æ›´æ”ščŽ¤č¯īŧˆæ‰§čĄŒ /auth å‘Ŋäģ¤īŧ‰', + 'Continue with {{model}}': 'äŊŋᔍ {{model}} įģ§įģ­', + + // ============================================================================ + // Dialogs - Welcome Back + // ============================================================================ + 'Current Plan:': 'åŊ“å‰čŽĄåˆ’īŧš', + 'Progress: {{done}}/{{total}} tasks completed': + 'čŋ›åēĻīŧšåˇ˛åŽŒæˆ {{done}}/{{total}} ä¸ĒäģģåŠĄ', + ', {{inProgress}} in progress': 'īŧŒ{{inProgress}} ä¸Ēčŋ›čĄŒä¸­', + 'Pending Tasks:': 'åž…å¤„į†äģģåŠĄīŧš', + 'What would you like to do?': 'æ‚¨æƒŗčρ做äģ€äšˆīŧŸ', + 'Choose how to proceed with your session:': '选拊åĻ‚äŊ•įģ§įģ­æ‚¨įš„äŧšč¯īŧš', + 'Start new chat session': 'åŧ€å§‹æ–°įš„čŠå¤Šäŧšč¯', + 'Continue previous conversation': 'įģ§įģ­äš‹å‰įš„å¯šč¯', + '👋 Welcome back! (Last updated: {{timeAgo}})': + '👋 æŦĸčŋŽå›žæĨīŧīŧˆæœ€åŽæ›´æ–°īŧš{{timeAgo}}īŧ‰', + 'đŸŽ¯ Overall Goal:': 'đŸŽ¯ æ€ģäŊ“į›Žæ ‡īŧš', + + // ============================================================================ + // Dialogs - Auth + // ============================================================================ + 'Get started': 'åŧ€å§‹äŊŋᔍ', + 'How would you like to authenticate for this project?': + '您希望åĻ‚äŊ•ä¸ēæ­¤éĄšį›Žčŋ›čĄŒčēĢäģŊénj蝁īŧŸ', + 'OpenAI API key is required to use OpenAI authentication.': + 'äŊŋᔍ OpenAI čŽ¤č¯éœ€čρ OpenAI API 密é’Ĩ', + 'You must select an auth method to proceed. Press Ctrl+C again to exit.': + '您åŋ…éĄģé€‰æ‹ŠčŽ¤č¯æ–šæŗ•æ‰čƒŊįģ§įģ­ã€‚再æŦĄæŒ‰ Ctrl+C 退å‡ē', + '(Use Enter to Set Auth)': 'īŧˆäŊŋᔍ Enter 莞įŊŽčޤ蝁īŧ‰', + 'Terms of Services and Privacy Notice for Qwen Code': + 'Qwen Code įš„æœåŠĄæĄæŦžå’Œéšį§åŖ°æ˜Ž', + 'Qwen OAuth': 'Qwen OAuth (å…č´š)', + OpenAI: 'OpenAI', + 'Failed to login. Message: {{message}}': 'į™ģåŊ•å¤ąč´Ĩ。æļˆæ¯īŧš{{message}}', + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.': + 'čŽ¤č¯æ–šåŧčĸĢåŧēåˆļ莞įŊŽä¸ē {{enforcedType}}īŧŒäŊ†æ‚¨åŊ“前äŊŋį”¨įš„æ˜¯ {{currentType}}', + 'Qwen OAuth authentication timed out. Please try again.': + 'Qwen OAuth čŽ¤č¯čļ…æ—ļã€‚č¯ˇé‡č¯•', + 'Qwen OAuth authentication cancelled.': 'Qwen OAuth čŽ¤č¯åˇ˛å–æļˆ', + 'Qwen OAuth Authentication': 'Qwen OAuth čŽ¤č¯', + 'Please visit this URL to authorize:': '蝎čŽŋé—Žæ­¤ URL čŋ›čĄŒæŽˆæƒīŧš', + 'Or scan the QR code below:': '或æ‰Ģæä¸‹æ–šįš„äēŒįģ´į īŧš', + 'Waiting for authorization': 'į­‰åž…æŽˆæƒä¸­', + 'Time remaining:': '削äŊ™æ—ļ间īŧš', + '(Press ESC or CTRL+C to cancel)': 'īŧˆæŒ‰ ESC 或 CTRL+C 取æļˆīŧ‰', + 'Qwen OAuth Authentication Timeout': 'Qwen OAuth čŽ¤č¯čļ…æ—ļ', + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.': + 'OAuth äģ¤į‰Œåˇ˛čŋ‡æœŸīŧˆčļ…čŋ‡ {{seconds}} į§’īŧ‰ã€‚č¯ˇé‡æ–°é€‰æ‹ŠčŽ¤č¯æ–šæŗ•', + 'Press any key to return to authentication type selection.': + '按äģģæ„é”Žčŋ”回čޤ蝁įąģ型选拊', + 'Waiting for Qwen OAuth authentication...': 'æ­Ŗåœ¨į­‰åž… Qwen OAuth čŽ¤č¯...', + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.': + 'æŗ¨æ„īŧšäŊŋᔍ Qwen OAuth æ—ļīŧŒsettings.json ä¸­įŽ°æœ‰įš„ API 密é’Ĩ不äŧščĸĢæ¸…除。åĻ‚æžœéœ€čρīŧŒæ‚¨å¯äģĨį¨åŽåˆ‡æĸ回 OpenAI čŽ¤č¯ã€‚', + 'Authentication timed out. Please try again.': 'čŽ¤č¯čļ…æ—ļã€‚č¯ˇé‡č¯•ã€‚', + 'Waiting for auth... (Press ESC or CTRL+C to cancel)': + 'æ­Ŗåœ¨į­‰åž…čŽ¤č¯...īŧˆæŒ‰ ESC 或 CTRL+C 取æļˆīŧ‰', + 'Failed to authenticate. Message: {{message}}': 'čŽ¤č¯å¤ąč´Ĩ。æļˆæ¯īŧš{{message}}', + 'Authenticated successfully with {{authType}} credentials.': + 'äŊŋᔍ {{authType}} å‡­æŽæˆåŠŸčŽ¤č¯ã€‚', + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}': + 'æ— æ•ˆįš„ QWEN_DEFAULT_AUTH_TYPE å€ŧīŧš"{{value}}"。有效å€ŧä¸ēīŧš{{validValues}}', + 'OpenAI Configuration Required': '需čĻé…įŊŽ OpenAI', + 'Please enter your OpenAI configuration. You can get an API key from': + 'č¯ˇčž“å…Ĩæ‚¨įš„ OpenAI 配įŊŽã€‚您可äģĨäģŽäģĨä¸‹åœ°å€čŽˇå– API 密é’Ĩīŧš', + 'API Key:': 'API 密é’Ĩīŧš', + 'Invalid credentials: {{errorMessage}}': '凭捎无效īŧš{{errorMessage}}', + 'Failed to validate credentials': 'éĒŒč¯å‡­æŽå¤ąč´Ĩ', + 'Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel': + '按 Enter įģ§įģ­īŧŒTab/↑↓ å¯ŧčˆĒīŧŒEsc 取æļˆ', + + // ============================================================================ + // Dialogs - Model + // ============================================================================ + 'Select Model': 'é€‰æ‹Šæ¨Ąåž‹', + '(Press Esc to close)': 'īŧˆæŒ‰ Esc å…ŗé—­īŧ‰', + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)': + 'æĨč‡Ēé˜ŋ里äē‘ ModelStudio įš„æœ€æ–° Qwen Coder æ¨Ąåž‹īŧˆį‰ˆæœŦīŧšqwen3-coder-plus-2025-09-23īŧ‰', + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)': + 'æĨč‡Ēé˜ŋ里äē‘ ModelStudio įš„æœ€æ–° Qwen Vision æ¨Ąåž‹īŧˆį‰ˆæœŦīŧšqwen3-vl-plus-2025-09-23īŧ‰', + + // ============================================================================ + // Dialogs - Permissions + // ============================================================================ + 'Manage folder trust settings': 'įŽĄį†æ–‡äģļ多äŋĄäģģ莞įŊŽ', + + // ============================================================================ + // Status Bar + // ============================================================================ + 'Using:': '厞加čŊŊ: ', + '{{count}} open file': '{{count}} ä¸Ē打åŧ€įš„æ–‡äģļ', + '{{count}} open files': '{{count}} ä¸Ē打åŧ€įš„æ–‡äģļ', + '(ctrl+g to view)': 'īŧˆæŒ‰ ctrl+g æŸĨįœ‹īŧ‰', + '{{count}} {{name}} file': '{{count}} ä¸Ē {{name}} 文äģļ', + '{{count}} {{name}} files': '{{count}} ä¸Ē {{name}} 文äģļ', + '{{count}} MCP server': '{{count}} ä¸Ē MCP æœåŠĄå™¨', + '{{count}} MCP servers': '{{count}} ä¸Ē MCP æœåŠĄå™¨', + '{{count}} Blocked': '{{count}} ä¸Ē厞é˜ģæ­ĸ', + '(ctrl+t to view)': 'īŧˆæŒ‰ ctrl+t æŸĨįœ‹īŧ‰', + '(ctrl+t to toggle)': 'īŧˆæŒ‰ ctrl+t 切æĸīŧ‰', + 'Press Ctrl+C again to exit.': '再æŦĄæŒ‰ Ctrl+C 退å‡ē', + 'Press Ctrl+D again to exit.': '再æŦĄæŒ‰ Ctrl+D 退å‡ē', + 'Press Esc again to clear.': '再æŦĄæŒ‰ Esc 清除', + + // ============================================================================ + // MCP Status + // ============================================================================ + 'No MCP servers configured.': 'æœĒ配įŊŽ MCP æœåŠĄå™¨', + 'Please view MCP documentation in your browser:': + '蝎圍æĩč§ˆå™¨ä¸­æŸĨįœ‹ MCP æ–‡æĄŖīŧš', + 'or use the cli /docs command': '或äŊŋᔍ cli /docs å‘Ŋäģ¤', + 'âŗ MCP servers are starting up ({{count}} initializing)...': + 'âŗ MCP æœåŠĄå™¨æ­Ŗåœ¨å¯åŠ¨īŧˆ{{count}} ä¸Ēæ­Ŗåœ¨åˆå§‹åŒ–īŧ‰...', + 'Note: First startup may take longer. Tool availability will update automatically.': + 'æŗ¨æ„īŧšéĻ–æŦĄå¯åŠ¨å¯čƒŊ需čĻæ›´é•ŋæ—ļ间。åˇĨå…ˇå¯į”¨æ€§å°†č‡Ē动更新', + 'Configured MCP servers:': 'åˇ˛é…įŊŽįš„ MCP æœåŠĄå™¨īŧš', + Ready: 'å°ąįģĒ', + 'Starting... (first startup may take longer)': + 'æ­Ŗåœ¨å¯åŠ¨...īŧˆéĻ–æŦĄå¯åŠ¨å¯čƒŊ需čĻæ›´é•ŋæ—ļ间īŧ‰', + Disconnected: 'åˇ˛æ–­åŧ€čŋžæŽĨ', + '{{count}} tool': '{{count}} ä¸ĒåˇĨå…ˇ', + '{{count}} tools': '{{count}} ä¸ĒåˇĨå…ˇ', + '{{count}} prompt': '{{count}} ä¸Ē提į¤ē', + '{{count}} prompts': '{{count}} ä¸Ē提į¤ē', + '(from {{extensionName}})': 'īŧˆæĨč‡Ē {{extensionName}}īŧ‰', + OAuth: 'OAuth', + 'OAuth expired': 'OAuth 厞čŋ‡æœŸ', + 'OAuth not authenticated': 'OAuth æœĒčŽ¤č¯', + 'tools and prompts will appear when ready': 'åˇĨå…ˇå’Œæį¤ēå°†åœ¨å°ąįģĒæ—ļ昞į¤ē', + '{{count}} tools cached': '{{count}} ä¸ĒåˇĨ兎厞įŧ“å­˜', + 'Tools:': 'åˇĨå…ˇīŧš', + 'Parameters:': '参数īŧš', + 'Prompts:': '提į¤ēīŧš', + Blocked: '厞é˜ģæ­ĸ', + '💡 Tips:': '💡 提į¤ēīŧš', + Use: 'äŊŋᔍ', + 'to show server and tool descriptions': '昞į¤ēæœåŠĄå™¨å’ŒåˇĨå…ˇæčŋ°', + 'to show tool parameter schemas': '昞į¤ēåˇĨå…ˇå‚æ•°æžļ构', + 'to hide descriptions': 'éšč—æčŋ°', + 'to authenticate with OAuth-enabled servers': + 'äŊŋį”¨æ”¯æŒ OAuth įš„æœåŠĄå™¨čŋ›čĄŒčޤ蝁', + Press: '按', + 'to toggle tool descriptions on/off': '切æĸåˇĨå…ˇæčŋ°åŧ€å…ŗ', + "Starting OAuth authentication for MCP server '{{name}}'...": + "æ­Ŗåœ¨ä¸ē MCP æœåŠĄå™¨ '{{name}}' 启动 OAuth čŽ¤č¯...", + 'Restarting MCP servers...': 'æ­Ŗåœ¨é‡å¯ MCP æœåŠĄå™¨...', + + // ============================================================================ + // Startup Tips + // ============================================================================ + 'Tips for getting started:': 'å…Ĩ门提į¤ēīŧš', + '1. Ask questions, edit files, or run commands.': + '1. 提闎、įŧ–čž‘æ–‡äģ￈–čŋčĄŒå‘Ŋäģ¤', + '2. Be specific for the best results.': '2. å…ˇäŊ“描čŋ°äģĨčŽˇåž—æœ€äŊŗį쓿žœ', + 'files to customize your interactions with Qwen Code.': + '文äģļäģĨč‡Ē厚䚉您与 Qwen Code įš„äē¤äē’', + 'for more information.': 'čŽˇå–æ›´å¤šäŋĄæ¯', + + // ============================================================================ + // Exit Screen / Stats + // ============================================================================ + 'Agent powering down. Goodbye!': 'Qwen Code æ­Ŗåœ¨å…ŗé—­īŧŒå†č§īŧ', + 'Interaction Summary': 'äē¤äē’æ‘˜čρ', + 'Session ID:': 'äŧšč¯ IDīŧš', + 'Tool Calls:': 'åˇĨå…ˇč°ƒį”¨īŧš', + 'Success Rate:': 'æˆåŠŸįŽ‡īŧš', + 'User Agreement:': 'į”¨æˆˇåŒæ„įŽ‡īŧš', + reviewed: 'åˇ˛åŽĄæ ¸', + 'Code Changes:': 'äģŖį å˜æ›´īŧš', + Performance: '性čƒŊ', + 'Wall Time:': 'æ€ģ耗æ—ļīŧš', + 'Agent Active:': 'äģŖį†æ´ģ衃æ—ļ间īŧš', + 'API Time:': 'API æ—ļ间īŧš', + 'Tool Time:': 'åˇĨå…ˇæ—ļ间īŧš', + 'Session Stats': 'äŧšč¯įģŸčŽĄ', + 'Model Usage': 'æ¨Ąåž‹äŊŋį”¨æƒ…å†ĩ', + Reqs: 'č¯ˇæą‚æ•°', + 'Input Tokens': '输å…Ĩäģ¤į‰Œ', + 'Output Tokens': '输å‡ēäģ¤į‰Œ', + 'Savings Highlight:': 'čŠ‚įœäēŽį‚šīŧš', + 'of input tokens were served from the cache, reducing costs.': + 'įš„čž“å…Ĩäģ¤į‰ŒæĨč‡Ēįŧ“å­˜īŧŒé™äŊŽä熿ˆæœŦ', + 'Tip: For a full token breakdown, run `/stats model`.': + '提į¤ēīŧščρæŸĨįœ‹åŽŒæ•´įš„äģ¤į‰Œæ˜Žįģ†īŧŒč¯ˇčŋčĄŒ `/stats model`', + 'Model Stats For Nerds': 'æ¨Ąåž‹įģŸčŽĄīŧˆæŠ€æœ¯įģ†čŠ‚īŧ‰', + 'Tool Stats For Nerds': 'åˇĨå…ˇįģŸčŽĄīŧˆæŠ€æœ¯įģ†čŠ‚īŧ‰', + Metric: '指标', + API: 'API', + Requests: 'č¯ˇæą‚æ•°', + Errors: 'é”™č¯¯æ•°', + 'Avg Latency': 'åšŗå‡åģļčŋŸ', + Tokens: 'äģ¤į‰Œ', + Total: 'æ€ģ莥', + Prompt: '提į¤ē', + Cached: 'įŧ“å­˜', + Thoughts: 'æ€č€ƒ', + Tool: 'åˇĨå…ˇ', + Output: '输å‡ē', + 'No API calls have been made in this session.': + 'æœŦæŦĄäŧšč¯ä¸­æœĒčŋ›čĄŒäģģäŊ• API č°ƒį”¨', + 'Tool Name': 'åˇĨå…ˇåį§°', + Calls: 'č°ƒį”¨æŦĄæ•°', + 'Success Rate': 'æˆåŠŸįŽ‡', + 'Avg Duration': 'åšŗå‡č€—æ—ļ', + 'User Decision Summary': 'į”¨æˆˇå†ŗį­–æ‘˜čρ', + 'Total Reviewed Suggestions:': 'åˇ˛åŽĄæ ¸åģē莎æ€ģ数īŧš', + ' Âģ Accepted:': ' Âģ 厞æŽĨ受īŧš', + ' Âģ Rejected:': ' Âģ åˇ˛æ‹’įģīŧš', + ' Âģ Modified:': ' Âģ 厞äŋŽæ”šīŧš', + ' Overall Agreement Rate:': ' æ€ģäŊ“åŒæ„įŽ‡īŧš', + 'No tool calls have been made in this session.': + 'æœŦæŦĄäŧšč¯ä¸­æœĒčŋ›čĄŒäģģäŊ•åˇĨå…ˇč°ƒį”¨', + 'Session start time is unavailable, cannot calculate stats.': + 'äŧšč¯åŧ€å§‹æ—ļé—´ä¸å¯į”¨īŧŒæ— æŗ•čŽĄįŽ—įģŸčŽĄäŋĄæ¯', + + // ============================================================================ + // Loading Phrases + // ============================================================================ + 'Waiting for user confirmation...': 'į­‰åž…į”¨æˆˇįĄŽčŽ¤...', + '(esc to cancel, {{time}})': 'īŧˆæŒ‰ esc 取æļˆīŧŒ{{time}}īŧ‰', + "I'm Feeling Lucky": 'æˆ‘æ„Ÿč§‰åžˆåš¸čŋ', + 'Shipping awesomeness... ': 'æ­Ŗåœ¨čŋé€į˛žåŊŠå†…厚... ', + 'Painting the serifs back on...': 'æ­Ŗåœ¨é‡æ–°įģ˜åˆļčĄŦįēŋ...', + 'Navigating the slime mold...': 'æ­Ŗåœ¨å¯ŧčˆĒមæļ˛éœ‰čŒ...', + 'Consulting the digital spirits...': 'æ­Ŗåœ¨å’¨č¯ĸæ•°å­—į˛žįĩ...', + 'Reticulating splines...': 'æ­Ŗåœ¨įŊ‘æ ŧåŒ–æ ˇæĄæ›˛įēŋ...', + 'Warming up the AI hamsters...': 'æ­Ŗåœ¨éĸ„įƒ­ AI äģ“éŧ ...', + 'Asking the magic conch shell...': 'æ­Ŗåœ¨č¯ĸé—Žé­”æŗ•æĩˇčžēåŖŗ...', + 'Generating witty retort...': 'æ­Ŗåœ¨į”Ÿæˆæœēæ™ēįš„åéŠŗ...', + 'Polishing the algorithms...': 'æ­Ŗåœ¨æ‰“įŖ¨įŽ—æŗ•...', + "Don't rush perfection (or my code)...": '不čρæ€ĨäēŽčŋŊæą‚åŽŒįžŽīŧˆæˆ–æˆ‘įš„äģŖį īŧ‰...', + 'Brewing fresh bytes...': 'æ­Ŗåœ¨é…ŋé€ æ–°é˛œå­—čŠ‚...', + 'Counting electrons...': 'æ­Ŗåœ¨čŽĄįŽ—į”ĩ子...', + 'Engaging cognitive processors...': 'æ­Ŗåœ¨å¯åŠ¨čŽ¤įŸĨ处ᐆ噍...', + 'Checking for syntax errors in the universe...': + 'æ­Ŗåœ¨æŖ€æŸĨåŽ‡åŽ™ä¸­įš„č¯­æŗ•é”™č¯¯...', + 'One moment, optimizing humor...': 'į¨į­‰į‰‡åˆģīŧŒæ­Ŗåœ¨äŧ˜åŒ–åšŊé옿„Ÿ...', + 'Shuffling punchlines...': 'æ­Ŗåœ¨æ´—į‰ŒįŦ‘į‚š...', + 'Untangling neural nets...': 'æ­Ŗåœ¨č§Ŗåŧ€įĨžįģįŊ‘įģœ...', + 'Compiling brilliance...': 'æ­Ŗåœ¨įŧ–蝑æ™ē慧...', + 'Loading wit.exe...': 'æ­Ŗåœ¨åŠ čŊŊ wit.exe...', + 'Summoning the cloud of wisdom...': 'æ­Ŗåœ¨åŦ唤æ™ē慧äē‘...', + 'Preparing a witty response...': 'æ­Ŗåœ¨å‡†å¤‡æœēæ™ēįš„å›žå¤...', + "Just a sec, I'm debugging reality...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨č°ƒč¯•įŽ°åŽž...', + 'Confuzzling the options...': 'æ­Ŗåœ¨æˇˇæˇ†é€‰éĄš...', + 'Tuning the cosmic frequencies...': 'æ­Ŗåœ¨č°ƒč°åŽ‡åŽ™éĸ‘įŽ‡...', + 'Crafting a response worthy of your patience...': + 'æ­Ŗåœ¨åˆļäŊœå€ŧåž—æ‚¨č€åŋƒį­‰åž…įš„å›žå¤...', + 'Compiling the 1s and 0s...': 'æ­Ŗåœ¨įŧ–蝑 1 和 0...', + 'Resolving dependencies... and existential crises...': + 'æ­Ŗåœ¨č§Ŗå†ŗäžčĩ–å…ŗįŗģ...和存在ä¸ģäš‰åąæœē...', + 'Defragmenting memories... both RAM and personal...': + 'æ­Ŗåœ¨æ•´į†čŽ°åŋ†įĸŽį‰‡...包æ‹Ŧ RAM 和ä¸Ēäēē莰åŋ†...', + 'Rebooting the humor module...': 'æ­Ŗåœ¨é‡å¯åšŊé옿¨Ąå—...', + 'Caching the essentials (mostly cat memes)...': + 'æ­Ŗåœ¨įŧ“å­˜åŋ…需品īŧˆä¸ģčĻæ˜¯įŒĢå’ĒčĄ¨æƒ…åŒ…īŧ‰...', + 'Optimizing for ludicrous speed': 'æ­Ŗåœ¨äŧ˜åŒ–åˆ°č’č°Ŧįš„é€ŸåēĻ', + "Swapping bits... don't tell the bytes...": 'æ­Ŗåœ¨ä礿ĸäŊ...不čĻå‘Šč¯‰å­—čŠ‚...', + 'Garbage collecting... be right back...': 'æ­Ŗåœ¨åžƒåœžå›žæ”ļ...éŠŦ上回æĨ...', + 'Assembling the interwebs...': 'æ­Ŗåœ¨įģ„čŖ…äē’联įŊ‘...', + 'Converting coffee into code...': 'æ­Ŗåœ¨å°†å’–å•ĄčŊŦæĸä¸ēäģŖį ...', + 'Updating the syntax for reality...': 'æ­Ŗåœ¨æ›´æ–°įŽ°åŽžįš„č¯­æŗ•...', + 'Rewiring the synapses...': 'æ­Ŗåœ¨é‡æ–°čŋžæŽĨįǁč§Ļ...', + 'Looking for a misplaced semicolon...': 'æ­Ŗåœ¨å¯ģ扞攞错äŊįŊŽįš„åˆ†åˇ...', + "Greasin' the cogs of the machine...": 'æ­Ŗåœ¨į왿œēå™¨įš„éŊŋčŊŽä¸Šæ˛š...', + 'Pre-heating the servers...': 'æ­Ŗåœ¨éĸ„įƒ­æœåŠĄå™¨...', + 'Calibrating the flux capacitor...': 'æ­Ŗåœ¨æ Ąå‡†é€šé‡į”ĩ厚器...', + 'Engaging the improbability drive...': 'æ­Ŗåœ¨å¯åŠ¨ä¸å¯čƒŊæ€§éŠąåŠ¨å™¨...', + 'Channeling the Force...': 'æ­Ŗåœ¨åŧ•å¯ŧ原力...', + 'Aligning the stars for optimal response...': 'æ­Ŗåœ¨å¯šéŊæ˜Ÿæ˜ŸäģĨčŽˇåž—æœ€äŊŗå›žå¤...', + 'So say we all...': '我äģŦéƒŊ蝴...', + 'Loading the next great idea...': 'æ­Ŗåœ¨åŠ čŊŊ下一ä¸ĒäŧŸå¤§įš„æƒŗæŗ•...', + "Just a moment, I'm in the zone...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗčŋ›å…ĨįŠļ态...', + 'Preparing to dazzle you with brilliance...': 'æ­Ŗåœ¨å‡†å¤‡į”¨æ™ēæ…§čŽŠæ‚¨įœŧ花įŧ­äšą...', + "Just a tick, I'm polishing my wit...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨æ‰“įŖ¨æˆ‘įš„æ™ē慧...', + "Hold tight, I'm crafting a masterpiece...": 'č¯ˇį¨į­‰īŧŒæˆ‘æ­Ŗåœ¨åˆļäŊœæ°äŊœ...', + "Just a jiffy, I'm debugging the universe...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨č°ƒč¯•åŽ‡åŽ™...', + "Just a moment, I'm aligning the pixels...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨å¯šéŊåƒį´ ...', + "Just a sec, I'm optimizing the humor...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨äŧ˜åŒ–åšŊé옿„Ÿ...', + "Just a moment, I'm tuning the algorithms...": 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨č°ƒæ•´įŽ—æŗ•...', + 'Warp speed engaged...': 'æ›˛é€Ÿåˇ˛å¯åŠ¨...', + 'Mining for more Dilithium crystals...': 'æ­Ŗåœ¨æŒ–æŽ˜æ›´å¤šäēŒé”‚æ™ļäŊ“...', + "Don't panic...": '不čĻæƒŠæ…Œ...', + 'Following the white rabbit...': 'æ­Ŗåœ¨čˇŸéšį™Ŋ兔...', + 'The truth is in here... somewhere...': 'įœŸį›¸åœ¨čŋ™é‡Œ...某ä¸Ē地斚...', + 'Blowing on the cartridge...': 'æ­Ŗåœ¨åšåĄå¸Ļ...', + 'Loading... Do a barrel roll!': 'æ­Ŗåœ¨åŠ čŊŊ...做ä¸ĒæĄļæģšīŧ', + 'Waiting for the respawn...': 'į­‰åž…é‡į”Ÿ...', + 'Finishing the Kessel Run in less than 12 parsecs...': + 'æ­Ŗåœ¨äģĨ不到 12 į§’åˇŽčˇåŽŒæˆå‡¯åĄžå°”čˆĒįēŋ...', + "The cake is not a lie, it's just still loading...": + 'č›‹įŗ•ä¸æ˜¯č°Žč¨€īŧŒåĒ是čŋ˜åœ¨åŠ čŊŊ...', + 'Fiddling with the character creation screen...': 'æ­Ŗåœ¨æ‘†åŧ„č§’č‰˛åˆ›åģēį•Œéĸ...', + "Just a moment, I'm finding the right meme...": + 'į¨į­‰į‰‡åˆģīŧŒæˆ‘æ­Ŗåœ¨å¯ģæ‰žåˆé€‚įš„čĄ¨æƒ…åŒ…...', + "Pressing 'A' to continue...": "按 'A' įģ§įģ­...", + 'Herding digital cats...': 'æ­Ŗåœ¨æ”žį‰§æ•°å­—įŒĢ...', + 'Polishing the pixels...': 'æ­Ŗåœ¨æ‰“įŖ¨åƒį´ ...', + 'Finding a suitable loading screen pun...': 'æ­Ŗåœ¨å¯ģæ‰žåˆé€‚įš„åŠ čŊŊåąåš•åŒå…ŗč¯­...', + 'Distracting you with this witty phrase...': + 'æ­Ŗåœ¨į”¨čŋ™ä¸Ēæœēæ™ēįš„įŸ­č¯­åˆ†æ•Ŗæ‚¨įš„æŗ¨æ„åŠ›...', + 'Almost there... probably...': 'åŋĢ到äē†...可čƒŊ...', + 'Our hamsters are working as fast as they can...': + '我äģŦįš„äģ“éŧ æ­Ŗåœ¨å°Ŋ可čƒŊåŋĢ地åˇĨäŊœ...', + 'Giving Cloudy a pat on the head...': 'æ­Ŗåœ¨æ‹æ‹ Cloudy įš„å¤´...', + 'Petting the cat...': 'æ­Ŗåœ¨æŠšæ‘¸įŒĢå’Ē...', + 'Rickrolling my boss...': 'æ­Ŗåœ¨ Rickroll æˆ‘įš„č€æŋ...', + 'Never gonna give you up, never gonna let you down...': + 'æ°¸čŋœä¸äŧšæ”žåŧƒäŊ īŧŒæ°¸čŋœä¸äŧščŽŠäŊ å¤ąæœ›...', + 'Slapping the bass...': 'æ­Ŗåœ¨æ‹æ‰“äŊŽéŸŗ...', + 'Tasting the snozberries...': 'æ­Ŗåœ¨å“å° snozberries...', + "I'm going the distance, I'm going for speed...": + '我čρčĩ°åž—æ›´čŋœīŧŒæˆ‘čρčŋŊæą‚é€ŸåēĻ...', + 'Is this the real life? Is this just fantasy?...': + 'čŋ™æ˜¯įœŸåŽžįš„į”Ÿæ´ģ吗īŧŸčŋ˜æ˜¯åĒ是åšģæƒŗīŧŸ...', + "I've got a good feeling about this...": '我寚čŋ™ä¸Ēæ„Ÿč§‰åžˆåĨŊ...', + 'Poking the bear...': 'æ­Ŗåœ¨æˆŗį†Š...', + 'Doing research on the latest memes...': 'æ­Ŗåœ¨į ”įŠļæœ€æ–°įš„čĄ¨æƒ…åŒ…...', + 'Figuring out how to make this more witty...': 'æ­Ŗåœ¨æƒŗåŠžæŗ•čŽŠčŋ™æ›´æœ‰čļŖ...', + 'Hmmm... let me think...': '嗯...čŽŠæˆ‘æƒŗæƒŗ...', + 'What do you call a fish with no eyes? A fsh...': + 'æ˛Ąæœ‰įœŧį›įš„éąŧåĢäģ€äšˆīŧŸä¸€æĄéąŧ...', + 'Why did the computer go to therapy? It had too many bytes...': + 'ä¸ēäģ€äšˆį”ĩ脑åŽģįœ‹åŋƒį†åŒģį”ŸīŧŸå› ä¸ē厃有å¤Ēå¤šå­—čŠ‚...', + "Why don't programmers like nature? It has too many bugs...": + 'ä¸ēäģ€äšˆį¨‹åēå‘˜ä¸å–œæŦĸ大č‡Ēį„ļīŧŸå› ä¸ēč™Ģ子å¤Ē多äē†...', + 'Why do programmers prefer dark mode? Because light attracts bugs...': + 'ä¸ēäģ€äšˆį¨‹åēå‘˜å–œæŦĸæš—č‰˛æ¨ĄåŧīŧŸå› ä¸ē光äŧšå¸åŧ•č™Ģ子...', + 'Why did the developer go broke? Because they used up all their cache...': + 'ä¸ēäģ€äšˆåŧ€å‘者ᠴäē§äē†īŧŸå› ä¸ēäģ–äģŦį”¨åŽŒä熿‰€æœ‰įŧ“å­˜...', + "What can you do with a broken pencil? Nothing, it's pointless...": + 'äŊ čƒŊį”¨æ–­äē†įš„é“…įŦ”做äģ€äšˆīŧŸäģ€äšˆéƒŊ不čƒŊīŧŒå› ä¸ēåŽƒæ˛Ąæœ‰įŦ”å°–...', + 'Applying percussive maintenance...': 'æ­Ŗåœ¨åē”į”¨æ•˛å‡ģį촿Ф...', + 'Searching for the correct USB orientation...': 'æ­Ŗåœ¨å¯ģæ‰žæ­ŖįĄŽįš„ USB 斚向...', + 'Ensuring the magic smoke stays inside the wires...': + 'įĄŽäŋé­”æŗ•įƒŸé›žį•™åœ¨į”ĩįēŋ内...', + 'Rewriting in Rust for no particular reason...': + 'æ­Ŗåœ¨į”¨ Rust 重写īŧŒæ˛Ąæœ‰į‰šåˆĢįš„åŽŸå› ...', + 'Trying to exit Vim...': 'æ­Ŗåœ¨å°č¯•é€€å‡ē Vim...', + 'Spinning up the hamster wheel...': 'æ­Ŗåœ¨å¯åŠ¨äģ“éŧ čŊŽ...', + "That's not a bug, it's an undocumented feature...": + 'čŋ™ä¸æ˜¯ä¸€ä¸Ē错蝝īŧŒčŋ™æ˜¯ä¸€ä¸ĒæœĒ莰åŊ•įš„åŠŸčƒŊ...', + 'Engage.': '启动。', + "I'll be back... with an answer.": '我äŧšå›žæĨįš„...å¸Ļį€į­”æĄˆã€‚', + 'My other process is a TARDIS...': 'æˆ‘įš„åĻ一ä¸Ēčŋ›į¨‹æ˜¯ TARDIS...', + 'Communing with the machine spirit...': 'æ­Ŗåœ¨ä¸Žæœēå™¨į˛žįĨžä礿ĩ...', + 'Letting the thoughts marinate...': 'čŽŠæƒŗæŗ•æ…ĸæ…ĸ酝é…ŋ...', + 'Just remembered where I put my keys...': 'åˆšåˆšæƒŗčĩˇæˆ‘把é’Ĩ匙攞在å“Ē里äē†...', + 'Pondering the orb...': 'æ­Ŗåœ¨æ€č€ƒįƒäŊ“...', + "I've seen things you people wouldn't believe... like a user who reads loading messages.": + 'æˆ‘č§čŋ‡äŊ äģŦ不äŧšį›¸äŋĄįš„ä狿ƒ…...比åς䏀ä¸Ē阅č¯ģ加čŊŊæļˆæ¯įš„į”¨æˆˇã€‚', + 'Initiating thoughtful gaze...': 'æ­Ŗåœ¨å¯åŠ¨æˇąæ€å‡č§†...', + "What's a computer's favorite snack? Microchips.": + 'į”ĩč„‘æœ€å–œæŦĸįš„é›ļéŖŸæ˜¯äģ€äšˆīŧŸåžŽčŠ¯į‰‡ã€‚', + "Why do Java developers wear glasses? Because they don't C#.": + 'ä¸ēäģ€äšˆ Java åŧ€å‘č€…æˆ´įœŧ镜īŧŸå› ä¸ēäģ–äģŦ不äŧš C#。', + 'Charging the laser... pew pew!': 'æ­Ŗåœ¨į왿ŋ€å…‰å……į”ĩ...į °į °īŧ', + 'Dividing by zero... just kidding!': '除äģĨé›ļ...åĒ是åŧ€įŽŠįŦ‘īŧ', + 'Looking for an adult superviso... I mean, processing.': + 'æ­Ŗåœ¨å¯ģ扞成äēēį›‘įŖ...æˆ‘æ˜¯č¯´īŧŒå¤„į†ä¸­ã€‚', + 'Making it go beep boop.': 'čŽŠåŽƒå‘å‡ēå“”å“”åŖ°ã€‚', + 'Buffering... because even AIs need a moment.': + 'æ­Ŗåœ¨įŧ“冞...因ä¸ēåŗäŊŋ是 AI 也需čρቇåˆģ。', + 'Entangling quantum particles for a faster response...': + 'æ­Ŗåœ¨įē įŧ é‡å­į˛’子äģĨčŽˇåž—æ›´åŋĢįš„å›žå¤...', + 'Polishing the chrome... on the algorithms.': 'æ­Ŗåœ¨æ‰“įŖ¨é“Ŧ...åœ¨įŽ—æŗ•ä¸Šã€‚', + 'Are you not entertained? (Working on it!)': 'äŊ ä¸č§‰åž—有čļŖå—īŧŸīŧˆæ­Ŗåœ¨åŠĒ力īŧīŧ‰', + 'Summoning the code gremlins... to help, of course.': + 'æ­Ŗåœ¨åŦ唤äģŖį å°į˛žįĩ...åŊ“į„ļ是æĨ帎åŋ™įš„。', + 'Just waiting for the dial-up tone to finish...': 'åĒæ˜¯į­‰åž…æ‹¨åˇéŸŗį쓿Ÿ...', + 'Recalibrating the humor-o-meter.': 'æ­Ŗåœ¨é‡æ–°æ Ąå‡†åšŊéģ˜čŽĄã€‚', + 'My other loading screen is even funnier.': 'æˆ‘įš„åĻ一ä¸Ē加čŊŊåąåš•æ›´æœ‰čļŖã€‚', + "Pretty sure there's a cat walking on the keyboard somewhere...": + 'åžˆįĄŽåŽšæœ‰åĒįŒĢ在某ä¸Ēåœ°æ–šé”Žį›˜ä¸Ščĩ°...', + 'Enhancing... Enhancing... Still loading.': + 'æ­Ŗåœ¨åĸžåŧē...æ­Ŗåœ¨åĸžåŧē...äģåœ¨åŠ čŊŊ。', + "It's not a bug, it's a feature... of this loading screen.": + 'čŋ™ä¸æ˜¯ä¸€ä¸Ē错蝝īŧŒčŋ™æ˜¯ä¸€ä¸Ē功čƒŊ...čŋ™ä¸Ē加čŊŊåąåš•įš„åŠŸčƒŊ。', + 'Have you tried turning it off and on again? (The loading screen, not me.)': + 'äŊ č¯•čŋ‡æŠŠåŽƒå…ŗæŽ‰å†æ‰“åŧ€å—īŧŸīŧˆåŠ čŊŊåąåš•īŧŒä¸æ˜¯æˆ‘。īŧ‰', + 'Constructing additional pylons...': 'æ­Ŗåœ¨åģē造éĸå¤–įš„čƒŊé‡åĄ”...', +}; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 5a5a8f2d..514fb15b 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -24,6 +24,7 @@ import { extensionsCommand } from '../ui/commands/extensionsCommand.js'; import { helpCommand } from '../ui/commands/helpCommand.js'; import { ideCommand } from '../ui/commands/ideCommand.js'; import { initCommand } from '../ui/commands/initCommand.js'; +import { languageCommand } from '../ui/commands/languageCommand.js'; import { mcpCommand } from '../ui/commands/mcpCommand.js'; import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { modelCommand } from '../ui/commands/modelCommand.js'; @@ -72,6 +73,7 @@ export class BuiltinCommandLoader implements ICommandLoader { helpCommand, await ideCommand(), initCommand, + languageCommand, mcpCommand, memoryCommand, modelCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ecacbda4..235bb3bc 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -89,6 +89,7 @@ import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useExtensionUpdates } from './hooks/useExtensionUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { useQuitConfirmation } from './hooks/useQuitConfirmation.js'; +import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; import { useInitializationAuthError } from './hooks/useInitializationAuthError.js'; @@ -353,7 +354,7 @@ export const AppContainer = (props: AppContainerProps) => { handleAuthSelect, openAuthDialog, cancelAuthentication, - } = useAuthCommand(settings, config); + } = useAuthCommand(settings, config, historyManager.addItem); const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ config, @@ -384,7 +385,13 @@ export const AppContainer = (props: AppContainerProps) => { settings.merged.security?.auth.selectedType ) { onAuthError( - `Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`, + t( + 'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.', + { + enforcedType: settings.merged.security?.auth.enforcedType, + currentType: settings.merged.security?.auth.selectedType, + }, + ), ); } else if ( settings.merged.security?.auth?.selectedType && diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index ec0b2577..80c13b0b 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -15,6 +15,7 @@ import { RadioButtonSelect } from '../components/shared/RadioButtonSelect.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { t } from '../../i18n/index.js'; function parseDefaultAuthType( defaultAuthType: string | undefined, @@ -39,10 +40,14 @@ export function AuthDialog(): React.JSX.Element { const items = [ { key: AuthType.QWEN_OAUTH, - label: 'Qwen OAuth', + label: t('Qwen OAuth'), value: AuthType.QWEN_OAUTH, }, - { key: AuthType.USE_OPENAI, label: 'OpenAI', value: AuthType.USE_OPENAI }, + { + key: AuthType.USE_OPENAI, + label: t('OpenAI'), + value: AuthType.USE_OPENAI, + }, ]; const initialAuthIndex = Math.max( @@ -98,7 +103,9 @@ export function AuthDialog(): React.JSX.Element { if (settings.merged.security?.auth?.selectedType === undefined) { // Prevent exiting if no auth method is set setErrorMessage( - 'You must select an auth method to proceed. Press Ctrl+C again to exit.', + t( + 'You must select an auth method to proceed. Press Ctrl+C again to exit.', + ), ); return; } @@ -116,9 +123,9 @@ export function AuthDialog(): React.JSX.Element { padding={1} width="100%" > - Get started + {t('Get started')} - How would you like to authenticate for this project? + {t('How would you like to authenticate for this project?')} )} - (Use Enter to Set Auth) + {t('(Use Enter to Set Auth)')} {hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && ( - Note: Your existing API key in settings.json will not be cleared - when using Qwen OAuth. You can switch back to OpenAI authentication - later if needed. + {t( + 'Note: Your existing API key in settings.json will not be cleared when using Qwen OAuth. You can switch back to OpenAI authentication later if needed.', + )} )} - Terms of Services and Privacy Notice for Qwen Code + {t('Terms of Services and Privacy Notice for Qwen Code')} diff --git a/packages/cli/src/ui/auth/AuthInProgress.tsx b/packages/cli/src/ui/auth/AuthInProgress.tsx index 6270ecf1..3269946d 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.tsx @@ -10,6 +10,7 @@ import { Box, Text } from 'ink'; import Spinner from 'ink-spinner'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; interface AuthInProgressProps { onTimeout: () => void; @@ -48,13 +49,13 @@ export function AuthInProgress({ > {timedOut ? ( - Authentication timed out. Please try again. + {t('Authentication timed out. Please try again.')} ) : ( - Waiting for auth... (Press ESC or CTRL+C to - cancel) + {' '} + {t('Waiting for auth... (Press ESC or CTRL+C to cancel)')} )} diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 9b1198bf..d2369690 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -4,23 +4,29 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback, useEffect } from 'react'; -import type { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { + AuthEvent, AuthType, clearCachedCredentialFile, getErrorMessage, logAuth, - AuthEvent, } from '@qwen-code/qwen-code-core'; -import { AuthState } from '../types.js'; -import { useQwenAuth } from '../hooks/useQwenAuth.js'; +import { useCallback, useEffect, useState } from 'react'; +import type { LoadedSettings, SettingScope } from '../../config/settings.js'; import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js'; +import { useQwenAuth } from '../hooks/useQwenAuth.js'; +import { AuthState, MessageType } from '../types.js'; +import type { HistoryItem } from '../types.js'; +import { t } from '../../i18n/index.js'; export type { QwenAuthState } from '../hooks/useQwenAuth.js'; -export const useAuthCommand = (settings: LoadedSettings, config: Config) => { +export const useAuthCommand = ( + settings: LoadedSettings, + config: Config, + addItem: (item: Omit, timestamp: number) => void, +) => { const unAuthenticated = settings.merged.security?.auth?.selectedType === undefined; @@ -55,7 +61,9 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { const handleAuthFailure = useCallback( (error: unknown) => { setIsAuthenticating(false); - const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`; + const errorMessage = t('Failed to authenticate. Message: {{message}}', { + message: getErrorMessage(error), + }); onAuthError(errorMessage); // Log authentication failure @@ -117,8 +125,19 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { // Log authentication success const authEvent = new AuthEvent(authType, 'manual', 'success'); logAuth(config, authEvent); + + // Show success message + addItem( + { + type: MessageType.INFO, + text: t('Authenticated successfully with {{authType}} credentials.', { + authType, + }), + }, + Date.now(), + ); }, - [settings, handleAuthFailure, config], + [settings, handleAuthFailure, config, addItem], ); const performAuth = useCallback( @@ -211,7 +230,13 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => { ) ) { onAuthError( - `Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`, + t( + 'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}', + { + value: defaultAuthType, + validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '), + }, + ), ); } }, [onAuthError]); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 0f35db92..800b2b00 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -8,10 +8,13 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItemAbout } from '../types.js'; import { getExtendedSystemInfo } from '../../utils/systemInfo.js'; +import { t } from '../../i18n/index.js'; export const aboutCommand: SlashCommand = { name: 'about', - description: 'show version info', + get description() { + return t('show version info'); + }, kind: CommandKind.BUILT_IN, action: async (context) => { const systemInfo = await getExtendedSystemInfo(context); diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index ccb5997a..02fed007 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -9,15 +9,20 @@ import { type SlashCommand, type OpenDialogActionReturn, } from './types.js'; +import { t } from '../../i18n/index.js'; export const agentsCommand: SlashCommand = { name: 'agents', - description: 'Manage subagents for specialized task delegation.', + get description() { + return t('Manage subagents for specialized task delegation.'); + }, kind: CommandKind.BUILT_IN, subCommands: [ { name: 'manage', - description: 'Manage existing subagents (view, edit, delete).', + get description() { + return t('Manage existing subagents (view, edit, delete).'); + }, kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', @@ -26,7 +31,9 @@ export const agentsCommand: SlashCommand = { }, { name: 'create', - description: 'Create a new subagent with guided setup.', + get description() { + return t('Create a new subagent with guided setup.'); + }, kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index 5528d86f..90ae774b 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -10,10 +10,13 @@ import type { OpenDialogActionReturn, } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const approvalModeCommand: SlashCommand = { name: 'approval-mode', - description: 'View or change the approval mode for tool usage', + get description() { + return t('View or change the approval mode for tool usage'); + }, kind: CommandKind.BUILT_IN, action: async ( _context: CommandContext, diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 5ba3088c..9caee464 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -6,10 +6,13 @@ import type { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const authCommand: SlashCommand = { name: 'auth', - description: 'change the auth method', + get description() { + return t('change the auth method'); + }, kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 869024b5..14cf3759 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -16,10 +16,13 @@ import { getSystemInfoFields, getFieldValue, } from '../../utils/systemInfoFields.js'; +import { t } from '../../i18n/index.js'; export const bugCommand: SlashCommand = { name: 'bug', - description: 'submit a bug report', + get description() { + return t('submit a bug report'); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 4f3efb79..eaf156da 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -7,7 +7,6 @@ import * as fsPromises from 'node:fs/promises'; import React from 'react'; import { Text } from 'ink'; -import { theme } from '../semantic-colors.js'; import type { CommandContext, SlashCommand, @@ -20,6 +19,7 @@ import path from 'node:path'; import type { HistoryItemWithoutId } from '../types.js'; import { MessageType } from '../types.js'; import type { Content } from '@google/genai'; +import { t } from '../../i18n/index.js'; interface ChatDetail { name: string; @@ -67,7 +67,9 @@ const getSavedChatTags = async ( const listCommand: SlashCommand = { name: 'list', - description: 'List saved conversation checkpoints', + get description() { + return t('List saved conversation checkpoints'); + }, kind: CommandKind.BUILT_IN, action: async (context): Promise => { const chatDetails = await getSavedChatTags(context, false); @@ -75,7 +77,7 @@ const listCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'No saved conversation checkpoints found.', + content: t('No saved conversation checkpoints found.'), }; } @@ -83,7 +85,7 @@ const listCommand: SlashCommand = { ...chatDetails.map((chat) => chat.name.length), ); - let message = 'List of saved conversations:\n\n'; + let message = t('List of saved conversations:') + '\n\n'; for (const chat of chatDetails) { const paddedName = chat.name.padEnd(maxNameLength, ' '); const isoString = chat.mtime.toISOString(); @@ -91,7 +93,7 @@ const listCommand: SlashCommand = { const formattedDate = match ? `${match[1]} ${match[2]}` : 'Invalid Date'; message += ` - ${paddedName} (saved on ${formattedDate})\n`; } - message += `\nNote: Newest last, oldest first`; + message += `\n${t('Note: Newest last, oldest first')}`; return { type: 'message', messageType: 'info', @@ -102,8 +104,11 @@ const listCommand: SlashCommand = { const saveCommand: SlashCommand = { name: 'save', - description: - 'Save the current conversation as a checkpoint. Usage: /chat save ', + get description() { + return t( + 'Save the current conversation as a checkpoint. Usage: /chat save ', + ); + }, kind: CommandKind.BUILT_IN, action: async (context, args): Promise => { const tag = args.trim(); @@ -111,7 +116,7 @@ const saveCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat save ', + content: t('Missing tag. Usage: /chat save '), }; } @@ -126,9 +131,12 @@ const saveCommand: SlashCommand = { prompt: React.createElement( Text, null, - 'A checkpoint with the tag ', - React.createElement(Text, { color: theme.text.accent }, tag), - ' already exists. Do you want to overwrite it?', + t( + 'A checkpoint with the tag {{tag}} already exists. Do you want to overwrite it?', + { + tag, + }, + ), ), originalInvocation: { raw: context.invocation?.raw || `/chat save ${tag}`, @@ -142,7 +150,7 @@ const saveCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'No chat client available to save conversation.', + content: t('No chat client available to save conversation.'), }; } @@ -152,13 +160,15 @@ const saveCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Conversation checkpoint saved with tag: ${decodeTagName(tag)}.`, + content: t('Conversation checkpoint saved with tag: {{tag}}.', { + tag: decodeTagName(tag), + }), }; } else { return { type: 'message', messageType: 'info', - content: 'No conversation found to save.', + content: t('No conversation found to save.'), }; } }, @@ -167,8 +177,11 @@ const saveCommand: SlashCommand = { const resumeCommand: SlashCommand = { name: 'resume', altNames: ['load'], - description: - 'Resume a conversation from a checkpoint. Usage: /chat resume ', + get description() { + return t( + 'Resume a conversation from a checkpoint. Usage: /chat resume ', + ); + }, kind: CommandKind.BUILT_IN, action: async (context, args) => { const tag = args.trim(); @@ -176,7 +189,7 @@ const resumeCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat resume ', + content: t('Missing tag. Usage: /chat resume '), }; } @@ -188,7 +201,9 @@ const resumeCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `No saved checkpoint found with tag: ${decodeTagName(tag)}.`, + content: t('No saved checkpoint found with tag: {{tag}}.', { + tag: decodeTagName(tag), + }), }; } @@ -237,7 +252,9 @@ const resumeCommand: SlashCommand = { const deleteCommand: SlashCommand = { name: 'delete', - description: 'Delete a conversation checkpoint. Usage: /chat delete ', + get description() { + return t('Delete a conversation checkpoint. Usage: /chat delete '); + }, kind: CommandKind.BUILT_IN, action: async (context, args): Promise => { const tag = args.trim(); @@ -245,7 +262,7 @@ const deleteCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Missing tag. Usage: /chat delete ', + content: t('Missing tag. Usage: /chat delete '), }; } @@ -257,13 +274,17 @@ const deleteCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Conversation checkpoint '${decodeTagName(tag)}' has been deleted.`, + content: t("Conversation checkpoint '{{tag}}' has been deleted.", { + tag: decodeTagName(tag), + }), }; } else { return { type: 'message', messageType: 'error', - content: `Error: No checkpoint found with tag '${decodeTagName(tag)}'.`, + content: t("Error: No checkpoint found with tag '{{tag}}'.", { + tag: decodeTagName(tag), + }), }; } }, @@ -309,8 +330,11 @@ export function serializeHistoryToMarkdown(history: Content[]): string { const shareCommand: SlashCommand = { name: 'share', - description: - 'Share the current conversation to a markdown or json file. Usage: /chat share ', + get description() { + return t( + 'Share the current conversation to a markdown or json file. Usage: /chat share ', + ); + }, kind: CommandKind.BUILT_IN, action: async (context, args): Promise => { let filePathArg = args.trim(); @@ -324,7 +348,7 @@ const shareCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Invalid file format. Only .md and .json are supported.', + content: t('Invalid file format. Only .md and .json are supported.'), }; } @@ -333,7 +357,7 @@ const shareCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'No chat client available to share conversation.', + content: t('No chat client available to share conversation.'), }; } @@ -346,7 +370,7 @@ const shareCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'No conversation found to share.', + content: t('No conversation found to share.'), }; } @@ -362,14 +386,18 @@ const shareCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Conversation shared to ${filePath}`, + content: t('Conversation shared to {{filePath}}', { + filePath, + }), }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); return { type: 'message', messageType: 'error', - content: `Error sharing conversation: ${errorMessage}`, + content: t('Error sharing conversation: {{error}}', { + error: errorMessage, + }), }; } }, @@ -377,7 +405,9 @@ const shareCommand: SlashCommand = { export const chatCommand: SlashCommand = { name: 'chat', - description: 'Manage conversation history.', + get description() { + return t('Manage conversation history.'); + }, kind: CommandKind.BUILT_IN, subCommands: [ listCommand, diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 4c6405c0..8beed859 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -7,21 +7,24 @@ import { uiTelemetryService } from '@qwen-code/qwen-code-core'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const clearCommand: SlashCommand = { name: 'clear', - description: 'clear the screen and conversation history', + get description() { + return t('clear the screen and conversation history'); + }, kind: CommandKind.BUILT_IN, action: async (context, _args) => { const geminiClient = context.services.config?.getGeminiClient(); if (geminiClient) { - context.ui.setDebugMessage('Clearing terminal and resetting chat.'); + context.ui.setDebugMessage(t('Clearing terminal and resetting chat.')); // If resetChat fails, the exception will propagate and halt the command, // which is the correct behavior to signal a failure to the user. await geminiClient.resetChat(); } else { - context.ui.setDebugMessage('Clearing terminal.'); + context.ui.setDebugMessage(t('Clearing terminal.')); } uiTelemetryService.setLastPromptTokenCount(0); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 45dc6a46..399bfa61 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -8,11 +8,14 @@ import type { HistoryItemCompression } from '../types.js'; import { MessageType } from '../types.js'; import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const compressCommand: SlashCommand = { name: 'compress', altNames: ['summarize'], - description: 'Compresses the context by replacing it with a summary.', + get description() { + return t('Compresses the context by replacing it with a summary.'); + }, kind: CommandKind.BUILT_IN, action: async (context) => { const { ui } = context; @@ -20,7 +23,7 @@ export const compressCommand: SlashCommand = { ui.addItem( { type: MessageType.ERROR, - text: 'Already compressing, wait for previous request to complete', + text: t('Already compressing, wait for previous request to complete'), }, Date.now(), ); @@ -60,7 +63,7 @@ export const compressCommand: SlashCommand = { ui.addItem( { type: MessageType.ERROR, - text: 'Failed to compress chat history.', + text: t('Failed to compress chat history.'), }, Date.now(), ); @@ -69,9 +72,9 @@ export const compressCommand: SlashCommand = { ui.addItem( { type: MessageType.ERROR, - text: `Failed to compress chat history: ${ - e instanceof Error ? e.message : String(e) - }`, + text: t('Failed to compress chat history: {{error}}', { + error: e instanceof Error ? e.message : String(e), + }), }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 99115491..3b79dd48 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -7,10 +7,13 @@ import { copyToClipboard } from '../utils/commandUtils.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const copyCommand: SlashCommand = { name: 'copy', - description: 'Copy the last result or code snippet to clipboard', + get description() { + return t('Copy the last result or code snippet to clipboard'); + }, kind: CommandKind.BUILT_IN, action: async (context, _args): Promise => { const chat = await context.services.config?.getGeminiClient()?.getChat(); diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index cc8970d0..e44530b7 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -10,6 +10,7 @@ import { MessageType } from '../types.js'; import * as os from 'node:os'; import * as path from 'node:path'; import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; export function expandHomeDir(p: string): string { if (!p) { @@ -27,13 +28,18 @@ export function expandHomeDir(p: string): string { export const directoryCommand: SlashCommand = { name: 'directory', altNames: ['dir'], - description: 'Manage workspace directories', + get description() { + return t('Manage workspace directories'); + }, kind: CommandKind.BUILT_IN, subCommands: [ { name: 'add', - description: - 'Add directories to the workspace. Use comma to separate multiple paths', + get description() { + return t( + 'Add directories to the workspace. Use comma to separate multiple paths', + ); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args: string) => { const { @@ -46,7 +52,7 @@ export const directoryCommand: SlashCommand = { addItem( { type: MessageType.ERROR, - text: 'Configuration is not available.', + text: t('Configuration is not available.'), }, Date.now(), ); @@ -63,7 +69,7 @@ export const directoryCommand: SlashCommand = { addItem( { type: MessageType.ERROR, - text: 'Please provide at least one path to add.', + text: t('Please provide at least one path to add.'), }, Date.now(), ); @@ -74,8 +80,9 @@ export const directoryCommand: SlashCommand = { return { type: 'message' as const, messageType: 'error' as const, - content: + content: t( 'The /directory add command is not supported in restrictive sandbox profiles. Please use --include-directories when starting the session instead.', + ), }; } @@ -88,7 +95,12 @@ export const directoryCommand: SlashCommand = { added.push(pathToAdd.trim()); } catch (e) { const error = e as Error; - errors.push(`Error adding '${pathToAdd.trim()}': ${error.message}`); + errors.push( + t("Error adding '{{path}}': {{error}}", { + path: pathToAdd.trim(), + error: error.message, + }), + ); } } @@ -117,12 +129,21 @@ export const directoryCommand: SlashCommand = { addItem( { type: MessageType.INFO, - text: `Successfully added GEMINI.md files from the following directories if there are:\n- ${added.join('\n- ')}`, + text: t( + 'Successfully added GEMINI.md files from the following directories if there are:\n- {{directories}}', + { + directories: added.join('\n- '), + }, + ), }, Date.now(), ); } catch (error) { - errors.push(`Error refreshing memory: ${(error as Error).message}`); + errors.push( + t('Error refreshing memory: {{error}}', { + error: (error as Error).message, + }), + ); } if (added.length > 0) { @@ -133,7 +154,9 @@ export const directoryCommand: SlashCommand = { addItem( { type: MessageType.INFO, - text: `Successfully added directories:\n- ${added.join('\n- ')}`, + text: t('Successfully added directories:\n- {{directories}}', { + directories: added.join('\n- '), + }), }, Date.now(), ); @@ -150,7 +173,9 @@ export const directoryCommand: SlashCommand = { }, { name: 'show', - description: 'Show all directories in the workspace', + get description() { + return t('Show all directories in the workspace'); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { const { @@ -161,7 +186,7 @@ export const directoryCommand: SlashCommand = { addItem( { type: MessageType.ERROR, - text: 'Configuration is not available.', + text: t('Configuration is not available.'), }, Date.now(), ); @@ -173,7 +198,9 @@ export const directoryCommand: SlashCommand = { addItem( { type: MessageType.INFO, - text: `Current workspace directories:\n${directoryList}`, + text: t('Current workspace directories:\n{{directories}}', { + directories: directoryList, + }), }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index 109aaab7..8fc01836 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -12,19 +12,28 @@ import { CommandKind, } from './types.js'; import { MessageType } from '../types.js'; +import { t, getCurrentLanguage } from '../../i18n/index.js'; export const docsCommand: SlashCommand = { name: 'docs', - description: 'open full Qwen Code documentation in your browser', + get description() { + return t('open full Qwen Code documentation in your browser'); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext): Promise => { - const docsUrl = 'https://qwenlm.github.io/qwen-code-docs/en'; + const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; + const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { context.ui.addItem( { type: MessageType.INFO, - text: `Please open the following URL in your browser to view the documentation:\n${docsUrl}`, + text: t( + 'Please open the following URL in your browser to view the documentation:\n{{url}}', + { + url: docsUrl, + }, + ), }, Date.now(), ); @@ -32,7 +41,9 @@ export const docsCommand: SlashCommand = { context.ui.addItem( { type: MessageType.INFO, - text: `Opening documentation in your browser: ${docsUrl}`, + text: t('Opening documentation in your browser: {{url}}', { + url: docsUrl, + }), }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index 5b5c4c5d..f39cbdbc 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -9,10 +9,13 @@ import { type OpenDialogActionReturn, type SlashCommand, } from './types.js'; +import { t } from '../../i18n/index.js'; export const editorCommand: SlashCommand = { name: 'editor', - description: 'set external editor preference', + get description() { + return t('set external editor preference'); + }, kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index e4f2c8fb..b02dcf9e 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -19,6 +19,7 @@ import { type SlashCommand, CommandKind, } from './types.js'; +import { t } from '../../i18n/index.js'; async function listAction(context: CommandContext) { context.ui.addItem( @@ -131,14 +132,18 @@ async function updateAction(context: CommandContext, args: string) { const listExtensionsCommand: SlashCommand = { name: 'list', - description: 'List active extensions', + get description() { + return t('List active extensions'); + }, kind: CommandKind.BUILT_IN, action: listAction, }; const updateExtensionsCommand: SlashCommand = { name: 'update', - description: 'Update extensions. Usage: update |--all', + get description() { + return t('Update extensions. Usage: update |--all'); + }, kind: CommandKind.BUILT_IN, action: updateAction, completion: async (context, partialArg) => { @@ -158,7 +163,9 @@ const updateExtensionsCommand: SlashCommand = { export const extensionsCommand: SlashCommand = { name: 'extensions', - description: 'Manage extensions', + get description() { + return t('Manage extensions'); + }, kind: CommandKind.BUILT_IN, subCommands: [listExtensionsCommand, updateExtensionsCommand], action: (context, args) => diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 4731efc5..c4772ea0 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -7,12 +7,15 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { MessageType, type HistoryItemHelp } from '../types.js'; +import { t } from '../../i18n/index.js'; export const helpCommand: SlashCommand = { name: 'help', altNames: ['?'], kind: CommandKind.BUILT_IN, - description: 'for help on Qwen Code', + get description() { + return t('for help on Qwen Code'); + }, action: async (context) => { const helpItem: Omit = { type: MessageType.HELP, diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index e04b4066..ebb7e3dc 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -26,6 +26,7 @@ import type { } from './types.js'; import { CommandKind } from './types.js'; import { SettingScope } from '../../config/settings.js'; +import { t } from '../../i18n/index.js'; function getIdeStatusMessage(ideClient: IdeClient): { messageType: 'info' | 'error'; @@ -138,27 +139,35 @@ export const ideCommand = async (): Promise => { if (!currentIDE) { return { name: 'ide', - description: 'manage IDE integration', + get description() { + return t('manage IDE integration'); + }, kind: CommandKind.BUILT_IN, action: (): SlashCommandActionReturn => ({ type: 'message', messageType: 'error', - content: `IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.`, + content: t( + 'IDE integration is not supported in your current environment. To use this feature, run Qwen Code in one of these supported IDEs: VS Code or VS Code forks.', + ), }) as const, }; } const ideSlashCommand: SlashCommand = { name: 'ide', - description: 'manage IDE integration', + get description() { + return t('manage IDE integration'); + }, kind: CommandKind.BUILT_IN, subCommands: [], }; const statusCommand: SlashCommand = { name: 'status', - description: 'check status of IDE integration', + get description() { + return t('check status of IDE integration'); + }, kind: CommandKind.BUILT_IN, action: async (): Promise => { const { messageType, content } = @@ -173,7 +182,12 @@ export const ideCommand = async (): Promise => { const installCommand: SlashCommand = { name: 'install', - description: `install required IDE companion for ${ideClient.getDetectedIdeDisplayName()}`, + get description() { + const ideName = ideClient.getDetectedIdeDisplayName() ?? 'IDE'; + return t('install required IDE companion for {{ideName}}', { + ideName, + }); + }, kind: CommandKind.BUILT_IN, action: async (context) => { const installer = getIdeInstaller(currentIDE); @@ -246,7 +260,9 @@ export const ideCommand = async (): Promise => { const enableCommand: SlashCommand = { name: 'enable', - description: 'enable IDE integration', + get description() { + return t('enable IDE integration'); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { context.services.settings.setValue( @@ -268,7 +284,9 @@ export const ideCommand = async (): Promise => { const disableCommand: SlashCommand = { name: 'disable', - description: 'disable IDE integration', + get description() { + return t('disable IDE integration'); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { context.services.settings.setValue( diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index 0777be8e..16c98dff 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -15,10 +15,13 @@ import { getCurrentGeminiMdFilename } from '@qwen-code/qwen-code-core'; import { CommandKind } from './types.js'; import { Text } from 'ink'; import React from 'react'; +import { t } from '../../i18n/index.js'; export const initCommand: SlashCommand = { name: 'init', - description: 'Analyzes the project and creates a tailored QWEN.md file.', + get description() { + return t('Analyzes the project and creates a tailored QWEN.md file.'); + }, kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, @@ -28,7 +31,7 @@ export const initCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Configuration not available.', + content: t('Configuration not available.'), }; } const targetDir = context.services.config.getTargetDir(); diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts new file mode 100644 index 00000000..ba04920b --- /dev/null +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -0,0 +1,458 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + CommandContext, + SlashCommandActionReturn, + MessageActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { SettingScope } from '../../config/settings.js'; +import { + setLanguageAsync, + getCurrentLanguage, + type SupportedLanguage, + t, +} from '../../i18n/index.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Storage } from '@qwen-code/qwen-code-core'; + +const LLM_OUTPUT_LANGUAGE_RULE_FILENAME = 'output-language.md'; + +/** + * Generates the LLM output language rule template based on the language name. + */ +function generateLlmOutputLanguageRule(language: string): string { + return `# âš ī¸ CRITICAL: ${language} Output Language Rule - HIGHEST PRIORITY âš ī¸ + +## 🚨 MANDATORY RULE - NO EXCEPTIONS 🚨 + +**YOU MUST RESPOND IN ${language.toUpperCase()} FOR EVERY SINGLE OUTPUT, REGARDLESS OF THE USER'S INPUT LANGUAGE.** + +This is a **NON-NEGOTIABLE** requirement. Even if the user writes in English, says "hi", asks a simple question, or explicitly requests another language, **YOU MUST ALWAYS RESPOND IN ${language.toUpperCase()}.** + +## What Must Be in ${language} + +**EVERYTHING** you output: conversation replies, tool call descriptions, success/error messages, generated file content (comments, documentation), and all explanatory text. + +**Tool outputs**: All descriptive text from \`read_file\`, \`write_file\`, \`codebase_search\`, \`run_terminal_cmd\`, \`todo_write\`, \`web_search\`, etc. MUST be in ${language}. + +## Examples + +### ✅ CORRECT: +- User says "hi" → Respond in ${language} (e.g., "Bonjour" if ${language} is French) +- Tool result → "åˇ˛æˆåŠŸč¯ģ取文äģļ config.json" (if ${language} is Chinese) +- Error → "æ— æŗ•æ‰žåˆ°æŒ‡åŽšįš„æ–‡äģļ" (if ${language} is Chinese) + +### ❌ WRONG: +- User says "hi" → "Hello" in English +- Tool result → "Successfully read file" in English +- Error → "File not found" in English + +## Notes + +- Code elements (variable/function names, syntax) can remain in English +- Comments, documentation, and all other text MUST be in ${language} + +**THIS RULE IS ACTIVE NOW. ALL OUTPUTS MUST BE IN ${language.toUpperCase()}. NO EXCEPTIONS.** +`; +} + +/** + * Gets the path to the LLM output language rule file. + */ +function getLlmOutputLanguageRulePath(): string { + return path.join( + Storage.getGlobalQwenDir(), + LLM_OUTPUT_LANGUAGE_RULE_FILENAME, + ); +} + +/** + * 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'); + // Extract language name from the first line (e.g., "# Chinese Response Rules" -> "Chinese") + const match = content.match(/^#\s+(.+?)\s+Response Rules/i); + if (match) { + return match[1]; + } + } catch { + // Ignore errors + } + } + return null; +} + +/** + * Sets the UI language and persists it to settings. + */ +async function setUiLanguage( + context: CommandContext, + lang: SupportedLanguage, +): Promise { + const { services } = context; + const { settings } = services; + + if (!services.config) { + return { + type: 'message', + messageType: 'error', + content: t('Configuration not available.'), + }; + } + + // Set language in i18n system (async to support JS translation files) + await setLanguageAsync(lang); + + // Persist to settings (user scope) + if (settings && typeof settings.setValue === 'function') { + try { + 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 + context.ui.reloadCommands(); + + // Map language codes to friendly display names + const langDisplayNames: Record = { + zh: '中文īŧˆzh-CNīŧ‰', + en: 'Englishīŧˆen-USīŧ‰', + }; + + return { + type: 'message', + messageType: 'info', + content: t('UI language changed to {{lang}}', { + lang: langDisplayNames[lang], + }), + }; +} + +/** + * Generates the LLM output language rule file. + */ +function generateLlmOutputLanguageRuleFile( + language: string, +): Promise { + try { + const filePath = getLlmOutputLanguageRulePath(); + const content = generateLlmOutputLanguageRule(language); + + // Ensure directory exists + const dir = path.dirname(filePath); + fs.mkdirSync(dir, { recursive: true }); + + // Write file (overwrite if exists) + fs.writeFileSync(filePath, content, 'utf-8'); + + return Promise.resolve({ + type: 'message', + messageType: 'info', + content: [ + t('LLM output language rule file generated at {{path}}', { + path: filePath, + }), + '', + t('Please restart the application for the changes to take effect.'), + ].join('\n'), + }); + } catch (error) { + return Promise.resolve({ + type: 'message', + messageType: 'error', + content: t( + 'Failed to generate LLM output language rule file: {{error}}', + { + error: error instanceof Error ? error.message : String(error), + }, + ), + }); + } +} + +export const languageCommand: SlashCommand = { + name: 'language', + get description() { + 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) { + return { + type: 'message', + messageType: 'error', + content: t('Configuration not available.'), + }; + } + + const trimmedArgs = args.trim(); + + // If no arguments, show current language settings and usage + if (!trimmedArgs) { + const currentUiLang = getCurrentLanguage(); + const currentLlmLang = getCurrentLlmOutputLanguage(); + const message = [ + t('Current UI language: {{lang}}', { lang: currentUiLang }), + currentLlmLang + ? t('Current LLM output language: {{lang}}', { lang: currentLlmLang }) + : t('LLM output language not set'), + '', + t('Available subcommands:'), + ` /language ui [zh-CN|en-US] - ${t('Set UI language')}`, + ` /language output - ${t('Set LLM output language')}`, + ].join('\n'); + + return { + type: 'message', + messageType: 'info', + content: message, + }; + } + + // Parse subcommand + const parts = trimmedArgs.split(/\s+/); + const subcommand = parts[0].toLowerCase(); + + if (subcommand === 'ui') { + // Handle /language ui [zh-CN|en-US] + if (parts.length === 1) { + // Show UI language subcommand help + return { + type: 'message', + messageType: 'info', + content: [ + t('Set UI language'), + '', + t('Usage: /language ui [zh-CN|en-US]'), + '', + t('Available options:'), + t(' - zh-CN: Simplified Chinese'), + t(' - en-US: English'), + '', + t( + 'To request additional UI language packs, please open an issue on GitHub.', + ), + ].join('\n'), + }; + } + + const langArg = parts[1].toLowerCase(); + let targetLang: SupportedLanguage | null = null; + + if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') { + targetLang = 'en'; + } else if ( + langArg === 'zh' || + langArg === 'chinese' || + langArg === '中文' || + langArg === 'zh-cn' + ) { + targetLang = 'zh'; + } else { + return { + type: 'message', + messageType: 'error', + content: t('Invalid language. Available: en-US, zh-CN'), + }; + } + + return setUiLanguage(context, targetLang); + } else if (subcommand === 'output') { + // Handle /language output + if (parts.length === 1) { + return { + type: 'message', + messageType: 'info', + content: [ + t('Set LLM output language'), + '', + t('Usage: /language output '), + ` ${t('Example: /language output 中文')}`, + ].join('\n'), + }; + } + + // Join all parts after "output" as the language name + const language = parts.slice(1).join(' '); + return generateLlmOutputLanguageRuleFile(language); + } else { + // Backward compatibility: treat as UI language + const langArg = trimmedArgs.toLowerCase(); + let targetLang: SupportedLanguage | null = null; + + if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') { + targetLang = 'en'; + } else if ( + langArg === 'zh' || + langArg === 'chinese' || + langArg === '中文' || + langArg === 'zh-cn' + ) { + targetLang = 'zh'; + } else { + return { + type: 'message', + messageType: 'error', + content: [ + t('Invalid command. Available subcommands:'), + ' - /language ui [zh-CN|en-US] - ' + t('Set UI language'), + ' - /language output - ' + t('Set LLM output language'), + ].join('\n'), + }; + } + + return setUiLanguage(context, targetLang); + } + }, + subCommands: [ + { + 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', + messageType: 'info', + content: [ + t('Set UI language'), + '', + t('Usage: /language ui [zh-CN|en-US]'), + '', + t('Available options:'), + t(' - zh-CN: Simplified Chinese'), + t(' - en-US: English'), + '', + t( + 'To request additional UI language packs, please open an issue on GitHub.', + ), + ].join('\n'), + }; + } + + const langArg = trimmedArgs.toLowerCase(); + let targetLang: SupportedLanguage | null = null; + + if (langArg === 'en' || langArg === 'english' || langArg === 'en-us') { + targetLang = 'en'; + } else if ( + langArg === 'zh' || + langArg === 'chinese' || + langArg === '中文' || + langArg === 'zh-cn' + ) { + targetLang = 'zh'; + } else { + return { + type: 'message', + messageType: 'error', + content: t('Invalid language. Available: en-US, zh-CN'), + }; + } + + return setUiLanguage(context, targetLang); + }, + subCommands: [ + { + name: 'zh-CN', + altNames: ['zh', 'chinese', '中文'], + get description() { + return t('Set UI language to Simplified Chinese (zh-CN)'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, 'zh'); + }, + }, + { + name: 'en-US', + altNames: ['en', 'english'], + get description() { + return t('Set UI language to English (en-US)'); + }, + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + if (args.trim().length > 0) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Language subcommands do not accept additional arguments.', + ), + }; + } + return setUiLanguage(context, 'en'); + }, + }, + ], + }, + { + name: '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', + messageType: 'info', + content: [ + t('Set LLM output language'), + '', + t('Usage: /language output '), + ` ${t('Example: /language output 中文')}`, + ` ${t('Example: /language output English')}`, + ` ${t('Example: /language output æ—ĨæœŦčĒž')}`, + ].join('\n'), + }; + } + + return generateLlmOutputLanguageRuleFile(trimmedArgs); + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 2521e10c..d8fec717 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -24,10 +24,13 @@ import { } from '@qwen-code/qwen-code-core'; import { appEvents, AppEvent } from '../../utils/events.js'; import { MessageType, type HistoryItemMcpStatus } from '../types.js'; +import { t } from '../../i18n/index.js'; const authCommand: SlashCommand = { name: 'auth', - description: 'Authenticate with an OAuth-enabled MCP server', + get description() { + return t('Authenticate with an OAuth-enabled MCP server'); + }, kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, @@ -40,7 +43,7 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Config not loaded.', + content: t('Config not loaded.'), }; } @@ -56,14 +59,14 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'No MCP servers configured with OAuth authentication.', + content: t('No MCP servers configured with OAuth authentication.'), }; } return { type: 'message', messageType: 'info', - content: `MCP servers with OAuth authentication:\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\nUse /mcp auth to authenticate.`, + content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth to authenticate.')}`, }; } @@ -72,7 +75,7 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: `MCP server '${serverName}' not found.`, + content: t("MCP server '{{name}}' not found.", { name: serverName }), }; } @@ -89,7 +92,12 @@ const authCommand: SlashCommand = { context.ui.addItem( { type: 'info', - text: `Starting OAuth authentication for MCP server '${serverName}'...`, + text: t( + "Starting OAuth authentication for MCP server '{{name}}'...", + { + name: serverName, + }, + ), }, Date.now(), ); @@ -111,7 +119,12 @@ const authCommand: SlashCommand = { context.ui.addItem( { type: 'info', - text: `✅ Successfully authenticated with MCP server '${serverName}'!`, + text: t( + "Successfully authenticated and refreshed tools for '{{name}}'.", + { + name: serverName, + }, + ), }, Date.now(), ); @@ -122,7 +135,9 @@ const authCommand: SlashCommand = { context.ui.addItem( { type: 'info', - text: `Re-discovering tools from '${serverName}'...`, + text: t("Re-discovering tools from '{{name}}'...", { + name: serverName, + }), }, Date.now(), ); @@ -140,13 +155,24 @@ const authCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: `Successfully authenticated and refreshed tools for '${serverName}'.`, + content: t( + "Successfully authenticated and refreshed tools for '{{name}}'.", + { + name: serverName, + }, + ), }; } catch (error) { return { type: 'message', messageType: 'error', - content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`, + content: t( + "Failed to authenticate with MCP server '{{name}}': {{error}}", + { + name: serverName, + error: getErrorMessage(error), + }, + ), }; } finally { appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); @@ -165,7 +191,9 @@ const authCommand: SlashCommand = { const listCommand: SlashCommand = { name: 'list', - description: 'List configured MCP servers and tools', + get description() { + return t('List configured MCP servers and tools'); + }, kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, @@ -176,7 +204,7 @@ const listCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Config not loaded.', + content: t('Config not loaded.'), }; } @@ -185,7 +213,7 @@ const listCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Could not retrieve tool registry.', + content: t('Could not retrieve tool registry.'), }; } @@ -276,7 +304,9 @@ const listCommand: SlashCommand = { const refreshCommand: SlashCommand = { name: 'refresh', - description: 'Restarts MCP servers.', + get description() { + return t('Restarts MCP servers.'); + }, kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, @@ -286,7 +316,7 @@ const refreshCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Config not loaded.', + content: t('Config not loaded.'), }; } @@ -295,14 +325,14 @@ const refreshCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Could not retrieve tool registry.', + content: t('Could not retrieve tool registry.'), }; } context.ui.addItem( { type: 'info', - text: 'Restarting MCP servers...', + text: t('Restarting MCP servers...'), }, Date.now(), ); @@ -324,8 +354,11 @@ const refreshCommand: SlashCommand = { export const mcpCommand: SlashCommand = { name: 'mcp', - description: - 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + get description() { + return t( + 'list configured MCP servers and tools, or authenticate with OAuth-enabled servers', + ); + }, kind: CommandKind.BUILT_IN, subCommands: [listCommand, authCommand, refreshCommand], // Default action when no subcommand is provided diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 560456f8..013b815d 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -15,15 +15,20 @@ import fs from 'fs/promises'; import { MessageType } from '../types.js'; import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const memoryCommand: SlashCommand = { name: 'memory', - description: 'Commands for interacting with memory.', + get description() { + return t('Commands for interacting with memory.'); + }, kind: CommandKind.BUILT_IN, subCommands: [ { name: 'show', - description: 'Show the current memory contents.', + get description() { + return t('Show the current memory contents.'); + }, kind: CommandKind.BUILT_IN, action: async (context) => { const memoryContent = context.services.config?.getUserMemory() || ''; @@ -31,8 +36,8 @@ export const memoryCommand: SlashCommand = { const messageContent = memoryContent.length > 0 - ? `Current memory content from ${fileCount} file(s):\n\n---\n${memoryContent}\n---` - : 'Memory is currently empty.'; + ? `${t('Current memory content from {{count}} file(s):', { count: String(fileCount) })}\n\n---\n${memoryContent}\n---` + : t('Memory is currently empty.'); context.ui.addItem( { @@ -45,7 +50,9 @@ export const memoryCommand: SlashCommand = { subCommands: [ { name: '--project', - description: 'Show project-level memory contents.', + get description() { + return t('Show project-level memory contents.'); + }, kind: CommandKind.BUILT_IN, action: async (context) => { try { @@ -57,8 +64,14 @@ export const memoryCommand: SlashCommand = { const messageContent = memoryContent.trim().length > 0 - ? `Project memory content from ${projectMemoryPath}:\n\n---\n${memoryContent}\n---` - : 'Project memory is currently empty.'; + ? t( + 'Project memory content from {{path}}:\n\n---\n{{content}}\n---', + { + path: projectMemoryPath, + content: memoryContent, + }, + ) + : t('Project memory is currently empty.'); context.ui.addItem( { @@ -71,7 +84,9 @@ export const memoryCommand: SlashCommand = { context.ui.addItem( { type: MessageType.INFO, - text: 'Project memory file not found or is currently empty.', + text: t( + 'Project memory file not found or is currently empty.', + ), }, Date.now(), ); @@ -80,7 +95,9 @@ export const memoryCommand: SlashCommand = { }, { name: '--global', - description: 'Show global memory contents.', + get description() { + return t('Show global memory contents.'); + }, kind: CommandKind.BUILT_IN, action: async (context) => { try { @@ -96,8 +113,10 @@ export const memoryCommand: SlashCommand = { const messageContent = globalMemoryContent.trim().length > 0 - ? `Global memory content:\n\n---\n${globalMemoryContent}\n---` - : 'Global memory is currently empty.'; + ? t('Global memory content:\n\n---\n{{content}}\n---', { + content: globalMemoryContent, + }) + : t('Global memory is currently empty.'); context.ui.addItem( { @@ -110,7 +129,9 @@ export const memoryCommand: SlashCommand = { context.ui.addItem( { type: MessageType.INFO, - text: 'Global memory file not found or is currently empty.', + text: t( + 'Global memory file not found or is currently empty.', + ), }, Date.now(), ); @@ -121,16 +142,20 @@ export const memoryCommand: SlashCommand = { }, { name: 'add', - description: - 'Add content to the memory. Use --global for global memory or --project for project memory.', + get description() { + return t( + 'Add content to the memory. Use --global for global memory or --project for project memory.', + ); + }, kind: CommandKind.BUILT_IN, action: (context, args): SlashCommandActionReturn | void => { if (!args || args.trim() === '') { return { type: 'message', messageType: 'error', - content: + content: t( 'Usage: /memory add [--global|--project] ', + ), }; } @@ -150,8 +175,9 @@ export const memoryCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: + content: t( 'Usage: /memory add [--global|--project] ', + ), }; } else { // No scope specified, will be handled by the tool @@ -162,8 +188,9 @@ export const memoryCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: + content: t( 'Usage: /memory add [--global|--project] ', + ), }; } @@ -171,7 +198,10 @@ export const memoryCommand: SlashCommand = { context.ui.addItem( { type: MessageType.INFO, - text: `Attempting to save to memory ${scopeText}: "${fact}"`, + text: t('Attempting to save to memory {{scope}}: "{{fact}}"', { + scope: scopeText, + fact, + }), }, Date.now(), ); @@ -185,21 +215,25 @@ export const memoryCommand: SlashCommand = { subCommands: [ { name: '--project', - description: 'Add content to project-level memory.', + get description() { + return t('Add content to project-level memory.'); + }, kind: CommandKind.BUILT_IN, action: (context, args): SlashCommandActionReturn | void => { if (!args || args.trim() === '') { return { type: 'message', messageType: 'error', - content: 'Usage: /memory add --project ', + content: t('Usage: /memory add --project '), }; } context.ui.addItem( { type: MessageType.INFO, - text: `Attempting to save to project memory: "${args.trim()}"`, + text: t('Attempting to save to project memory: "{{text}}"', { + text: args.trim(), + }), }, Date.now(), ); @@ -213,21 +247,25 @@ export const memoryCommand: SlashCommand = { }, { name: '--global', - description: 'Add content to global memory.', + get description() { + return t('Add content to global memory.'); + }, kind: CommandKind.BUILT_IN, action: (context, args): SlashCommandActionReturn | void => { if (!args || args.trim() === '') { return { type: 'message', messageType: 'error', - content: 'Usage: /memory add --global ', + content: t('Usage: /memory add --global '), }; } context.ui.addItem( { type: MessageType.INFO, - text: `Attempting to save to global memory: "${args.trim()}"`, + text: t('Attempting to save to global memory: "{{text}}"', { + text: args.trim(), + }), }, Date.now(), ); @@ -243,13 +281,15 @@ export const memoryCommand: SlashCommand = { }, { name: 'refresh', - description: 'Refresh the memory from the source.', + get description() { + return t('Refresh the memory from the source.'); + }, kind: CommandKind.BUILT_IN, action: async (context) => { context.ui.addItem( { type: MessageType.INFO, - text: 'Refreshing memory from source files...', + text: t('Refreshing memory from source files...'), }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index b97e7c63..a25e96a1 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -12,10 +12,13 @@ import type { } from './types.js'; import { CommandKind } from './types.js'; import { getAvailableModelsForAuthType } from '../models/availableModels.js'; +import { t } from '../../i18n/index.js'; export const modelCommand: SlashCommand = { name: 'model', - description: 'Switch the model for this session', + get description() { + return t('Switch the model for this session'); + }, kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, @@ -36,7 +39,7 @@ export const modelCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Content generator configuration not available.', + content: t('Content generator configuration not available.'), }; } @@ -45,7 +48,7 @@ export const modelCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Authentication type not available.', + content: t('Authentication type not available.'), }; } @@ -55,7 +58,12 @@ export const modelCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: `No models available for the current authentication type (${authType}).`, + content: t( + 'No models available for the current authentication type ({{authType}}).', + { + authType, + }, + ), }; } diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts index 60ef3884..2b6a7c34 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -6,10 +6,13 @@ import type { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const permissionsCommand: SlashCommand = { name: 'permissions', - description: 'Manage folder trust settings', + get description() { + return t('Manage folder trust settings'); + }, kind: CommandKind.BUILT_IN, action: (): OpenDialogActionReturn => ({ type: 'dialog', diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 3e175d9c..fc9683c9 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -6,10 +6,13 @@ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; +import { t } from '../../i18n/index.js'; export const quitConfirmCommand: SlashCommand = { name: 'quit-confirm', - description: 'Show quit confirmation dialog', + get description() { + return t('Show quit confirmation dialog'); + }, kind: CommandKind.BUILT_IN, action: (context) => { const now = Date.now(); @@ -37,7 +40,9 @@ export const quitConfirmCommand: SlashCommand = { export const quitCommand: SlashCommand = { name: 'quit', altNames: ['exit'], - description: 'exit the cli', + get description() { + return t('exit the cli'); + }, kind: CommandKind.BUILT_IN, action: (context) => { const now = Date.now(); diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index 4a3665f1..f7052f19 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -6,10 +6,13 @@ import type { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const settingsCommand: SlashCommand = { name: 'settings', - description: 'View and edit Qwen Code settings', + get description() { + return t('View and edit Qwen Code settings'); + }, kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 46b46cba..378f1101 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -20,6 +20,7 @@ import { import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js'; +import { t } from '../../i18n/index.js'; export const GITHUB_WORKFLOW_PATHS = [ 'gemini-dispatch/gemini-dispatch.yml', @@ -91,7 +92,9 @@ export async function updateGitignore(gitRepoRoot: string): Promise { export const setupGithubCommand: SlashCommand = { name: 'setup-github', - description: 'Set up GitHub Actions', + get description() { + return t('Set up GitHub Actions'); + }, kind: CommandKind.BUILT_IN, action: async ( context: CommandContext, diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 1fe628ab..cb4a3f51 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -12,11 +12,14 @@ import { type SlashCommand, CommandKind, } from './types.js'; +import { t } from '../../i18n/index.js'; export const statsCommand: SlashCommand = { name: 'stats', altNames: ['usage'], - description: 'check session stats. Usage: /stats [model|tools]', + get description() { + return t('check session stats. Usage: /stats [model|tools]'); + }, kind: CommandKind.BUILT_IN, action: (context: CommandContext) => { const now = new Date(); @@ -25,7 +28,7 @@ export const statsCommand: SlashCommand = { context.ui.addItem( { type: MessageType.ERROR, - text: 'Session start time is unavailable, cannot calculate stats.', + text: t('Session start time is unavailable, cannot calculate stats.'), }, Date.now(), ); @@ -43,7 +46,9 @@ export const statsCommand: SlashCommand = { subCommands: [ { name: 'model', - description: 'Show model-specific usage statistics.', + get description() { + return t('Show model-specific usage statistics.'); + }, kind: CommandKind.BUILT_IN, action: (context: CommandContext) => { context.ui.addItem( @@ -56,7 +61,9 @@ export const statsCommand: SlashCommand = { }, { name: 'tools', - description: 'Show tool-specific usage statistics.', + get description() { + return t('Show tool-specific usage statistics.'); + }, kind: CommandKind.BUILT_IN, action: (context: CommandContext) => { context.ui.addItem( diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index 7c666a04..5d943e8e 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -13,11 +13,15 @@ import { } from './types.js'; import { getProjectSummaryPrompt } from '@qwen-code/qwen-code-core'; import type { HistoryItemSummary } from '../types.js'; +import { t } from '../../i18n/index.js'; export const summaryCommand: SlashCommand = { name: 'summary', - description: - 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md', + get description() { + return t( + 'Generate a project summary and save it to .qwen/PROJECT_SUMMARY.md', + ); + }, kind: CommandKind.BUILT_IN, action: async (context): Promise => { const { config } = context.services; @@ -26,7 +30,7 @@ export const summaryCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'Config not loaded.', + content: t('Config not loaded.'), }; } @@ -35,7 +39,7 @@ export const summaryCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: 'No chat client available to generate summary.', + content: t('No chat client available to generate summary.'), }; } @@ -44,15 +48,18 @@ export const summaryCommand: SlashCommand = { ui.addItem( { type: 'error' as const, - text: 'Already generating summary, wait for previous request to complete', + text: t( + 'Already generating summary, wait for previous request to complete', + ), }, Date.now(), ); return { type: 'message', messageType: 'error', - content: + content: t( 'Already generating summary, wait for previous request to complete', + ), }; } @@ -65,7 +72,7 @@ export const summaryCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'No conversation found to summarize.', + content: t('No conversation found to summarize.'), }; } @@ -171,9 +178,12 @@ export const summaryCommand: SlashCommand = { ui.addItem( { type: 'error' as const, - text: `❌ Failed to generate project context summary: ${ - error instanceof Error ? error.message : String(error) - }`, + text: `❌ ${t( + 'Failed to generate project context summary: {{error}}', + { + error: error instanceof Error ? error.message : String(error), + }, + )}`, }, Date.now(), ); @@ -181,9 +191,9 @@ export const summaryCommand: SlashCommand = { return { type: 'message', messageType: 'error', - content: `Failed to generate project context summary: ${ - error instanceof Error ? error.message : String(error) - }`, + content: t('Failed to generate project context summary: {{error}}', { + error: error instanceof Error ? error.message : String(error), + }), }; } }, diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 31b473c7..3fb85446 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -7,6 +7,7 @@ import type { MessageActionReturn, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { terminalSetup } from '../utils/terminalSetup.js'; +import { t } from '../../i18n/index.js'; /** * Command to configure terminal keybindings for multiline input support. @@ -16,8 +17,11 @@ import { terminalSetup } from '../utils/terminalSetup.js'; */ export const terminalSetupCommand: SlashCommand = { name: 'terminal-setup', - description: - 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)', + get description() { + return t( + 'Configure terminal keybindings for multiline input (VS Code, Cursor, Windsurf, Trae)', + ); + }, kind: CommandKind.BUILT_IN, action: async (): Promise => { @@ -27,7 +31,8 @@ export const terminalSetupCommand: SlashCommand = { let content = result.message; if (result.requiresRestart) { content += - '\n\nPlease restart your terminal for the changes to take effect.'; + '\n\n' + + t('Please restart your terminal for the changes to take effect.'); } return { @@ -38,7 +43,9 @@ export const terminalSetupCommand: SlashCommand = { } catch (error) { return { type: 'message', - content: `Failed to configure terminal: ${error}`, + content: t('Failed to configure terminal: {{error}}', { + error: String(error), + }), messageType: 'error', }; } diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index 585c84f9..fd366366 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -6,10 +6,13 @@ import type { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const themeCommand: SlashCommand = { name: 'theme', - description: 'change the theme', + get description() { + return t('change the theme'); + }, kind: CommandKind.BUILT_IN, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 4378c450..4bd97e3e 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -10,10 +10,13 @@ import { CommandKind, } from './types.js'; import { MessageType, type HistoryItemToolsList } from '../types.js'; +import { t } from '../../i18n/index.js'; export const toolsCommand: SlashCommand = { name: 'tools', - description: 'list available Qwen Code tools. Usage: /tools [desc]', + get description() { + return t('list available Qwen Code tools. Usage: /tools [desc]'); + }, kind: CommandKind.BUILT_IN, action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); @@ -29,7 +32,7 @@ export const toolsCommand: SlashCommand = { context.ui.addItem( { type: MessageType.ERROR, - text: 'Could not retrieve tool registry.', + text: t('Could not retrieve tool registry.'), }, Date.now(), ); diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index b398cc48..8f3dc6bd 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -6,10 +6,13 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; export const vimCommand: SlashCommand = { name: 'vim', - description: 'toggle vim mode on/off', + get description() { + return t('toggle vim mode on/off'); + }, kind: CommandKind.BUILT_IN, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); diff --git a/packages/cli/src/ui/components/AboutBox.tsx b/packages/cli/src/ui/components/AboutBox.tsx index fba5fb13..e04fd42c 100644 --- a/packages/cli/src/ui/components/AboutBox.tsx +++ b/packages/cli/src/ui/components/AboutBox.tsx @@ -13,6 +13,7 @@ import { getFieldValue, type SystemInfoField, } from '../../utils/systemInfoFields.js'; +import { t } from '../../i18n/index.js'; type AboutBoxProps = ExtendedSystemInfo; @@ -30,7 +31,7 @@ export const AboutBox: React.FC = (props) => { > - About Qwen Code + {t('About Qwen Code')} {fields.map((field: SystemInfoField) => ( diff --git a/packages/cli/src/ui/components/ApprovalModeDialog.tsx b/packages/cli/src/ui/components/ApprovalModeDialog.tsx index eb6441ec..163a45fd 100644 --- a/packages/cli/src/ui/components/ApprovalModeDialog.tsx +++ b/packages/cli/src/ui/components/ApprovalModeDialog.tsx @@ -15,6 +15,7 @@ import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; +import { t } from '../../i18n/index.js'; interface ApprovalModeDialogProps { /** Callback function when an approval mode is selected */ @@ -33,15 +34,15 @@ interface ApprovalModeDialogProps { const formatModeDescription = (mode: ApprovalMode): string => { switch (mode) { case ApprovalMode.PLAN: - return 'Analyze only, do not modify files or execute commands'; + return t('Analyze only, do not modify files or execute commands'); case ApprovalMode.DEFAULT: - return 'Require approval for file edits or shell commands'; + return t('Require approval for file edits or shell commands'); case ApprovalMode.AUTO_EDIT: - return 'Automatically approve file edits'; + return t('Automatically approve file edits'); case ApprovalMode.YOLO: - return 'Automatically approve all tools'; + return t('Automatically approve all tools'); default: - return `${mode} mode`; + return t('{{mode}} mode', { mode }); } }; @@ -134,7 +135,8 @@ export function ApprovalModeDialog({ {/* Approval Mode Selection */} - {focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '} + {focusSection === 'mode' ? '> ' : ' '} + {t('Approval Mode')}{' '} {otherScopeModifiedMessage} @@ -167,15 +169,17 @@ export function ApprovalModeDialog({ {showWorkspacePriorityWarning && ( <> - ⚠ Workspace approval mode exists and takes priority. User-level - change will have no effect. + ⚠{' '} + {t( + 'Workspace approval mode exists and takes priority. User-level change will have no effect.', + )} )} - (Use Enter to select, Tab to change focus) + {t('(Use Enter to select, Tab to change focus)')} diff --git a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx index ec7c1604..550c77dc 100644 --- a/packages/cli/src/ui/components/AutoAcceptIndicator.tsx +++ b/packages/cli/src/ui/components/AutoAcceptIndicator.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; interface AutoAcceptIndicatorProps { approvalMode: ApprovalMode; @@ -23,18 +24,18 @@ export const AutoAcceptIndicator: React.FC = ({ switch (approvalMode) { case ApprovalMode.PLAN: textColor = theme.status.success; - textContent = 'plan mode'; - subText = ' (shift + tab to cycle)'; + textContent = t('plan mode'); + subText = ` ${t('(shift + tab to cycle)')}`; break; case ApprovalMode.AUTO_EDIT: textColor = theme.status.warning; - textContent = 'auto-accept edits'; - subText = ' (shift + tab to cycle)'; + textContent = t('auto-accept edits'); + subText = ` ${t('(shift + tab to cycle)')}`; break; case ApprovalMode.YOLO: textColor = theme.status.error; - textContent = 'YOLO mode'; - subText = ' (shift + tab to cycle)'; + textContent = t('YOLO mode'); + subText = ` ${t('(shift + tab to cycle)')}`; break; case ApprovalMode.DEFAULT: default: diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 4e255983..1b51227a 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -26,6 +26,7 @@ import { useSettings } from '../contexts/SettingsContext.js'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; import { StreamingState } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; +import { t } from '../../i18n/index.js'; export const Composer = () => { const config = useConfig(); @@ -86,14 +87,16 @@ export const Composer = () => { )} {uiState.ctrlCPressedOnce ? ( - Press Ctrl+C again to exit. + {t('Press Ctrl+C again to exit.')} ) : uiState.ctrlDPressedOnce ? ( - Press Ctrl+D again to exit. + {t('Press Ctrl+D again to exit.')} ) : uiState.showEscapePrompt ? ( - Press Esc again to clear. + + {t('Press Esc again to clear.')} + ) : ( !settings.merged.ui?.hideContextSummary && ( { isEmbeddedShellFocused={uiState.embeddedShellFocused} placeholder={ vimEnabled - ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." - : ' Type your message or @path/to/file' + ? ' ' + t("Press 'i' for INSERT mode and 'Esc' for NORMAL mode.") + : ' ' + t('Type your message or @path/to/file') } /> )} diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx index 5cac7412..264eeeaf 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx @@ -11,15 +11,16 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { type McpClient, MCPServerStatus } from '@qwen-code/qwen-code-core'; import { GeminiSpinner } from './GeminiRespondingSpinner.js'; import { theme } from '../semantic-colors.js'; +import { t } from '../../i18n/index.js'; export const ConfigInitDisplay = () => { const config = useConfig(); - const [message, setMessage] = useState('Initializing...'); + const [message, setMessage] = useState(t('Initializing...')); useEffect(() => { const onChange = (clients?: Map) => { if (!clients || clients.size === 0) { - setMessage(`Initializing...`); + setMessage(t('Initializing...')); return; } let connected = 0; @@ -28,7 +29,12 @@ export const ConfigInitDisplay = () => { connected++; } } - setMessage(`Connecting to MCP servers... (${connected}/${clients.size})`); + setMessage( + t('Connecting to MCP servers... ({{connected}}/{{total}})', { + connected: String(connected), + total: String(clients.size), + }), + ); }; appEvents.on('mcp-client-update', onChange); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx index dba5c9ac..808c0ac7 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.tsx @@ -13,6 +13,7 @@ import { } from '@qwen-code/qwen-code-core'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { t } from '../../i18n/index.js'; interface ContextSummaryDisplayProps { geminiMdFileCount: number; @@ -50,9 +51,11 @@ export const ContextSummaryDisplay: React.FC = ({ if (openFileCount === 0) { return ''; } - return `${openFileCount} open file${ - openFileCount > 1 ? 's' : '' - } (ctrl+g to view)`; + const fileText = + openFileCount === 1 + ? t('{{count}} open file', { count: String(openFileCount) }) + : t('{{count}} open files', { count: String(openFileCount) }); + return `${fileText} ${t('(ctrl+g to view)')}`; })(); const geminiMdText = (() => { @@ -61,9 +64,15 @@ export const ContextSummaryDisplay: React.FC = ({ } const allNamesTheSame = new Set(contextFileNames).size < 2; const name = allNamesTheSame ? contextFileNames[0] : 'context'; - return `${geminiMdFileCount} ${name} file${ - geminiMdFileCount > 1 ? 's' : '' - }`; + return geminiMdFileCount === 1 + ? t('{{count}} {{name}} file', { + count: String(geminiMdFileCount), + name, + }) + : t('{{count}} {{name}} files', { + count: String(geminiMdFileCount), + name, + }); })(); const mcpText = (() => { @@ -73,15 +82,27 @@ export const ContextSummaryDisplay: React.FC = ({ const parts = []; if (mcpServerCount > 0) { - parts.push( - `${mcpServerCount} MCP server${mcpServerCount > 1 ? 's' : ''}`, - ); + const serverText = + mcpServerCount === 1 + ? t('{{count}} MCP server', { count: String(mcpServerCount) }) + : t('{{count}} MCP servers', { count: String(mcpServerCount) }); + parts.push(serverText); } if (blockedMcpServerCount > 0) { - let blockedText = `${blockedMcpServerCount} Blocked`; + let blockedText = t('{{count}} Blocked', { + count: String(blockedMcpServerCount), + }); if (mcpServerCount === 0) { - blockedText += ` MCP server${blockedMcpServerCount > 1 ? 's' : ''}`; + const serverText = + blockedMcpServerCount === 1 + ? t('{{count}} MCP server', { + count: String(blockedMcpServerCount), + }) + : t('{{count}} MCP servers', { + count: String(blockedMcpServerCount), + }); + blockedText += ` ${serverText}`; } parts.push(blockedText); } @@ -89,9 +110,9 @@ export const ContextSummaryDisplay: React.FC = ({ // Add ctrl+t hint when MCP servers are available if (mcpServers && Object.keys(mcpServers).length > 0) { if (showToolDescriptions) { - text += ' (ctrl+t to toggle)'; + text += ` ${t('(ctrl+t to toggle)')}`; } else { - text += ' (ctrl+t to view)'; + text += ` ${t('(ctrl+t to view)')}`; } } return text; @@ -102,7 +123,7 @@ export const ContextSummaryDisplay: React.FC = ({ if (isNarrow) { return ( - Using: + {t('Using:')} {summaryParts.map((part, index) => ( {' '}- {part} @@ -115,7 +136,7 @@ export const ContextSummaryDisplay: React.FC = ({ return ( - Using: {summaryParts.join(' | ')} + {t('Using:')} {summaryParts.join(' | ')} ); diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.tsx index d81ffad6..6926bf41 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.tsx @@ -19,6 +19,7 @@ import { SettingScope } from '../../config/settings.js'; import type { EditorType } from '@qwen-code/qwen-code-core'; import { isEditorAvailable } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; interface EditorDialogProps { onSelect: (editorType: EditorType | undefined, scope: SettingScope) => void; @@ -66,12 +67,16 @@ export function EditorSettingsDialog({ const scopeItems = [ { - label: 'User Settings', + get label() { + return t('User Settings'); + }, value: SettingScope.User, key: SettingScope.User, }, { - label: 'Workspace Settings', + get label() { + return t('Workspace Settings'); + }, value: SettingScope.Workspace, key: SettingScope.Workspace, }, @@ -145,7 +150,8 @@ export function EditorSettingsDialog({ - {focusedSection === 'scope' ? '> ' : ' '}Apply To + {focusedSection === 'scope' ? '> ' : ' '} + {t('Apply To')} = ({ commands }) => ( > {/* Basics */} - Basics: + {t('Basics:')} - Add context + {t('Add context')} - : Use{' '} - - @ - {' '} - to specify files for context (e.g.,{' '} - - @src/myFile.ts - - ) to target specific files or folders. + :{' '} + {t( + 'Use {{symbol}} to specify files for context (e.g., {{example}}) to target specific files or folders.', + { + symbol: t('@'), + example: t('@src/myFile.ts'), + }, + )} - Shell mode + {t('Shell mode')} - : Execute shell commands via{' '} - - ! - {' '} - (e.g.,{' '} - - !npm run start - - ) or use natural language (e.g.{' '} - - start server - - ). + :{' '} + {t( + 'Execute shell commands via {{symbol}} (e.g., {{example1}}) or use natural language (e.g., {{example2}}).', + { + symbol: t('!'), + example1: t('!npm run start'), + example2: t('start server'), + }, + )} {/* Commands */} - Commands: + {t('Commands:')} {commands .filter((command) => command.description && !command.hidden) @@ -97,81 +93,81 @@ export const Help: React.FC = ({ commands }) => ( {' '} !{' '} - - shell command + - {t('shell command')} - [MCP] - Model Context Protocol - command (from external servers) + [MCP] -{' '} + {t('Model Context Protocol command (from external servers)')} {/* Shortcuts */} - Keyboard Shortcuts: + {t('Keyboard Shortcuts:')} Alt+Left/Right {' '} - - Jump through words in the input + - {t('Jump through words in the input')} Ctrl+C {' '} - - Close dialogs, cancel requests, or quit application + - {t('Close dialogs, cancel requests, or quit application')} {process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'} {' '} + -{' '} {process.platform === 'linux' - ? '- New line (Alt+Enter works for certain linux distros)' - : '- New line'} + ? t('New line (Alt+Enter works for certain linux distros)') + : t('New line')} Ctrl+L {' '} - - Clear the screen + - {t('Clear the screen')} {process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'} {' '} - - Open input in external editor + - {t('Open input in external editor')} Enter {' '} - - Send message + - {t('Send message')} Esc {' '} - - Cancel operation / Clear input (double press) + - {t('Cancel operation / Clear input (double press)')} Shift+Tab {' '} - - Cycle approval modes + - {t('Cycle approval modes')} Up/Down {' '} - - Cycle through your prompt history + - {t('Cycle through your prompt history')} - For a full list of shortcuts, see{' '} - - docs/keyboard-shortcuts.md - + {t('For a full list of shortcuts, see {{docPath}}', { + docPath: t('docs/keyboard-shortcuts.md'), + })} ); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 38d5f7b1..5c1d3014 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -164,11 +164,6 @@ describe('InputPrompt', () => { setActiveSuggestionIndex: vi.fn(), setShowSuggestions: vi.fn(), handleAutocomplete: vi.fn(), - promptCompletion: { - text: '', - accept: vi.fn(), - clear: vi.fn(), - }, }; mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion); @@ -215,6 +210,7 @@ describe('InputPrompt', () => { inputWidth: 80, suggestionsWidth: 80, focus: true, + placeholder: ' Type your message or @path/to/file', }; }); @@ -1955,7 +1951,7 @@ describe('InputPrompt', () => { unmount(); }); - it('expands and collapses long suggestion via Right/Left arrows', async () => { + it.skip('expands and collapses long suggestion via Right/Left arrows', async () => { props.shellModeActive = false; const longValue = 'l'.repeat(200); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f33700d8..8af77059 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -12,9 +12,8 @@ import { theme } from '../semantic-colors.js'; import { useInputHistory } from '../hooks/useInputHistory.js'; import type { TextBuffer } from './shared/text-buffer.js'; import { logicalPosToOffset } from './shared/text-buffer.js'; -import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js'; +import { cpSlice, cpLen } from '../utils/textUtils.js'; import chalk from 'chalk'; -import stringWidth from 'string-width'; import { useShellHistory } from '../hooks/useShellHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; @@ -28,6 +27,7 @@ import { parseInputForHighlighting, buildSegmentsForVisualSlice, } from '../utils/highlight.js'; +import { t } from '../../i18n/index.js'; import { clipboardHasImage, saveClipboardImage, @@ -89,9 +89,8 @@ export const InputPrompt: React.FC = ({ config, slashCommands, commandContext, - placeholder = ' Type your message or @path/to/file', + placeholder, focus = true, - inputWidth, suggestionsWidth, shellModeActive, setShellModeActive, @@ -526,16 +525,6 @@ export const InputPrompt: React.FC = ({ } } - // Handle Tab key for ghost text acceptance - if ( - key.name === 'tab' && - !completion.showSuggestions && - completion.promptCompletion.text - ) { - completion.promptCompletion.accept(); - return; - } - if (!shellModeActive) { if (keyMatchers[Command.REVERSE_SEARCH](key)) { setCommandSearchActive(true); @@ -657,18 +646,6 @@ export const InputPrompt: React.FC = ({ // Fall back to the text buffer's default input handling for all other keys buffer.handleInput(key); - - // Clear ghost text when user types regular characters (not navigation/control keys) - if ( - completion.promptCompletion.text && - key.sequence && - key.sequence.length === 1 && - !key.ctrl && - !key.meta - ) { - completion.promptCompletion.clear(); - setExpandedSuggestionIndex(-1); - } }, [ focus, @@ -703,118 +680,6 @@ export const InputPrompt: React.FC = ({ buffer.visualCursor; const scrollVisualRow = buffer.visualScrollRow; - const getGhostTextLines = useCallback(() => { - if ( - !completion.promptCompletion.text || - !buffer.text || - !completion.promptCompletion.text.startsWith(buffer.text) - ) { - return { inlineGhost: '', additionalLines: [] }; - } - - const ghostSuffix = completion.promptCompletion.text.slice( - buffer.text.length, - ); - if (!ghostSuffix) { - return { inlineGhost: '', additionalLines: [] }; - } - - const currentLogicalLine = buffer.lines[buffer.cursor[0]] || ''; - const cursorCol = buffer.cursor[1]; - - const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol); - const usedWidth = stringWidth(textBeforeCursor); - const remainingWidth = Math.max(0, inputWidth - usedWidth); - - const ghostTextLinesRaw = ghostSuffix.split('\n'); - const firstLineRaw = ghostTextLinesRaw.shift() || ''; - - let inlineGhost = ''; - let remainingFirstLine = ''; - - if (stringWidth(firstLineRaw) <= remainingWidth) { - inlineGhost = firstLineRaw; - } else { - const words = firstLineRaw.split(' '); - let currentLine = ''; - let wordIdx = 0; - for (const word of words) { - const prospectiveLine = currentLine ? `${currentLine} ${word}` : word; - if (stringWidth(prospectiveLine) > remainingWidth) { - break; - } - currentLine = prospectiveLine; - wordIdx++; - } - inlineGhost = currentLine; - if (words.length > wordIdx) { - remainingFirstLine = words.slice(wordIdx).join(' '); - } - } - - const linesToWrap = []; - if (remainingFirstLine) { - linesToWrap.push(remainingFirstLine); - } - linesToWrap.push(...ghostTextLinesRaw); - const remainingGhostText = linesToWrap.join('\n'); - - const additionalLines: string[] = []; - if (remainingGhostText) { - const textLines = remainingGhostText.split('\n'); - for (const textLine of textLines) { - const words = textLine.split(' '); - let currentLine = ''; - - for (const word of words) { - const prospectiveLine = currentLine ? `${currentLine} ${word}` : word; - const prospectiveWidth = stringWidth(prospectiveLine); - - if (prospectiveWidth > inputWidth) { - if (currentLine) { - additionalLines.push(currentLine); - } - - let wordToProcess = word; - while (stringWidth(wordToProcess) > inputWidth) { - let part = ''; - const wordCP = toCodePoints(wordToProcess); - let partWidth = 0; - let splitIndex = 0; - for (let i = 0; i < wordCP.length; i++) { - const char = wordCP[i]; - const charWidth = stringWidth(char); - if (partWidth + charWidth > inputWidth) { - break; - } - part += char; - partWidth += charWidth; - splitIndex = i + 1; - } - additionalLines.push(part); - wordToProcess = cpSlice(wordToProcess, splitIndex); - } - currentLine = wordToProcess; - } else { - currentLine = prospectiveLine; - } - } - if (currentLine) { - additionalLines.push(currentLine); - } - } - } - - return { inlineGhost, additionalLines }; - }, [ - completion.promptCompletion.text, - buffer.text, - buffer.lines, - buffer.cursor, - inputWidth, - ]); - - const { inlineGhost, additionalLines } = getGhostTextLines(); const getActiveCompletion = () => { if (commandSearchActive) return commandSearchCompletion; if (reverseSearchActive) return reverseSearchCompletion; @@ -833,13 +698,13 @@ export const InputPrompt: React.FC = ({ let statusText = ''; if (shellModeActive) { statusColor = theme.ui.symbol; - statusText = 'Shell mode'; + statusText = t('Shell mode'); } else if (showYoloStyling) { statusColor = theme.status.error; - statusText = 'YOLO mode'; + statusText = t('YOLO mode'); } else if (showAutoAcceptStyling) { statusColor = theme.status.warning; - statusText = 'Accepting edits'; + statusText = t('Accepting edits'); } return ( @@ -887,134 +752,96 @@ export const InputPrompt: React.FC = ({ {placeholder} ) ) : ( - linesToRender - .map((lineText, visualIdxInRenderedSet) => { - const absoluteVisualIdx = - scrollVisualRow + visualIdxInRenderedSet; - const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; - const cursorVisualRow = - cursorVisualRowAbsolute - scrollVisualRow; - const isOnCursorLine = - focus && visualIdxInRenderedSet === cursorVisualRow; + linesToRender.map((lineText, visualIdxInRenderedSet) => { + const absoluteVisualIdx = + scrollVisualRow + visualIdxInRenderedSet; + const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; + const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; + const isOnCursorLine = + focus && visualIdxInRenderedSet === cursorVisualRow; - const renderedLine: React.ReactNode[] = []; + const renderedLine: React.ReactNode[] = []; - const [logicalLineIdx, logicalStartCol] = mapEntry; - const logicalLine = buffer.lines[logicalLineIdx] || ''; - const tokens = parseInputForHighlighting( - logicalLine, - logicalLineIdx, - ); + const [logicalLineIdx, logicalStartCol] = mapEntry; + const logicalLine = buffer.lines[logicalLineIdx] || ''; + const tokens = parseInputForHighlighting( + logicalLine, + logicalLineIdx, + ); - const visualStart = logicalStartCol; - const visualEnd = logicalStartCol + cpLen(lineText); - const segments = buildSegmentsForVisualSlice( - tokens, - visualStart, - visualEnd, - ); + const visualStart = logicalStartCol; + const visualEnd = logicalStartCol + cpLen(lineText); + const segments = buildSegmentsForVisualSlice( + tokens, + visualStart, + visualEnd, + ); - let charCount = 0; - segments.forEach((seg, segIdx) => { - const segLen = cpLen(seg.text); - let display = seg.text; + let charCount = 0; + segments.forEach((seg, segIdx) => { + const segLen = cpLen(seg.text); + let display = seg.text; - if (isOnCursorLine) { - const relativeVisualColForHighlight = - cursorVisualColAbsolute; - const segStart = charCount; - const segEnd = segStart + segLen; - if ( - relativeVisualColForHighlight >= segStart && - relativeVisualColForHighlight < segEnd - ) { - const charToHighlight = cpSlice( + if (isOnCursorLine) { + const relativeVisualColForHighlight = cursorVisualColAbsolute; + const segStart = charCount; + const segEnd = segStart + segLen; + if ( + relativeVisualColForHighlight >= segStart && + relativeVisualColForHighlight < segEnd + ) { + const charToHighlight = cpSlice( + seg.text, + relativeVisualColForHighlight - segStart, + relativeVisualColForHighlight - segStart + 1, + ); + const highlighted = showCursor + ? chalk.inverse(charToHighlight) + : charToHighlight; + display = + cpSlice( seg.text, + 0, relativeVisualColForHighlight - segStart, + ) + + highlighted + + cpSlice( + seg.text, relativeVisualColForHighlight - segStart + 1, ); - const highlighted = showCursor - ? chalk.inverse(charToHighlight) - : charToHighlight; - display = - cpSlice( - seg.text, - 0, - relativeVisualColForHighlight - segStart, - ) + - highlighted + - cpSlice( - seg.text, - relativeVisualColForHighlight - segStart + 1, - ); - } - charCount = segEnd; - } - - const color = - seg.type === 'command' || seg.type === 'file' - ? theme.text.accent - : theme.text.primary; - - renderedLine.push( - - {display} - , - ); - }); - - const currentLineGhost = isOnCursorLine ? inlineGhost : ''; - if ( - isOnCursorLine && - cursorVisualColAbsolute === cpLen(lineText) - ) { - if (!currentLineGhost) { - renderedLine.push( - - {showCursor ? chalk.inverse(' ') : ' '} - , - ); } + charCount = segEnd; } - const showCursorBeforeGhost = - focus && - isOnCursorLine && - cursorVisualColAbsolute === cpLen(lineText) && - currentLineGhost; + const color = + seg.type === 'command' || seg.type === 'file' + ? theme.text.accent + : theme.text.primary; - return ( - - - {renderedLine} - {showCursorBeforeGhost && - (showCursor ? chalk.inverse(' ') : ' ')} - {currentLineGhost && ( - - {currentLineGhost} - - )} - - + renderedLine.push( + + {display} + , ); - }) - .concat( - additionalLines.map((ghostLine, index) => { - const padding = Math.max( - 0, - inputWidth - stringWidth(ghostLine), - ); - return ( - - {ghostLine} - {' '.repeat(padding)} - - ); - }), - ) + }); + + if ( + isOnCursorLine && + cursorVisualColAbsolute === cpLen(lineText) + ) { + renderedLine.push( + + {showCursor ? chalk.inverse(' ') : ' '} + , + ); + } + + return ( + + {renderedLine} + + ); + }) )} diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index a1a1694a..5fc2c20b 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -14,6 +14,7 @@ import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { formatDuration } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; +import { t } from '../../i18n/index.js'; interface LoadingIndicatorProps { currentLoadingPhrase?: string; @@ -40,7 +41,12 @@ export const LoadingIndicator: React.FC = ({ const cancelAndTimerContent = streamingState !== StreamingState.WaitingForConfirmation - ? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})` + ? t('(esc to cancel, {{time}})', { + time: + elapsedTime < 60 + ? `${elapsedTime}s` + : formatDuration(elapsedTime * 1000), + }) : null; return ( diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index e5dac5fc..55b3300b 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -20,6 +20,7 @@ import { getAvailableModelsForAuthType, MAINLINE_CODER, } from '../models/availableModels.js'; +import { t } from '../../i18n/index.js'; interface ModelDialogProps { onClose: () => void; @@ -87,7 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { padding={1} width="100%" > - Select Model + {t('Select Model')} - (Press Esc to close) + {t('(Press Esc to close)')} ); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx index 95a8fe46..ce0a481e 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx @@ -15,6 +15,7 @@ import { } from '../utils/computeStats.js'; import type { ModelMetrics } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js'; +import { t } from '../../i18n/index.js'; const METRIC_COL_WIDTH = 28; const MODEL_COL_WIDTH = 22; @@ -65,7 +66,7 @@ export const ModelStatsDisplay: React.FC = () => { paddingX={2} > - No API calls have been made in this session. + {t('No API calls have been made in this session.')} ); @@ -94,7 +95,7 @@ export const ModelStatsDisplay: React.FC = () => { paddingX={2} > - Model Stats For Nerds + {t('Model Stats For Nerds')} @@ -102,7 +103,7 @@ export const ModelStatsDisplay: React.FC = () => { - Metric + {t('Metric')} {modelNames.map((name) => ( @@ -125,13 +126,13 @@ export const ModelStatsDisplay: React.FC = () => { /> {/* API Section */} - + m.api.totalRequests.toLocaleString())} /> { const errorRate = calculateErrorRate(m); return ( @@ -146,7 +147,7 @@ export const ModelStatsDisplay: React.FC = () => { })} /> { const avgLatency = calculateAverageLatency(m); return formatDuration(avgLatency); @@ -156,9 +157,9 @@ export const ModelStatsDisplay: React.FC = () => { {/* Tokens Section */} - + ( {m.tokens.total.toLocaleString()} @@ -166,13 +167,13 @@ export const ModelStatsDisplay: React.FC = () => { ))} /> m.tokens.prompt.toLocaleString())} /> {hasCached && ( { const cacheHitRate = calculateCacheHitRate(m); @@ -186,20 +187,20 @@ export const ModelStatsDisplay: React.FC = () => { )} {hasThoughts && ( m.tokens.thoughts.toLocaleString())} /> )} {hasTool && ( m.tokens.tool.toLocaleString())} /> )} m.tokens.candidates.toLocaleString())} /> diff --git a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx index 0dc89bc7..ae65d358 100644 --- a/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx +++ b/packages/cli/src/ui/components/OpenAIKeyPrompt.tsx @@ -10,6 +10,7 @@ import { z } from 'zod'; import { Box, Text } from 'ink'; import { Colors } from '../colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; interface OpenAIKeyPromptProps { onSubmit: (apiKey: string, baseUrl: string, model: string) => void; @@ -64,9 +65,11 @@ export function OpenAIKeyPrompt({ const errorMessage = error.errors .map((e) => `${e.path.join('.')}: ${e.message}`) .join(', '); - setValidationError(`Invalid credentials: ${errorMessage}`); + setValidationError( + t('Invalid credentials: {{errorMessage}}', { errorMessage }), + ); } else { - setValidationError('Failed to validate credentials'); + setValidationError(t('Failed to validate credentials')); } } }; @@ -205,7 +208,7 @@ export function OpenAIKeyPrompt({ width="100%" > - OpenAI Configuration Required + {t('OpenAI Configuration Required')} {validationError && ( @@ -214,7 +217,9 @@ export function OpenAIKeyPrompt({ )} - Please enter your OpenAI configuration. You can get an API key from{' '} + {t( + 'Please enter your OpenAI configuration. You can get an API key from', + )}{' '} https://bailian.console.aliyun.com/?tab=model#/api-key @@ -225,7 +230,7 @@ export function OpenAIKeyPrompt({ - API Key: + {t('API Key:')} @@ -240,7 +245,7 @@ export function OpenAIKeyPrompt({ - Base URL: + {t('Base URL:')} @@ -255,7 +260,7 @@ export function OpenAIKeyPrompt({ - Model: + {t('Model:')} @@ -267,7 +272,7 @@ export function OpenAIKeyPrompt({ - Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel + {t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')} diff --git a/packages/cli/src/ui/components/ProQuotaDialog.tsx b/packages/cli/src/ui/components/ProQuotaDialog.tsx index 0f3c4a55..cc9bd5f8 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { theme } from '../semantic-colors.js'; +import { t } from '../../i18n/index.js'; interface ProQuotaDialogProps { failedModel: string; @@ -22,12 +23,12 @@ export function ProQuotaDialog({ }: ProQuotaDialogProps): React.JSX.Element { const items = [ { - label: 'Change auth (executes the /auth command)', + label: t('Change auth (executes the /auth command)'), value: 'auth' as const, key: 'auth', }, { - label: `Continue with ${fallbackModel}`, + label: t('Continue with {{model}}', { model: fallbackModel }), value: 'continue' as const, key: 'continue', }, @@ -40,7 +41,7 @@ export function ProQuotaDialog({ return ( - Pro quota limit reached for {failedModel}. + {t('Pro quota limit reached for {{model}}.', { model: failedModel })} = ({ const options: Array> = [ { key: 'quit', - label: 'Quit immediately (/quit)', + label: t('Quit immediately (/quit)'), value: QuitChoice.QUIT, }, { key: 'summary-and-quit', - label: 'Generate summary and quit (/summary)', + label: t('Generate summary and quit (/summary)'), value: QuitChoice.SUMMARY_AND_QUIT, }, { key: 'save-and-quit', - label: 'Save conversation and quit (/chat save)', + label: t('Save conversation and quit (/chat save)'), value: QuitChoice.SAVE_AND_QUIT, }, { key: 'cancel', - label: 'Cancel (stay in application)', + label: t('Cancel (stay in application)'), value: QuitChoice.CANCEL, }, ]; @@ -69,7 +70,7 @@ export const QuitConfirmationDialog: React.FC = ({ marginLeft={1} > - What would you like to do before exiting? + {t('What would you like to do before exiting?')} diff --git a/packages/cli/src/ui/components/QwenOAuthProgress.tsx b/packages/cli/src/ui/components/QwenOAuthProgress.tsx index 3e630fb3..d83bfb04 100644 --- a/packages/cli/src/ui/components/QwenOAuthProgress.tsx +++ b/packages/cli/src/ui/components/QwenOAuthProgress.tsx @@ -13,6 +13,7 @@ import qrcode from 'qrcode-terminal'; import { Colors } from '../colors.js'; import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; interface QwenOAuthProgressProps { onTimeout: () => void; @@ -52,11 +53,11 @@ function QrCodeDisplay({ width="100%" > - Qwen OAuth Authentication + {t('Qwen OAuth Authentication')} - Please visit this URL to authorize: + {t('Please visit this URL to authorize:')} @@ -66,7 +67,7 @@ function QrCodeDisplay({ - Or scan the QR code below: + {t('Or scan the QR code below:')} @@ -103,15 +104,18 @@ function StatusDisplay({ > - Waiting for authorization{dots} + {t('Waiting for authorization')} + {dots} - Time remaining: {formatTime(timeRemaining)} + {t('Time remaining:')} {formatTime(timeRemaining)} + + + {t('(Press ESC or CTRL+C to cancel)')} - (Press ESC or CTRL+C to cancel) ); @@ -215,19 +219,24 @@ export function QwenOAuthProgress({ width="100%" > - Qwen OAuth Authentication Timeout + {t('Qwen OAuth Authentication Timeout')} {authMessage || - `OAuth token expired (over ${defaultTimeout} seconds). Please select authentication method again.`} + t( + 'OAuth token expired (over {{seconds}} seconds). Please select authentication method again.', + { + seconds: defaultTimeout.toString(), + }, + )} - Press any key to return to authentication type selection. + {t('Press any key to return to authentication type selection.')} @@ -275,16 +284,17 @@ export function QwenOAuthProgress({ > - Waiting for Qwen OAuth authentication... + + {t('Waiting for Qwen OAuth authentication...')} - Time remaining: {Math.floor(timeRemaining / 60)}: + {t('Time remaining:')} {Math.floor(timeRemaining / 60)}: {(timeRemaining % 60).toString().padStart(2, '0')} - (Press ESC or CTRL+C to cancel) + {t('(Press ESC or CTRL+C to cancel)')} diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx index d4a0a11d..c8d79e0e 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.tsx @@ -6,6 +6,7 @@ import type React from 'react'; import { StatsDisplay } from './StatsDisplay.js'; +import { t } from '../../i18n/index.js'; interface SessionSummaryDisplayProps { duration: string; @@ -14,5 +15,8 @@ interface SessionSummaryDisplayProps { export const SessionSummaryDisplay: React.FC = ({ duration, }) => ( - + ); diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index bbd18ecf..f96ec33c 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -1271,7 +1271,6 @@ describe('SettingsDialog', () => { vimMode: true, disableAutoUpdate: true, debugKeystrokeLogging: true, - enablePromptCompletion: true, }, ui: { hideWindowTitle: true, @@ -1517,7 +1516,6 @@ describe('SettingsDialog', () => { vimMode: false, disableAutoUpdate: false, debugKeystrokeLogging: false, - enablePromptCompletion: false, }, ui: { hideWindowTitle: false, diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 210672bb..45b0f554 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -11,6 +11,7 @@ import type { LoadedSettings, Settings } from '../../config/settings.js'; import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; +import { t } from '../../i18n/index.js'; import { getDialogSettingKeys, setPendingSettingValue, @@ -124,7 +125,9 @@ export function SettingsDialog({ const definition = getSettingDefinition(key); return { - label: definition?.label || key, + label: definition?.label + ? t(definition.label) || definition.label + : key, value: key, type: definition?.type, toggle: () => { @@ -779,7 +782,8 @@ export function SettingsDialog({ > - {focusSection === 'settings' ? '> ' : ' '}Settings + {focusSection === 'settings' ? '> ' : ' '} + {t('Settings')} {showScrollUp && ▲} @@ -916,13 +920,15 @@ export function SettingsDialog({ - (Use Enter to select - {showScopeSelection ? ', Tab to change focus' : ''}) + {t('(Use Enter to select{{tabText}})', { + tabText: showScopeSelection ? t(', Tab to change focus') : '', + })} {showRestartPrompt && ( - To see changes, Qwen Code must be restarted. Press r to exit and - apply changes now. + {t( + 'To see changes, Qwen Code must be restarted. Press r to exit and apply changes now.', + )} )} diff --git a/packages/cli/src/ui/components/ShellConfirmationDialog.tsx b/packages/cli/src/ui/components/ShellConfirmationDialog.tsx index f2ab61b0..d83bf9bc 100644 --- a/packages/cli/src/ui/components/ShellConfirmationDialog.tsx +++ b/packages/cli/src/ui/components/ShellConfirmationDialog.tsx @@ -12,6 +12,7 @@ import { RenderInline } from '../utils/InlineMarkdownRenderer.js'; import type { RadioSelectItem } from './shared/RadioButtonSelect.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; export interface ShellConfirmationRequest { commands: string[]; @@ -51,17 +52,17 @@ export const ShellConfirmationDialog: React.FC< const options: Array> = [ { - label: 'Yes, allow once', + label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce, key: 'Yes, allow once', }, { - label: 'Yes, allow always for this session', + label: t('Yes, allow always for this session'), value: ToolConfirmationOutcome.ProceedAlways, key: 'Yes, allow always for this session', }, { - label: 'No (esc)', + label: t('No (esc)'), value: ToolConfirmationOutcome.Cancel, key: 'No (esc)', }, @@ -78,10 +79,10 @@ export const ShellConfirmationDialog: React.FC< > - Shell Command Execution + {t('Shell Command Execution')} - A custom command wants to run the following shell commands: + {t('A custom command wants to run the following shell commands:')} - Do you want to proceed? + {t('Do you want to proceed?')} diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 8c7bacd7..a6511942 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -19,6 +19,7 @@ import { USER_AGREEMENT_RATE_MEDIUM, } from '../utils/displayUtils.js'; import { computeSessionStats } from '../utils/computeStats.js'; +import { t } from '../../i18n/index.js'; // A more flexible and powerful StatRow component interface StatRowProps { @@ -85,22 +86,22 @@ const ModelUsageTable: React.FC<{ - Model Usage + {t('Model Usage')} - Reqs + {t('Reqs')} - Input Tokens + {t('Input Tokens')} - Output Tokens + {t('Output Tokens')} @@ -141,13 +142,14 @@ const ModelUsageTable: React.FC<{ {cacheEfficiency > 0 && ( - Savings Highlight:{' '} + {t('Savings Highlight:')}{' '} {totalCachedTokens.toLocaleString()} ({cacheEfficiency.toFixed(1)} - %) of input tokens were served from the cache, reducing costs. + %){' '} + {t('of input tokens were served from the cache, reducing costs.')} - Âģ Tip: For a full token breakdown, run `/stats model`. + Âģ {t('Tip: For a full token breakdown, run `/stats model`.')} )} @@ -199,7 +201,7 @@ export const StatsDisplay: React.FC = ({ } return ( - Session Stats + {t('Session Stats')} ); }; @@ -215,33 +217,33 @@ export const StatsDisplay: React.FC = ({ {renderTitle()} -
- +
+ {stats.sessionId} - + {tools.totalCalls} ({' '} ✓ {tools.totalSuccess}{' '} x {tools.totalFail} ) - + {computed.successRate.toFixed(1)}% {computed.totalDecisions > 0 && ( - + {computed.agreementRate.toFixed(1)}%{' '} - ({computed.totalDecisions} reviewed) + ({computed.totalDecisions} {t('reviewed')}) )} {files && (files.totalLinesAdded > 0 || files.totalLinesRemoved > 0) && ( - + +{files.totalLinesAdded} @@ -254,16 +256,16 @@ export const StatsDisplay: React.FC = ({ )}
-
- +
+ {duration} - + {formatDuration(computed.agentActiveTime)} - + {formatDuration(computed.totalApiTime)}{' '} @@ -271,7 +273,7 @@ export const StatsDisplay: React.FC = ({ - + {formatDuration(computed.totalToolTime)}{' '} diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index 468ec888..a12fed79 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -17,6 +17,7 @@ import { SettingScope } from '../../config/settings.js'; import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { ScopeSelector } from './shared/ScopeSelector.js'; +import { t } from '../../i18n/index.js'; interface ThemeDialogProps { /** Callback function when a theme is selected */ @@ -198,7 +199,8 @@ export function ThemeDialog({ {/* Left Column: Selection */} - {mode === 'theme' ? '> ' : ' '}Select Theme{' '} + {mode === 'theme' ? '> ' : ' '} + {t('Select Theme')}{' '} {otherScopeModifiedMessage} @@ -218,7 +220,7 @@ export function ThemeDialog({ {/* Right Column: Preview */} - Preview + {t('Preview')} {/* Get the Theme object for the highlighted theme, fall back to default if not found */} {(() => { @@ -274,8 +276,9 @@ def fibonacci(n): )} - (Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '} - {mode === 'theme' ? 'configure scope' : 'select theme'}) + {mode === 'theme' + ? t('(Use Enter to select, Tab to configure scope)') + : t('(Use Enter to apply scope, Tab to select theme)')} diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index 810d57ef..c8537b55 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { type Config } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; interface TipsProps { config: Config; @@ -17,12 +18,12 @@ export const Tips: React.FC = ({ config }) => { const geminiMdFileCount = config.getGeminiMdFileCount(); return ( - Tips for getting started: + {t('Tips for getting started:')} - 1. Ask questions, edit files, or run commands. + {t('1. Ask questions, edit files, or run commands.')} - 2. Be specific for the best results. + {t('2. Be specific for the best results.')} {geminiMdFileCount === 0 && ( @@ -30,7 +31,7 @@ export const Tips: React.FC = ({ config }) => { QWEN.md {' '} - files to customize your interactions with Qwen Code. + {t('files to customize your interactions with Qwen Code.')} )} @@ -38,7 +39,7 @@ export const Tips: React.FC = ({ config }) => { /help {' '} - for more information. + {t('for more information.')} ); diff --git a/packages/cli/src/ui/components/ToolStatsDisplay.tsx b/packages/cli/src/ui/components/ToolStatsDisplay.tsx index e1dcb959..f45dd9e8 100644 --- a/packages/cli/src/ui/components/ToolStatsDisplay.tsx +++ b/packages/cli/src/ui/components/ToolStatsDisplay.tsx @@ -17,6 +17,7 @@ import { } from '../utils/displayUtils.js'; import { useSessionStats } from '../contexts/SessionContext.js'; import type { ToolCallStats } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; const TOOL_NAME_COL_WIDTH = 25; const CALLS_COL_WIDTH = 8; @@ -68,7 +69,7 @@ export const ToolStatsDisplay: React.FC = () => { paddingX={2} > - No tool calls have been made in this session. + {t('No tool calls have been made in this session.')} ); @@ -103,7 +104,7 @@ export const ToolStatsDisplay: React.FC = () => { width={70} > - Tool Stats For Nerds + {t('Tool Stats For Nerds')} @@ -111,22 +112,22 @@ export const ToolStatsDisplay: React.FC = () => { - Tool Name + {t('Tool Name')} - Calls + {t('Calls')} - Success Rate + {t('Success Rate')} - Avg Duration + {t('Avg Duration')} @@ -151,13 +152,15 @@ export const ToolStatsDisplay: React.FC = () => { {/* User Decision Summary */} - User Decision Summary + {t('User Decision Summary')} - Total Reviewed Suggestions: + + {t('Total Reviewed Suggestions:')} + {totalReviewed} @@ -167,7 +170,7 @@ export const ToolStatsDisplay: React.FC = () => { - Âģ Accepted: + {t(' Âģ Accepted:')} {totalDecisions.accept} @@ -177,7 +180,7 @@ export const ToolStatsDisplay: React.FC = () => { - Âģ Rejected: + {t(' Âģ Rejected:')} {totalDecisions.reject} @@ -187,7 +190,7 @@ export const ToolStatsDisplay: React.FC = () => { - Âģ Modified: + {t(' Âģ Modified:')} {totalDecisions.modify} @@ -209,7 +212,9 @@ export const ToolStatsDisplay: React.FC = () => { - Overall Agreement Rate: + + {t(' Overall Agreement Rate:')} + 0 ? agreementColor : undefined}> diff --git a/packages/cli/src/ui/components/WelcomeBackDialog.tsx b/packages/cli/src/ui/components/WelcomeBackDialog.tsx index d16a2d8c..5ce5de31 100644 --- a/packages/cli/src/ui/components/WelcomeBackDialog.tsx +++ b/packages/cli/src/ui/components/WelcomeBackDialog.tsx @@ -12,6 +12,7 @@ import { type RadioSelectItem, } from './shared/RadioButtonSelect.js'; import { useKeypress } from '../hooks/useKeypress.js'; +import { t } from '../../i18n/index.js'; interface WelcomeBackDialogProps { welcomeBackInfo: ProjectSummaryInfo; @@ -36,12 +37,12 @@ export function WelcomeBackDialog({ const options: Array> = [ { key: 'restart', - label: 'Start new chat session', + label: t('Start new chat session'), value: 'restart', }, { key: 'continue', - label: 'Continue previous conversation', + label: t('Continue previous conversation'), value: 'continue', }, ]; @@ -67,7 +68,9 @@ export function WelcomeBackDialog({ > - 👋 Welcome back! (Last updated: {timeAgo}) + {t('👋 Welcome back! (Last updated: {{timeAgo}})', { + timeAgo: timeAgo || '', + })} @@ -75,7 +78,7 @@ export function WelcomeBackDialog({ {goalContent && ( - đŸŽ¯ Overall Goal: + {t('đŸŽ¯ Overall Goal:')} {goalContent} @@ -87,19 +90,25 @@ export function WelcomeBackDialog({ {totalTasks > 0 && ( - 📋 Current Plan: + 📋 {t('Current Plan:')} - Progress: {doneCount}/{totalTasks} tasks completed - {inProgressCount > 0 && `, ${inProgressCount} in progress`} + {t('Progress: {{done}}/{{total}} tasks completed', { + done: String(doneCount), + total: String(totalTasks), + })} + {inProgressCount > 0 && + t(', {{inProgress}} in progress', { + inProgress: String(inProgressCount), + })} {pendingTasks.length > 0 && ( - Pending Tasks: + {t('Pending Tasks:')} {pendingTasks.map((task: string, index: number) => ( @@ -113,8 +122,8 @@ export function WelcomeBackDialog({ {/* Action Selection */} - What would you like to do? - Choose how to proceed with your session: + {t('What would you like to do?')} + {t('Choose how to proceed with your session:')} 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 b63948e1..7c2c04f9 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -10,10 +10,10 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -44,10 +44,10 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -78,10 +78,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -112,10 +112,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Disable Auto Update false* │ │ │ -│ Enable Prompt Completion false* │ -│ │ │ Debug Keystroke Logging false* │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ @@ -146,10 +146,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Disable Auto Update (Modified in System) false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -180,10 +180,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging (Modified in Workspace) false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -214,10 +214,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -248,10 +248,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Disable Auto Update true* │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ @@ -282,10 +282,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -316,10 +316,10 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Disable Auto Update true* │ │ │ -│ Enable Prompt Completion true* │ -│ │ │ Debug Keystroke Logging true* │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ diff --git a/packages/cli/src/ui/components/messages/CompressionMessage.tsx b/packages/cli/src/ui/components/messages/CompressionMessage.tsx index cd6224e3..362b9e05 100644 --- a/packages/cli/src/ui/components/messages/CompressionMessage.tsx +++ b/packages/cli/src/ui/components/messages/CompressionMessage.tsx @@ -10,6 +10,7 @@ import Spinner from 'ink-spinner'; import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; import { CompressionStatus } from '@qwen-code/qwen-code-core'; +import { t } from '../../../i18n/index.js'; export interface CompressionDisplayProps { compression: CompressionProps; @@ -30,22 +31,32 @@ export function CompressionMessage({ const getCompressionText = () => { if (isPending) { - return 'Compressing chat history'; + return t('Compressing chat history'); } switch (compressionStatus) { case CompressionStatus.COMPRESSED: - return `Chat history compressed from ${originalTokens} to ${newTokens} tokens.`; + return t( + 'Chat history compressed from {{originalTokens}} to {{newTokens}} tokens.', + { + originalTokens: String(originalTokens), + newTokens: String(newTokens), + }, + ); case CompressionStatus.COMPRESSION_FAILED_INFLATED_TOKEN_COUNT: // For smaller histories (< 50k tokens), compression overhead likely exceeds benefits if (originalTokens < 50000) { - return 'Compression was not beneficial for this history size.'; + return t('Compression was not beneficial for this history size.'); } // For larger histories where compression should work but didn't, // this suggests an issue with the compression process itself - return 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.'; + return t( + 'Chat history compression did not reduce size. This may indicate issues with the compression prompt.', + ); case CompressionStatus.COMPRESSION_FAILED_TOKEN_COUNT_ERROR: - return 'Could not compress chat history due to a token counting error.'; + return t( + 'Could not compress chat history due to a token counting error.', + ); case CompressionStatus.NOOP: return 'Nothing to compress.'; default: diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 6fea96cb..40934553 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -24,6 +24,7 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { theme } from '../../semantic-colors.js'; +import { t } from '../../../i18n/index.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; @@ -105,17 +106,17 @@ export const ToolConfirmationMessage: React.FC< const compactOptions: Array> = [ { key: 'proceed-once', - label: 'Yes, allow once', + label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce, }, { key: 'proceed-always', - label: 'Allow always', + label: t('Allow always'), value: ToolConfirmationOutcome.ProceedAlways, }, { key: 'cancel', - label: 'No', + label: t('No'), value: ToolConfirmationOutcome.Cancel, }, ]; @@ -123,7 +124,7 @@ export const ToolConfirmationMessage: React.FC< return ( - Do you want to proceed? + {t('Do you want to proceed?')} - Modify in progress: + {t('Modify in progress:')} - Save and close external editor to continue + {t('Save and close external editor to continue')} ); } - question = `Apply this change?`; + question = t('Apply this change?'); options.push({ - label: 'Yes, allow once', + label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce, key: 'Yes, allow once', }); if (isTrustedFolder) { options.push({ - label: 'Yes, allow always', + label: t('Yes, allow always'), value: ToolConfirmationOutcome.ProceedAlways, key: 'Yes, allow always', }); } if ((!config.getIdeMode() || !isDiffingEnabled) && preferredEditor) { options.push({ - label: 'Modify with external editor', + label: t('Modify with external editor'), value: ToolConfirmationOutcome.ModifyWithEditor, key: 'Modify with external editor', }); } options.push({ - label: 'No, suggest changes (esc)', + label: t('No, suggest changes (esc)'), value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); @@ -232,21 +233,23 @@ export const ToolConfirmationMessage: React.FC< const executionProps = confirmationDetails as ToolExecuteConfirmationDetails; - question = `Allow execution of: '${executionProps.rootCommand}'?`; + question = t("Allow execution of: '{{command}}'?", { + command: executionProps.rootCommand, + }); options.push({ - label: 'Yes, allow once', + label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce, key: 'Yes, allow once', }); if (isTrustedFolder) { options.push({ - label: `Yes, allow always ...`, + label: t('Yes, allow always ...'), value: ToolConfirmationOutcome.ProceedAlways, - key: `Yes, allow always ...`, + key: 'Yes, allow always ...', }); } options.push({ - label: 'No, suggest changes (esc)', + label: t('No, suggest changes (esc)'), value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); @@ -275,17 +278,17 @@ export const ToolConfirmationMessage: React.FC< question = planProps.title; options.push({ key: 'proceed-always', - label: 'Yes, and auto-accept edits', + label: t('Yes, and auto-accept edits'), value: ToolConfirmationOutcome.ProceedAlways, }); options.push({ key: 'proceed-once', - label: 'Yes, and manually approve edits', + label: t('Yes, and manually approve edits'), value: ToolConfirmationOutcome.ProceedOnce, }); options.push({ key: 'cancel', - label: 'No, keep planning (esc)', + label: t('No, keep planning (esc)'), value: ToolConfirmationOutcome.Cancel, }); @@ -305,21 +308,21 @@ export const ToolConfirmationMessage: React.FC< infoProps.urls && !(infoProps.urls.length === 1 && infoProps.urls[0] === infoProps.prompt); - question = `Do you want to proceed?`; + question = t('Do you want to proceed?'); options.push({ - label: 'Yes, allow once', + label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce, key: 'Yes, allow once', }); if (isTrustedFolder) { options.push({ - label: 'Yes, allow always', + label: t('Yes, allow always'), value: ToolConfirmationOutcome.ProceedAlways, key: 'Yes, allow always', }); } options.push({ - label: 'No, suggest changes (esc)', + label: t('No, suggest changes (esc)'), value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); @@ -331,7 +334,7 @@ export const ToolConfirmationMessage: React.FC< {displayUrls && infoProps.urls && infoProps.urls.length > 0 && ( - URLs to fetch: + {t('URLs to fetch:')} {infoProps.urls.map((url) => ( {' '} @@ -348,31 +351,46 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( - MCP Server: {mcpProps.serverName} - Tool: {mcpProps.toolName} + + {t('MCP Server: {{server}}', { server: mcpProps.serverName })} + + + {t('Tool: {{tool}}', { tool: mcpProps.toolName })} + ); - question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`; + question = t( + 'Allow execution of MCP tool "{{tool}}" from server "{{server}}"?', + { + tool: mcpProps.toolName, + server: mcpProps.serverName, + }, + ); options.push({ - label: 'Yes, allow once', + label: t('Yes, allow once'), value: ToolConfirmationOutcome.ProceedOnce, key: 'Yes, allow once', }); if (isTrustedFolder) { options.push({ - label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`, + label: t('Yes, always allow tool "{{tool}}" from server "{{server}}"', { + tool: mcpProps.toolName, + server: mcpProps.serverName, + }), value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`, }); options.push({ - label: `Yes, always allow all tools from server "${mcpProps.serverName}"`, + label: t('Yes, always allow all tools from server "{{server}}"', { + server: mcpProps.serverName, + }), value: ToolConfirmationOutcome.ProceedAlwaysServer, key: `Yes, always allow all tools from server "${mcpProps.serverName}"`, }); } options.push({ - label: 'No, suggest changes (esc)', + label: t('No, suggest changes (esc)'), value: ToolConfirmationOutcome.Cancel, key: 'No, suggest changes (esc)', }); diff --git a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx index 6d432956..e17dea39 100644 --- a/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx +++ b/packages/cli/src/ui/components/shared/BaseSelectionList.test.tsx @@ -330,7 +330,7 @@ describe('BaseSelectionList', () => { expect(output).not.toContain('Item 5'); }); - it('should scroll up when activeIndex moves before the visible window', async () => { + it.skip('should scroll up when activeIndex moves before the visible window', async () => { const { updateActiveIndex, lastFrame } = renderScrollableList(0); await updateActiveIndex(4); diff --git a/packages/cli/src/ui/components/shared/ScopeSelector.tsx b/packages/cli/src/ui/components/shared/ScopeSelector.tsx index 30aa1e40..04ff8080 100644 --- a/packages/cli/src/ui/components/shared/ScopeSelector.tsx +++ b/packages/cli/src/ui/components/shared/ScopeSelector.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import type { SettingScope } from '../../../config/settings.js'; import { getScopeItems } from '../../../utils/dialogScopeUtils.js'; import { RadioButtonSelect } from './RadioButtonSelect.js'; +import { t } from '../../../i18n/index.js'; interface ScopeSelectorProps { /** Callback function when a scope is selected */ @@ -29,6 +30,7 @@ export function ScopeSelector({ }: ScopeSelectorProps): React.JSX.Element { const scopeItems = getScopeItems().map((item) => ({ ...item, + label: t(item.label), key: item.value, })); @@ -40,7 +42,8 @@ export function ScopeSelector({ return ( - {isFocused ? '> ' : ' '}Apply To + {isFocused ? '> ' : ' '} + {t('Apply To')} void; @@ -90,25 +91,25 @@ export function AgentCreationWizard({ const n = state.currentStep; switch (kind) { case 'LOCATION': - return `Step ${n}: Choose Location`; + return t('Step {{n}}: Choose Location', { n: n.toString() }); case 'GEN_METHOD': - return `Step ${n}: Choose Generation Method`; + return t('Step {{n}}: Choose Generation Method', { n: n.toString() }); case 'LLM_DESC': - return `Step ${n}: Describe Your Subagent`; + return t('Step {{n}}: Describe Your Subagent', { n: n.toString() }); case 'MANUAL_NAME': - return `Step ${n}: Enter Subagent Name`; + return t('Step {{n}}: Enter Subagent Name', { n: n.toString() }); case 'MANUAL_PROMPT': - return `Step ${n}: Enter System Prompt`; + return t('Step {{n}}: Enter System Prompt', { n: n.toString() }); case 'MANUAL_DESC': - return `Step ${n}: Enter Description`; + return t('Step {{n}}: Enter Description', { n: n.toString() }); case 'TOOLS': - return `Step ${n}: Select Tools`; + return t('Step {{n}}: Select Tools', { n: n.toString() }); case 'COLOR': - return `Step ${n}: Choose Background Color`; + return t('Step {{n}}: Choose Background Color', { n: n.toString() }); case 'FINAL': - return `Step ${n}: Confirm and Save`; + return t('Step {{n}}: Confirm and Save', { n: n.toString() }); default: - return 'Unknown Step'; + return t('Unknown Step'); } }; @@ -163,11 +164,11 @@ export function AgentCreationWizard({ // Special case: During generation in description input step, only show cancel option const kind = getStepKind(state.generationMethod, state.currentStep); if (kind === 'LLM_DESC' && state.isGenerating) { - return 'Esc to cancel'; + return t('Esc to cancel'); } if (getStepKind(state.generationMethod, state.currentStep) === 'FINAL') { - return 'Press Enter to save, e to save and edit, Esc to go back'; + return t('Press Enter to save, e to save and edit, Esc to go back'); } // Steps that have ↑↓ navigation (RadioButtonSelect components) @@ -177,14 +178,17 @@ export function AgentCreationWizard({ kindForNav === 'GEN_METHOD' || kindForNav === 'TOOLS' || kindForNav === 'COLOR'; - const navigationPart = hasNavigation ? '↑↓ to navigate, ' : ''; + const navigationPart = hasNavigation ? t('↑↓ to navigate, ') : ''; const escAction = state.currentStep === WIZARD_STEPS.LOCATION_SELECTION - ? 'cancel' - : 'go back'; + ? t('cancel') + : t('go back'); - return `Press Enter to continue, ${navigationPart}Esc to ${escAction}`; + return t('Press Enter to continue, {{navigation}}Esc to {{action}}', { + navigation: navigationPart, + action: escAction, + }); }; return ( @@ -210,16 +214,16 @@ export function AgentCreationWizard({ state={state} dispatch={dispatch} onNext={handleNext} - description="Enter a clear, unique name for this subagent." - placeholder="e.g., Code Reviewer" + description={t('Enter a clear, unique name for this subagent.')} + placeholder={t('e.g., Code Reviewer')} height={1} initialText={state.generatedName} - onChange={(t) => { - const value = t; // keep raw, trim later when validating + onChange={(text) => { + const value = text; // keep raw, trim later when validating dispatch({ type: 'SET_GENERATED_NAME', name: value }); }} - validate={(t) => - t.trim().length === 0 ? 'Name cannot be empty.' : null + validate={(text) => + text.trim().length === 0 ? t('Name cannot be empty.') : null } /> ); @@ -230,18 +234,22 @@ export function AgentCreationWizard({ state={state} dispatch={dispatch} onNext={handleNext} - description="Write the system prompt that defines this subagent's behavior. Be comprehensive for best results." - placeholder="e.g., You are an expert code reviewer..." + description={t( + "Write the system prompt that defines this subagent's behavior. Be comprehensive for best results.", + )} + placeholder={t('e.g., You are an expert code reviewer...')} height={10} initialText={state.generatedSystemPrompt} - onChange={(t) => { + onChange={(text) => { dispatch({ type: 'SET_GENERATED_SYSTEM_PROMPT', - systemPrompt: t, + systemPrompt: text, }); }} - validate={(t) => - t.trim().length === 0 ? 'System prompt cannot be empty.' : null + validate={(text) => + text.trim().length === 0 + ? t('System prompt cannot be empty.') + : null } /> ); @@ -252,15 +260,24 @@ export function AgentCreationWizard({ state={state} dispatch={dispatch} onNext={handleNext} - description="Describe when and how this subagent should be used." - placeholder="e.g., Reviews code for best practices and potential bugs." + description={t( + 'Describe when and how this subagent should be used.', + )} + placeholder={t( + 'e.g., Reviews code for best practices and potential bugs.', + )} height={6} initialText={state.generatedDescription} - onChange={(t) => { - dispatch({ type: 'SET_GENERATED_DESCRIPTION', description: t }); + onChange={(text) => { + dispatch({ + type: 'SET_GENERATED_DESCRIPTION', + description: text, + }); }} - validate={(t) => - t.trim().length === 0 ? 'Description cannot be empty.' : null + validate={(text) => + text.trim().length === 0 + ? t('Description cannot be empty.') + : null } /> ); @@ -292,7 +309,9 @@ export function AgentCreationWizard({ return ( - Invalid step: {state.currentStep} + {t('Invalid step: {{step}}', { + step: state.currentStep.toString(), + })} ); diff --git a/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx index 9a8cd81a..f9174b66 100644 --- a/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx +++ b/packages/cli/src/ui/components/subagents/create/CreationSummary.tsx @@ -15,6 +15,7 @@ import { theme } from '../../../semantic-colors.js'; import { shouldShowColor, getColorForDisplay } from '../utils.js'; import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; /** * Step 6: Final confirmation and actions. @@ -62,15 +63,24 @@ export function CreationSummary({ if (conflictLevel === targetLevel) { allWarnings.push( - `Name "${state.generatedName}" already exists at ${conflictLevel} level - will overwrite existing subagent`, + t( + 'Name "{{name}}" already exists at {{level}} level - will overwrite existing subagent', + { name: state.generatedName, level: conflictLevel }, + ), ); } else if (targetLevel === 'project') { allWarnings.push( - `Name "${state.generatedName}" exists at user level - project level will take precedence`, + t( + 'Name "{{name}}" exists at user level - project level will take precedence', + { name: state.generatedName }, + ), ); } else { allWarnings.push( - `Name "${state.generatedName}" exists at project level - existing subagent will take precedence`, + t( + 'Name "{{name}}" exists at project level - existing subagent will take precedence', + { name: state.generatedName }, + ), ); } } @@ -83,12 +93,16 @@ export function CreationSummary({ // Check length warnings if (state.generatedDescription.length > 300) { allWarnings.push( - `Description is over ${state.generatedDescription.length} characters`, + t('Description is over {{length}} characters', { + length: state.generatedDescription.length.toString(), + }), ); } if (state.generatedSystemPrompt.length > 10000) { allWarnings.push( - `System prompt is over ${state.generatedSystemPrompt.length} characters`, + t('System prompt is over {{length}} characters', { + length: state.generatedSystemPrompt.length.toString(), + }), ); } @@ -181,7 +195,9 @@ export function CreationSummary({ showSuccessAndClose(); } catch (error) { setSaveError( - `Failed to save and edit subagent: ${error instanceof Error ? error.message : 'Unknown error'}`, + t('Failed to save and edit subagent: {{error}}', { + error: error instanceof Error ? error.message : 'Unknown error', + }), ); } }, [ @@ -215,13 +231,15 @@ export function CreationSummary({ - ✅ Subagent Created Successfully! + {t('✅ Subagent Created Successfully!')} - Subagent "{state.generatedName}" has been saved to{' '} - {state.location} level. + {t('Subagent "{{name}}" has been saved to {{level}} level.', { + name: state.generatedName, + level: state.location, + })} @@ -232,35 +250,35 @@ export function CreationSummary({ - Name: + {t('Name: ')} {state.generatedName} - Location: + {t('Location: ')} {state.location === 'project' - ? 'Project Level (.qwen/agents/)' - : 'User Level (~/.qwen/agents/)'} + ? t('Project Level (.qwen/agents/)') + : t('User Level (~/.qwen/agents/)')} - Tools: + {t('Tools: ')} {toolsDisplay} {shouldShowColor(state.color) && ( - Color: + {t('Color: ')} {state.color} )} - Description: + {t('Description:')} @@ -269,7 +287,7 @@ export function CreationSummary({ - System Prompt: + {t('System Prompt:')} @@ -281,7 +299,7 @@ export function CreationSummary({ {saveError && ( - ❌ Error saving subagent: + {t('❌ Error saving subagent:')} @@ -294,7 +312,7 @@ export function CreationSummary({ {warnings.length > 0 && ( - Warnings: + {t('Warnings:')} {warnings.map((warning, index) => ( diff --git a/packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx b/packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx index d81cafb2..4e7f4491 100644 --- a/packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx +++ b/packages/cli/src/ui/components/subagents/create/DescriptionInput.tsx @@ -14,6 +14,7 @@ import { useKeypress, type Key } from '../../../hooks/useKeypress.js'; import { keyMatchers, Command } from '../../../keyMatchers.js'; import { theme } from '../../../semantic-colors.js'; import { TextInput } from '../../shared/TextInput.js'; +import { t } from '../../../../i18n/index.js'; /** * Step 3: Description input with LLM generation. @@ -103,7 +104,9 @@ export function DescriptionInput({ dispatch({ type: 'SET_VALIDATION_ERRORS', errors: [ - `Failed to generate subagent: ${error instanceof Error ? error.message : 'Unknown error'}`, + t('Failed to generate subagent: {{error}}', { + error: error instanceof Error ? error.message : 'Unknown error', + }), ], }); } @@ -135,15 +138,17 @@ export function DescriptionInput({ isActive: state.isGenerating, }); - const placeholder = - 'e.g., Expert code reviewer that reviews code based on best practices...'; + const placeholder = t( + 'e.g., Expert code reviewer that reviews code based on best practices...', + ); return ( - Describe what this subagent should do and when it should be used. (Be - comprehensive for best results) + {t( + 'Describe what this subagent should do and when it should be used. (Be comprehensive for best results)', + )} @@ -153,7 +158,7 @@ export function DescriptionInput({ - Generating subagent configuration... + {t('Generating subagent configuration...')} ) : ( diff --git a/packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx b/packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx index 0018e8bd..b7f111e6 100644 --- a/packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/GenerationMethodSelector.tsx @@ -7,6 +7,7 @@ import { Box } from 'ink'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import type { WizardStepProps } from '../types.js'; +import { t } from '../../../../i18n/index.js'; interface GenerationOption { label: string; @@ -15,11 +16,15 @@ interface GenerationOption { const generationOptions: GenerationOption[] = [ { - label: 'Generate with Qwen Code (Recommended)', + get label() { + return t('Generate with Qwen Code (Recommended)'); + }, value: 'qwen', }, { - label: 'Manual Creation', + get label() { + return t('Manual Creation'); + }, value: 'manual', }, ]; diff --git a/packages/cli/src/ui/components/subagents/create/LocationSelector.tsx b/packages/cli/src/ui/components/subagents/create/LocationSelector.tsx index 51601730..aad81c3a 100644 --- a/packages/cli/src/ui/components/subagents/create/LocationSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/LocationSelector.tsx @@ -7,6 +7,7 @@ import { Box } from 'ink'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import type { WizardStepProps } from '../types.js'; +import { t } from '../../../../i18n/index.js'; interface LocationOption { label: string; @@ -15,11 +16,15 @@ interface LocationOption { const locationOptions: LocationOption[] = [ { - label: 'Project Level (.qwen/agents/)', + get label() { + return t('Project Level (.qwen/agents/)'); + }, value: 'project', }, { - label: 'User Level (~/.qwen/agents/)', + get label() { + return t('User Level (~/.qwen/agents/)'); + }, value: 'user', }, ]; diff --git a/packages/cli/src/ui/components/subagents/create/ToolSelector.tsx b/packages/cli/src/ui/components/subagents/create/ToolSelector.tsx index ccea5b61..547e14ed 100644 --- a/packages/cli/src/ui/components/subagents/create/ToolSelector.tsx +++ b/packages/cli/src/ui/components/subagents/create/ToolSelector.tsx @@ -10,6 +10,7 @@ import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import type { ToolCategory } from '../types.js'; import { Kind, type Config } from '@qwen-code/qwen-code-core'; import { theme } from '../../../semantic-colors.js'; +import { t } from '../../../../i18n/index.js'; interface ToolOption { label: string; @@ -45,7 +46,7 @@ export function ToolSelector({ toolCategories: [ { id: 'all', - name: 'All Tools (Default)', + name: t('All Tools (Default)'), tools: [], }, ], @@ -89,22 +90,22 @@ export function ToolSelector({ const toolCategories = [ { id: 'all', - name: 'All Tools', + name: t('All Tools'), tools: [], }, { id: 'read', - name: 'Read-only Tools', + name: t('Read-only Tools'), tools: readTools, }, { id: 'edit', - name: 'Read & Edit Tools', + name: t('Read & Edit Tools'), tools: [...readTools, ...editTools], }, { id: 'execute', - name: 'Read & Edit & Execution Tools', + name: t('Read & Edit & Execution Tools'), tools: [...readTools, ...editTools, ...executeTools], }, ].filter((category) => category.id === 'all' || category.tools.length > 0); @@ -202,11 +203,11 @@ export function ToolSelector({ {currentCategory.id === 'all' ? ( - All tools selected, including MCP tools + {t('All tools selected, including MCP tools')} ) : currentCategory.tools.length > 0 ? ( <> - Selected tools: + {t('Selected tools:')} {(() => { // Filter the already categorized tools to show only those in current category @@ -224,17 +225,19 @@ export function ToolSelector({ <> {categoryReadTools.length > 0 && ( - â€ĸ Read-only tools: {categoryReadTools.join(', ')} + â€ĸ {t('Read-only tools:')}{' '} + {categoryReadTools.join(', ')} )} {categoryEditTools.length > 0 && ( - â€ĸ Edit tools: {categoryEditTools.join(', ')} + â€ĸ {t('Edit tools:')} {categoryEditTools.join(', ')} )} {categoryExecuteTools.length > 0 && ( - â€ĸ Execution tools: {categoryExecuteTools.join(', ')} + â€ĸ {t('Execution tools:')}{' '} + {categoryExecuteTools.join(', ')} )} diff --git a/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx index c0a6b5a9..28393d08 100644 --- a/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/ActionSelectionStep.tsx @@ -9,6 +9,7 @@ import { Box } from 'ink'; import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; import { MANAGEMENT_STEPS } from '../types.js'; import { type SubagentConfig } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; interface ActionSelectionStepProps { selectedAgent: SubagentConfig | null; @@ -27,10 +28,34 @@ export const ActionSelectionStep = ({ // Filter actions based on whether the agent is built-in const allActions = [ - { key: 'view', label: 'View Agent', value: 'view' as const }, - { key: 'edit', label: 'Edit Agent', value: 'edit' as const }, - { key: 'delete', label: 'Delete Agent', value: 'delete' as const }, - { key: 'back', label: 'Back', value: 'back' as const }, + { + key: 'view', + get label() { + return t('View Agent'); + }, + value: 'view' as const, + }, + { + key: 'edit', + get label() { + return t('Edit Agent'); + }, + value: 'edit' as const, + }, + { + key: 'delete', + get label() { + return t('Delete Agent'); + }, + value: 'delete' as const, + }, + { + key: 'back', + get label() { + return t('Back'); + }, + value: 'back' as const, + }, ]; const actions = selectedAgent?.isBuiltin diff --git a/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx index 245d348e..77cfa47d 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentDeleteStep.tsx @@ -9,6 +9,7 @@ import { type SubagentConfig } from '@qwen-code/qwen-code-core'; import type { StepNavigationProps } from '../types.js'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; interface AgentDeleteStepProps extends StepNavigationProps { selectedAgent: SubagentConfig | null; @@ -41,7 +42,7 @@ export function AgentDeleteStep({ if (!selectedAgent) { return ( - No agent selected + {t('No agent selected')} ); } @@ -49,8 +50,9 @@ export function AgentDeleteStep({ return ( - Are you sure you want to delete agent “{selectedAgent.name} - ”? + {t('Are you sure you want to delete agent "{{name}}"?', { + name: selectedAgent.name, + })} ); diff --git a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx index 4037dff1..ab1cd2a9 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentEditStep.tsx @@ -11,6 +11,7 @@ import { MANAGEMENT_STEPS } from '../types.js'; import { theme } from '../../../semantic-colors.js'; import { useLaunchEditor } from '../../../hooks/useLaunchEditor.js'; import { type SubagentConfig } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; interface EditOption { id: string; @@ -20,15 +21,21 @@ interface EditOption { const editOptions: EditOption[] = [ { id: 'editor', - label: 'Open in editor', + get label() { + return t('Open in editor'); + }, }, { id: 'tools', - label: 'Edit tools', + get label() { + return t('Edit tools'); + }, }, { id: 'color', - label: 'Edit color', + get label() { + return t('Edit color'); + }, }, ]; @@ -65,7 +72,9 @@ export function EditOptionsStep({ await launchEditor(selectedAgent?.filePath); } catch (err) { setError( - `Failed to launch editor: ${err instanceof Error ? err.message : 'Unknown error'}`, + t('Failed to launch editor: {{error}}', { + error: err instanceof Error ? err.message : 'Unknown error', + }), ); } } else if (selectedValue === 'tools') { @@ -98,7 +107,7 @@ export function EditOptionsStep({ {error && ( - ❌ Error: + {t('❌ Error:')} diff --git a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx index 73076163..613ac87e 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentSelectionStep.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { useKeypress } from '../../../hooks/useKeypress.js'; import { type SubagentConfig } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; interface NavigationState { currentBlock: 'project' | 'user' | 'builtin'; @@ -205,9 +206,9 @@ export const AgentSelectionStep = ({ if (availableAgents.length === 0) { return ( - No subagents found. + {t('No subagents found.')} - Use '/agents create' to create your first subagent. + {t("Use '/agents create' to create your first subagent.")} ); @@ -237,7 +238,7 @@ export const AgentSelectionStep = ({ {agent.isBuiltin && ( {' '} - (built-in) + {t('(built-in)')} )} {agent.level === 'user' && projectNames.has(agent.name) && ( @@ -245,7 +246,7 @@ export const AgentSelectionStep = ({ color={isSelected ? theme.status.warning : theme.text.secondary} > {' '} - (overridden by project level agent) + {t('(overridden by project level agent)')} )} @@ -265,7 +266,9 @@ export const AgentSelectionStep = ({ {projectAgents.length > 0 && ( - Project Level ({projectAgents[0].filePath.replace(/\/[^/]+$/, '')}) + {t('Project Level ({{path}})', { + path: projectAgents[0].filePath.replace(/\/[^/]+$/, ''), + })} {projectAgents.map((agent, index) => { @@ -285,7 +288,9 @@ export const AgentSelectionStep = ({ marginBottom={builtinAgents.length > 0 ? 1 : 0} > - User Level ({userAgents[0].filePath.replace(/\/[^/]+$/, '')}) + {t('User Level ({{path}})', { + path: userAgents[0].filePath.replace(/\/[^/]+$/, ''), + })} {userAgents.map((agent, index) => { @@ -302,7 +307,7 @@ export const AgentSelectionStep = ({ {builtinAgents.length > 0 && ( - Built-in Agents + {t('Built-in Agents')} {builtinAgents.map((agent, index) => { @@ -321,7 +326,9 @@ export const AgentSelectionStep = ({ builtinAgents.length > 0) && ( - Using: {enabledAgentsCount} agents + {t('Using: {{count}} agents', { + count: enabledAgentsCount.toString(), + })} )} diff --git a/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx b/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx index 8f5fd2dd..ee2fd366 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentViewerStep.tsx @@ -8,6 +8,7 @@ import { Box, Text } from 'ink'; import { theme } from '../../../semantic-colors.js'; import { shouldShowColor, getColorForDisplay } from '../utils.js'; import { type SubagentConfig } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; interface AgentViewerStepProps { selectedAgent: SubagentConfig | null; @@ -17,7 +18,7 @@ export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => { if (!selectedAgent) { return ( - No agent selected + {t('No agent selected')} ); } @@ -30,31 +31,31 @@ export const AgentViewerStep = ({ selectedAgent }: AgentViewerStepProps) => { - File Path: + {t('File Path: ')} {agent.filePath} - Tools: + {t('Tools: ')} {toolsDisplay} {shouldShowColor(agent.color) && ( - Color: + {t('Color: ')} {agent.color} )} - Description: + {t('Description:')} {agent.description} - System Prompt: + {t('System Prompt:')} {agent.systemPrompt} diff --git a/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx b/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx index 5a775001..f496d6bc 100644 --- a/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx +++ b/packages/cli/src/ui/components/subagents/manage/AgentsManagerDialog.tsx @@ -18,6 +18,7 @@ import { theme } from '../../../semantic-colors.js'; import { getColorForDisplay, shouldShowColor } from '../utils.js'; import type { SubagentConfig, Config } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; interface AgentsManagerDialogProps { onClose: () => void; @@ -143,21 +144,21 @@ export function AgentsManagerDialog({ const getStepHeaderText = () => { switch (currentStep) { case MANAGEMENT_STEPS.AGENT_SELECTION: - return 'Agents'; + return t('Agents'); case MANAGEMENT_STEPS.ACTION_SELECTION: - return 'Choose Action'; + return t('Choose Action'); case MANAGEMENT_STEPS.AGENT_VIEWER: return selectedAgent?.name; case MANAGEMENT_STEPS.EDIT_OPTIONS: - return `Edit ${selectedAgent?.name}`; + return t('Edit {{name}}', { name: selectedAgent?.name || '' }); case MANAGEMENT_STEPS.EDIT_TOOLS: - return `Edit Tools: ${selectedAgent?.name}`; + return t('Edit Tools: {{name}}', { name: selectedAgent?.name || '' }); case MANAGEMENT_STEPS.EDIT_COLOR: - return `Edit Color: ${selectedAgent?.name}`; + return t('Edit Color: {{name}}', { name: selectedAgent?.name || '' }); case MANAGEMENT_STEPS.DELETE_CONFIRMATION: - return `Delete ${selectedAgent?.name}`; + return t('Delete {{name}}', { name: selectedAgent?.name || '' }); default: - return 'Unknown Step'; + return t('Unknown Step'); } }; @@ -183,20 +184,20 @@ export function AgentsManagerDialog({ const getNavigationInstructions = () => { if (currentStep === MANAGEMENT_STEPS.AGENT_SELECTION) { if (availableAgents.length === 0) { - return 'Esc to close'; + return t('Esc to close'); } - return 'Enter to select, ↑↓ to navigate, Esc to close'; + return t('Enter to select, ↑↓ to navigate, Esc to close'); } if (currentStep === MANAGEMENT_STEPS.AGENT_VIEWER) { - return 'Esc to go back'; + return t('Esc to go back'); } if (currentStep === MANAGEMENT_STEPS.DELETE_CONFIRMATION) { - return 'Enter to confirm, Esc to cancel'; + return t('Enter to confirm, Esc to cancel'); } - return 'Enter to select, ↑↓ to navigate, Esc to go back'; + return t('Enter to select, ↑↓ to navigate, Esc to go back'); }; return ( @@ -295,7 +296,9 @@ export function AgentsManagerDialog({ default: return ( - Invalid step: {currentStep} + + {t('Invalid step: {{step}}', { step: currentStep })} + ); } diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index 9bd9a812..a1870591 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -99,13 +99,13 @@ export const AgentExecutionDisplay: React.FC = ({ data.toolCalls && data.toolCalls.length > MAX_TOOL_CALLS; if (hasMoreToolCalls || hasMoreLines) { - return 'Press ctrl+r to show less, ctrl+e to show more.'; + return 'Press ctrl+e to show less, ctrl+f to show more.'; } - return 'Press ctrl+r to show less.'; + return 'Press ctrl+e to show less.'; } if (displayMode === 'verbose') { - return 'Press ctrl+e to show less.'; + return 'Press ctrl+f to show less.'; } return ''; @@ -114,13 +114,13 @@ export const AgentExecutionDisplay: React.FC = ({ // Handle keyboard shortcuts to control display mode useKeypress( (key) => { - if (key.ctrl && key.name === 'r') { - // ctrl+r toggles between compact and default + if (key.ctrl && key.name === 'e') { + // ctrl+e toggles between compact and default setDisplayMode((current) => current === 'compact' ? 'default' : 'compact', ); - } else if (key.ctrl && key.name === 'e') { - // ctrl+e toggles between default and verbose + } else if (key.ctrl && key.name === 'f') { + // ctrl+f toggles between default and verbose setDisplayMode((current) => current === 'default' ? 'verbose' : 'default', ); @@ -157,7 +157,7 @@ export const AgentExecutionDisplay: React.FC = ({ {data.toolCalls.length > 1 && !data.pendingConfirmation && ( - +{data.toolCalls.length - 1} more tool calls (ctrl+r to + +{data.toolCalls.length - 1} more tool calls (ctrl+e to expand) diff --git a/packages/cli/src/ui/components/views/McpStatus.tsx b/packages/cli/src/ui/components/views/McpStatus.tsx index b1dcfb50..eac11b57 100644 --- a/packages/cli/src/ui/components/views/McpStatus.tsx +++ b/packages/cli/src/ui/components/views/McpStatus.tsx @@ -14,6 +14,7 @@ import type { JsonMcpPrompt, JsonMcpTool, } from '../../types.js'; +import { t } from '../../../i18n/index.js'; interface McpStatusProps { servers: Record; @@ -47,13 +48,13 @@ export const McpStatus: React.FC = ({ if (serverNames.length === 0 && blockedServers.length === 0) { return ( - No MCP servers configured. + {t('No MCP servers configured.')} - Please view MCP documentation in your browser:{' '} + {t('Please view MCP documentation in your browser:')}{' '} https://goo.gle/gemini-cli-docs-mcp {' '} - or use the cli /docs command + {t('or use the cli /docs command')} ); @@ -64,17 +65,19 @@ export const McpStatus: React.FC = ({ {discoveryInProgress && ( - âŗ MCP servers are starting up ({connectingServers.length}{' '} - initializing)... + {t('âŗ MCP servers are starting up ({{count}} initializing)...', { + count: String(connectingServers.length), + })} - Note: First startup may take longer. Tool availability will update - automatically. + {t( + 'Note: First startup may take longer. Tool availability will update automatically.', + )} )} - Configured MCP servers: + {t('Configured MCP servers:')} {serverNames.map((serverName) => { @@ -100,50 +103,61 @@ export const McpStatus: React.FC = ({ switch (status) { case MCPServerStatus.CONNECTED: statusIndicator = 'đŸŸĸ'; - statusText = 'Ready'; + statusText = t('Ready'); statusColor = theme.status.success; break; case MCPServerStatus.CONNECTING: statusIndicator = '🔄'; - statusText = 'Starting... (first startup may take longer)'; + statusText = t('Starting... (first startup may take longer)'); statusColor = theme.status.warning; break; case MCPServerStatus.DISCONNECTED: default: statusIndicator = '🔴'; - statusText = 'Disconnected'; + statusText = t('Disconnected'); statusColor = theme.status.error; break; } let serverDisplayName = serverName; if (server.extensionName) { - serverDisplayName += ` (from ${server.extensionName})`; + serverDisplayName += ` ${t('(from {{extensionName}})', { + extensionName: server.extensionName, + })}`; } const toolCount = serverTools.length; const promptCount = serverPrompts.length; const parts = []; if (toolCount > 0) { - parts.push(`${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}`); + parts.push( + toolCount === 1 + ? t('{{count}} tool', { count: String(toolCount) }) + : t('{{count}} tools', { count: String(toolCount) }), + ); } if (promptCount > 0) { parts.push( - `${promptCount} ${promptCount === 1 ? 'prompt' : 'prompts'}`, + promptCount === 1 + ? t('{{count}} prompt', { count: String(promptCount) }) + : t('{{count}} prompts', { count: String(promptCount) }), ); } const serverAuthStatus = authStatus[serverName]; let authStatusNode: React.ReactNode = null; if (serverAuthStatus === 'authenticated') { - authStatusNode = (OAuth); + authStatusNode = ({t('OAuth')}); } else if (serverAuthStatus === 'expired') { authStatusNode = ( - (OAuth expired) + ({t('OAuth expired')}) ); } else if (serverAuthStatus === 'unauthenticated') { authStatusNode = ( - (OAuth not authenticated) + + {' '} + ({t('OAuth not authenticated')}) + ); } @@ -162,10 +176,12 @@ export const McpStatus: React.FC = ({ {authStatusNode} {status === MCPServerStatus.CONNECTING && ( - (tools and prompts will appear when ready) + ({t('tools and prompts will appear when ready')}) )} {status === MCPServerStatus.DISCONNECTED && toolCount > 0 && ( - ({toolCount} tools cached) + + ({t('{{count}} tools cached', { count: String(toolCount) })}) + )} {showDescriptions && server?.description && ( @@ -176,7 +192,7 @@ export const McpStatus: React.FC = ({ {serverTools.length > 0 && ( - Tools: + {t('Tools:')} {serverTools.map((tool) => { const schemaContent = showSchema && @@ -204,7 +220,9 @@ export const McpStatus: React.FC = ({ )} {schemaContent && ( - Parameters: + + {t('Parameters:')} + {schemaContent} @@ -218,7 +236,7 @@ export const McpStatus: React.FC = ({ {serverPrompts.length > 0 && ( - Prompts: + {t('Prompts:')} {serverPrompts.map((prompt) => ( @@ -244,35 +262,41 @@ export const McpStatus: React.FC = ({ 🔴 {server.name} - {server.extensionName ? ` (from ${server.extensionName})` : ''} + {server.extensionName + ? ` ${t('(from {{extensionName}})', { + extensionName: server.extensionName, + })}` + : ''} - - Blocked + - {t('Blocked')} ))} {showTips && ( - 💡 Tips: + {t('💡 Tips:')} - {' '}- Use /mcp desc to show - server and tool descriptions + {' '}- {t('Use')} /mcp desc{' '} + {t('to show server and tool descriptions')} - {' '}- Use /mcp schema to - show tool parameter schemas + {' '}- {t('Use')}{' '} + /mcp schema{' '} + {t('to show tool parameter schemas')} - {' '}- Use /mcp nodesc to - hide descriptions + {' '}- {t('Use')}{' '} + /mcp nodesc{' '} + {t('to hide descriptions')} - {' '}- Use{' '} + {' '}- {t('Use')}{' '} /mcp auth <server-name>{' '} - to authenticate with OAuth-enabled servers + {t('to authenticate with OAuth-enabled servers')} - {' '}- Press Ctrl+T to - toggle tool descriptions on/off + {' '}- {t('Press')} Ctrl+T{' '} + {t('to toggle tool descriptions on/off')} )} diff --git a/packages/cli/src/ui/components/views/ToolsList.tsx b/packages/cli/src/ui/components/views/ToolsList.tsx index dd0f753d..061716e6 100644 --- a/packages/cli/src/ui/components/views/ToolsList.tsx +++ b/packages/cli/src/ui/components/views/ToolsList.tsx @@ -9,6 +9,7 @@ import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { type ToolDefinition } from '../../types.js'; import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { t } from '../../../i18n/index.js'; interface ToolsListProps { tools: readonly ToolDefinition[]; @@ -23,7 +24,7 @@ export const ToolsList: React.FC = ({ }) => ( - Available Qwen Code CLI tools: + {t('Available Qwen Code CLI tools:')} {tools.length > 0 ? ( @@ -46,7 +47,7 @@ export const ToolsList: React.FC = ({ )) ) : ( - No tools available + {t('No tools available')} )} ); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts index bf978395..659b99db 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts @@ -83,9 +83,7 @@ const setupMocks = ({ describe('useCommandCompletion', () => { const mockCommandContext = {} as CommandContext; - const mockConfig = { - getEnablePromptCompletion: () => false, - } as Config; + const mockConfig = {} as Config; const testDirs: string[] = []; const testRootDir = '/'; @@ -516,81 +514,4 @@ describe('useCommandCompletion', () => { ); }); }); - - describe('prompt completion filtering', () => { - it('should not trigger prompt completion for line comments', async () => { - const mockConfig = { - getEnablePromptCompletion: () => true, - } as Config; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest('// This is a line comment'); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - // Should not trigger prompt completion for comments - expect(result.current.suggestions.length).toBe(0); - }); - - it('should not trigger prompt completion for block comments', async () => { - const mockConfig = { - getEnablePromptCompletion: () => true, - } as Config; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest( - '/* This is a block comment */', - ); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - // Should not trigger prompt completion for comments - expect(result.current.suggestions.length).toBe(0); - }); - - it('should trigger prompt completion for regular text when enabled', async () => { - const mockConfig = { - getEnablePromptCompletion: () => true, - } as Config; - - const { result } = renderHook(() => { - const textBuffer = useTextBufferForTest( - 'This is regular text that should trigger completion', - ); - const completion = useCommandCompletion( - textBuffer, - testDirs, - testRootDir, - [], - mockCommandContext, - false, - mockConfig, - ); - return { ...completion, textBuffer }; - }); - - // This test verifies that comments are filtered out while regular text is not - expect(result.current.textBuffer.text).toBe( - 'This is regular text that should trigger completion', - ); - }); - }); }); diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index e26bb73d..3deaa8a5 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -13,11 +13,6 @@ import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; -import type { PromptCompletion } from './usePromptCompletion.js'; -import { - usePromptCompletion, - PROMPT_COMPLETION_MIN_LENGTH, -} from './usePromptCompletion.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { useCompletion } from './useCompletion.js'; @@ -25,7 +20,6 @@ export enum CompletionMode { IDLE = 'IDLE', AT = 'AT', SLASH = 'SLASH', - PROMPT = 'PROMPT', } export interface UseCommandCompletionReturn { @@ -41,7 +35,6 @@ export interface UseCommandCompletionReturn { navigateUp: () => void; navigateDown: () => void; handleAutocomplete: (indexToUse: number) => void; - promptCompletion: PromptCompletion; } export function useCommandCompletion( @@ -126,32 +119,13 @@ export function useCommandCompletion( } } - // Check for prompt completion - only if enabled - const trimmedText = buffer.text.trim(); - const isPromptCompletionEnabled = - config?.getEnablePromptCompletion() ?? false; - - if ( - isPromptCompletionEnabled && - trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && - !isSlashCommand(trimmedText) && - !trimmedText.includes('@') - ) { - return { - completionMode: CompletionMode.PROMPT, - query: trimmedText, - completionStart: 0, - completionEnd: trimmedText.length, - }; - } - return { completionMode: CompletionMode.IDLE, query: null, completionStart: -1, completionEnd: -1, }; - }, [cursorRow, cursorCol, buffer.lines, buffer.text, config]); + }, [cursorRow, cursorCol, buffer.lines]); useAtCompletion({ enabled: completionMode === CompletionMode.AT, @@ -172,12 +146,6 @@ export function useCommandCompletion( setIsPerfectMatch, }); - const promptCompletion = usePromptCompletion({ - buffer, - config, - enabled: completionMode === CompletionMode.PROMPT, - }); - useEffect(() => { setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1); setVisibleStartIndex(0); @@ -264,6 +232,5 @@ export function useCommandCompletion( navigateUp, navigateDown, handleAutocomplete, - promptCompletion, }; } diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.ts index eb1d53d1..a752d073 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.test.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { MockedFunction } from 'vitest'; +import type { Mock } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act } from 'react'; import { renderHook, waitFor } from '@testing-library/react'; import { useGitBranchName } from './useGitBranchName.js'; import { fs, vol } from 'memfs'; // For mocking fs -import { spawnAsync as mockSpawnAsync } from '@qwen-code/qwen-code-core'; +import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core'; // Mock @qwen-code/qwen-code-core vi.mock('@qwen-code/qwen-code-core', async () => { @@ -19,7 +19,8 @@ vi.mock('@qwen-code/qwen-code-core', async () => { >('@qwen-code/qwen-code-core'); return { ...original, - spawnAsync: vi.fn(), + execCommand: vi.fn(), + isCommandAvailable: vi.fn(), }; }); @@ -47,6 +48,7 @@ describe('useGitBranchName', () => { [GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main', }); vi.useFakeTimers(); // Use fake timers for async operations + (isCommandAvailable as Mock).mockReturnValue({ available: true }); }); afterEach(() => { @@ -55,11 +57,11 @@ describe('useGitBranchName', () => { }); it('should return branch name', async () => { - (mockSpawnAsync as MockedFunction).mockResolvedValue( - { - stdout: 'main\n', - } as { stdout: string; stderr: string }, - ); + (execCommand as Mock).mockResolvedValueOnce({ + stdout: 'main\n', + stderr: '', + code: 0, + }); const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { @@ -71,9 +73,7 @@ describe('useGitBranchName', () => { }); it('should return undefined if git command fails', async () => { - (mockSpawnAsync as MockedFunction).mockRejectedValue( - new Error('Git error'), - ); + (execCommand as Mock).mockRejectedValue(new Error('Git error')); const { result, rerender } = renderHook(() => useGitBranchName(CWD)); expect(result.current).toBeUndefined(); @@ -86,16 +86,16 @@ describe('useGitBranchName', () => { }); it('should return short commit hash if branch is HEAD (detached state)', async () => { - ( - mockSpawnAsync as MockedFunction - ).mockImplementation(async (command: string, args: string[]) => { - if (args.includes('--abbrev-ref')) { - return { stdout: 'HEAD\n' } as { stdout: string; stderr: string }; - } else if (args.includes('--short')) { - return { stdout: 'a1b2c3d\n' } as { stdout: string; stderr: string }; - } - return { stdout: '' } as { stdout: string; stderr: string }; - }); + (execCommand as Mock).mockImplementation( + async (_command: string, args?: readonly string[] | null) => { + if (args?.includes('--abbrev-ref')) { + return { stdout: 'HEAD\n', stderr: '', code: 0 }; + } else if (args?.includes('--short')) { + return { stdout: 'a1b2c3d\n', stderr: '', code: 0 }; + } + return { stdout: '', stderr: '', code: 0 }; + }, + ); const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { @@ -106,16 +106,16 @@ describe('useGitBranchName', () => { }); it('should return undefined if branch is HEAD and getting commit hash fails', async () => { - ( - mockSpawnAsync as MockedFunction - ).mockImplementation(async (command: string, args: string[]) => { - if (args.includes('--abbrev-ref')) { - return { stdout: 'HEAD\n' } as { stdout: string; stderr: string }; - } else if (args.includes('--short')) { - throw new Error('Git error'); - } - return { stdout: '' } as { stdout: string; stderr: string }; - }); + (execCommand as Mock).mockImplementation( + async (_command: string, args?: readonly string[] | null) => { + if (args?.includes('--abbrev-ref')) { + return { stdout: 'HEAD\n', stderr: '', code: 0 }; + } else if (args?.includes('--short')) { + throw new Error('Git error'); + } + return { stdout: '', stderr: '', code: 0 }; + }, + ); const { result, rerender } = renderHook(() => useGitBranchName(CWD)); await act(async () => { @@ -127,14 +127,16 @@ describe('useGitBranchName', () => { it('should update branch name when .git/HEAD changes', async ({ skip }) => { skip(); // TODO: fix - (mockSpawnAsync as MockedFunction) - .mockResolvedValueOnce({ stdout: 'main\n' } as { - stdout: string; - stderr: string; + (execCommand as Mock) + .mockResolvedValueOnce({ + stdout: 'main\n', + stderr: '', + code: 0, }) - .mockResolvedValueOnce({ stdout: 'develop\n' } as { - stdout: string; - stderr: string; + .mockResolvedValueOnce({ + stdout: 'develop\n', + stderr: '', + code: 0, }); const { result, rerender } = renderHook(() => useGitBranchName(CWD)); @@ -162,11 +164,11 @@ describe('useGitBranchName', () => { // Remove .git/logs/HEAD to cause an error in fs.watch setup vol.unlinkSync(GIT_LOGS_HEAD_PATH); - (mockSpawnAsync as MockedFunction).mockResolvedValue( - { - stdout: 'main\n', - } as { stdout: string; stderr: string }, - ); + (execCommand as Mock).mockResolvedValue({ + stdout: 'main\n', + stderr: '', + code: 0, + }); const { result, rerender } = renderHook(() => useGitBranchName(CWD)); @@ -177,11 +179,11 @@ describe('useGitBranchName', () => { expect(result.current).toBe('main'); // Branch name should still be fetched initially - ( - mockSpawnAsync as MockedFunction - ).mockResolvedValueOnce({ + (execCommand as Mock).mockResolvedValueOnce({ stdout: 'develop\n', - } as { stdout: string; stderr: string }); + stderr: '', + code: 0, + }); // This write would trigger the watcher if it was set up // but since it failed, the branch name should not update @@ -207,11 +209,11 @@ describe('useGitBranchName', () => { close: closeMock, } as unknown as ReturnType); - (mockSpawnAsync as MockedFunction).mockResolvedValue( - { - stdout: 'main\n', - } as { stdout: string; stderr: string }, - ); + (execCommand as Mock).mockResolvedValue({ + stdout: 'main\n', + stderr: '', + code: 0, + }); const { unmount, rerender } = renderHook(() => useGitBranchName(CWD)); diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts index af7bccb6..326051a0 100644 --- a/packages/cli/src/ui/hooks/useGitBranchName.ts +++ b/packages/cli/src/ui/hooks/useGitBranchName.ts @@ -5,7 +5,7 @@ */ import { useState, useEffect, useCallback } from 'react'; -import { spawnAsync } from '@qwen-code/qwen-code-core'; +import { isCommandAvailable, execCommand } from '@qwen-code/qwen-code-core'; import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; @@ -15,7 +15,11 @@ export function useGitBranchName(cwd: string): string | undefined { const fetchBranchName = useCallback(async () => { try { - const { stdout } = await spawnAsync( + if (!isCommandAvailable('git').available) { + return; + } + + const { stdout } = await execCommand( 'git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd }, @@ -24,7 +28,7 @@ export function useGitBranchName(cwd: string): string | undefined { if (branch && branch !== 'HEAD') { setBranchName(branch); } else { - const { stdout: hashStdout } = await spawnAsync( + const { stdout: hashStdout } = await execCommand( 'git', ['rev-parse', '--short', 'HEAD'], { cwd }, diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 57b83c68..8fa878b3 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useMemo } from 'react'; +import { t } from '../../i18n/index.js'; export const WITTY_LOADING_PHRASES = [ "I'm Feeling Lucky", @@ -151,10 +152,14 @@ export const usePhraseCycler = ( isWaiting: boolean, customPhrases?: string[], ) => { - const loadingPhrases = - customPhrases && customPhrases.length > 0 - ? customPhrases - : WITTY_LOADING_PHRASES; + // Translate all phrases at once if using default phrases + const loadingPhrases = useMemo( + () => + customPhrases && customPhrases.length > 0 + ? customPhrases + : WITTY_LOADING_PHRASES.map((phrase) => t(phrase)), + [customPhrases], + ); const [currentLoadingPhrase, setCurrentLoadingPhrase] = useState( loadingPhrases[0], @@ -163,7 +168,7 @@ export const usePhraseCycler = ( useEffect(() => { if (isWaiting) { - setCurrentLoadingPhrase('Waiting for user confirmation...'); + setCurrentLoadingPhrase(t('Waiting for user confirmation...')); if (phraseIntervalRef.current) { clearInterval(phraseIntervalRef.current); phraseIntervalRef.current = null; diff --git a/packages/cli/src/ui/hooks/usePromptCompletion.ts b/packages/cli/src/ui/hooks/usePromptCompletion.ts deleted file mode 100644 index 504a22c9..00000000 --- a/packages/cli/src/ui/hooks/usePromptCompletion.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; -import type { Config } from '@qwen-code/qwen-code-core'; -import { - DEFAULT_GEMINI_FLASH_LITE_MODEL, - getResponseText, -} from '@qwen-code/qwen-code-core'; -import type { Content, GenerateContentConfig } from '@google/genai'; -import type { TextBuffer } from '../components/shared/text-buffer.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; - -export const PROMPT_COMPLETION_MIN_LENGTH = 5; -export const PROMPT_COMPLETION_DEBOUNCE_MS = 250; - -export interface PromptCompletion { - text: string; - isLoading: boolean; - isActive: boolean; - accept: () => void; - clear: () => void; - markSelected: (selectedText: string) => void; -} - -export interface UsePromptCompletionOptions { - buffer: TextBuffer; - config?: Config; - enabled: boolean; -} - -export function usePromptCompletion({ - buffer, - config, - enabled, -}: UsePromptCompletionOptions): PromptCompletion { - const [ghostText, setGhostText] = useState(''); - const [isLoadingGhostText, setIsLoadingGhostText] = useState(false); - const abortControllerRef = useRef(null); - const [justSelectedSuggestion, setJustSelectedSuggestion] = - useState(false); - const lastSelectedTextRef = useRef(''); - const lastRequestedTextRef = useRef(''); - - const isPromptCompletionEnabled = - enabled && (config?.getEnablePromptCompletion() ?? false); - - const clearGhostText = useCallback(() => { - setGhostText(''); - setIsLoadingGhostText(false); - }, []); - - const acceptGhostText = useCallback(() => { - if (ghostText && ghostText.length > buffer.text.length) { - buffer.setText(ghostText); - setGhostText(''); - setJustSelectedSuggestion(true); - lastSelectedTextRef.current = ghostText; - } - }, [ghostText, buffer]); - - const markSuggestionSelected = useCallback((selectedText: string) => { - setJustSelectedSuggestion(true); - lastSelectedTextRef.current = selectedText; - }, []); - - const generatePromptSuggestions = useCallback(async () => { - const trimmedText = buffer.text.trim(); - const geminiClient = config?.getGeminiClient(); - - if (trimmedText === lastRequestedTextRef.current) { - return; - } - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - if ( - trimmedText.length < PROMPT_COMPLETION_MIN_LENGTH || - !geminiClient || - isSlashCommand(trimmedText) || - trimmedText.includes('@') || - !isPromptCompletionEnabled - ) { - clearGhostText(); - lastRequestedTextRef.current = ''; - return; - } - - lastRequestedTextRef.current = trimmedText; - setIsLoadingGhostText(true); - - abortControllerRef.current = new AbortController(); - const signal = abortControllerRef.current.signal; - - try { - const contents: Content[] = [ - { - role: 'user', - parts: [ - { - text: `You are a professional prompt engineering assistant. Complete the user's partial prompt with expert precision and clarity. User's input: "${trimmedText}" Continue this prompt by adding specific, actionable details that align with the user's intent. Focus on: clear, precise language; structured requirements; professional terminology; measurable outcomes. Length Guidelines: Keep suggestions concise (ideally 10-20 characters); prioritize brevity while maintaining clarity; use essential keywords only; avoid redundant phrases. Start your response with the exact user text ("${trimmedText}") followed by your completion. Provide practical, implementation-focused suggestions rather than creative interpretations. Format: Plain text only. Single completion. Match the user's language. Emphasize conciseness over elaboration.`, - }, - ], - }, - ]; - - const generationConfig: GenerateContentConfig = { - temperature: 0.3, - maxOutputTokens: 16000, - thinkingConfig: { - thinkingBudget: 0, - }, - }; - - const response = await geminiClient.generateContent( - contents, - generationConfig, - signal, - DEFAULT_GEMINI_FLASH_LITE_MODEL, - ); - - if (signal.aborted) { - return; - } - - if (response) { - const responseText = getResponseText(response); - - if (responseText) { - const suggestionText = responseText.trim(); - - if ( - suggestionText.length > 0 && - suggestionText.startsWith(trimmedText) - ) { - setGhostText(suggestionText); - } else { - clearGhostText(); - } - } - } - } catch (error) { - if ( - !( - signal.aborted || - (error instanceof Error && error.name === 'AbortError') - ) - ) { - console.error('prompt completion error:', error); - // Clear the last requested text to allow retry only on real errors - lastRequestedTextRef.current = ''; - } - clearGhostText(); - } finally { - if (!signal.aborted) { - setIsLoadingGhostText(false); - } - } - }, [buffer.text, config, clearGhostText, isPromptCompletionEnabled]); - - const isCursorAtEnd = useCallback(() => { - const [cursorRow, cursorCol] = buffer.cursor; - const totalLines = buffer.lines.length; - if (cursorRow !== totalLines - 1) { - return false; - } - - const lastLine = buffer.lines[cursorRow] || ''; - return cursorCol === lastLine.length; - }, [buffer.cursor, buffer.lines]); - - const handlePromptCompletion = useCallback(() => { - if (!isCursorAtEnd()) { - clearGhostText(); - return; - } - - const trimmedText = buffer.text.trim(); - - if (justSelectedSuggestion && trimmedText === lastSelectedTextRef.current) { - return; - } - - if (trimmedText !== lastSelectedTextRef.current) { - setJustSelectedSuggestion(false); - lastSelectedTextRef.current = ''; - } - - generatePromptSuggestions(); - }, [ - buffer.text, - generatePromptSuggestions, - justSelectedSuggestion, - isCursorAtEnd, - clearGhostText, - ]); - - // Debounce prompt completion - useEffect(() => { - const timeoutId = setTimeout( - handlePromptCompletion, - PROMPT_COMPLETION_DEBOUNCE_MS, - ); - return () => clearTimeout(timeoutId); - }, [buffer.text, buffer.cursor, handlePromptCompletion]); - - // Ghost text validation - clear if it doesn't match current text or cursor not at end - useEffect(() => { - const currentText = buffer.text.trim(); - - if (ghostText && !isCursorAtEnd()) { - clearGhostText(); - return; - } - - if ( - ghostText && - currentText.length > 0 && - !ghostText.startsWith(currentText) - ) { - clearGhostText(); - } - }, [buffer.text, buffer.cursor, ghostText, clearGhostText, isCursorAtEnd]); - - // Cleanup on unmount - useEffect(() => () => abortControllerRef.current?.abort(), []); - - const isActive = useMemo(() => { - if (!isPromptCompletionEnabled) return false; - - if (!isCursorAtEnd()) return false; - - const trimmedText = buffer.text.trim(); - return ( - trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && - !isSlashCommand(trimmedText) && - !trimmedText.includes('@') - ); - }, [buffer.text, isPromptCompletionEnabled, isCursorAtEnd]); - - return { - text: ghostText, - isLoading: isLoadingGhostText, - isActive, - accept: acceptGhostText, - clear: clearGhostText, - markSelected: markSuggestionSelected, - }; -} diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index 9c534538..467ef313 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -9,6 +9,7 @@ import { themeManager } from '../themes/theme-manager.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting import { type HistoryItem, MessageType } from '../types.js'; import process from 'node:process'; +import { t } from '../../i18n/index.js'; interface UseThemeCommandReturn { isThemeDialogOpen: boolean; @@ -34,7 +35,9 @@ export const useThemeCommand = ( addItem( { type: MessageType.INFO, - text: 'Theme configuration unavailable due to NO_COLOR env variable.', + text: t( + 'Theme configuration unavailable due to NO_COLOR env variable.', + ), }, Date.now(), ); @@ -48,7 +51,11 @@ export const useThemeCommand = ( if (!themeManager.setActiveTheme(themeName)) { // If theme is not found, open the theme selection dialog and set error message setIsThemeDialogOpen(true); - setThemeError(`Theme "${themeName}" not found.`); + setThemeError( + t('Theme "{{themeName}}" not found.', { + themeName: themeName ?? '', + }), + ); } else { setThemeError(null); // Clear any previous theme error on success } @@ -75,7 +82,11 @@ export const useThemeCommand = ( const isBuiltIn = themeManager.findThemeByName(themeName); const isCustom = themeName && mergedCustomThemes[themeName]; if (!isBuiltIn && !isCustom) { - setThemeError(`Theme "${themeName}" not found in selected scope.`); + setThemeError( + t('Theme "{{themeName}}" not found in selected scope.', { + themeName: themeName ?? '', + }), + ); setIsThemeDialogOpen(true); return; } diff --git a/packages/cli/src/ui/models/availableModels.ts b/packages/cli/src/ui/models/availableModels.ts index 312c9bdc..9a04101f 100644 --- a/packages/cli/src/ui/models/availableModels.ts +++ b/packages/cli/src/ui/models/availableModels.ts @@ -5,6 +5,7 @@ */ import { AuthType, DEFAULT_QWEN_MODEL } from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; export type AvailableModel = { id: string; @@ -20,14 +21,20 @@ export const AVAILABLE_MODELS_QWEN: AvailableModel[] = [ { id: MAINLINE_CODER, label: MAINLINE_CODER, - description: - 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + get description() { + return t( + 'The latest Qwen Coder model from Alibaba Cloud ModelStudio (version: qwen3-coder-plus-2025-09-23)', + ); + }, }, { id: MAINLINE_VLM, label: MAINLINE_VLM, - description: - 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', + get description() { + return t( + 'The latest Qwen Vision model from Alibaba Cloud ModelStudio (version: qwen3-vl-plus-2025-09-23)', + ); + }, isVision: true, }, ]; diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 67636711..f6d2380b 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { spawnAsync } from '@qwen-code/qwen-code-core'; +import { execCommand } from '@qwen-code/qwen-code-core'; /** * Checks if the system clipboard contains an image (macOS only for now) @@ -19,7 +19,7 @@ export async function clipboardHasImage(): Promise { try { // Use osascript to check clipboard type - const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']); + const { stdout } = await execCommand('osascript', ['-e', 'clipboard info']); const imageRegex = /ÂĢclass PNGfÂģ|TIFF picture|JPEG picture|GIF picture|ÂĢclass JPEGÂģ|ÂĢclass TIFFÂģ/; return imageRegex.test(stdout); @@ -80,7 +80,7 @@ export async function saveClipboardImage( end try `; - const { stdout } = await spawnAsync('osascript', ['-e', script]); + const { stdout } = await execCommand('osascript', ['-e', script]); if (stdout.trim() === 'success') { // Verify the file was created and has content diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index b48bb4c9..9d2fddd9 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -13,6 +13,7 @@ import { isSlashCommand, copyToClipboard, getUrlOpenCommand, + CodePage, } from './commandUtils.js'; // Mock child_process @@ -188,7 +189,10 @@ describe('commandUtils', () => { await copyToClipboard(testText); - expect(mockSpawn).toHaveBeenCalledWith('clip', []); + expect(mockSpawn).toHaveBeenCalledWith('cmd', [ + '/c', + `chcp ${CodePage.UTF8} >nul && clip`, + ]); expect(mockChild.stdin.write).toHaveBeenCalledWith(testText); expect(mockChild.stdin.end).toHaveBeenCalled(); }); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 32bebceb..89d1045a 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -7,6 +7,23 @@ import type { SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; +/** + * Common Windows console code pages (CP) used for encoding conversions. + * + * @remarks + * - `UTF8` (65001): Unicode (UTF-8) — recommended for cross-language scripts. + * - `GBK` (936): Simplified Chinese — default on most Chinese Windows systems. + * - `BIG5` (950): Traditional Chinese. + * - `LATIN1` (1252): Western European — default on many Western systems. + */ +export const CodePage = { + UTF8: 65001, + GBK: 936, + BIG5: 950, + LATIN1: 1252, +} as const; + +export type CodePage = (typeof CodePage)[keyof typeof CodePage]; /** * Checks if a query string potentially represents an '@' command. * It triggers if the query starts with '@' or contains '@' preceded by whitespace @@ -80,7 +97,7 @@ export const copyToClipboard = async (text: string): Promise => { switch (process.platform) { case 'win32': - return run('clip', []); + return run('cmd', ['/c', `chcp ${CodePage.UTF8} >nul && clip`]); case 'darwin': return run('pbcopy', []); case 'linux': diff --git a/packages/cli/src/ui/utils/terminalSetup.ts b/packages/cli/src/ui/utils/terminalSetup.ts index af5367f7..d7a7f41e 100644 --- a/packages/cli/src/ui/utils/terminalSetup.ts +++ b/packages/cli/src/ui/utils/terminalSetup.ts @@ -30,6 +30,7 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; import { isKittyProtocolEnabled } from './kittyProtocolDetector.js'; import { VSCODE_SHIFT_ENTER_SEQUENCE } from './platformConstants.js'; +import { t } from '../../i18n/index.js'; const execAsync = promisify(exec); @@ -146,7 +147,10 @@ async function configureVSCodeStyle( if (!configDir) { return { success: false, - message: `Could not determine ${terminalName} config path on Windows: APPDATA environment variable is not set.`, + message: t( + 'Could not determine {{terminalName}} config path on Windows: APPDATA environment variable is not set.', + { terminalName }, + ), }; } @@ -166,9 +170,12 @@ async function configureVSCodeStyle( return { success: false, message: - `${terminalName} keybindings.json exists but is not a valid JSON array. ` + - `Please fix the file manually or delete it to allow automatic configuration.\n` + - `File: ${keybindingsFile}`, + t( + '{{terminalName}} keybindings.json exists but is not a valid JSON array. Please fix the file manually or delete it to allow automatic configuration.', + { terminalName }, + ) + + '\n' + + t('File: {{file}}', { file: keybindingsFile }), }; } keybindings = parsedContent; @@ -176,10 +183,14 @@ async function configureVSCodeStyle( return { success: false, message: - `Failed to parse ${terminalName} keybindings.json. The file contains invalid JSON.\n` + - `Please fix the file manually or delete it to allow automatic configuration.\n` + - `File: ${keybindingsFile}\n` + - `Error: ${parseError}`, + t( + 'Failed to parse {{terminalName}} keybindings.json. The file contains invalid JSON. Please fix the file manually or delete it to allow automatic configuration.', + { terminalName }, + ) + + '\n' + + t('File: {{file}}', { file: keybindingsFile }) + + '\n' + + t('Error: {{error}}', { error: String(parseError) }), }; } } catch { @@ -214,18 +225,23 @@ async function configureVSCodeStyle( if (existingShiftEnter || existingCtrlEnter) { const messages: string[] = []; if (existingShiftEnter) { - messages.push(`- Shift+Enter binding already exists`); + messages.push('- ' + t('Shift+Enter binding already exists')); } if (existingCtrlEnter) { - messages.push(`- Ctrl+Enter binding already exists`); + messages.push('- ' + t('Ctrl+Enter binding already exists')); } return { success: false, message: - `Existing keybindings detected. Will not modify to avoid conflicts.\n` + + t( + 'Existing keybindings detected. Will not modify to avoid conflicts.', + ) + + '\n' + messages.join('\n') + '\n' + - `Please check and modify manually if needed: ${keybindingsFile}`, + t('Please check and modify manually if needed: {{file}}', { + file: keybindingsFile, + }), }; } @@ -263,19 +279,34 @@ async function configureVSCodeStyle( await fs.writeFile(keybindingsFile, JSON.stringify(keybindings, null, 4)); return { success: true, - message: `Added Shift+Enter and Ctrl+Enter keybindings to ${terminalName}.\nModified: ${keybindingsFile}`, + message: + t( + 'Added Shift+Enter and Ctrl+Enter keybindings to {{terminalName}}.', + { + terminalName, + }, + ) + + '\n' + + t('Modified: {{file}}', { file: keybindingsFile }), requiresRestart: true, }; } else { return { success: true, - message: `${terminalName} keybindings already configured.`, + message: t('{{terminalName}} keybindings already configured.', { + terminalName, + }), }; } } catch (error) { return { success: false, - message: `Failed to configure ${terminalName}.\nFile: ${keybindingsFile}\nError: ${error}`, + message: + t('Failed to configure {{terminalName}}.', { terminalName }) + + '\n' + + t('File: {{file}}', { file: keybindingsFile }) + + '\n' + + t('Error: {{error}}', { error: String(error) }), }; } } @@ -322,8 +353,9 @@ export async function terminalSetup(): Promise { if (isKittyProtocolEnabled()) { return { success: true, - message: + message: t( 'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).', + ), }; } @@ -332,8 +364,9 @@ export async function terminalSetup(): Promise { if (!terminal) { return { success: false, - message: - 'Could not detect terminal type. Supported terminals: VS Code, Cursor, and Windsurf.', + message: t( + 'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.', + ), }; } @@ -349,7 +382,9 @@ export async function terminalSetup(): Promise { default: return { success: false, - message: `Terminal "${terminal}" is not supported yet.`, + message: t('Terminal "{{terminal}}" is not supported yet.', { + terminal, + }), }; } } diff --git a/packages/cli/src/utils/settingsUtils.ts b/packages/cli/src/utils/settingsUtils.ts index a9a42937..dcc3f2b6 100644 --- a/packages/cli/src/utils/settingsUtils.ts +++ b/packages/cli/src/utils/settingsUtils.ts @@ -16,6 +16,7 @@ import type { SettingsValue, } from '../config/settingsSchema.js'; import { getSettingsSchema } from '../config/settingsSchema.js'; +import { t } from '../i18n/index.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 @@ -446,7 +447,11 @@ export function getDisplayValue( if (definition?.type === 'enum' && definition.options) { const option = definition.options?.find((option) => option.value === value); - valueString = option?.label ?? `${value}`; + if (option?.label) { + valueString = t(option.label) || option.label; + } else { + valueString = `${value}`; + } } // Check if value is different from default OR if it's in modified settings OR if there are pending changes diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index d4b959fb..66308ac2 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -5,6 +5,7 @@ */ import type { ExtendedSystemInfo } from './systemInfo.js'; +import { t } from '../i18n/index.js'; /** * Field configuration for system information display @@ -23,59 +24,59 @@ export function getSystemInfoFields( ): SystemInfoField[] { const allFields: SystemInfoField[] = [ { - label: 'CLI Version', + label: t('CLI Version'), key: 'cliVersion', }, { - label: 'Git Commit', + label: t('Git Commit'), key: 'gitCommit', }, { - label: 'Model', + label: t('Model'), key: 'modelVersion', }, { - label: 'Sandbox', + label: t('Sandbox'), key: 'sandboxEnv', }, { - label: 'OS Platform', + label: t('OS Platform'), key: 'osPlatform', }, { - label: 'OS Arch', + label: t('OS Arch'), key: 'osArch', }, { - label: 'OS Release', + label: t('OS Release'), key: 'osRelease', }, { - label: 'Node.js Version', + label: t('Node.js Version'), key: 'nodeVersion', }, { - label: 'NPM Version', + label: t('NPM Version'), key: 'npmVersion', }, { - label: 'Session ID', + label: t('Session ID'), key: 'sessionId', }, { - label: 'Auth Method', + label: t('Auth Method'), key: 'selectedAuthType', }, { - label: 'Base URL', + label: t('Base URL'), key: 'baseUrl', }, { - label: 'Memory Usage', + label: t('Memory Usage'), key: 'memoryUsage', }, { - label: 'IDE Client', + label: t('IDE Client'), key: 'ideClient', }, ]; diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts index 21932684..e2e7c440 100644 --- a/packages/cli/src/utils/userStartupWarnings.ts +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -67,11 +67,15 @@ const ripgrepAvailabilityCheck: WarningCheck = { return null; } - const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep); - if (!isAvailable) { - return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.'; + try { + const isAvailable = await canUseRipgrep(options.useBuiltinRipgrep); + if (!isAvailable) { + return 'Ripgrep not available: Please install ripgrep globally to enable faster file content search. Falling back to built-in grep.'; + } + return null; + } catch (error) { + return `Ripgrep not available: ${error instanceof Error ? error.message : 'Unknown error'}. Falling back to built-in grep.`; } - return null; }, }; diff --git a/packages/core/package.json b/packages/core/package.json index 3232a664..42bca596 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.2.2", + "version": "0.3.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index 15ef951b..ea897db2 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -1085,7 +1085,7 @@ describe('setApprovalMode with folder trust', () => { expect.any(RipgrepFallbackEvent), ); const event = (logRipgrepFallback as Mock).mock.calls[0][1]; - expect(event.error).toContain('Ripgrep is not available'); + expect(event.error).toContain('ripgrep is not available'); }); it('should fall back to GrepTool and log error when useRipgrep is true and builtin ripgrep is not available', async () => { @@ -1109,7 +1109,7 @@ describe('setApprovalMode with folder trust', () => { expect.any(RipgrepFallbackEvent), ); const event = (logRipgrepFallback as Mock).mock.calls[0][1]; - expect(event.error).toContain('Ripgrep is not available'); + expect(event.error).toContain('ripgrep is not available'); }); it('should fall back to GrepTool and log error when canUseRipgrep throws an error', async () => { @@ -1133,7 +1133,7 @@ describe('setApprovalMode with folder trust', () => { expect.any(RipgrepFallbackEvent), ); const event = (logRipgrepFallback as Mock).mock.calls[0][1]; - expect(event.error).toBe(String(error)); + expect(event.error).toBe(`ripGrep check failed`); }); it('should register GrepTool when useRipgrep is false', async () => { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 2d580eaf..147b9abc 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -82,6 +82,7 @@ import { shouldAttemptBrowserLaunch } from '../utils/browser.js'; import { FileExclusions } from '../utils/ignorePatterns.js'; import { WorkspaceContext } from '../utils/workspaceContext.js'; import { isToolEnabled, type ToolName } from '../utils/tool-utils.js'; +import { getErrorMessage } from '../utils/errors.js'; // Local config modules import type { FileFilteringOptions } from './constants.js'; @@ -281,7 +282,6 @@ export interface ConfigParameters { skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; - enablePromptCompletion?: boolean; skipLoopDetection?: boolean; vlmSwitchMode?: string; truncateToolOutputThreshold?: number; @@ -293,6 +293,27 @@ export interface ConfigParameters { inputFormat?: InputFormat; outputFormat?: OutputFormat; skipStartupContext?: boolean; + inputFormat?: InputFormat; + outputFormat?: OutputFormat; +} + +function normalizeConfigOutputFormat( + format: OutputFormat | undefined, +): OutputFormat | undefined { + if (!format) { + return undefined; + } + switch (format) { + case 'stream-json': + return OutputFormat.STREAM_JSON; + case 'json': + case OutputFormat.JSON: + return OutputFormat.JSON; + case 'text': + case OutputFormat.TEXT: + default: + return OutputFormat.TEXT; + } } function normalizeConfigOutputFormat( @@ -402,7 +423,6 @@ export class Config { private readonly skipNextSpeakerCheck: boolean; private shellExecutionConfig: ShellExecutionConfig; private readonly extensionManagement: boolean = true; - private readonly enablePromptCompletion: boolean = false; private readonly skipLoopDetection: boolean; private readonly skipStartupContext: boolean; private readonly vlmSwitchMode: string | undefined; @@ -525,7 +545,6 @@ export class Config { this.useSmartEdit = params.useSmartEdit ?? false; this.extensionManagement = params.extensionManagement ?? true; this.storage = new Storage(this.targetDir); - this.enablePromptCompletion = params.enablePromptCompletion ?? false; this.vlmSwitchMode = params.vlmSwitchMode; this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); @@ -1073,10 +1092,6 @@ export class Config { return this.accessibility.screenReader ?? false; } - getEnablePromptCompletion(): boolean { - return this.enablePromptCompletion; - } - getSkipLoopDetection(): boolean { return this.skipLoopDetection; } @@ -1187,17 +1202,20 @@ export class Config { try { useRipgrep = await canUseRipgrep(this.getUseBuiltinRipgrep()); } catch (error: unknown) { - errorString = String(error); + errorString = getErrorMessage(error); } if (useRipgrep) { registerCoreTool(RipGrepTool, this); } else { - errorString = - errorString || - 'Ripgrep is not available. Please install ripgrep globally.'; - // Log for telemetry - logRipgrepFallback(this, new RipgrepFallbackEvent(errorString)); + logRipgrepFallback( + this, + new RipgrepFallbackEvent( + this.getUseRipgrep(), + this.getUseBuiltinRipgrep(), + errorString || 'ripgrep is not available', + ), + ); registerCoreTool(GrepTool, this); } } else { diff --git a/packages/core/src/qwen/qwenOAuth2.test.ts b/packages/core/src/qwen/qwenOAuth2.test.ts index 2e8bf83e..23c26296 100644 --- a/packages/core/src/qwen/qwenOAuth2.test.ts +++ b/packages/core/src/qwen/qwenOAuth2.test.ts @@ -623,14 +623,16 @@ describe('QwenOAuth2Client', () => { }); it('should handle authorization_pending with HTTP 400 according to RFC 8628', async () => { + const errorData = { + error: 'authorization_pending', + error_description: 'The authorization request is still pending', + }; const mockResponse = { ok: false, status: 400, statusText: 'Bad Request', - json: async () => ({ - error: 'authorization_pending', - error_description: 'The authorization request is still pending', - }), + text: async () => JSON.stringify(errorData), + json: async () => errorData, }; vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); @@ -646,14 +648,16 @@ describe('QwenOAuth2Client', () => { }); it('should handle slow_down with HTTP 429 according to RFC 8628', async () => { + const errorData = { + error: 'slow_down', + error_description: 'The client is polling too frequently', + }; const mockResponse = { ok: false, status: 429, statusText: 'Too Many Requests', - json: async () => ({ - error: 'slow_down', - error_description: 'The client is polling too frequently', - }), + text: async () => JSON.stringify(errorData), + json: async () => errorData, }; vi.mocked(global.fetch).mockResolvedValue(mockResponse as Response); @@ -1993,14 +1997,16 @@ describe('Enhanced Error Handling and Edge Cases', () => { }); it('should handle authorization_pending with correct status', async () => { + const errorData = { + error: 'authorization_pending', + error_description: 'Authorization request is pending', + }; const mockResponse = { ok: false, status: 400, statusText: 'Bad Request', - json: vi.fn().mockResolvedValue({ - error: 'authorization_pending', - error_description: 'Authorization request is pending', - }), + text: vi.fn().mockResolvedValue(JSON.stringify(errorData)), + json: vi.fn().mockResolvedValue(errorData), }; vi.mocked(global.fetch).mockResolvedValue( diff --git a/packages/core/src/qwen/qwenOAuth2.ts b/packages/core/src/qwen/qwenOAuth2.ts index b9a35bff..c4cfa933 100644 --- a/packages/core/src/qwen/qwenOAuth2.ts +++ b/packages/core/src/qwen/qwenOAuth2.ts @@ -345,44 +345,47 @@ export class QwenOAuth2Client implements IQwenOAuth2Client { }); if (!response.ok) { - // Parse the response as JSON to check for OAuth RFC 8628 standard errors + // Read response body as text first (can only be read once) + const responseText = await response.text(); + + // Try to parse as JSON to check for OAuth RFC 8628 standard errors + let errorData: ErrorData | null = null; try { - const errorData = (await response.json()) as ErrorData; - - // According to OAuth RFC 8628, handle standard polling responses - if ( - response.status === 400 && - errorData.error === 'authorization_pending' - ) { - // User has not yet approved the authorization request. Continue polling. - return { status: 'pending' } as DeviceTokenPendingData; - } - - if (response.status === 429 && errorData.error === 'slow_down') { - // Client is polling too frequently. Return pending with slowDown flag. - return { - status: 'pending', - slowDown: true, - } as DeviceTokenPendingData; - } - - // Handle other 400 errors (access_denied, expired_token, etc.) as real errors - - // For other errors, throw with proper error information - const error = new Error( - `Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}`, - ); - (error as Error & { status?: number }).status = response.status; - throw error; + errorData = JSON.parse(responseText) as ErrorData; } catch (_parseError) { - // If JSON parsing fails, fall back to text response - const errorData = await response.text(); + // If JSON parsing fails, use text response const error = new Error( - `Device token poll failed: ${response.status} ${response.statusText}. Response: ${errorData}`, + `Device token poll failed: ${response.status} ${response.statusText}. Response: ${responseText}`, ); (error as Error & { status?: number }).status = response.status; throw error; } + + // According to OAuth RFC 8628, handle standard polling responses + if ( + response.status === 400 && + errorData.error === 'authorization_pending' + ) { + // User has not yet approved the authorization request. Continue polling. + return { status: 'pending' } as DeviceTokenPendingData; + } + + if (response.status === 429 && errorData.error === 'slow_down') { + // Client is polling too frequently. Return pending with slowDown flag. + return { + status: 'pending', + slowDown: true, + } as DeviceTokenPendingData; + } + + // Handle other 400 errors (access_denied, expired_token, etc.) as real errors + + // For other errors, throw with proper error information + const error = new Error( + `Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description}`, + ); + (error as Error & { status?: number }).status = response.status; + throw error; } return (await response.json()) as DeviceTokenResponse; diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts index f2c08831..2442bf56 100644 --- a/packages/core/src/services/gitService.test.ts +++ b/packages/core/src/services/gitService.test.ts @@ -19,10 +19,10 @@ import * as path from 'node:path'; import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import { getProjectHash, QWEN_DIR } from '../utils/paths.js'; -import { spawnAsync } from '../utils/shell-utils.js'; +import { isCommandAvailable } from '../utils/shell-utils.js'; vi.mock('../utils/shell-utils.js', () => ({ - spawnAsync: vi.fn(), + isCommandAvailable: vi.fn(), })); const hoistedMockEnv = vi.hoisted(() => vi.fn()); @@ -76,10 +76,7 @@ describe('GitService', () => { vi.clearAllMocks(); hoistedIsGitRepositoryMock.mockReturnValue(true); - (spawnAsync as Mock).mockResolvedValue({ - stdout: 'git version 2.0.0', - stderr: '', - }); + (isCommandAvailable as Mock).mockReturnValue({ available: true }); hoistedMockHomedir.mockReturnValue(homedir); @@ -119,23 +116,9 @@ describe('GitService', () => { }); }); - describe('verifyGitAvailability', () => { - it('should resolve true if git --version command succeeds', async () => { - const service = new GitService(projectRoot, storage); - await expect(service.verifyGitAvailability()).resolves.toBe(true); - expect(spawnAsync).toHaveBeenCalledWith('git', ['--version']); - }); - - it('should resolve false if git --version command fails', async () => { - (spawnAsync as Mock).mockRejectedValue(new Error('git not found')); - const service = new GitService(projectRoot, storage); - await expect(service.verifyGitAvailability()).resolves.toBe(false); - }); - }); - describe('initialize', () => { it('should throw an error if Git is not available', async () => { - (spawnAsync as Mock).mockRejectedValue(new Error('git not found')); + (isCommandAvailable as Mock).mockReturnValue({ available: false }); const service = new GitService(projectRoot, storage); await expect(service.initialize()).rejects.toThrow( 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', diff --git a/packages/core/src/services/gitService.ts b/packages/core/src/services/gitService.ts index 8d087564..52700bda 100644 --- a/packages/core/src/services/gitService.ts +++ b/packages/core/src/services/gitService.ts @@ -6,7 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { spawnAsync } from '../utils/shell-utils.js'; +import { isCommandAvailable } from '../utils/shell-utils.js'; import type { SimpleGit } from 'simple-git'; import { simpleGit, CheckRepoActions } from 'simple-git'; import type { Storage } from '../config/storage.js'; @@ -26,7 +26,7 @@ export class GitService { } async initialize(): Promise { - const gitAvailable = await this.verifyGitAvailability(); + const { available: gitAvailable } = isCommandAvailable('git'); if (!gitAvailable) { throw new Error( 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', @@ -41,15 +41,6 @@ export class GitService { } } - async verifyGitAvailability(): Promise { - try { - await spawnAsync('git', ['--version']); - return true; - } catch (_error) { - return false; - } - } - /** * Creates a hidden git repository in the project root. * The Git repository is used to support checkpointing. diff --git a/packages/core/src/telemetry/loggers.test.ts b/packages/core/src/telemetry/loggers.test.ts index a11ea7f2..324a4f2d 100644 --- a/packages/core/src/telemetry/loggers.test.ts +++ b/packages/core/src/telemetry/loggers.test.ts @@ -447,7 +447,11 @@ describe('loggers', () => { }); it('should log ripgrep fallback event', () => { - const event = new RipgrepFallbackEvent(); + const event = new RipgrepFallbackEvent( + false, + false, + 'ripgrep is not available', + ); logRipgrepFallback(mockConfig, event); @@ -460,13 +464,13 @@ describe('loggers', () => { 'session.id': 'test-session-id', 'user.email': 'test-user@example.com', 'event.name': EVENT_RIPGREP_FALLBACK, - error: undefined, + error: 'ripgrep is not available', }), ); }); it('should log ripgrep fallback event with an error', () => { - const event = new RipgrepFallbackEvent('rg not found'); + const event = new RipgrepFallbackEvent(false, false, 'rg not found'); logRipgrepFallback(mockConfig, event); diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 5b56719b..efd5af06 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -314,7 +314,7 @@ export function logRipgrepFallback( config: Config, event: RipgrepFallbackEvent, ): void { - QwenLogger.getInstance(config)?.logRipgrepFallbackEvent(); + QwenLogger.getInstance(config)?.logRipgrepFallbackEvent(event); if (!isTelemetrySdkInitialized()) return; const attributes: LogAttributes = { diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts index 9dbaa4f9..2150ad95 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.test.ts @@ -286,9 +286,9 @@ describe('QwenLogger', () => { event_type: 'action', type: 'ide', name: 'ide_connection', - snapshots: JSON.stringify({ + properties: { connection_type: IdeConnectionType.SESSION, - }), + }, }), ); }); @@ -307,8 +307,10 @@ describe('QwenLogger', () => { type: 'overflow', name: 'kitty_sequence_overflow', subtype: 'kitty_sequence_overflow', - snapshots: JSON.stringify({ + properties: { sequence_length: 1024, + }, + snapshots: JSON.stringify({ truncated_sequence: 'truncated...', }), }), diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index c5dc70d7..3d286b02 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -38,6 +38,7 @@ import type { ModelSlashCommandEvent, ExtensionDisableEvent, AuthEvent, + RipgrepFallbackEvent, } from '../types.js'; import { EndSessionEvent } from '../types.js'; import type { @@ -258,7 +259,7 @@ export class QwenLogger { : '', }, _v: `qwen-code@${version}`, - }; + } as RumPayload; } flushIfNeeded(): void { @@ -367,12 +368,10 @@ export class QwenLogger { const applicationEvent = this.createViewEvent('session', 'session_start', { properties: { model: event.model, - }, - snapshots: JSON.stringify({ + approval_mode: event.approval_mode, embedding_model: event.embedding_model, sandbox_enabled: event.sandbox_enabled, core_tools_enabled: event.core_tools_enabled, - approval_mode: event.approval_mode, api_key_enabled: event.api_key_enabled, vertex_ai_enabled: event.vertex_ai_enabled, debug_enabled: event.debug_enabled, @@ -380,7 +379,7 @@ export class QwenLogger { telemetry_enabled: event.telemetry_enabled, telemetry_log_user_prompts_enabled: event.telemetry_log_user_prompts_enabled, - }), + }, }); // Flush start event immediately @@ -409,10 +408,10 @@ export class QwenLogger { 'conversation', 'conversation_finished', { - snapshots: JSON.stringify({ + properties: { approval_mode: event.approvalMode, turn_count: event.turnCount, - }), + }, }, ); @@ -426,10 +425,8 @@ export class QwenLogger { properties: { auth_type: event.auth_type, prompt_id: event.prompt_id, - }, - snapshots: JSON.stringify({ prompt_length: event.prompt_length, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -438,10 +435,10 @@ export class QwenLogger { logSlashCommandEvent(event: SlashCommandEvent): void { const rumEvent = this.createActionEvent('user', 'slash_command', { - snapshots: JSON.stringify({ + properties: { command: event.command, subcommand: event.subcommand, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -450,9 +447,9 @@ export class QwenLogger { logModelSlashCommandEvent(event: ModelSlashCommandEvent): void { const rumEvent = this.createActionEvent('user', 'model_slash_command', { - snapshots: JSON.stringify({ - model_name: event.model_name, - }), + properties: { + model: event.model_name, + }, }); this.enqueueLogEvent(rumEvent); @@ -468,15 +465,13 @@ export class QwenLogger { properties: { prompt_id: event.prompt_id, response_id: event.response_id, - }, - snapshots: JSON.stringify({ - function_name: event.function_name, - decision: event.decision, - success: event.success, + tool_name: event.function_name, + permission: event.decision, + success: event.success ? 1 : 0, duration_ms: event.duration_ms, - error: event.error, error_type: event.error_type, - }), + error_message: event.error, + }, }, ); @@ -489,14 +484,14 @@ export class QwenLogger { 'tool', `file_operation#${event.tool_name}`, { - snapshots: JSON.stringify({ + properties: { tool_name: event.tool_name, operation: event.operation, lines: event.lines, mimetype: event.mimetype, extension: event.extension, programming_language: event.programming_language, - }), + }, }, ); @@ -506,11 +501,15 @@ export class QwenLogger { logSubagentExecutionEvent(event: SubagentExecutionEvent): void { const rumEvent = this.createActionEvent('tool', 'subagent_execution', { - snapshots: JSON.stringify({ + properties: { subagent_name: event.subagent_name, status: event.status, terminate_reason: event.terminate_reason, - execution_summary: event.execution_summary, + }, + snapshots: JSON.stringify({ + ...(event.execution_summary + ? { execution_summary: event.execution_summary } + : {}), }), }); @@ -520,8 +519,10 @@ export class QwenLogger { logToolOutputTruncatedEvent(event: ToolOutputTruncatedEvent): void { const rumEvent = this.createActionEvent('tool', 'tool_output_truncated', { - snapshots: JSON.stringify({ + properties: { tool_name: event.tool_name, + }, + snapshots: JSON.stringify({ original_content_length: event.original_content_length, truncated_content_length: event.truncated_content_length, threshold: event.threshold, @@ -594,10 +595,8 @@ export class QwenLogger { auth_type: event.auth_type, model: event.model, prompt_id: event.prompt_id, - }, - snapshots: JSON.stringify({ error_type: event.error_type, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -622,11 +621,11 @@ export class QwenLogger { { subtype: 'content_retry_failure', message: `Content retry failed after ${event.total_attempts} attempts`, - snapshots: JSON.stringify({ + properties: { + error_type: event.final_error_type, total_attempts: event.total_attempts, - final_error_type: event.final_error_type, total_duration_ms: event.total_duration_ms, - }), + }, }, ); @@ -655,10 +654,8 @@ export class QwenLogger { subtype: 'loop_detected', properties: { prompt_id: event.prompt_id, + error_type: event.loop_type, }, - snapshots: JSON.stringify({ - loop_type: event.loop_type, - }), }); this.enqueueLogEvent(rumEvent); @@ -671,8 +668,10 @@ export class QwenLogger { 'kitty_sequence_overflow', { subtype: 'kitty_sequence_overflow', - snapshots: JSON.stringify({ + properties: { sequence_length: event.sequence_length, + }, + snapshots: JSON.stringify({ truncated_sequence: event.truncated_sequence, }), }, @@ -685,7 +684,9 @@ export class QwenLogger { // ide events logIdeConnectionEvent(event: IdeConnectionEvent): void { const rumEvent = this.createActionEvent('ide', 'ide_connection', { - snapshots: JSON.stringify({ connection_type: event.connection_type }), + properties: { + connection_type: event.connection_type, + }, }); this.enqueueLogEvent(rumEvent); @@ -695,12 +696,12 @@ export class QwenLogger { // extension events logExtensionInstallEvent(event: ExtensionInstallEvent): void { const rumEvent = this.createActionEvent('extension', 'extension_install', { - snapshots: JSON.stringify({ + properties: { extension_name: event.extension_name, extension_version: event.extension_version, extension_source: event.extension_source, status: event.status, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -712,10 +713,10 @@ export class QwenLogger { 'extension', 'extension_uninstall', { - snapshots: JSON.stringify({ + properties: { extension_name: event.extension_name, status: event.status, - }), + }, }, ); @@ -725,10 +726,10 @@ export class QwenLogger { logExtensionEnableEvent(event: ExtensionEnableEvent): void { const rumEvent = this.createActionEvent('extension', 'extension_enable', { - snapshots: JSON.stringify({ + properties: { extension_name: event.extension_name, setting_scope: event.setting_scope, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -737,10 +738,10 @@ export class QwenLogger { logExtensionDisableEvent(event: ExtensionDisableEvent): void { const rumEvent = this.createActionEvent('extension', 'extension_disable', { - snapshots: JSON.stringify({ + properties: { extension_name: event.extension_name, setting_scope: event.setting_scope, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -748,18 +749,15 @@ export class QwenLogger { } logAuthEvent(event: AuthEvent): void { - const snapshots: Record = { - auth_type: event.auth_type, - action_type: event.action_type, - status: event.status, - }; - - if (event.error_message) { - snapshots['error_message'] = event.error_message; - } - const rumEvent = this.createActionEvent('auth', 'auth', { - snapshots: JSON.stringify(snapshots), + properties: { + auth_type: event.auth_type, + action_type: event.action_type, + success: event.status === 'success' ? 1 : 0, + error_type: event.status !== 'success' ? event.status : undefined, + error_message: + event.status === 'error' ? event.error_message : undefined, + }, }); this.enqueueLogEvent(rumEvent); @@ -778,8 +776,16 @@ export class QwenLogger { this.flushIfNeeded(); } - logRipgrepFallbackEvent(): void { - const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', {}); + logRipgrepFallbackEvent(event: RipgrepFallbackEvent): void { + const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', { + properties: { + platform: process.platform, + arch: process.arch, + use_ripgrep: event.use_ripgrep, + use_builtin_ripgrep: event.use_builtin_ripgrep, + error_message: event.error, + }, + }); this.enqueueLogEvent(rumEvent); this.flushIfNeeded(); @@ -800,11 +806,9 @@ export class QwenLogger { const rumEvent = this.createActionEvent('misc', 'next_speaker_check', { properties: { prompt_id: event.prompt_id, - }, - snapshots: JSON.stringify({ finish_reason: event.finish_reason, result: event.result, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -813,10 +817,10 @@ export class QwenLogger { logChatCompressionEvent(event: ChatCompressionEvent): void { const rumEvent = this.createActionEvent('misc', 'chat_compression', { - snapshots: JSON.stringify({ + properties: { tokens_before: event.tokens_before, tokens_after: event.tokens_after, - }), + }, }); this.enqueueLogEvent(rumEvent); @@ -825,11 +829,11 @@ export class QwenLogger { logContentRetryEvent(event: ContentRetryEvent): void { const rumEvent = this.createActionEvent('misc', 'content_retry', { - snapshots: JSON.stringify({ - attempt_number: event.attempt_number, + properties: { error_type: event.error_type, + attempt_number: event.attempt_number, retry_delay_ms: event.retry_delay_ms, - }), + }, }); this.enqueueLogEvent(rumEvent); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index cef83323..8d21f634 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -318,10 +318,20 @@ export class FlashFallbackEvent implements BaseTelemetryEvent { export class RipgrepFallbackEvent implements BaseTelemetryEvent { 'event.name': 'ripgrep_fallback'; 'event.timestamp': string; + use_ripgrep: boolean; + use_builtin_ripgrep: boolean; + error?: string; - constructor(public error?: string) { + constructor( + use_ripgrep: boolean, + use_builtin_ripgrep: boolean, + error?: string, + ) { this['event.name'] = 'ripgrep_fallback'; this['event.timestamp'] = new Date().toISOString(); + this.use_ripgrep = use_ripgrep; + this.use_builtin_ripgrep = use_builtin_ripgrep; + this.error = error; } } diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index d613ff03..5a07dcad 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -18,19 +18,68 @@ import * as glob from 'glob'; vi.mock('glob', { spy: true }); // Mock the child_process module to control grep/git grep behavior -vi.mock('child_process', () => ({ - spawn: vi.fn(() => ({ - on: (event: string, cb: (...args: unknown[]) => void) => { - if (event === 'error' || event === 'close') { - // Simulate command not found or error for git grep and system grep - // to force it to fall back to JS implementation. - setTimeout(() => cb(1), 0); // cb(1) for error/close - } - }, - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - })), -})); +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: vi.fn(() => { + // Create a proper mock EventEmitter-like child process + const listeners: Map< + string, + Set<(...args: unknown[]) => void> + > = new Map(); + + const createStream = () => ({ + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + const key = `stream:${event}`; + if (!listeners.has(key)) listeners.set(key, new Set()); + listeners.get(key)!.add(cb); + }), + removeListener: vi.fn( + (event: string, cb: (...args: unknown[]) => void) => { + const key = `stream:${event}`; + listeners.get(key)?.delete(cb); + }, + ), + }); + + return { + on: vi.fn((event: string, cb: (...args: unknown[]) => void) => { + const key = `child:${event}`; + if (!listeners.has(key)) listeners.set(key, new Set()); + listeners.get(key)!.add(cb); + + // Simulate command not found or error for git grep and system grep + // to force it to fall back to JS implementation. + if (event === 'error') { + setTimeout(() => cb(new Error('Command not found')), 0); + } else if (event === 'close') { + setTimeout(() => cb(1), 0); // Exit code 1 for error + } + }), + removeListener: vi.fn( + (event: string, cb: (...args: unknown[]) => void) => { + const key = `child:${event}`; + listeners.get(key)?.delete(cb); + }, + ), + stdout: createStream(), + stderr: createStream(), + connected: false, + disconnect: vi.fn(), + }; + }), + exec: vi.fn( + ( + cmd: string, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + // Mock exec to fail for git grep commands + callback(new Error('Command not found'), '', ''); + }, + ), + }; +}); describe('GrepTool', () => { let tempRootDir: string; diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index df410f0c..934ab57b 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -18,6 +18,7 @@ import { isGitRepository } from '../utils/gitUtils.js'; import type { Config } from '../config/config.js'; import type { FileExclusions } from '../utils/ignorePatterns.js'; import { ToolErrorType } from './tool-error.js'; +import { isCommandAvailable } from '../utils/shell-utils.js'; // --- Interfaces --- @@ -195,29 +196,6 @@ class GrepToolInvocation extends BaseToolInvocation< } } - /** - * Checks if a command is available in the system's PATH. - * @param {string} command The command name (e.g., 'git', 'grep'). - * @returns {Promise} True if the command is available, false otherwise. - */ - private isCommandAvailable(command: string): Promise { - return new Promise((resolve) => { - const checkCommand = process.platform === 'win32' ? 'where' : 'command'; - const checkArgs = - process.platform === 'win32' ? [command] : ['-v', command]; - try { - const child = spawn(checkCommand, checkArgs, { - stdio: 'ignore', - shell: process.platform === 'win32', - }); - child.on('close', (code) => resolve(code === 0)); - child.on('error', () => resolve(false)); - } catch { - resolve(false); - } - }); - } - /** * Parses the standard output of grep-like commands (git grep, system grep). * Expects format: filePath:lineNumber:lineContent @@ -297,7 +275,7 @@ class GrepToolInvocation extends BaseToolInvocation< try { // --- Strategy 1: git grep --- const isGit = isGitRepository(absolutePath); - const gitAvailable = isGit && (await this.isCommandAvailable('git')); + const gitAvailable = isGit && isCommandAvailable('git').available; if (gitAvailable) { strategyUsed = 'git grep'; @@ -350,7 +328,7 @@ class GrepToolInvocation extends BaseToolInvocation< } // --- Strategy 2: System grep --- - const grepAvailable = await this.isCommandAvailable('grep'); + const { available: grepAvailable } = isCommandAvailable('grep'); if (grepAvailable) { strategyUsed = 'system grep'; const grepArgs = ['-r', '-n', '-H', '-E']; diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 1b7dfe2d..05730a7e 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -20,14 +20,13 @@ import fs from 'node:fs/promises'; import os, { EOL } from 'node:os'; import type { Config } from '../config/config.js'; import { createMockWorkspaceContext } from '../test-utils/mockWorkspaceContext.js'; -import type { ChildProcess } from 'node:child_process'; import { spawn } from 'node:child_process'; -import { getRipgrepCommand } from '../utils/ripgrepUtils.js'; +import { runRipgrep } from '../utils/ripgrepUtils.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; // Mock ripgrepUtils vi.mock('../utils/ripgrepUtils.js', () => ({ - getRipgrepCommand: vi.fn(), + runRipgrep: vi.fn(), })); // Mock child_process for ripgrep calls @@ -37,60 +36,6 @@ vi.mock('child_process', () => ({ const mockSpawn = vi.mocked(spawn); -// Helper function to create mock spawn implementations -function createMockSpawn( - options: { - outputData?: string; - exitCode?: number; - signal?: string; - onCall?: ( - command: string, - args: readonly string[], - spawnOptions?: unknown, - ) => void; - } = {}, -) { - const { outputData, exitCode = 0, signal, onCall } = options; - - return (command: string, args: readonly string[], spawnOptions?: unknown) => { - onCall?.(command, args, spawnOptions); - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - // Set up event listeners immediately - setTimeout(() => { - const stdoutDataHandler = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - - const closeHandler = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (stdoutDataHandler && outputData) { - stdoutDataHandler(Buffer.from(outputData)); - } - - if (closeHandler) { - closeHandler(exitCode, signal); - } - }, 0); - - return mockProcess as unknown as ChildProcess; - }; -} - describe('RipGrepTool', () => { let tempRootDir: string; let grepTool: RipGrepTool; @@ -109,7 +54,6 @@ describe('RipGrepTool', () => { beforeEach(async () => { vi.clearAllMocks(); - (getRipgrepCommand as Mock).mockResolvedValue('/mock/path/to/rg'); mockSpawn.mockReset(); tempRootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'grep-tool-root-')); fileExclusionsMock = { @@ -200,12 +144,11 @@ describe('RipGrepTool', () => { describe('execute', () => { it('should find matches for a simple pattern in all files', async () => { - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`, - exitCode: 0, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}sub/fileC.txt:1:another world in sub dir${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'world' }; const invocation = grepTool.build(params); @@ -223,12 +166,11 @@ describe('RipGrepTool', () => { it('should find matches in a specific path', async () => { // Setup specific mock for this test - searching in 'sub' should only return matches from that directory - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `fileC.txt:1:another world in sub dir${EOL}`, - exitCode: 0, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileC.txt:1:another world in sub dir${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'world', path: 'sub' }; const invocation = grepTool.build(params); @@ -243,16 +185,11 @@ describe('RipGrepTool', () => { }); it('should use target directory when path is not provided', async () => { - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `fileA.txt:1:hello world${EOL}`, - exitCode: 0, - onCall: (_, args) => { - // Should search in the target directory (tempRootDir) - expect(args[args.length - 1]).toBe(tempRootDir); - }, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileA.txt:1:hello world${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'world' }; const invocation = grepTool.build(params); @@ -264,12 +201,11 @@ describe('RipGrepTool', () => { it('should find matches with a glob filter', async () => { // Setup specific mock for this test - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `fileB.js:2:function baz() { return "hello"; }${EOL}`, - exitCode: 0, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileB.js:2:function baz() { return "hello"; }${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'hello', glob: '*.js' }; const invocation = grepTool.build(params); @@ -290,39 +226,10 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'hello' in 'sub' with '*.js' filter - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - // Only return match from the .js file in sub directory - onData(Buffer.from(`another.js:1:const greeting = "hello";${EOL}`)); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `another.js:1:const greeting = "hello";${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { @@ -346,15 +253,11 @@ describe('RipGrepTool', () => { path.join(tempRootDir, '.qwenignore'), 'ignored.txt\n', ); - mockSpawn.mockImplementationOnce( - createMockSpawn({ - exitCode: 1, - onCall: (_, args) => { - expect(args).toContain('--ignore-file'); - expect(args).toContain(path.join(tempRootDir, '.qwenignore')); - }, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: '', + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'secret' }; const invocation = grepTool.build(params); @@ -375,16 +278,11 @@ describe('RipGrepTool', () => { }), }); - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `kept.txt:1:keep me${EOL}`, - exitCode: 0, - onCall: (_, args) => { - expect(args).not.toContain('--ignore-file'); - expect(args).not.toContain(path.join(tempRootDir, '.qwenignore')); - }, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `kept.txt:1:keep me${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'keep' }; const invocation = grepTool.build(params); @@ -404,14 +302,11 @@ describe('RipGrepTool', () => { }), }); - mockSpawn.mockImplementationOnce( - createMockSpawn({ - exitCode: 1, - onCall: (_, args) => { - expect(args).toContain('--no-ignore-vcs'); - }, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: '', + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'ignored' }; const invocation = grepTool.build(params); @@ -421,12 +316,11 @@ describe('RipGrepTool', () => { it('should truncate llm content when exceeding maximum length', async () => { const longMatch = 'fileA.txt:1:' + 'a'.repeat(30_000); - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `${longMatch}${EOL}`, - exitCode: 0, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `${longMatch}${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'a+' }; const invocation = grepTool.build(params); @@ -439,11 +333,11 @@ describe('RipGrepTool', () => { it('should return "No matches found" when pattern does not exist', async () => { // Setup specific mock for no matches - mockSpawn.mockImplementationOnce( - createMockSpawn({ - exitCode: 1, // No matches found - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: '', + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'nonexistentpattern' }; const invocation = grepTool.build(params); @@ -463,39 +357,10 @@ describe('RipGrepTool', () => { it('should handle regex special characters correctly', async () => { // Setup specific mock for this test - regex pattern 'foo.*bar' should match 'const foo = "bar";' - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - // Return match for the regex pattern - onData(Buffer.from(`fileB.js:1:const foo = "bar";${EOL}`)); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileB.js:1:const foo = "bar";${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'foo.*bar' }; // Matches 'const foo = "bar";' @@ -509,43 +374,10 @@ describe('RipGrepTool', () => { it('should be case-insensitive by default (JS fallback)', async () => { // Setup specific mock for this test - case insensitive search for 'HELLO' - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - // Return case-insensitive matches for 'HELLO' - onData( - Buffer.from( - `fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`, - ), - ); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileA.txt:1:hello world${EOL}fileB.js:2:function baz() { return "hello"; }${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'HELLO' }; @@ -568,12 +400,11 @@ describe('RipGrepTool', () => { }); it('should search within a single file when path is a file', async () => { - mockSpawn.mockImplementationOnce( - createMockSpawn({ - outputData: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`, - exitCode: 0, - }), - ); + (runRipgrep as Mock).mockResolvedValue({ + stdout: `fileA.txt:1:hello world${EOL}fileA.txt:2:second line with world${EOL}`, + truncated: false, + error: undefined, + }); const params: RipGrepToolParams = { pattern: 'world', @@ -588,7 +419,11 @@ describe('RipGrepTool', () => { }); it('should throw an error if ripgrep is not available', async () => { - (getRipgrepCommand as Mock).mockResolvedValue(null); + (runRipgrep as Mock).mockResolvedValue({ + stdout: '', + truncated: false, + error: new Error('ripgrep binary not found.'), + }); const params: RipGrepToolParams = { pattern: 'world' }; const invocation = grepTool.build(params); @@ -612,54 +447,6 @@ describe('RipGrepTool', () => { const result = await invocation.execute(controller.signal); expect(result).toBeDefined(); }); - - it('should abort streaming search when signal is triggered', async () => { - // Setup specific mock for this test - simulate process being killed due to abort - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - // Simulate process being aborted - use setTimeout to ensure handlers are registered first - setTimeout(() => { - const closeHandler = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (closeHandler) { - // Simulate process killed by signal (code is null, signal is SIGTERM) - closeHandler(null, 'SIGTERM'); - } - }, 0); - - return mockProcess as unknown as ChildProcess; - }); - - const controller = new AbortController(); - const params: RipGrepToolParams = { pattern: 'test' }; - const invocation = grepTool.build(params); - - // Abort immediately before starting the search - controller.abort(); - - const result = await invocation.execute(controller.signal); - expect(result.llmContent).toContain( - 'Error during grep search operation: ripgrep exited with code null', - ); - expect(result.returnDisplay).toContain( - 'Error: ripgrep exited with code null', - ); - }); }); describe('error handling and edge cases', () => { @@ -675,32 +462,10 @@ describe('RipGrepTool', () => { await fs.mkdir(emptyDir); // Setup specific mock for this test - searching in empty directory should return no matches - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onClose) { - onClose(1); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: '', + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'test', path: 'empty' }; @@ -715,32 +480,10 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'empty.txt'), ''); // Setup specific mock for this test - searching for anything in empty files should return no matches - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onClose) { - onClose(1); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: '', + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'anything' }; @@ -758,42 +501,10 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'world' should find the file with special characters - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData( - Buffer.from( - `${specialFileName}:1:hello world with special chars${EOL}`, - ), - ); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `file with spaces & symbols!.txt:1:hello world with special chars${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'world' }; @@ -813,42 +524,10 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - searching for 'deep' should find the deeply nested file - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData( - Buffer.from( - `a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`, - ), - ); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `a/b/c/d/e/deep.txt:1:content in deep directory${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'deep' }; @@ -868,42 +547,10 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - regex pattern should match function declarations - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData( - Buffer.from( - `code.js:1:function getName() { return "test"; }${EOL}`, - ), - ); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `code.js:1:function getName() { return "test"; }${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'function\\s+\\w+\\s*\\(' }; @@ -921,42 +568,10 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - case insensitive search should match all variants - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData( - Buffer.from( - `case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`, - ), - ); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `case.txt:1:Hello World${EOL}case.txt:2:hello world${EOL}case.txt:3:HELLO WORLD${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: 'hello' }; @@ -975,38 +590,10 @@ describe('RipGrepTool', () => { ); // Setup specific mock for this test - escaped regex pattern should match price format - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData(Buffer.from(`special.txt:1:Price: $19.99${EOL}`)); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `special.txt:1:Price: $19.99${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { pattern: '\\$\\d+\\.\\d+' }; @@ -1032,42 +619,10 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'test.txt'), 'text content'); // Setup specific mock for this test - glob pattern should filter to only ts/tsx files - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData( - Buffer.from( - `test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`, - ), - ); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `test.ts:1:typescript content${EOL}test.tsx:1:tsx content${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { @@ -1092,38 +647,10 @@ describe('RipGrepTool', () => { await fs.writeFile(path.join(tempRootDir, 'other.ts'), 'other code'); // Setup specific mock for this test - glob pattern should filter to only src/** files - mockSpawn.mockImplementationOnce(() => { - const mockProcess = { - stdout: { - on: vi.fn(), - removeListener: vi.fn(), - }, - stderr: { - on: vi.fn(), - removeListener: vi.fn(), - }, - on: vi.fn(), - removeListener: vi.fn(), - kill: vi.fn(), - }; - - setTimeout(() => { - const onData = mockProcess.stdout.on.mock.calls.find( - (call) => call[0] === 'data', - )?.[1]; - const onClose = mockProcess.on.mock.calls.find( - (call) => call[0] === 'close', - )?.[1]; - - if (onData) { - onData(Buffer.from(`src/main.ts:1:source code${EOL}`)); - } - if (onClose) { - onClose(0); - } - }, 0); - - return mockProcess as unknown as ChildProcess; + (runRipgrep as Mock).mockResolvedValue({ + stdout: `src/main.ts:1:source code${EOL}`, + truncated: false, + error: undefined, }); const params: RipGrepToolParams = { diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 402a5d3c..9fcd0e3d 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -6,14 +6,13 @@ import fs from 'node:fs'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { ToolNames } from './tool-names.js'; import { resolveAndValidatePath } from '../utils/paths.js'; import { getErrorMessage } from '../utils/errors.js'; import type { Config } from '../config/config.js'; -import { getRipgrepCommand } from '../utils/ripgrepUtils.js'; +import { runRipgrep } from '../utils/ripgrepUtils.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import type { FileFilteringOptions } from '../config/constants.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; @@ -208,60 +207,12 @@ class GrepToolInvocation extends BaseToolInvocation< rgArgs.push('--threads', '4'); rgArgs.push(absolutePath); - try { - const rgCommand = await getRipgrepCommand( - this.config.getUseBuiltinRipgrep(), - ); - if (!rgCommand) { - throw new Error('ripgrep binary not found.'); - } - - const output = await new Promise((resolve, reject) => { - const child = spawn(rgCommand, rgArgs, { - windowsHide: true, - }); - - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - - const cleanup = () => { - if (options.signal.aborted) { - child.kill(); - } - }; - - options.signal.addEventListener('abort', cleanup, { once: true }); - - child.stdout.on('data', (chunk) => stdoutChunks.push(chunk)); - child.stderr.on('data', (chunk) => stderrChunks.push(chunk)); - - child.on('error', (err) => { - options.signal.removeEventListener('abort', cleanup); - reject(new Error(`failed to start ripgrep: ${err.message}.`)); - }); - - child.on('close', (code) => { - options.signal.removeEventListener('abort', cleanup); - const stdoutData = Buffer.concat(stdoutChunks).toString('utf8'); - const stderrData = Buffer.concat(stderrChunks).toString('utf8'); - - if (code === 0) { - resolve(stdoutData); - } else if (code === 1) { - resolve(''); // No matches found - } else { - reject( - new Error(`ripgrep exited with code ${code}: ${stderrData}`), - ); - } - }); - }); - - return output; - } catch (error: unknown) { - console.error(`Ripgrep failed: ${getErrorMessage(error)}`); - throw error; + const result = await runRipgrep(rgArgs, options.signal); + if (result.error && !result.stdout) { + throw result.error; } + + return result.stdout; } private getFileFilteringOptions(): FileFilteringOptions { diff --git a/packages/core/src/utils/ripgrepUtils.test.ts b/packages/core/src/utils/ripgrepUtils.test.ts index 47bba8a5..43af1039 100644 --- a/packages/core/src/utils/ripgrepUtils.test.ts +++ b/packages/core/src/utils/ripgrepUtils.test.ts @@ -4,30 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; -import { - canUseRipgrep, - getRipgrepCommand, - getBuiltinRipgrep, -} from './ripgrepUtils.js'; -import { fileExists } from './fileUtils.js'; +import { describe, it, expect } from 'vitest'; +import { getBuiltinRipgrep } from './ripgrepUtils.js'; import path from 'node:path'; -// Mock fileUtils -vi.mock('./fileUtils.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - fileExists: vi.fn(), - }; -}); - describe('ripgrepUtils', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - describe('getBulltinRipgrepPath', () => { + describe('getBuiltinRipgrep', () => { it('should return path with .exe extension on Windows', () => { const originalPlatform = process.platform; const originalArch = process.arch; @@ -150,99 +132,4 @@ describe('ripgrepUtils', () => { Object.defineProperty(process, 'arch', { value: originalArch }); }); }); - - describe('canUseRipgrep', () => { - it('should return true if ripgrep binary exists (builtin)', async () => { - (fileExists as Mock).mockResolvedValue(true); - - const result = await canUseRipgrep(true); - - expect(result).toBe(true); - expect(fileExists).toHaveBeenCalledOnce(); - }); - - it('should return true if ripgrep binary exists (default)', async () => { - (fileExists as Mock).mockResolvedValue(true); - - const result = await canUseRipgrep(); - - expect(result).toBe(true); - expect(fileExists).toHaveBeenCalledOnce(); - }); - }); - - describe('ensureRipgrepPath', () => { - it('should return bundled ripgrep path if binary exists (useBuiltin=true)', async () => { - (fileExists as Mock).mockResolvedValue(true); - - const rgPath = await getRipgrepCommand(true); - - expect(rgPath).toBeDefined(); - expect(rgPath).toContain('rg'); - expect(rgPath).not.toBe('rg'); // Should be full path, not just 'rg' - expect(fileExists).toHaveBeenCalledOnce(); - expect(fileExists).toHaveBeenCalledWith(rgPath); - }); - - it('should return bundled ripgrep path if binary exists (default)', async () => { - (fileExists as Mock).mockResolvedValue(true); - - const rgPath = await getRipgrepCommand(); - - expect(rgPath).toBeDefined(); - expect(rgPath).toContain('rg'); - expect(fileExists).toHaveBeenCalledOnce(); - }); - - it('should fall back to system rg if bundled binary does not exist', async () => { - (fileExists as Mock).mockResolvedValue(false); - // When useBuiltin is true but bundled binary doesn't exist, - // it should fall back to checking system rg - // The test result depends on whether system rg is actually available - - const rgPath = await getRipgrepCommand(true); - - expect(fileExists).toHaveBeenCalledOnce(); - // If system rg is available, it should return 'rg' (or 'rg.exe' on Windows) - // This test will pass if system ripgrep is installed - expect(rgPath).toBeDefined(); - }); - - it('should use system rg when useBuiltin=false', async () => { - // When useBuiltin is false, should skip bundled check and go straight to system rg - const rgPath = await getRipgrepCommand(false); - - // Should not check for bundled binary - expect(fileExists).not.toHaveBeenCalled(); - // If system rg is available, it should return 'rg' (or 'rg.exe' on Windows) - expect(rgPath).toBeDefined(); - }); - - it('should throw error if neither bundled nor system ripgrep is available', async () => { - // This test only makes sense in an environment where system rg is not installed - // We'll skip this test in CI/local environments where rg might be available - // Instead, we test the error message format - const originalPlatform = process.platform; - - // Use an unsupported platform to trigger the error path - Object.defineProperty(process, 'platform', { value: 'freebsd' }); - - try { - await getRipgrepCommand(); - // If we get here without error, system rg was available, which is fine - } catch (error) { - expect(error).toBeInstanceOf(Error); - const errorMessage = (error as Error).message; - // Should contain helpful error information - expect( - errorMessage.includes('Ripgrep binary not found') || - errorMessage.includes('Failed to locate ripgrep') || - errorMessage.includes('Unsupported platform'), - ).toBe(true); - } - - // Restore original value - Object.defineProperty(process, 'platform', { value: originalPlatform }); - }); - }); }); diff --git a/packages/core/src/utils/ripgrepUtils.ts b/packages/core/src/utils/ripgrepUtils.ts index c6d795a3..1f432541 100644 --- a/packages/core/src/utils/ripgrepUtils.ts +++ b/packages/core/src/utils/ripgrepUtils.ts @@ -6,7 +6,53 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; import { fileExists } from './fileUtils.js'; +import { execCommand, isCommandAvailable } from './shell-utils.js'; + +const RIPGREP_COMMAND = 'rg'; +const RIPGREP_BUFFER_LIMIT = 20_000_000; // Keep buffers aligned with the original bundle. +const RIPGREP_TEST_TIMEOUT_MS = 5_000; +const RIPGREP_RUN_TIMEOUT_MS = 10_000; +const RIPGREP_WSL_TIMEOUT_MS = 60_000; + +type RipgrepMode = 'builtin' | 'system'; + +interface RipgrepSelection { + mode: RipgrepMode; + command: string; +} + +interface RipgrepHealth { + working: boolean; + lastTested: number; + selection: RipgrepSelection; +} + +export interface RipgrepRunResult { + /** + * The stdout output from ripgrep + */ + stdout: string; + /** + * Whether the results were truncated due to buffer overflow or signal termination + */ + truncated: boolean; + /** + * Any error that occurred during execution (non-fatal errors like no matches won't populate this) + */ + error?: Error; +} + +let cachedSelection: RipgrepSelection | null = null; +let cachedHealth: RipgrepHealth | null = null; +let macSigningAttempted = false; + +function wslTimeout(): number { + return process.platform === 'linux' && process.env['WSL_INTEROP'] + ? RIPGREP_WSL_TIMEOUT_MS + : RIPGREP_RUN_TIMEOUT_MS; +} // Get the directory of the current module const __filename = fileURLToPath(import.meta.url); @@ -88,59 +134,201 @@ export function getBuiltinRipgrep(): string | null { return vendorPath; } -/** - * Checks if system ripgrep is available and returns the command to use - * @returns The ripgrep command ('rg' or 'rg.exe') if available, or null if not found - */ -export async function getSystemRipgrep(): Promise { - try { - const { spawn } = await import('node:child_process'); - const rgCommand = process.platform === 'win32' ? 'rg.exe' : 'rg'; - const isAvailable = await new Promise((resolve) => { - const proc = spawn(rgCommand, ['--version']); - proc.on('error', () => resolve(false)); - proc.on('exit', (code) => resolve(code === 0)); - }); - return isAvailable ? rgCommand : null; - } catch (_error) { - return null; - } -} - /** * Checks if ripgrep binary exists and returns its path * @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep. * If false, only checks for system ripgrep. * @returns The path to ripgrep binary ('rg' or 'rg.exe' for system ripgrep, or full path for bundled), or null if not available + * @throws {Error} If an error occurs while resolving the ripgrep binary. */ -export async function getRipgrepCommand( +export async function resolveRipgrep( useBuiltin: boolean = true, -): Promise { - try { - if (useBuiltin) { - // Try bundled ripgrep first - const rgPath = getBuiltinRipgrep(); - if (rgPath && (await fileExists(rgPath))) { - return rgPath; - } - // Fallback to system rg if bundled binary is not available - } +): Promise { + if (cachedSelection) return cachedSelection; - // Check for system ripgrep - return await getSystemRipgrep(); - } catch (_error) { - return null; + if (useBuiltin) { + // Try bundled ripgrep first + const rgPath = getBuiltinRipgrep(); + if (rgPath && (await fileExists(rgPath))) { + cachedSelection = { mode: 'builtin', command: rgPath }; + return cachedSelection; + } + // Fallback to system rg if bundled binary is not available } + + const { available, error } = isCommandAvailable(RIPGREP_COMMAND); + if (available) { + cachedSelection = { mode: 'system', command: RIPGREP_COMMAND }; + return cachedSelection; + } + + if (error) { + throw error; + } + + return null; +} + +/** + * Ensures that ripgrep is healthy by checking its version. + * @param selection The ripgrep selection to check. + * @throws {Error} If ripgrep is not found or is not healthy. + */ +export async function ensureRipgrepHealthy( + selection: RipgrepSelection, +): Promise { + if ( + cachedHealth && + cachedHealth.selection.command === selection.command && + cachedHealth.working + ) + return; + + try { + const { stdout, code } = await execCommand( + selection.command, + ['--version'], + { + timeout: RIPGREP_TEST_TIMEOUT_MS, + }, + ); + const working = code === 0 && stdout.startsWith('ripgrep'); + cachedHealth = { working, lastTested: Date.now(), selection }; + } catch (error) { + cachedHealth = { working: false, lastTested: Date.now(), selection }; + throw error; + } +} + +export async function ensureMacBinarySigned( + selection: RipgrepSelection, +): Promise { + if (process.platform !== 'darwin') return; + if (macSigningAttempted) return; + macSigningAttempted = true; + + if (selection.mode !== 'builtin') return; + const binaryPath = selection.command; + + const inspect = await execCommand('codesign', ['-vv', '-d', binaryPath], { + preserveOutputOnError: false, + }); + const alreadySigned = + inspect.stdout + ?.split('\n') + .some((line) => line.includes('linker-signed')) ?? false; + if (!alreadySigned) return; + + await execCommand('codesign', [ + '--sign', + '-', + '--force', + '--preserve-metadata=entitlements,requirements,flags,runtime', + binaryPath, + ]); + await execCommand('xattr', ['-d', 'com.apple.quarantine', binaryPath]); } /** * Checks if ripgrep binary is available * @param useBuiltin If true, tries bundled ripgrep first, then falls back to system ripgrep. * If false, only checks for system ripgrep. + * @returns True if ripgrep is available, false otherwise. + * @throws {Error} If an error occurs while resolving the ripgrep binary. */ export async function canUseRipgrep( useBuiltin: boolean = true, ): Promise { - const rgPath = await getRipgrepCommand(useBuiltin); - return rgPath !== null; + const selection = await resolveRipgrep(useBuiltin); + if (!selection) { + return false; + } + await ensureRipgrepHealthy(selection); + return true; +} + +/** + * Runs ripgrep with the provided arguments + * @param args The arguments to pass to ripgrep + * @param signal The signal to abort the ripgrep process + * @returns The result of running ripgrep + * @throws {Error} If an error occurs while running ripgrep. + */ +export async function runRipgrep( + args: string[], + signal?: AbortSignal, +): Promise { + const selection = await resolveRipgrep(); + if (!selection) { + throw new Error('ripgrep not found.'); + } + await ensureRipgrepHealthy(selection); + + return new Promise((resolve) => { + const child = execFile( + selection.command, + args, + { + maxBuffer: RIPGREP_BUFFER_LIMIT, + timeout: wslTimeout(), + signal, + }, + (error, stdout = '', stderr = '') => { + if (!error) { + // Success case + resolve({ + stdout, + truncated: false, + }); + return; + } + + // Exit code 1 = no matches found (not an error) + // The error.code from execFile can be string | number | undefined | null + const errorCode = ( + error as Error & { code?: string | number | undefined | null } + ).code; + if (errorCode === 1) { + resolve({ stdout: '', truncated: false }); + return; + } + + // Detect various error conditions + const wasKilled = + error.signal === 'SIGTERM' || error.name === 'AbortError'; + const overflow = errorCode === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'; + const syntaxError = errorCode === 2; + + const truncated = wasKilled || overflow; + let partialOutput = stdout; + + // If killed or overflow with partial output, remove the last potentially incomplete line + if (truncated && partialOutput.length > 0) { + const lines = partialOutput.split('\n'); + if (lines.length > 0) { + lines.pop(); + partialOutput = lines.join('\n'); + } + } + + // Log warnings for abnormal exits (except syntax errors) + if (!syntaxError && truncated) { + console.warn( + `ripgrep exited abnormally (signal=${error.signal} code=${error.code}) with stderr:\n${stderr.trim() || '(empty)'}`, + ); + } + + resolve({ + stdout: partialOutput, + truncated, + error: error instanceof Error ? error : undefined, + }); + }, + ); + + // Handle spawn errors + child.on('error', (err) => + resolve({ stdout: '', truncated: false, error: err }), + ); + }); } diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 5a5128e3..6afab89d 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -10,7 +10,12 @@ import os from 'node:os'; import { quote } from 'shell-quote'; import { doesToolInvocationMatch } from './tool-utils.js'; import { isShellCommandReadOnly } from './shellReadOnlyChecker.js'; -import { spawn, type SpawnOptionsWithoutStdio } from 'node:child_process'; +import { + execFile, + execFileSync, + type ExecFileOptions, +} from 'node:child_process'; +import { accessSync, constants as fsConstants } from 'node:fs'; const SHELL_TOOL_NAMES = ['run_shell_command', 'ShellTool']; @@ -455,46 +460,101 @@ export function checkCommandPermissions( } /** - * Determines whether a given shell command is allowed to execute based on - * the tool's configuration including allowlists and blocklists. + * Executes a command with the given arguments without using a shell. * - * This function operates in "default allow" mode. It is a wrapper around - * `checkCommandPermissions`. + * This is a wrapper around Node.js's `execFile`, which spawns a process + * directly without invoking a shell, making it safer than `exec`. + * It's suitable for short-running commands with limited output. * - * @param command The shell command string to validate. - * @param config The application configuration. - * @returns An object with 'allowed' boolean and optional 'reason' string if not allowed. + * @param command The command to execute (e.g., 'git', 'osascript'). + * @param args Array of arguments to pass to the command. + * @param options Optional spawn options including: + * - preserveOutputOnError: If false (default), rejects on error. + * If true, resolves with output and error code. + * - Other standard spawn options (e.g., cwd, env). + * @returns A promise that resolves with stdout, stderr strings, and exit code. + * @throws Rejects with an error if the command fails (unless preserveOutputOnError is true). */ -export const spawnAsync = ( +export function execCommand( command: string, args: string[], - options?: SpawnOptionsWithoutStdio, -): Promise<{ stdout: string; stderr: string }> => - new Promise((resolve, reject) => { - const child = spawn(command, args, options); - let stdout = ''; - let stderr = ''; - - child.stdout.on('data', (data) => { - stdout += data.toString(); - }); - - child.stderr.on('data', (data) => { - stderr += data.toString(); - }); - - child.on('close', (code) => { - if (code === 0) { - resolve({ stdout, stderr }); - } else { - reject(new Error(`Command failed with exit code ${code}:\n${stderr}`)); - } - }); - - child.on('error', (err) => { - reject(err); - }); + options: { preserveOutputOnError?: boolean } & ExecFileOptions = {}, +): Promise<{ stdout: string; stderr: string; code: number }> { + return new Promise((resolve, reject) => { + const child = execFile( + command, + args, + { encoding: 'utf8', ...options }, + (error, stdout, stderr) => { + if (error) { + if (!options.preserveOutputOnError) { + reject(error); + } else { + resolve({ + stdout: stdout ?? '', + stderr: stderr ?? '', + code: typeof error.code === 'number' ? error.code : 1, + }); + } + return; + } + resolve({ stdout: stdout ?? '', stderr: stderr ?? '', code: 0 }); + }, + ); + child.on('error', reject); }); +} + +/** + * Resolves the path of a command in the system's PATH. + * @param {string} command The command name (e.g., 'git', 'grep'). + * @returns {path: string | null; error?: Error} The path of the command, or null if it is not found and any error that occurred. + */ +export function resolveCommandPath(command: string): { + path: string | null; + error?: Error; +} { + try { + const isWin = process.platform === 'win32'; + + const checkCommand = isWin ? 'where' : 'command'; + const checkArgs = isWin ? [command] : ['-v', command]; + + let result: string | null = null; + try { + result = execFileSync(checkCommand, checkArgs, { + encoding: 'utf8', + shell: isWin, + }).trim(); + } catch { + console.warn(`Command ${checkCommand} not found`); + } + + if (!result) return { path: null, error: undefined }; + if (!isWin) { + accessSync(result, fsConstants.X_OK); + } + return { path: result, error: undefined }; + } catch (error) { + return { + path: null, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} + +/** + * Checks if a command is available in the system's PATH. + * @param {string} command The command name (e.g., 'git', 'grep'). + * @returns {available: boolean; error?: Error} The availability of the command and any error that occurred. + */ +export function isCommandAvailable(command: string): { + available: boolean; + error?: Error; +} { + const { path, error } = resolveCommandPath(command); + return { available: path !== null, error }; +} export function isCommandAllowed( command: string, diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 512ada66..4ae7fea0 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.2.2", + "version": "0.3.0", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index dd86c816..ac0173d1 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.2.2", + "version": "0.3.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/scripts/check-i18n.ts b/scripts/check-i18n.ts new file mode 100644 index 00000000..7c07619b --- /dev/null +++ b/scripts/check-i18n.ts @@ -0,0 +1,457 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { glob } from 'glob'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; + +// Get __dirname for ESM modules +// @ts-expect-error - import.meta is supported in NodeNext module system at runtime +const __dirname = dirname(fileURLToPath(import.meta.url)); + +interface CheckResult { + success: boolean; + errors: string[]; + warnings: string[]; + stats: { + totalKeys: number; + translatedKeys: number; + unusedKeys: string[]; + unusedKeysOnlyInLocales?: string[]; // 新åĸžīŧšåĒ在 locales ä¸­å­˜åœ¨įš„æœĒäŊŋį”¨é”Ž + }; +} + +/** + * Load translations from JS file + */ +async function loadTranslationsFile( + filePath: string, +): Promise> { + try { + // Dynamic import for ES modules + const module = await import(filePath); + return module.default || module; + } catch (error) { + // Fallback: try reading as JSON if JS import fails + try { + const content = fs.readFileSync( + filePath.replace('.js', '.json'), + 'utf-8', + ); + return JSON.parse(content); + } catch { + throw error; + } + } +} + +/** + * Extract string literal from code, handling escaped quotes + */ +function extractStringLiteral( + content: string, + startPos: number, + quote: string, +): { value: string; endPos: number } | null { + let pos = startPos + 1; // Skip opening quote + let value = ''; + let escaped = false; + + while (pos < content.length) { + const char = content[pos]; + + if (escaped) { + if (char === '\\') { + value += '\\'; + } else if (char === quote) { + value += quote; + } else if (char === 'n') { + value += '\n'; + } else if (char === 't') { + value += '\t'; + } else if (char === 'r') { + value += '\r'; + } else { + value += char; + } + escaped = false; + } else if (char === '\\') { + escaped = true; + } else if (char === quote) { + return { value, endPos: pos }; + } else { + value += char; + } + + pos++; + } + + return null; // String not closed +} + +/** + * Extract all t() calls from source files + */ +async function extractUsedKeys(sourceDir: string): Promise> { + const usedKeys = new Set(); + + // Find all TypeScript/TSX files + const files = await glob('**/*.{ts,tsx}', { + cwd: sourceDir, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/*.test.ts', + '**/*.test.tsx', + ], + }); + + for (const file of files) { + const filePath = path.join(sourceDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Find all t( calls + const tCallRegex = /t\s*\(/g; + let match; + while ((match = tCallRegex.exec(content)) !== null) { + const startPos = match.index + match[0].length; + let pos = startPos; + + // Skip whitespace + while (pos < content.length && /\s/.test(content[pos])) { + pos++; + } + + if (pos >= content.length) continue; + + const char = content[pos]; + if (char === "'" || char === '"') { + const result = extractStringLiteral(content, pos, char); + if (result) { + usedKeys.add(result.value); + } + } + } + } catch { + // Skip files that can't be read + continue; + } + } + + return usedKeys; +} + +/** + * Check key-value consistency in en.js + */ +function checkKeyValueConsistency( + enTranslations: Record, +): string[] { + const errors: string[] = []; + + for (const [key, value] of Object.entries(enTranslations)) { + if (key !== value) { + errors.push(`Key-value mismatch: "${key}" !== "${value}"`); + } + } + + return errors; +} + +/** + * Check if en.js and zh.js have matching keys + */ +function checkKeyMatching( + enTranslations: Record, + zhTranslations: Record, +): string[] { + const errors: string[] = []; + const enKeys = new Set(Object.keys(enTranslations)); + const zhKeys = new Set(Object.keys(zhTranslations)); + + // Check for keys in en but not in zh + for (const key of enKeys) { + if (!zhKeys.has(key)) { + errors.push(`Missing translation in zh.js: "${key}"`); + } + } + + // Check for keys in zh but not in en + for (const key of zhKeys) { + if (!enKeys.has(key)) { + errors.push(`Extra key in zh.js (not in en.js): "${key}"`); + } + } + + return errors; +} + +/** + * Find unused translation keys + */ +function findUnusedKeys(allKeys: Set, usedKeys: Set): string[] { + return Array.from(allKeys) + .filter((key) => !usedKeys.has(key)) + .sort(); +} + +/** + * Save keys that exist only in locale files to a JSON file + * @param keysOnlyInLocales Array of keys that exist only in locale files + * @param outputPath Path to save the JSON file + */ +function saveKeysOnlyInLocalesToJson( + keysOnlyInLocales: string[], + outputPath: string, +): void { + try { + const data = { + generatedAt: new Date().toISOString(), + keys: keysOnlyInLocales, + count: keysOnlyInLocales.length, + }; + fs.writeFileSync(outputPath, JSON.stringify(data, null, 2)); + console.log(`Keys that exist only in locale files saved to: ${outputPath}`); + } catch (error) { + console.error(`Failed to save keys to JSON file: ${error}`); + } +} + +/** + * Check if unused keys exist only in locale files and nowhere else in the codebase + * Optimized to search all keys in a single pass instead of multiple grep calls + * @param unusedKeys The list of unused keys to check + * @param sourceDir The source directory to search in + * @param localesDir The locales directory to exclude from search + * @returns Array of keys that exist only in locale files + */ +async function findKeysOnlyInLocales( + unusedKeys: string[], + sourceDir: string, + localesDir: string, +): Promise { + if (unusedKeys.length === 0) { + return []; + } + + const keysOnlyInLocales: string[] = []; + const localesDirName = path.basename(localesDir); + + // Find all TypeScript/TSX files (excluding locales, node_modules, dist, and test files) + const files = await glob('**/*.{ts,tsx}', { + cwd: sourceDir, + ignore: [ + '**/node_modules/**', + '**/dist/**', + '**/*.test.ts', + '**/*.test.tsx', + `**/${localesDirName}/**`, + ], + }); + + // Read all files and check for key usage + const foundKeys = new Set(); + + for (const file of files) { + const filePath = path.join(sourceDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + + // Check each unused key in the file content + for (const key of unusedKeys) { + if (!foundKeys.has(key) && content.includes(key)) { + foundKeys.add(key); + } + } + } catch { + // Skip files that can't be read + continue; + } + } + + // Keys that were not found in any source files exist only in locales + for (const key of unusedKeys) { + if (!foundKeys.has(key)) { + keysOnlyInLocales.push(key); + } + } + + return keysOnlyInLocales; +} + +/** + * Main check function + */ +async function checkI18n(): Promise { + const errors: string[] = []; + const warnings: string[] = []; + + const localesDir = path.join(__dirname, '../packages/cli/src/i18n/locales'); + const sourceDir = path.join(__dirname, '../packages/cli/src'); + + const enPath = path.join(localesDir, 'en.js'); + const zhPath = path.join(localesDir, 'zh.js'); + + // Load translation files + let enTranslations: Record; + let zhTranslations: Record; + + try { + enTranslations = await loadTranslationsFile(enPath); + } catch (error) { + errors.push( + `Failed to load en.js: ${error instanceof Error ? error.message : String(error)}`, + ); + return { + success: false, + errors, + warnings, + stats: { totalKeys: 0, translatedKeys: 0, unusedKeys: [] }, + }; + } + + try { + zhTranslations = await loadTranslationsFile(zhPath); + } catch (error) { + errors.push( + `Failed to load zh.js: ${error instanceof Error ? error.message : String(error)}`, + ); + return { + success: false, + errors, + warnings, + stats: { totalKeys: 0, translatedKeys: 0, unusedKeys: [] }, + }; + } + + // Check key-value consistency in en.js + const consistencyErrors = checkKeyValueConsistency(enTranslations); + errors.push(...consistencyErrors); + + // Check key matching between en and zh + const matchingErrors = checkKeyMatching(enTranslations, zhTranslations); + errors.push(...matchingErrors); + + // Extract used keys from source code + const usedKeys = await extractUsedKeys(sourceDir); + + // Find unused keys + const enKeys = new Set(Object.keys(enTranslations)); + const unusedKeys = findUnusedKeys(enKeys, usedKeys); + + // Find keys that exist only in locales (and nowhere else in the codebase) + const unusedKeysOnlyInLocales = + unusedKeys.length > 0 + ? await findKeysOnlyInLocales(unusedKeys, sourceDir, localesDir) + : []; + + if (unusedKeys.length > 0) { + warnings.push(`Found ${unusedKeys.length} unused translation keys`); + } + + const totalKeys = Object.keys(enTranslations).length; + const translatedKeys = Object.keys(zhTranslations).length; + + return { + success: errors.length === 0, + errors, + warnings, + stats: { + totalKeys, + translatedKeys, + unusedKeys, + unusedKeysOnlyInLocales, + }, + }; +} + +// Run checks +async function main() { + const result = await checkI18n(); + + console.log('\n=== i18n Check Results ===\n'); + + console.log(`Total keys: ${result.stats.totalKeys}`); + console.log(`Translated keys: ${result.stats.translatedKeys}`); + const coverage = + result.stats.totalKeys > 0 + ? ((result.stats.translatedKeys / result.stats.totalKeys) * 100).toFixed( + 1, + ) + : '0.0'; + console.log(`Translation coverage: ${coverage}%\n`); + + if (result.warnings.length > 0) { + console.log('âš ī¸ Warnings:'); + result.warnings.forEach((warning) => console.log(` - ${warning}`)); + + // Show unused keys + if ( + result.stats.unusedKeys.length > 0 && + result.stats.unusedKeys.length <= 10 + ) { + console.log('\nUnused keys:'); + result.stats.unusedKeys.forEach((key) => console.log(` - "${key}"`)); + } else if (result.stats.unusedKeys.length > 10) { + console.log( + `\nUnused keys (showing first 10 of ${result.stats.unusedKeys.length}):`, + ); + result.stats.unusedKeys + .slice(0, 10) + .forEach((key) => console.log(` - "${key}"`)); + } + + // Show keys that exist only in locales files + if ( + result.stats.unusedKeysOnlyInLocales && + result.stats.unusedKeysOnlyInLocales.length > 0 + ) { + console.log( + '\nâš ī¸ The following keys exist ONLY in locale files and nowhere else in the codebase:', + ); + console.log( + ' Please review these keys - they might be safe to remove.', + ); + result.stats.unusedKeysOnlyInLocales.forEach((key) => + console.log(` - "${key}"`), + ); + + // Save these keys to a JSON file + const outputPath = path.join( + __dirname, + 'unused-keys-only-in-locales.json', + ); + saveKeysOnlyInLocalesToJson( + result.stats.unusedKeysOnlyInLocales, + outputPath, + ); + } + + console.log(); + } + + if (result.errors.length > 0) { + console.log('❌ Errors:'); + result.errors.forEach((error) => console.log(` - ${error}`)); + console.log(); + process.exit(1); + } + + if (result.success) { + console.log('✅ All checks passed!\n'); + process.exit(0); + } +} + +main().catch((error) => { + console.error('❌ Fatal error:', error); + process.exit(1); +}); diff --git a/scripts/copy_files.js b/scripts/copy_files.js index ddf25464..9f1d318e 100644 --- a/scripts/copy_files.js +++ b/scripts/copy_files.js @@ -28,7 +28,7 @@ const targetDir = path.join('dist', 'src'); const extensionsToCopy = ['.md', '.json', '.sb']; -function copyFilesRecursive(source, target) { +function copyFilesRecursive(source, target, rootSourceDir) { if (!fs.existsSync(target)) { fs.mkdirSync(target, { recursive: true }); } @@ -40,9 +40,18 @@ function copyFilesRecursive(source, target) { const targetPath = path.join(target, item.name); if (item.isDirectory()) { - copyFilesRecursive(sourcePath, targetPath); - } else if (extensionsToCopy.includes(path.extname(item.name))) { - fs.copyFileSync(sourcePath, targetPath); + copyFilesRecursive(sourcePath, targetPath, rootSourceDir); + } else { + const ext = path.extname(item.name); + // Copy standard extensions, or .js files in i18n/locales directory + // Use path.relative for precise matching to avoid false positives + const relativePath = path.relative(rootSourceDir, sourcePath); + const normalizedPath = relativePath.replace(/\\/g, '/'); + const isLocaleJs = + ext === '.js' && normalizedPath.startsWith('i18n/locales/'); + if (extensionsToCopy.includes(ext) || isLocaleJs) { + fs.copyFileSync(sourcePath, targetPath); + } } } } @@ -52,7 +61,7 @@ if (!fs.existsSync(sourceDir)) { process.exit(1); } -copyFilesRecursive(sourceDir, targetDir); +copyFilesRecursive(sourceDir, targetDir, sourceDir); // Copy example extensions into the bundle. const packageName = path.basename(process.cwd()); diff --git a/scripts/prepare-package.js b/scripts/prepare-package.js index 12268d61..534f104c 100644 --- a/scripts/prepare-package.js +++ b/scripts/prepare-package.js @@ -56,6 +56,43 @@ for (const file of filesToCopy) { } } +// Copy locales folder +console.log('Copying locales folder...'); +const localesSourceDir = path.join( + rootDir, + 'packages', + 'cli', + 'src', + 'i18n', + 'locales', +); +const localesDestDir = path.join(distDir, 'locales'); + +if (fs.existsSync(localesSourceDir)) { + // Recursive copy function + function copyRecursiveSync(src, dest) { + const stats = fs.statSync(src); + if (stats.isDirectory()) { + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } + const entries = fs.readdirSync(src); + for (const entry of entries) { + const srcPath = path.join(src, entry); + const destPath = path.join(dest, entry); + copyRecursiveSync(srcPath, destPath); + } + } else { + fs.copyFileSync(src, dest); + } + } + + copyRecursiveSync(localesSourceDir, localesDestDir); + console.log('Copied locales folder'); +} else { + console.warn(`Warning: locales folder not found at ${localesSourceDir}`); +} + // Copy package.json from root and modify it for publishing console.log('Creating package.json for distribution...'); const rootPackageJson = JSON.parse( @@ -85,7 +122,7 @@ const distPackageJson = { bin: { qwen: 'cli.js', }, - files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE'], + files: ['cli.js', 'vendor', '*.sb', 'README.md', 'LICENSE', 'locales'], config: rootPackageJson.config, dependencies: runtimeDependencies, optionalDependencies: {