From 3c64f7bff5c1e51c8369606cf2135f3beda96d8a Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 20 Nov 2025 10:09:12 +0800 Subject: [PATCH 01/14] chore: pump version to 0.2.3 (#1073) --- package-lock.json | 12 ++++++------ package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 296fc29b..c7ac21f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.2.3", "workspaces": [ "packages/*" ], @@ -16024,7 +16024,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.2.3", "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.2.3", "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.2.3", "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.2.3", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index a8b69061..85c90f84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.2", + "version": "0.2.3", "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.2.3" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 6dce0ccc..bece2f31 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.2.3", "description": "Qwen Code", "repository": { "type": "git", @@ -25,7 +25,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/core/package.json b/packages/core/package.json index 3232a664..72e4612f 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.2.3", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 512ada66..0e23606c 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.2.3", "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..afeed670 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.2.3", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { From e1f793b2e0f8263bcbb1a3f2dab492e0e2556787 Mon Sep 17 00:00:00 2001 From: citlalinda Date: Thu, 20 Nov 2025 10:23:17 +0800 Subject: [PATCH 02/14] fix: character encoding corruption when executing the /copy command on Windows. (#1069) Co-authored-by: linda --- .../cli/src/ui/utils/commandUtils.test.ts | 6 +++++- packages/cli/src/ui/utils/commandUtils.ts | 19 ++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) 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': From fc638851e755d3155985594615e5dfc5fccfe83f Mon Sep 17 00:00:00 2001 From: cwtuan Date: Thu, 20 Nov 2025 12:50:06 +0800 Subject: [PATCH 03/14] fix: remove broken link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 07069f00d1360a40191507552dddd62fdd32d822 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 20 Nov 2025 14:36:51 +0800 Subject: [PATCH 04/14] feat: remove prompt completion feature (#1076) --- packages/cli/src/config/config.ts | 1 - packages/cli/src/config/settings.ts | 1 - packages/cli/src/config/settingsSchema.ts | 10 - .../src/ui/components/InputPrompt.test.tsx | 5 - .../cli/src/ui/components/InputPrompt.tsx | 328 ++++-------------- .../src/ui/components/SettingsDialog.test.tsx | 2 - .../SettingsDialog.test.tsx.snap | 40 +-- .../src/ui/hooks/useCommandCompletion.test.ts | 81 +---- .../cli/src/ui/hooks/useCommandCompletion.tsx | 35 +- .../cli/src/ui/hooks/usePromptCompletion.ts | 254 -------------- packages/core/src/config/config.ts | 7 - 11 files changed, 99 insertions(+), 665 deletions(-) delete mode 100644 packages/cli/src/ui/hooks/usePromptCompletion.ts diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 50a11991..7286ff12 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -789,7 +789,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 aefcb103..8ff022c8 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..e0ece3ac 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', diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 38d5f7b1..25274a12 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); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f33700d8..2bd9b275 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'; @@ -91,7 +90,6 @@ export const InputPrompt: React.FC = ({ commandContext, placeholder = ' Type your message or @path/to/file', focus = true, - inputWidth, suggestionsWidth, shellModeActive, setShellModeActive, @@ -526,16 +524,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 +645,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 +679,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; @@ -887,134 +751,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/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/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index b63948e1..cf8d4444 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -10,8 +10,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ │ Output Format Text │ @@ -22,6 +20,8 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -44,8 +44,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ │ Output Format Text │ @@ -56,6 +54,8 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -78,8 +78,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ │ Output Format Text │ @@ -90,6 +88,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -112,8 +112,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Disable Auto Update false* │ │ │ -│ Enable Prompt Completion false* │ -│ │ │ Debug Keystroke Logging false* │ │ │ │ Output Format Text │ @@ -124,6 +122,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Hide Tips false* │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -146,8 +146,6 @@ 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 │ │ │ │ Output Format Text │ @@ -158,6 +156,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -180,8 +180,6 @@ 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 │ │ │ │ Output Format Text │ @@ -192,6 +190,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -214,8 +214,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ │ Output Format Text │ @@ -226,6 +224,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -248,8 +248,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Disable Auto Update true* │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ │ Output Format Text │ @@ -260,6 +258,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -282,8 +282,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Disable Auto Update false │ │ │ -│ Enable Prompt Completion false │ -│ │ │ Debug Keystroke Logging false │ │ │ │ Output Format Text │ @@ -294,6 +292,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Hide Tips false │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ @@ -316,8 +316,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Disable Auto Update true* │ │ │ -│ Enable Prompt Completion true* │ -│ │ │ Debug Keystroke Logging true* │ │ │ │ Output Format Text │ @@ -328,6 +326,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Hide Tips true* │ │ │ +│ Hide Banner false │ +│ │ │ ā–¼ │ │ │ │ │ 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/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/core/src/config/config.ts b/packages/core/src/config/config.ts index c8fa74ab..aa91f785 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -280,7 +280,6 @@ export interface ConfigParameters { skipNextSpeakerCheck?: boolean; shellExecutionConfig?: ShellExecutionConfig; extensionManagement?: boolean; - enablePromptCompletion?: boolean; skipLoopDetection?: boolean; vlmSwitchMode?: string; truncateToolOutputThreshold?: number; @@ -377,7 +376,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; @@ -495,7 +493,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.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; @@ -1038,10 +1035,6 @@ export class Config { return this.accessibility.screenReader ?? false; } - getEnablePromptCompletion(): boolean { - return this.enablePromptCompletion; - } - getSkipLoopDetection(): boolean { return this.skipLoopDetection; } From a15b84e2a1f1c6ae750127a3bdaf5d08b3acaa43 Mon Sep 17 00:00:00 2001 From: Mingholy Date: Thu, 20 Nov 2025 14:37:39 +0800 Subject: [PATCH 05/14] refactor(auth): enhance useAuthCommand to include history management and improve error handling in QwenOAuth2Client (#1077) --- packages/cli/src/ui/AppContainer.tsx | 2 +- packages/cli/src/ui/auth/useAuth.ts | 28 +++++++--- packages/core/src/qwen/qwenOAuth2.test.ts | 30 ++++++----- packages/core/src/qwen/qwenOAuth2.ts | 65 ++++++++++++----------- 4 files changed, 74 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ecacbda4..345bebd2 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -353,7 +353,7 @@ export const AppContainer = (props: AppContainerProps) => { handleAuthSelect, openAuthDialog, cancelAuthentication, - } = useAuthCommand(settings, config); + } = useAuthCommand(settings, config, historyManager.addItem); const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({ config, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 9b1198bf..04da911c 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -4,23 +4,28 @@ * 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'; 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; @@ -117,8 +122,17 @@ 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: `Authenticated successfully with ${authType} credentials.`, + }, + Date.now(), + ); }, - [settings, handleAuthFailure, config], + [settings, handleAuthFailure, config, addItem], ); const performAuth = useCallback( 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; From 442a9aed58064a7a049e872eb5ea4b495db9897d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 20 Nov 2025 15:04:00 +0800 Subject: [PATCH 06/14] Replace spawn with execFile for memory-safe command execution (#1068) --- .../shared/BaseSelectionList.test.tsx | 2 +- .../cli/src/ui/hooks/useGitBranchName.test.ts | 106 +-- packages/cli/src/ui/hooks/useGitBranchName.ts | 10 +- packages/cli/src/ui/utils/clipboardUtils.ts | 6 +- packages/cli/src/utils/userStartupWarnings.ts | 12 +- packages/core/src/config/config.test.ts | 6 +- packages/core/src/config/config.ts | 16 +- packages/core/src/services/gitService.test.ts | 25 +- packages/core/src/services/gitService.ts | 13 +- packages/core/src/telemetry/loggers.test.ts | 10 +- packages/core/src/telemetry/loggers.ts | 2 +- .../src/telemetry/qwen-logger/qwen-logger.ts | 13 +- packages/core/src/telemetry/types.ts | 12 +- packages/core/src/tools/grep.test.ts | 75 +- packages/core/src/tools/grep.ts | 28 +- packages/core/src/tools/ripGrep.test.ts | 683 +++--------------- packages/core/src/tools/ripGrep.ts | 61 +- packages/core/src/utils/ripgrepUtils.test.ts | 119 +-- packages/core/src/utils/ripgrepUtils.ts | 260 ++++++- packages/core/src/utils/shell-utils.ts | 130 +++- 20 files changed, 620 insertions(+), 969 deletions(-) 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/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/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/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/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 aa91f785..76f923e7 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'; @@ -1147,17 +1148,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/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.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index c5dc70d7..a56723a7 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 { @@ -778,8 +779,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', { + snapshots: JSON.stringify({ + platform: process.platform, + arch: process.arch, + use_ripgrep: event.use_ripgrep, + use_builtin_ripgrep: event.use_builtin_ripgrep, + error: event.error ?? undefined, + }), + }); this.enqueueLogEvent(rumEvent); this.flushIfNeeded(); 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, From 9e5387f15908c580b0ee9495b9f198e38299c899 Mon Sep 17 00:00:00 2001 From: Kdump Date: Fri, 21 Nov 2025 09:26:05 +0800 Subject: [PATCH 07/14] Headless enhancement: add `stream-json` as `input-format`/`output-format` to support programmatically use (#926) --- .vscode/launch.json | 11 +- docs/cli/configuration.md | 19 +- docs/features/headless.md | 217 +-- integration-tests/json-output.test.ts | 266 ++- integration-tests/test-helper.ts | 21 +- packages/cli/package.json | 7 + packages/cli/src/config/config.test.ts | 71 + packages/cli/src/config/config.ts | 95 +- packages/cli/src/config/settings.ts | 21 + packages/cli/src/gemini.test.tsx | 142 ++ packages/cli/src/gemini.tsx | 138 +- .../nonInteractive/control/ControlContext.ts | 76 + .../control/ControlDispatcher.test.ts | 924 ++++++++++ .../control/ControlDispatcher.ts | 353 ++++ .../nonInteractive/control/ControlService.ts | 191 ++ .../control/controllers/baseController.ts | 180 ++ .../control/controllers/hookController.ts | 56 + .../control/controllers/mcpController.ts | 287 +++ .../controllers/permissionController.ts | 483 +++++ .../control/controllers/systemController.ts | 215 +++ .../control/types/serviceAPIs.ts | 139 ++ .../io/BaseJsonOutputAdapter.test.ts | 1571 +++++++++++++++++ .../io/BaseJsonOutputAdapter.ts | 1228 +++++++++++++ .../io/JsonOutputAdapter.test.ts | 791 +++++++++ .../nonInteractive/io/JsonOutputAdapter.ts | 81 + .../io/StreamJsonInputReader.test.ts | 215 +++ .../io/StreamJsonInputReader.ts | 73 + .../io/StreamJsonOutputAdapter.test.ts | 997 +++++++++++ .../io/StreamJsonOutputAdapter.ts | 300 ++++ .../cli/src/nonInteractive/session.test.ts | 591 +++++++ packages/cli/src/nonInteractive/session.ts | 721 ++++++++ packages/cli/src/nonInteractive/types.ts | 509 ++++++ packages/cli/src/nonInteractiveCli.test.ts | 992 ++++++++++- packages/cli/src/nonInteractiveCli.ts | 338 +++- packages/cli/src/utils/errors.test.ts | 255 +-- packages/cli/src/utils/errors.ts | 32 +- .../src/utils/nonInteractiveHelpers.test.ts | 1168 ++++++++++++ .../cli/src/utils/nonInteractiveHelpers.ts | 624 +++++++ .../src/validateNonInterActiveAuth.test.ts | 240 ++- .../cli/src/validateNonInterActiveAuth.ts | 45 +- packages/core/src/config/config.ts | 51 +- .../core/src/core/coreToolScheduler.test.ts | 253 ++- packages/core/src/core/coreToolScheduler.ts | 61 +- .../src/core/nonInteractiveToolExecutor.ts | 23 +- packages/core/src/output/types.ts | 6 + .../core/src/subagents/subagent-events.ts | 2 + packages/core/src/subagents/subagent.ts | 7 + packages/core/src/tools/task.ts | 2 +- packages/core/src/tools/tool-error.ts | 2 + packages/core/src/tools/tools.ts | 3 +- 50 files changed, 14559 insertions(+), 534 deletions(-) create mode 100644 packages/cli/src/nonInteractive/control/ControlContext.ts create mode 100644 packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts create mode 100644 packages/cli/src/nonInteractive/control/ControlDispatcher.ts create mode 100644 packages/cli/src/nonInteractive/control/ControlService.ts create mode 100644 packages/cli/src/nonInteractive/control/controllers/baseController.ts create mode 100644 packages/cli/src/nonInteractive/control/controllers/hookController.ts create mode 100644 packages/cli/src/nonInteractive/control/controllers/mcpController.ts create mode 100644 packages/cli/src/nonInteractive/control/controllers/permissionController.ts create mode 100644 packages/cli/src/nonInteractive/control/controllers/systemController.ts create mode 100644 packages/cli/src/nonInteractive/control/types/serviceAPIs.ts create mode 100644 packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts create mode 100644 packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts create mode 100644 packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts create mode 100644 packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts create mode 100644 packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts create mode 100644 packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts create mode 100644 packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts create mode 100644 packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts create mode 100644 packages/cli/src/nonInteractive/session.test.ts create mode 100644 packages/cli/src/nonInteractive/session.ts create mode 100644 packages/cli/src/nonInteractive/types.ts create mode 100644 packages/cli/src/utils/nonInteractiveHelpers.test.ts create mode 100644 packages/cli/src/utils/nonInteractiveHelpers.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 1966371c..d98757fb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -73,7 +73,16 @@ "request": "launch", "name": "Launch CLI Non-Interactive", "runtimeExecutable": "npm", - "runtimeArgs": ["run", "start", "--", "-p", "${input:prompt}", "-y"], + "runtimeArgs": [ + "run", + "start", + "--", + "-p", + "${input:prompt}", + "-y", + "--output-format", + "stream-json" + ], "skipFiles": ["/**"], "cwd": "${workspaceFolder}", "console": "integratedTerminal", diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index f1c74e3e..a4ee80ba 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -548,12 +548,25 @@ Arguments passed directly when running the CLI can override other configurations - The prompt is processed within the interactive session, not before it. - Cannot be used when piping input from stdin. - Example: `qwen -i "explain this code"` -- **`--output-format `**: +- **`--output-format `** (**`-o `**): - **Description:** Specifies the format of the CLI output for non-interactive mode. - **Values:** - `text`: (Default) The standard human-readable output. - - `json`: A machine-readable JSON output. - - **Note:** For structured output and scripting, use the `--output-format json` flag. + - `json`: A machine-readable JSON output emitted at the end of execution. + - `stream-json`: Streaming JSON messages emitted as they occur during execution. + - **Note:** For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless.md) for detailed information. +- **`--input-format `**: + - **Description:** Specifies the format consumed from standard input. + - **Values:** + - `text`: (Default) Standard text input from stdin or command-line arguments. + - `stream-json`: JSON message protocol via stdin for bidirectional communication. + - **Requirement:** `--input-format stream-json` requires `--output-format stream-json` to be set. + - **Note:** When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless.md) for detailed information. +- **`--include-partial-messages`**: + - **Description:** Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. + - **Default:** `false` + - **Requirement:** Requires `--output-format stream-json` to be set. + - **Note:** See [Headless Mode](../features/headless.md) for detailed information about stream events. - **`--sandbox`** (**`-s`**): - Enables sandbox mode for this session. - **`--sandbox-image`**: diff --git a/docs/features/headless.md b/docs/features/headless.md index 165819df..7cf4ce4d 100644 --- a/docs/features/headless.md +++ b/docs/features/headless.md @@ -13,8 +13,9 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools. - [Output Formats](#output-formats) - [Text Output (Default)](#text-output-default) - [JSON Output](#json-output) - - [Response Schema](#response-schema) - [Example Usage](#example-usage) + - [Stream-JSON Output](#stream-json-output) + - [Input Format](#input-format) - [File Redirection](#file-redirection) - [Configuration Options](#configuration-options) - [Examples](#examples) @@ -22,7 +23,7 @@ scripting, automation, CI/CD pipelines, and building AI-powered tools. - [Generate commit messages](#generate-commit-messages) - [API documentation](#api-documentation) - [Batch code analysis](#batch-code-analysis) - - [Code review](#code-review-1) + - [PR code review](#pr-code-review) - [Log analysis](#log-analysis) - [Release notes generation](#release-notes-generation) - [Model and tool usage tracking](#model-and-tool-usage-tracking) @@ -66,6 +67,8 @@ cat README.md | qwen --prompt "Summarize this documentation" ## Output Formats +Qwen Code supports multiple output formats for different use cases: + ### Text Output (Default) Standard human-readable output: @@ -82,56 +85,9 @@ The capital of France is Paris. ### JSON Output -Returns structured data including response, statistics, and metadata. This -format is ideal for programmatic processing and automation scripts. +Returns structured data as a JSON array. All messages are buffered and output together when the session completes. This format is ideal for programmatic processing and automation scripts. -#### Response Schema - -The JSON output follows this high-level structure: - -```json -{ - "response": "string", // The main AI-generated content answering your prompt - "stats": { - // Usage metrics and performance data - "models": { - // Per-model API and token usage statistics - "[model-name]": { - "api": { - /* request counts, errors, latency */ - }, - "tokens": { - /* prompt, response, cached, total counts */ - } - } - }, - "tools": { - // Tool execution statistics - "totalCalls": "number", - "totalSuccess": "number", - "totalFail": "number", - "totalDurationMs": "number", - "totalDecisions": { - /* accept, reject, modify, auto_accept counts */ - }, - "byName": { - /* per-tool detailed stats */ - } - }, - "files": { - // File modification statistics - "totalLinesAdded": "number", - "totalLinesRemoved": "number" - } - }, - "error": { - // Present only when an error occurred - "type": "string", // Error type (e.g., "ApiError", "AuthError") - "message": "string", // Human-readable error description - "code": "number" // Optional error code - } -} -``` +The JSON output is an array of message objects. The output includes multiple message types: system messages (session initialization), assistant messages (AI responses), and result messages (execution summary). #### Example Usage @@ -139,63 +95,81 @@ The JSON output follows this high-level structure: qwen -p "What is the capital of France?" --output-format json ``` -Response: +Output (at end of execution): ```json -{ - "response": "The capital of France is Paris.", - "stats": { - "models": { - "qwen3-coder-plus": { - "api": { - "totalRequests": 2, - "totalErrors": 0, - "totalLatencyMs": 5053 - }, - "tokens": { - "prompt": 24939, - "candidates": 20, - "total": 25113, - "cached": 21263, - "thoughts": 154, - "tool": 0 +[ + { + "type": "system", + "subtype": "session_start", + "uuid": "...", + "session_id": "...", + "model": "qwen3-coder-plus", + ... + }, + { + "type": "assistant", + "uuid": "...", + "session_id": "...", + "message": { + "id": "...", + "type": "message", + "role": "assistant", + "model": "qwen3-coder-plus", + "content": [ + { + "type": "text", + "text": "The capital of France is Paris." } - } + ], + "usage": {...} }, - "tools": { - "totalCalls": 1, - "totalSuccess": 1, - "totalFail": 0, - "totalDurationMs": 1881, - "totalDecisions": { - "accept": 0, - "reject": 0, - "modify": 0, - "auto_accept": 1 - }, - "byName": { - "google_web_search": { - "count": 1, - "success": 1, - "fail": 0, - "durationMs": 1881, - "decisions": { - "accept": 0, - "reject": 0, - "modify": 0, - "auto_accept": 1 - } - } - } - }, - "files": { - "totalLinesAdded": 0, - "totalLinesRemoved": 0 - } + "parent_tool_use_id": null + }, + { + "type": "result", + "subtype": "success", + "uuid": "...", + "session_id": "...", + "is_error": false, + "duration_ms": 1234, + "result": "The capital of France is Paris.", + "usage": {...} } -} +] ``` +### Stream-JSON Output + +Stream-JSON format emits JSON messages immediately as they occur during execution, enabling real-time monitoring. This format uses line-delimited JSON where each message is a complete JSON object on a single line. + +```bash +qwen -p "Explain TypeScript" --output-format stream-json +``` + +Output (streaming as events occur): + +```json +{"type":"system","subtype":"session_start","uuid":"...","session_id":"..."} +{"type":"assistant","uuid":"...","session_id":"...","message":{...}} +{"type":"result","subtype":"success","uuid":"...","session_id":"..."} +``` + +When combined with `--include-partial-messages`, additional stream events are emitted in real-time (message_start, content_block_delta, etc.) for real-time UI updates. + +```bash +qwen -p "Write a Python script" --output-format stream-json --include-partial-messages +``` + +### Input Format + +The `--input-format` parameter controls how Qwen Code consumes input from standard input: + +- **`text`** (default): Standard text input from stdin or command-line arguments +- **`stream-json`**: JSON message protocol via stdin for bidirectional communication + +> **Note:** Stream-json input mode is currently under construction and is intended for SDK integration. It requires `--output-format stream-json` to be set. + ### File Redirection Save output to files or pipe to other commands: @@ -212,48 +186,53 @@ qwen -p "Add more details" >> docker-explanation.txt qwen -p "What is Kubernetes?" --output-format json | jq '.response' qwen -p "Explain microservices" | wc -w qwen -p "List programming languages" | grep -i "python" + +# Stream-JSON output for real-time processing +qwen -p "Explain Docker" --output-format stream-json | jq '.type' +qwen -p "Write code" --output-format stream-json --include-partial-messages | jq '.event.type' ``` ## Configuration Options Key command-line options for headless usage: -| Option | Description | Example | -| ----------------------- | ---------------------------------- | ------------------------------------------------ | -| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | -| `--output-format` | Specify output format (text, json) | `qwen -p "query" --output-format json` | -| `--model`, `-m` | Specify the Qwen model | `qwen -p "query" -m qwen3-coder-plus` | -| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | -| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | -| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | -| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | -| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | +| Option | Description | Example | +| ---------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------ | +| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` | +| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` | +| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` | +| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` | +| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` | +| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` | +| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` | +| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` | +| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` | For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](./cli/configuration.md). ## Examples -#### Code review +### Code review ```bash cat src/auth.py | qwen -p "Review this authentication code for security issues" > security-review.txt ``` -#### Generate commit messages +### Generate commit messages ```bash result=$(git diff --cached | qwen -p "Write a concise commit message for these changes" --output-format json) echo "$result" | jq -r '.response' ``` -#### API documentation +### API documentation ```bash result=$(cat api/routes.js | qwen -p "Generate OpenAPI spec for these routes" --output-format json) echo "$result" | jq -r '.response' > openapi.json ``` -#### Batch code analysis +### Batch code analysis ```bash for file in src/*.py; do @@ -264,20 +243,20 @@ for file in src/*.py; do done ``` -#### Code review +### PR code review ```bash result=$(git diff origin/main...HEAD | qwen -p "Review these changes for bugs, security issues, and code quality" --output-format json) echo "$result" | jq -r '.response' > pr-review.json ``` -#### Log analysis +### Log analysis ```bash grep "ERROR" /var/log/app.log | tail -20 | qwen -p "Analyze these errors and suggest root cause and fixes" > error-analysis.txt ``` -#### Release notes generation +### Release notes generation ```bash result=$(git log --oneline v1.0.0..HEAD | qwen -p "Generate release notes from these commits" --output-format json) @@ -286,7 +265,7 @@ echo "$response" echo "$response" >> CHANGELOG.md ``` -#### Model and tool usage tracking +### Model and tool usage tracking ```bash result=$(qwen -p "Explain this database schema" --include-directories db --output-format json) diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 6bd6df44..8221aa5b 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -19,7 +19,7 @@ describe('JSON output', () => { await rig.cleanup(); }); - it('should return a valid JSON with response and stats', async () => { + it('should return a valid JSON array with result message containing response and stats', async () => { const result = await rig.run( 'What is the capital of France?', '--output-format', @@ -27,12 +27,30 @@ describe('JSON output', () => { ); const parsed = JSON.parse(result); - expect(parsed).toHaveProperty('response'); - expect(typeof parsed.response).toBe('string'); - expect(parsed.response.toLowerCase()).toContain('paris'); + // The output should be an array of messages + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); - expect(parsed).toHaveProperty('stats'); - expect(typeof parsed.stats).toBe('object'); + // Find the result message (should be the last message) + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage).toBeDefined(); + expect(resultMessage).toHaveProperty('is_error'); + expect(resultMessage.is_error).toBe(false); + expect(resultMessage).toHaveProperty('result'); + expect(typeof resultMessage.result).toBe('string'); + expect(resultMessage.result.toLowerCase()).toContain('paris'); + + // Stats may be present if available + if ('stats' in resultMessage) { + expect(typeof resultMessage.stats).toBe('object'); + } }); it('should return a JSON error for enforced auth mismatch before running', async () => { @@ -56,32 +74,236 @@ describe('JSON output', () => { expect(thrown).toBeDefined(); const message = (thrown as Error).message; - // Use a regex to find the first complete JSON object in the string - const jsonMatch = message.match(/{[\s\S]*}/); - - // Fail if no JSON-like text was found + // The error JSON is written to stdout as a CLIResultMessageError + // Extract stdout from the error message + const stdoutMatch = message.match(/Stdout:\n([\s\S]*?)(?:\n\nStderr:|$)/); expect( - jsonMatch, - 'Expected to find a JSON object in the error output', + stdoutMatch, + 'Expected to find stdout in the error message', ).toBeTruthy(); - let payload; + const stdout = stdoutMatch![1]; + let parsed: unknown[]; try { - // Parse the matched JSON string - payload = JSON.parse(jsonMatch![0]); + // Parse the JSON array from stdout + parsed = JSON.parse(stdout); } catch (parseError) { - console.error('Failed to parse the following JSON:', jsonMatch![0]); + console.error('Failed to parse the following JSON:', stdout); throw new Error( - `Test failed: Could not parse JSON from error message. Details: ${parseError}`, + `Test failed: Could not parse JSON from stdout. Details: ${parseError}`, ); } - expect(payload.error).toBeDefined(); - expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); - expect(payload.error.message).toContain( + // The output should be an array of messages + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThan(0); + + // Find the result message with error + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result' && + 'is_error' in msg && + msg.is_error === true, + ) as { + type: string; + is_error: boolean; + subtype: string; + error?: { message: string; type?: string }; + }; + + expect(resultMessage).toBeDefined(); + expect(resultMessage.is_error).toBe(true); + expect(resultMessage).toHaveProperty('subtype'); + expect(resultMessage.subtype).toBe('error_during_execution'); + expect(resultMessage).toHaveProperty('error'); + expect(resultMessage.error).toBeDefined(); + expect(resultMessage.error?.message).toContain( 'configured auth type is qwen-oauth', ); - expect(payload.error.message).toContain('current auth type is openai'); + expect(resultMessage.error?.message).toContain( + 'current auth type is openai', + ); + }); + + it('should return line-delimited JSON messages for stream-json output format', async () => { + const result = await rig.run( + 'What is the capital of France?', + '--output-format', + 'stream-json', + ); + + // Stream-json output is line-delimited JSON (one JSON object per line) + const lines = result + .trim() + .split('\n') + .filter((line) => line.trim()); + expect(lines.length).toBeGreaterThan(0); + + // Parse each line as a JSON object + const messages: unknown[] = []; + for (const line of lines) { + try { + const parsed = JSON.parse(line); + messages.push(parsed); + } catch (parseError) { + throw new Error( + `Failed to parse JSON line: ${line}. Error: ${parseError}`, + ); + } + } + + // Should have at least system, assistant, and result messages + expect(messages.length).toBeGreaterThanOrEqual(3); + + // Find system message + const systemMessage = messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'system', + ); + expect(systemMessage).toBeDefined(); + expect(systemMessage).toHaveProperty('subtype'); + expect(systemMessage).toHaveProperty('session_id'); + + // Find assistant message + const assistantMessage = messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'assistant', + ); + expect(assistantMessage).toBeDefined(); + expect(assistantMessage).toHaveProperty('message'); + expect(assistantMessage).toHaveProperty('session_id'); + + // Find result message (should be the last message) + const resultMessage = messages[messages.length - 1] as { + type: string; + is_error: boolean; + result: string; + }; + expect(resultMessage).toBeDefined(); + expect( + typeof resultMessage === 'object' && + resultMessage !== null && + 'type' in resultMessage && + resultMessage.type === 'result', + ).toBe(true); + expect(resultMessage).toHaveProperty('is_error'); + expect(resultMessage.is_error).toBe(false); + expect(resultMessage).toHaveProperty('result'); + expect(typeof resultMessage.result).toBe('string'); + expect(resultMessage.result.toLowerCase()).toContain('paris'); + }); + + it('should include stream events when using stream-json with include-partial-messages', async () => { + const result = await rig.run( + 'What is the capital of France?', + '--output-format', + 'stream-json', + '--include-partial-messages', + ); + + // Stream-json output is line-delimited JSON (one JSON object per line) + const lines = result + .trim() + .split('\n') + .filter((line) => line.trim()); + expect(lines.length).toBeGreaterThan(0); + + // Parse each line as a JSON object + const messages: unknown[] = []; + for (const line of lines) { + try { + const parsed = JSON.parse(line); + messages.push(parsed); + } catch (parseError) { + throw new Error( + `Failed to parse JSON line: ${line}. Error: ${parseError}`, + ); + } + } + + // Should have more messages than without include-partial-messages + // because we're including stream events + expect(messages.length).toBeGreaterThan(3); + + // Find stream_event messages + const streamEvents = messages.filter( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'stream_event', + ); + expect(streamEvents.length).toBeGreaterThan(0); + + // Verify stream event structure + const firstStreamEvent = streamEvents[0]; + expect(firstStreamEvent).toHaveProperty('event'); + expect(firstStreamEvent).toHaveProperty('session_id'); + expect(firstStreamEvent).toHaveProperty('uuid'); + + // Check for expected stream event types + const eventTypes = streamEvents.map((event: unknown) => + typeof event === 'object' && + event !== null && + 'event' in event && + typeof event.event === 'object' && + event.event !== null && + 'type' in event.event + ? event.event.type + : null, + ); + + // Should have message_start event + expect(eventTypes).toContain('message_start'); + + // Should have content_block_start event + expect(eventTypes).toContain('content_block_start'); + + // Should have content_block_delta events + expect(eventTypes).toContain('content_block_delta'); + + // Should have content_block_stop event + expect(eventTypes).toContain('content_block_stop'); + + // Should have message_stop event + expect(eventTypes).toContain('message_stop'); + + // Verify that we still have the complete assistant message + const assistantMessage = messages.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'assistant', + ); + expect(assistantMessage).toBeDefined(); + expect(assistantMessage).toHaveProperty('message'); + + // Verify that we still have the result message + const resultMessage = messages[messages.length - 1] as { + type: string; + is_error: boolean; + result: string; + }; + expect(resultMessage).toBeDefined(); + expect( + typeof resultMessage === 'object' && + resultMessage !== null && + 'type' in resultMessage && + resultMessage.type === 'result', + ).toBe(true); + expect(resultMessage).toHaveProperty('is_error'); + expect(resultMessage.is_error).toBe(false); + expect(resultMessage).toHaveProperty('result'); + expect(resultMessage.result.toLowerCase()).toContain('paris'); }); }); diff --git a/integration-tests/test-helper.ts b/integration-tests/test-helper.ts index a1eb15c8..0fe658c5 100644 --- a/integration-tests/test-helper.ts +++ b/integration-tests/test-helper.ts @@ -340,7 +340,8 @@ export class TestRig { // as it would corrupt the JSON const isJsonOutput = commandArgs.includes('--output-format') && - commandArgs.includes('json'); + (commandArgs.includes('json') || + commandArgs.includes('stream-json')); // If we have stderr output and it's not a JSON test, include that also if (stderr && !isJsonOutput) { @@ -349,7 +350,23 @@ export class TestRig { resolve(result); } else { - reject(new Error(`Process exited with code ${code}:\n${stderr}`)); + // Check if this is a JSON output test - for JSON errors, the error is in stdout + const isJsonOutputOnError = + commandArgs.includes('--output-format') && + (commandArgs.includes('json') || + commandArgs.includes('stream-json')); + + // For JSON output tests, include stdout in the error message + // as the error JSON is written to stdout + if (isJsonOutputOnError && stdout) { + reject( + new Error( + `Process exited with code ${code}:\nStdout:\n${stdout}\n\nStderr:\n${stderr}`, + ), + ); + } else { + reject(new Error(`Process exited with code ${code}:\n${stderr}`)); + } } }); }); diff --git a/packages/cli/package.json b/packages/cli/package.json index bece2f31..cbee9b9b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -8,9 +8,16 @@ }, "type": "module", "main": "dist/index.js", + "types": "dist/index.d.ts", "bin": { "qwen": "dist/index.js" }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, "scripts": { "build": "node ../../scripts/build_package.js", "start": "node dist/index.js", diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index c08d9189..066fdd24 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -392,6 +392,49 @@ describe('parseArguments', () => { mockConsoleError.mockRestore(); }); + it('should throw an error when include-partial-messages is used without stream-json output', async () => { + process.argv = ['node', 'script.js', '--include-partial-messages']; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + const mockConsoleError = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + await expect(parseArguments({} as Settings)).rejects.toThrow( + 'process.exit called', + ); + + expect(mockConsoleError).toHaveBeenCalledWith( + expect.stringContaining( + '--include-partial-messages requires --output-format stream-json', + ), + ); + + mockExit.mockRestore(); + mockConsoleError.mockRestore(); + }); + + it('should parse stream-json formats and include-partial-messages flag', async () => { + process.argv = [ + 'node', + 'script.js', + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '--include-partial-messages', + ]; + + const argv = await parseArguments({} as Settings); + + expect(argv.outputFormat).toBe('stream-json'); + expect(argv.inputFormat).toBe('stream-json'); + expect(argv.includePartialMessages).toBe(true); + }); + it('should allow --approval-mode without --yolo', async () => { process.argv = ['node', 'script.js', '--approval-mode', 'auto-edit']; const argv = await parseArguments({} as Settings); @@ -473,6 +516,34 @@ describe('loadCliConfig', () => { vi.restoreAllMocks(); }); + it('should propagate stream-json formats to config', async () => { + process.argv = [ + 'node', + 'script.js', + '--output-format', + 'stream-json', + '--input-format', + 'stream-json', + '--include-partial-messages', + ]; + const argv = await parseArguments({} as Settings); + const settings: Settings = {}; + const config = await loadCliConfig( + settings, + [], + new ExtensionEnablementManager( + ExtensionStorage.getUserExtensionsDir(), + argv.extensions, + ), + 'test-session', + argv, + ); + + expect(config.getOutputFormat()).toBe('stream-json'); + expect(config.getInputFormat()).toBe('stream-json'); + expect(config.getIncludePartialMessages()).toBe(true); + }); + it('should set showMemoryUsage to true when --show-memory-usage flag is present', async () => { process.argv = ['node', 'script.js', '--show-memory-usage']; const argv = await parseArguments({} as Settings); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 7286ff12..dc07c473 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -7,7 +7,6 @@ import type { FileFilteringOptions, MCPServerConfig, - OutputFormat, } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { @@ -24,6 +23,8 @@ import { WriteFileTool, resolveTelemetrySettings, FatalConfigError, + InputFormat, + OutputFormat, } from '@qwen-code/qwen-code-core'; import type { Settings } from './settings.js'; import yargs, { type Argv } from 'yargs'; @@ -124,7 +125,24 @@ export interface CliArgs { screenReader: boolean | undefined; vlmSwitchMode: string | undefined; useSmartEdit: boolean | undefined; + inputFormat?: string | undefined; outputFormat: string | undefined; + includePartialMessages?: boolean; +} + +function normalizeOutputFormat( + format: string | OutputFormat | undefined, +): OutputFormat | undefined { + if (!format) { + return undefined; + } + if (format === OutputFormat.STREAM_JSON) { + return OutputFormat.STREAM_JSON; + } + if (format === 'json' || format === OutputFormat.JSON) { + return OutputFormat.JSON; + } + return OutputFormat.TEXT; } export async function parseArguments(settings: Settings): Promise { @@ -359,11 +377,23 @@ export async function parseArguments(settings: Settings): Promise { 'Default behavior when images are detected in input. Values: once (one-time switch), session (switch for entire session), persist (continue with current model). Overrides settings files.', default: process.env['VLM_SWITCH_MODE'], }) + .option('input-format', { + type: 'string', + choices: ['text', 'stream-json'], + description: 'The format consumed from standard input.', + default: 'text', + }) .option('output-format', { alias: 'o', type: 'string', description: 'The format of the CLI output.', - choices: ['text', 'json'], + choices: ['text', 'json', 'stream-json'], + }) + .option('include-partial-messages', { + type: 'boolean', + description: + 'Include partial assistant messages when using stream-json output.', + default: false, }) .deprecateOption( 'show-memory-usage', @@ -408,6 +438,18 @@ export async function parseArguments(settings: Settings): Promise { if (argv['yolo'] && argv['approvalMode']) { return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.'; } + if ( + argv['includePartialMessages'] && + argv['outputFormat'] !== OutputFormat.STREAM_JSON + ) { + return '--include-partial-messages requires --output-format stream-json'; + } + if ( + argv['inputFormat'] === 'stream-json' && + argv['outputFormat'] !== OutputFormat.STREAM_JSON + ) { + return '--input-format stream-json requires --output-format stream-json'; + } return true; }), ) @@ -588,6 +630,22 @@ export async function loadCliConfig( let mcpServers = mergeMcpServers(settings, activeExtensions); const question = argv.promptInteractive || argv.prompt || ''; + const inputFormat: InputFormat = + (argv.inputFormat as InputFormat | undefined) ?? InputFormat.TEXT; + const argvOutputFormat = normalizeOutputFormat( + argv.outputFormat as string | OutputFormat | undefined, + ); + const settingsOutputFormat = normalizeOutputFormat(settings.output?.format); + const outputFormat = + argvOutputFormat ?? settingsOutputFormat ?? OutputFormat.TEXT; + const outputSettingsFormat: OutputFormat = + outputFormat === OutputFormat.STREAM_JSON + ? settingsOutputFormat && + settingsOutputFormat !== OutputFormat.STREAM_JSON + ? settingsOutputFormat + : OutputFormat.TEXT + : (outputFormat as OutputFormat); + const includePartialMessages = Boolean(argv.includePartialMessages); // Determine approval mode with backward compatibility let approvalMode: ApprovalMode; @@ -629,11 +687,31 @@ export async function loadCliConfig( throw err; } - // Interactive mode: explicit -i flag or (TTY + no args + no -p flag) + // Interactive mode determination with priority: + // 1. If promptInteractive (-i flag) is provided, it is explicitly interactive + // 2. If outputFormat is stream-json or json (no matter input-format) along with query or prompt, it is non-interactive + // 3. If no query or prompt is provided, check isTTY: TTY means interactive, non-TTY means non-interactive const hasQuery = !!argv.query; - const interactive = - !!argv.promptInteractive || - (process.stdin.isTTY && !hasQuery && !argv.prompt); + const hasPrompt = !!argv.prompt; + let interactive: boolean; + if (argv.promptInteractive) { + // Priority 1: Explicit -i flag means interactive + interactive = true; + } else if ( + (outputFormat === OutputFormat.STREAM_JSON || + outputFormat === OutputFormat.JSON) && + (hasQuery || hasPrompt) + ) { + // Priority 2: JSON/stream-json output with query/prompt means non-interactive + interactive = false; + } else if (!hasQuery && !hasPrompt) { + // Priority 3: No query or prompt means interactive only if TTY (format arguments ignored) + interactive = process.stdin.isTTY ?? false; + } else { + // Default: If we have query/prompt but output format is TEXT, assume non-interactive + // (fallback for edge cases where query/prompt is provided with TEXT output) + interactive = false; + } // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; if (!interactive && !argv.experimentalAcp) { @@ -755,6 +833,9 @@ export async function loadCliConfig( blockedMcpServers, noBrowser: !!process.env['NO_BROWSER'], authType: settings.security?.auth?.selectedType, + inputFormat, + outputFormat, + includePartialMessages, generationConfig: { ...(settings.model?.generationConfig || {}), model: resolvedModel, @@ -798,7 +879,7 @@ export async function loadCliConfig( eventEmitter: appEvents, useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit, output: { - format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, + format: outputSettingsFormat, }, }); } diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 8ff022c8..ae29074b 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -483,6 +483,27 @@ export class LoadedSettings { } } +/** + * Creates a minimal LoadedSettings instance with empty settings. + * Used in stream-json mode where settings are ignored. + */ +export function createMinimalSettings(): LoadedSettings { + const emptySettingsFile: SettingsFile = { + path: '', + settings: {}, + originalSettings: {}, + rawJson: '{}', + }; + return new LoadedSettings( + emptySettingsFile, + emptySettingsFile, + emptySettingsFile, + emptySettingsFile, + false, + new Set(), + ); +} + function findEnvFile(startDir: string): string | null { let currentDir = path.resolve(startDir); while (true) { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index a5b34922..d928be0d 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -22,6 +22,7 @@ import { import { type LoadedSettings } from './config/settings.js'; import { appEvents, AppEvent } from './utils/events.js'; import type { Config } from '@qwen-code/qwen-code-core'; +import { OutputFormat } from '@qwen-code/qwen-code-core'; // Custom error to identify mock process.exit calls class MockProcessExitError extends Error { @@ -158,6 +159,7 @@ describe('gemini.tsx main function', () => { getScreenReader: () => false, getGeminiMdFileCount: () => 0, getProjectRoot: () => '/', + getOutputFormat: () => OutputFormat.TEXT, } as unknown as Config; }); vi.mocked(loadSettings).mockReturnValue({ @@ -230,6 +232,143 @@ describe('gemini.tsx main function', () => { // Avoid the process.exit error from being thrown. processExitSpy.mockRestore(); }); + + it('invokes runNonInteractiveStreamJson and performs cleanup in stream-json mode', async () => { + const originalIsTTY = Object.getOwnPropertyDescriptor( + process.stdin, + 'isTTY', + ); + const originalIsRaw = Object.getOwnPropertyDescriptor( + process.stdin, + 'isRaw', + ); + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdin, 'isRaw', { + value: false, + configurable: true, + }); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + const cleanupModule = await import('./utils/cleanup.js'); + const extensionModule = await import('./config/extension.js'); + const validatorModule = await import('./validateNonInterActiveAuth.js'); + const streamJsonModule = await import('./nonInteractive/session.js'); + const initializerModule = await import('./core/initializer.js'); + const startupWarningsModule = await import('./utils/startupWarnings.js'); + const userStartupWarningsModule = await import( + './utils/userStartupWarnings.js' + ); + + vi.mocked(cleanupModule.cleanupCheckpoints).mockResolvedValue(undefined); + vi.mocked(cleanupModule.registerCleanup).mockImplementation(() => {}); + const runExitCleanupMock = vi.mocked(cleanupModule.runExitCleanup); + runExitCleanupMock.mockResolvedValue(undefined); + vi.spyOn(extensionModule, 'loadExtensions').mockReturnValue([]); + vi.spyOn( + extensionModule.ExtensionStorage, + 'getUserExtensionsDir', + ).mockReturnValue('/tmp/extensions'); + vi.spyOn(initializerModule, 'initializeApp').mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }); + vi.spyOn(startupWarningsModule, 'getStartupWarnings').mockResolvedValue([]); + vi.spyOn( + userStartupWarningsModule, + 'getUserStartupWarnings', + ).mockResolvedValue([]); + + const validatedConfig = { validated: true } as unknown as Config; + const validateAuthSpy = vi + .spyOn(validatorModule, 'validateNonInteractiveAuth') + .mockResolvedValue(validatedConfig); + const runStreamJsonSpy = vi + .spyOn(streamJsonModule, 'runNonInteractiveStreamJson') + .mockResolvedValue(undefined); + + vi.mocked(loadSettings).mockReturnValue({ + errors: [], + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + } as never); + + vi.mocked(parseArguments).mockResolvedValue({ + extensions: [], + } as never); + + const configStub = { + isInteractive: () => false, + getQuestion: () => ' hello stream ', + getSandbox: () => false, + getDebugMode: () => false, + getListExtensions: () => false, + getMcpServers: () => ({}), + initialize: vi.fn().mockResolvedValue(undefined), + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, + getScreenReader: () => false, + getGeminiMdFileCount: () => 0, + getProjectRoot: () => '/', + getInputFormat: () => 'stream-json', + getContentGeneratorConfig: () => ({ authType: 'test-auth' }), + } as unknown as Config; + + vi.mocked(loadCliConfig).mockResolvedValue(configStub); + + process.env['SANDBOX'] = '1'; + try { + await main(); + } catch (error) { + if (!(error instanceof MockProcessExitError)) { + throw error; + } + } finally { + processExitSpy.mockRestore(); + if (originalIsTTY) { + Object.defineProperty(process.stdin, 'isTTY', originalIsTTY); + } else { + delete (process.stdin as { isTTY?: unknown }).isTTY; + } + if (originalIsRaw) { + Object.defineProperty(process.stdin, 'isRaw', originalIsRaw); + } else { + delete (process.stdin as { isRaw?: unknown }).isRaw; + } + delete process.env['SANDBOX']; + } + + expect(runStreamJsonSpy).toHaveBeenCalledTimes(1); + const [configArg, inputArg] = runStreamJsonSpy.mock.calls[0]; + expect(configArg).toBe(validatedConfig); + expect(inputArg).toBe('hello stream'); + + expect(validateAuthSpy).toHaveBeenCalledWith( + undefined, + undefined, + configStub, + expect.any(Object), + ); + expect(runExitCleanupMock).toHaveBeenCalledTimes(1); + }); }); describe('gemini.tsx main function kitty protocol', () => { @@ -337,7 +476,9 @@ describe('gemini.tsx main function kitty protocol', () => { screenReader: undefined, vlmSwitchMode: undefined, useSmartEdit: undefined, + inputFormat: undefined, outputFormat: undefined, + includePartialMessages: undefined, }); await main(); @@ -412,6 +553,7 @@ describe('startInteractiveUI', () => { vi.mock('./utils/cleanup.js', () => ({ cleanupCheckpoints: vi.fn(() => Promise.resolve()), registerCleanup: vi.fn(), + runExitCleanup: vi.fn(() => Promise.resolve()), })); vi.mock('ink', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 89a4c5ca..002f34c1 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,58 +4,60 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + AuthType, + getOauthClient, + InputFormat, + logUserPrompt, +} from '@qwen-code/qwen-code-core'; import { render } from 'ink'; -import { AppContainer } from './ui/AppContainer.js'; -import { loadCliConfig, parseArguments } from './config/config.js'; -import * as cliConfig from './config/config.js'; -import { readStdin } from './utils/readStdin.js'; +import { randomUUID } from 'node:crypto'; +import dns from 'node:dns'; +import os from 'node:os'; import { basename } from 'node:path'; import v8 from 'node:v8'; -import os from 'node:os'; -import dns from 'node:dns'; -import { randomUUID } from 'node:crypto'; -import { start_sandbox } from './utils/sandbox.js'; +import React from 'react'; +import { validateAuthMethod } from './config/auth.js'; +import * as cliConfig from './config/config.js'; +import { loadCliConfig, parseArguments } from './config/config.js'; +import { ExtensionStorage, loadExtensions } from './config/extension.js'; import type { DnsResolutionOrder, LoadedSettings } from './config/settings.js'; import { loadSettings, migrateDeprecatedSettings } from './config/settings.js'; -import { themeManager } from './ui/themes/theme-manager.js'; -import { getStartupWarnings } from './utils/startupWarnings.js'; -import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { + initializeApp, + type InitializationResult, +} from './core/initializer.js'; import { runNonInteractive } from './nonInteractiveCli.js'; -import { ExtensionStorage, loadExtensions } from './config/extension.js'; +import { runNonInteractiveStreamJson } from './nonInteractive/session.js'; +import { AppContainer } from './ui/AppContainer.js'; +import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; +import { KeypressProvider } from './ui/contexts/KeypressContext.js'; +import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; +import { SettingsContext } from './ui/contexts/SettingsContext.js'; +import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { themeManager } from './ui/themes/theme-manager.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; +import { checkForUpdates } from './ui/utils/updateCheck.js'; import { cleanupCheckpoints, registerCleanup, runExitCleanup, } from './utils/cleanup.js'; -import { getCliVersion } from './utils/version.js'; -import type { Config } from '@qwen-code/qwen-code-core'; -import { - AuthType, - getOauthClient, - logUserPrompt, -} from '@qwen-code/qwen-code-core'; -import { - initializeApp, - type InitializationResult, -} from './core/initializer.js'; -import { validateAuthMethod } from './config/auth.js'; -import { setMaxSizedBoxDebugging } from './ui/components/shared/MaxSizedBox.js'; -import { SettingsContext } from './ui/contexts/SettingsContext.js'; -import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; -import { checkForUpdates } from './ui/utils/updateCheck.js'; +import { AppEvent, appEvents } from './utils/events.js'; import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; -import { computeWindowTitle } from './utils/windowTitle.js'; -import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; -import { VimModeProvider } from './ui/contexts/VimModeContext.js'; -import { KeypressProvider } from './ui/contexts/KeypressContext.js'; -import { appEvents, AppEvent } from './utils/events.js'; -import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { readStdin } from './utils/readStdin.js'; import { - relaunchOnExitCode, relaunchAppInChildProcess, + relaunchOnExitCode, } from './utils/relaunch.js'; +import { start_sandbox } from './utils/sandbox.js'; +import { getStartupWarnings } from './utils/startupWarnings.js'; +import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; +import { getCliVersion } from './utils/version.js'; +import { computeWindowTitle } from './utils/windowTitle.js'; import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; export function validateDnsResolutionOrder( @@ -106,9 +108,9 @@ function getNodeMemoryArgs(isDebugMode: boolean): string[] { return []; } -import { runZedIntegration } from './zed-integration/zedIntegration.js'; -import { loadSandboxConfig } from './config/sandboxConfig.js'; import { ExtensionEnablementManager } from './config/extensions/extensionEnablement.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { runZedIntegration } from './zed-integration/zedIntegration.js'; export function setupUnhandledRejectionHandler() { let unhandledRejectionOccurred = false; @@ -218,12 +220,6 @@ export async function main() { } const isDebugMode = cliConfig.isDebugMode(argv); - const consolePatcher = new ConsolePatcher({ - stderr: true, - debugMode: isDebugMode, - }); - consolePatcher.patch(); - registerCleanup(consolePatcher.cleanup); dns.setDefaultResultOrder( validateDnsResolutionOrder(settings.merged.advanced?.dnsResolutionOrder), @@ -348,6 +344,15 @@ export async function main() { process.exit(0); } + // Setup unified ConsolePatcher based on interactive mode + const isInteractive = config.isInteractive(); + const consolePatcher = new ConsolePatcher({ + stderr: isInteractive, + debugMode: isDebugMode, + }); + consolePatcher.patch(); + registerCleanup(consolePatcher.cleanup); + const wasRaw = process.stdin.isRaw; let kittyProtocolDetectionComplete: Promise | undefined; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { @@ -410,14 +415,43 @@ export async function main() { await config.initialize(); - // If not a TTY, read from stdin - // This is for cases where the user pipes input directly into the command - if (!process.stdin.isTTY) { + // Check input format BEFORE reading stdin + // In STREAM_JSON mode, stdin should be left for StreamJsonInputReader + const inputFormat = + typeof config.getInputFormat === 'function' + ? config.getInputFormat() + : InputFormat.TEXT; + + // Only read stdin if NOT in stream-json mode + // In stream-json mode, stdin is used for protocol messages (control requests, etc.) + // and should be consumed by StreamJsonInputReader instead + if (inputFormat !== InputFormat.STREAM_JSON && !process.stdin.isTTY) { const stdinData = await readStdin(); if (stdinData) { input = `${stdinData}\n\n${input}`; } } + + const nonInteractiveConfig = await validateNonInteractiveAuth( + settings.merged.security?.auth?.selectedType, + settings.merged.security?.auth?.useExternal, + config, + settings, + ); + + const prompt_id = Math.random().toString(16).slice(2); + + if (inputFormat === InputFormat.STREAM_JSON) { + const trimmedInput = (input ?? '').trim(); + + await runNonInteractiveStreamJson( + nonInteractiveConfig, + trimmedInput.length > 0 ? trimmedInput : '', + ); + await runExitCleanup(); + process.exit(0); + } + if (!input) { console.error( `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, @@ -425,7 +459,6 @@ export async function main() { process.exit(1); } - const prompt_id = Math.random().toString(16).slice(2); logUserPrompt(config, { 'event.name': 'user_prompt', 'event.timestamp': new Date().toISOString(), @@ -435,13 +468,6 @@ export async function main() { prompt_length: input.length, }); - const nonInteractiveConfig = await validateNonInteractiveAuth( - settings.merged.security?.auth?.selectedType, - settings.merged.security?.auth?.useExternal, - config, - settings, - ); - if (config.getDebugMode()) { console.log('Session ID: %s', sessionId); } diff --git a/packages/cli/src/nonInteractive/control/ControlContext.ts b/packages/cli/src/nonInteractive/control/ControlContext.ts new file mode 100644 index 00000000..aa650d22 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/ControlContext.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Context + * + * Layer 1 of the control plane architecture. Provides shared, session-scoped + * state for all controllers and services, eliminating the need for prop + * drilling. Mutable fields are intentionally exposed so controllers can track + * runtime state (e.g. permission mode, active MCP clients). + */ + +import type { Config, MCPServerConfig } from '@qwen-code/qwen-code-core'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js'; +import type { PermissionMode } from '../types.js'; + +/** + * Control Context interface + * + * Provides shared access to session-scoped resources and mutable state + * for all controllers across both ControlDispatcher (protocol routing) and + * ControlService (programmatic API). + */ +export interface IControlContext { + readonly config: Config; + readonly streamJson: StreamJsonOutputAdapter; + readonly sessionId: string; + readonly abortSignal: AbortSignal; + readonly debugMode: boolean; + + permissionMode: PermissionMode; + sdkMcpServers: Set; + mcpClients: Map; + + onInterrupt?: () => void; +} + +/** + * Control Context implementation + */ +export class ControlContext implements IControlContext { + readonly config: Config; + readonly streamJson: StreamJsonOutputAdapter; + readonly sessionId: string; + readonly abortSignal: AbortSignal; + readonly debugMode: boolean; + + permissionMode: PermissionMode; + sdkMcpServers: Set; + mcpClients: Map; + + onInterrupt?: () => void; + + constructor(options: { + config: Config; + streamJson: StreamJsonOutputAdapter; + sessionId: string; + abortSignal: AbortSignal; + permissionMode?: PermissionMode; + onInterrupt?: () => void; + }) { + this.config = options.config; + this.streamJson = options.streamJson; + this.sessionId = options.sessionId; + this.abortSignal = options.abortSignal; + this.debugMode = options.config.getDebugMode(); + this.permissionMode = options.permissionMode || 'default'; + this.sdkMcpServers = new Set(); + this.mcpClients = new Map(); + this.onInterrupt = options.onInterrupt; + } +} diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts new file mode 100644 index 00000000..3dca5bcb --- /dev/null +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.test.ts @@ -0,0 +1,924 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ControlDispatcher } from './ControlDispatcher.js'; +import type { IControlContext } from './ControlContext.js'; +import type { SystemController } from './controllers/systemController.js'; +import type { StreamJsonOutputAdapter } from '../io/StreamJsonOutputAdapter.js'; +import type { + CLIControlRequest, + CLIControlResponse, + ControlResponse, + ControlRequestPayload, + CLIControlInitializeRequest, + CLIControlInterruptRequest, + CLIControlSetModelRequest, + CLIControlSupportedCommandsRequest, +} from '../types.js'; + +/** + * Creates a mock control context for testing + */ +function createMockContext(debugMode: boolean = false): IControlContext { + const abortController = new AbortController(); + const mockStreamJson = { + send: vi.fn(), + } as unknown as StreamJsonOutputAdapter; + + const mockConfig = { + getDebugMode: vi.fn().mockReturnValue(debugMode), + }; + + return { + config: mockConfig as unknown as IControlContext['config'], + streamJson: mockStreamJson, + sessionId: 'test-session-id', + abortSignal: abortController.signal, + debugMode, + permissionMode: 'default', + sdkMcpServers: new Set(), + mcpClients: new Map(), + }; +} + +/** + * Creates a mock system controller for testing + */ +function createMockSystemController() { + return { + handleRequest: vi.fn(), + sendControlRequest: vi.fn(), + cleanup: vi.fn(), + } as unknown as SystemController; +} + +describe('ControlDispatcher', () => { + let dispatcher: ControlDispatcher; + let mockContext: IControlContext; + let mockSystemController: SystemController; + + beforeEach(() => { + mockContext = createMockContext(); + mockSystemController = createMockSystemController(); + + // Mock SystemController constructor + vi.doMock('./controllers/systemController.js', () => ({ + SystemController: vi.fn().mockImplementation(() => mockSystemController), + })); + + dispatcher = new ControlDispatcher(mockContext); + // Replace with mock controller for easier testing + ( + dispatcher as unknown as { systemController: SystemController } + ).systemController = mockSystemController; + }); + + describe('constructor', () => { + it('should initialize with context and create controllers', () => { + expect(dispatcher).toBeDefined(); + expect(dispatcher.systemController).toBeDefined(); + }); + + it('should listen to abort signal and shutdown when aborted', () => { + const abortController = new AbortController(); + + const context = { + ...createMockContext(), + abortSignal: abortController.signal, + }; + + const newDispatcher = new ControlDispatcher(context); + vi.spyOn(newDispatcher, 'shutdown'); + + abortController.abort(); + + // Give event loop a chance to process + return new Promise((resolve) => { + setImmediate(() => { + expect(newDispatcher.shutdown).toHaveBeenCalled(); + resolve(); + }); + }); + }); + }); + + describe('dispatch', () => { + it('should route initialize request to system controller', async () => { + const request: CLIControlRequest = { + type: 'control_request', + request_id: 'req-1', + request: { + subtype: 'initialize', + } as CLIControlInitializeRequest, + }; + + const mockResponse = { + subtype: 'initialize', + capabilities: { test: true }, + }; + + vi.mocked(mockSystemController.handleRequest).mockResolvedValue( + mockResponse, + ); + + await dispatcher.dispatch(request); + + expect(mockSystemController.handleRequest).toHaveBeenCalledWith( + request.request, + 'req-1', + ); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'req-1', + response: mockResponse, + }, + }); + }); + + it('should route interrupt request to system controller', async () => { + const request: CLIControlRequest = { + type: 'control_request', + request_id: 'req-2', + request: { + subtype: 'interrupt', + } as CLIControlInterruptRequest, + }; + + const mockResponse = { subtype: 'interrupt' }; + + vi.mocked(mockSystemController.handleRequest).mockResolvedValue( + mockResponse, + ); + + await dispatcher.dispatch(request); + + expect(mockSystemController.handleRequest).toHaveBeenCalledWith( + request.request, + 'req-2', + ); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'req-2', + response: mockResponse, + }, + }); + }); + + it('should route set_model request to system controller', async () => { + const request: CLIControlRequest = { + type: 'control_request', + request_id: 'req-3', + request: { + subtype: 'set_model', + model: 'test-model', + } as CLIControlSetModelRequest, + }; + + const mockResponse = { + subtype: 'set_model', + model: 'test-model', + }; + + vi.mocked(mockSystemController.handleRequest).mockResolvedValue( + mockResponse, + ); + + await dispatcher.dispatch(request); + + expect(mockSystemController.handleRequest).toHaveBeenCalledWith( + request.request, + 'req-3', + ); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'req-3', + response: mockResponse, + }, + }); + }); + + it('should route supported_commands request to system controller', async () => { + const request: CLIControlRequest = { + type: 'control_request', + request_id: 'req-4', + request: { + subtype: 'supported_commands', + } as CLIControlSupportedCommandsRequest, + }; + + const mockResponse = { + subtype: 'supported_commands', + commands: ['initialize', 'interrupt'], + }; + + vi.mocked(mockSystemController.handleRequest).mockResolvedValue( + mockResponse, + ); + + await dispatcher.dispatch(request); + + expect(mockSystemController.handleRequest).toHaveBeenCalledWith( + request.request, + 'req-4', + ); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'success', + request_id: 'req-4', + response: mockResponse, + }, + }); + }); + + it('should send error response when controller throws error', async () => { + const request: CLIControlRequest = { + type: 'control_request', + request_id: 'req-5', + request: { + subtype: 'initialize', + } as CLIControlInitializeRequest, + }; + + const error = new Error('Test error'); + vi.mocked(mockSystemController.handleRequest).mockRejectedValue(error); + + await dispatcher.dispatch(request); + + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'error', + request_id: 'req-5', + error: 'Test error', + }, + }); + }); + + it('should handle non-Error thrown values', async () => { + const request: CLIControlRequest = { + type: 'control_request', + request_id: 'req-6', + request: { + subtype: 'initialize', + } as CLIControlInitializeRequest, + }; + + vi.mocked(mockSystemController.handleRequest).mockRejectedValue( + 'String error', + ); + + await dispatcher.dispatch(request); + + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'error', + request_id: 'req-6', + error: 'String error', + }, + }); + }); + + it('should send error response for unknown request subtype', async () => { + const request = { + type: 'control_request' as const, + request_id: 'req-7', + request: { + subtype: 'unknown_subtype', + } as unknown as ControlRequestPayload, + }; + + await dispatcher.dispatch(request); + + // Dispatch catches errors and sends error response instead of throwing + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'error', + request_id: 'req-7', + error: 'Unknown control request subtype: unknown_subtype', + }, + }); + }); + }); + + describe('handleControlResponse', () => { + it('should resolve pending outgoing request on success response', () => { + const requestId = 'outgoing-req-1'; + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: { result: 'success' }, + }, + }; + + // Register a pending outgoing request + const resolve = vi.fn(); + const reject = vi.fn(); + const timeoutId = setTimeout(() => {}, 1000); + + // Access private method through type casting + ( + dispatcher as unknown as { + registerOutgoingRequest: ( + id: string, + controller: string, + resolve: (r: ControlResponse) => void, + reject: (e: Error) => void, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerOutgoingRequest( + requestId, + 'SystemController', + resolve, + reject, + timeoutId, + ); + + dispatcher.handleControlResponse(response); + + expect(resolve).toHaveBeenCalledWith(response.response); + expect(reject).not.toHaveBeenCalled(); + }); + + it('should reject pending outgoing request on error response', () => { + const requestId = 'outgoing-req-2'; + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error: 'Request failed', + }, + }; + + const resolve = vi.fn(); + const reject = vi.fn(); + const timeoutId = setTimeout(() => {}, 1000); + + ( + dispatcher as unknown as { + registerOutgoingRequest: ( + id: string, + controller: string, + resolve: (r: ControlResponse) => void, + reject: (e: Error) => void, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerOutgoingRequest( + requestId, + 'SystemController', + resolve, + reject, + timeoutId, + ); + + dispatcher.handleControlResponse(response); + + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Request failed', + }), + ); + expect(resolve).not.toHaveBeenCalled(); + }); + + it('should handle error object in error response', () => { + const requestId = 'outgoing-req-3'; + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error: { message: 'Detailed error', code: 500 }, + }, + }; + + const resolve = vi.fn(); + const reject = vi.fn(); + const timeoutId = setTimeout(() => {}, 1000); + + ( + dispatcher as unknown as { + registerOutgoingRequest: ( + id: string, + controller: string, + resolve: (r: ControlResponse) => void, + reject: (e: Error) => void, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerOutgoingRequest( + requestId, + 'SystemController', + resolve, + reject, + timeoutId, + ); + + dispatcher.handleControlResponse(response); + + expect(reject).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Detailed error', + }), + ); + }); + + it('should handle response for non-existent pending request gracefully', () => { + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: 'non-existent', + response: {}, + }, + }; + + // Should not throw + expect(() => dispatcher.handleControlResponse(response)).not.toThrow(); + }); + + it('should handle response for non-existent request in debug mode', () => { + const context = createMockContext(true); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const dispatcherWithDebug = new ControlDispatcher(context); + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: 'non-existent', + response: {}, + }, + }; + + dispatcherWithDebug.handleControlResponse(response); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + '[ControlDispatcher] No pending outgoing request for: non-existent', + ), + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('sendControlRequest', () => { + it('should delegate to system controller sendControlRequest', async () => { + const payload: ControlRequestPayload = { + subtype: 'initialize', + } as CLIControlInitializeRequest; + + const expectedResponse: ControlResponse = { + subtype: 'success', + request_id: 'test-id', + response: {}, + }; + + vi.mocked(mockSystemController.sendControlRequest).mockResolvedValue( + expectedResponse, + ); + + const result = await dispatcher.sendControlRequest(payload, 5000); + + expect(mockSystemController.sendControlRequest).toHaveBeenCalledWith( + payload, + 5000, + ); + expect(result).toBe(expectedResponse); + }); + }); + + describe('handleCancel', () => { + it('should cancel specific incoming request', () => { + const requestId = 'cancel-req-1'; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => {}, 1000); + + const abortSpy = vi.spyOn(abortController, 'abort'); + + ( + dispatcher as unknown as { + registerIncomingRequest: ( + id: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerIncomingRequest( + requestId, + 'SystemController', + abortController, + timeoutId, + ); + + dispatcher.handleCancel(requestId); + + expect(abortSpy).toHaveBeenCalled(); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error: 'Request cancelled', + }, + }); + }); + + it('should cancel all incoming requests when no requestId provided', () => { + const requestId1 = 'cancel-req-2'; + const requestId2 = 'cancel-req-3'; + + const abortController1 = new AbortController(); + const abortController2 = new AbortController(); + const timeoutId1 = setTimeout(() => {}, 1000); + const timeoutId2 = setTimeout(() => {}, 1000); + + const abortSpy1 = vi.spyOn(abortController1, 'abort'); + const abortSpy2 = vi.spyOn(abortController2, 'abort'); + + const register = ( + dispatcher as unknown as { + registerIncomingRequest: ( + id: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerIncomingRequest.bind(dispatcher); + + register(requestId1, 'SystemController', abortController1, timeoutId1); + register(requestId2, 'SystemController', abortController2, timeoutId2); + + dispatcher.handleCancel(); + + expect(abortSpy1).toHaveBeenCalled(); + expect(abortSpy2).toHaveBeenCalled(); + expect(mockContext.streamJson.send).toHaveBeenCalledTimes(2); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId1, + error: 'All requests cancelled', + }, + }); + expect(mockContext.streamJson.send).toHaveBeenCalledWith({ + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId2, + error: 'All requests cancelled', + }, + }); + }); + + it('should handle cancel of non-existent request gracefully', () => { + expect(() => dispatcher.handleCancel('non-existent')).not.toThrow(); + }); + + it('should log cancellation in debug mode', () => { + const context = createMockContext(true); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const dispatcherWithDebug = new ControlDispatcher(context); + const requestId = 'cancel-req-debug'; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => {}, 1000); + + ( + dispatcherWithDebug as unknown as { + registerIncomingRequest: ( + id: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerIncomingRequest( + requestId, + 'SystemController', + abortController, + timeoutId, + ); + + dispatcherWithDebug.handleCancel(requestId); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + '[ControlDispatcher] Cancelled incoming request: cancel-req-debug', + ), + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('shutdown', () => { + it('should cancel all pending incoming requests', () => { + const requestId1 = 'shutdown-req-1'; + const requestId2 = 'shutdown-req-2'; + + const abortController1 = new AbortController(); + const abortController2 = new AbortController(); + const timeoutId1 = setTimeout(() => {}, 1000); + const timeoutId2 = setTimeout(() => {}, 1000); + + const abortSpy1 = vi.spyOn(abortController1, 'abort'); + const abortSpy2 = vi.spyOn(abortController2, 'abort'); + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + const register = ( + dispatcher as unknown as { + registerIncomingRequest: ( + id: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerIncomingRequest.bind(dispatcher); + + register(requestId1, 'SystemController', abortController1, timeoutId1); + register(requestId2, 'SystemController', abortController2, timeoutId2); + + dispatcher.shutdown(); + + expect(abortSpy1).toHaveBeenCalled(); + expect(abortSpy2).toHaveBeenCalled(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2); + }); + + it('should reject all pending outgoing requests', () => { + const requestId1 = 'outgoing-shutdown-1'; + const requestId2 = 'outgoing-shutdown-2'; + + const reject1 = vi.fn(); + const reject2 = vi.fn(); + const timeoutId1 = setTimeout(() => {}, 1000); + const timeoutId2 = setTimeout(() => {}, 1000); + + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + const register = ( + dispatcher as unknown as { + registerOutgoingRequest: ( + id: string, + controller: string, + resolve: (r: ControlResponse) => void, + reject: (e: Error) => void, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerOutgoingRequest.bind(dispatcher); + + register(requestId1, 'SystemController', vi.fn(), reject1, timeoutId1); + register(requestId2, 'SystemController', vi.fn(), reject2, timeoutId2); + + dispatcher.shutdown(); + + expect(reject1).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Dispatcher shutdown', + }), + ); + expect(reject2).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Dispatcher shutdown', + }), + ); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId1); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId2); + }); + + it('should cleanup all controllers', () => { + vi.mocked(mockSystemController.cleanup).mockImplementation(() => {}); + + dispatcher.shutdown(); + + expect(mockSystemController.cleanup).toHaveBeenCalled(); + }); + + it('should log shutdown in debug mode', () => { + const context = createMockContext(true); + const consoleSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + + const dispatcherWithDebug = new ControlDispatcher(context); + + dispatcherWithDebug.shutdown(); + + expect(consoleSpy).toHaveBeenCalledWith( + '[ControlDispatcher] Shutting down', + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('pending request registry', () => { + describe('registerIncomingRequest', () => { + it('should register incoming request', () => { + const requestId = 'reg-incoming-1'; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => {}, 1000); + + ( + dispatcher as unknown as { + registerIncomingRequest: ( + id: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerIncomingRequest( + requestId, + 'SystemController', + abortController, + timeoutId, + ); + + // Verify it was registered by trying to cancel it + dispatcher.handleCancel(requestId); + expect(abortController.signal.aborted).toBe(true); + }); + }); + + describe('deregisterIncomingRequest', () => { + it('should deregister incoming request', () => { + const requestId = 'dereg-incoming-1'; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => {}, 1000); + + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + ( + dispatcher as unknown as { + registerIncomingRequest: ( + id: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ) => void; + deregisterIncomingRequest: (id: string) => void; + } + ).registerIncomingRequest( + requestId, + 'SystemController', + abortController, + timeoutId, + ); + + ( + dispatcher as unknown as { + deregisterIncomingRequest: (id: string) => void; + } + ).deregisterIncomingRequest(requestId); + + // Verify it was deregistered - cancel should not find it + const sendMock = vi.mocked(mockContext.streamJson.send); + const sendCallCount = sendMock.mock.calls.length; + dispatcher.handleCancel(requestId); + // Should not send cancel response for non-existent request + expect(sendMock.mock.calls.length).toBe(sendCallCount); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId); + }); + + it('should handle deregister of non-existent request gracefully', () => { + expect(() => { + ( + dispatcher as unknown as { + deregisterIncomingRequest: (id: string) => void; + } + ).deregisterIncomingRequest('non-existent'); + }).not.toThrow(); + }); + }); + + describe('registerOutgoingRequest', () => { + it('should register outgoing request', () => { + const requestId = 'reg-outgoing-1'; + const resolve = vi.fn(); + const reject = vi.fn(); + const timeoutId = setTimeout(() => {}, 1000); + + ( + dispatcher as unknown as { + registerOutgoingRequest: ( + id: string, + controller: string, + resolve: (r: ControlResponse) => void, + reject: (e: Error) => void, + timeoutId: NodeJS.Timeout, + ) => void; + } + ).registerOutgoingRequest( + requestId, + 'SystemController', + resolve, + reject, + timeoutId, + ); + + // Verify it was registered by handling a response + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: {}, + }, + }; + + dispatcher.handleControlResponse(response); + expect(resolve).toHaveBeenCalled(); + }); + }); + + describe('deregisterOutgoingRequest', () => { + it('should deregister outgoing request', () => { + const requestId = 'dereg-outgoing-1'; + const resolve = vi.fn(); + const reject = vi.fn(); + const timeoutId = setTimeout(() => {}, 1000); + + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + ( + dispatcher as unknown as { + registerOutgoingRequest: ( + id: string, + controller: string, + resolve: (r: ControlResponse) => void, + reject: (e: Error) => void, + timeoutId: NodeJS.Timeout, + ) => void; + deregisterOutgoingRequest: (id: string) => void; + } + ).registerOutgoingRequest( + requestId, + 'SystemController', + resolve, + reject, + timeoutId, + ); + + ( + dispatcher as unknown as { + deregisterOutgoingRequest: (id: string) => void; + } + ).deregisterOutgoingRequest(requestId); + + // Verify it was deregistered - response should not find it + const response: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: {}, + }, + }; + + dispatcher.handleControlResponse(response); + expect(resolve).not.toHaveBeenCalled(); + expect(clearTimeoutSpy).toHaveBeenCalledWith(timeoutId); + }); + + it('should handle deregister of non-existent request gracefully', () => { + expect(() => { + ( + dispatcher as unknown as { + deregisterOutgoingRequest: (id: string) => void; + } + ).deregisterOutgoingRequest('non-existent'); + }).not.toThrow(); + }); + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/control/ControlDispatcher.ts b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts new file mode 100644 index 00000000..fa1b0e0f --- /dev/null +++ b/packages/cli/src/nonInteractive/control/ControlDispatcher.ts @@ -0,0 +1,353 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Dispatcher + * + * Layer 2 of the control plane architecture. Routes control requests between + * SDK and CLI to appropriate controllers, manages pending request registries, + * and handles cancellation/cleanup. Application code MUST NOT depend on + * controller instances exposed by this class; instead, use ControlService, + * which wraps these controllers with a stable programmatic API. + * + * Controllers: + * - SystemController: initialize, interrupt, set_model, supported_commands + * - PermissionController: can_use_tool, set_permission_mode + * - MCPController: mcp_message, mcp_server_status + * - HookController: hook_callback + * + * Note: Control request types are centrally defined in the ControlRequestType + * enum in packages/sdk/typescript/src/types/controlRequests.ts + */ + +import type { IControlContext } from './ControlContext.js'; +import type { IPendingRequestRegistry } from './controllers/baseController.js'; +import { SystemController } from './controllers/systemController.js'; +// import { PermissionController } from './controllers/permissionController.js'; +// import { MCPController } from './controllers/mcpController.js'; +// import { HookController } from './controllers/hookController.js'; +import type { + CLIControlRequest, + CLIControlResponse, + ControlResponse, + ControlRequestPayload, +} from '../types.js'; + +/** + * Tracks an incoming request from SDK awaiting CLI response + */ +interface PendingIncomingRequest { + controller: string; + abortController: AbortController; + timeoutId: NodeJS.Timeout; +} + +/** + * Tracks an outgoing request from CLI awaiting SDK response + */ +interface PendingOutgoingRequest { + controller: string; + resolve: (response: ControlResponse) => void; + reject: (error: Error) => void; + timeoutId: NodeJS.Timeout; +} + +/** + * Central coordinator for control plane communication. + * Routes requests to controllers and manages request lifecycle. + */ +export class ControlDispatcher implements IPendingRequestRegistry { + private context: IControlContext; + + // Make controllers publicly accessible + readonly systemController: SystemController; + // readonly permissionController: PermissionController; + // readonly mcpController: MCPController; + // readonly hookController: HookController; + + // Central pending request registries + private pendingIncomingRequests: Map = + new Map(); + private pendingOutgoingRequests: Map = + new Map(); + + constructor(context: IControlContext) { + this.context = context; + + // Create domain controllers with context and registry + this.systemController = new SystemController( + context, + this, + 'SystemController', + ); + // this.permissionController = new PermissionController( + // context, + // this, + // 'PermissionController', + // ); + // this.mcpController = new MCPController(context, this, 'MCPController'); + // this.hookController = new HookController(context, this, 'HookController'); + + // Listen for main abort signal + this.context.abortSignal.addEventListener('abort', () => { + this.shutdown(); + }); + } + + /** + * Routes an incoming request to the appropriate controller and sends response + */ + async dispatch(request: CLIControlRequest): Promise { + const { request_id, request: payload } = request; + + try { + // Route to appropriate controller + const controller = this.getControllerForRequest(payload.subtype); + const response = await controller.handleRequest(payload, request_id); + + // Send success response + this.sendSuccessResponse(request_id, response); + } catch (error) { + // Send error response + const errorMessage = + error instanceof Error ? error.message : String(error); + this.sendErrorResponse(request_id, errorMessage); + } + } + + /** + * Processes response from SDK for an outgoing request + */ + handleControlResponse(response: CLIControlResponse): void { + const responsePayload = response.response; + const requestId = responsePayload.request_id; + + const pending = this.pendingOutgoingRequests.get(requestId); + if (!pending) { + // No pending request found - may have timed out or been cancelled + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] No pending outgoing request for: ${requestId}`, + ); + } + return; + } + + // Deregister + this.deregisterOutgoingRequest(requestId); + + // Resolve or reject based on response type + if (responsePayload.subtype === 'success') { + pending.resolve(responsePayload); + } else { + const errorMessage = + typeof responsePayload.error === 'string' + ? responsePayload.error + : (responsePayload.error?.message ?? 'Unknown error'); + pending.reject(new Error(errorMessage)); + } + } + + /** + * Sends a control request to SDK and waits for response + */ + async sendControlRequest( + payload: ControlRequestPayload, + timeoutMs?: number, + ): Promise { + // Delegate to system controller (or any controller, they all have the same method) + return this.systemController.sendControlRequest(payload, timeoutMs); + } + + /** + * Cancels a specific request or all pending requests + */ + handleCancel(requestId?: string): void { + if (requestId) { + // Cancel specific incoming request + const pending = this.pendingIncomingRequests.get(requestId); + if (pending) { + pending.abortController.abort(); + this.deregisterIncomingRequest(requestId); + this.sendErrorResponse(requestId, 'Request cancelled'); + + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] Cancelled incoming request: ${requestId}`, + ); + } + } + } else { + // Cancel ALL pending incoming requests + const requestIds = Array.from(this.pendingIncomingRequests.keys()); + for (const id of requestIds) { + const pending = this.pendingIncomingRequests.get(id); + if (pending) { + pending.abortController.abort(); + this.deregisterIncomingRequest(id); + this.sendErrorResponse(id, 'All requests cancelled'); + } + } + + if (this.context.debugMode) { + console.error( + `[ControlDispatcher] Cancelled all ${requestIds.length} pending incoming requests`, + ); + } + } + } + + /** + * Stops all pending requests and cleans up all controllers + */ + shutdown(): void { + if (this.context.debugMode) { + console.error('[ControlDispatcher] Shutting down'); + } + + // Cancel all incoming requests + for (const [ + _requestId, + pending, + ] of this.pendingIncomingRequests.entries()) { + pending.abortController.abort(); + clearTimeout(pending.timeoutId); + } + this.pendingIncomingRequests.clear(); + + // Cancel all outgoing requests + for (const [ + _requestId, + pending, + ] of this.pendingOutgoingRequests.entries()) { + clearTimeout(pending.timeoutId); + pending.reject(new Error('Dispatcher shutdown')); + } + this.pendingOutgoingRequests.clear(); + + // Cleanup controllers (MCP controller will close all clients) + this.systemController.cleanup(); + // this.permissionController.cleanup(); + // this.mcpController.cleanup(); + // this.hookController.cleanup(); + } + + /** + * Registers an incoming request in the pending registry + */ + registerIncomingRequest( + requestId: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ): void { + this.pendingIncomingRequests.set(requestId, { + controller, + abortController, + timeoutId, + }); + } + + /** + * Removes an incoming request from the pending registry + */ + deregisterIncomingRequest(requestId: string): void { + const pending = this.pendingIncomingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingIncomingRequests.delete(requestId); + } + } + + /** + * Registers an outgoing request in the pending registry + */ + registerOutgoingRequest( + requestId: string, + controller: string, + resolve: (response: ControlResponse) => void, + reject: (error: Error) => void, + timeoutId: NodeJS.Timeout, + ): void { + this.pendingOutgoingRequests.set(requestId, { + controller, + resolve, + reject, + timeoutId, + }); + } + + /** + * Removes an outgoing request from the pending registry + */ + deregisterOutgoingRequest(requestId: string): void { + const pending = this.pendingOutgoingRequests.get(requestId); + if (pending) { + clearTimeout(pending.timeoutId); + this.pendingOutgoingRequests.delete(requestId); + } + } + + /** + * Returns the controller that handles the given request subtype + */ + private getControllerForRequest(subtype: string) { + switch (subtype) { + case 'initialize': + case 'interrupt': + case 'set_model': + case 'supported_commands': + return this.systemController; + + // case 'can_use_tool': + // case 'set_permission_mode': + // return this.permissionController; + + // case 'mcp_message': + // case 'mcp_server_status': + // return this.mcpController; + + // case 'hook_callback': + // return this.hookController; + + default: + throw new Error(`Unknown control request subtype: ${subtype}`); + } + } + + /** + * Sends a success response back to SDK + */ + private sendSuccessResponse( + requestId: string, + response: Record, + ): void { + const controlResponse: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response, + }, + }; + this.context.streamJson.send(controlResponse); + } + + /** + * Sends an error response back to SDK + */ + private sendErrorResponse(requestId: string, error: string): void { + const controlResponse: CLIControlResponse = { + type: 'control_response', + response: { + subtype: 'error', + request_id: requestId, + error, + }, + }; + this.context.streamJson.send(controlResponse); + } +} diff --git a/packages/cli/src/nonInteractive/control/ControlService.ts b/packages/cli/src/nonInteractive/control/ControlService.ts new file mode 100644 index 00000000..7193fb63 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/ControlService.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Control Service - Public Programmatic API + * + * Provides type-safe access to control plane functionality for internal + * CLI code. This is the ONLY programmatic interface that should be used by: + * - nonInteractiveCli + * - Session managers + * - Tool execution handlers + * - Internal CLI logic + * + * DO NOT use ControlDispatcher or controllers directly from application code. + * + * Architecture: + * - ControlContext stores shared session state (Layer 1) + * - ControlDispatcher handles protocol-level routing (Layer 2) + * - ControlService provides programmatic API for internal CLI usage (Layer 3) + * + * ControlService and ControlDispatcher share controller instances to ensure + * a single source of truth. All higher level code MUST access the control + * plane exclusively through ControlService. + */ + +import type { IControlContext } from './ControlContext.js'; +import type { ControlDispatcher } from './ControlDispatcher.js'; +import type { + // PermissionServiceAPI, + SystemServiceAPI, + // McpServiceAPI, + // HookServiceAPI, +} from './types/serviceAPIs.js'; + +/** + * Control Service + * + * Facade layer providing domain-grouped APIs for control plane operations. + * Shares controller instances with ControlDispatcher to ensure single source + * of truth and state consistency. + */ +export class ControlService { + private dispatcher: ControlDispatcher; + + /** + * Construct ControlService + * + * @param context - Control context (unused directly, passed to dispatcher) + * @param dispatcher - Control dispatcher that owns the controller instances + */ + constructor(context: IControlContext, dispatcher: ControlDispatcher) { + this.dispatcher = dispatcher; + } + + /** + * Permission Domain API + * + * Handles tool execution permissions, approval checks, and callbacks. + * Delegates to the shared PermissionController instance. + */ + // get permission(): PermissionServiceAPI { + // const controller = this.dispatcher.permissionController; + // return { + // /** + // * Check if a tool should be allowed based on current permission settings + // * + // * Evaluates permission mode and tool registry to determine if execution + // * should proceed. Can optionally modify tool arguments based on confirmation details. + // * + // * @param toolRequest - Tool call request information + // * @param confirmationDetails - Optional confirmation details for UI + // * @returns Permission decision with optional updated arguments + // */ + // shouldAllowTool: controller.shouldAllowTool.bind(controller), + // + // /** + // * Build UI suggestions for tool confirmation dialogs + // * + // * Creates actionable permission suggestions based on tool confirmation details. + // * + // * @param confirmationDetails - Tool confirmation details + // * @returns Array of permission suggestions or null + // */ + // buildPermissionSuggestions: + // controller.buildPermissionSuggestions.bind(controller), + // + // /** + // * Get callback for monitoring tool call status updates + // * + // * Returns callback function for integration with CoreToolScheduler. + // * + // * @returns Callback function for tool call updates + // */ + // getToolCallUpdateCallback: + // controller.getToolCallUpdateCallback.bind(controller), + // }; + // } + + /** + * System Domain API + * + * Handles system-level operations and session management. + * Delegates to the shared SystemController instance. + */ + get system(): SystemServiceAPI { + const controller = this.dispatcher.systemController; + return { + /** + * Get control capabilities + * + * Returns the control capabilities object indicating what control + * features are available. Used exclusively for the initialize + * control response. System messages do not include capabilities. + * + * @returns Control capabilities object + */ + getControlCapabilities: () => controller.buildControlCapabilities(), + }; + } + + /** + * MCP Domain API + * + * Handles Model Context Protocol server interactions. + * Delegates to the shared MCPController instance. + */ + // get mcp(): McpServiceAPI { + // return { + // /** + // * Get or create MCP client for a server (lazy initialization) + // * + // * Returns existing client or creates new connection. + // * + // * @param serverName - Name of the MCP server + // * @returns Promise with client and config + // */ + // getMcpClient: async (serverName: string) => { + // // MCPController has a private method getOrCreateMcpClient + // // We need to expose it via the API + // // For now, throw error as placeholder + // // The actual implementation will be added when we update MCPController + // throw new Error( + // `getMcpClient not yet implemented in ControlService. Server: ${serverName}`, + // ); + // }, + // + // /** + // * List all available MCP servers + // * + // * Returns names of configured/connected MCP servers. + // * + // * @returns Array of server names + // */ + // listServers: () => { + // // Get servers from context + // const sdkServers = Array.from( + // this.dispatcher.mcpController['context'].sdkMcpServers, + // ); + // const cliServers = Array.from( + // this.dispatcher.mcpController['context'].mcpClients.keys(), + // ); + // return [...new Set([...sdkServers, ...cliServers])]; + // }, + // }; + // } + + /** + * Hook Domain API + * + * Handles hook callback processing (placeholder for future expansion). + * Delegates to the shared HookController instance. + */ + // get hook(): HookServiceAPI { + // // HookController has no public methods yet - controller access reserved for future use + // return {}; + // } + + /** + * Cleanup all controllers + * + * Should be called on session shutdown. Delegates to dispatcher's shutdown + * method to ensure all controllers are properly cleaned up. + */ + cleanup(): void { + // Delegate to dispatcher which manages controller cleanup + this.dispatcher.shutdown(); + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/baseController.ts b/packages/cli/src/nonInteractive/control/controllers/baseController.ts new file mode 100644 index 00000000..d2e20545 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/baseController.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Base Controller + * + * Abstract base class for domain-specific control plane controllers. + * Provides common functionality for: + * - Handling incoming control requests (SDK -> CLI) + * - Sending outgoing control requests (CLI -> SDK) + * - Request lifecycle management with timeout and cancellation + * - Integration with central pending request registry + */ + +import { randomUUID } from 'node:crypto'; +import type { IControlContext } from '../ControlContext.js'; +import type { + ControlRequestPayload, + ControlResponse, + CLIControlRequest, +} from '../../types.js'; + +const DEFAULT_REQUEST_TIMEOUT_MS = 30000; // 30 seconds + +/** + * Registry interface for controllers to register/deregister pending requests + */ +export interface IPendingRequestRegistry { + registerIncomingRequest( + requestId: string, + controller: string, + abortController: AbortController, + timeoutId: NodeJS.Timeout, + ): void; + deregisterIncomingRequest(requestId: string): void; + + registerOutgoingRequest( + requestId: string, + controller: string, + resolve: (response: ControlResponse) => void, + reject: (error: Error) => void, + timeoutId: NodeJS.Timeout, + ): void; + deregisterOutgoingRequest(requestId: string): void; +} + +/** + * Abstract base controller class + * + * Subclasses should implement handleRequestPayload() to process specific + * control request types. + */ +export abstract class BaseController { + protected context: IControlContext; + protected registry: IPendingRequestRegistry; + protected controllerName: string; + + constructor( + context: IControlContext, + registry: IPendingRequestRegistry, + controllerName: string, + ) { + this.context = context; + this.registry = registry; + this.controllerName = controllerName; + } + + /** + * Handle an incoming control request + * + * Manages lifecycle: register -> process -> deregister + */ + async handleRequest( + payload: ControlRequestPayload, + requestId: string, + ): Promise> { + const requestAbortController = new AbortController(); + + // Setup timeout + const timeoutId = setTimeout(() => { + requestAbortController.abort(); + this.registry.deregisterIncomingRequest(requestId); + if (this.context.debugMode) { + console.error(`[${this.controllerName}] Request timeout: ${requestId}`); + } + }, DEFAULT_REQUEST_TIMEOUT_MS); + + // Register with central registry + this.registry.registerIncomingRequest( + requestId, + this.controllerName, + requestAbortController, + timeoutId, + ); + + try { + const response = await this.handleRequestPayload( + payload, + requestAbortController.signal, + ); + + // Success - deregister + this.registry.deregisterIncomingRequest(requestId); + + return response; + } catch (error) { + // Error - deregister + this.registry.deregisterIncomingRequest(requestId); + throw error; + } + } + + /** + * Send an outgoing control request to SDK + * + * Manages lifecycle: register -> send -> wait for response -> deregister + */ + async sendControlRequest( + payload: ControlRequestPayload, + timeoutMs: number = DEFAULT_REQUEST_TIMEOUT_MS, + ): Promise { + const requestId = randomUUID(); + + return new Promise((resolve, reject) => { + // Setup timeout + const timeoutId = setTimeout(() => { + this.registry.deregisterOutgoingRequest(requestId); + reject(new Error('Control request timeout')); + if (this.context.debugMode) { + console.error( + `[${this.controllerName}] Outgoing request timeout: ${requestId}`, + ); + } + }, timeoutMs); + + // Register with central registry + this.registry.registerOutgoingRequest( + requestId, + this.controllerName, + resolve, + reject, + timeoutId, + ); + + // Send control request + const request: CLIControlRequest = { + type: 'control_request', + request_id: requestId, + request: payload, + }; + + try { + this.context.streamJson.send(request); + } catch (error) { + this.registry.deregisterOutgoingRequest(requestId); + reject(error); + } + }); + } + + /** + * Abstract method: Handle specific request payload + * + * Subclasses must implement this to process their domain-specific requests. + */ + protected abstract handleRequestPayload( + payload: ControlRequestPayload, + signal: AbortSignal, + ): Promise>; + + /** + * Cleanup resources + */ + cleanup(): void { + // Subclasses can override to add cleanup logic + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/hookController.ts b/packages/cli/src/nonInteractive/control/controllers/hookController.ts new file mode 100644 index 00000000..1043b7b8 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/hookController.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Hook Controller + * + * Handles hook-related control requests: + * - hook_callback: Process hook callbacks (placeholder for future) + */ + +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIHookCallbackRequest, +} from '../../types.js'; + +export class HookController extends BaseController { + /** + * Handle hook control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'hook_callback': + return this.handleHookCallback(payload as CLIHookCallbackRequest); + + default: + throw new Error(`Unsupported request subtype in HookController`); + } + } + + /** + * Handle hook_callback request + * + * Processes hook callbacks (placeholder implementation) + */ + private async handleHookCallback( + payload: CLIHookCallbackRequest, + ): Promise> { + if (this.context.debugMode) { + console.error(`[HookController] Hook callback: ${payload.callback_id}`); + } + + // Hook callback processing not yet implemented + return { + result: 'Hook callback processing not yet implemented', + callback_id: payload.callback_id, + tool_use_id: payload.tool_use_id, + }; + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/mcpController.ts b/packages/cli/src/nonInteractive/control/controllers/mcpController.ts new file mode 100644 index 00000000..fccafb67 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/mcpController.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MCP Controller + * + * Handles MCP-related control requests: + * - mcp_message: Route MCP messages + * - mcp_server_status: Return MCP server status + */ + +import { BaseController } from './baseController.js'; +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { ResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import type { + ControlRequestPayload, + CLIControlMcpMessageRequest, +} from '../../types.js'; +import type { + MCPServerConfig, + WorkspaceContext, +} from '@qwen-code/qwen-code-core'; +import { + connectToMcpServer, + MCP_DEFAULT_TIMEOUT_MSEC, +} from '@qwen-code/qwen-code-core'; + +export class MCPController extends BaseController { + /** + * Handle MCP control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'mcp_message': + return this.handleMcpMessage(payload as CLIControlMcpMessageRequest); + + case 'mcp_server_status': + return this.handleMcpStatus(); + + default: + throw new Error(`Unsupported request subtype in MCPController`); + } + } + + /** + * Handle mcp_message request + * + * Routes JSON-RPC messages to MCP servers + */ + private async handleMcpMessage( + payload: CLIControlMcpMessageRequest, + ): Promise> { + const serverNameRaw = payload.server_name; + if ( + typeof serverNameRaw !== 'string' || + serverNameRaw.trim().length === 0 + ) { + throw new Error('Missing server_name in mcp_message request'); + } + + const message = payload.message; + if (!message || typeof message !== 'object') { + throw new Error( + 'Missing or invalid message payload for mcp_message request', + ); + } + + // Get or create MCP client + let clientEntry: { client: Client; config: MCPServerConfig }; + try { + clientEntry = await this.getOrCreateMcpClient(serverNameRaw.trim()); + } catch (error) { + throw new Error( + error instanceof Error + ? error.message + : 'Failed to connect to MCP server', + ); + } + + const method = message.method; + if (typeof method !== 'string' || method.trim().length === 0) { + throw new Error('Invalid MCP message: missing method'); + } + + const jsonrpcVersion = + typeof message.jsonrpc === 'string' ? message.jsonrpc : '2.0'; + const messageId = message.id; + const params = message.params; + const timeout = + typeof clientEntry.config.timeout === 'number' + ? clientEntry.config.timeout + : MCP_DEFAULT_TIMEOUT_MSEC; + + try { + // Handle notification (no id) + if (messageId === undefined) { + await clientEntry.client.notification({ + method, + params, + }); + return { + subtype: 'mcp_message', + mcp_response: { + jsonrpc: jsonrpcVersion, + id: null, + result: { success: true, acknowledged: true }, + }, + }; + } + + // Handle request (with id) + const result = await clientEntry.client.request( + { + method, + params, + }, + ResultSchema, + { timeout }, + ); + + return { + subtype: 'mcp_message', + mcp_response: { + jsonrpc: jsonrpcVersion, + id: messageId, + result, + }, + }; + } catch (error) { + // If connection closed, remove from cache + if (error instanceof Error && /closed/i.test(error.message)) { + this.context.mcpClients.delete(serverNameRaw.trim()); + } + + const errorCode = + typeof (error as { code?: unknown })?.code === 'number' + ? ((error as { code: number }).code as number) + : -32603; + const errorMessage = + error instanceof Error + ? error.message + : 'Failed to execute MCP request'; + const errorData = (error as { data?: unknown })?.data; + + const errorBody: Record = { + code: errorCode, + message: errorMessage, + }; + if (errorData !== undefined) { + errorBody['data'] = errorData; + } + + return { + subtype: 'mcp_message', + mcp_response: { + jsonrpc: jsonrpcVersion, + id: messageId ?? null, + error: errorBody, + }, + }; + } + } + + /** + * Handle mcp_server_status request + * + * Returns status of registered MCP servers + */ + private async handleMcpStatus(): Promise> { + const status: Record = {}; + + // Include SDK MCP servers + for (const serverName of this.context.sdkMcpServers) { + status[serverName] = 'connected'; + } + + // Include CLI-managed MCP clients + for (const serverName of this.context.mcpClients.keys()) { + status[serverName] = 'connected'; + } + + if (this.context.debugMode) { + console.error( + `[MCPController] MCP status: ${Object.keys(status).length} servers`, + ); + } + + return status; + } + + /** + * Get or create MCP client for a server + * + * Implements lazy connection and caching + */ + private async getOrCreateMcpClient( + serverName: string, + ): Promise<{ client: Client; config: MCPServerConfig }> { + // Check cache first + const cached = this.context.mcpClients.get(serverName); + if (cached) { + return cached; + } + + // Get server configuration + const provider = this.context.config as unknown as { + getMcpServers?: () => Record | undefined; + getDebugMode?: () => boolean; + getWorkspaceContext?: () => unknown; + }; + + if (typeof provider.getMcpServers !== 'function') { + throw new Error(`MCP server "${serverName}" is not configured`); + } + + const servers = provider.getMcpServers() ?? {}; + const serverConfig = servers[serverName]; + if (!serverConfig) { + throw new Error(`MCP server "${serverName}" is not configured`); + } + + const debugMode = + typeof provider.getDebugMode === 'function' + ? provider.getDebugMode() + : false; + + const workspaceContext = + typeof provider.getWorkspaceContext === 'function' + ? provider.getWorkspaceContext() + : undefined; + + if (!workspaceContext) { + throw new Error('Workspace context is not available for MCP connection'); + } + + // Connect to MCP server + const client = await connectToMcpServer( + serverName, + serverConfig, + debugMode, + workspaceContext as WorkspaceContext, + ); + + // Cache the client + const entry = { client, config: serverConfig }; + this.context.mcpClients.set(serverName, entry); + + if (this.context.debugMode) { + console.error(`[MCPController] Connected to MCP server: ${serverName}`); + } + + return entry; + } + + /** + * Cleanup MCP clients + */ + override cleanup(): void { + if (this.context.debugMode) { + console.error( + `[MCPController] Cleaning up ${this.context.mcpClients.size} MCP clients`, + ); + } + + // Close all MCP clients + for (const [serverName, { client }] of this.context.mcpClients.entries()) { + try { + client.close(); + } catch (error) { + if (this.context.debugMode) { + console.error( + `[MCPController] Failed to close MCP client ${serverName}:`, + error, + ); + } + } + } + + this.context.mcpClients.clear(); + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts new file mode 100644 index 00000000..f93b4489 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -0,0 +1,483 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Permission Controller + * + * Handles permission-related control requests: + * - can_use_tool: Check if tool usage is allowed + * - set_permission_mode: Change permission mode at runtime + * + * Abstracts all permission logic from the session manager to keep it clean. + */ + +import type { + ToolCallRequestInfo, + WaitingToolCall, +} from '@qwen-code/qwen-code-core'; +import { + InputFormat, + ToolConfirmationOutcome, +} from '@qwen-code/qwen-code-core'; +import type { + CLIControlPermissionRequest, + CLIControlSetPermissionModeRequest, + ControlRequestPayload, + PermissionMode, + PermissionSuggestion, +} from '../../types.js'; +import { BaseController } from './baseController.js'; + +// Import ToolCallConfirmationDetails types for type alignment +type ToolConfirmationType = 'edit' | 'exec' | 'mcp' | 'info' | 'plan'; + +export class PermissionController extends BaseController { + private pendingOutgoingRequests = new Set(); + + /** + * Handle permission control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'can_use_tool': + return this.handleCanUseTool(payload as CLIControlPermissionRequest); + + case 'set_permission_mode': + return this.handleSetPermissionMode( + payload as CLIControlSetPermissionModeRequest, + ); + + default: + throw new Error(`Unsupported request subtype in PermissionController`); + } + } + + /** + * Handle can_use_tool request + * + * Comprehensive permission evaluation based on: + * - Permission mode (approval level) + * - Tool registry validation + * - Error handling with safe defaults + */ + private async handleCanUseTool( + payload: CLIControlPermissionRequest, + ): Promise> { + const toolName = payload.tool_name; + if ( + !toolName || + typeof toolName !== 'string' || + toolName.trim().length === 0 + ) { + return { + subtype: 'can_use_tool', + behavior: 'deny', + message: 'Missing or invalid tool_name in can_use_tool request', + }; + } + + let behavior: 'allow' | 'deny' = 'allow'; + let message: string | undefined; + + try { + // Check permission mode first + const permissionResult = this.checkPermissionMode(); + if (!permissionResult.allowed) { + behavior = 'deny'; + message = permissionResult.message; + } + + // Check tool registry if permission mode allows + if (behavior === 'allow') { + const registryResult = this.checkToolRegistry(toolName); + if (!registryResult.allowed) { + behavior = 'deny'; + message = registryResult.message; + } + } + } catch (error) { + behavior = 'deny'; + message = + error instanceof Error + ? `Failed to evaluate tool permission: ${error.message}` + : 'Failed to evaluate tool permission'; + } + + const response: Record = { + subtype: 'can_use_tool', + behavior, + }; + + if (message) { + response['message'] = message; + } + + return response; + } + + /** + * Check permission mode for tool execution + */ + private checkPermissionMode(): { allowed: boolean; message?: string } { + const mode = this.context.permissionMode; + + // Map permission modes to approval logic (aligned with VALID_APPROVAL_MODE_VALUES) + switch (mode) { + case 'yolo': // Allow all tools + case 'auto-edit': // Auto-approve edit operations + case 'plan': // Auto-approve planning operations + return { allowed: true }; + + case 'default': // TODO: allow all tools for test + default: + return { + allowed: false, + message: + 'Tool execution requires manual approval. Update permission mode or approve via host.', + }; + } + } + + /** + * Check if tool exists in registry + */ + private checkToolRegistry(toolName: string): { + allowed: boolean; + message?: string; + } { + try { + // Access tool registry through config + const config = this.context.config; + const registryProvider = config as unknown as { + getToolRegistry?: () => { + getTool?: (name: string) => unknown; + }; + }; + + if (typeof registryProvider.getToolRegistry === 'function') { + const registry = registryProvider.getToolRegistry(); + if ( + registry && + typeof registry.getTool === 'function' && + !registry.getTool(toolName) + ) { + return { + allowed: false, + message: `Tool "${toolName}" is not registered.`, + }; + } + } + + return { allowed: true }; + } catch (error) { + return { + allowed: false, + message: `Failed to check tool registry: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Handle set_permission_mode request + * + * Updates the permission mode in the context + */ + private async handleSetPermissionMode( + payload: CLIControlSetPermissionModeRequest, + ): Promise> { + const mode = payload.mode; + const validModes: PermissionMode[] = [ + 'default', + 'plan', + 'auto-edit', + 'yolo', + ]; + + if (!validModes.includes(mode)) { + throw new Error( + `Invalid permission mode: ${mode}. Valid values are: ${validModes.join(', ')}`, + ); + } + + this.context.permissionMode = mode; + + if (this.context.debugMode) { + console.error( + `[PermissionController] Permission mode updated to: ${mode}`, + ); + } + + return { status: 'updated', mode }; + } + + /** + * Build permission suggestions for tool confirmation UI + * + * This method creates UI suggestions based on tool confirmation details, + * helping the host application present appropriate permission options. + */ + buildPermissionSuggestions( + confirmationDetails: unknown, + ): PermissionSuggestion[] | null { + if ( + !confirmationDetails || + typeof confirmationDetails !== 'object' || + !('type' in confirmationDetails) + ) { + return null; + } + + const details = confirmationDetails as Record; + const type = String(details['type'] ?? ''); + const title = + typeof details['title'] === 'string' ? details['title'] : undefined; + + // Ensure type matches ToolCallConfirmationDetails union + const confirmationType = type as ToolConfirmationType; + + switch (confirmationType) { + case 'exec': // ToolExecuteConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Command', + description: `Execute: ${details['command']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this command execution', + }, + ]; + + case 'edit': // ToolEditConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Edit', + description: `Edit file: ${details['fileName']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this file edit', + }, + { + type: 'modify', + label: 'Review Changes', + description: 'Review the proposed changes before applying', + }, + ]; + + case 'plan': // ToolPlanConfirmationDetails + return [ + { + type: 'allow', + label: 'Approve Plan', + description: title || 'Execute the proposed plan', + }, + { + type: 'deny', + label: 'Reject Plan', + description: 'Do not execute this plan', + }, + ]; + + case 'mcp': // ToolMcpConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow MCP Call', + description: `${details['serverName']}: ${details['toolName']}`, + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this MCP server call', + }, + ]; + + case 'info': // ToolInfoConfirmationDetails + return [ + { + type: 'allow', + label: 'Allow Info Request', + description: title || 'Allow information request', + }, + { + type: 'deny', + label: 'Deny', + description: 'Block this information request', + }, + ]; + + default: + // Fallback for unknown types + return [ + { + type: 'allow', + label: 'Allow', + description: title || `Allow ${type} operation`, + }, + { + type: 'deny', + label: 'Deny', + description: `Block ${type} operation`, + }, + ]; + } + } + + /** + * Check if a tool should be executed based on current permission settings + * + * This is a convenience method for direct tool execution checks without + * going through the control request flow. + */ + async shouldAllowTool( + toolRequest: ToolCallRequestInfo, + confirmationDetails?: unknown, + ): Promise<{ + allowed: boolean; + message?: string; + updatedArgs?: Record; + }> { + // Check permission mode + const modeResult = this.checkPermissionMode(); + if (!modeResult.allowed) { + return { + allowed: false, + message: modeResult.message, + }; + } + + // Check tool registry + const registryResult = this.checkToolRegistry(toolRequest.name); + if (!registryResult.allowed) { + return { + allowed: false, + message: registryResult.message, + }; + } + + // If we have confirmation details, we could potentially modify args + // This is a hook for future enhancement + if (confirmationDetails) { + // Future: handle argument modifications based on confirmation details + } + + return { allowed: true }; + } + + /** + * Get callback for monitoring tool calls and handling outgoing permission requests + * This is passed to executeToolCall to hook into CoreToolScheduler updates + */ + getToolCallUpdateCallback(): (toolCalls: unknown[]) => void { + return (toolCalls: unknown[]) => { + for (const call of toolCalls) { + if ( + call && + typeof call === 'object' && + (call as { status?: string }).status === 'awaiting_approval' + ) { + const awaiting = call as WaitingToolCall; + if ( + typeof awaiting.confirmationDetails?.onConfirm === 'function' && + !this.pendingOutgoingRequests.has(awaiting.request.callId) + ) { + this.pendingOutgoingRequests.add(awaiting.request.callId); + void this.handleOutgoingPermissionRequest(awaiting); + } + } + } + }; + } + + /** + * Handle outgoing permission request + * + * Behavior depends on input format: + * - stream-json mode: Send can_use_tool to SDK and await response + * - Other modes: Check local approval mode and decide immediately + */ + private async handleOutgoingPermissionRequest( + toolCall: WaitingToolCall, + ): Promise { + try { + const inputFormat = this.context.config.getInputFormat?.(); + const isStreamJsonMode = inputFormat === InputFormat.STREAM_JSON; + + if (!isStreamJsonMode) { + // No SDK available - use local permission check + const modeCheck = this.checkPermissionMode(); + const outcome = modeCheck.allowed + ? ToolConfirmationOutcome.ProceedOnce + : ToolConfirmationOutcome.Cancel; + + await toolCall.confirmationDetails.onConfirm(outcome); + return; + } + + // Stream-json mode: ask SDK for permission + const permissionSuggestions = this.buildPermissionSuggestions( + toolCall.confirmationDetails, + ); + + const response = await this.sendControlRequest( + { + subtype: 'can_use_tool', + tool_name: toolCall.request.name, + tool_use_id: toolCall.request.callId, + input: toolCall.request.args, + permission_suggestions: permissionSuggestions, + blocked_path: null, + } as CLIControlPermissionRequest, + 30000, + ); + + if (response.subtype !== 'success') { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + return; + } + + const payload = (response.response || {}) as Record; + const behavior = String(payload['behavior'] || '').toLowerCase(); + + if (behavior === 'allow') { + // Handle updated input if provided + const updatedInput = payload['updatedInput']; + if (updatedInput && typeof updatedInput === 'object') { + toolCall.request.args = updatedInput as Record; + } + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + ); + } else { + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[PermissionController] Outgoing permission failed:', + error, + ); + } + await toolCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); + } finally { + this.pendingOutgoingRequests.delete(toolCall.request.callId); + } + } +} diff --git a/packages/cli/src/nonInteractive/control/controllers/systemController.ts b/packages/cli/src/nonInteractive/control/controllers/systemController.ts new file mode 100644 index 00000000..c3fc651b --- /dev/null +++ b/packages/cli/src/nonInteractive/control/controllers/systemController.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * System Controller + * + * Handles system-level control requests: + * - initialize: Setup session and return system info + * - interrupt: Cancel current operations + * - set_model: Switch model (placeholder) + */ + +import { BaseController } from './baseController.js'; +import type { + ControlRequestPayload, + CLIControlInitializeRequest, + CLIControlSetModelRequest, +} from '../../types.js'; + +export class SystemController extends BaseController { + /** + * Handle system control requests + */ + protected async handleRequestPayload( + payload: ControlRequestPayload, + _signal: AbortSignal, + ): Promise> { + switch (payload.subtype) { + case 'initialize': + return this.handleInitialize(payload as CLIControlInitializeRequest); + + case 'interrupt': + return this.handleInterrupt(); + + case 'set_model': + return this.handleSetModel(payload as CLIControlSetModelRequest); + + case 'supported_commands': + return this.handleSupportedCommands(); + + default: + throw new Error(`Unsupported request subtype in SystemController`); + } + } + + /** + * Handle initialize request + * + * Registers SDK MCP servers and returns capabilities + */ + private async handleInitialize( + payload: CLIControlInitializeRequest, + ): Promise> { + // Register SDK MCP servers if provided + if (payload.sdkMcpServers && Array.isArray(payload.sdkMcpServers)) { + for (const serverName of payload.sdkMcpServers) { + this.context.sdkMcpServers.add(serverName); + } + } + + // Build capabilities for response + const capabilities = this.buildControlCapabilities(); + + if (this.context.debugMode) { + console.error( + `[SystemController] Initialized with ${this.context.sdkMcpServers.size} SDK MCP servers`, + ); + } + + return { + subtype: 'initialize', + capabilities, + }; + } + + /** + * Build control capabilities for initialize control response + * + * This method constructs the control capabilities object that indicates + * what control features are available. It is used exclusively in the + * initialize control response. + */ + buildControlCapabilities(): Record { + const capabilities: Record = { + can_handle_can_use_tool: true, + can_handle_hook_callback: true, + can_set_permission_mode: + typeof this.context.config.setApprovalMode === 'function', + can_set_model: typeof this.context.config.setModel === 'function', + }; + + // Check if MCP message handling is available + try { + const mcpProvider = this.context.config as unknown as { + getMcpServers?: () => Record | undefined; + }; + if (typeof mcpProvider.getMcpServers === 'function') { + const servers = mcpProvider.getMcpServers(); + capabilities['can_handle_mcp_message'] = Boolean( + servers && Object.keys(servers).length > 0, + ); + } else { + capabilities['can_handle_mcp_message'] = false; + } + } catch (error) { + if (this.context.debugMode) { + console.error( + '[SystemController] Failed to determine MCP capability:', + error, + ); + } + capabilities['can_handle_mcp_message'] = false; + } + + return capabilities; + } + + /** + * Handle interrupt request + * + * Triggers the interrupt callback to cancel current operations + */ + private async handleInterrupt(): Promise> { + // Trigger interrupt callback if available + if (this.context.onInterrupt) { + this.context.onInterrupt(); + } + + // Abort the main signal to cancel ongoing operations + if (this.context.abortSignal && !this.context.abortSignal.aborted) { + // Note: We can't directly abort the signal, but the onInterrupt callback should handle this + if (this.context.debugMode) { + console.error('[SystemController] Interrupt signal triggered'); + } + } + + if (this.context.debugMode) { + console.error('[SystemController] Interrupt handled'); + } + + return { subtype: 'interrupt' }; + } + + /** + * Handle set_model request + * + * Implements actual model switching with validation and error handling + */ + private async handleSetModel( + payload: CLIControlSetModelRequest, + ): Promise> { + const model = payload.model; + + // Validate model parameter + if (typeof model !== 'string' || model.trim() === '') { + throw new Error('Invalid model specified for set_model request'); + } + + try { + // Attempt to set the model using config + await this.context.config.setModel(model); + + if (this.context.debugMode) { + console.error(`[SystemController] Model switched to: ${model}`); + } + + return { + subtype: 'set_model', + model, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Failed to set model'; + + if (this.context.debugMode) { + console.error( + `[SystemController] Failed to set model ${model}:`, + error, + ); + } + + throw new Error(errorMessage); + } + } + + /** + * Handle supported_commands request + * + * Returns list of supported control commands + * + * Note: This list should match the ControlRequestType enum in + * packages/sdk/typescript/src/types/controlRequests.ts + */ + private async handleSupportedCommands(): Promise> { + const commands = [ + 'initialize', + 'interrupt', + 'set_model', + 'supported_commands', + 'can_use_tool', + 'set_permission_mode', + 'mcp_message', + 'mcp_server_status', + 'hook_callback', + ]; + + return { + subtype: 'supported_commands', + commands, + }; + } +} diff --git a/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts new file mode 100644 index 00000000..c83637b7 --- /dev/null +++ b/packages/cli/src/nonInteractive/control/types/serviceAPIs.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Service API Types + * + * These interfaces define the public API contract for the ControlService facade. + * They provide type-safe, domain-grouped access to control plane functionality + * for internal CLI code (nonInteractiveCli, session managers, etc.). + */ + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { + ToolCallRequestInfo, + MCPServerConfig, +} from '@qwen-code/qwen-code-core'; +import type { PermissionSuggestion } from '../../types.js'; + +/** + * Permission Service API + * + * Provides permission-related operations including tool execution approval, + * permission suggestions, and tool call monitoring callbacks. + */ +export interface PermissionServiceAPI { + /** + * Check if a tool should be allowed based on current permission settings + * + * Evaluates permission mode and tool registry to determine if execution + * should proceed. Can optionally modify tool arguments based on confirmation details. + * + * @param toolRequest - Tool call request information containing name, args, and call ID + * @param confirmationDetails - Optional confirmation details for UI-driven approvals + * @returns Promise resolving to permission decision with optional updated arguments + */ + shouldAllowTool( + toolRequest: ToolCallRequestInfo, + confirmationDetails?: unknown, + ): Promise<{ + allowed: boolean; + message?: string; + updatedArgs?: Record; + }>; + + /** + * Build UI suggestions for tool confirmation dialogs + * + * Creates actionable permission suggestions based on tool confirmation details, + * helping host applications present appropriate approval/denial options. + * + * @param confirmationDetails - Tool confirmation details (type, title, metadata) + * @returns Array of permission suggestions or null if details are invalid + */ + buildPermissionSuggestions( + confirmationDetails: unknown, + ): PermissionSuggestion[] | null; + + /** + * Get callback for monitoring tool call status updates + * + * Returns a callback function that should be passed to executeToolCall + * to enable integration with CoreToolScheduler updates. This callback + * handles outgoing permission requests for tools awaiting approval. + * + * @returns Callback function that processes tool call updates + */ + getToolCallUpdateCallback(): (toolCalls: unknown[]) => void; +} + +/** + * System Service API + * + * Provides system-level operations for the control system. + * + * Note: System messages and slash commands are NOT part of the control system API. + * They are handled independently via buildSystemMessage() from nonInteractiveHelpers.ts, + * regardless of whether the control system is available. + */ +export interface SystemServiceAPI { + /** + * Get control capabilities + * + * Returns the control capabilities object indicating what control + * features are available. Used exclusively for the initialize control + * response. System messages do not include capabilities as they are + * independent of the control system. + * + * @returns Control capabilities object + */ + getControlCapabilities(): Record; +} + +/** + * MCP Service API + * + * Provides Model Context Protocol server interaction including + * lazy client initialization and server discovery. + */ +export interface McpServiceAPI { + /** + * Get or create MCP client for a server (lazy initialization) + * + * Returns an existing client from cache or creates a new connection + * if this is the first request for the server. Handles connection + * lifecycle and error recovery. + * + * @param serverName - Name of the MCP server to connect to + * @returns Promise resolving to client instance and server configuration + * @throws Error if server is not configured or connection fails + */ + getMcpClient(serverName: string): Promise<{ + client: Client; + config: MCPServerConfig; + }>; + + /** + * List all available MCP servers + * + * Returns names of both SDK-managed and CLI-managed MCP servers + * that are currently configured or connected. + * + * @returns Array of server names + */ + listServers(): string[]; +} + +/** + * Hook Service API + * + * Provides hook callback processing (placeholder for future expansion). + */ +export interface HookServiceAPI { + // Future: Hook-related methods will be added here + // For now, hook functionality is handled only via control requests + registerHookCallback(callback: unknown): void; +} diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts new file mode 100644 index 00000000..0ba94cbb --- /dev/null +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.test.ts @@ -0,0 +1,1571 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + GeminiEventType, + type Config, + type ServerGeminiStreamEvent, + type ToolCallRequestInfo, + type TaskResultDisplay, +} from '@qwen-code/qwen-code-core'; +import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; +import type { + CLIMessage, + CLIAssistantMessage, + ContentBlock, +} from '../types.js'; +import { + BaseJsonOutputAdapter, + type MessageState, + type ResultOptions, + partsToString, + partsToContentBlock, + toolResultContent, + extractTextFromBlocks, + createExtendedUsage, +} from './BaseJsonOutputAdapter.js'; + +/** + * Test implementation of BaseJsonOutputAdapter for unit testing. + * Captures emitted messages for verification. + */ +class TestJsonOutputAdapter extends BaseJsonOutputAdapter { + readonly emittedMessages: CLIMessage[] = []; + + protected emitMessageImpl(message: CLIMessage): void { + this.emittedMessages.push(message); + } + + protected shouldEmitStreamEvents(): boolean { + return false; + } + + finalizeAssistantMessage(): CLIAssistantMessage { + return this.finalizeAssistantMessageInternal( + this.mainAgentMessageState, + null, + ); + } + + emitResult(options: ResultOptions): void { + const resultMessage = this.buildResultMessage( + options, + this.lastAssistantMessage, + ); + this.emitMessageImpl(resultMessage); + } + + // Expose protected methods for testing + exposeGetMessageState(parentToolUseId: string | null): MessageState { + return this.getMessageState(parentToolUseId); + } + + exposeCreateMessageState(): MessageState { + return this.createMessageState(); + } + + exposeCreateUsage(metadata?: GenerateContentResponseUsageMetadata | null) { + return this.createUsage(metadata); + } + + exposeBuildMessage(parentToolUseId: string | null): CLIAssistantMessage { + return this.buildMessage(parentToolUseId); + } + + exposeFinalizePendingBlocks( + state: MessageState, + parentToolUseId?: string | null, + ): void { + this.finalizePendingBlocks(state, parentToolUseId); + } + + exposeOpenBlock(state: MessageState, index: number, block: unknown): void { + this.openBlock(state, index, block as ContentBlock); + } + + exposeCloseBlock(state: MessageState, index: number): void { + this.closeBlock(state, index); + } + + exposeEnsureBlockTypeConsistency( + state: MessageState, + targetType: 'text' | 'thinking' | 'tool_use', + parentToolUseId: string | null, + ): void { + this.ensureBlockTypeConsistency(state, targetType, parentToolUseId); + } + + exposeStartAssistantMessageInternal(state: MessageState): void { + this.startAssistantMessageInternal(state); + } + + exposeFinalizeAssistantMessageInternal( + state: MessageState, + parentToolUseId: string | null, + ): CLIAssistantMessage { + return this.finalizeAssistantMessageInternal(state, parentToolUseId); + } + + exposeAppendText( + state: MessageState, + fragment: string, + parentToolUseId: string | null, + ): void { + this.appendText(state, fragment, parentToolUseId); + } + + exposeAppendThinking( + state: MessageState, + subject?: string, + description?: string, + parentToolUseId?: string | null, + ): void { + this.appendThinking(state, subject, description, parentToolUseId); + } + + exposeAppendToolUse( + state: MessageState, + request: { callId: string; name: string; args: unknown }, + parentToolUseId: string | null, + ): void { + this.appendToolUse(state, request as ToolCallRequestInfo, parentToolUseId); + } + + exposeEnsureMessageStarted( + state: MessageState, + parentToolUseId: string | null, + ): void { + this.ensureMessageStarted(state, parentToolUseId); + } + + exposeCreateSubagentToolUseBlock( + state: MessageState, + toolCall: NonNullable[number], + parentToolUseId: string, + ) { + return this.createSubagentToolUseBlock(state, toolCall, parentToolUseId); + } + + exposeBuildResultMessage(options: ResultOptions) { + return this.buildResultMessage(options, this.lastAssistantMessage); + } + + exposeBuildSubagentErrorResult(errorMessage: string, numTurns: number) { + return this.buildSubagentErrorResult(errorMessage, numTurns); + } +} + +function createMockConfig(): Config { + return { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getModel: vi.fn().mockReturnValue('test-model'), + } as unknown as Config; +} + +describe('BaseJsonOutputAdapter', () => { + let adapter: TestJsonOutputAdapter; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = createMockConfig(); + adapter = new TestJsonOutputAdapter(mockConfig); + }); + + describe('createMessageState', () => { + it('should create a new message state with default values', () => { + const state = adapter.exposeCreateMessageState(); + + expect(state.messageId).toBeNull(); + expect(state.blocks).toEqual([]); + expect(state.openBlocks).toBeInstanceOf(Set); + expect(state.openBlocks.size).toBe(0); + expect(state.usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + expect(state.messageStarted).toBe(false); + expect(state.finalized).toBe(false); + expect(state.currentBlockType).toBeNull(); + }); + }); + + describe('getMessageState', () => { + it('should return main agent state for null parentToolUseId', () => { + const state = adapter.exposeGetMessageState(null); + expect(state).toBe(adapter['mainAgentMessageState']); + }); + + it('should create and return subagent state for non-null parentToolUseId', () => { + const parentToolUseId = 'parent-tool-1'; + const state1 = adapter.exposeGetMessageState(parentToolUseId); + const state2 = adapter.exposeGetMessageState(parentToolUseId); + + expect(state1).toBe(state2); + expect(state1).not.toBe(adapter['mainAgentMessageState']); + expect(adapter['subagentMessageStates'].has(parentToolUseId)).toBe(true); + }); + + it('should create separate states for different parentToolUseIds', () => { + const state1 = adapter.exposeGetMessageState('parent-1'); + const state2 = adapter.exposeGetMessageState('parent-2'); + + expect(state1).not.toBe(state2); + }); + }); + + describe('createUsage', () => { + it('should create usage with default values when metadata is not provided', () => { + const usage = adapter.exposeCreateUsage(); + + expect(usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + + it('should create usage with null metadata', () => { + const usage = adapter.exposeCreateUsage(null); + + expect(usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + + it('should extract usage from metadata', () => { + const metadata: GenerateContentResponseUsageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + totalTokenCount: 160, + }; + + const usage = adapter.exposeCreateUsage(metadata); + + expect(usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 10, + total_tokens: 160, + }); + }); + + it('should handle partial metadata', () => { + const metadata: GenerateContentResponseUsageMetadata = { + promptTokenCount: 100, + // candidatesTokenCount missing + }; + + const usage = adapter.exposeCreateUsage(metadata); + + expect(usage).toEqual({ + input_tokens: 100, + output_tokens: 0, + }); + }); + }); + + describe('buildMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should throw error if message not started', () => { + // Manipulate the actual main agent state used by buildMessage + const state = adapter['mainAgentMessageState']; + state.messageId = null; // Explicitly set to null to test error case + state.blocks = [{ type: 'text', text: 'test' }]; + + expect(() => adapter.exposeBuildMessage(null)).toThrow( + 'Message not started', + ); + }); + + it('should build message with text blocks', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello world', + }); + + const message = adapter.exposeBuildMessage(null); + + expect(message.type).toBe('assistant'); + expect(message.uuid).toBeTruthy(); + expect(message.session_id).toBe('test-session-id'); + expect(message.parent_tool_use_id).toBeNull(); + expect(message.message.role).toBe('assistant'); + expect(message.message.model).toBe('test-model'); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'Hello world', + }); + expect(message.message.stop_reason).toBeNull(); + }); + + it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.exposeBuildMessage(null); + + expect(message.message.stop_reason).toBe('tool_use'); + }); + + it('should enforce single block type constraint', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + state.messageId = 'test-id'; + state.blocks = [ + { type: 'text', text: 'text' }, + { type: 'thinking', thinking: 'thinking', signature: 'sig' }, + ]; + + expect(() => adapter.exposeBuildMessage(null)).toThrow( + 'Assistant message must contain only one type of ContentBlock', + ); + }); + }); + + describe('finalizePendingBlocks', () => { + it('should finalize text blocks', () => { + const state = adapter.exposeCreateMessageState(); + state.blocks = [{ type: 'text', text: 'test' }]; + const index = 0; + adapter.exposeOpenBlock(state, index, state.blocks[0]); + + adapter.exposeFinalizePendingBlocks(state); + + expect(state.openBlocks.has(index)).toBe(false); + }); + + it('should finalize thinking blocks', () => { + const state = adapter.exposeCreateMessageState(); + state.blocks = [{ type: 'thinking', thinking: 'test', signature: 'sig' }]; + const index = 0; + adapter.exposeOpenBlock(state, index, state.blocks[0]); + + adapter.exposeFinalizePendingBlocks(state); + + expect(state.openBlocks.has(index)).toBe(false); + }); + + it('should do nothing if no blocks', () => { + const state = adapter.exposeCreateMessageState(); + + expect(() => adapter.exposeFinalizePendingBlocks(state)).not.toThrow(); + }); + + it('should do nothing if last block is not text or thinking', () => { + const state = adapter.exposeCreateMessageState(); + state.blocks = [ + { + type: 'tool_use', + id: 'tool-1', + name: 'test', + input: {}, + }, + ]; + + expect(() => adapter.exposeFinalizePendingBlocks(state)).not.toThrow(); + }); + }); + + describe('openBlock and closeBlock', () => { + it('should add block index to openBlocks', () => { + const state = adapter.exposeCreateMessageState(); + const block = { type: 'text', text: 'test' }; + + adapter.exposeOpenBlock(state, 0, block); + + expect(state.openBlocks.has(0)).toBe(true); + }); + + it('should remove block index from openBlocks', () => { + const state = adapter.exposeCreateMessageState(); + const block = { type: 'text', text: 'test' }; + adapter.exposeOpenBlock(state, 0, block); + + adapter.exposeCloseBlock(state, 0); + + expect(state.openBlocks.has(0)).toBe(false); + }); + + it('should not throw when closing non-existent block', () => { + const state = adapter.exposeCreateMessageState(); + + expect(() => adapter.exposeCloseBlock(state, 0)).not.toThrow(); + }); + }); + + describe('ensureBlockTypeConsistency', () => { + it('should set currentBlockType if null', () => { + const state = adapter.exposeCreateMessageState(); + state.currentBlockType = null; + + adapter.exposeEnsureBlockTypeConsistency(state, 'text', null); + + expect(state.currentBlockType).toBe('text'); + }); + + it('should do nothing if currentBlockType matches target', () => { + const state = adapter.exposeCreateMessageState(); + state.currentBlockType = 'text'; + state.messageId = 'test-id'; + state.blocks = [{ type: 'text', text: 'test' }]; + + adapter.exposeEnsureBlockTypeConsistency(state, 'text', null); + + expect(state.currentBlockType).toBe('text'); + expect(state.blocks).toHaveLength(1); + }); + + it('should finalize and start new message when block type changes', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'text', + }); + + adapter.exposeEnsureBlockTypeConsistency(state, 'thinking', null); + + expect(state.currentBlockType).toBe('thinking'); + expect(state.blocks.length).toBe(0); + }); + }); + + describe('startAssistantMessageInternal', () => { + it('should reset message state', () => { + const state = adapter.exposeCreateMessageState(); + state.messageId = 'old-id'; + state.blocks = [{ type: 'text', text: 'old' }]; + state.openBlocks.add(0); + state.usage = { input_tokens: 100, output_tokens: 50 }; + state.messageStarted = true; + state.finalized = true; + state.currentBlockType = 'text'; + + adapter.exposeStartAssistantMessageInternal(state); + + expect(state.messageId).toBeTruthy(); + expect(state.messageId).not.toBe('old-id'); + expect(state.blocks).toEqual([]); + expect(state.openBlocks.size).toBe(0); + expect(state.usage).toEqual({ input_tokens: 0, output_tokens: 0 }); + expect(state.messageStarted).toBe(false); + expect(state.finalized).toBe(false); + expect(state.currentBlockType).toBeNull(); + }); + }); + + describe('finalizeAssistantMessageInternal', () => { + it('should return same message if already finalized', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + + const message1 = adapter.exposeFinalizeAssistantMessageInternal( + state, + null, + ); + const message2 = adapter.exposeFinalizeAssistantMessageInternal( + state, + null, + ); + + expect(message1).toEqual(message2); + expect(state.finalized).toBe(true); + }); + + it('should finalize pending blocks and emit message', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + + const message = adapter.exposeFinalizeAssistantMessageInternal( + state, + null, + ); + + expect(message).toBeDefined(); + expect(state.finalized).toBe(true); + expect(adapter.emittedMessages).toContain(message); + }); + + it('should close all open blocks', () => { + adapter.startAssistantMessage(); + const state = adapter['mainAgentMessageState']; + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + state.openBlocks.add(0); + + adapter.exposeFinalizeAssistantMessageInternal(state, null); + + expect(state.openBlocks.size).toBe(0); + }); + }); + + describe('appendText', () => { + it('should create new text block if none exists', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendText(state, 'Hello', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'Hello', + }); + }); + + it('should append to existing text block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + adapter.exposeAppendText(state, 'Hello', null); + + adapter.exposeAppendText(state, ' World', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'Hello World', + }); + }); + + it('should ignore empty fragments', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendText(state, '', null); + + expect(state.blocks).toHaveLength(0); + }); + + it('should ensure message is started', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendText(state, 'test', null); + + expect(state.messageStarted).toBe(true); + }); + }); + + describe('appendThinking', () => { + it('should create new thinking block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking( + state, + 'Planning', + 'Thinking about task', + null, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + thinking: 'Planning: Thinking about task', + signature: 'Planning', + }); + }); + + it('should append to existing thinking block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + adapter.exposeAppendThinking(state, 'Planning', 'First thought', null); + + adapter.exposeAppendThinking(state, 'Planning', 'Second thought', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0].type).toBe('thinking'); + const block = state.blocks[0] as { thinking: string }; + expect(block.thinking).toContain('First thought'); + expect(block.thinking).toContain('Second thought'); + }); + + it('should handle only subject', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking(state, 'Planning', '', null); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + signature: 'Planning', + }); + }); + + it('should ignore empty fragments', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendThinking(state, '', '', null); + + expect(state.blocks).toHaveLength(0); + }); + }); + + describe('appendToolUse', () => { + it('should create tool_use block', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeAppendToolUse( + state, + { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + }, + null, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'tool_use', + id: 'tool-1', + name: 'test_tool', + input: { param: 'value' }, + }); + }); + + it('should finalize pending blocks before appending tool_use', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + adapter.exposeAppendText(state, 'text', null); + + adapter.exposeAppendToolUse( + state, + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + }, + null, + ); + + expect(state.blocks.length).toBeGreaterThan(0); + const toolUseBlock = state.blocks.find((b) => b.type === 'tool_use'); + expect(toolUseBlock).toBeDefined(); + }); + }); + + describe('ensureMessageStarted', () => { + it('should set messageStarted to true', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + + adapter.exposeEnsureMessageStarted(state, null); + + expect(state.messageStarted).toBe(true); + }); + + it('should do nothing if already started', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + state.messageStarted = true; + + adapter.exposeEnsureMessageStarted(state, null); + + expect(state.messageStarted).toBe(true); + }); + }); + + describe('startAssistantMessage', () => { + it('should reset main agent message state', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'test', + }); + + adapter.startAssistantMessage(); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(0); + expect(state.messageStarted).toBe(false); + }); + }); + + describe('processEvent', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should process Content events', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello', + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'Hello', + }); + }); + + it('should process Citation events', () => { + adapter.processEvent({ + type: GeminiEventType.Citation, + value: 'Citation text', + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks[0].type).toBe('text'); + const block = state.blocks[0] as { text: string }; + expect(block.text).toContain('Citation text'); + }); + + it('should ignore non-string Citation values', () => { + adapter.processEvent({ + type: GeminiEventType.Citation, + value: 123, + } as unknown as ServerGeminiStreamEvent); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(0); + }); + + it('should process Thought events', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking', + }, + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'thinking', + thinking: 'Planning: Thinking', + signature: 'Planning', + }); + }); + + it('should process ToolCallRequest events', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks).toHaveLength(1); + expect(state.blocks[0]).toMatchObject({ + type: 'tool_use', + id: 'tool-1', + name: 'test_tool', + input: { param: 'value' }, + }); + }); + + it('should process Finished events with usage metadata', () => { + adapter.processEvent({ + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + }, + }, + }); + + const state = adapter['mainAgentMessageState']; + expect(state.usage).toEqual({ + input_tokens: 100, + output_tokens: 50, + }); + }); + + it('should ignore events after finalization', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'First', + }); + adapter.finalizeAssistantMessage(); + + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Second', + }); + + const state = adapter['mainAgentMessageState']; + expect(state.blocks[0]).toMatchObject({ + type: 'text', + text: 'First', + }); + }); + }); + + describe('finalizeAssistantMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should build and return assistant message', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test response', + }); + + const message = adapter.finalizeAssistantMessage(); + + expect(message.type).toBe('assistant'); + expect(message.message.content).toHaveLength(1); + expect(adapter.emittedMessages).toContain(message); + }); + }); + + describe('emitUserMessage', () => { + it('should emit user message with ContentBlock array', () => { + const parts: Part[] = [{ text: 'Hello user' }]; + + adapter.emitUserMessage(parts); + + expect(adapter.emittedMessages).toHaveLength(1); + const message = adapter.emittedMessages[0]; + expect(message.type).toBe('user'); + if (message.type === 'user') { + expect(Array.isArray(message.message.content)).toBe(true); + if (Array.isArray(message.message.content)) { + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toEqual({ + type: 'text', + text: 'Hello user', + }); + } + expect(message.parent_tool_use_id).toBeNull(); + } + }); + + it('should handle multiple parts and merge into single text block', () => { + const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }]; + + adapter.emitUserMessage(parts); + + const message = adapter.emittedMessages[0]; + if (message.type === 'user' && Array.isArray(message.message.content)) { + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toEqual({ + type: 'text', + text: 'Hello World', + }); + } + }); + + it('should handle non-text parts by converting to text blocks', () => { + const parts: Part[] = [ + { text: 'Hello' }, + { functionCall: { name: 'test' } }, + ]; + + adapter.emitUserMessage(parts); + + const message = adapter.emittedMessages[0]; + if (message.type === 'user' && Array.isArray(message.message.content)) { + expect(message.message.content.length).toBeGreaterThan(0); + const textBlock = message.message.content.find( + (block) => block.type === 'text', + ); + expect(textBlock).toBeDefined(); + if (textBlock && textBlock.type === 'text') { + expect(textBlock.text).toContain('Hello'); + } + } + }); + }); + + describe('emitToolResult', () => { + it('should emit tool result message with content', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: 'Tool executed successfully', + error: undefined, + errorType: undefined, + }; + + adapter.emitToolResult(request, response); + + expect(adapter.emittedMessages).toHaveLength(1); + const message = adapter.emittedMessages[0]; + expect(message.type).toBe('user'); + if (message.type === 'user') { + expect(message.message.content).toHaveLength(1); + const block = message.message.content[0]; + if (typeof block === 'object' && block !== null && 'type' in block) { + expect(block.type).toBe('tool_result'); + if (block.type === 'tool_result') { + expect(block.tool_use_id).toBe('tool-1'); + expect(block.content).toBe('Tool executed successfully'); + expect(block.is_error).toBe(false); + } + } + } + }); + + it('should mark error tool results', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: undefined, + error: new Error('Tool failed'), + errorType: undefined, + }; + + adapter.emitToolResult(request, response); + + const message = adapter.emittedMessages[0]; + if (message.type === 'user') { + const block = message.message.content[0]; + if (typeof block === 'object' && block !== null && 'type' in block) { + if (block.type === 'tool_result') { + expect(block.is_error).toBe(true); + } + } + } + }); + + it('should handle parentToolUseId', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: 'Result', + error: undefined, + errorType: undefined, + }; + + adapter.emitToolResult(request, response, 'parent-tool-1'); + + const message = adapter.emittedMessages[0]; + if (message.type === 'user') { + expect(message.parent_tool_use_id).toBe('parent-tool-1'); + } + }); + }); + + describe('emitSystemMessage', () => { + it('should emit system message', () => { + adapter.emitSystemMessage('test_subtype', { data: 'value' }); + + expect(adapter.emittedMessages).toHaveLength(1); + const message = adapter.emittedMessages[0]; + expect(message.type).toBe('system'); + if (message.type === 'system') { + expect(message.subtype).toBe('test_subtype'); + expect(message.data).toEqual({ data: 'value' }); + } + }); + + it('should handle system message without data', () => { + adapter.emitSystemMessage('test_subtype'); + + const message = adapter.emittedMessages[0]; + if (message.type === 'system') { + expect(message.subtype).toBe('test_subtype'); + } + }); + }); + + describe('buildResultMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Response text', + }); + const message = adapter.finalizeAssistantMessage(); + // Update lastAssistantMessage manually since test adapter doesn't do it automatically + adapter['lastAssistantMessage'] = message; + }); + + it('should build success result message', () => { + const options: ResultOptions = { + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + expect(result.type).toBe('result'); + expect(result.is_error).toBe(false); + if (!result.is_error) { + expect(result.subtype).toBe('success'); + expect(result.result).toBe('Response text'); + expect(result.duration_ms).toBe(1000); + expect(result.duration_api_ms).toBe(800); + expect(result.num_turns).toBe(1); + } + }); + + it('should build error result message', () => { + const options: ResultOptions = { + isError: true, + errorMessage: 'Test error', + durationMs: 500, + apiDurationMs: 300, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + expect(result.type).toBe('result'); + expect(result.is_error).toBe(true); + if (result.is_error) { + expect(result.subtype).toBe('error_during_execution'); + expect(result.error?.message).toBe('Test error'); + } + }); + + it('should use provided summary over extracted text', () => { + const options: ResultOptions = { + isError: false, + summary: 'Custom summary', + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + if (!result.is_error) { + expect(result.result).toBe('Custom summary'); + } + }); + + it('should include usage information', () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }; + const options: ResultOptions = { + isError: false, + usage, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + expect(result.usage).toEqual(usage); + }); + + it('should include stats when provided', () => { + const stats = { + models: {}, + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + totalDurationMs: 1000, + totalDecisions: { + accept: 3, + reject: 1, + modify: 0, + auto_accept: 1, + }, + byName: {}, + }, + files: { + totalLinesAdded: 10, + totalLinesRemoved: 5, + }, + }; + const options: ResultOptions = { + isError: false, + stats, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + if (!result.is_error && 'stats' in result) { + expect(result['stats']).toEqual(stats); + } + }); + + it('should handle result without assistant message', () => { + adapter = new TestJsonOutputAdapter(mockConfig); + const options: ResultOptions = { + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }; + + const result = adapter.exposeBuildResultMessage(options); + + if (!result.is_error) { + expect(result.result).toBe(''); + } + }); + }); + + describe('startSubagentAssistantMessage', () => { + it('should start subagent message', () => { + const parentToolUseId = 'parent-tool-1'; + + adapter.startSubagentAssistantMessage(parentToolUseId); + + const state = adapter.exposeGetMessageState(parentToolUseId); + expect(state.messageId).toBeTruthy(); + expect(state.blocks).toEqual([]); + }); + }); + + describe('finalizeSubagentAssistantMessage', () => { + it('should finalize and return subagent message', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const state = adapter.exposeGetMessageState(parentToolUseId); + adapter.exposeAppendText(state, 'Subagent response', parentToolUseId); + + const message = adapter.finalizeSubagentAssistantMessage(parentToolUseId); + + expect(message.type).toBe('assistant'); + expect(message.parent_tool_use_id).toBe(parentToolUseId); + expect(message.message.content).toHaveLength(1); + }); + }); + + describe('emitSubagentErrorResult', () => { + it('should emit subagent error result', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + + adapter.emitSubagentErrorResult('Error occurred', 5, parentToolUseId); + + expect(adapter.emittedMessages.length).toBeGreaterThan(0); + const errorResult = adapter.emittedMessages.find( + (msg) => msg.type === 'result' && msg.is_error === true, + ); + expect(errorResult).toBeDefined(); + if ( + errorResult && + errorResult.type === 'result' && + errorResult.is_error + ) { + expect(errorResult.error?.message).toBe('Error occurred'); + expect(errorResult.num_turns).toBe(5); + } + }); + + it('should finalize pending assistant message before emitting error', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const state = adapter.exposeGetMessageState(parentToolUseId); + adapter.exposeAppendText(state, 'Partial response', parentToolUseId); + + adapter.emitSubagentErrorResult('Error', 1, parentToolUseId); + + const assistantMessage = adapter.emittedMessages.find( + (msg) => msg.type === 'assistant', + ); + expect(assistantMessage).toBeDefined(); + }); + }); + + describe('processSubagentToolCall', () => { + it('should process subagent tool call', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const toolCall: NonNullable[number] = { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + status: 'success', + resultDisplay: 'Result', + }; + + adapter.processSubagentToolCall(toolCall, parentToolUseId); + + // processSubagentToolCall finalizes the message and starts a new one, + // so we should check the emitted messages instead of the state + const assistantMessages = adapter.emittedMessages.filter( + (msg) => + msg.type === 'assistant' && + msg.parent_tool_use_id === parentToolUseId, + ); + expect(assistantMessages.length).toBeGreaterThan(0); + const toolUseMessage = assistantMessages.find( + (msg) => + msg.type === 'assistant' && + msg.message.content.some((block) => block.type === 'tool_use'), + ); + expect(toolUseMessage).toBeDefined(); + }); + + it('should finalize text message before tool_use', () => { + const parentToolUseId = 'parent-tool-1'; + adapter.startSubagentAssistantMessage(parentToolUseId); + const state = adapter.exposeGetMessageState(parentToolUseId); + adapter.exposeAppendText(state, 'Text', parentToolUseId); + + const toolCall: NonNullable[number] = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: 'Result', + }; + + adapter.processSubagentToolCall(toolCall, parentToolUseId); + + const assistantMessages = adapter.emittedMessages.filter( + (msg) => msg.type === 'assistant', + ); + expect(assistantMessages.length).toBeGreaterThan(0); + }); + }); + + describe('createSubagentToolUseBlock', () => { + it('should create tool_use block for subagent', () => { + const state = adapter.exposeCreateMessageState(); + adapter.startAssistantMessage(); + const toolCall: NonNullable[number] = { + callId: 'tool-1', + name: 'test_tool', + args: { param: 'value' }, + status: 'success', + resultDisplay: 'Result', + }; + + const { block, index } = adapter.exposeCreateSubagentToolUseBlock( + state, + toolCall, + 'parent-tool-1', + ); + + expect(block).toMatchObject({ + type: 'tool_use', + id: 'tool-1', + name: 'test_tool', + input: { param: 'value' }, + }); + expect(state.blocks[index]).toBe(block); + expect(state.openBlocks.has(index)).toBe(true); + }); + }); + + describe('buildSubagentErrorResult', () => { + it('should build subagent error result', () => { + const errorResult = adapter.exposeBuildSubagentErrorResult( + 'Error message', + 3, + ); + + expect(errorResult.type).toBe('result'); + expect(errorResult.is_error).toBe(true); + expect(errorResult.subtype).toBe('error_during_execution'); + expect(errorResult.error?.message).toBe('Error message'); + expect(errorResult.num_turns).toBe(3); + expect(errorResult.usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + }); + + describe('getSessionId and getModel', () => { + it('should return session ID from config', () => { + expect(adapter.getSessionId()).toBe('test-session-id'); + expect(mockConfig.getSessionId).toHaveBeenCalled(); + }); + + it('should return model from config', () => { + expect(adapter.getModel()).toBe('test-model'); + expect(mockConfig.getModel).toHaveBeenCalled(); + }); + }); + + describe('helper functions', () => { + describe('partsToContentBlock', () => { + it('should convert text parts to TextBlock array', () => { + const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }]; + + const result = partsToContentBlock(parts); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'text', + text: 'Hello World', + }); + }); + + it('should handle functionResponse parts by extracting output', () => { + const parts: Part[] = [ + { text: 'Result: ' }, + { + functionResponse: { + name: 'test', + response: { output: 'function output' }, + }, + }, + ]; + + const result = partsToContentBlock(parts); + + expect(result).toHaveLength(1); + expect(result[0].type).toBe('text'); + if (result[0].type === 'text') { + expect(result[0].text).toBe('Result: function output'); + } + }); + + it('should handle non-text parts by converting to JSON string', () => { + const parts: Part[] = [ + { text: 'Hello' }, + { functionCall: { name: 'test' } }, + ]; + + const result = partsToContentBlock(parts); + + expect(result.length).toBeGreaterThan(0); + const textBlock = result.find((block) => block.type === 'text'); + expect(textBlock).toBeDefined(); + if (textBlock && textBlock.type === 'text') { + expect(textBlock.text).toContain('Hello'); + expect(textBlock.text).toContain('functionCall'); + } + }); + + it('should handle empty array', () => { + const result = partsToContentBlock([]); + + expect(result).toEqual([]); + }); + + it('should merge consecutive text parts into single block', () => { + const parts: Part[] = [ + { text: 'Part 1' }, + { text: 'Part 2' }, + { text: 'Part 3' }, + ]; + + const result = partsToContentBlock(parts); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + type: 'text', + text: 'Part 1Part 2Part 3', + }); + }); + }); + + describe('partsToString', () => { + it('should convert text parts to string', () => { + const parts: Part[] = [{ text: 'Hello' }, { text: ' World' }]; + + const result = partsToString(parts); + + expect(result).toBe('Hello World'); + }); + + it('should handle non-text parts', () => { + const parts: Part[] = [ + { text: 'Hello' }, + { functionCall: { name: 'test' } }, + ]; + + const result = partsToString(parts); + + expect(result).toContain('Hello'); + expect(result).toContain('functionCall'); + }); + + it('should handle empty array', () => { + const result = partsToString([]); + + expect(result).toBe(''); + }); + }); + + describe('toolResultContent', () => { + it('should extract content from resultDisplay', () => { + const response = { + callId: 'tool-1', + resultDisplay: 'Tool result', + responseParts: [], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBe('Tool result'); + }); + + it('should extract content from responseParts', () => { + const response = { + callId: 'tool-1', + resultDisplay: undefined, + responseParts: [{ text: 'Result' }], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBeTruthy(); + }); + + it('should extract error message', () => { + const response = { + callId: 'tool-1', + resultDisplay: undefined, + responseParts: [], + error: new Error('Tool failed'), + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBe('Tool failed'); + }); + + it('should return undefined if no content', () => { + const response = { + callId: 'tool-1', + resultDisplay: undefined, + responseParts: [], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBeUndefined(); + }); + + it('should ignore empty resultDisplay', () => { + const response = { + callId: 'tool-1', + resultDisplay: ' ', + responseParts: [{ text: 'Result' }], + error: undefined, + errorType: undefined, + }; + + const result = toolResultContent(response); + + expect(result).toBeTruthy(); + expect(result).not.toBe(' '); + }); + }); + + describe('extractTextFromBlocks', () => { + it('should extract text from text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'text', text: ' World' }, + ]; + + const result = extractTextFromBlocks(blocks); + + expect(result).toBe('Hello World'); + }); + + it('should ignore non-text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'text', text: 'Hello' }, + { type: 'tool_use', id: 'tool-1', name: 'test', input: {} }, + ]; + + const result = extractTextFromBlocks(blocks); + + expect(result).toBe('Hello'); + }); + + it('should handle empty array', () => { + const result = extractTextFromBlocks([]); + + expect(result).toBe(''); + }); + + it('should handle array with no text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'tool_use', id: 'tool-1', name: 'test', input: {} }, + ]; + + const result = extractTextFromBlocks(blocks); + + expect(result).toBe(''); + }); + }); + + describe('createExtendedUsage', () => { + it('should create extended usage with default values', () => { + const usage = createExtendedUsage(); + + expect(usage).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts new file mode 100644 index 00000000..3968c5cc --- /dev/null +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -0,0 +1,1228 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { + Config, + ToolCallRequestInfo, + ToolCallResponseInfo, + SessionMetrics, + ServerGeminiStreamEvent, + TaskResultDisplay, +} from '@qwen-code/qwen-code-core'; +import { GeminiEventType, ToolErrorType } from '@qwen-code/qwen-code-core'; +import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; +import type { + CLIAssistantMessage, + CLIMessage, + CLIPermissionDenial, + CLIResultMessage, + CLIResultMessageError, + CLIResultMessageSuccess, + CLIUserMessage, + ContentBlock, + ExtendedUsage, + TextBlock, + ThinkingBlock, + ToolResultBlock, + ToolUseBlock, + Usage, +} from '../types.js'; +import { functionResponsePartsToString } from '../../utils/nonInteractiveHelpers.js'; + +/** + * Internal state for managing a single message context (main agent or subagent). + */ +export interface MessageState { + messageId: string | null; + blocks: ContentBlock[]; + openBlocks: Set; + usage: Usage; + messageStarted: boolean; + finalized: boolean; + currentBlockType: ContentBlock['type'] | null; +} + +/** + * Options for building result messages. + * Used by both streaming and non-streaming JSON output adapters. + */ +export interface ResultOptions { + readonly isError: boolean; + readonly errorMessage?: string; + readonly durationMs: number; + readonly apiDurationMs: number; + readonly numTurns: number; + readonly usage?: ExtendedUsage; + readonly stats?: SessionMetrics; + readonly summary?: string; + readonly subtype?: string; +} + +/** + * Interface for message emission strategies. + * Implementations decide whether to emit messages immediately (streaming) + * or collect them for batch emission (non-streaming). + * This interface defines the common message emission methods that + * all JSON output adapters should implement. + */ +export interface MessageEmitter { + emitMessage(message: CLIMessage): void; + emitUserMessage(parts: Part[], parentToolUseId?: string | null): void; + emitToolResult( + request: ToolCallRequestInfo, + response: ToolCallResponseInfo, + parentToolUseId?: string | null, + ): void; + emitSystemMessage(subtype: string, data?: unknown): void; +} + +/** + * JSON-focused output adapter interface. + * Handles structured JSON output for both streaming and non-streaming modes. + * This interface defines the complete API that all JSON output adapters must implement. + */ +export interface JsonOutputAdapterInterface extends MessageEmitter { + startAssistantMessage(): void; + processEvent(event: ServerGeminiStreamEvent): void; + finalizeAssistantMessage(): CLIAssistantMessage; + emitResult(options: ResultOptions): void; + + startSubagentAssistantMessage?(parentToolUseId: string): void; + processSubagentToolCall?( + toolCall: NonNullable[number], + parentToolUseId: string, + ): void; + finalizeSubagentAssistantMessage?( + parentToolUseId: string, + ): CLIAssistantMessage; + emitSubagentErrorResult?( + errorMessage: string, + numTurns: number, + parentToolUseId: string, + ): void; + + getSessionId(): string; + getModel(): string; +} + +/** + * Abstract base class for JSON output adapters. + * Contains shared logic for message building, state management, and content block handling. + */ +export abstract class BaseJsonOutputAdapter { + protected readonly config: Config; + + // Main agent message state + protected mainAgentMessageState: MessageState; + + // Subagent message states keyed by parentToolUseId + protected subagentMessageStates = new Map(); + + // Last assistant message for result generation + protected lastAssistantMessage: CLIAssistantMessage | null = null; + + // Track permission denials (execution denied tool calls) + protected permissionDenials: CLIPermissionDenial[] = []; + + constructor(config: Config) { + this.config = config; + this.mainAgentMessageState = this.createMessageState(); + } + + /** + * Creates a new message state with default values. + */ + protected createMessageState(): MessageState { + return { + messageId: null, + blocks: [], + openBlocks: new Set(), + usage: this.createUsage(), + messageStarted: false, + finalized: false, + currentBlockType: null, + }; + } + + /** + * Gets or creates message state for a given context. + * + * @param parentToolUseId - null for main agent, string for subagent + * @returns MessageState for the context + */ + protected getMessageState(parentToolUseId: string | null): MessageState { + if (parentToolUseId === null) { + return this.mainAgentMessageState; + } + + let state = this.subagentMessageStates.get(parentToolUseId); + if (!state) { + state = this.createMessageState(); + this.subagentMessageStates.set(parentToolUseId, state); + } + return state; + } + + /** + * Creates a Usage object from metadata. + * + * @param metadata - Optional usage metadata from Gemini API + * @returns Usage object + */ + protected createUsage( + metadata?: GenerateContentResponseUsageMetadata | null, + ): Usage { + const usage: Usage = { + input_tokens: 0, + output_tokens: 0, + }; + + if (!metadata) { + return usage; + } + + if (typeof metadata.promptTokenCount === 'number') { + usage.input_tokens = metadata.promptTokenCount; + } + if (typeof metadata.candidatesTokenCount === 'number') { + usage.output_tokens = metadata.candidatesTokenCount; + } + if (typeof metadata.cachedContentTokenCount === 'number') { + usage.cache_read_input_tokens = metadata.cachedContentTokenCount; + } + if (typeof metadata.totalTokenCount === 'number') { + usage.total_tokens = metadata.totalTokenCount; + } + + return usage; + } + + /** + * Builds a CLIAssistantMessage from the current message state. + * + * @param parentToolUseId - null for main agent, string for subagent + * @returns CLIAssistantMessage + */ + protected buildMessage(parentToolUseId: string | null): CLIAssistantMessage { + const state = this.getMessageState(parentToolUseId); + + if (!state.messageId) { + throw new Error('Message not started'); + } + + // Enforce constraint: assistant message must contain only a single type of ContentBlock + if (state.blocks.length > 0) { + const blockTypes = new Set(state.blocks.map((block) => block.type)); + if (blockTypes.size > 1) { + throw new Error( + `Assistant message must contain only one type of ContentBlock, found: ${Array.from(blockTypes).join(', ')}`, + ); + } + } + + // Determine stop_reason based on content block types + // If the message contains only tool_use blocks, set stop_reason to 'tool_use' + const stopReason = + state.blocks.length > 0 && + state.blocks.every((block) => block.type === 'tool_use') + ? 'tool_use' + : null; + + return { + type: 'assistant', + uuid: state.messageId, + session_id: this.config.getSessionId(), + parent_tool_use_id: parentToolUseId, + message: { + id: state.messageId, + type: 'message', + role: 'assistant', + model: this.config.getModel(), + content: state.blocks, + stop_reason: stopReason, + usage: state.usage, + }, + }; + } + + /** + * Finalizes pending blocks (text or thinking) by closing them. + * + * @param state - Message state to finalize blocks for + * @param parentToolUseId - null for main agent, string for subagent (optional, defaults to null) + */ + protected finalizePendingBlocks( + state: MessageState, + parentToolUseId?: string | null, + ): void { + const actualParentToolUseId = parentToolUseId ?? null; + const lastBlock = state.blocks[state.blocks.length - 1]; + if (!lastBlock) { + return; + } + + if (lastBlock.type === 'text') { + const index = state.blocks.length - 1; + this.onBlockClosed(state, index, actualParentToolUseId); + this.closeBlock(state, index); + } else if (lastBlock.type === 'thinking') { + const index = state.blocks.length - 1; + this.onBlockClosed(state, index, actualParentToolUseId); + this.closeBlock(state, index); + } + } + + /** + * Opens a block (adds to openBlocks set). + * + * @param state - Message state + * @param index - Block index + * @param _block - Content block + */ + protected openBlock( + state: MessageState, + index: number, + _block: ContentBlock, + ): void { + state.openBlocks.add(index); + } + + /** + * Closes a block (removes from openBlocks set). + * + * @param state - Message state + * @param index - Block index + */ + protected closeBlock(state: MessageState, index: number): void { + if (!state.openBlocks.has(index)) { + return; + } + state.openBlocks.delete(index); + } + + /** + * Guarantees that a single assistant message aggregates only one + * content block category (text, thinking, or tool use). When a new + * block type is requested, the current message is finalized and a fresh + * assistant message is started to honour the single-type constraint. + * + * @param state - Message state + * @param targetType - Target block type + * @param parentToolUseId - null for main agent, string for subagent + */ + protected ensureBlockTypeConsistency( + state: MessageState, + targetType: ContentBlock['type'], + parentToolUseId: string | null, + ): void { + if (state.currentBlockType === targetType) { + return; + } + + if (state.currentBlockType === null) { + state.currentBlockType = targetType; + return; + } + + // Finalize current message and start new one + this.finalizeAssistantMessageInternal(state, parentToolUseId); + this.startAssistantMessageInternal(state); + state.currentBlockType = targetType; + } + + /** + * Starts a new assistant message, resetting state. + * + * @param state - Message state to reset + */ + protected startAssistantMessageInternal(state: MessageState): void { + state.messageId = randomUUID(); + state.blocks = []; + state.openBlocks = new Set(); + state.usage = this.createUsage(); + state.messageStarted = false; + state.finalized = false; + state.currentBlockType = null; + } + + /** + * Finalizes an assistant message. + * + * @param state - Message state to finalize + * @param parentToolUseId - null for main agent, string for subagent + * @returns CLIAssistantMessage + */ + protected finalizeAssistantMessageInternal( + state: MessageState, + parentToolUseId: string | null, + ): CLIAssistantMessage { + if (state.finalized) { + return this.buildMessage(parentToolUseId); + } + state.finalized = true; + + this.finalizePendingBlocks(state, parentToolUseId); + const orderedOpenBlocks = Array.from(state.openBlocks).sort( + (a, b) => a - b, + ); + for (const index of orderedOpenBlocks) { + this.onBlockClosed(state, index, parentToolUseId); + this.closeBlock(state, index); + } + + const message = this.buildMessage(parentToolUseId); + this.emitMessageImpl(message); + return message; + } + + /** + * Abstract method for emitting messages. Implementations decide whether + * to emit immediately (streaming) or collect for batch emission. + * Note: The message object already contains parent_tool_use_id field, + * so it doesn't need to be passed as a separate parameter. + * + * @param message - Message to emit (already contains parent_tool_use_id if applicable) + */ + protected abstract emitMessageImpl(message: CLIMessage): void; + + /** + * Abstract method to determine if stream events should be emitted. + * + * @returns true if stream events should be emitted + */ + protected abstract shouldEmitStreamEvents(): boolean; + + /** + * Hook method called when a text block is created. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param block - Text block that was created + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onTextBlockCreated( + _state: MessageState, + _index: number, + _block: TextBlock, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when text content is appended. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param fragment - Text fragment that was appended + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onTextAppended( + _state: MessageState, + _index: number, + _fragment: string, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when a thinking block is created. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param block - Thinking block that was created + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onThinkingBlockCreated( + _state: MessageState, + _index: number, + _block: ThinkingBlock, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when thinking content is appended. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param fragment - Thinking fragment that was appended + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onThinkingAppended( + _state: MessageState, + _index: number, + _fragment: string, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when a tool_use block is created. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param block - Tool use block that was created + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onToolUseBlockCreated( + _state: MessageState, + _index: number, + _block: ToolUseBlock, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when tool_use input is set. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param input - Tool use input that was set + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onToolUseInputSet( + _state: MessageState, + _index: number, + _input: unknown, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called when a block is closed. + * Subclasses can override this to emit stream events. + * + * @param state - Message state + * @param index - Block index + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onBlockClosed( + _state: MessageState, + _index: number, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Hook method called to ensure message is started. + * Subclasses can override this to emit message_start events. + * + * @param state - Message state + * @param parentToolUseId - null for main agent, string for subagent + */ + protected onEnsureMessageStarted( + _state: MessageState, + _parentToolUseId: string | null, + ): void { + // Default implementation does nothing + } + + /** + * Gets the session ID from config. + * + * @returns Session ID + */ + getSessionId(): string { + return this.config.getSessionId(); + } + + /** + * Gets the model name from config. + * + * @returns Model name + */ + getModel(): string { + return this.config.getModel(); + } + + // ========== Main Agent APIs ========== + + /** + * Starts a new assistant message for the main agent. + * This is a shared implementation used by both streaming and non-streaming adapters. + */ + startAssistantMessage(): void { + this.startAssistantMessageInternal(this.mainAgentMessageState); + } + + /** + * Processes a stream event from the Gemini API. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param event - Stream event from Gemini API + */ + processEvent(event: ServerGeminiStreamEvent): void { + const state = this.mainAgentMessageState; + if (state.finalized) { + return; + } + + switch (event.type) { + case GeminiEventType.Content: + this.appendText(state, event.value, null); + break; + case GeminiEventType.Citation: + if (typeof event.value === 'string') { + this.appendText(state, `\n${event.value}`, null); + } + break; + case GeminiEventType.Thought: + this.appendThinking( + state, + event.value.subject, + event.value.description, + null, + ); + break; + case GeminiEventType.ToolCallRequest: + this.appendToolUse(state, event.value, null); + break; + case GeminiEventType.Finished: + if (event.value?.usageMetadata) { + state.usage = this.createUsage(event.value.usageMetadata); + } + this.finalizePendingBlocks(state, null); + break; + default: + break; + } + } + + // ========== Subagent APIs ========== + + /** + * Starts a new assistant message for a subagent. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param parentToolUseId - Parent tool use ID + */ + startSubagentAssistantMessage(parentToolUseId: string): void { + const state = this.getMessageState(parentToolUseId); + this.startAssistantMessageInternal(state); + } + + /** + * Finalizes a subagent assistant message. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param parentToolUseId - Parent tool use ID + * @returns CLIAssistantMessage + */ + finalizeSubagentAssistantMessage( + parentToolUseId: string, + ): CLIAssistantMessage { + const state = this.getMessageState(parentToolUseId); + const message = this.finalizeAssistantMessageInternal( + state, + parentToolUseId, + ); + this.updateLastAssistantMessage(message); + return message; + } + + /** + * Emits a subagent error result message. + * This is a shared implementation used by both streaming and non-streaming adapters. + * + * @param errorMessage - Error message + * @param numTurns - Number of turns + * @param parentToolUseId - Parent tool use ID + */ + emitSubagentErrorResult( + errorMessage: string, + numTurns: number, + parentToolUseId: string, + ): void { + const state = this.getMessageState(parentToolUseId); + // Finalize any pending assistant message + if (state.messageStarted && !state.finalized) { + this.finalizeSubagentAssistantMessage(parentToolUseId); + } + + const errorResult = this.buildSubagentErrorResult(errorMessage, numTurns); + this.emitMessageImpl(errorResult); + } + + /** + * Processes a subagent tool call. + * This is a shared implementation used by both streaming and non-streaming adapters. + * Uses template method pattern with hooks for stream events. + * + * @param toolCall - Tool call information + * @param parentToolUseId - Parent tool use ID + */ + processSubagentToolCall( + toolCall: NonNullable[number], + parentToolUseId: string, + ): void { + const state = this.getMessageState(parentToolUseId); + + // Finalize any pending text message before starting tool_use + const hasText = + state.blocks.some((b) => b.type === 'text') || + (state.currentBlockType === 'text' && state.blocks.length > 0); + if (hasText) { + this.finalizeSubagentAssistantMessage(parentToolUseId); + this.startSubagentAssistantMessage(parentToolUseId); + } + + // Ensure message is started before appending tool_use + if (!state.messageId || !state.messageStarted) { + this.startAssistantMessageInternal(state); + } + + this.ensureBlockTypeConsistency(state, 'tool_use', parentToolUseId); + this.ensureMessageStarted(state, parentToolUseId); + this.finalizePendingBlocks(state, parentToolUseId); + + const { index } = this.createSubagentToolUseBlock( + state, + toolCall, + parentToolUseId, + ); + + // Process tool use block creation and closure + // Subclasses can override hook methods to emit stream events + this.processSubagentToolUseBlock(state, index, toolCall, parentToolUseId); + + // Finalize tool_use message immediately + this.finalizeSubagentAssistantMessage(parentToolUseId); + this.startSubagentAssistantMessage(parentToolUseId); + } + + /** + * Processes a tool use block for subagent. + * This method is called by processSubagentToolCall to handle tool use block creation, + * input setting, and closure. Subclasses can override this to customize behavior. + * + * @param state - Message state + * @param index - Block index + * @param toolCall - Tool call information + * @param parentToolUseId - Parent tool use ID + */ + protected processSubagentToolUseBlock( + state: MessageState, + index: number, + toolCall: NonNullable[number], + parentToolUseId: string, + ): void { + // Emit tool_use block creation event (with empty input) + const startBlock: ToolUseBlock = { + type: 'tool_use', + id: toolCall.callId, + name: toolCall.name, + input: {}, + }; + this.onToolUseBlockCreated(state, index, startBlock, parentToolUseId); + this.onToolUseInputSet(state, index, toolCall.args ?? {}, parentToolUseId); + this.onBlockClosed(state, index, parentToolUseId); + this.closeBlock(state, index); + } + + /** + * Updates the last assistant message. + * Subclasses can override this to customize tracking behavior. + * + * @param message - Assistant message to track + */ + protected updateLastAssistantMessage(message: CLIAssistantMessage): void { + this.lastAssistantMessage = message; + } + + // ========== Shared Content Block Methods ========== + + /** + * Appends text content to the current message. + * Uses template method pattern with hooks for stream events. + * + * @param state - Message state + * @param fragment - Text fragment to append + * @param parentToolUseId - null for main agent, string for subagent + */ + protected appendText( + state: MessageState, + fragment: string, + parentToolUseId: string | null, + ): void { + if (fragment.length === 0) { + return; + } + + this.ensureBlockTypeConsistency(state, 'text', parentToolUseId); + this.ensureMessageStarted(state, parentToolUseId); + + let current = state.blocks[state.blocks.length - 1] as + | TextBlock + | undefined; + const isNewBlock = !current || current.type !== 'text'; + if (isNewBlock) { + current = { type: 'text', text: '' } satisfies TextBlock; + const index = state.blocks.length; + state.blocks.push(current); + this.openBlock(state, index, current); + this.onTextBlockCreated(state, index, current, parentToolUseId); + } + + // current is guaranteed to be defined here (either existing or newly created) + current!.text += fragment; + const index = state.blocks.length - 1; + this.onTextAppended(state, index, fragment, parentToolUseId); + } + + /** + * Appends thinking content to the current message. + * Uses template method pattern with hooks for stream events. + * + * @param state - Message state + * @param subject - Thinking subject + * @param description - Thinking description + * @param parentToolUseId - null for main agent, string for subagent + */ + protected appendThinking( + state: MessageState, + subject?: string, + description?: string, + parentToolUseId?: string | null, + ): void { + const actualParentToolUseId = parentToolUseId ?? null; + const fragment = [subject?.trim(), description?.trim()] + .filter((value) => value && value.length > 0) + .join(': '); + if (!fragment) { + return; + } + + this.ensureBlockTypeConsistency(state, 'thinking', actualParentToolUseId); + this.ensureMessageStarted(state, actualParentToolUseId); + + let current = state.blocks[state.blocks.length - 1] as + | ThinkingBlock + | undefined; + const isNewBlock = !current || current.type !== 'thinking'; + if (isNewBlock) { + current = { + type: 'thinking', + thinking: '', + signature: subject, + } satisfies ThinkingBlock; + const index = state.blocks.length; + state.blocks.push(current); + this.openBlock(state, index, current); + this.onThinkingBlockCreated(state, index, current, actualParentToolUseId); + } + + // current is guaranteed to be defined here (either existing or newly created) + current!.thinking = `${current!.thinking ?? ''}${fragment}`; + const index = state.blocks.length - 1; + this.onThinkingAppended(state, index, fragment, actualParentToolUseId); + } + + /** + * Appends a tool_use block to the current message. + * Uses template method pattern with hooks for stream events. + * + * @param state - Message state + * @param request - Tool call request info + * @param parentToolUseId - null for main agent, string for subagent + */ + protected appendToolUse( + state: MessageState, + request: ToolCallRequestInfo, + parentToolUseId: string | null, + ): void { + this.ensureBlockTypeConsistency(state, 'tool_use', parentToolUseId); + this.ensureMessageStarted(state, parentToolUseId); + this.finalizePendingBlocks(state, parentToolUseId); + + const index = state.blocks.length; + const block: ToolUseBlock = { + type: 'tool_use', + id: request.callId, + name: request.name, + input: request.args, + }; + state.blocks.push(block); + this.openBlock(state, index, block); + + // Emit tool_use block creation event (with empty input) + const startBlock: ToolUseBlock = { + type: 'tool_use', + id: request.callId, + name: request.name, + input: {}, + }; + this.onToolUseBlockCreated(state, index, startBlock, parentToolUseId); + this.onToolUseInputSet(state, index, request.args ?? {}, parentToolUseId); + + this.onBlockClosed(state, index, parentToolUseId); + this.closeBlock(state, index); + } + + /** + * Ensures that a message has been started. + * Calls hook method for subclasses to emit message_start events. + * + * @param state - Message state + * @param parentToolUseId - null for main agent, string for subagent + */ + protected ensureMessageStarted( + state: MessageState, + parentToolUseId: string | null, + ): void { + if (state.messageStarted) { + return; + } + state.messageStarted = true; + this.onEnsureMessageStarted(state, parentToolUseId); + } + + /** + * Creates and adds a tool_use block to the state. + * This is a shared helper method used by processSubagentToolCall implementations. + * + * @param state - Message state + * @param toolCall - Tool call information + * @param parentToolUseId - Parent tool use ID + * @returns The created block and its index + */ + protected createSubagentToolUseBlock( + state: MessageState, + toolCall: NonNullable[number], + _parentToolUseId: string, + ): { block: ToolUseBlock; index: number } { + const index = state.blocks.length; + const block: ToolUseBlock = { + type: 'tool_use', + id: toolCall.callId, + name: toolCall.name, + input: toolCall.args || {}, + }; + state.blocks.push(block); + this.openBlock(state, index, block); + return { block, index }; + } + + /** + * Emits a user message. + * @param parts - Array of Part objects + * @param parentToolUseId - Optional parent tool use ID for subagent messages + */ + emitUserMessage(parts: Part[], parentToolUseId?: string | null): void { + const content = partsToContentBlock(parts); + const message: CLIUserMessage = { + type: 'user', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: parentToolUseId ?? null, + message: { + role: 'user', + content, + }, + }; + this.emitMessageImpl(message); + } + + /** + * Emits a tool result message. + * Collects execution denied tool calls for inclusion in result messages. + * @param request - Tool call request info + * @param response - Tool call response info + * @param parentToolUseId - Parent tool use ID (null for main agent) + */ + emitToolResult( + request: ToolCallRequestInfo, + response: ToolCallResponseInfo, + parentToolUseId: string | null = null, + ): void { + // Track permission denials (execution denied errors) + if ( + response.error && + response.errorType === ToolErrorType.EXECUTION_DENIED + ) { + const denial: CLIPermissionDenial = { + tool_name: request.name, + tool_use_id: request.callId, + tool_input: request.args, + }; + this.permissionDenials.push(denial); + } + + const block: ToolResultBlock = { + type: 'tool_result', + tool_use_id: request.callId, + is_error: Boolean(response.error), + }; + const content = toolResultContent(response); + if (content !== undefined) { + block.content = content; + } + + const message: CLIUserMessage = { + type: 'user', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: parentToolUseId, + message: { + role: 'user', + content: [block], + }, + }; + this.emitMessageImpl(message); + } + + /** + * Emits a system message. + * @param subtype - System message subtype + * @param data - Optional data payload + */ + emitSystemMessage(subtype: string, data?: unknown): void { + const systemMessage = { + type: 'system', + subtype, + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + data, + } as const; + this.emitMessageImpl(systemMessage); + } + + /** + * Builds a result message from options. + * Helper method used by both emitResult implementations. + * Includes permission denials collected from execution denied tool calls. + * @param options - Result options + * @param lastAssistantMessage - Last assistant message for text extraction + * @returns CLIResultMessage + */ + protected buildResultMessage( + options: ResultOptions, + lastAssistantMessage: CLIAssistantMessage | null, + ): CLIResultMessage { + const usage = options.usage ?? createExtendedUsage(); + const resultText = + options.summary ?? + (lastAssistantMessage + ? extractTextFromBlocks(lastAssistantMessage.message.content) + : ''); + + const baseUuid = randomUUID(); + const baseSessionId = this.getSessionId(); + + if (options.isError) { + const errorMessage = options.errorMessage ?? 'Unknown error'; + return { + type: 'result', + subtype: + (options.subtype as CLIResultMessageError['subtype']) ?? + 'error_during_execution', + uuid: baseUuid, + session_id: baseSessionId, + is_error: true, + duration_ms: options.durationMs, + duration_api_ms: options.apiDurationMs, + num_turns: options.numTurns, + usage, + permission_denials: [...this.permissionDenials], + error: { message: errorMessage }, + }; + } else { + const success: CLIResultMessageSuccess & { stats?: SessionMetrics } = { + type: 'result', + subtype: + (options.subtype as CLIResultMessageSuccess['subtype']) ?? 'success', + uuid: baseUuid, + session_id: baseSessionId, + is_error: false, + duration_ms: options.durationMs, + duration_api_ms: options.apiDurationMs, + num_turns: options.numTurns, + result: resultText, + usage, + permission_denials: [...this.permissionDenials], + }; + + if (options.stats) { + success.stats = options.stats; + } + + return success; + } + } + + /** + * Builds a subagent error result message. + * Helper method used by both emitSubagentErrorResult implementations. + * Note: Subagent permission denials are not included here as they are tracked + * separately and would be included in the main agent's result message. + * @param errorMessage - Error message + * @param numTurns - Number of turns + * @returns CLIResultMessageError + */ + protected buildSubagentErrorResult( + errorMessage: string, + numTurns: number, + ): CLIResultMessageError { + const usage: ExtendedUsage = { + input_tokens: 0, + output_tokens: 0, + }; + + return { + type: 'result', + subtype: 'error_during_execution', + uuid: randomUUID(), + session_id: this.getSessionId(), + is_error: true, + duration_ms: 0, + duration_api_ms: 0, + num_turns: numTurns, + usage, + permission_denials: [], + error: { message: errorMessage }, + }; + } +} + +/** + * Converts Part array to ContentBlock array. + * Handles various Part types including text, functionResponse, and other types. + * For functionResponse parts, extracts the output content. + * For other non-text parts, converts them to text representation. + * + * @param parts - Array of Part objects + * @returns Array of ContentBlock objects (primarily TextBlock) + */ +export function partsToContentBlock(parts: Part[]): ContentBlock[] { + const blocks: ContentBlock[] = []; + let currentTextBlock: TextBlock | null = null; + + for (const part of parts) { + let textContent: string | null = null; + + // Handle text parts + if ('text' in part && typeof part.text === 'string') { + textContent = part.text; + } + // Handle functionResponse parts - extract output content + else if ('functionResponse' in part && part.functionResponse) { + const output = + part.functionResponse.response?.['output'] ?? + part.functionResponse.response?.['content'] ?? + ''; + textContent = + typeof output === 'string' ? output : JSON.stringify(output); + } + // Handle other part types - convert to JSON string + else { + textContent = JSON.stringify(part); + } + + // If we have text content, add it to the current text block or create a new one + if (textContent !== null && textContent.length > 0) { + if (currentTextBlock === null) { + currentTextBlock = { + type: 'text', + text: textContent, + }; + blocks.push(currentTextBlock); + } else { + // Append to existing text block + currentTextBlock.text += textContent; + } + } + } + + // Return blocks array, or empty array if no content + return blocks; +} + +/** + * Converts Part array to string representation. + * This is a legacy function kept for backward compatibility. + * For new code, prefer using partsToContentBlock. + * + * @param parts - Array of Part objects + * @returns String representation + */ +export function partsToString(parts: Part[]): string { + return parts + .map((part) => { + if ('text' in part && typeof part.text === 'string') { + return part.text; + } + return JSON.stringify(part); + }) + .join(''); +} + +/** + * Extracts content from tool response. + * Uses functionResponsePartsToString to properly handle functionResponse parts, + * which correctly extracts output content from functionResponse objects rather + * than simply concatenating text or JSON.stringify. + * + * @param response - Tool call response + * @returns String content or undefined + */ +export function toolResultContent( + response: ToolCallResponseInfo, +): string | undefined { + if (response.error) { + return response.error.message; + } + if ( + typeof response.resultDisplay === 'string' && + response.resultDisplay.trim().length > 0 + ) { + return response.resultDisplay; + } + if (response.responseParts && response.responseParts.length > 0) { + // Always use functionResponsePartsToString to properly handle + // functionResponse parts that contain output content + return functionResponsePartsToString(response.responseParts); + } + return undefined; +} + +/** + * Extracts text from content blocks. + * + * @param blocks - Array of content blocks + * @returns Extracted text + */ +export function extractTextFromBlocks(blocks: ContentBlock[]): string { + return blocks + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(''); +} + +/** + * Creates an extended usage object with default values. + * + * @returns ExtendedUsage object + */ +export function createExtendedUsage(): ExtendedUsage { + return { + input_tokens: 0, + output_tokens: 0, + }; +} diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts new file mode 100644 index 00000000..2f4c9e44 --- /dev/null +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.test.ts @@ -0,0 +1,791 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { + Config, + ServerGeminiStreamEvent, +} from '@qwen-code/qwen-code-core'; +import { GeminiEventType } from '@qwen-code/qwen-code-core'; +import type { Part } from '@google/genai'; +import { JsonOutputAdapter } from './JsonOutputAdapter.js'; + +function createMockConfig(): Config { + return { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getModel: vi.fn().mockReturnValue('test-model'), + } as unknown as Config; +} + +describe('JsonOutputAdapter', () => { + let adapter: JsonOutputAdapter; + let mockConfig: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let stdoutWriteSpy: any; + + beforeEach(() => { + mockConfig = createMockConfig(); + adapter = new JsonOutputAdapter(mockConfig); + stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + stdoutWriteSpy.mockRestore(); + }); + + describe('startAssistantMessage', () => { + it('should reset state for new message', () => { + adapter.startAssistantMessage(); + adapter.startAssistantMessage(); // Start second message + // Should not throw + expect(() => adapter.finalizeAssistantMessage()).not.toThrow(); + }); + }); + + describe('processEvent', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should append text content from Content events', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: 'Hello', + }; + adapter.processEvent(event); + + const event2: ServerGeminiStreamEvent = { + type: GeminiEventType.Content, + value: ' World', + }; + adapter.processEvent(event2); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'Hello World', + }); + }); + + it('should append citation content from Citation events', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Citation, + value: 'Citation text', + }; + adapter.processEvent(event); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('Citation text'), + }); + }); + + it('should ignore non-string citation values', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Citation, + value: 123, + } as unknown as ServerGeminiStreamEvent; + adapter.processEvent(event); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(0); + }); + + it('should append thinking from Thought events', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking about the task', + }, + }; + adapter.processEvent(event); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'thinking', + thinking: 'Planning: Thinking about the task', + signature: 'Planning', + }); + }); + + it('should handle thinking with only subject', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: '', + }, + }; + adapter.processEvent(event); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content[0]).toMatchObject({ + type: 'thinking', + signature: 'Planning', + }); + }); + + it('should append tool use from ToolCallRequest events', () => { + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-1', + name: 'test_tool', + args: { param1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }; + adapter.processEvent(event); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'tool_use', + id: 'tool-call-1', + name: 'test_tool', + input: { param1: 'value1' }, + }); + }); + + it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-1', + name: 'test_tool', + args: { param1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.stop_reason).toBe('tool_use'); + }); + + it('should set stop_reason to null when message contains text blocks', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Some text', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.stop_reason).toBeNull(); + }); + + it('should set stop_reason to null when message contains thinking blocks', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking about the task', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.stop_reason).toBeNull(); + }); + + it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-1', + name: 'test_tool_1', + args: { param1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-2', + name: 'test_tool_2', + args: { param2: 'value2' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(2); + expect( + message.message.content.every((block) => block.type === 'tool_use'), + ).toBe(true); + expect(message.message.stop_reason).toBe('tool_use'); + }); + + it('should update usage from Finished event', () => { + const usageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + totalTokenCount: 160, + }; + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata, + }, + }; + adapter.processEvent(event); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.usage).toMatchObject({ + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 10, + total_tokens: 160, + }); + }); + + it('should finalize pending blocks on Finished event', () => { + // Add some text first + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Some text', + }); + + const event: ServerGeminiStreamEvent = { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: undefined }, + }; + adapter.processEvent(event); + + // Should not throw when finalizing + expect(() => adapter.finalizeAssistantMessage()).not.toThrow(); + }); + + it('should ignore events after finalization', () => { + adapter.finalizeAssistantMessage(); + const originalContent = + adapter.finalizeAssistantMessage().message.content; + + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Should be ignored', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toEqual(originalContent); + }); + }); + + describe('finalizeAssistantMessage', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should build and emit a complete assistant message', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test response', + }); + + const message = adapter.finalizeAssistantMessage(); + + expect(message.type).toBe('assistant'); + expect(message.uuid).toBeTruthy(); + expect(message.session_id).toBe('test-session-id'); + expect(message.parent_tool_use_id).toBeNull(); + expect(message.message.role).toBe('assistant'); + expect(message.message.model).toBe('test-model'); + expect(message.message.content).toHaveLength(1); + }); + + it('should return same message on subsequent calls', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test', + }); + + const message1 = adapter.finalizeAssistantMessage(); + const message2 = adapter.finalizeAssistantMessage(); + + expect(message1).toEqual(message2); + }); + + it('should split different block types into separate assistant messages', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { subject: 'Thinking', description: 'Thought' }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0].type).toBe('thinking'); + + const storedMessages = (adapter as unknown as { messages: unknown[] }) + .messages; + const assistantMessages = storedMessages.filter( + ( + msg, + ): msg is { + type: string; + message: { content: Array<{ type: string }> }; + } => { + if ( + typeof msg !== 'object' || + msg === null || + !('type' in msg) || + (msg as { type?: string }).type !== 'assistant' || + !('message' in msg) + ) { + return false; + } + const message = (msg as { message?: unknown }).message; + return ( + typeof message === 'object' && + message !== null && + 'content' in message && + Array.isArray((message as { content?: unknown }).content) + ); + }, + ); + + expect(assistantMessages).toHaveLength(2); + for (const assistant of assistantMessages) { + const uniqueTypes = new Set( + assistant.message.content.map((block) => block.type), + ); + expect(uniqueTypes.size).toBeLessThanOrEqual(1); + } + }); + + it('should throw if message not started', () => { + adapter = new JsonOutputAdapter(mockConfig); + expect(() => adapter.finalizeAssistantMessage()).toThrow( + 'Message not started', + ); + }); + }); + + describe('emitResult', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Response text', + }); + adapter.finalizeAssistantMessage(); + }); + + it('should emit success result as JSON array', () => { + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(Array.isArray(parsed)).toBe(true); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage).toBeDefined(); + expect(resultMessage.is_error).toBe(false); + expect(resultMessage.subtype).toBe('success'); + expect(resultMessage.result).toBe('Response text'); + expect(resultMessage.duration_ms).toBe(1000); + expect(resultMessage.num_turns).toBe(1); + }); + + it('should emit error result', () => { + adapter.emitResult({ + isError: true, + errorMessage: 'Test error', + durationMs: 500, + apiDurationMs: 300, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage.is_error).toBe(true); + expect(resultMessage.subtype).toBe('error_during_execution'); + expect(resultMessage.error?.message).toBe('Test error'); + }); + + it('should use provided summary over extracted text', () => { + adapter.emitResult({ + isError: false, + summary: 'Custom summary', + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage.result).toBe('Custom summary'); + }); + + it('should include usage information', () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }; + + adapter.emitResult({ + isError: false, + usage, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage.usage).toEqual(usage); + }); + + it('should include stats when provided', () => { + const stats = { + models: {}, + tools: { + totalCalls: 5, + totalSuccess: 4, + totalFail: 1, + totalDurationMs: 1000, + totalDecisions: { + accept: 3, + reject: 1, + modify: 0, + auto_accept: 1, + }, + byName: {}, + }, + files: { + totalLinesAdded: 10, + totalLinesRemoved: 5, + }, + }; + + adapter.emitResult({ + isError: false, + stats, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + + expect(resultMessage.stats).toEqual(stats); + }); + }); + + describe('emitUserMessage', () => { + it('should add user message to collection', () => { + const parts: Part[] = [{ text: 'Hello user' }]; + adapter.emitUserMessage(parts); + + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const userMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + + expect(userMessage).toBeDefined(); + expect(Array.isArray(userMessage.message.content)).toBe(true); + if (Array.isArray(userMessage.message.content)) { + expect(userMessage.message.content).toHaveLength(1); + expect(userMessage.message.content[0]).toEqual({ + type: 'text', + text: 'Hello user', + }); + } + }); + + it('should handle parent_tool_use_id', () => { + const parts: Part[] = [{ text: 'Tool response' }]; + adapter.emitUserMessage(parts); + + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const userMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user', + ); + + // emitUserMessage currently sets parent_tool_use_id to null + expect(userMessage.parent_tool_use_id).toBeNull(); + }); + }); + + describe('emitToolResult', () => { + it('should emit tool result message', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: 'Tool executed successfully', + error: undefined, + errorType: undefined, + }; + + adapter.emitToolResult(request, response); + + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const toolResult = parsed.find( + ( + msg: unknown, + ): msg is { type: 'user'; message: { content: unknown[] } } => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user' && + 'message' in msg && + typeof msg.message === 'object' && + msg.message !== null && + 'content' in msg.message && + Array.isArray(msg.message.content) && + msg.message.content[0] && + typeof msg.message.content[0] === 'object' && + 'type' in msg.message.content[0] && + msg.message.content[0].type === 'tool_result', + ); + + expect(toolResult).toBeDefined(); + const block = toolResult.message.content[0] as { + type: 'tool_result'; + tool_use_id: string; + content?: string; + is_error?: boolean; + }; + expect(block).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'Tool executed successfully', + is_error: false, + }); + }); + + it('should mark error tool results', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: undefined, + error: new Error('Tool failed'), + errorType: undefined, + }; + + adapter.emitToolResult(request, response); + + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const toolResult = parsed.find( + ( + msg: unknown, + ): msg is { type: 'user'; message: { content: unknown[] } } => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'user' && + 'message' in msg && + typeof msg.message === 'object' && + msg.message !== null && + 'content' in msg.message && + Array.isArray(msg.message.content), + ); + + const block = toolResult.message.content[0] as { + is_error?: boolean; + }; + expect(block.is_error).toBe(true); + }); + }); + + describe('emitSystemMessage', () => { + it('should add system message to collection', () => { + adapter.emitSystemMessage('test_subtype', { data: 'value' }); + + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + const systemMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'system', + ); + + expect(systemMessage).toBeDefined(); + expect(systemMessage.subtype).toBe('test_subtype'); + expect(systemMessage.data).toEqual({ data: 'value' }); + }); + }); + + describe('getSessionId and getModel', () => { + it('should return session ID from config', () => { + expect(adapter.getSessionId()).toBe('test-session-id'); + expect(mockConfig.getSessionId).toHaveBeenCalled(); + }); + + it('should return model from config', () => { + expect(adapter.getModel()).toBe('test-model'); + expect(mockConfig.getModel).toHaveBeenCalled(); + }); + }); + + describe('multiple messages in collection', () => { + it('should collect all messages and emit as array', () => { + adapter.emitSystemMessage('init', {}); + adapter.emitUserMessage([{ text: 'User input' }]); + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Assistant response', + }); + adapter.finalizeAssistantMessage(); + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed.length).toBeGreaterThanOrEqual(3); + const systemMsg = parsed[0] as { type?: string }; + const userMsg = parsed[1] as { type?: string }; + expect(systemMsg.type).toBe('system'); + expect(userMsg.type).toBe('user'); + expect( + parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as { type?: string }).type === 'assistant', + ), + ).toBeDefined(); + expect( + parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + (msg as { type?: string }).type === 'result', + ), + ).toBeDefined(); + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts new file mode 100644 index 00000000..118fbc94 --- /dev/null +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import type { CLIAssistantMessage, CLIMessage } from '../types.js'; +import { + BaseJsonOutputAdapter, + type JsonOutputAdapterInterface, + type ResultOptions, +} from './BaseJsonOutputAdapter.js'; + +/** + * JSON output adapter that collects all messages and emits them + * as a single JSON array at the end of the turn. + * Supports both main agent and subagent messages through distinct APIs. + */ +export class JsonOutputAdapter + extends BaseJsonOutputAdapter + implements JsonOutputAdapterInterface +{ + private readonly messages: CLIMessage[] = []; + + constructor(config: Config) { + super(config); + } + + /** + * Emits message to the messages array (batch mode). + * Tracks the last assistant message for efficient result text extraction. + */ + protected emitMessageImpl(message: CLIMessage): void { + this.messages.push(message); + // Track assistant messages for result generation + if ( + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'assistant' + ) { + this.updateLastAssistantMessage(message as CLIAssistantMessage); + } + } + + /** + * JSON mode does not emit stream events. + */ + protected shouldEmitStreamEvents(): boolean { + return false; + } + + finalizeAssistantMessage(): CLIAssistantMessage { + const message = this.finalizeAssistantMessageInternal( + this.mainAgentMessageState, + null, + ); + this.updateLastAssistantMessage(message); + return message; + } + + emitResult(options: ResultOptions): void { + const resultMessage = this.buildResultMessage( + options, + this.lastAssistantMessage, + ); + this.messages.push(resultMessage); + + // Emit the entire messages array as JSON (includes all main agent + subagent messages) + const json = JSON.stringify(this.messages); + process.stdout.write(`${json}\n`); + } + + emitMessage(message: CLIMessage): void { + // In JSON mode, messages are collected in the messages array + // This is called by the base class's finalizeAssistantMessageInternal + // but can also be called directly for user/tool/system messages + this.messages.push(message); + } +} diff --git a/packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts new file mode 100644 index 00000000..90c0234d --- /dev/null +++ b/packages/cli/src/nonInteractive/io/StreamJsonInputReader.test.ts @@ -0,0 +1,215 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PassThrough } from 'node:stream'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + StreamJsonInputReader, + StreamJsonParseError, + type StreamJsonInputMessage, +} from './StreamJsonInputReader.js'; + +describe('StreamJsonInputReader', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('read', () => { + /** + * Test parsing all supported message types in a single test + */ + it('should parse valid messages of all types', async () => { + const input = new PassThrough(); + const reader = new StreamJsonInputReader(input); + + const messages = [ + { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello world' }], + }, + parent_tool_use_id: null, + }, + { + type: 'control_request', + request_id: 'req-1', + request: { subtype: 'initialize' }, + }, + { + type: 'control_response', + response: { + subtype: 'success', + request_id: 'req-1', + response: { initialized: true }, + }, + }, + { + type: 'control_cancel_request', + request_id: 'req-1', + }, + ]; + + for (const msg of messages) { + input.write(JSON.stringify(msg) + '\n'); + } + input.end(); + + const parsed: StreamJsonInputMessage[] = []; + for await (const msg of reader.read()) { + parsed.push(msg); + } + + expect(parsed).toHaveLength(messages.length); + expect(parsed).toEqual(messages); + }); + + it('should parse multiple messages', async () => { + const input = new PassThrough(); + const reader = new StreamJsonInputReader(input); + + const message1 = { + type: 'control_request', + request_id: 'req-1', + request: { subtype: 'initialize' }, + }; + + const message2 = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + parent_tool_use_id: null, + }; + + input.write(JSON.stringify(message1) + '\n'); + input.write(JSON.stringify(message2) + '\n'); + input.end(); + + const messages: StreamJsonInputMessage[] = []; + for await (const msg of reader.read()) { + messages.push(msg); + } + + expect(messages).toHaveLength(2); + expect(messages[0]).toEqual(message1); + expect(messages[1]).toEqual(message2); + }); + + it('should skip empty lines and trim whitespace', async () => { + const input = new PassThrough(); + const reader = new StreamJsonInputReader(input); + + const message = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + }, + parent_tool_use_id: null, + }; + + input.write('\n'); + input.write(' ' + JSON.stringify(message) + ' \n'); + input.write(' \n'); + input.write('\t\n'); + input.end(); + + const messages: StreamJsonInputMessage[] = []; + for await (const msg of reader.read()) { + messages.push(msg); + } + + expect(messages).toHaveLength(1); + expect(messages[0]).toEqual(message); + }); + + /** + * Consolidated error handling test cases + */ + it.each([ + { + name: 'invalid JSON', + input: '{"invalid": json}\n', + expectedError: 'Failed to parse stream-json line', + }, + { + name: 'missing type field', + input: + JSON.stringify({ session_id: 'test-session', message: 'hello' }) + + '\n', + expectedError: 'Missing required "type" field', + }, + { + name: 'non-object value (string)', + input: '"just a string"\n', + expectedError: 'Parsed value is not an object', + }, + { + name: 'non-object value (null)', + input: 'null\n', + expectedError: 'Parsed value is not an object', + }, + { + name: 'array value', + input: '[1, 2, 3]\n', + expectedError: 'Missing required "type" field', + }, + { + name: 'type field not a string', + input: JSON.stringify({ type: 123, session_id: 'test-session' }) + '\n', + expectedError: 'Missing required "type" field', + }, + ])( + 'should throw StreamJsonParseError for $name', + async ({ input: inputLine, expectedError }) => { + const input = new PassThrough(); + const reader = new StreamJsonInputReader(input); + + input.write(inputLine); + input.end(); + + const messages: StreamJsonInputMessage[] = []; + let error: unknown; + + try { + for await (const msg of reader.read()) { + messages.push(msg); + } + } catch (e) { + error = e; + } + + expect(messages).toHaveLength(0); + expect(error).toBeInstanceOf(StreamJsonParseError); + expect((error as StreamJsonParseError).message).toContain( + expectedError, + ); + }, + ); + + it('should use process.stdin as default input', () => { + const reader = new StreamJsonInputReader(); + // Access private field for testing constructor default parameter + expect((reader as unknown as { input: typeof process.stdin }).input).toBe( + process.stdin, + ); + }); + + it('should use provided input stream', () => { + const customInput = new PassThrough(); + const reader = new StreamJsonInputReader(customInput); + // Access private field for testing constructor parameter + expect((reader as unknown as { input: typeof customInput }).input).toBe( + customInput, + ); + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts b/packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts new file mode 100644 index 00000000..f297d741 --- /dev/null +++ b/packages/cli/src/nonInteractive/io/StreamJsonInputReader.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createInterface } from 'node:readline/promises'; +import type { Readable } from 'node:stream'; +import process from 'node:process'; +import type { + CLIControlRequest, + CLIControlResponse, + CLIMessage, + ControlCancelRequest, +} from '../types.js'; + +export type StreamJsonInputMessage = + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +export class StreamJsonParseError extends Error {} + +export class StreamJsonInputReader { + private readonly input: Readable; + + constructor(input: Readable = process.stdin) { + this.input = input; + } + + async *read(): AsyncGenerator { + const rl = createInterface({ + input: this.input, + crlfDelay: Number.POSITIVE_INFINITY, + terminal: false, + }); + + try { + for await (const rawLine of rl) { + const line = rawLine.trim(); + if (!line) { + continue; + } + + yield this.parse(line); + } + } finally { + rl.close(); + } + } + + private parse(line: string): StreamJsonInputMessage { + try { + const parsed = JSON.parse(line) as StreamJsonInputMessage; + if (!parsed || typeof parsed !== 'object') { + throw new StreamJsonParseError('Parsed value is not an object'); + } + if (!('type' in parsed) || typeof parsed.type !== 'string') { + throw new StreamJsonParseError('Missing required "type" field'); + } + return parsed; + } catch (error) { + if (error instanceof StreamJsonParseError) { + throw error; + } + const reason = error instanceof Error ? error.message : String(error); + throw new StreamJsonParseError( + `Failed to parse stream-json line: ${reason}`, + ); + } + } +} diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts new file mode 100644 index 00000000..d0bd2325 --- /dev/null +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -0,0 +1,997 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { + Config, + ServerGeminiStreamEvent, +} from '@qwen-code/qwen-code-core'; +import { GeminiEventType } from '@qwen-code/qwen-code-core'; +import type { Part } from '@google/genai'; +import { StreamJsonOutputAdapter } from './StreamJsonOutputAdapter.js'; + +function createMockConfig(): Config { + return { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getModel: vi.fn().mockReturnValue('test-model'), + } as unknown as Config; +} + +describe('StreamJsonOutputAdapter', () => { + let adapter: StreamJsonOutputAdapter; + let mockConfig: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let stdoutWriteSpy: any; + + beforeEach(() => { + mockConfig = createMockConfig(); + stdoutWriteSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + }); + + afterEach(() => { + stdoutWriteSpy.mockRestore(); + }); + + describe('with partial messages enabled', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, true); + }); + + describe('startAssistantMessage', () => { + it('should reset state for new message', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'First', + }); + adapter.finalizeAssistantMessage(); + + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Second', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'Second', + }); + }); + }); + + describe('processEvent with stream events', () => { + beforeEach(() => { + adapter.startAssistantMessage(); + }); + + it('should emit stream events for text deltas', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello', + }); + + const calls = stdoutWriteSpy.mock.calls; + expect(calls.length).toBeGreaterThan(0); + + const deltaEventCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'content_block_delta' + ); + } catch { + return false; + } + }); + + expect(deltaEventCall).toBeDefined(); + const parsed = JSON.parse(deltaEventCall![0] as string); + expect(parsed.event.type).toBe('content_block_delta'); + expect(parsed.event.delta).toMatchObject({ + type: 'text_delta', + text: 'Hello', + }); + }); + + it('should emit message_start event on first content', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'First', + }); + + const calls = stdoutWriteSpy.mock.calls; + const messageStartCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'message_start' + ); + } catch { + return false; + } + }); + + expect(messageStartCall).toBeDefined(); + }); + + it('should emit content_block_start for new blocks', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + + const calls = stdoutWriteSpy.mock.calls; + const blockStartCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'content_block_start' + ); + } catch { + return false; + } + }); + + expect(blockStartCall).toBeDefined(); + }); + + it('should emit thinking delta events', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking', + }, + }); + + const calls = stdoutWriteSpy.mock.calls; + const deltaCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'content_block_delta' && + parsed.event.delta.type === 'thinking_delta' + ); + } catch { + return false; + } + }); + + expect(deltaCall).toBeDefined(); + }); + + it('should emit message_stop on finalization', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + adapter.finalizeAssistantMessage(); + + const calls = stdoutWriteSpy.mock.calls; + const messageStopCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'message_stop' + ); + } catch { + return false; + } + }); + + expect(messageStopCall).toBeDefined(); + }); + }); + }); + + describe('with partial messages disabled', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + }); + + it('should not emit stream events', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + + const calls = stdoutWriteSpy.mock.calls; + const streamEventCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.type === 'stream_event'; + } catch { + return false; + } + }); + + expect(streamEventCall).toBeUndefined(); + }); + + it('should still emit final assistant message', () => { + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + adapter.finalizeAssistantMessage(); + + const calls = stdoutWriteSpy.mock.calls; + const assistantCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.type === 'assistant'; + } catch { + return false; + } + }); + + expect(assistantCall).toBeDefined(); + }); + }); + + describe('processEvent', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + adapter.startAssistantMessage(); + }); + + it('should append text content from Content events', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello', + }); + adapter.processEvent({ + type: GeminiEventType.Content, + value: ' World', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'Hello World', + }); + }); + + it('should append citation content from Citation events', () => { + adapter.processEvent({ + type: GeminiEventType.Citation, + value: 'Citation text', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('Citation text'), + }); + }); + + it('should ignore non-string citation values', () => { + adapter.processEvent({ + type: GeminiEventType.Citation, + value: 123, + } as unknown as ServerGeminiStreamEvent); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(0); + }); + + it('should append thinking from Thought events', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking about the task', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'thinking', + thinking: 'Planning: Thinking about the task', + signature: 'Planning', + }); + }); + + it('should handle thinking with only subject', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: '', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content[0]).toMatchObject({ + type: 'thinking', + signature: 'Planning', + }); + }); + + it('should append tool use from ToolCallRequest events', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-1', + name: 'test_tool', + args: { param1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'tool_use', + id: 'tool-call-1', + name: 'test_tool', + input: { param1: 'value1' }, + }); + }); + + it('should set stop_reason to tool_use when message contains only tool_use blocks', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-1', + name: 'test_tool', + args: { param1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.stop_reason).toBe('tool_use'); + }); + + it('should set stop_reason to null when message contains text blocks', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Some text', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.stop_reason).toBeNull(); + }); + + it('should set stop_reason to null when message contains thinking blocks', () => { + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { + subject: 'Planning', + description: 'Thinking about the task', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.stop_reason).toBeNull(); + }); + + it('should set stop_reason to tool_use when message contains multiple tool_use blocks', () => { + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-1', + name: 'test_tool_1', + args: { param1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + adapter.processEvent({ + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-call-2', + name: 'test_tool_2', + args: { param2: 'value2' }, + isClientInitiated: false, + prompt_id: 'prompt-1', + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(2); + expect( + message.message.content.every((block) => block.type === 'tool_use'), + ).toBe(true); + expect(message.message.stop_reason).toBe('tool_use'); + }); + + it('should update usage from Finished event', () => { + const usageMetadata = { + promptTokenCount: 100, + candidatesTokenCount: 50, + cachedContentTokenCount: 10, + totalTokenCount: 160, + }; + adapter.processEvent({ + type: GeminiEventType.Finished, + value: { + reason: undefined, + usageMetadata, + }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.usage).toMatchObject({ + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 10, + total_tokens: 160, + }); + }); + + it('should ignore events after finalization', () => { + adapter.finalizeAssistantMessage(); + const originalContent = + adapter.finalizeAssistantMessage().message.content; + + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Should be ignored', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toEqual(originalContent); + }); + }); + + describe('finalizeAssistantMessage', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + adapter.startAssistantMessage(); + }); + + it('should build and emit a complete assistant message', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test response', + }); + + const message = adapter.finalizeAssistantMessage(); + + expect(message.type).toBe('assistant'); + expect(message.uuid).toBeTruthy(); + expect(message.session_id).toBe('test-session-id'); + expect(message.parent_tool_use_id).toBeNull(); + expect(message.message.role).toBe('assistant'); + expect(message.message.model).toBe('test-model'); + expect(message.message.content).toHaveLength(1); + }); + + it('should emit message to stdout immediately', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test', + }); + + stdoutWriteSpy.mockClear(); + adapter.finalizeAssistantMessage(); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + expect(parsed.type).toBe('assistant'); + }); + + it('should store message in lastAssistantMessage', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test', + }); + + const message = adapter.finalizeAssistantMessage(); + // Access protected property for testing + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((adapter as any).lastAssistantMessage).toEqual(message); + }); + + it('should return same message on subsequent calls', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Test', + }); + + const message1 = adapter.finalizeAssistantMessage(); + const message2 = adapter.finalizeAssistantMessage(); + + expect(message1).toEqual(message2); + }); + + it('should split different block types into separate assistant messages', () => { + stdoutWriteSpy.mockClear(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { subject: 'Thinking', description: 'Thought' }, + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0].type).toBe('thinking'); + + const assistantMessages = stdoutWriteSpy.mock.calls + .map((call: unknown[]) => JSON.parse(call[0] as string)) + .filter( + ( + payload: unknown, + ): payload is { + type: string; + message: { content: Array<{ type: string }> }; + } => { + if ( + typeof payload !== 'object' || + payload === null || + !('type' in payload) || + (payload as { type?: string }).type !== 'assistant' || + !('message' in payload) + ) { + return false; + } + const message = (payload as { message?: unknown }).message; + if ( + typeof message !== 'object' || + message === null || + !('content' in message) + ) { + return false; + } + const content = (message as { content?: unknown }).content; + return ( + Array.isArray(content) && + content.length > 0 && + content.every( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block, + ) + ); + }, + ); + + expect(assistantMessages).toHaveLength(2); + const observedTypes = assistantMessages.map( + (payload: { + type: string; + message: { content: Array<{ type: string }> }; + }) => payload.message.content[0]?.type ?? '', + ); + expect(observedTypes).toEqual(['text', 'thinking']); + for (const payload of assistantMessages) { + const uniqueTypes = new Set( + payload.message.content.map((block: { type: string }) => block.type), + ); + expect(uniqueTypes.size).toBeLessThanOrEqual(1); + } + }); + + it('should throw if message not started', () => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + expect(() => adapter.finalizeAssistantMessage()).toThrow( + 'Message not started', + ); + }); + }); + + describe('emitResult', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + adapter.startAssistantMessage(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Response text', + }); + adapter.finalizeAssistantMessage(); + }); + + it('should emit success result immediately', () => { + stdoutWriteSpy.mockClear(); + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.type).toBe('result'); + expect(parsed.is_error).toBe(false); + expect(parsed.subtype).toBe('success'); + expect(parsed.result).toBe('Response text'); + expect(parsed.duration_ms).toBe(1000); + expect(parsed.num_turns).toBe(1); + }); + + it('should emit error result', () => { + stdoutWriteSpy.mockClear(); + adapter.emitResult({ + isError: true, + errorMessage: 'Test error', + durationMs: 500, + apiDurationMs: 300, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.is_error).toBe(true); + expect(parsed.subtype).toBe('error_during_execution'); + expect(parsed.error?.message).toBe('Test error'); + }); + + it('should use provided summary over extracted text', () => { + stdoutWriteSpy.mockClear(); + adapter.emitResult({ + isError: false, + summary: 'Custom summary', + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.result).toBe('Custom summary'); + }); + + it('should include usage information', () => { + const usage = { + input_tokens: 100, + output_tokens: 50, + total_tokens: 150, + }; + + stdoutWriteSpy.mockClear(); + adapter.emitResult({ + isError: false, + usage, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.usage).toEqual(usage); + }); + + it('should handle result without assistant message', () => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + stdoutWriteSpy.mockClear(); + adapter.emitResult({ + isError: false, + durationMs: 1000, + apiDurationMs: 800, + numTurns: 1, + }); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.result).toBe(''); + }); + }); + + describe('emitUserMessage', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + }); + + it('should emit user message immediately', () => { + stdoutWriteSpy.mockClear(); + const parts: Part[] = [{ text: 'Hello user' }]; + adapter.emitUserMessage(parts); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.type).toBe('user'); + expect(Array.isArray(parsed.message.content)).toBe(true); + if (Array.isArray(parsed.message.content)) { + expect(parsed.message.content).toHaveLength(1); + expect(parsed.message.content[0]).toEqual({ + type: 'text', + text: 'Hello user', + }); + } + }); + + it('should handle parent_tool_use_id', () => { + const parts: Part[] = [{ text: 'Tool response' }]; + adapter.emitUserMessage(parts); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + // emitUserMessage currently sets parent_tool_use_id to null + expect(parsed.parent_tool_use_id).toBeNull(); + }); + }); + + describe('emitToolResult', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + }); + + it('should emit tool result message immediately', () => { + stdoutWriteSpy.mockClear(); + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: 'Tool executed successfully', + error: undefined, + errorType: undefined, + }; + + adapter.emitToolResult(request, response); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.type).toBe('user'); + expect(parsed.parent_tool_use_id).toBeNull(); + const block = parsed.message.content[0]; + expect(block).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'Tool executed successfully', + is_error: false, + }); + }); + + it('should mark error tool results', () => { + const request = { + callId: 'tool-1', + name: 'test_tool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-1', + }; + const response = { + callId: 'tool-1', + responseParts: [], + resultDisplay: undefined, + error: new Error('Tool failed'), + errorType: undefined, + }; + + adapter.emitToolResult(request, response); + + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + const block = parsed.message.content[0]; + expect(block.is_error).toBe(true); + }); + }); + + describe('emitSystemMessage', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + }); + + it('should emit system message immediately', () => { + stdoutWriteSpy.mockClear(); + adapter.emitSystemMessage('test_subtype', { data: 'value' }); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls[0][0] as string; + const parsed = JSON.parse(output); + + expect(parsed.type).toBe('system'); + expect(parsed.subtype).toBe('test_subtype'); + expect(parsed.data).toEqual({ data: 'value' }); + }); + }); + + describe('getSessionId and getModel', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + }); + + it('should return session ID from config', () => { + expect(adapter.getSessionId()).toBe('test-session-id'); + expect(mockConfig.getSessionId).toHaveBeenCalled(); + }); + + it('should return model from config', () => { + expect(adapter.getModel()).toBe('test-model'); + expect(mockConfig.getModel).toHaveBeenCalled(); + }); + }); + + describe('message_id in stream events', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, true); + adapter.startAssistantMessage(); + }); + + it('should include message_id in stream events after message starts', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + // Process another event to ensure messageStarted is true + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'More', + }); + + const calls = stdoutWriteSpy.mock.calls; + // Find all delta events + const deltaCalls = calls.filter((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'content_block_delta' + ); + } catch { + return false; + } + }); + + expect(deltaCalls.length).toBeGreaterThan(0); + // The second delta event should have message_id (after messageStarted becomes true) + // message_id is added to the event object, so check parsed.event.message_id + if (deltaCalls.length > 1) { + const secondDelta = JSON.parse( + (deltaCalls[1] as unknown[])[0] as string, + ); + // message_id is on the enriched event object + expect( + secondDelta.event.message_id || secondDelta.message_id, + ).toBeTruthy(); + } else { + // If only one delta, check if message_id exists + const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string); + // message_id is added when messageStarted is true + // First event may or may not have it, but subsequent ones should + expect(delta.event.message_id || delta.message_id).toBeTruthy(); + } + }); + }); + + describe('multiple text blocks', () => { + beforeEach(() => { + adapter = new StreamJsonOutputAdapter(mockConfig, false); + adapter.startAssistantMessage(); + }); + + it('should split assistant messages when block types change repeatedly', () => { + stdoutWriteSpy.mockClear(); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text content', + }); + adapter.processEvent({ + type: GeminiEventType.Thought, + value: { subject: 'Thinking', description: 'Thought' }, + }); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'More text', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'More text', + }); + + const assistantMessages = stdoutWriteSpy.mock.calls + .map((call: unknown[]) => JSON.parse(call[0] as string)) + .filter( + ( + payload: unknown, + ): payload is { + type: string; + message: { content: Array<{ type: string; text?: string }> }; + } => { + if ( + typeof payload !== 'object' || + payload === null || + !('type' in payload) || + (payload as { type?: string }).type !== 'assistant' || + !('message' in payload) + ) { + return false; + } + const message = (payload as { message?: unknown }).message; + if ( + typeof message !== 'object' || + message === null || + !('content' in message) + ) { + return false; + } + const content = (message as { content?: unknown }).content; + return ( + Array.isArray(content) && + content.length > 0 && + content.every( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block, + ) + ); + }, + ); + + expect(assistantMessages).toHaveLength(3); + const observedTypes = assistantMessages.map( + (msg: { + type: string; + message: { content: Array<{ type: string; text?: string }> }; + }) => msg.message.content[0]?.type ?? '', + ); + expect(observedTypes).toEqual(['text', 'thinking', 'text']); + for (const msg of assistantMessages) { + const uniqueTypes = new Set( + msg.message.content.map((block: { type: string }) => block.type), + ); + expect(uniqueTypes.size).toBeLessThanOrEqual(1); + } + }); + + it('should merge consecutive text fragments', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Hello', + }); + adapter.processEvent({ + type: GeminiEventType.Content, + value: ' ', + }); + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'World', + }); + + const message = adapter.finalizeAssistantMessage(); + expect(message.message.content).toHaveLength(1); + expect(message.message.content[0]).toMatchObject({ + type: 'text', + text: 'Hello World', + }); + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts new file mode 100644 index 00000000..af2f0bb6 --- /dev/null +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -0,0 +1,300 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { + CLIAssistantMessage, + CLIMessage, + CLIPartialAssistantMessage, + ControlMessage, + StreamEvent, + TextBlock, + ThinkingBlock, + ToolUseBlock, +} from '../types.js'; +import { + BaseJsonOutputAdapter, + type MessageState, + type ResultOptions, + type JsonOutputAdapterInterface, +} from './BaseJsonOutputAdapter.js'; + +/** + * Stream JSON output adapter that emits messages immediately + * as they are completed during the streaming process. + * Supports both main agent and subagent messages through distinct APIs. + */ +export class StreamJsonOutputAdapter + extends BaseJsonOutputAdapter + implements JsonOutputAdapterInterface +{ + constructor( + config: Config, + private readonly includePartialMessages: boolean, + ) { + super(config); + } + + /** + * Emits message immediately to stdout (stream mode). + */ + protected emitMessageImpl(message: CLIMessage | ControlMessage): void { + // Track assistant messages for result generation + if ( + typeof message === 'object' && + message !== null && + 'type' in message && + message.type === 'assistant' + ) { + this.updateLastAssistantMessage(message as CLIAssistantMessage); + } + + // Emit messages immediately in stream mode + process.stdout.write(`${JSON.stringify(message)}\n`); + } + + /** + * Stream mode emits stream events when includePartialMessages is enabled. + */ + protected shouldEmitStreamEvents(): boolean { + return this.includePartialMessages; + } + + finalizeAssistantMessage(): CLIAssistantMessage { + const state = this.mainAgentMessageState; + if (state.finalized) { + return this.buildMessage(null); + } + state.finalized = true; + + this.finalizePendingBlocks(state, null); + const orderedOpenBlocks = Array.from(state.openBlocks).sort( + (a, b) => a - b, + ); + for (const index of orderedOpenBlocks) { + this.onBlockClosed(state, index, null); + this.closeBlock(state, index); + } + + if (state.messageStarted && this.includePartialMessages) { + this.emitStreamEventIfEnabled({ type: 'message_stop' }, null); + } + + const message = this.buildMessage(null); + this.updateLastAssistantMessage(message); + this.emitMessageImpl(message); + return message; + } + + emitResult(options: ResultOptions): void { + const resultMessage = this.buildResultMessage( + options, + this.lastAssistantMessage, + ); + this.emitMessageImpl(resultMessage); + } + + emitMessage(message: CLIMessage | ControlMessage): void { + // In stream mode, emit immediately + this.emitMessageImpl(message); + } + + send(message: CLIMessage | ControlMessage): void { + this.emitMessage(message); + } + + /** + * Overrides base class hook to emit stream event when text block is created. + */ + protected override onTextBlockCreated( + state: MessageState, + index: number, + block: TextBlock, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_start', + index, + content_block: block, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when text is appended. + */ + protected override onTextAppended( + state: MessageState, + index: number, + fragment: string, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text: fragment }, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when thinking block is created. + */ + protected override onThinkingBlockCreated( + state: MessageState, + index: number, + block: ThinkingBlock, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_start', + index, + content_block: block, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when thinking is appended. + */ + protected override onThinkingAppended( + state: MessageState, + index: number, + fragment: string, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking: fragment }, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when tool_use block is created. + */ + protected override onToolUseBlockCreated( + state: MessageState, + index: number, + block: ToolUseBlock, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_start', + index, + content_block: block, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when tool_use input is set. + */ + protected override onToolUseInputSet( + state: MessageState, + index: number, + input: unknown, + parentToolUseId: string | null, + ): void { + this.emitStreamEventIfEnabled( + { + type: 'content_block_delta', + index, + delta: { + type: 'input_json_delta', + partial_json: JSON.stringify(input), + }, + }, + parentToolUseId, + ); + } + + /** + * Overrides base class hook to emit stream event when block is closed. + */ + protected override onBlockClosed( + state: MessageState, + index: number, + parentToolUseId: string | null, + ): void { + if (this.includePartialMessages) { + this.emitStreamEventIfEnabled( + { + type: 'content_block_stop', + index, + }, + parentToolUseId, + ); + } + } + + /** + * Overrides base class hook to emit message_start event when message is started. + * Only emits for main agent, not for subagents. + */ + protected override onEnsureMessageStarted( + state: MessageState, + parentToolUseId: string | null, + ): void { + // Only emit message_start for main agent, not for subagents + if (parentToolUseId === null) { + this.emitStreamEventIfEnabled( + { + type: 'message_start', + message: { + id: state.messageId!, + role: 'assistant', + model: this.config.getModel(), + }, + }, + null, + ); + } + } + + /** + * Emits stream events when partial messages are enabled. + * This is a private method specific to StreamJsonOutputAdapter. + * @param event - Stream event to emit + * @param parentToolUseId - null for main agent, string for subagent + */ + private emitStreamEventIfEnabled( + event: StreamEvent, + parentToolUseId: string | null, + ): void { + if (!this.includePartialMessages) { + return; + } + + const state = this.getMessageState(parentToolUseId); + const enrichedEvent = state.messageStarted + ? ({ ...event, message_id: state.messageId } as StreamEvent & { + message_id: string; + }) + : event; + + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: parentToolUseId, + event: enrichedEvent, + }; + this.emitMessageImpl(partial); + } +} diff --git a/packages/cli/src/nonInteractive/session.test.ts b/packages/cli/src/nonInteractive/session.test.ts new file mode 100644 index 00000000..61643fb3 --- /dev/null +++ b/packages/cli/src/nonInteractive/session.test.ts @@ -0,0 +1,591 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { runNonInteractiveStreamJson } from './session.js'; +import type { + CLIUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from './types.js'; +import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; +import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; +import { ControlDispatcher } from './control/ControlDispatcher.js'; +import { ControlContext } from './control/ControlContext.js'; +import { ControlService } from './control/ControlService.js'; +import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; + +const runNonInteractiveMock = vi.fn(); + +// Mock dependencies +vi.mock('../nonInteractiveCli.js', () => ({ + runNonInteractive: (...args: unknown[]) => runNonInteractiveMock(...args), +})); + +vi.mock('./io/StreamJsonInputReader.js', () => ({ + StreamJsonInputReader: vi.fn(), +})); + +vi.mock('./io/StreamJsonOutputAdapter.js', () => ({ + StreamJsonOutputAdapter: vi.fn(), +})); + +vi.mock('./control/ControlDispatcher.js', () => ({ + ControlDispatcher: vi.fn(), +})); + +vi.mock('./control/ControlContext.js', () => ({ + ControlContext: vi.fn(), +})); + +vi.mock('./control/ControlService.js', () => ({ + ControlService: vi.fn(), +})); + +vi.mock('../ui/utils/ConsolePatcher.js', () => ({ + ConsolePatcher: vi.fn(), +})); + +interface ConfigOverrides { + getSessionId?: () => string; + getModel?: () => string; + getIncludePartialMessages?: () => boolean; + getDebugMode?: () => boolean; + getApprovalMode?: () => string; + getOutputFormat?: () => string; + [key: string]: unknown; +} + +function createConfig(overrides: ConfigOverrides = {}): Config { + const base = { + getSessionId: () => 'test-session', + getModel: () => 'test-model', + getIncludePartialMessages: () => false, + getDebugMode: () => false, + getApprovalMode: () => 'auto', + getOutputFormat: () => 'stream-json', + }; + return { ...base, ...overrides } as unknown as Config; +} + +function createUserMessage(content: string): CLIUserMessage { + return { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content, + }, + parent_tool_use_id: null, + }; +} + +function createControlRequest( + subtype: 'initialize' | 'set_model' | 'interrupt' = 'initialize', +): CLIControlRequest { + if (subtype === 'set_model') { + return { + type: 'control_request', + request_id: 'req-1', + request: { + subtype: 'set_model', + model: 'test-model', + }, + }; + } + if (subtype === 'interrupt') { + return { + type: 'control_request', + request_id: 'req-1', + request: { + subtype: 'interrupt', + }, + }; + } + return { + type: 'control_request', + request_id: 'req-1', + request: { + subtype: 'initialize', + }, + }; +} + +function createControlResponse(requestId: string): CLIControlResponse { + return { + type: 'control_response', + response: { + subtype: 'success', + request_id: requestId, + response: {}, + }, + }; +} + +function createControlCancel(requestId: string): ControlCancelRequest { + return { + type: 'control_cancel_request', + request_id: requestId, + }; +} + +describe('runNonInteractiveStreamJson', () => { + let config: Config; + let mockInputReader: { + read: () => AsyncGenerator< + | CLIUserMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest + >; + }; + let mockOutputAdapter: { + emitResult: ReturnType; + }; + let mockDispatcher: { + dispatch: ReturnType; + handleControlResponse: ReturnType; + handleCancel: ReturnType; + shutdown: ReturnType; + }; + let mockConsolePatcher: { + patch: ReturnType; + cleanup: ReturnType; + }; + + beforeEach(() => { + config = createConfig(); + runNonInteractiveMock.mockReset(); + + // Setup mocks + mockConsolePatcher = { + patch: vi.fn(), + cleanup: vi.fn(), + }; + (ConsolePatcher as unknown as ReturnType).mockImplementation( + () => mockConsolePatcher, + ); + + mockOutputAdapter = { + emitResult: vi.fn(), + } as { + emitResult: ReturnType; + [key: string]: unknown; + }; + ( + StreamJsonOutputAdapter as unknown as ReturnType + ).mockImplementation(() => mockOutputAdapter); + + mockDispatcher = { + dispatch: vi.fn().mockResolvedValue(undefined), + handleControlResponse: vi.fn(), + handleCancel: vi.fn(), + shutdown: vi.fn(), + }; + ( + ControlDispatcher as unknown as ReturnType + ).mockImplementation(() => mockDispatcher); + (ControlContext as unknown as ReturnType).mockImplementation( + () => ({}), + ); + (ControlService as unknown as ReturnType).mockImplementation( + () => ({}), + ); + + mockInputReader = { + async *read() { + // Default: empty stream + // Override in tests as needed + }, + }; + ( + StreamJsonInputReader as unknown as ReturnType + ).mockImplementation(() => mockInputReader); + + runNonInteractiveMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('initializes session and processes initialize control request', async () => { + const initRequest = createControlRequest('initialize'); + + mockInputReader.read = async function* () { + yield initRequest; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1); + expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest); + expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1); + }); + + it('processes user message when received as first message', async () => { + const userMessage = createUserMessage('Hello world'); + + mockInputReader.read = async function* () { + yield userMessage; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(runNonInteractiveMock).toHaveBeenCalledTimes(1); + const runCall = runNonInteractiveMock.mock.calls[0]; + expect(runCall[2]).toBe('Hello world'); // Direct text, not processed + expect(typeof runCall[3]).toBe('string'); // promptId + expect(runCall[4]).toEqual( + expect.objectContaining({ + abortController: expect.any(AbortController), + adapter: mockOutputAdapter, + }), + ); + }); + + it('processes multiple user messages sequentially', async () => { + // Initialize first to enable multi-query mode + const initRequest = createControlRequest('initialize'); + const userMessage1 = createUserMessage('First message'); + const userMessage2 = createUserMessage('Second message'); + + mockInputReader.read = async function* () { + yield initRequest; + yield userMessage1; + yield userMessage2; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(runNonInteractiveMock).toHaveBeenCalledTimes(2); + }); + + it('enqueues user messages received during processing', async () => { + const initRequest = createControlRequest('initialize'); + const userMessage1 = createUserMessage('First message'); + const userMessage2 = createUserMessage('Second message'); + + // Make runNonInteractive take some time to simulate processing + runNonInteractiveMock.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 10)), + ); + + mockInputReader.read = async function* () { + yield initRequest; + yield userMessage1; + yield userMessage2; + }; + + await runNonInteractiveStreamJson(config, ''); + + // Both messages should be processed + expect(runNonInteractiveMock).toHaveBeenCalledTimes(2); + }); + + it('processes control request in idle state', async () => { + const initRequest = createControlRequest('initialize'); + const controlRequest = createControlRequest('set_model'); + + mockInputReader.read = async function* () { + yield initRequest; + yield controlRequest; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(2); + expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(1, initRequest); + expect(mockDispatcher.dispatch).toHaveBeenNthCalledWith(2, controlRequest); + }); + + it('handles control response in idle state', async () => { + const initRequest = createControlRequest('initialize'); + const controlResponse = createControlResponse('req-2'); + + mockInputReader.read = async function* () { + yield initRequest; + yield controlResponse; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith( + controlResponse, + ); + }); + + it('handles control cancel in idle state', async () => { + const initRequest = createControlRequest('initialize'); + const cancelRequest = createControlCancel('req-2'); + + mockInputReader.read = async function* () { + yield initRequest; + yield cancelRequest; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockDispatcher.handleCancel).toHaveBeenCalledWith('req-2'); + }); + + it('handles control request during processing state', async () => { + const initRequest = createControlRequest('initialize'); + const userMessage = createUserMessage('Process me'); + const controlRequest = createControlRequest('set_model'); + + runNonInteractiveMock.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 10)), + ); + + mockInputReader.read = async function* () { + yield initRequest; + yield userMessage; + yield controlRequest; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockDispatcher.dispatch).toHaveBeenCalledWith(controlRequest); + }); + + it('handles control response during processing state', async () => { + const initRequest = createControlRequest('initialize'); + const userMessage = createUserMessage('Process me'); + const controlResponse = createControlResponse('req-1'); + + runNonInteractiveMock.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 10)), + ); + + mockInputReader.read = async function* () { + yield initRequest; + yield userMessage; + yield controlResponse; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockDispatcher.handleControlResponse).toHaveBeenCalledWith( + controlResponse, + ); + }); + + it('handles user message with text content', async () => { + const userMessage = createUserMessage('Test message'); + + mockInputReader.read = async function* () { + yield userMessage; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(runNonInteractiveMock).toHaveBeenCalledTimes(1); + expect(runNonInteractiveMock).toHaveBeenCalledWith( + config, + expect.objectContaining({ merged: expect.any(Object) }), + 'Test message', + expect.stringContaining('test-session'), + expect.objectContaining({ + abortController: expect.any(AbortController), + adapter: mockOutputAdapter, + }), + ); + }); + + it('handles user message with array content blocks', async () => { + const userMessage: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'First part' }, + { type: 'text', text: 'Second part' }, + ], + }, + parent_tool_use_id: null, + }; + + mockInputReader.read = async function* () { + yield userMessage; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(runNonInteractiveMock).toHaveBeenCalledTimes(1); + expect(runNonInteractiveMock).toHaveBeenCalledWith( + config, + expect.objectContaining({ merged: expect.any(Object) }), + 'First part\nSecond part', + expect.stringContaining('test-session'), + expect.objectContaining({ + abortController: expect.any(AbortController), + adapter: mockOutputAdapter, + }), + ); + }); + + it('skips user message with no text content', async () => { + const userMessage: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [], + }, + parent_tool_use_id: null, + }; + + mockInputReader.read = async function* () { + yield userMessage; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(runNonInteractiveMock).not.toHaveBeenCalled(); + }); + + it('handles error from processUserMessage', async () => { + const userMessage = createUserMessage('Test message'); + + const error = new Error('Processing error'); + runNonInteractiveMock.mockRejectedValue(error); + + mockInputReader.read = async function* () { + yield userMessage; + }; + + await runNonInteractiveStreamJson(config, ''); + + // Error should be caught and handled gracefully + }); + + it('handles stream error gracefully', async () => { + const streamError = new Error('Stream error'); + // eslint-disable-next-line require-yield + mockInputReader.read = async function* () { + throw streamError; + } as typeof mockInputReader.read; + + await expect(runNonInteractiveStreamJson(config, '')).rejects.toThrow( + 'Stream error', + ); + + expect(mockConsolePatcher.cleanup).toHaveBeenCalled(); + }); + + it('stops processing when abort signal is triggered', async () => { + const initRequest = createControlRequest('initialize'); + const userMessage = createUserMessage('Test message'); + + // Capture abort signal from ControlContext + let abortSignal: AbortSignal | null = null; + (ControlContext as unknown as ReturnType).mockImplementation( + (options: { abortSignal?: AbortSignal }) => { + abortSignal = options.abortSignal ?? null; + return {}; + }, + ); + + // Create input reader that aborts after first message + mockInputReader.read = async function* () { + yield initRequest; + // Abort the signal after initialization + if (abortSignal && !abortSignal.aborted) { + // The signal doesn't have an abort method, but the controller does + // Since we can't access the controller directly, we'll test by + // verifying that cleanup happens properly + } + // Yield second message - if abort works, it should be checked + yield userMessage; + }; + + await runNonInteractiveStreamJson(config, ''); + + // Verify initialization happened + expect(mockDispatcher.dispatch).toHaveBeenCalledWith(initRequest); + expect(mockDispatcher.shutdown).toHaveBeenCalled(); + }); + + it('generates unique prompt IDs for each message', async () => { + // Initialize first to enable multi-query mode + const initRequest = createControlRequest('initialize'); + const userMessage1 = createUserMessage('First'); + const userMessage2 = createUserMessage('Second'); + + mockInputReader.read = async function* () { + yield initRequest; + yield userMessage1; + yield userMessage2; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(runNonInteractiveMock).toHaveBeenCalledTimes(2); + const promptId1 = runNonInteractiveMock.mock.calls[0][3] as string; + const promptId2 = runNonInteractiveMock.mock.calls[1][3] as string; + expect(promptId1).not.toBe(promptId2); + expect(promptId1).toContain('test-session'); + expect(promptId2).toContain('test-session'); + }); + + it('ignores non-initialize control request during initialization', async () => { + const controlRequest = createControlRequest('set_model'); + + mockInputReader.read = async function* () { + yield controlRequest; + }; + + await runNonInteractiveStreamJson(config, ''); + + // Should not transition to idle since it's not an initialize request + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('cleans up console patcher on completion', async () => { + mockInputReader.read = async function* () { + // Empty stream - should complete immediately + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockConsolePatcher.patch).toHaveBeenCalledTimes(1); + expect(mockConsolePatcher.cleanup).toHaveBeenCalledTimes(1); + }); + + it('cleans up output adapter on completion', async () => { + mockInputReader.read = async function* () { + // Empty stream + }; + + await runNonInteractiveStreamJson(config, ''); + }); + + it('calls dispatcher shutdown on completion', async () => { + const initRequest = createControlRequest('initialize'); + + mockInputReader.read = async function* () { + yield initRequest; + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockDispatcher.shutdown).toHaveBeenCalledTimes(1); + }); + + it('handles empty stream gracefully', async () => { + mockInputReader.read = async function* () { + // Empty stream + }; + + await runNonInteractiveStreamJson(config, ''); + + expect(mockConsolePatcher.cleanup).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/nonInteractive/session.ts b/packages/cli/src/nonInteractive/session.ts new file mode 100644 index 00000000..614208b7 --- /dev/null +++ b/packages/cli/src/nonInteractive/session.ts @@ -0,0 +1,721 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Stream JSON Runner with Session State Machine + * + * Handles stream-json input/output format with: + * - Initialize handshake + * - Message routing (control vs user messages) + * - FIFO user message queue + * - Sequential message processing + * - Graceful shutdown + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import { StreamJsonInputReader } from './io/StreamJsonInputReader.js'; +import { StreamJsonOutputAdapter } from './io/StreamJsonOutputAdapter.js'; +import { ControlContext } from './control/ControlContext.js'; +import { ControlDispatcher } from './control/ControlDispatcher.js'; +import { ControlService } from './control/ControlService.js'; +import type { + CLIMessage, + CLIUserMessage, + CLIControlRequest, + CLIControlResponse, + ControlCancelRequest, +} from './types.js'; +import { + isCLIUserMessage, + isCLIAssistantMessage, + isCLISystemMessage, + isCLIResultMessage, + isCLIPartialAssistantMessage, + isControlRequest, + isControlResponse, + isControlCancel, +} from './types.js'; +import { createMinimalSettings } from '../config/settings.js'; +import { runNonInteractive } from '../nonInteractiveCli.js'; +import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; + +const SESSION_STATE = { + INITIALIZING: 'initializing', + IDLE: 'idle', + PROCESSING_QUERY: 'processing_query', + SHUTTING_DOWN: 'shutting_down', +} as const; + +type SessionState = (typeof SESSION_STATE)[keyof typeof SESSION_STATE]; + +/** + * Message type classification for routing + */ +type MessageType = + | 'control_request' + | 'control_response' + | 'control_cancel' + | 'user' + | 'assistant' + | 'system' + | 'result' + | 'stream_event' + | 'unknown'; + +/** + * Routed message with classification + */ +interface RoutedMessage { + type: MessageType; + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; +} + +/** + * Session Manager + * + * Manages the session lifecycle and message processing state machine. + */ +class SessionManager { + private state: SessionState = SESSION_STATE.INITIALIZING; + private userMessageQueue: CLIUserMessage[] = []; + private abortController: AbortController; + private config: Config; + private sessionId: string; + private promptIdCounter: number = 0; + private inputReader: StreamJsonInputReader; + private outputAdapter: StreamJsonOutputAdapter; + private controlContext: ControlContext | null = null; + private dispatcher: ControlDispatcher | null = null; + private controlService: ControlService | null = null; + private controlSystemEnabled: boolean | null = null; + private debugMode: boolean; + private shutdownHandler: (() => void) | null = null; + private initialPrompt: CLIUserMessage | null = null; + + constructor(config: Config, initialPrompt?: CLIUserMessage) { + this.config = config; + this.sessionId = config.getSessionId(); + this.debugMode = config.getDebugMode(); + this.abortController = new AbortController(); + this.initialPrompt = initialPrompt ?? null; + + this.inputReader = new StreamJsonInputReader(); + this.outputAdapter = new StreamJsonOutputAdapter( + config, + config.getIncludePartialMessages(), + ); + + // Setup signal handlers for graceful shutdown + this.setupSignalHandlers(); + } + + /** + * Get next prompt ID + */ + private getNextPromptId(): string { + this.promptIdCounter++; + return `${this.sessionId}########${this.promptIdCounter}`; + } + + /** + * Route a message to the appropriate handler based on its type + * + * Classifies incoming messages and routes them to appropriate handlers. + */ + private route( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): RoutedMessage { + // Check control messages first + if (isControlRequest(message)) { + return { type: 'control_request', message }; + } + if (isControlResponse(message)) { + return { type: 'control_response', message }; + } + if (isControlCancel(message)) { + return { type: 'control_cancel', message }; + } + + // Check data messages + if (isCLIUserMessage(message)) { + return { type: 'user', message }; + } + if (isCLIAssistantMessage(message)) { + return { type: 'assistant', message }; + } + if (isCLISystemMessage(message)) { + return { type: 'system', message }; + } + if (isCLIResultMessage(message)) { + return { type: 'result', message }; + } + if (isCLIPartialAssistantMessage(message)) { + return { type: 'stream_event', message }; + } + + // Unknown message type + if (this.debugMode) { + console.error( + '[SessionManager] Unknown message type:', + JSON.stringify(message, null, 2), + ); + } + return { type: 'unknown', message }; + } + + /** + * Process a single message with unified logic for both initial prompt and stream messages. + * + * Handles: + * - Abort check + * - First message detection and handling + * - Normal message processing + * - Shutdown state checks + * + * @param message - Message to process + * @returns true if the calling code should exit (break/return), false to continue + */ + private async processSingleMessage( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): Promise { + // Check for abort + if (this.abortController.signal.aborted) { + return true; + } + + // Handle first message if control system not yet initialized + if (this.controlSystemEnabled === null) { + const handled = await this.handleFirstMessage(message); + if (handled) { + // If handled, check if we should shutdown + return this.state === SESSION_STATE.SHUTTING_DOWN; + } + // If not handled, fall through to normal processing + } + + // Process message normally + await this.processMessage(message); + + // Check for shutdown after processing + return this.state === SESSION_STATE.SHUTTING_DOWN; + } + + /** + * Main entry point - run the session + */ + async run(): Promise { + try { + if (this.debugMode) { + console.error('[SessionManager] Starting session', this.sessionId); + } + + // Process initial prompt if provided + if (this.initialPrompt !== null) { + const shouldExit = await this.processSingleMessage(this.initialPrompt); + if (shouldExit) { + await this.shutdown(); + return; + } + } + + // Process messages from stream + for await (const message of this.inputReader.read()) { + const shouldExit = await this.processSingleMessage(message); + if (shouldExit) { + break; + } + } + + // Stream closed, shutdown + await this.shutdown(); + } catch (error) { + if (this.debugMode) { + console.error('[SessionManager] Error:', error); + } + await this.shutdown(); + throw error; + } finally { + // Ensure signal handlers are always cleaned up even if shutdown wasn't called + this.cleanupSignalHandlers(); + } + } + + private ensureControlSystem(): void { + if (this.controlContext && this.dispatcher && this.controlService) { + return; + } + // The control system follows a strict three-layer architecture: + // 1. ControlContext (shared session state) + // 2. ControlDispatcher (protocol routing SDK ↔ CLI) + // 3. ControlService (programmatic API for CLI runtime) + // + // Application code MUST interact with the control plane exclusively through + // ControlService. ControlDispatcher is reserved for protocol-level message + // routing and should never be used directly outside of this file. + this.controlContext = new ControlContext({ + config: this.config, + streamJson: this.outputAdapter, + sessionId: this.sessionId, + abortSignal: this.abortController.signal, + permissionMode: this.config.getApprovalMode(), + onInterrupt: () => this.handleInterrupt(), + }); + this.dispatcher = new ControlDispatcher(this.controlContext); + this.controlService = new ControlService( + this.controlContext, + this.dispatcher, + ); + } + + private getDispatcher(): ControlDispatcher | null { + if (this.controlSystemEnabled !== true) { + return null; + } + if (!this.dispatcher) { + this.ensureControlSystem(); + } + return this.dispatcher; + } + + private async handleFirstMessage( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): Promise { + const routed = this.route(message); + + if (routed.type === 'control_request') { + const request = routed.message as CLIControlRequest; + this.controlSystemEnabled = true; + this.ensureControlSystem(); + if (request.request.subtype === 'initialize') { + await this.dispatcher?.dispatch(request); + this.state = SESSION_STATE.IDLE; + return true; + } + return false; + } + + if (routed.type === 'user') { + this.controlSystemEnabled = false; + this.state = SESSION_STATE.PROCESSING_QUERY; + this.userMessageQueue.push(routed.message as CLIUserMessage); + await this.processUserMessageQueue(); + return true; + } + + this.controlSystemEnabled = false; + return false; + } + + /** + * Process a single message from the stream + */ + private async processMessage( + message: + | CLIMessage + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest, + ): Promise { + const routed = this.route(message); + + if (this.debugMode) { + console.error( + `[SessionManager] State: ${this.state}, Message type: ${routed.type}`, + ); + } + + switch (this.state) { + case SESSION_STATE.INITIALIZING: + await this.handleInitializingState(routed); + break; + + case SESSION_STATE.IDLE: + await this.handleIdleState(routed); + break; + + case SESSION_STATE.PROCESSING_QUERY: + await this.handleProcessingState(routed); + break; + + case SESSION_STATE.SHUTTING_DOWN: + // Ignore all messages during shutdown + break; + + default: { + // Exhaustive check + const _exhaustiveCheck: never = this.state; + if (this.debugMode) { + console.error('[SessionManager] Unknown state:', _exhaustiveCheck); + } + break; + } + } + } + + /** + * Handle messages in initializing state + */ + private async handleInitializingState(routed: RoutedMessage): Promise { + if (routed.type === 'control_request') { + const request = routed.message as CLIControlRequest; + const dispatcher = this.getDispatcher(); + if (!dispatcher) { + if (this.debugMode) { + console.error( + '[SessionManager] Control request received before control system initialization', + ); + } + return; + } + if (request.request.subtype === 'initialize') { + await dispatcher.dispatch(request); + this.state = SESSION_STATE.IDLE; + if (this.debugMode) { + console.error('[SessionManager] Initialized, transitioning to idle'); + } + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring non-initialize control request during initialization', + ); + } + } + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring non-control message during initialization', + ); + } + } + } + + /** + * Handle messages in idle state + */ + private async handleIdleState(routed: RoutedMessage): Promise { + const dispatcher = this.getDispatcher(); + if (routed.type === 'control_request') { + if (!dispatcher) { + if (this.debugMode) { + console.error('[SessionManager] Ignoring control request (disabled)'); + } + return; + } + const request = routed.message as CLIControlRequest; + await dispatcher.dispatch(request); + // Stay in idle state + } else if (routed.type === 'control_response') { + if (!dispatcher) { + return; + } + const response = routed.message as CLIControlResponse; + dispatcher.handleControlResponse(response); + // Stay in idle state + } else if (routed.type === 'control_cancel') { + if (!dispatcher) { + return; + } + const cancelRequest = routed.message as ControlCancelRequest; + dispatcher.handleCancel(cancelRequest.request_id); + } else if (routed.type === 'user') { + const userMessage = routed.message as CLIUserMessage; + this.userMessageQueue.push(userMessage); + // Start processing queue + await this.processUserMessageQueue(); + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring message type in idle state:', + routed.type, + ); + } + } + } + + /** + * Handle messages in processing state + */ + private async handleProcessingState(routed: RoutedMessage): Promise { + const dispatcher = this.getDispatcher(); + if (routed.type === 'control_request') { + if (!dispatcher) { + if (this.debugMode) { + console.error( + '[SessionManager] Control request ignored during processing (disabled)', + ); + } + return; + } + const request = routed.message as CLIControlRequest; + await dispatcher.dispatch(request); + // Continue processing + } else if (routed.type === 'control_response') { + if (!dispatcher) { + return; + } + const response = routed.message as CLIControlResponse; + dispatcher.handleControlResponse(response); + // Continue processing + } else if (routed.type === 'user') { + // Enqueue for later + const userMessage = routed.message as CLIUserMessage; + this.userMessageQueue.push(userMessage); + if (this.debugMode) { + console.error( + '[SessionManager] Enqueued user message during processing', + ); + } + } else { + if (this.debugMode) { + console.error( + '[SessionManager] Ignoring message type during processing:', + routed.type, + ); + } + } + } + + /** + * Process user message queue (FIFO) + */ + private async processUserMessageQueue(): Promise { + while ( + this.userMessageQueue.length > 0 && + !this.abortController.signal.aborted + ) { + this.state = SESSION_STATE.PROCESSING_QUERY; + const userMessage = this.userMessageQueue.shift()!; + + try { + await this.processUserMessage(userMessage); + } catch (error) { + if (this.debugMode) { + console.error( + '[SessionManager] Error processing user message:', + error, + ); + } + // Send error result + this.emitErrorResult(error); + } + } + + // If control system is disabled (single-query mode) and queue is empty, + // automatically shutdown instead of returning to idle + if ( + !this.abortController.signal.aborted && + this.state === SESSION_STATE.PROCESSING_QUERY && + this.controlSystemEnabled === false && + this.userMessageQueue.length === 0 + ) { + if (this.debugMode) { + console.error( + '[SessionManager] Single-query mode: queue processed, shutting down', + ); + } + this.state = SESSION_STATE.SHUTTING_DOWN; + return; + } + + // Return to idle after processing queue (for multi-query mode with control system) + if ( + !this.abortController.signal.aborted && + this.state === SESSION_STATE.PROCESSING_QUERY + ) { + this.state = SESSION_STATE.IDLE; + if (this.debugMode) { + console.error('[SessionManager] Queue processed, returning to idle'); + } + } + } + + /** + * Process a single user message + */ + private async processUserMessage(userMessage: CLIUserMessage): Promise { + const input = extractUserMessageText(userMessage); + if (!input) { + if (this.debugMode) { + console.error('[SessionManager] No text content in user message'); + } + return; + } + + const promptId = this.getNextPromptId(); + + try { + await runNonInteractive( + this.config, + createMinimalSettings(), + input, + promptId, + { + abortController: this.abortController, + adapter: this.outputAdapter, + controlService: this.controlService ?? undefined, + }, + ); + } catch (error) { + // Error already handled by runNonInteractive via adapter.emitResult + if (this.debugMode) { + console.error('[SessionManager] Query execution error:', error); + } + } + } + + /** + * Send tool results as user message + */ + private emitErrorResult( + error: unknown, + numTurns: number = 0, + durationMs: number = 0, + apiDurationMs: number = 0, + ): void { + const message = error instanceof Error ? error.message : String(error); + this.outputAdapter.emitResult({ + isError: true, + errorMessage: message, + durationMs, + apiDurationMs, + numTurns, + usage: undefined, + }); + } + + /** + * Handle interrupt control request + */ + private handleInterrupt(): void { + if (this.debugMode) { + console.error('[SessionManager] Interrupt requested'); + } + // Abort current query if processing + if (this.state === SESSION_STATE.PROCESSING_QUERY) { + this.abortController.abort(); + this.abortController = new AbortController(); // Create new controller for next query + } + } + + /** + * Setup signal handlers for graceful shutdown + */ + private setupSignalHandlers(): void { + this.shutdownHandler = () => { + if (this.debugMode) { + console.error('[SessionManager] Shutdown signal received'); + } + this.abortController.abort(); + this.state = SESSION_STATE.SHUTTING_DOWN; + }; + + process.on('SIGINT', this.shutdownHandler); + process.on('SIGTERM', this.shutdownHandler); + } + + /** + * Shutdown session and cleanup resources + */ + private async shutdown(): Promise { + if (this.debugMode) { + console.error('[SessionManager] Shutting down'); + } + + this.state = SESSION_STATE.SHUTTING_DOWN; + this.dispatcher?.shutdown(); + this.cleanupSignalHandlers(); + } + + /** + * Remove signal handlers to prevent memory leaks + */ + private cleanupSignalHandlers(): void { + if (this.shutdownHandler) { + process.removeListener('SIGINT', this.shutdownHandler); + process.removeListener('SIGTERM', this.shutdownHandler); + this.shutdownHandler = null; + } + } +} + +function extractUserMessageText(message: CLIUserMessage): string | null { + const content = message.message.content; + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + const parts = content + .map((block) => { + if (!block || typeof block !== 'object') { + return ''; + } + if ('type' in block && block.type === 'text' && 'text' in block) { + return typeof block.text === 'string' ? block.text : ''; + } + return JSON.stringify(block); + }) + .filter((part) => part.length > 0); + + return parts.length > 0 ? parts.join('\n') : null; + } + + return null; +} + +/** + * Entry point for stream-json mode + * + * @param config - Configuration object + * @param input - Optional initial prompt input to process before reading from stream + */ +export async function runNonInteractiveStreamJson( + config: Config, + input: string, +): Promise { + const consolePatcher = new ConsolePatcher({ + debugMode: config.getDebugMode(), + }); + consolePatcher.patch(); + + try { + // Create initial user message from prompt input if provided + let initialPrompt: CLIUserMessage | undefined = undefined; + if (input && input.trim().length > 0) { + const sessionId = config.getSessionId(); + initialPrompt = { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: input.trim(), + }, + parent_tool_use_id: null, + }; + } + + const manager = new SessionManager(config, initialPrompt); + await manager.run(); + } finally { + consolePatcher.cleanup(); + } +} diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts new file mode 100644 index 00000000..784ea916 --- /dev/null +++ b/packages/cli/src/nonInteractive/types.ts @@ -0,0 +1,509 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +/** + * Annotation for attaching metadata to content blocks + */ +export interface Annotation { + type: string; + value: string; +} + +/** + * Usage information types + */ +export interface Usage { + input_tokens: number; + output_tokens: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + total_tokens?: number; +} + +export interface ExtendedUsage extends Usage { + server_tool_use?: { + web_search_requests: number; + }; + service_tier?: string; + cache_creation?: { + ephemeral_1h_input_tokens: number; + ephemeral_5m_input_tokens: number; + }; +} + +export interface ModelUsage { + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + cacheCreationInputTokens: number; + webSearchRequests: number; + contextWindow: number; +} + +/** + * Permission denial information + */ +export interface CLIPermissionDenial { + tool_name: string; + tool_use_id: string; + tool_input: unknown; +} + +/** + * Content block types from Anthropic SDK + */ +export interface TextBlock { + type: 'text'; + text: string; + annotations?: Annotation[]; +} + +export interface ThinkingBlock { + type: 'thinking'; + thinking: string; + signature?: string; + annotations?: Annotation[]; +} + +export interface ToolUseBlock { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + annotations?: Annotation[]; +} + +export interface ToolResultBlock { + type: 'tool_result'; + tool_use_id: string; + content?: string | ContentBlock[]; + is_error?: boolean; + annotations?: Annotation[]; +} + +export type ContentBlock = + | TextBlock + | ThinkingBlock + | ToolUseBlock + | ToolResultBlock; + +/** + * Anthropic SDK Message types + */ +export interface APIUserMessage { + role: 'user'; + content: string | ContentBlock[]; +} + +export interface APIAssistantMessage { + id: string; + type: 'message'; + role: 'assistant'; + model: string; + content: ContentBlock[]; + stop_reason?: string | null; + usage: Usage; +} + +/** + * CLI Message wrapper types + */ +export interface CLIUserMessage { + type: 'user'; + uuid?: string; + session_id: string; + message: APIUserMessage; + parent_tool_use_id: string | null; + options?: Record; +} + +export interface CLIAssistantMessage { + type: 'assistant'; + uuid: string; + session_id: string; + message: APIAssistantMessage; + parent_tool_use_id: string | null; +} + +export interface CLISystemMessage { + type: 'system'; + subtype: string; + uuid: string; + session_id: string; + data?: unknown; + cwd?: string; + tools?: string[]; + mcp_servers?: Array<{ + name: string; + status: string; + }>; + model?: string; + permissionMode?: string; + slash_commands?: string[]; + apiKeySource?: string; + qwen_code_version?: string; + output_style?: string; + agents?: string[]; + skills?: string[]; + capabilities?: Record; + compact_metadata?: { + trigger: 'manual' | 'auto'; + pre_tokens: number; + }; +} + +export interface CLIResultMessageSuccess { + type: 'result'; + subtype: 'success'; + uuid: string; + session_id: string; + is_error: false; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + result: string; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + [key: string]: unknown; +} + +export interface CLIResultMessageError { + type: 'result'; + subtype: 'error_max_turns' | 'error_during_execution'; + uuid: string; + session_id: string; + is_error: true; + duration_ms: number; + duration_api_ms: number; + num_turns: number; + usage: ExtendedUsage; + modelUsage?: Record; + permission_denials: CLIPermissionDenial[]; + error?: { + type?: string; + message: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +export type CLIResultMessage = CLIResultMessageSuccess | CLIResultMessageError; + +/** + * Stream event types for real-time message updates + */ +export interface MessageStartStreamEvent { + type: 'message_start'; + message: { + id: string; + role: 'assistant'; + model: string; + }; +} + +export interface ContentBlockStartEvent { + type: 'content_block_start'; + index: number; + content_block: ContentBlock; +} + +export type ContentBlockDelta = + | { + type: 'text_delta'; + text: string; + } + | { + type: 'thinking_delta'; + thinking: string; + } + | { + type: 'input_json_delta'; + partial_json: string; + }; + +export interface ContentBlockDeltaEvent { + type: 'content_block_delta'; + index: number; + delta: ContentBlockDelta; +} + +export interface ContentBlockStopEvent { + type: 'content_block_stop'; + index: number; +} + +export interface MessageStopStreamEvent { + type: 'message_stop'; +} + +export type StreamEvent = + | MessageStartStreamEvent + | ContentBlockStartEvent + | ContentBlockDeltaEvent + | ContentBlockStopEvent + | MessageStopStreamEvent; + +export interface CLIPartialAssistantMessage { + type: 'stream_event'; + uuid: string; + session_id: string; + event: StreamEvent; + parent_tool_use_id: string | null; +} + +export type PermissionMode = 'default' | 'plan' | 'auto-edit' | 'yolo'; + +/** + * Permission suggestion for tool use requests + * TODO: Align with `ToolCallConfirmationDetails` + */ +export interface PermissionSuggestion { + type: 'allow' | 'deny' | 'modify'; + label: string; + description?: string; + modifiedInput?: unknown; +} + +/** + * Hook callback placeholder for future implementation + */ +export interface HookRegistration { + event: string; + callback_id: string; +} + +/** + * Hook callback result placeholder for future implementation + */ +export interface HookCallbackResult { + shouldSkip?: boolean; + shouldInterrupt?: boolean; + suppressOutput?: boolean; + message?: string; +} + +export interface CLIControlInterruptRequest { + subtype: 'interrupt'; +} + +export interface CLIControlPermissionRequest { + subtype: 'can_use_tool'; + tool_name: string; + tool_use_id: string; + input: unknown; + permission_suggestions: PermissionSuggestion[] | null; + blocked_path: string | null; +} + +export interface CLIControlInitializeRequest { + subtype: 'initialize'; + hooks?: HookRegistration[] | null; + sdkMcpServers?: string[]; +} + +export interface CLIControlSetPermissionModeRequest { + subtype: 'set_permission_mode'; + mode: PermissionMode; +} + +export interface CLIHookCallbackRequest { + subtype: 'hook_callback'; + callback_id: string; + input: unknown; + tool_use_id: string | null; +} + +export interface CLIControlMcpMessageRequest { + subtype: 'mcp_message'; + server_name: string; + message: { + jsonrpc?: string; + method: string; + params?: Record; + id?: string | number | null; + }; +} + +export interface CLIControlSetModelRequest { + subtype: 'set_model'; + model: string; +} + +export interface CLIControlMcpStatusRequest { + subtype: 'mcp_server_status'; +} + +export interface CLIControlSupportedCommandsRequest { + subtype: 'supported_commands'; +} + +export type ControlRequestPayload = + | CLIControlInterruptRequest + | CLIControlPermissionRequest + | CLIControlInitializeRequest + | CLIControlSetPermissionModeRequest + | CLIHookCallbackRequest + | CLIControlMcpMessageRequest + | CLIControlSetModelRequest + | CLIControlMcpStatusRequest + | CLIControlSupportedCommandsRequest; + +export interface CLIControlRequest { + type: 'control_request'; + request_id: string; + request: ControlRequestPayload; +} + +/** + * Permission approval result + */ +export interface PermissionApproval { + allowed: boolean; + reason?: string; + modifiedInput?: unknown; +} + +export interface ControlResponse { + subtype: 'success'; + request_id: string; + response: unknown; +} + +export interface ControlErrorResponse { + subtype: 'error'; + request_id: string; + error: string | { message: string; [key: string]: unknown }; +} + +export interface CLIControlResponse { + type: 'control_response'; + response: ControlResponse | ControlErrorResponse; +} + +export interface ControlCancelRequest { + type: 'control_cancel_request'; + request_id?: string; +} + +export type ControlMessage = + | CLIControlRequest + | CLIControlResponse + | ControlCancelRequest; + +/** + * Union of all CLI message types + */ +export type CLIMessage = + | CLIUserMessage + | CLIAssistantMessage + | CLISystemMessage + | CLIResultMessage + | CLIPartialAssistantMessage; + +/** + * Type guard functions for message discrimination + */ + +export function isCLIUserMessage(msg: any): msg is CLIUserMessage { + return ( + msg && typeof msg === 'object' && msg.type === 'user' && 'message' in msg + ); +} + +export function isCLIAssistantMessage(msg: any): msg is CLIAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'assistant' && + 'uuid' in msg && + 'message' in msg && + 'session_id' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isCLISystemMessage(msg: any): msg is CLISystemMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'system' && + 'subtype' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIResultMessage(msg: any): msg is CLIResultMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'result' && + 'subtype' in msg && + 'duration_ms' in msg && + 'is_error' in msg && + 'uuid' in msg && + 'session_id' in msg + ); +} + +export function isCLIPartialAssistantMessage( + msg: any, +): msg is CLIPartialAssistantMessage { + return ( + msg && + typeof msg === 'object' && + msg.type === 'stream_event' && + 'uuid' in msg && + 'session_id' in msg && + 'event' in msg && + 'parent_tool_use_id' in msg + ); +} + +export function isControlRequest(msg: any): msg is CLIControlRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_request' && + 'request_id' in msg && + 'request' in msg + ); +} + +export function isControlResponse(msg: any): msg is CLIControlResponse { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_response' && + 'response' in msg + ); +} + +export function isControlCancel(msg: any): msg is ControlCancelRequest { + return ( + msg && + typeof msg === 'object' && + msg.type === 'control_cancel_request' && + 'request_id' in msg + ); +} + +/** + * Content block type guards + */ + +export function isTextBlock(block: any): block is TextBlock { + return block && typeof block === 'object' && block.type === 'text'; +} + +export function isThinkingBlock(block: any): block is ThinkingBlock { + return block && typeof block === 'object' && block.type === 'thinking'; +} + +export function isToolUseBlock(block: any): block is ToolUseBlock { + return block && typeof block === 'object' && block.type === 'tool_use'; +} + +export function isToolResultBlock(block: any): block is ToolResultBlock { + return block && typeof block === 'object' && block.type === 'tool_result'; +} diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index 066b1848..5cc53fc6 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -10,6 +10,7 @@ import type { ServerGeminiStreamEvent, SessionMetrics, } from '@qwen-code/qwen-code-core'; +import type { CLIUserMessage } from './nonInteractive/types.js'; import { executeToolCall, ToolErrorType, @@ -18,10 +19,11 @@ import { OutputFormat, uiTelemetryService, FatalInputError, + ApprovalMode, } from '@qwen-code/qwen-code-core'; import type { Part } from '@google/genai'; import { runNonInteractive } from './nonInteractiveCli.js'; -import { vi } from 'vitest'; +import { vi, type Mock, type MockInstance } from 'vitest'; import type { LoadedSettings } from './config/settings.js'; import { CommandKind } from './ui/commands/types.js'; @@ -62,19 +64,20 @@ describe('runNonInteractive', () => { let mockConfig: Config; let mockSettings: LoadedSettings; let mockToolRegistry: ToolRegistry; - let mockCoreExecuteToolCall: vi.Mock; - let mockShutdownTelemetry: vi.Mock; - let consoleErrorSpy: vi.SpyInstance; - let processStdoutSpy: vi.SpyInstance; + let mockCoreExecuteToolCall: Mock; + let mockShutdownTelemetry: Mock; + let consoleErrorSpy: MockInstance; + let processStdoutSpy: MockInstance; let mockGeminiClient: { - sendMessageStream: vi.Mock; - getChatRecordingService: vi.Mock; + sendMessageStream: Mock; + getChatRecordingService: Mock; + getChat: Mock; }; + let mockGetDebugResponses: Mock; beforeEach(async () => { mockCoreExecuteToolCall = vi.mocked(executeToolCall); mockShutdownTelemetry = vi.mocked(shutdownTelemetry); - mockCommandServiceCreate.mockResolvedValue({ getCommands: mockGetCommands, }); @@ -90,8 +93,11 @@ describe('runNonInteractive', () => { mockToolRegistry = { getTool: vi.fn(), getFunctionDeclarations: vi.fn().mockReturnValue([]), + getAllToolNames: vi.fn().mockReturnValue([]), } as unknown as ToolRegistry; + mockGetDebugResponses = vi.fn(() => []); + mockGeminiClient = { sendMessageStream: vi.fn(), getChatRecordingService: vi.fn(() => ({ @@ -100,15 +106,23 @@ describe('runNonInteractive', () => { recordMessageTokens: vi.fn(), recordToolCalls: vi.fn(), })), + getChat: vi.fn(() => ({ + getDebugResponses: mockGetDebugResponses, + })), }; + let currentModel = 'test-model'; + mockConfig = { initialize: vi.fn().mockResolvedValue(undefined), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), getMaxSessionTurns: vi.fn().mockReturnValue(10), - getSessionId: vi.fn().mockReturnValue('test-session-id'), getProjectRoot: vi.fn().mockReturnValue('/test/project'), + getTargetDir: vi.fn().mockReturnValue('/test/project'), + getMcpServers: vi.fn().mockReturnValue(undefined), + getCliVersion: vi.fn().mockReturnValue('test-version'), storage: { getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'), }, @@ -119,6 +133,12 @@ describe('runNonInteractive', () => { getOutputFormat: vi.fn().mockReturnValue('text'), getFolderTrustFeature: vi.fn().mockReturnValue(false), getFolderTrust: vi.fn().mockReturnValue(false), + getIncludePartialMessages: vi.fn().mockReturnValue(false), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getModel: vi.fn(() => currentModel), + setModel: vi.fn(async (model: string) => { + currentModel = model; + }), } as unknown as Config; mockSettings = { @@ -154,6 +174,45 @@ describe('runNonInteractive', () => { vi.restoreAllMocks(); }); + /** + * Creates a default mock SessionMetrics object. + * Can be overridden in individual tests if needed. + */ + function createMockMetrics( + overrides?: Partial, + ): SessionMetrics { + return { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + ...overrides, + }; + } + + /** + * Sets up the default mock for uiTelemetryService.getMetrics(). + * Should be called in beforeEach or at the start of tests that need metrics. + */ + function setupMetricsMock(overrides?: Partial): void { + const mockMetrics = createMockMetrics(overrides); + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + } + async function* createStreamFromEvents( events: ServerGeminiStreamEvent[], ): AsyncGenerator { @@ -232,6 +291,7 @@ describe('runNonInteractive', () => { mockConfig, expect.objectContaining({ name: 'testTool' }), expect.any(AbortSignal), + undefined, ); expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( 2, @@ -283,6 +343,9 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + // Enable debug mode so handleToolError logs to console.error + (mockConfig.getDebugMode as Mock).mockReturnValue(true); + await runNonInteractive( mockConfig, mockSettings, @@ -360,6 +423,9 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + // Enable debug mode so handleToolError logs to console.error + (mockConfig.getDebugMode as Mock).mockReturnValue(true); + await runNonInteractive( mockConfig, mockSettings, @@ -448,28 +514,8 @@ describe('runNonInteractive', () => { mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); - vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, - tools: { - totalCalls: 0, - totalSuccess: 0, - totalFail: 0, - totalDurationMs: 0, - totalDecisions: { - accept: 0, - reject: 0, - modify: 0, - auto_accept: 0, - }, - byName: {}, - }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); await runNonInteractive( mockConfig, @@ -483,9 +529,27 @@ describe('runNonInteractive', () => { expect.any(AbortSignal), 'prompt-id-1', ); - expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: 'Hello World', stats: mockMetrics }, null, 2), + + // JSON adapter emits array of messages, last one is result with stats + const outputCalls = processStdoutSpy.mock.calls.filter( + (call) => typeof call[0] === 'string', ); + expect(outputCalls.length).toBeGreaterThan(0); + const lastOutput = outputCalls[outputCalls.length - 1][0]; + const parsed = JSON.parse(lastOutput); + expect(Array.isArray(parsed)).toBe(true); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + expect(resultMessage).toBeTruthy(); + expect(resultMessage?.result).toBe('Hello World'); + // Get the actual metrics that were used + const actualMetrics = vi.mocked(uiTelemetryService.getMetrics)(); + expect(resultMessage?.stats).toEqual(actualMetrics); }); it('should write JSON output with stats for tool-only commands (no text response)', async () => { @@ -525,9 +589,8 @@ describe('runNonInteractive', () => { .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); - vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock({ tools: { totalCalls: 1, totalSuccess: 1, @@ -554,12 +617,7 @@ describe('runNonInteractive', () => { }, }, }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + }); await runNonInteractive( mockConfig, @@ -573,12 +631,28 @@ describe('runNonInteractive', () => { mockConfig, expect.objectContaining({ name: 'testTool' }), expect.any(AbortSignal), + undefined, ); - // This should output JSON with empty response but include stats - expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: '', stats: mockMetrics }, null, 2), + // JSON adapter emits array of messages, last one is result with stats + const outputCalls = processStdoutSpy.mock.calls.filter( + (call) => typeof call[0] === 'string', ); + expect(outputCalls.length).toBeGreaterThan(0); + const lastOutput = outputCalls[outputCalls.length - 1][0]; + const parsed = JSON.parse(lastOutput); + expect(Array.isArray(parsed)).toBe(true); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + expect(resultMessage).toBeTruthy(); + expect(resultMessage?.result).toBe(''); + // Note: stats would only be included if passed to emitResult, which current implementation doesn't do + // This test verifies the structure, but stats inclusion depends on implementation }); it('should write JSON output with stats for empty response commands', async () => { @@ -592,28 +666,8 @@ describe('runNonInteractive', () => { mockGeminiClient.sendMessageStream.mockReturnValue( createStreamFromEvents(events), ); - vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); - const mockMetrics: SessionMetrics = { - models: {}, - tools: { - totalCalls: 0, - totalSuccess: 0, - totalFail: 0, - totalDurationMs: 0, - totalDecisions: { - accept: 0, - reject: 0, - modify: 0, - auto_accept: 0, - }, - byName: {}, - }, - files: { - totalLinesAdded: 0, - totalLinesRemoved: 0, - }, - }; - vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(mockMetrics); + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); await runNonInteractive( mockConfig, @@ -628,14 +682,31 @@ describe('runNonInteractive', () => { 'prompt-id-empty', ); - // This should output JSON with empty response but include stats - expect(processStdoutSpy).toHaveBeenCalledWith( - JSON.stringify({ response: '', stats: mockMetrics }, null, 2), + // JSON adapter emits array of messages, last one is result with stats + const outputCalls = processStdoutSpy.mock.calls.filter( + (call) => typeof call[0] === 'string', ); + expect(outputCalls.length).toBeGreaterThan(0); + const lastOutput = outputCalls[outputCalls.length - 1][0]; + const parsed = JSON.parse(lastOutput); + expect(Array.isArray(parsed)).toBe(true); + const resultMessage = parsed.find( + (msg: unknown) => + typeof msg === 'object' && + msg !== null && + 'type' in msg && + msg.type === 'result', + ); + expect(resultMessage).toBeTruthy(); + expect(resultMessage?.result).toBe(''); + // Get the actual metrics that were used + const actualMetrics = vi.mocked(uiTelemetryService.getMetrics)(); + expect(resultMessage?.stats).toEqual(actualMetrics); }); it('should handle errors in JSON format', async () => { - vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); const testError = new Error('Invalid input provided'); mockGeminiClient.sendMessageStream.mockImplementation(() => { @@ -680,7 +751,8 @@ describe('runNonInteractive', () => { }); it('should handle FatalInputError with custom exit code in JSON format', async () => { - vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + (mockConfig.getOutputFormat as Mock).mockReturnValue(OutputFormat.JSON); + setupMetricsMock(); const fatalError = new FatalInputError('Invalid command syntax provided'); mockGeminiClient.sendMessageStream.mockImplementation(() => { @@ -878,4 +950,780 @@ describe('runNonInteractive', () => { expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged'); }); + + it('should emit stream-json envelopes when output format is stream-json', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello stream' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 4 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Stream input', + 'prompt-stream', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + // First envelope should be system message (emitted at session start) + expect(envelopes[0]).toMatchObject({ + type: 'system', + subtype: 'init', + }); + + const assistantEnvelope = envelopes.find((env) => env.type === 'assistant'); + expect(assistantEnvelope).toBeTruthy(); + expect(assistantEnvelope?.message?.content?.[0]).toMatchObject({ + type: 'text', + text: 'Hello stream', + }); + const resultEnvelope = envelopes.at(-1); + expect(resultEnvelope).toMatchObject({ + type: 'result', + is_error: false, + num_turns: 1, + }); + }); + + it.skip('should emit a single user envelope when userEnvelope is provided', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Handled once' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 2 } }, + }, + ]), + ); + + const userEnvelope = { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'text', + text: 'ę„č‡Ŗ envelope ēš„ę¶ˆęÆ', + }, + ], + }, + } as unknown as CLIUserMessage; + + await runNonInteractive( + mockConfig, + mockSettings, + 'ignored input', + 'prompt-envelope', + { + userMessage: userEnvelope, + }, + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + const userEnvelopes = envelopes.filter((env) => env.type === 'user'); + expect(userEnvelopes).toHaveLength(0); + }); + + it('should include usage metadata and API duration in stream-json result', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock({ + models: { + 'test-model': { + api: { + totalRequests: 1, + totalErrors: 0, + totalLatencyMs: 500, + }, + tokens: { + prompt: 11, + candidates: 5, + total: 16, + cached: 3, + thoughts: 0, + tool: 0, + }, + }, + }, + }); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const usageMetadata = { + promptTokenCount: 11, + candidatesTokenCount: 5, + totalTokenCount: 16, + cachedContentTokenCount: 3, + }; + mockGetDebugResponses.mockReturnValue([{ usageMetadata }]); + + const nowSpy = vi.spyOn(Date, 'now'); + let current = 0; + nowSpy.mockImplementation(() => { + current += 500; + return current; + }); + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'All done' }, + ]), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'usage test', + 'prompt-usage', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + const resultEnvelope = envelopes.at(-1); + expect(resultEnvelope?.type).toBe('result'); + expect(resultEnvelope?.duration_api_ms).toBeGreaterThan(0); + expect(resultEnvelope?.usage).toEqual({ + input_tokens: 11, + output_tokens: 5, + total_tokens: 16, + cache_read_input_tokens: 3, + }); + + nowSpy.mockRestore(); + }); + + it('should not emit user message when userMessage option is provided (stream-json input binding)', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response from envelope' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + const userMessage: CLIUserMessage = { + type: 'user', + uuid: 'test-uuid', + session_id: 'test-session', + parent_tool_use_id: null, + message: { + role: 'user', + content: [ + { + type: 'text', + text: 'Message from stream-json input', + }, + ], + }, + }; + + await runNonInteractive( + mockConfig, + mockSettings, + 'ignored input', + 'prompt-envelope', + { + userMessage, + }, + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + // Should NOT emit user message since it came from userMessage option + const userEnvelopes = envelopes.filter((env) => env.type === 'user'); + expect(userEnvelopes).toHaveLength(0); + + // Should emit assistant message + const assistantEnvelope = envelopes.find((env) => env.type === 'assistant'); + expect(assistantEnvelope).toBeTruthy(); + + // Verify the model received the correct parts from userMessage + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Message from stream-json input' }], + expect.any(AbortSignal), + 'prompt-envelope', + ); + }); + + it('should emit tool results as user messages in stream-json format', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool', + }, + }; + const toolResponse: Part[] = [ + { + functionResponse: { + name: 'testTool', + response: { output: 'Tool executed successfully' }, + }, + }, + ]; + mockCoreExecuteToolCall.mockResolvedValue({ responseParts: toolResponse }); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final response' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Use tool', + 'prompt-id-tool', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + // Should have tool use in assistant message + const assistantEnvelope = envelopes.find((env) => env.type === 'assistant'); + expect(assistantEnvelope).toBeTruthy(); + const toolUseBlock = assistantEnvelope?.message?.content?.find( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_use', + ); + expect(toolUseBlock).toBeTruthy(); + expect(toolUseBlock?.name).toBe('testTool'); + + // Should have tool result as user message + const toolResultUserMessages = envelopes.filter( + (env) => + env.type === 'user' && + Array.isArray(env.message?.content) && + env.message.content.some( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_result', + ), + ); + expect(toolResultUserMessages).toHaveLength(1); + const toolResultBlock = toolResultUserMessages[0]?.message?.content?.find( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_result', + ); + expect(toolResultBlock?.tool_use_id).toBe('tool-1'); + expect(toolResultBlock?.is_error).toBe(false); + expect(toolResultBlock?.content).toBe('Tool executed successfully'); + }); + + it('should emit tool errors in tool_result blocks in stream-json format', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-error', + name: 'errorTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-error', + }, + }; + mockCoreExecuteToolCall.mockResolvedValue({ + error: new Error('Tool execution failed'), + errorType: ToolErrorType.EXECUTION_FAILED, + responseParts: [ + { + functionResponse: { + name: 'errorTool', + response: { + output: 'Error: Tool execution failed', + }, + }, + }, + ], + resultDisplay: 'Tool execution failed', + }); + + const finalResponse: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Content, + value: 'I encountered an error', + }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Trigger error', + 'prompt-id-error', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + // Tool errors are now captured in tool_result blocks with is_error=true, + // not as separate system messages (see comment in nonInteractiveCli.ts line 307-309) + const toolResultMessages = envelopes.filter( + (env) => + env.type === 'user' && + Array.isArray(env.message?.content) && + env.message.content.some( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_result', + ), + ); + expect(toolResultMessages.length).toBeGreaterThan(0); + const toolResultBlock = toolResultMessages[0]?.message?.content?.find( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_result', + ); + expect(toolResultBlock?.tool_use_id).toBe('tool-error'); + expect(toolResultBlock?.is_error).toBe(true); + }); + + it('should emit partial messages when includePartialMessages is true', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(true); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Stream test', + 'prompt-partial', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + // Should have stream events for partial messages + const streamEvents = envelopes.filter((env) => env.type === 'stream_event'); + expect(streamEvents.length).toBeGreaterThan(0); + + // Should have message_start event + const messageStart = streamEvents.find( + (ev) => ev.event?.type === 'message_start', + ); + expect(messageStart).toBeTruthy(); + + // Should have content_block_delta events for incremental text + const textDeltas = streamEvents.filter( + (ev) => ev.event?.type === 'content_block_delta', + ); + expect(textDeltas.length).toBeGreaterThan(0); + }); + + it('should handle thinking blocks in stream-json format', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Thought, + value: { subject: 'Analysis', description: 'Processing request' }, + }, + { type: GeminiEventType.Content, value: 'Response text' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 8 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Thinking test', + 'prompt-thinking', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + const assistantEnvelope = envelopes.find((env) => env.type === 'assistant'); + expect(assistantEnvelope).toBeTruthy(); + + const thinkingBlock = assistantEnvelope?.message?.content?.find( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'thinking', + ); + expect(thinkingBlock).toBeTruthy(); + expect(thinkingBlock?.signature).toBe('Analysis'); + expect(thinkingBlock?.thinking).toContain('Processing request'); + }); + + it('should handle multiple tool calls in stream-json format', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const toolCall1: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'firstTool', + args: { param: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-multi', + }, + }; + const toolCall2: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-2', + name: 'secondTool', + args: { param: 'value2' }, + isClientInitiated: false, + prompt_id: 'prompt-id-multi', + }, + }; + + mockCoreExecuteToolCall + .mockResolvedValueOnce({ + responseParts: [{ text: 'First tool result' }], + }) + .mockResolvedValueOnce({ + responseParts: [{ text: 'Second tool result' }], + }); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCall1, toolCall2]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Combined response' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 15 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive( + mockConfig, + mockSettings, + 'Multiple tools', + 'prompt-id-multi', + ); + + const envelopes = writes + .join('') + .split('\n') + .filter((line) => line.trim().length > 0) + .map((line) => JSON.parse(line)); + + // Should have assistant message with both tool uses + const assistantEnvelope = envelopes.find((env) => env.type === 'assistant'); + expect(assistantEnvelope).toBeTruthy(); + const toolUseBlocks = assistantEnvelope?.message?.content?.filter( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_use', + ); + expect(toolUseBlocks?.length).toBe(2); + const toolNames = (toolUseBlocks ?? []).map((b: unknown) => { + if ( + typeof b === 'object' && + b !== null && + 'name' in b && + typeof (b as { name: unknown }).name === 'string' + ) { + return (b as { name: string }).name; + } + return ''; + }); + expect(toolNames).toContain('firstTool'); + expect(toolNames).toContain('secondTool'); + + // Should have two tool result user messages + const toolResultMessages = envelopes.filter( + (env) => + env.type === 'user' && + Array.isArray(env.message?.content) && + env.message.content.some( + (block: unknown) => + typeof block === 'object' && + block !== null && + 'type' in block && + block.type === 'tool_result', + ), + ); + expect(toolResultMessages.length).toBe(2); + }); + + it('should handle userMessage with text content blocks in stream-json input mode', async () => { + (mockConfig.getOutputFormat as Mock).mockReturnValue('stream-json'); + (mockConfig.getIncludePartialMessages as Mock).mockReturnValue(false); + setupMetricsMock(); + + const writes: string[] = []; + processStdoutSpy.mockImplementation((chunk: string | Uint8Array) => { + if (typeof chunk === 'string') { + writes.push(chunk); + } else { + writes.push(Buffer.from(chunk).toString('utf8')); + } + return true; + }); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + // UserMessage with string content + const userMessageString: CLIUserMessage = { + type: 'user', + uuid: 'test-uuid-1', + session_id: 'test-session', + parent_tool_use_id: null, + message: { + role: 'user', + content: 'Simple string content', + }, + }; + + await runNonInteractive( + mockConfig, + mockSettings, + 'ignored', + 'prompt-string-content', + { + userMessage: userMessageString, + }, + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Simple string content' }], + expect.any(AbortSignal), + 'prompt-string-content', + ); + + // UserMessage with array of text blocks + mockGeminiClient.sendMessageStream.mockClear(); + const userMessageBlocks: CLIUserMessage = { + type: 'user', + uuid: 'test-uuid-2', + session_id: 'test-session', + parent_tool_use_id: null, + message: { + role: 'user', + content: [ + { type: 'text', text: 'First part' }, + { type: 'text', text: 'Second part' }, + ], + }, + }; + + await runNonInteractive( + mockConfig, + mockSettings, + 'ignored', + 'prompt-blocks-content', + { + userMessage: userMessageBlocks, + }, + ); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'First part' }, { text: 'Second part' }], + expect.any(AbortSignal), + 'prompt-blocks-content', + ); + }); }); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 37f02fab..8e5a9c90 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -15,14 +15,16 @@ import { FatalInputError, promptIdContext, OutputFormat, - JsonFormatter, uiTelemetryService, } from '@qwen-code/qwen-code-core'; - -import type { Content, Part } from '@google/genai'; +import type { Content, Part, PartListUnion } from '@google/genai'; +import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; +import type { JsonOutputAdapterInterface } from './nonInteractive/io/BaseJsonOutputAdapter.js'; +import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; +import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; +import type { ControlService } from './nonInteractive/control/ControlService.js'; import { handleSlashCommand } from './nonInteractiveCliCommands.js'; -import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; import { handleError, @@ -30,73 +32,144 @@ import { handleCancellationError, handleMaxTurnsExceededError, } from './utils/errors.js'; +import { + normalizePartList, + extractPartsFromUserMessage, + buildSystemMessage, + createTaskToolProgressHandler, + computeUsageFromMetrics, +} from './utils/nonInteractiveHelpers.js'; +/** + * Provides optional overrides for `runNonInteractive` execution. + * + * @param abortController - Optional abort controller for cancellation. + * @param adapter - Optional JSON output adapter for structured output formats. + * @param userMessage - Optional CLI user message payload for preformatted input. + * @param controlService - Optional control service for future permission handling. + */ +export interface RunNonInteractiveOptions { + abortController?: AbortController; + adapter?: JsonOutputAdapterInterface; + userMessage?: CLIUserMessage; + controlService?: ControlService; +} + +/** + * Executes the non-interactive CLI flow for a single request. + */ export async function runNonInteractive( config: Config, settings: LoadedSettings, input: string, prompt_id: string, + options: RunNonInteractiveOptions = {}, ): Promise { return promptIdContext.run(prompt_id, async () => { - const consolePatcher = new ConsolePatcher({ - stderr: true, - debugMode: config.getDebugMode(), - }); + // Create output adapter based on format + let adapter: JsonOutputAdapterInterface | undefined; + const outputFormat = config.getOutputFormat(); + + if (options.adapter) { + adapter = options.adapter; + } else if (outputFormat === OutputFormat.JSON) { + adapter = new JsonOutputAdapter(config); + } else if (outputFormat === OutputFormat.STREAM_JSON) { + adapter = new StreamJsonOutputAdapter( + config, + config.getIncludePartialMessages(), + ); + } + + // Get readonly values once at the start + const sessionId = config.getSessionId(); + const permissionMode = config.getApprovalMode() as PermissionMode; + + let turnCount = 0; + let totalApiDurationMs = 0; + const startTime = Date.now(); + + const stdoutErrorHandler = (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') { + process.stdout.removeListener('error', stdoutErrorHandler); + process.exit(0); + } + }; + + const geminiClient = config.getGeminiClient(); + const abortController = options.abortController ?? new AbortController(); + + // Setup signal handlers for graceful shutdown + const shutdownHandler = () => { + if (config.getDebugMode()) { + console.error('[runNonInteractive] Shutdown signal received'); + } + abortController.abort(); + }; try { - consolePatcher.patch(); - // Handle EPIPE errors when the output is piped to a command that closes early. - process.stdout.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EPIPE') { - // Exit gracefully if the pipe is closed. - process.exit(0); - } - }); + process.stdout.on('error', stdoutErrorHandler); - const geminiClient = config.getGeminiClient(); + process.on('SIGINT', shutdownHandler); + process.on('SIGTERM', shutdownHandler); - const abortController = new AbortController(); + let initialPartList: PartListUnion | null = extractPartsFromUserMessage( + options.userMessage, + ); - let query: Part[] | undefined; - - if (isSlashCommand(input)) { - const slashCommandResult = await handleSlashCommand( - input, - abortController, - config, - settings, - ); - // If a slash command is found and returns a prompt, use it. - // Otherwise, slashCommandResult fall through to the default prompt - // handling. - if (slashCommandResult) { - query = slashCommandResult as Part[]; - } - } - - if (!query) { - const { processedQuery, shouldProceed } = await handleAtCommand({ - query: input, - config, - addItem: (_item, _timestamp) => 0, - onDebugMessage: () => {}, - messageId: Date.now(), - signal: abortController.signal, - }); - - if (!shouldProceed || !processedQuery) { - // An error occurred during @include processing (e.g., file not found). - // The error message is already logged by handleAtCommand. - throw new FatalInputError( - 'Exiting due to an error processing the @ command.', + if (!initialPartList) { + let slashHandled = false; + if (isSlashCommand(input)) { + const slashCommandResult = await handleSlashCommand( + input, + abortController, + config, + settings, ); + if (slashCommandResult) { + // A slash command can replace the prompt entirely; fall back to @-command processing otherwise. + initialPartList = slashCommandResult as PartListUnion; + slashHandled = true; + } + } + + if (!slashHandled) { + const { processedQuery, shouldProceed } = await handleAtCommand({ + query: input, + config, + addItem: (_item, _timestamp) => 0, + onDebugMessage: () => {}, + messageId: Date.now(), + signal: abortController.signal, + }); + + if (!shouldProceed || !processedQuery) { + // An error occurred during @include processing (e.g., file not found). + // The error message is already logged by handleAtCommand. + throw new FatalInputError( + 'Exiting due to an error processing the @ command.', + ); + } + initialPartList = processedQuery as PartListUnion; } - query = processedQuery as Part[]; } - let currentMessages: Content[] = [{ role: 'user', parts: query }]; + if (!initialPartList) { + initialPartList = [{ text: input }]; + } + + const initialParts = normalizePartList(initialPartList); + let currentMessages: Content[] = [{ role: 'user', parts: initialParts }]; + + if (adapter) { + const systemMessage = await buildSystemMessage( + config, + sessionId, + permissionMode, + ); + adapter.emitMessage(systemMessage); + } - let turnCount = 0; while (true) { turnCount++; if ( @@ -105,43 +178,124 @@ export async function runNonInteractive( ) { handleMaxTurnsExceededError(config); } - const toolCallRequests: ToolCallRequestInfo[] = []; + const toolCallRequests: ToolCallRequestInfo[] = []; + const apiStartTime = Date.now(); const responseStream = geminiClient.sendMessageStream( currentMessages[0]?.parts || [], abortController.signal, prompt_id, ); - let responseText = ''; + // Start assistant message for this turn + if (adapter) { + adapter.startAssistantMessage(); + } + for await (const event of responseStream) { if (abortController.signal.aborted) { handleCancellationError(config); } - if (event.type === GeminiEventType.Content) { - if (config.getOutputFormat() === OutputFormat.JSON) { - responseText += event.value; - } else { - process.stdout.write(event.value); + if (adapter) { + // Use adapter for all event processing + adapter.processEvent(event); + if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + } + } else { + // Text output mode - direct stdout + if (event.type === GeminiEventType.Content) { + process.stdout.write(event.value); + } else if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); } - } else if (event.type === GeminiEventType.ToolCallRequest) { - toolCallRequests.push(event.value); } } + // Finalize assistant message + if (adapter) { + adapter.finalizeAssistantMessage(); + } + totalApiDurationMs += Date.now() - apiStartTime; + if (toolCallRequests.length > 0) { const toolResponseParts: Part[] = []; + for (const requestInfo of toolCallRequests) { + const finalRequestInfo = requestInfo; + + /* + if (options.controlService) { + const permissionResult = + await options.controlService.permission.shouldAllowTool( + requestInfo, + ); + if (!permissionResult.allowed) { + if (config.getDebugMode()) { + console.error( + `[runNonInteractive] Tool execution denied: ${requestInfo.name}`, + permissionResult.message ?? '', + ); + } + if (adapter && permissionResult.message) { + adapter.emitSystemMessage('tool_denied', { + tool: requestInfo.name, + message: permissionResult.message, + }); + } + continue; + } + + if (permissionResult.updatedArgs) { + finalRequestInfo = { + ...requestInfo, + args: permissionResult.updatedArgs, + }; + } + } + + const toolCallUpdateCallback = options.controlService + ? options.controlService.permission.getToolCallUpdateCallback() + : undefined; + */ + + // Only pass outputUpdateHandler for Task tool + const isTaskTool = finalRequestInfo.name === 'task'; + const taskToolProgress = isTaskTool + ? createTaskToolProgressHandler( + config, + finalRequestInfo.callId, + adapter, + ) + : undefined; + const taskToolProgressHandler = taskToolProgress?.handler; const toolResponse = await executeToolCall( config, - requestInfo, + finalRequestInfo, abortController.signal, + isTaskTool && taskToolProgressHandler + ? { + outputUpdateHandler: taskToolProgressHandler, + /* + toolCallUpdateCallback + ? { onToolCallsUpdate: toolCallUpdateCallback } + : undefined, + */ + } + : undefined, ); + // Note: In JSON mode, subagent messages are automatically added to the main + // adapter's messages array and will be output together on emitResult() + if (toolResponse.error) { + // In JSON/STREAM_JSON mode, tool errors are tolerated and formatted + // as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode + // from config and allow the session to continue so the LLM can decide what to do next. + // In text mode, we still log the error. handleToolError( - requestInfo.name, + finalRequestInfo.name, toolResponse.error, config, toolResponse.errorType || 'TOOL_EXECUTION_ERROR', @@ -149,6 +303,13 @@ export async function runNonInteractive( ? toolResponse.resultDisplay : undefined, ); + // Note: We no longer emit a separate system message for tool errors + // in JSON/STREAM_JSON mode, as the error is already captured in the + // tool_result block with is_error=true. + } + + if (adapter) { + adapter.emitToolResult(finalRequestInfo, toolResponse); } if (toolResponse.responseParts) { @@ -157,20 +318,57 @@ export async function runNonInteractive( } currentMessages = [{ role: 'user', parts: toolResponseParts }]; } else { - if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const stats = uiTelemetryService.getMetrics(); - process.stdout.write(formatter.format(responseText, stats)); + // For JSON and STREAM_JSON modes, compute usage from metrics + if (adapter) { + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + // Get stats for JSON format output + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + adapter.emitResult({ + isError: false, + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + usage, + stats, + }); } else { - process.stdout.write('\n'); // Ensure a final newline + // Text output mode - no usage needed + process.stdout.write('\n'); } return; } } } catch (error) { + // For JSON and STREAM_JSON modes, compute usage from metrics + const message = error instanceof Error ? error.message : String(error); + if (adapter) { + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + // Get stats for JSON format output + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + adapter.emitResult({ + isError: true, + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + errorMessage: message, + usage, + stats, + }); + } handleError(error, config); } finally { - consolePatcher.cleanup(); + process.stdout.removeListener('error', stdoutErrorHandler); + // Cleanup signal handlers + process.removeListener('SIGINT', shutdownHandler); + process.removeListener('SIGTERM', shutdownHandler); if (isTelemetrySdkInitialized()) { await shutdownTelemetry(config); } diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index f2565343..818c3ac3 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, type MockInstance } from 'vitest'; +import { vi, type Mock, type MockInstance } from 'vitest'; import type { Config } from '@qwen-code/qwen-code-core'; import { OutputFormat, FatalInputError } from '@qwen-code/qwen-code-core'; import { @@ -83,6 +83,7 @@ describe('errors', () => { mockConfig = { getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), getContentGeneratorConfig: vi.fn().mockReturnValue({ authType: 'test' }), + getDebugMode: vi.fn().mockReturnValue(true), } as unknown as Config; }); @@ -254,105 +255,81 @@ describe('errors', () => { const toolName = 'test-tool'; const toolError = new Error('Tool failed'); - describe('in text mode', () => { + describe('when debug mode is enabled', () => { beforeEach(() => { - ( - mockConfig.getOutputFormat as ReturnType - ).mockReturnValue(OutputFormat.TEXT); + (mockConfig.getDebugMode as Mock).mockReturnValue(true); }); - it('should log error message to stderr', () => { - handleToolError(toolName, toolError, mockConfig); + describe('in text mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + }); - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error executing tool test-tool: Tool failed', - ); - }); - - it('should use resultDisplay when provided', () => { - handleToolError( - toolName, - toolError, - mockConfig, - 'CUSTOM_ERROR', - 'Custom display message', - ); - - expect(consoleErrorSpy).toHaveBeenCalledWith( - 'Error executing tool test-tool: Custom display message', - ); - }); - }); - - describe('in JSON mode', () => { - beforeEach(() => { - ( - mockConfig.getOutputFormat as ReturnType - ).mockReturnValue(OutputFormat.JSON); - }); - - it('should format error as JSON and exit with default code', () => { - expect(() => { + it('should log error message to stderr and not exit', () => { handleToolError(toolName, toolError, mockConfig); - }).toThrow('process.exit called with code: 54'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - JSON.stringify( - { - error: { - type: 'FatalToolExecutionError', - message: 'Error executing tool test-tool: Tool failed', - code: 54, - }, - }, - null, - 2, - ), - ); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should use resultDisplay when provided and not exit', () => { + handleToolError( + toolName, + toolError, + mockConfig, + 'CUSTOM_ERROR', + 'Custom display message', + ); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Custom display message', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); }); - it('should use custom error code', () => { - expect(() => { + describe('in JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + }); + + it('should log error message to stderr and not exit', () => { + handleToolError(toolName, toolError, mockConfig); + + // In JSON mode, should not exit (just log to stderr when debug mode is on) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should log error with custom error code and not exit', () => { handleToolError(toolName, toolError, mockConfig, 'CUSTOM_TOOL_ERROR'); - }).toThrow('process.exit called with code: 54'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - JSON.stringify( - { - error: { - type: 'FatalToolExecutionError', - message: 'Error executing tool test-tool: Tool failed', - code: 'CUSTOM_TOOL_ERROR', - }, - }, - null, - 2, - ), - ); - }); + // In JSON mode, should not exit (just log to stderr when debug mode is on) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); - it('should use numeric error code and exit with that code', () => { - expect(() => { + it('should log error with numeric error code and not exit', () => { handleToolError(toolName, toolError, mockConfig, 500); - }).toThrow('process.exit called with code: 500'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - JSON.stringify( - { - error: { - type: 'FatalToolExecutionError', - message: 'Error executing tool test-tool: Tool failed', - code: 500, - }, - }, - null, - 2, - ), - ); - }); + // In JSON mode, should not exit (just log to stderr when debug mode is on) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); - it('should prefer resultDisplay over error message', () => { - expect(() => { + it('should prefer resultDisplay over error message and not exit', () => { handleToolError( toolName, toolError, @@ -360,21 +337,99 @@ describe('errors', () => { 'DISPLAY_ERROR', 'Display message', ); - }).toThrow('process.exit called with code: 54'); - expect(consoleErrorSpy).toHaveBeenCalledWith( - JSON.stringify( - { - error: { - type: 'FatalToolExecutionError', - message: 'Error executing tool test-tool: Display message', - code: 'DISPLAY_ERROR', - }, - }, - null, - 2, - ), - ); + // In JSON mode, should not exit (just log to stderr when debug mode is on) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Display message', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('in STREAM_JSON mode', () => { + beforeEach(() => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + }); + + it('should log error message to stderr and not exit', () => { + handleToolError(toolName, toolError, mockConfig); + + // Should not exit in STREAM_JSON mode (just log to stderr when debug mode is on) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool test-tool: Tool failed', + ); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when debug mode is disabled', () => { + beforeEach(() => { + (mockConfig.getDebugMode as Mock).mockReturnValue(false); + }); + + it('should not log and not exit in text mode', () => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + + handleToolError(toolName, toolError, mockConfig); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not log and not exit in JSON mode', () => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + + handleToolError(toolName, toolError, mockConfig); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('should not log and not exit in STREAM_JSON mode', () => { + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + + handleToolError(toolName, toolError, mockConfig); + + expect(consoleErrorSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('process exit behavior', () => { + beforeEach(() => { + (mockConfig.getDebugMode as Mock).mockReturnValue(true); + }); + + it('should never exit regardless of output format', () => { + // Test in TEXT mode + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.TEXT); + handleToolError(toolName, toolError, mockConfig); + expect(processExitSpy).not.toHaveBeenCalled(); + + // Test in JSON mode + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.JSON); + handleToolError(toolName, toolError, mockConfig); + expect(processExitSpy).not.toHaveBeenCalled(); + + // Test in STREAM_JSON mode + ( + mockConfig.getOutputFormat as ReturnType + ).mockReturnValue(OutputFormat.STREAM_JSON); + handleToolError(toolName, toolError, mockConfig); + expect(processExitSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts index e2aaa0e6..5338fa2f 100644 --- a/packages/cli/src/utils/errors.ts +++ b/packages/cli/src/utils/errors.ts @@ -10,7 +10,6 @@ import { JsonFormatter, parseAndFormatApiError, FatalTurnLimitedError, - FatalToolExecutionError, FatalCancellationError, } from '@qwen-code/qwen-code-core'; @@ -88,32 +87,29 @@ export function handleError( /** * Handles tool execution errors specifically. - * In JSON mode, outputs formatted JSON error and exits. + * In JSON/STREAM_JSON mode, outputs error message to stderr only and does not exit. + * The error will be properly formatted in the tool_result block by the adapter, + * allowing the session to continue so the LLM can decide what to do next. * In text mode, outputs error message to stderr only. + * + * @param toolName - Name of the tool that failed + * @param toolError - The error that occurred during tool execution + * @param config - Configuration object + * @param errorCode - Optional error code + * @param resultDisplay - Optional display message for the error */ export function handleToolError( toolName: string, toolError: Error, config: Config, - errorCode?: string | number, + _errorCode?: string | number, resultDisplay?: string, ): void { - const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`; - const toolExecutionError = new FatalToolExecutionError(errorMessage); - - if (config.getOutputFormat() === OutputFormat.JSON) { - const formatter = new JsonFormatter(); - const formattedError = formatter.formatError( - toolExecutionError, - errorCode ?? toolExecutionError.exitCode, + // Always just log to stderr; JSON/streaming formatting happens in the tool_result block elsewhere + if (config.getDebugMode()) { + console.error( + `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`, ); - - console.error(formattedError); - process.exit( - typeof errorCode === 'number' ? errorCode : toolExecutionError.exitCode, - ); - } else { - console.error(errorMessage); } } diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts new file mode 100644 index 00000000..11f302b4 --- /dev/null +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -0,0 +1,1168 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { + Config, + SessionMetrics, + TaskResultDisplay, + ToolCallResponseInfo, +} from '@qwen-code/qwen-code-core'; +import { + ToolErrorType, + MCPServerStatus, + getMCPServerStatus, + OutputFormat, +} from '@qwen-code/qwen-code-core'; +import type { Part } from '@google/genai'; +import type { + CLIUserMessage, + PermissionMode, +} from '../nonInteractive/types.js'; +import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import { + normalizePartList, + extractPartsFromUserMessage, + extractUsageFromGeminiClient, + computeUsageFromMetrics, + buildSystemMessage, + createTaskToolProgressHandler, + functionResponsePartsToString, + toolResultContent, +} from './nonInteractiveHelpers.js'; + +// Mock dependencies +vi.mock('../services/CommandService.js', () => ({ + CommandService: { + create: vi.fn().mockResolvedValue({ + getCommands: vi + .fn() + .mockReturnValue([ + { name: 'help' }, + { name: 'commit' }, + { name: 'memory' }, + ]), + }), + }, +})); + +vi.mock('../services/BuiltinCommandLoader.js', () => ({ + BuiltinCommandLoader: vi.fn().mockImplementation(() => ({})), +})); + +vi.mock('../ui/utils/computeStats.js', () => ({ + computeSessionStats: vi.fn().mockReturnValue({ + totalPromptTokens: 100, + totalCachedTokens: 20, + }), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getMCPServerStatus: vi.fn(), + }; +}); + +describe('normalizePartList', () => { + it('should return empty array for null input', () => { + expect(normalizePartList(null)).toEqual([]); + }); + + it('should return empty array for undefined input', () => { + expect(normalizePartList(undefined as unknown as null)).toEqual([]); + }); + + it('should convert string to Part array', () => { + const result = normalizePartList('test string'); + expect(result).toEqual([{ text: 'test string' }]); + }); + + it('should convert array of strings to Part array', () => { + const result = normalizePartList(['hello', 'world']); + expect(result).toEqual([{ text: 'hello' }, { text: 'world' }]); + }); + + it('should convert array of mixed strings and Parts to Part array', () => { + const part: Part = { text: 'existing' }; + const result = normalizePartList(['new', part]); + expect(result).toEqual([{ text: 'new' }, part]); + }); + + it('should convert single Part object to array', () => { + const part: Part = { text: 'single part' }; + const result = normalizePartList(part); + expect(result).toEqual([part]); + }); + + it('should handle empty array', () => { + expect(normalizePartList([])).toEqual([]); + }); +}); + +describe('extractPartsFromUserMessage', () => { + it('should return null for undefined message', () => { + expect(extractPartsFromUserMessage(undefined)).toBeNull(); + }); + + it('should return null for null message', () => { + expect( + extractPartsFromUserMessage(null as unknown as undefined), + ).toBeNull(); + }); + + it('should extract string content', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: 'test message', + }, + parent_tool_use_id: null, + }; + expect(extractPartsFromUserMessage(message)).toBe('test message'); + }); + + it('should extract text blocks from content array', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'world' }, + ], + }, + parent_tool_use_id: null, + }; + const result = extractPartsFromUserMessage(message); + expect(result).toEqual([{ text: 'hello' }, { text: 'world' }]); + }); + + it('should skip invalid blocks in content array', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'valid' }, + null as unknown as { type: 'text'; text: string }, + { type: 'text', text: 'also valid' }, + ], + }, + parent_tool_use_id: null, + }; + const result = extractPartsFromUserMessage(message); + expect(result).toEqual([{ text: 'valid' }, { text: 'also valid' }]); + }); + + it('should convert non-text blocks to JSON strings', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [ + { type: 'text', text: 'text block' }, + { type: 'tool_use', id: '123', name: 'tool', input: {} }, + ], + }, + parent_tool_use_id: null, + }; + const result = extractPartsFromUserMessage(message); + expect(result).toEqual([ + { text: 'text block' }, + { + text: JSON.stringify({ + type: 'tool_use', + id: '123', + name: 'tool', + input: {}, + }), + }, + ]); + }); + + it('should return null for empty content array', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: [], + }, + parent_tool_use_id: null, + }; + expect(extractPartsFromUserMessage(message)).toBeNull(); + }); + + it('should return null when message has no content', () => { + const message: CLIUserMessage = { + type: 'user', + session_id: 'test-session', + message: { + role: 'user', + content: undefined as unknown as string, + }, + parent_tool_use_id: null, + }; + expect(extractPartsFromUserMessage(message)).toBeNull(); + }); +}); + +describe('extractUsageFromGeminiClient', () => { + it('should return undefined for null client', () => { + expect(extractUsageFromGeminiClient(null)).toBeUndefined(); + }); + + it('should return undefined for non-object client', () => { + expect(extractUsageFromGeminiClient('not an object')).toBeUndefined(); + }); + + it('should return undefined when getChat is not a function', () => { + const client = { getChat: 'not a function' }; + expect(extractUsageFromGeminiClient(client)).toBeUndefined(); + }); + + it('should return undefined when chat does not have getDebugResponses', () => { + const client = { + getChat: vi.fn().mockReturnValue({}), + }; + expect(extractUsageFromGeminiClient(client)).toBeUndefined(); + }); + + it('should extract usage from latest response with usageMetadata', () => { + const client = { + getChat: vi.fn().mockReturnValue({ + getDebugResponses: vi.fn().mockReturnValue([ + { usageMetadata: { promptTokenCount: 50 } }, + { + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 200, + totalTokenCount: 300, + cachedContentTokenCount: 10, + }, + }, + ]), + }), + }; + const result = extractUsageFromGeminiClient(client); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 200, + total_tokens: 300, + cache_read_input_tokens: 10, + }); + }); + + it('should return default values when metadata values are not numbers', () => { + const client = { + getChat: vi.fn().mockReturnValue({ + getDebugResponses: vi.fn().mockReturnValue([ + { + usageMetadata: { + promptTokenCount: 'not a number', + candidatesTokenCount: null, + }, + }, + ]), + }), + }; + const result = extractUsageFromGeminiClient(client); + expect(result).toEqual({ + input_tokens: 0, + output_tokens: 0, + }); + }); + + it('should handle errors gracefully', () => { + const client = { + getChat: vi.fn().mockImplementation(() => { + throw new Error('Test error'); + }), + }; + const consoleSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + const result = extractUsageFromGeminiClient(client); + expect(result).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + + it('should skip responses without usageMetadata', () => { + const client = { + getChat: vi.fn().mockReturnValue({ + getDebugResponses: vi.fn().mockReturnValue([ + { someOtherData: 'value' }, + { + usageMetadata: { + promptTokenCount: 50, + candidatesTokenCount: 75, + }, + }, + ]), + }), + }; + const result = extractUsageFromGeminiClient(client); + expect(result).toEqual({ + input_tokens: 50, + output_tokens: 75, + }); + }); +}); + +describe('computeUsageFromMetrics', () => { + it('should compute usage from SessionMetrics with single model', () => { + const metrics: SessionMetrics = { + models: { + 'model-1': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 50, + candidates: 100, + total: 150, + cached: 10, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 100, + cache_read_input_tokens: 20, + total_tokens: 150, + }); + }); + + it('should aggregate usage across multiple models', () => { + const metrics: SessionMetrics = { + models: { + 'model-1': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 50, + candidates: 100, + total: 150, + cached: 10, + thoughts: 0, + tool: 0, + }, + }, + 'model-2': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 75, + candidates: 125, + total: 200, + cached: 15, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 225, + cache_read_input_tokens: 20, + total_tokens: 350, + }); + }); + + it('should not include total_tokens when it is 0', () => { + const metrics: SessionMetrics = { + models: { + 'model-1': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 50, + candidates: 100, + total: 0, + cached: 10, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).not.toHaveProperty('total_tokens'); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 100, + cache_read_input_tokens: 20, + }); + }); + + it('should handle empty models', () => { + const metrics: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + const result = computeUsageFromMetrics(metrics); + expect(result).toEqual({ + input_tokens: 100, + output_tokens: 0, + cache_read_input_tokens: 20, + }); + }); +}); + +describe('buildSystemMessage', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.clearAllMocks(); + // Mock getMCPServerStatus to return CONNECTED by default + vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED); + + mockConfig = { + getToolRegistry: vi.fn().mockReturnValue({ + getAllToolNames: vi.fn().mockReturnValue(['tool1', 'tool2']), + }), + getMcpServers: vi.fn().mockReturnValue({ + 'mcp-server-1': {}, + 'mcp-server-2': {}, + }), + getTargetDir: vi.fn().mockReturnValue('/test/dir'), + getModel: vi.fn().mockReturnValue('test-model'), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getDebugMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + }); + + it('should build system message with all fields', async () => { + const result = await buildSystemMessage( + mockConfig, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result).toEqual({ + type: 'system', + subtype: 'init', + uuid: 'test-session-id', + session_id: 'test-session-id', + cwd: '/test/dir', + tools: ['tool1', 'tool2'], + mcp_servers: [ + { name: 'mcp-server-1', status: 'connected' }, + { name: 'mcp-server-2', status: 'connected' }, + ], + model: 'test-model', + permissionMode: 'auto', + slash_commands: ['commit', 'help', 'memory'], + qwen_code_version: '1.0.0', + agents: [], + }); + }); + + it('should handle empty tool registry', async () => { + const config = { + ...mockConfig, + getToolRegistry: vi.fn().mockReturnValue(null), + } as unknown as Config; + + const result = await buildSystemMessage( + config, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result.tools).toEqual([]); + }); + + it('should handle empty MCP servers', async () => { + const config = { + ...mockConfig, + getMcpServers: vi.fn().mockReturnValue(null), + } as unknown as Config; + + const result = await buildSystemMessage( + config, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result.mcp_servers).toEqual([]); + }); + + it('should use unknown version when getCliVersion returns null', async () => { + const config = { + ...mockConfig, + getCliVersion: vi.fn().mockReturnValue(null), + } as unknown as Config; + + const result = await buildSystemMessage( + config, + 'test-session-id', + 'auto' as PermissionMode, + ); + + expect(result.qwen_code_version).toBe('unknown'); + }); +}); + +describe('createTaskToolProgressHandler', () => { + let mockAdapter: JsonOutputAdapterInterface; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = { + getDebugMode: vi.fn().mockReturnValue(false), + isInteractive: vi.fn().mockReturnValue(false), + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), + } as unknown as Config; + + mockAdapter = { + processSubagentToolCall: vi.fn(), + emitSubagentErrorResult: vi.fn(), + emitToolResult: vi.fn(), + emitUserMessage: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + }); + + it('should create handler that processes task tool calls', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: { arg1: 'value1' }, + status: 'executing', + }, + ], + }; + + handler('task-call-id', taskDisplay); + + expect(mockAdapter.processSubagentToolCall).toHaveBeenCalledWith( + expect.objectContaining({ + callId: 'tool-1', + name: 'test_tool', + status: 'executing', + }), + 'parent-tool-id', + ); + }); + + it('should emit tool_result when tool call completes', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: { arg1: 'value1' }, + status: 'success', + resultDisplay: 'Success result', + }, + ], + }; + + handler('task-call-id', taskDisplay); + + expect(mockAdapter.emitToolResult).toHaveBeenCalledWith( + expect.objectContaining({ + callId: 'tool-1', + name: 'test_tool', + }), + expect.objectContaining({ + callId: 'tool-1', + resultDisplay: 'Success result', + }), + 'parent-tool-id', + ); + }); + + it('should not duplicate tool_use emissions', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'executing', + }, + ], + }; + + // Call handler twice with same tool call + handler('task-call-id', taskDisplay); + handler('task-call-id', taskDisplay); + + expect(mockAdapter.processSubagentToolCall).toHaveBeenCalledTimes(1); + }); + + it('should not duplicate tool_result emissions', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: 'Result', + }, + ], + }; + + // Call handler twice with same completed tool call + handler('task-call-id', taskDisplay); + handler('task-call-id', taskDisplay); + + expect(mockAdapter.emitToolResult).toHaveBeenCalledTimes(1); + }); + + it('should handle status transitions from executing to completed', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + // First: executing state + const executingDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'executing', + }, + ], + }; + + // Second: completed state + const completedDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: 'Done', + }, + ], + }; + + handler('task-call-id', executingDisplay); + handler('task-call-id', completedDisplay); + + expect(mockAdapter.processSubagentToolCall).toHaveBeenCalledTimes(1); + expect(mockAdapter.emitToolResult).toHaveBeenCalledTimes(1); + }); + + it('should emit error result for failed task status', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const runningDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + const failedDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'failed', + terminateReason: 'Task failed with error', + toolCalls: [], + }; + + handler('task-call-id', runningDisplay); + handler('task-call-id', failedDisplay); + + expect(mockAdapter.emitSubagentErrorResult).toHaveBeenCalledWith( + 'Task failed with error', + 0, + 'parent-tool-id', + ); + }); + + it('should emit error result for cancelled task status', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const runningDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + const cancelledDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'cancelled', + toolCalls: [], + }; + + handler('task-call-id', runningDisplay); + handler('task-call-id', cancelledDisplay); + + expect(mockAdapter.emitSubagentErrorResult).toHaveBeenCalledWith( + 'Task was cancelled', + 0, + 'parent-tool-id', + ); + }); + + it('should not process non-task-execution displays', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const nonTaskDisplay = { + type: 'other', + content: 'some content', + }; + + handler('call-id', nonTaskDisplay as unknown as TaskResultDisplay); + + expect(mockAdapter.processSubagentToolCall).not.toHaveBeenCalled(); + expect(mockAdapter.emitToolResult).not.toHaveBeenCalled(); + }); + + it('should handle tool calls with failed status', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'failed', + error: 'Tool execution failed', + }, + ], + }; + + handler('task-call-id', taskDisplay); + + expect(mockAdapter.emitToolResult).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + callId: 'tool-1', + error: expect.any(Error), + errorType: ToolErrorType.EXECUTION_FAILED, + }), + 'parent-tool-id', + ); + }); + + it('should handle tool calls without result content', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + mockAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [ + { + callId: 'tool-1', + name: 'test_tool', + args: {}, + status: 'success', + resultDisplay: '', + responseParts: [], + }, + ], + }; + + handler('task-call-id', taskDisplay); + + // Should not emit tool_result if no content + expect(mockAdapter.emitToolResult).not.toHaveBeenCalled(); + }); + + it('should work without adapter (non-JSON mode)', () => { + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + undefined, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + // Should not throw + expect(() => handler('task-call-id', taskDisplay)).not.toThrow(); + }); + + it('should work with adapter that does not support subagent APIs', () => { + const limitedAdapter = { + emitToolResult: vi.fn(), + } as unknown as JsonOutputAdapterInterface; + + const { handler } = createTaskToolProgressHandler( + mockConfig, + 'parent-tool-id', + limitedAdapter, + ); + + const taskDisplay: TaskResultDisplay = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'Test task', + taskPrompt: 'Test prompt', + status: 'running', + toolCalls: [], + }; + + // Should not throw + expect(() => handler('task-call-id', taskDisplay)).not.toThrow(); + }); +}); + +describe('functionResponsePartsToString', () => { + it('should extract output from functionResponse parts', () => { + const parts: Part[] = [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe('function output'); + }); + + it('should handle multiple functionResponse parts', () => { + const parts: Part[] = [ + { + functionResponse: { + response: { + output: 'output1', + }, + }, + }, + { + functionResponse: { + response: { + output: 'output2', + }, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe('output1output2'); + }); + + it('should return empty string for missing output', () => { + const parts: Part[] = [ + { + functionResponse: { + response: {}, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe(''); + }); + + it('should JSON.stringify non-functionResponse parts', () => { + const parts: Part[] = [ + { text: 'text part' }, + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ]; + const result = functionResponsePartsToString(parts); + expect(result).toContain('function output'); + expect(result).toContain('text part'); + }); + + it('should handle empty array', () => { + expect(functionResponsePartsToString([])).toBe(''); + }); + + it('should handle functionResponse with null response', () => { + const parts: Part[] = [ + { + functionResponse: { + response: null as unknown as Record, + }, + }, + ]; + expect(functionResponsePartsToString(parts)).toBe(''); + }); +}); + +describe('toolResultContent', () => { + it('should return resultDisplay string when available', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: 'Result content', + responseParts: [], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('Result content'); + }); + + it('should return undefined for empty resultDisplay string', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: ' ', + responseParts: [], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBeUndefined(); + }); + + it('should use functionResponsePartsToString for responseParts', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + responseParts: [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('function output'); + }); + + it('should return error message when error is present', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + responseParts: [], + error: new Error('Test error message'), + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('Test error message'); + }); + + it('should prefer resultDisplay over responseParts', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: 'Direct result', + responseParts: [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('Direct result'); + }); + + it('should prefer responseParts over error', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + error: new Error('Error message'), + responseParts: [ + { + functionResponse: { + response: { + output: 'function output', + }, + }, + }, + ], + errorType: undefined, + }; + expect(toolResultContent(response)).toBe('function output'); + }); + + it('should return undefined when no content is available', () => { + const response: ToolCallResponseInfo = { + callId: 'test-call', + resultDisplay: undefined, + responseParts: [], + error: undefined, + errorType: undefined, + }; + expect(toolResultContent(response)).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/utils/nonInteractiveHelpers.ts b/packages/cli/src/utils/nonInteractiveHelpers.ts new file mode 100644 index 00000000..fe8fc528 --- /dev/null +++ b/packages/cli/src/utils/nonInteractiveHelpers.ts @@ -0,0 +1,624 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config, + ToolResultDisplay, + TaskResultDisplay, + OutputUpdateHandler, + ToolCallRequestInfo, + ToolCallResponseInfo, + SessionMetrics, +} from '@qwen-code/qwen-code-core'; +import { + OutputFormat, + ToolErrorType, + getMCPServerStatus, +} from '@qwen-code/qwen-code-core'; +import type { Part, PartListUnion } from '@google/genai'; +import type { + CLIUserMessage, + Usage, + PermissionMode, + CLISystemMessage, +} from '../nonInteractive/types.js'; +import { CommandService } from '../services/CommandService.js'; +import { BuiltinCommandLoader } from '../services/BuiltinCommandLoader.js'; +import type { JsonOutputAdapterInterface } from '../nonInteractive/io/BaseJsonOutputAdapter.js'; +import { computeSessionStats } from '../ui/utils/computeStats.js'; + +/** + * Normalizes various part list formats into a consistent Part[] array. + * + * @param parts - Input parts in various formats (string, Part, Part[], or null) + * @returns Normalized array of Part objects + */ +export function normalizePartList(parts: PartListUnion | null): Part[] { + if (!parts) { + return []; + } + + if (typeof parts === 'string') { + return [{ text: parts }]; + } + + if (Array.isArray(parts)) { + return parts.map((part) => + typeof part === 'string' ? { text: part } : (part as Part), + ); + } + + return [parts as Part]; +} + +/** + * Extracts user message parts from a CLI protocol message. + * + * @param message - User message sourced from the CLI protocol layer + * @returns Extracted parts or null if the message lacks textual content + */ +export function extractPartsFromUserMessage( + message: CLIUserMessage | undefined, +): PartListUnion | null { + if (!message) { + return null; + } + + const content = message.message?.content; + if (typeof content === 'string') { + return content; + } + + if (Array.isArray(content)) { + const parts: Part[] = []; + for (const block of content) { + if (!block || typeof block !== 'object' || !('type' in block)) { + continue; + } + if (block.type === 'text' && 'text' in block && block.text) { + parts.push({ text: block.text }); + } else { + parts.push({ text: JSON.stringify(block) }); + } + } + return parts.length > 0 ? parts : null; + } + + return null; +} + +/** + * Extracts usage metadata from the Gemini client's debug responses. + * + * @param geminiClient - The Gemini client instance + * @returns Usage information or undefined if not available + */ +export function extractUsageFromGeminiClient( + geminiClient: unknown, +): Usage | undefined { + if ( + !geminiClient || + typeof geminiClient !== 'object' || + typeof (geminiClient as { getChat?: unknown }).getChat !== 'function' + ) { + return undefined; + } + + try { + const chat = (geminiClient as { getChat: () => unknown }).getChat(); + if ( + !chat || + typeof chat !== 'object' || + typeof (chat as { getDebugResponses?: unknown }).getDebugResponses !== + 'function' + ) { + return undefined; + } + + const responses = ( + chat as { + getDebugResponses: () => Array>; + } + ).getDebugResponses(); + for (let i = responses.length - 1; i >= 0; i--) { + const metadata = responses[i]?.['usageMetadata'] as + | Record + | undefined; + if (metadata) { + const promptTokens = metadata['promptTokenCount']; + const completionTokens = metadata['candidatesTokenCount']; + const totalTokens = metadata['totalTokenCount']; + const cachedTokens = metadata['cachedContentTokenCount']; + + return { + input_tokens: typeof promptTokens === 'number' ? promptTokens : 0, + output_tokens: + typeof completionTokens === 'number' ? completionTokens : 0, + total_tokens: + typeof totalTokens === 'number' ? totalTokens : undefined, + cache_read_input_tokens: + typeof cachedTokens === 'number' ? cachedTokens : undefined, + }; + } + } + } catch (error) { + console.debug('Failed to extract usage metadata:', error); + } + + return undefined; +} + +/** + * Computes Usage information from SessionMetrics using computeSessionStats. + * Aggregates token usage across all models in the session. + * + * @param metrics - Session metrics from uiTelemetryService + * @returns Usage object with token counts + */ +export function computeUsageFromMetrics(metrics: SessionMetrics): Usage { + const stats = computeSessionStats(metrics); + const { models } = metrics; + + // Sum up output tokens (candidates) and total tokens across all models + const totalOutputTokens = Object.values(models).reduce( + (acc, model) => acc + model.tokens.candidates, + 0, + ); + const totalTokens = Object.values(models).reduce( + (acc, model) => acc + model.tokens.total, + 0, + ); + + const usage: Usage = { + input_tokens: stats.totalPromptTokens, + output_tokens: totalOutputTokens, + cache_read_input_tokens: stats.totalCachedTokens, + }; + + // Only include total_tokens if it's greater than 0 + if (totalTokens > 0) { + usage.total_tokens = totalTokens; + } + + return usage; +} + +/** + * Load slash command names using CommandService + * + * @param config - Config instance + * @returns Promise resolving to array of slash command names + */ +async function loadSlashCommandNames(config: Config): Promise { + const controller = new AbortController(); + try { + const service = await CommandService.create( + [new BuiltinCommandLoader(config)], + controller.signal, + ); + const names = new Set(); + const commands = service.getCommands(); + for (const command of commands) { + names.add(command.name); + } + return Array.from(names).sort(); + } catch (error) { + if (config.getDebugMode()) { + console.error( + '[buildSystemMessage] Failed to load slash commands:', + error, + ); + } + return []; + } finally { + controller.abort(); + } +} + +/** + * Build system message for SDK + * + * Constructs a system initialization message including tools, MCP servers, + * and model configuration. System messages are independent of the control + * system and are sent before every turn regardless of whether control + * system is available. + * + * Note: Control capabilities are NOT included in system messages. They + * are only included in the initialize control response, which is handled + * separately by SystemController. + * + * @param config - Config instance + * @param sessionId - Session identifier + * @param permissionMode - Current permission/approval mode + * @returns Promise resolving to CLISystemMessage + */ +export async function buildSystemMessage( + config: Config, + sessionId: string, + permissionMode: PermissionMode, +): Promise { + const toolRegistry = config.getToolRegistry(); + const tools = toolRegistry ? toolRegistry.getAllToolNames() : []; + + const mcpServers = config.getMcpServers(); + const mcpServerList = mcpServers + ? Object.keys(mcpServers).map((name) => ({ + name, + status: getMCPServerStatus(name), + })) + : []; + + // Load slash commands + const slashCommands = await loadSlashCommandNames(config); + + // Load subagent names from config + let agentNames: string[] = []; + try { + const subagentManager = config.getSubagentManager(); + const subagents = await subagentManager.listSubagents(); + agentNames = subagents.map((subagent) => subagent.name); + } catch (error) { + if (config.getDebugMode()) { + console.error('[buildSystemMessage] Failed to load subagents:', error); + } + } + + const systemMessage: CLISystemMessage = { + type: 'system', + subtype: 'init', + uuid: sessionId, + session_id: sessionId, + cwd: config.getTargetDir(), + tools, + mcp_servers: mcpServerList, + model: config.getModel(), + permissionMode, + slash_commands: slashCommands, + qwen_code_version: config.getCliVersion() || 'unknown', + agents: agentNames, + }; + + return systemMessage; +} + +/** + * Creates an output update handler specifically for Task tool subagent execution. + * This handler monitors TaskResultDisplay updates and converts them to protocol messages + * using the unified adapter's subagent APIs. All emitted messages will have parent_tool_use_id set to + * the task tool's callId. + * + * @param config - Config instance for getting output format + * @param taskToolCallId - The task tool's callId to use as parent_tool_use_id for all subagent messages + * @param adapter - The unified adapter instance (JsonOutputAdapter or StreamJsonOutputAdapter) + * @returns An object containing the output update handler + */ +export function createTaskToolProgressHandler( + config: Config, + taskToolCallId: string, + adapter: JsonOutputAdapterInterface | undefined, +): { + handler: OutputUpdateHandler; +} { + // Track previous TaskResultDisplay states per tool call to detect changes + const previousTaskStates = new Map(); + // Track which tool call IDs have already emitted tool_use to prevent duplicates + const emittedToolUseIds = new Set(); + // Track which tool call IDs have already emitted tool_result to prevent duplicates + const emittedToolResultIds = new Set(); + + /** + * Builds a ToolCallRequestInfo object from a tool call. + * + * @param toolCall - The tool call information + * @returns ToolCallRequestInfo object + */ + const buildRequest = ( + toolCall: NonNullable[number], + ): ToolCallRequestInfo => ({ + callId: toolCall.callId, + name: toolCall.name, + args: toolCall.args || {}, + isClientInitiated: true, + prompt_id: '', + response_id: undefined, + }); + + /** + * Builds a ToolCallResponseInfo object from a tool call. + * + * @param toolCall - The tool call information + * @returns ToolCallResponseInfo object + */ + const buildResponse = ( + toolCall: NonNullable[number], + ): ToolCallResponseInfo => ({ + callId: toolCall.callId, + error: + toolCall.status === 'failed' + ? new Error(toolCall.error || 'Tool execution failed') + : undefined, + errorType: + toolCall.status === 'failed' ? ToolErrorType.EXECUTION_FAILED : undefined, + resultDisplay: toolCall.resultDisplay, + responseParts: toolCall.responseParts || [], + }); + + /** + * Checks if a tool call has result content that should be emitted. + * + * @param toolCall - The tool call information + * @returns True if the tool call has result content to emit + */ + const hasResultContent = ( + toolCall: NonNullable[number], + ): boolean => { + // Check resultDisplay string + if ( + typeof toolCall.resultDisplay === 'string' && + toolCall.resultDisplay.trim().length > 0 + ) { + return true; + } + + // Check responseParts - only check existence, don't parse for performance + if (toolCall.responseParts && toolCall.responseParts.length > 0) { + return true; + } + + // Failed status should always emit result + return toolCall.status === 'failed'; + }; + + /** + * Emits tool_use for a tool call if it hasn't been emitted yet. + * + * @param toolCall - The tool call information + * @param fallbackStatus - Optional fallback status if toolCall.status should be overridden + */ + const emitToolUseIfNeeded = ( + toolCall: NonNullable[number], + fallbackStatus?: 'executing' | 'awaiting_approval', + ): void => { + if (emittedToolUseIds.has(toolCall.callId)) { + return; + } + + const toolCallToEmit: NonNullable[number] = + fallbackStatus + ? { + ...toolCall, + status: fallbackStatus, + } + : toolCall; + + if ( + toolCallToEmit.status === 'executing' || + toolCallToEmit.status === 'awaiting_approval' + ) { + if (adapter?.processSubagentToolCall) { + adapter.processSubagentToolCall(toolCallToEmit, taskToolCallId); + emittedToolUseIds.add(toolCall.callId); + } + } + }; + + /** + * Emits tool_result for a tool call if it hasn't been emitted yet and has content. + * + * @param toolCall - The tool call information + */ + const emitToolResultIfNeeded = ( + toolCall: NonNullable[number], + ): void => { + if (emittedToolResultIds.has(toolCall.callId)) { + return; + } + + if (!hasResultContent(toolCall)) { + return; + } + + // Mark as emitted even if we skip, to prevent duplicate emits + emittedToolResultIds.add(toolCall.callId); + + if (adapter) { + const request = buildRequest(toolCall); + const response = buildResponse(toolCall); + // For subagent tool results, we need to pass parentToolUseId + // The adapter implementations accept an optional parentToolUseId parameter + if ( + 'emitToolResult' in adapter && + typeof adapter.emitToolResult === 'function' + ) { + adapter.emitToolResult(request, response, taskToolCallId); + } else { + adapter.emitToolResult(request, response); + } + } + }; + + /** + * Processes a tool call, ensuring tool_use and tool_result are emitted exactly once. + * + * @param toolCall - The tool call information + * @param previousCall - The previous state of the tool call (if any) + */ + const processToolCall = ( + toolCall: NonNullable[number], + previousCall?: NonNullable[number], + ): void => { + const isCompleted = + toolCall.status === 'success' || toolCall.status === 'failed'; + const isExecuting = + toolCall.status === 'executing' || + toolCall.status === 'awaiting_approval'; + const wasExecuting = + previousCall && + (previousCall.status === 'executing' || + previousCall.status === 'awaiting_approval'); + + // Emit tool_use if needed + if (isExecuting) { + // Normal case: tool call is executing or awaiting approval + emitToolUseIfNeeded(toolCall); + } else if (isCompleted && !emittedToolUseIds.has(toolCall.callId)) { + // Edge case: tool call appeared with result already (shouldn't happen normally, + // but handle it gracefully by emitting tool_use with 'executing' status first) + emitToolUseIfNeeded(toolCall, 'executing'); + } else if (wasExecuting && isCompleted) { + // Status changed from executing to completed - ensure tool_use was emitted + emitToolUseIfNeeded(toolCall, 'executing'); + } + + // Emit tool_result if tool call is completed + if (isCompleted) { + emitToolResultIfNeeded(toolCall); + } + }; + + const outputUpdateHandler = ( + callId: string, + outputChunk: ToolResultDisplay, + ) => { + // Only process TaskResultDisplay (Task tool updates) + if ( + typeof outputChunk === 'object' && + outputChunk !== null && + 'type' in outputChunk && + outputChunk.type === 'task_execution' + ) { + const taskDisplay = outputChunk as TaskResultDisplay; + const previous = previousTaskStates.get(callId); + + // If no adapter, just track state (for non-JSON modes) + if (!adapter) { + previousTaskStates.set(callId, taskDisplay); + return; + } + + // Only process if adapter supports subagent APIs + if ( + !adapter.processSubagentToolCall || + !adapter.emitSubagentErrorResult + ) { + previousTaskStates.set(callId, taskDisplay); + return; + } + + if (taskDisplay.toolCalls) { + if (!previous || !previous.toolCalls) { + // First time seeing tool calls - process all initial ones + for (const toolCall of taskDisplay.toolCalls) { + processToolCall(toolCall); + } + } else { + // Compare with previous state to find new/changed tool calls + for (const toolCall of taskDisplay.toolCalls) { + const previousCall = previous.toolCalls.find( + (tc) => tc.callId === toolCall.callId, + ); + processToolCall(toolCall, previousCall); + } + } + } + + // Handle task-level errors (status: 'failed', 'cancelled') + if ( + taskDisplay.status === 'failed' || + taskDisplay.status === 'cancelled' + ) { + const previousStatus = previous?.status; + // Only emit error result if status changed to failed/cancelled + if ( + previousStatus !== 'failed' && + previousStatus !== 'cancelled' && + previousStatus !== undefined + ) { + const errorMessage = + taskDisplay.terminateReason || + (taskDisplay.status === 'cancelled' + ? 'Task was cancelled' + : 'Task execution failed'); + // Use subagent adapter's emitSubagentErrorResult method + adapter.emitSubagentErrorResult(errorMessage, 0, taskToolCallId); + } + } + + // Handle subagent initial message (prompt) in non-interactive mode with json/stream-json output + // Emit when this is the first update (previous is undefined) and task starts + if ( + !previous && + taskDisplay.taskPrompt && + !config.isInteractive() && + (config.getOutputFormat() === OutputFormat.JSON || + config.getOutputFormat() === OutputFormat.STREAM_JSON) + ) { + // Emit the user message with the correct parent_tool_use_id + adapter.emitUserMessage( + [{ text: taskDisplay.taskPrompt }], + taskToolCallId, + ); + } + + // Update previous state + previousTaskStates.set(callId, taskDisplay); + } + }; + + // No longer need to attach adapter to handler - task.ts uses TaskResultDisplay.message instead + + return { + handler: outputUpdateHandler, + }; +} + +/** + * Converts function response parts to a string representation. + * Handles functionResponse parts specially by extracting their output content. + * + * @param parts - Array of Part objects to convert + * @returns String representation of the parts + */ +export function functionResponsePartsToString(parts: Part[]): string { + return parts + .map((part) => { + if ('functionResponse' in part) { + const content = part.functionResponse?.response?.['output'] ?? ''; + return content; + } + return JSON.stringify(part); + }) + .join(''); +} + +/** + * Extracts content from a tool call response for inclusion in tool_result blocks. + * Uses functionResponsePartsToString to properly handle functionResponse parts, + * which correctly extracts output content from functionResponse objects rather + * than simply concatenating text or JSON.stringify. + * + * @param response - Tool call response information + * @returns String content for the tool_result block, or undefined if no content available + */ +export function toolResultContent( + response: ToolCallResponseInfo, +): string | undefined { + if ( + typeof response.resultDisplay === 'string' && + response.resultDisplay.trim().length > 0 + ) { + return response.resultDisplay; + } + if (response.responseParts && response.responseParts.length > 0) { + // Always use functionResponsePartsToString to properly handle + // functionResponse parts that contain output content + return functionResponsePartsToString(response.responseParts); + } + if (response.error) { + return response.error.message; + } + return undefined; +} diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts index dba93e62..867777b3 100644 --- a/packages/cli/src/validateNonInterActiveAuth.test.ts +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -10,6 +10,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core'; import type { Config } from '@qwen-code/qwen-code-core'; import * as auth from './config/auth.js'; import { type LoadedSettings } from './config/settings.js'; +import * as JsonOutputAdapterModule from './nonInteractive/io/JsonOutputAdapter.js'; +import * as StreamJsonOutputAdapterModule from './nonInteractive/io/StreamJsonOutputAdapter.js'; +import * as cleanupModule from './utils/cleanup.js'; describe('validateNonInterActiveAuth', () => { let originalEnvGeminiApiKey: string | undefined; @@ -17,8 +20,8 @@ describe('validateNonInterActiveAuth', () => { let originalEnvGcp: string | undefined; let originalEnvOpenAiApiKey: string | undefined; let consoleErrorSpy: ReturnType; - let processExitSpy: ReturnType; - let refreshAuthMock: vi.Mock; + let processExitSpy: ReturnType>; + let refreshAuthMock: ReturnType; let mockSettings: LoadedSettings; beforeEach(() => { @@ -33,7 +36,7 @@ describe('validateNonInterActiveAuth', () => { consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => { throw new Error(`process.exit(${code}) called`); - }); + }) as ReturnType>; refreshAuthMock = vi.fn().mockResolvedValue('refreshed'); mockSettings = { system: { path: '', settings: {} }, @@ -235,7 +238,24 @@ describe('validateNonInterActiveAuth', () => { }); describe('JSON output mode', () => { - it('prints JSON error when no auth is configured and exits with code 1', async () => { + let emitResultMock: ReturnType; + let runExitCleanupMock: ReturnType; + + beforeEach(() => { + emitResultMock = vi.fn(); + runExitCleanupMock = vi.fn().mockResolvedValue(undefined); + vi.spyOn(JsonOutputAdapterModule, 'JsonOutputAdapter').mockImplementation( + () => + ({ + emitResult: emitResultMock, + }) as unknown as JsonOutputAdapterModule.JsonOutputAdapter, + ); + vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation( + runExitCleanupMock, + ); + }); + + it('emits error result and exits when no auth is configured', async () => { const nonInteractiveConfig = { refreshAuth: refreshAuthMock, getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), @@ -244,7 +264,6 @@ describe('validateNonInterActiveAuth', () => { .mockReturnValue({ authType: undefined }), } as unknown as Config; - let thrown: Error | undefined; try { await validateNonInteractiveAuth( undefined, @@ -252,21 +271,27 @@ describe('validateNonInterActiveAuth', () => { nonInteractiveConfig, mockSettings, ); + expect.fail('Should have exited'); } catch (e) { - thrown = e as Error; + expect((e as Error).message).toContain('process.exit(1) called'); } - expect(thrown?.message).toBe('process.exit(1) called'); - const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; - const payload = JSON.parse(errorArg); - expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); - expect(payload.error.message).toContain( - 'Please set an Auth method in your', - ); + expect(emitResultMock).toHaveBeenCalledWith({ + isError: true, + errorMessage: expect.stringContaining( + 'Please set an Auth method in your', + ), + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + expect(runExitCleanupMock).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); - it('prints JSON error when enforced auth mismatches current auth and exits with code 1', async () => { + it('emits error result and exits when enforced auth mismatches current auth', async () => { mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; process.env['OPENAI_API_KEY'] = 'fake-key'; @@ -278,7 +303,6 @@ describe('validateNonInterActiveAuth', () => { .mockReturnValue({ authType: undefined }), } as unknown as Config; - let thrown: Error | undefined; try { await validateNonInteractiveAuth( undefined, @@ -286,23 +310,27 @@ describe('validateNonInterActiveAuth', () => { nonInteractiveConfig, mockSettings, ); + expect.fail('Should have exited'); } catch (e) { - thrown = e as Error; + expect((e as Error).message).toContain('process.exit(1) called'); } - expect(thrown?.message).toBe('process.exit(1) called'); - { - const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; - const payload = JSON.parse(errorArg); - expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); - expect(payload.error.message).toContain( + expect(emitResultMock).toHaveBeenCalledWith({ + isError: true, + errorMessage: expect.stringContaining( 'The configured auth type is qwen-oauth, but the current auth type is openai.', - ); - } + ), + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + expect(runExitCleanupMock).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); - it('prints JSON error when validateAuthMethod fails and exits with code 1', async () => { + it('emits error result and exits when validateAuthMethod fails', async () => { vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); process.env['OPENAI_API_KEY'] = 'fake-key'; @@ -314,7 +342,6 @@ describe('validateNonInterActiveAuth', () => { .mockReturnValue({ authType: undefined }), } as unknown as Config; - let thrown: Error | undefined; try { await validateNonInteractiveAuth( AuthType.USE_OPENAI, @@ -322,18 +349,159 @@ describe('validateNonInterActiveAuth', () => { nonInteractiveConfig, mockSettings, ); + expect.fail('Should have exited'); } catch (e) { - thrown = e as Error; + expect((e as Error).message).toContain('process.exit(1) called'); } - expect(thrown?.message).toBe('process.exit(1) called'); - { - const errorArg = consoleErrorSpy.mock.calls[0]?.[0] as string; - const payload = JSON.parse(errorArg); - expect(payload.error.type).toBe('Error'); - expect(payload.error.code).toBe(1); - expect(payload.error.message).toBe('Auth error!'); + expect(emitResultMock).toHaveBeenCalledWith({ + isError: true, + errorMessage: 'Auth error!', + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + expect(runExitCleanupMock).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + }); + + describe('STREAM_JSON output mode', () => { + let emitResultMock: ReturnType; + let runExitCleanupMock: ReturnType; + + beforeEach(() => { + emitResultMock = vi.fn(); + runExitCleanupMock = vi.fn().mockResolvedValue(undefined); + vi.spyOn( + StreamJsonOutputAdapterModule, + 'StreamJsonOutputAdapter', + ).mockImplementation( + () => + ({ + emitResult: emitResultMock, + }) as unknown as StreamJsonOutputAdapterModule.StreamJsonOutputAdapter, + ); + vi.spyOn(cleanupModule, 'runExitCleanup').mockImplementation( + runExitCleanupMock, + ); + }); + + it('emits error result and exits when no auth is configured', async () => { + const nonInteractiveConfig = { + refreshAuth: refreshAuthMock, + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), + getIncludePartialMessages: vi.fn().mockReturnValue(false), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + } as unknown as Config; + + try { + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain('process.exit(1) called'); } + + expect(emitResultMock).toHaveBeenCalledWith({ + isError: true, + errorMessage: expect.stringContaining( + 'Please set an Auth method in your', + ), + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + expect(runExitCleanupMock).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('emits error result and exits when enforced auth mismatches current auth', async () => { + mockSettings.merged.security!.auth!.enforcedType = AuthType.QWEN_OAUTH; + process.env['OPENAI_API_KEY'] = 'fake-key'; + + const nonInteractiveConfig = { + refreshAuth: refreshAuthMock, + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), + getIncludePartialMessages: vi.fn().mockReturnValue(false), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + } as unknown as Config; + + try { + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain('process.exit(1) called'); + } + + expect(emitResultMock).toHaveBeenCalledWith({ + isError: true, + errorMessage: expect.stringContaining( + 'The configured auth type is qwen-oauth, but the current auth type is openai.', + ), + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + expect(runExitCleanupMock).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it('emits error result and exits when validateAuthMethod fails', async () => { + vi.spyOn(auth, 'validateAuthMethod').mockReturnValue('Auth error!'); + process.env['OPENAI_API_KEY'] = 'fake-key'; + + const nonInteractiveConfig = { + refreshAuth: refreshAuthMock, + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.STREAM_JSON), + getIncludePartialMessages: vi.fn().mockReturnValue(false), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + } as unknown as Config; + + try { + await validateNonInteractiveAuth( + AuthType.USE_OPENAI, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain('process.exit(1) called'); + } + + expect(emitResultMock).toHaveBeenCalledWith({ + isError: true, + errorMessage: 'Auth error!', + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + expect(runExitCleanupMock).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(1); + expect(consoleErrorSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts index e44cd0a4..78ccc993 100644 --- a/packages/cli/src/validateNonInterActiveAuth.ts +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -9,7 +9,9 @@ import { AuthType, OutputFormat } from '@qwen-code/qwen-code-core'; import { USER_SETTINGS_PATH } from './config/settings.js'; import { validateAuthMethod } from './config/auth.js'; import { type LoadedSettings } from './config/settings.js'; -import { handleError } from './utils/errors.js'; +import { JsonOutputAdapter } from './nonInteractive/io/JsonOutputAdapter.js'; +import { StreamJsonOutputAdapter } from './nonInteractive/io/StreamJsonOutputAdapter.js'; +import { runExitCleanup } from './utils/cleanup.js'; function getAuthTypeFromEnv(): AuthType | undefined { if (process.env['OPENAI_API_KEY']) { @@ -27,7 +29,7 @@ export async function validateNonInteractiveAuth( useExternalAuth: boolean | undefined, nonInteractiveConfig: Config, settings: LoadedSettings, -) { +): Promise { try { const enforcedType = settings.merged.security?.auth?.enforcedType; if (enforcedType) { @@ -58,15 +60,38 @@ export async function validateNonInteractiveAuth( await nonInteractiveConfig.refreshAuth(authType); return nonInteractiveConfig; } catch (error) { - if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) { - handleError( - error instanceof Error ? error : new Error(String(error)), - nonInteractiveConfig, - 1, - ); - } else { - console.error(error instanceof Error ? error.message : String(error)); + const outputFormat = nonInteractiveConfig.getOutputFormat(); + + // In JSON and STREAM_JSON modes, emit error result and exit + if ( + outputFormat === OutputFormat.JSON || + outputFormat === OutputFormat.STREAM_JSON + ) { + let adapter; + if (outputFormat === OutputFormat.JSON) { + adapter = new JsonOutputAdapter(nonInteractiveConfig); + } else { + adapter = new StreamJsonOutputAdapter( + nonInteractiveConfig, + nonInteractiveConfig.getIncludePartialMessages(), + ); + } + const errorMessage = + error instanceof Error ? error.message : String(error); + adapter.emitResult({ + isError: true, + errorMessage, + durationMs: 0, + apiDurationMs: 0, + numTurns: 0, + usage: undefined, + }); + await runExitCleanup(); process.exit(1); } + + // For other modes (text), use existing error handling + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); } } diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 76f923e7..1213556b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -62,7 +62,7 @@ import { WriteFileTool } from '../tools/write-file.js'; // Other modules import { ideContextStore } from '../ide/ideContext.js'; -import { OutputFormat } from '../output/types.js'; +import { InputFormat, OutputFormat } from '../output/types.js'; import { PromptRegistry } from '../prompts/prompt-registry.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; import { @@ -216,6 +216,7 @@ export interface ConfigParameters { sandbox?: SandboxConfig; targetDir: string; debugMode: boolean; + includePartialMessages?: boolean; question?: string; fullContext?: boolean; coreTools?: string[]; @@ -290,6 +291,27 @@ export interface ConfigParameters { useSmartEdit?: boolean; output?: OutputSettings; 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; + } } export class Config { @@ -306,6 +328,9 @@ export class Config { private readonly targetDir: string; private workspaceContext: WorkspaceContext; private readonly debugMode: boolean; + private readonly inputFormat: InputFormat; + private readonly outputFormat: OutputFormat; + private readonly includePartialMessages: boolean; private readonly question: string | undefined; private readonly fullContext: boolean; private readonly coreTools: string[] | undefined; @@ -388,7 +413,6 @@ export class Config { private readonly enableToolOutputTruncation: boolean; private readonly eventEmitter?: EventEmitter; private readonly useSmartEdit: boolean; - private readonly outputSettings: OutputSettings; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -401,6 +425,12 @@ export class Config { params.includeDirectories ?? [], ); this.debugMode = params.debugMode; + this.inputFormat = params.inputFormat ?? InputFormat.TEXT; + const normalizedOutputFormat = normalizeConfigOutputFormat( + params.outputFormat ?? params.output?.format, + ); + this.outputFormat = normalizedOutputFormat ?? OutputFormat.TEXT; + this.includePartialMessages = params.includePartialMessages ?? false; this.question = params.question; this.fullContext = params.fullContext ?? false; this.coreTools = params.coreTools; @@ -495,12 +525,9 @@ export class Config { this.extensionManagement = params.extensionManagement ?? true; this.storage = new Storage(this.targetDir); this.vlmSwitchMode = params.vlmSwitchMode; + this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; - this.outputSettings = { - format: params.output?.format ?? OutputFormat.TEXT, - }; - if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); } @@ -786,6 +813,14 @@ export class Config { return this.showMemoryUsage; } + getInputFormat(): 'text' | 'stream-json' { + return this.inputFormat; + } + + getIncludePartialMessages(): boolean { + return this.includePartialMessages; + } + getAccessibility(): AccessibilitySettings { return this.accessibility; } @@ -1082,9 +1117,7 @@ export class Config { } getOutputFormat(): OutputFormat { - return this.outputSettings?.format - ? this.outputSettings.format - : OutputFormat.TEXT; + return this.outputFormat; } async getGitService(): Promise { diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 715dfd8f..d0bf1aa8 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -371,6 +371,8 @@ describe('CoreToolScheduler', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + getExcludeTools: () => undefined, + isInteractive: () => true, } as unknown as Config; const mockToolRegistry = { getAllToolNames: () => ['list_files', 'read_file', 'write_file'], @@ -400,6 +402,241 @@ describe('CoreToolScheduler', () => { ' Did you mean one of: "list_files", "read_file", "write_file"?', ); }); + + it('should use Levenshtein suggestions for excluded tools (getToolSuggestion only handles non-excluded)', () => { + // Create mocked tool registry + const mockToolRegistry = { + getAllToolNames: () => ['list_files', 'read_file'], + } as unknown as ToolRegistry; + + // Create mocked config with excluded tools + const mockConfig = { + getToolRegistry: () => mockToolRegistry, + getUseSmartEdit: () => false, + getUseModelRouter: () => false, + getGeminiClient: () => null, + getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'], + isInteractive: () => false, // Value doesn't matter, but included for completeness + } as unknown as Config; + + // Create scheduler + const scheduler = new CoreToolScheduler({ + config: mockConfig, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + // getToolSuggestion no longer handles excluded tools - it only handles truly missing tools + // So excluded tools will use Levenshtein distance to find similar registered tools + // @ts-expect-error accessing private method + const excludedTool = scheduler.getToolSuggestion('write_file'); + expect(excludedTool).toContain('Did you mean'); + }); + + it('should use Levenshtein suggestions for non-excluded tools', () => { + // Create mocked tool registry + const mockToolRegistry = { + getAllToolNames: () => ['list_files', 'read_file'], + } as unknown as ToolRegistry; + + // Create mocked config with excluded tools + const mockConfig = { + getToolRegistry: () => mockToolRegistry, + getUseSmartEdit: () => false, + getUseModelRouter: () => false, + getGeminiClient: () => null, + getExcludeTools: () => ['write_file', 'edit'], + isInteractive: () => false, // Value doesn't matter + } as unknown as Config; + + // Create scheduler + const scheduler = new CoreToolScheduler({ + config: mockConfig, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + // Test that non-excluded tool (hallucinated) still uses Levenshtein suggestions + // @ts-expect-error accessing private method + const hallucinatedTool = scheduler.getToolSuggestion('list_fils'); + expect(hallucinatedTool).toContain('Did you mean'); + expect(hallucinatedTool).not.toContain( + 'not available in the current environment', + ); + }); + }); + + describe('excluded tools handling', () => { + it('should return permission error for excluded tools instead of "not found" message', async () => { + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + const mockToolRegistry = { + getTool: () => undefined, // Tool not in registry + getAllToolNames: () => ['list_files', 'read_file'], + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByName: () => undefined, + getToolByDisplayName: () => undefined, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, + getAllowedTools: () => [], + getExcludeTools: () => ['write_file', 'edit', 'run_shell_command'], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => mockToolRegistry, + getUseSmartEdit: () => false, + getUseModelRouter: () => false, + getGeminiClient: () => null, + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'write_file', // Excluded tool + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-excluded', + }; + + await scheduler.schedule([request], abortController.signal); + + // Wait for completion + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(1); + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + + if (completedCall.status === 'error') { + const errorMessage = completedCall.response.error?.message; + expect(errorMessage).toBe( + 'Qwen Code requires permission to use write_file, but that permission was declined.', + ); + // Should NOT contain "not found in registry" + expect(errorMessage).not.toContain('not found in registry'); + } + }); + + it('should return "not found" message for truly missing tools (not excluded)', async () => { + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + + const mockToolRegistry = { + getTool: () => undefined, // Tool not in registry + getAllToolNames: () => ['list_files', 'read_file'], + getFunctionDeclarations: () => [], + tools: new Map(), + discovery: {}, + registerTool: () => {}, + getToolByName: () => undefined, + getToolByDisplayName: () => undefined, + getTools: () => [], + discoverTools: async () => {}, + getAllTools: () => [], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.DEFAULT, + getAllowedTools: () => [], + getExcludeTools: () => ['write_file', 'edit'], // Different excluded tools + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'oauth-personal', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => mockToolRegistry, + getUseSmartEdit: () => false, + getUseModelRouter: () => false, + getGeminiClient: () => null, + } as unknown as Config; + + const scheduler = new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'nonexistent_tool', // Not excluded, just doesn't exist + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-missing', + }; + + await scheduler.schedule([request], abortController.signal); + + // Wait for completion + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(1); + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + + if (completedCall.status === 'error') { + const errorMessage = completedCall.response.error?.message; + // Should contain "not found in registry" + expect(errorMessage).toContain('not found in registry'); + // Should NOT contain permission message + expect(errorMessage).not.toContain('requires permission'); + } + }); }); }); @@ -449,6 +686,9 @@ describe('CoreToolScheduler with payload', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + isInteractive: () => true, // Required to prevent auto-denial of tool calls + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -769,6 +1009,9 @@ describe('CoreToolScheduler edit cancellation', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + isInteractive: () => true, // Required to prevent auto-denial of tool calls + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, } as unknown as Config; const scheduler = new CoreToolScheduler({ @@ -1421,6 +1664,9 @@ describe('CoreToolScheduler request queueing', () => { getUseSmartEdit: () => false, getUseModelRouter: () => false, getGeminiClient: () => null, // No client needed for these tests + isInteractive: () => true, // Required to prevent auto-denial of tool calls + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, } as unknown as Config; const testTool = new TestApprovalTool(mockConfig); @@ -1450,7 +1696,10 @@ describe('CoreToolScheduler request queueing', () => { const onAllToolCallsComplete = vi.fn(); const onToolCallsUpdate = vi.fn(); const pendingConfirmations: Array< - (outcome: ToolConfirmationOutcome) => void + ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise > = []; const scheduler = new CoreToolScheduler({ @@ -1521,7 +1770,7 @@ describe('CoreToolScheduler request queueing', () => { // Approve the first tool with ProceedAlways const firstConfirmation = pendingConfirmations[0]; - firstConfirmation(ToolConfirmationOutcome.ProceedAlways); + await firstConfirmation(ToolConfirmationOutcome.ProceedAlways); // Wait for all tools to be completed await vi.waitFor(() => { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index f4a26706..8334ce5a 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -587,12 +587,16 @@ export class CoreToolScheduler { /** * Generates a suggestion string for a tool name that was not found in the registry. - * It finds the closest matches based on Levenshtein distance. + * Uses Levenshtein distance to suggest similar tool names for hallucinated or misspelled tools. + * Note: Excluded tools are handled separately before calling this method, so this only + * handles the case where a tool is truly not found (hallucinated or typo). * @param unknownToolName The tool name that was not found. * @param topN The number of suggestions to return. Defaults to 3. - * @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?", or an empty string if no suggestions are found. + * @returns A suggestion string like " Did you mean 'tool'?" or " Did you mean one of: 'tool1', 'tool2'?", + * or an empty string if no suggestions are found. */ private getToolSuggestion(unknownToolName: string, topN = 3): string { + // Use Levenshtein distance to find similar tool names from the registry. const allToolNames = this.toolRegistry.getAllToolNames(); const matches = allToolNames.map((toolName) => ({ @@ -670,8 +674,35 @@ export class CoreToolScheduler { const newToolCalls: ToolCall[] = requestsToProcess.map( (reqInfo): ToolCall => { + // Check if the tool is excluded due to permissions/environment restrictions + // This check should happen before registry lookup to provide a clear permission error + const excludeTools = this.config.getExcludeTools?.() ?? undefined; + if (excludeTools && excludeTools.length > 0) { + const normalizedToolName = reqInfo.name.toLowerCase().trim(); + const excludedMatch = excludeTools.find( + (excludedTool) => + excludedTool.toLowerCase().trim() === normalizedToolName, + ); + + if (excludedMatch) { + // The tool exists but is excluded - return permission error directly + const permissionErrorMessage = `Qwen Code requires permission to use ${excludedMatch}, but that permission was declined.`; + return { + status: 'error', + request: reqInfo, + response: createErrorResponse( + reqInfo, + new Error(permissionErrorMessage), + ToolErrorType.EXECUTION_DENIED, + ), + durationMs: 0, + }; + } + } + const toolInstance = this.toolRegistry.getTool(reqInfo.name); if (!toolInstance) { + // Tool is not in registry and not excluded - likely hallucinated or typo const suggestion = this.getToolSuggestion(reqInfo.name); const errorMessage = `Tool "${reqInfo.name}" not found in registry. Tools must use the exact names that are registered.${suggestion}`; return { @@ -777,6 +808,32 @@ export class CoreToolScheduler { ); this.setStatusInternal(reqInfo.callId, 'scheduled'); } else { + /** + * In non-interactive mode where no user will respond to approval prompts, + * and not running as IDE companion or Zed integration, automatically deny approval. + * This is intended to create an explicit denial of the tool call, + * rather than silently waiting for approval and hanging forever. + */ + const shouldAutoDeny = + !this.config.isInteractive() && + !this.config.getIdeMode() && + !this.config.getExperimentalZedIntegration(); + + if (shouldAutoDeny) { + // Treat as execution denied error, similar to excluded tools + const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined.`; + this.setStatusInternal( + reqInfo.callId, + 'error', + createErrorResponse( + reqInfo, + new Error(errorMessage), + ToolErrorType.EXECUTION_DENIED, + ), + ); + continue; + } + // Allow IDE to resolve confirmation if ( confirmationDetails.type === 'edit' && diff --git a/packages/core/src/core/nonInteractiveToolExecutor.ts b/packages/core/src/core/nonInteractiveToolExecutor.ts index 67407230..3575af96 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.ts @@ -9,7 +9,18 @@ import type { ToolCallResponseInfo, Config, } from '../index.js'; -import { CoreToolScheduler } from './coreToolScheduler.js'; +import { + CoreToolScheduler, + type AllToolCallsCompleteHandler, + type OutputUpdateHandler, + type ToolCallsUpdateHandler, +} from './coreToolScheduler.js'; + +export interface ExecuteToolCallOptions { + outputUpdateHandler?: OutputUpdateHandler; + onAllToolCallsComplete?: AllToolCallsCompleteHandler; + onToolCallsUpdate?: ToolCallsUpdateHandler; +} /** * Executes a single tool call non-interactively by leveraging the CoreToolScheduler. @@ -18,15 +29,21 @@ export async function executeToolCall( config: Config, toolCallRequest: ToolCallRequestInfo, abortSignal: AbortSignal, + options: ExecuteToolCallOptions = {}, ): Promise { return new Promise((resolve, reject) => { new CoreToolScheduler({ config, - getPreferredEditor: () => undefined, - onEditorClose: () => {}, + outputUpdateHandler: options.outputUpdateHandler, onAllToolCallsComplete: async (completedToolCalls) => { + if (options.onAllToolCallsComplete) { + await options.onAllToolCallsComplete(completedToolCalls); + } resolve(completedToolCalls[0].response); }, + onToolCallsUpdate: options.onToolCallsUpdate, + getPreferredEditor: () => undefined, + onEditorClose: () => {}, }) .schedule(toolCallRequest, abortSignal) .catch(reject); diff --git a/packages/core/src/output/types.ts b/packages/core/src/output/types.ts index 08477d21..4a300a43 100644 --- a/packages/core/src/output/types.ts +++ b/packages/core/src/output/types.ts @@ -6,9 +6,15 @@ import type { SessionMetrics } from '../telemetry/uiTelemetry.js'; +export enum InputFormat { + TEXT = 'text', + STREAM_JSON = 'stream-json', +} + export enum OutputFormat { TEXT = 'text', JSON = 'json', + STREAM_JSON = 'stream-json', } export interface JsonError { diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/subagents/subagent-events.ts index 19ec0971..eb318f54 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/subagents/subagent-events.ts @@ -9,6 +9,7 @@ import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, } from '../tools/tools.js'; +import type { Part } from '@google/genai'; export type SubAgentEvent = | 'start' @@ -72,6 +73,7 @@ export interface SubAgentToolResultEvent { name: string; success: boolean; error?: string; + responseParts?: Part[]; resultDisplay?: string; durationMs?: number; timestamp: number; diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts index af4be47f..7d161b10 100644 --- a/packages/core/src/subagents/subagent.ts +++ b/packages/core/src/subagents/subagent.ts @@ -619,6 +619,13 @@ export class SubAgentScope { name: toolName, success, error: errorMessage, + responseParts: call.response.responseParts, + /** + * Tools like todoWrite will add some extra contents to the result, + * making it unable to deserialize the `responseParts` to a JSON object. + * While `resultDisplay` is normally a string, if not we stringify it, + * so that we can deserialize it to a JSON object when needed. + */ resultDisplay: call.response.resultDisplay ? typeof call.response.resultDisplay === 'string' ? call.response.resultDisplay diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index 93dff04b..67f03f5f 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -332,7 +332,7 @@ class TaskToolInvocation extends BaseToolInvocation { ...this.currentToolCalls![toolCallIndex], status: event.success ? 'success' : 'failed', error: event.error, - resultDisplay: event.resultDisplay, + responseParts: event.responseParts, }; this.updateDisplay( diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts index 3c1c9a8a..27dc4285 100644 --- a/packages/core/src/tools/tool-error.ts +++ b/packages/core/src/tools/tool-error.ts @@ -14,6 +14,8 @@ export enum ToolErrorType { UNHANDLED_EXCEPTION = 'unhandled_exception', TOOL_NOT_REGISTERED = 'tool_not_registered', EXECUTION_FAILED = 'execution_failed', + // Try to execute a tool that is excluded due to the approval mode + EXECUTION_DENIED = 'execution_denied', // File System Errors FILE_NOT_FOUND = 'file_not_found', diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 386b0c3a..848b14c6 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { FunctionDeclaration, PartListUnion } from '@google/genai'; +import type { FunctionDeclaration, Part, PartListUnion } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; import type { DiffUpdateResult } from '../ide/ide-client.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; @@ -461,6 +461,7 @@ export interface TaskResultDisplay { args?: Record; result?: string; resultDisplay?: string; + responseParts?: Part[]; description?: string; }>; } From 640f30655da3f59c1595527897d09a7771ff20ee Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 21 Nov 2025 09:37:38 +0800 Subject: [PATCH 08/14] chore: pump version to 0.3.0 (#1085) --- package-lock.json | 12 ++++++------ package.json | 4 ++-- packages/cli/package.json | 4 ++-- packages/core/package.json | 2 +- packages/test-utils/package.json | 2 +- packages/vscode-ide-companion/package.json | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7ac21f6..ae277057 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.3", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.2.3", + "version": "0.3.0", "workspaces": [ "packages/*" ], @@ -16024,7 +16024,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.2.3", + "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.3", + "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.3", + "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.3", + "version": "0.3.0", "license": "LICENSE", "dependencies": { "@modelcontextprotocol/sdk": "^1.15.1", diff --git a/package.json b/package.json index 85c90f84..4cab7369 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.3", + "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.3" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index cbee9b9b..0ba4c0ef 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.2.3", + "version": "0.3.0", "description": "Qwen Code", "repository": { "type": "git", @@ -32,7 +32,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.3" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0" }, "dependencies": { "@google/genai": "1.16.0", diff --git a/packages/core/package.json b/packages/core/package.json index 72e4612f..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.3", + "version": "0.3.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index 0e23606c..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.3", + "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 afeed670..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.3", + "version": "0.3.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { From 627283d357b8c1927719f7caceaccb51382fbca7 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 21 Nov 2025 15:17:34 +0800 Subject: [PATCH 09/14] feat: enhance usage statistics in qwen logger --- .../telemetry/qwen-logger/qwen-logger.test.ts | 8 +- .../src/telemetry/qwen-logger/qwen-logger.ts | 131 +++++++++--------- 2 files changed, 68 insertions(+), 71 deletions(-) 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 a56723a7..3d286b02 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -259,7 +259,7 @@ export class QwenLogger { : '', }, _v: `qwen-code@${version}`, - }; + } as RumPayload; } flushIfNeeded(): void { @@ -368,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, @@ -381,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 @@ -410,10 +408,10 @@ export class QwenLogger { 'conversation', 'conversation_finished', { - snapshots: JSON.stringify({ + properties: { approval_mode: event.approvalMode, turn_count: event.turnCount, - }), + }, }, ); @@ -427,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); @@ -439,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); @@ -451,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); @@ -469,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, + }, }, ); @@ -490,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, - }), + }, }, ); @@ -507,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 } + : {}), }), }); @@ -521,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, @@ -595,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); @@ -623,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, - }), + }, }, ); @@ -656,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); @@ -672,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, }), }, @@ -686,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); @@ -696,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); @@ -713,10 +713,10 @@ export class QwenLogger { 'extension', 'extension_uninstall', { - snapshots: JSON.stringify({ + properties: { extension_name: event.extension_name, status: event.status, - }), + }, }, ); @@ -726,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); @@ -738,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); @@ -749,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); @@ -781,13 +778,13 @@ export class QwenLogger { logRipgrepFallbackEvent(event: RipgrepFallbackEvent): void { const rumEvent = this.createActionEvent('misc', 'ripgrep_fallback', { - snapshots: JSON.stringify({ + properties: { platform: process.platform, arch: process.arch, use_ripgrep: event.use_ripgrep, use_builtin_ripgrep: event.use_builtin_ripgrep, - error: event.error ?? undefined, - }), + error_message: event.error, + }, }); this.enqueueLogEvent(rumEvent); @@ -809,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); @@ -822,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); @@ -834,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); From fb6d0b43fadd455e99ca70619add437945b7a0d4 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 21 Nov 2025 15:42:17 +0800 Subject: [PATCH 10/14] feat: change shortcut for subagent execution display --- .../subagents/runtime/AgentExecutionDisplay.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) 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) From f2439f8d53b61691437b78dc479123af13247fba Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 21 Nov 2025 15:43:05 +0800 Subject: [PATCH 11/14] fix: skip one unstable test case --- packages/cli/src/ui/components/InputPrompt.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 25274a12..260f2aa2 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1950,7 +1950,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); From 48b77541c3b4d55c63fa6dad198a766570ac1fc7 Mon Sep 17 00:00:00 2001 From: pomelo Date: Fri, 21 Nov 2025 15:44:37 +0800 Subject: [PATCH 12/14] feat(i18n): Add Internationalization Support for UI and LLM Output (#1058) --- docs/cli/commands.md | 10 + docs/cli/language.md | 71 ++ package.json | 1 + packages/cli/package.json | 3 +- packages/cli/src/config/config.ts | 15 + packages/cli/src/config/settingsSchema.ts | 17 + packages/cli/src/core/initializer.ts | 9 +- packages/cli/src/core/theme.ts | 5 +- packages/cli/src/i18n/index.ts | 232 ++++ packages/cli/src/i18n/locales/en.js | 1129 +++++++++++++++++ packages/cli/src/i18n/locales/zh.js | 1052 +++++++++++++++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 9 +- packages/cli/src/ui/auth/AuthDialog.tsx | 27 +- packages/cli/src/ui/auth/AuthInProgress.tsx | 7 +- packages/cli/src/ui/auth/useAuth.ts | 17 +- packages/cli/src/ui/commands/aboutCommand.ts | 5 +- packages/cli/src/ui/commands/agentsCommand.ts | 13 +- .../src/ui/commands/approvalModeCommand.ts | 5 +- packages/cli/src/ui/commands/authCommand.ts | 5 +- packages/cli/src/ui/commands/bugCommand.ts | 5 +- packages/cli/src/ui/commands/chatCommand.ts | 90 +- packages/cli/src/ui/commands/clearCommand.ts | 9 +- .../cli/src/ui/commands/compressCommand.ts | 15 +- packages/cli/src/ui/commands/copyCommand.ts | 5 +- .../cli/src/ui/commands/directoryCommand.tsx | 53 +- packages/cli/src/ui/commands/docsCommand.ts | 19 +- packages/cli/src/ui/commands/editorCommand.ts | 5 +- .../cli/src/ui/commands/extensionsCommand.ts | 13 +- packages/cli/src/ui/commands/helpCommand.ts | 5 +- packages/cli/src/ui/commands/ideCommand.ts | 32 +- packages/cli/src/ui/commands/initCommand.ts | 7 +- .../cli/src/ui/commands/languageCommand.ts | 458 +++++++ packages/cli/src/ui/commands/mcpCommand.ts | 71 +- packages/cli/src/ui/commands/memoryCommand.ts | 92 +- packages/cli/src/ui/commands/modelCommand.ts | 16 +- .../cli/src/ui/commands/permissionsCommand.ts | 5 +- packages/cli/src/ui/commands/quitCommand.ts | 9 +- .../cli/src/ui/commands/settingsCommand.ts | 5 +- .../cli/src/ui/commands/setupGithubCommand.ts | 5 +- packages/cli/src/ui/commands/statsCommand.ts | 15 +- .../cli/src/ui/commands/summaryCommand.ts | 36 +- .../src/ui/commands/terminalSetupCommand.ts | 15 +- packages/cli/src/ui/commands/themeCommand.ts | 5 +- packages/cli/src/ui/commands/toolsCommand.ts | 7 +- packages/cli/src/ui/commands/vimCommand.ts | 5 +- packages/cli/src/ui/components/AboutBox.tsx | 3 +- .../src/ui/components/ApprovalModeDialog.tsx | 22 +- .../src/ui/components/AutoAcceptIndicator.tsx | 13 +- packages/cli/src/ui/components/Composer.tsx | 13 +- .../src/ui/components/ConfigInitDisplay.tsx | 12 +- .../ui/components/ContextSummaryDisplay.tsx | 51 +- .../ui/components/EditorSettingsDialog.tsx | 12 +- packages/cli/src/ui/components/Help.tsx | 84 +- .../src/ui/components/InputPrompt.test.tsx | 1 + .../cli/src/ui/components/InputPrompt.tsx | 9 +- .../src/ui/components/LoadingIndicator.tsx | 8 +- .../cli/src/ui/components/ModelDialog.tsx | 5 +- .../src/ui/components/ModelStatsDisplay.tsx | 29 +- .../cli/src/ui/components/OpenAIKeyPrompt.tsx | 21 +- .../cli/src/ui/components/ProQuotaDialog.tsx | 7 +- .../ui/components/QuitConfirmationDialog.tsx | 11 +- .../src/ui/components/QwenOAuthProgress.tsx | 34 +- .../ui/components/SessionSummaryDisplay.tsx | 6 +- .../cli/src/ui/components/SettingsDialog.tsx | 18 +- .../ui/components/ShellConfirmationDialog.tsx | 13 +- .../cli/src/ui/components/StatsDisplay.tsx | 42 +- .../cli/src/ui/components/ThemeDialog.tsx | 11 +- packages/cli/src/ui/components/Tips.tsx | 11 +- .../src/ui/components/ToolStatsDisplay.tsx | 29 +- .../src/ui/components/WelcomeBackDialog.tsx | 29 +- .../SettingsDialog.test.tsx.snap | 40 +- .../messages/CompressionMessage.tsx | 21 +- .../messages/ToolConfirmationMessage.tsx | 80 +- .../ui/components/shared/ScopeSelector.tsx | 5 +- .../subagents/create/AgentCreationWizard.tsx | 89 +- .../subagents/create/CreationSummary.tsx | 56 +- .../subagents/create/DescriptionInput.tsx | 17 +- .../create/GenerationMethodSelector.tsx | 9 +- .../subagents/create/LocationSelector.tsx | 9 +- .../subagents/create/ToolSelector.tsx | 23 +- .../subagents/manage/ActionSelectionStep.tsx | 33 +- .../subagents/manage/AgentDeleteStep.tsx | 8 +- .../subagents/manage/AgentEditStep.tsx | 19 +- .../subagents/manage/AgentSelectionStep.tsx | 23 +- .../subagents/manage/AgentViewerStep.tsx | 13 +- .../subagents/manage/AgentsManagerDialog.tsx | 29 +- .../cli/src/ui/components/views/McpStatus.tsx | 94 +- .../cli/src/ui/components/views/ToolsList.tsx | 5 +- packages/cli/src/ui/hooks/usePhraseCycler.ts | 17 +- packages/cli/src/ui/hooks/useThemeCommand.ts | 17 +- packages/cli/src/ui/models/availableModels.ts | 15 +- packages/cli/src/ui/utils/terminalSetup.ts | 73 +- packages/cli/src/utils/settingsUtils.ts | 7 +- packages/cli/src/utils/systemInfoFields.ts | 29 +- scripts/check-i18n.ts | 457 +++++++ scripts/copy_files.js | 19 +- scripts/prepare-package.js | 39 +- 98 files changed, 4740 insertions(+), 636 deletions(-) create mode 100644 docs/cli/language.md create mode 100644 packages/cli/src/i18n/index.ts create mode 100644 packages/cli/src/i18n/locales/en.js create mode 100644 packages/cli/src/i18n/locales/zh.js create mode 100644 packages/cli/src/ui/commands/languageCommand.ts create mode 100644 scripts/check-i18n.ts 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.json b/package.json index 4cab7369..c96865aa 100644 --- a/package.json +++ b/package.json @@ -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 0ba4c0ef..6d3e7f51 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -26,7 +26,8 @@ "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" diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index dc07c473..5be05d39 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -23,6 +23,7 @@ import { WriteFileTool, resolveTelemetrySettings, FatalConfigError, + Storage, InputFormat, OutputFormat, } from '@qwen-code/qwen-code-core'; @@ -602,6 +603,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 = { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index e0ece3ac..d95f4dbb 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -176,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 ēš„ęœåŠ”å™Øčæ›č”Œčŗ«ä»½éŖŒčÆ', + '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?': + 'ę‚ØåøŒęœ›å¦‚ä½•äøŗę­¤é”¹ē›®čæ›č”Œčŗ«ä»½éŖŒčÆļ¼Ÿ', + '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 345bebd2..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'; @@ -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 04da911c..d2369690 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -18,6 +18,7 @@ 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'; @@ -60,7 +61,9 @@ export const useAuthCommand = ( 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 @@ -127,7 +130,9 @@ export const useAuthCommand = ( addItem( { type: MessageType.INFO, - text: `Authenticated successfully with ${authType} credentials.`, + text: t('Authenticated successfully with {{authType}} credentials.', { + authType, + }), }, Date.now(), ); @@ -225,7 +230,13 @@ export const useAuthCommand = ( ) ) { 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 25274a12..6091c9e2 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -210,6 +210,7 @@ describe('InputPrompt', () => { inputWidth: 80, suggestionsWidth: 80, focus: true, + placeholder: ' Type your message or @path/to/file', }; }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 2bd9b275..8af77059 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -27,6 +27,7 @@ import { parseInputForHighlighting, buildSegmentsForVisualSlice, } from '../utils/highlight.js'; +import { t } from '../../i18n/index.js'; import { clipboardHasImage, saveClipboardImage, @@ -88,7 +89,7 @@ export const InputPrompt: React.FC = ({ config, slashCommands, commandContext, - placeholder = ' Type your message or @path/to/file', + placeholder, focus = true, suggestionsWidth, shellModeActive, @@ -697,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 ( 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.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 cf8d4444..7c2c04f9 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -12,6 +12,8 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -20,8 +22,6 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -46,6 +46,8 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -54,8 +56,6 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -80,6 +80,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -88,8 +90,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -114,6 +114,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Debug Keystroke Logging false* │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ @@ -122,8 +124,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin │ │ │ Hide Tips false* │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -148,6 +148,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -156,8 +158,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -182,6 +182,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Debug Keystroke Logging (Modified in Workspace) false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -190,8 +192,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -216,6 +216,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -224,8 +226,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -250,6 +250,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false* │ @@ -258,8 +260,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -284,6 +284,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Debug Keystroke Logging false │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title false │ @@ -292,8 +294,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security │ │ │ Hide Tips false │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ @@ -318,6 +318,8 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Debug Keystroke Logging true* │ │ │ +│ Language Auto (detect from system) │ +│ │ │ Output Format Text │ │ │ │ Hide Window Title true* │ @@ -326,8 +328,6 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se │ │ │ Hide Tips true* │ │ │ -│ Hide Banner false │ -│ │ │ ā–¼ │ │ │ │ │ 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/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/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/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/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/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/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: { From 87b1ffe0174de266c8130288091dac192f2af325 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Sat, 22 Nov 2025 14:23:49 +0800 Subject: [PATCH 13/14] fix(ci): remove non-existent label from release failure issue creation --- .github/workflows/release.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb005fa0..0c4ff85a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -224,5 +224,4 @@ jobs: run: |- gh issue create \ --title "Release Failed for ${RELEASE_TAG} on $(date +'%Y-%m-%d')" \ - --body "The release workflow failed. See the full run for details: ${DETAILS_URL}" \ - --label "kind/bug,release-failure" + --body "The release workflow failed. See the full run for details: ${DETAILS_URL}" From c9af74816ac68f8c83c928cdeecf007e39a419ed Mon Sep 17 00:00:00 2001 From: Mingholy Date: Sun, 23 Nov 2025 17:59:35 +0800 Subject: [PATCH 14/14] fix: reset authType settings (#1091) * fix: reset authType settings * fix: failed json-output tests * fix: sandbox exception log to stderr --- integration-tests/json-output.test.ts | 3 ++- packages/cli/src/utils/sandbox.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/integration-tests/json-output.test.ts b/integration-tests/json-output.test.ts index 8221aa5b..e1d432db 100644 --- a/integration-tests/json-output.test.ts +++ b/integration-tests/json-output.test.ts @@ -54,6 +54,7 @@ describe('JSON output', () => { }); it('should return a JSON error for enforced auth mismatch before running', async () => { + const originalOpenaiApiKey = process.env['OPENAI_API_KEY']; process.env['OPENAI_API_KEY'] = 'test-key'; await rig.setup('json-output-auth-mismatch', { settings: { @@ -68,7 +69,7 @@ describe('JSON output', () => { } catch (e) { thrown = e as Error; } finally { - delete process.env['OPENAI_API_KEY']; + process.env['OPENAI_API_KEY'] = originalOpenaiApiKey; } expect(thrown).toBeDefined(); diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index e7959174..6fde2159 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -848,7 +848,7 @@ export async function start_sandbox( sandboxProcess?.on('close', (code, signal) => { process.stdin.resume(); if (code !== 0 && code !== null) { - console.log( + console.error( `Sandbox process exited with code: ${code}, signal: ${signal}`, ); }