Merge branch 'main' of https://github.com/QwenLM/qwen-code into feat/jinjing/write-and-read-file-in-vscode

This commit is contained in:
yiliang114
2025-11-24 20:40:19 +08:00
182 changed files with 20186 additions and 2931 deletions

View File

@@ -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}"

11
.vscode/launch.json vendored
View File

@@ -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": ["<node_internals>/**"],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",

View File

@@ -25,7 +25,7 @@
</div>
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

View File

@@ -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.

View File

@@ -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 <format>`**:
- **`--output-format <format>`** (**`-o <format>`**):
- **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 <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`**:

71
docs/cli/language.md Normal file
View File

@@ -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 <language>
```
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

View File

@@ -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)

View File

@@ -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,15 +27,34 @@ 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 () => {
const originalOpenaiApiKey = process.env['OPENAI_API_KEY'];
process.env['OPENAI_API_KEY'] = 'test-key';
await rig.setup('json-output-auth-mismatch', {
settings: {
@@ -50,38 +69,242 @@ 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();
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');
});
});

View File

@@ -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}`));
}
}
});
});

12
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.3.0",
"workspaces": [
"packages/*"
],
@@ -17088,7 +17088,7 @@
},
"packages/cli": {
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.3.0",
"dependencies": {
"@google/genai": "1.16.0",
"@iarna/toml": "^2.2.5",
@@ -17203,7 +17203,7 @@
},
"packages/core": {
"name": "@qwen-code/qwen-code-core",
"version": "0.2.2",
"version": "0.3.0",
"hasInstallScript": true,
"dependencies": {
"@google/genai": "1.16.0",
@@ -17342,7 +17342,7 @@
},
"packages/test-utils": {
"name": "@qwen-code/qwen-code-test-utils",
"version": "0.2.2",
"version": "0.3.0",
"dev": true,
"license": "Apache-2.0",
"devDependencies": {
@@ -17354,7 +17354,7 @@
},
"packages/vscode-ide-companion": {
"name": "qwen-code-vscode-ide-companion",
"version": "0.2.2",
"version": "0.3.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.15.1",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.3.0",
"engines": {
"node": ">=20.0.0"
},
@@ -13,7 +13,7 @@
"url": "git+https://github.com/QwenLM/qwen-code.git"
},
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
},
"scripts": {
"start": "cross-env node scripts/start.js",
@@ -46,6 +46,7 @@
"lint:all": "node scripts/lint.js",
"format": "prettier --experimental-cli --write .",
"typecheck": "npm run typecheck --workspaces --if-present",
"check-i18n": "npm run check-i18n --workspace=packages/cli",
"preflight": "npm run clean && npm ci && npm run format && npm run lint:ci && npm run build && npm run typecheck && npm run test:ci",
"prepare": "husky && npm run bundle",
"prepare:package": "node scripts/prepare-package.js",

View File

@@ -1,6 +1,6 @@
{
"name": "@qwen-code/qwen-code",
"version": "0.2.2",
"version": "0.3.0",
"description": "Qwen Code",
"repository": {
"type": "git",
@@ -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",
@@ -19,13 +26,14 @@
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"check-i18n": "tsx ../../scripts/check-i18n.ts"
},
"files": [
"dist"
],
"config": {
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.2.2"
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.3.0"
},
"dependencies": {
"@google/genai": "1.16.0",

View File

@@ -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);

View File

@@ -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,9 @@ import {
WriteFileTool,
resolveTelemetrySettings,
FatalConfigError,
Storage,
InputFormat,
OutputFormat,
} from '@qwen-code/qwen-code-core';
import type { Settings } from './settings.js';
import yargs, { type Argv } from 'yargs';
@@ -124,7 +126,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<CliArgs> {
@@ -359,11 +378,23 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
'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 +439,18 @@ export async function parseArguments(settings: Settings): Promise<CliArgs> {
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;
}),
)
@@ -560,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 = {
@@ -588,6 +645,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 +702,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 +848,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,
@@ -789,7 +885,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,
@@ -799,7 +894,7 @@ export async function loadCliConfig(
eventEmitter: appEvents,
useSmartEdit: argv.useSmartEdit ?? settings.useSmartEdit,
output: {
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
format: outputSettingsFormat,
},
});
}

View File

@@ -77,7 +77,6 @@ const MIGRATION_MAP: Record<string, string> = {
disableAutoUpdate: 'general.disableAutoUpdate',
disableUpdateNag: 'general.disableUpdateNag',
dnsResolutionOrder: 'advanced.dnsResolutionOrder',
enablePromptCompletion: 'general.enablePromptCompletion',
enforcedAuthType: 'security.auth.enforcedType',
excludeTools: 'tools.exclude',
excludeMCPServers: 'mcp.excluded',
@@ -484,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) {

View File

@@ -167,16 +167,6 @@ const SETTINGS_SCHEMA = {
},
},
},
enablePromptCompletion: {
type: 'boolean',
label: 'Enable Prompt Completion',
category: 'General',
requiresRestart: true,
default: false,
description:
'Enable AI-powered prompt completion suggestions while typing.',
showInDialog: true,
},
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
@@ -186,6 +176,23 @@ const SETTINGS_SCHEMA = {
description: 'Enable debug logging of keystrokes to the console.',
showInDialog: true,
},
language: {
type: 'enum',
label: 'Language',
category: 'General',
requiresRestart: false,
default: 'auto',
description:
'The language for the user interface. Use "auto" to detect from system settings. ' +
'You can also use custom language codes (e.g., "es", "fr") by placing JS language files ' +
'in ~/.qwen/locales/ (e.g., ~/.qwen/locales/es.js).',
showInDialog: true,
options: [
{ value: 'auto', label: 'Auto (detect from system)' },
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文 (Chinese)' },
],
},
},
},
output: {

View File

@@ -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<InitializationResult> {
// 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 =

View File

@@ -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;
}

View File

@@ -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', () => ({

View File

@@ -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<boolean> | 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);
}

View File

@@ -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<string, string> = {};
// Cache
type TranslationDict = Record<string, string>;
const translationCache: Record<string, TranslationDict> = {};
const loadingPromises: Record<string, Promise<TranslationDict>> = {};
// 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<TranslationDict> {
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, string>,
): 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<void> {
currentLanguage = resolveLanguage(lang);
translations = await loadTranslationsAsync(currentLanguage);
}
export function getCurrentLanguage(): SupportedLanguage {
return currentLanguage;
}
export function t(key: string, params?: Record<string, string>): string {
const translation = translations[key] ?? key;
return interpolate(translation, params);
}
export async function initializeI18n(
lang?: SupportedLanguage | 'auto',
): Promise<void> {
await setLanguageAsync(lang ?? 'auto');
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
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<string>;
mcpClients: Map<string, { client: Client; config: MCPServerConfig }>;
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;
}
}

View File

@@ -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<string>(),
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<void>((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();
});
});
});
});

View File

@@ -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<string, PendingIncomingRequest> =
new Map();
private pendingOutgoingRequests: Map<string, PendingOutgoingRequest> =
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<void> {
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<ControlResponse> {
// 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<string, unknown>,
): 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);
}
}

View File

@@ -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();
}
}

View File

@@ -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<Record<string, unknown>> {
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<ControlResponse> {
const requestId = randomUUID();
return new Promise<ControlResponse>((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<Record<string, unknown>>;
/**
* Cleanup resources
*/
cleanup(): void {
// Subclasses can override to add cleanup logic
}
}

View File

@@ -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<Record<string, unknown>> {
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<Record<string, unknown>> {
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,
};
}
}

View File

@@ -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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> = {
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<Record<string, unknown>> {
const status: Record<string, string> = {};
// 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<string, MCPServerConfig> | 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();
}
}

View File

@@ -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<string>();
/**
* Handle permission control requests
*/
protected async handleRequestPayload(
payload: ControlRequestPayload,
_signal: AbortSignal,
): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> = {
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<Record<string, unknown>> {
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<string, unknown>;
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<string, unknown>;
}> {
// 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<void> {
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<string, unknown>;
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<string, unknown>;
}
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);
}
}
}

View File

@@ -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<Record<string, unknown>> {
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<Record<string, unknown>> {
// 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<string, unknown> {
const capabilities: Record<string, unknown> = {
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<string, unknown> | 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<Record<string, unknown>> {
// 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<Record<string, unknown>> {
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<Record<string, unknown>> {
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,
};
}
}

View File

@@ -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<string, unknown>;
}>;
/**
* 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<string, unknown>;
}
/**
* 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;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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();
});
});
});

View File

@@ -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);
}
}

View File

@@ -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,
);
});
});
});

View File

@@ -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<StreamJsonInputMessage> {
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}`,
);
}
}
}

View File

@@ -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',
});
});
});
});

View File

@@ -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);
}
}

View File

@@ -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<typeof vi.fn>;
};
let mockDispatcher: {
dispatch: ReturnType<typeof vi.fn>;
handleControlResponse: ReturnType<typeof vi.fn>;
handleCancel: ReturnType<typeof vi.fn>;
shutdown: ReturnType<typeof vi.fn>;
};
let mockConsolePatcher: {
patch: ReturnType<typeof vi.fn>;
cleanup: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
config = createConfig();
runNonInteractiveMock.mockReset();
// Setup mocks
mockConsolePatcher = {
patch: vi.fn(),
cleanup: vi.fn(),
};
(ConsolePatcher as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => mockConsolePatcher,
);
mockOutputAdapter = {
emitResult: vi.fn(),
} as {
emitResult: ReturnType<typeof vi.fn>;
[key: string]: unknown;
};
(
StreamJsonOutputAdapter as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockOutputAdapter);
mockDispatcher = {
dispatch: vi.fn().mockResolvedValue(undefined),
handleControlResponse: vi.fn(),
handleCancel: vi.fn(),
shutdown: vi.fn(),
};
(
ControlDispatcher as unknown as ReturnType<typeof vi.fn>
).mockImplementation(() => mockDispatcher);
(ControlContext as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({}),
);
(ControlService as unknown as ReturnType<typeof vi.fn>).mockImplementation(
() => ({}),
);
mockInputReader = {
async *read() {
// Default: empty stream
// Override in tests as needed
},
};
(
StreamJsonInputReader as unknown as ReturnType<typeof vi.fn>
).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<typeof vi.fn>).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();
});
});

View File

@@ -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<boolean> {
// 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<void> {
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<boolean> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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();
}
}

View File

@@ -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<string, unknown>;
}
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<string, unknown>;
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<string, ModelUsage>;
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<string, ModelUsage>;
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<string, unknown>;
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';
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<void> {
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);
}

View File

@@ -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,

View File

@@ -89,6 +89,7 @@ import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useQuitConfirmation } from './hooks/useQuitConfirmation.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
@@ -353,7 +354,7 @@ export const AppContainer = (props: AppContainerProps) => {
handleAuthSelect,
openAuthDialog,
cancelAuthentication,
} = useAuthCommand(settings, config);
} = useAuthCommand(settings, config, historyManager.addItem);
const { proQuotaRequest, handleProQuotaChoice } = useQuotaAndFallback({
config,
@@ -384,7 +385,13 @@ export const AppContainer = (props: AppContainerProps) => {
settings.merged.security?.auth.selectedType
) {
onAuthError(
`Authentication is enforced to be ${settings.merged.security?.auth.enforcedType}, but you are currently using ${settings.merged.security?.auth.selectedType}.`,
t(
'Authentication is enforced to be {{enforcedType}}, but you are currently using {{currentType}}.',
{
enforcedType: settings.merged.security?.auth.enforcedType,
currentType: settings.merged.security?.auth.selectedType,
},
),
);
} else if (
settings.merged.security?.auth?.selectedType &&

View File

@@ -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%"
>
<Text bold>Get started</Text>
<Text bold>{t('Get started')}</Text>
<Box marginTop={1}>
<Text>How would you like to authenticate for this project?</Text>
<Text>{t('How would you like to authenticate for this project?')}</Text>
</Box>
<Box marginTop={1}>
<RadioButtonSelect
@@ -134,19 +141,19 @@ export function AuthDialog(): React.JSX.Element {
</Box>
)}
<Box marginTop={1}>
<Text color={Colors.AccentPurple}>(Use Enter to Set Auth)</Text>
<Text color={Colors.AccentPurple}>{t('(Use Enter to Set Auth)')}</Text>
</Box>
{hasApiKey && currentSelectedAuthType === AuthType.QWEN_OAUTH && (
<Box marginTop={1}>
<Text color={Colors.Gray}>
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.',
)}
</Text>
</Box>
)}
<Box marginTop={1}>
<Text>Terms of Services and Privacy Notice for Qwen Code</Text>
<Text>{t('Terms of Services and Privacy Notice for Qwen Code')}</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.AccentBlue}>

View File

@@ -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 ? (
<Text color={theme.status.error}>
Authentication timed out. Please try again.
{t('Authentication timed out. Please try again.')}
</Text>
) : (
<Box>
<Text>
<Spinner type="dots" /> Waiting for auth... (Press ESC or CTRL+C to
cancel)
<Spinner type="dots" />{' '}
{t('Waiting for auth... (Press ESC or CTRL+C to cancel)')}
</Text>
</Box>
)}

View File

@@ -4,23 +4,29 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { Config } from '@qwen-code/qwen-code-core';
import {
AuthEvent,
AuthType,
clearCachedCredentialFile,
getErrorMessage,
logAuth,
AuthEvent,
} from '@qwen-code/qwen-code-core';
import { AuthState } from '../types.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { useCallback, useEffect, useState } from 'react';
import type { LoadedSettings, SettingScope } from '../../config/settings.js';
import type { OpenAICredentials } from '../components/OpenAIKeyPrompt.js';
import { useQwenAuth } from '../hooks/useQwenAuth.js';
import { AuthState, MessageType } from '../types.js';
import type { HistoryItem } from '../types.js';
import { t } from '../../i18n/index.js';
export type { QwenAuthState } from '../hooks/useQwenAuth.js';
export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
export const useAuthCommand = (
settings: LoadedSettings,
config: Config,
addItem: (item: Omit<HistoryItem, 'id'>, timestamp: number) => void,
) => {
const unAuthenticated =
settings.merged.security?.auth?.selectedType === undefined;
@@ -55,7 +61,9 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
const handleAuthFailure = useCallback(
(error: unknown) => {
setIsAuthenticating(false);
const errorMessage = `Failed to authenticate. Message: ${getErrorMessage(error)}`;
const errorMessage = t('Failed to authenticate. Message: {{message}}', {
message: getErrorMessage(error),
});
onAuthError(errorMessage);
// Log authentication failure
@@ -117,8 +125,19 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
// Log authentication success
const authEvent = new AuthEvent(authType, 'manual', 'success');
logAuth(config, authEvent);
// Show success message
addItem(
{
type: MessageType.INFO,
text: t('Authenticated successfully with {{authType}} credentials.', {
authType,
}),
},
Date.now(),
);
},
[settings, handleAuthFailure, config],
[settings, handleAuthFailure, config, addItem],
);
const performAuth = useCallback(
@@ -211,7 +230,13 @@ export const useAuthCommand = (settings: LoadedSettings, config: Config) => {
)
) {
onAuthError(
`Invalid QWEN_DEFAULT_AUTH_TYPE value: "${defaultAuthType}". Valid values are: ${[AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', ')}`,
t(
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}',
{
value: defaultAuthType,
validValues: [AuthType.QWEN_OAUTH, AuthType.USE_OPENAI].join(', '),
},
),
);
}
}, [onAuthError]);

View File

@@ -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);

View File

@@ -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',

View File

@@ -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,

View File

@@ -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',

View File

@@ -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<void> => {
const bugDescription = (args || '').trim();

View File

@@ -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<MessageActionReturn> => {
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 <tag>',
get description() {
return t(
'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<SlashCommandActionReturn | void> => {
const tag = args.trim();
@@ -111,7 +116,7 @@ const saveCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat save <tag>',
content: t('Missing tag. Usage: /chat save <tag>'),
};
}
@@ -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 <tag>',
get description() {
return t(
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
);
},
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 <tag>',
content: t('Missing tag. Usage: /chat resume <tag>'),
};
}
@@ -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 <tag>',
get description() {
return t('Delete a conversation checkpoint. Usage: /chat delete <tag>');
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim();
@@ -245,7 +262,7 @@ const deleteCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content: 'Missing tag. Usage: /chat delete <tag>',
content: t('Missing tag. Usage: /chat delete <tag>'),
};
}
@@ -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 <file>',
get description() {
return t(
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
);
},
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => {
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,

View File

@@ -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);

View File

@@ -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(),
);

View File

@@ -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<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat();

View File

@@ -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(),
);

View File

@@ -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<void> => {
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(),
);

View File

@@ -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',

View File

@@ -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 <extension-names>|--all',
get description() {
return t('Update extensions. Usage: update <extension-names>|--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) =>

View File

@@ -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<HistoryItemHelp, 'id'> = {
type: MessageType.HELP,

View File

@@ -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<SlashCommand> => {
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<SlashCommandActionReturn> => {
const { messageType, content } =
@@ -173,7 +182,12 @@ export const ideCommand = async (): Promise<SlashCommand> => {
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<SlashCommand> => {
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<SlashCommand> => {
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(

View File

@@ -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();

View File

@@ -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<MessageActionReturn> {
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<SupportedLanguage, string> = {
zh: '中文zh-CN',
en: 'Englishen-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<MessageActionReturn> {
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<SlashCommandActionReturn> => {
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 <language> - ${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 <language>
if (parts.length === 1) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${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 <language> - ' + 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<MessageActionReturn> => {
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<MessageActionReturn> => {
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<MessageActionReturn> => {
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<MessageActionReturn> => {
const trimmedArgs = args.trim();
if (!trimmedArgs) {
return {
type: 'message',
messageType: 'info',
content: [
t('Set LLM output language'),
'',
t('Usage: /language output <language>'),
` ${t('Example: /language output 中文')}`,
` ${t('Example: /language output English')}`,
` ${t('Example: /language output 日本語')}`,
].join('\n'),
};
}
return generateLlmOutputLanguageRuleFile(trimmedArgs);
},
},
],
};

View File

@@ -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 <server-name> to authenticate.`,
content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth <server-name> 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

View File

@@ -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] <text to remember>',
),
};
}
@@ -150,8 +175,9 @@ export const memoryCommand: SlashCommand = {
return {
type: 'message',
messageType: 'error',
content:
content: t(
'Usage: /memory add [--global|--project] <text to remember>',
),
};
} 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] <text to remember>',
),
};
}
@@ -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 <text to remember>',
content: t('Usage: /memory add --project <text to remember>'),
};
}
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 <text to remember>',
content: t('Usage: /memory add --global <text to remember>'),
};
}
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(),
);

View File

@@ -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,
},
),
};
}

View File

@@ -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',

View File

@@ -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();

View File

@@ -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',

View File

@@ -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<void> {
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,

View File

@@ -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(

View File

@@ -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<SlashCommandActionReturn> => {
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),
}),
};
}
},

View File

@@ -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<MessageActionReturn> => {
@@ -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',
};
}

View File

@@ -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',

View File

@@ -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<void> => {
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(),
);

View File

@@ -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();

View File

@@ -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<AboutBoxProps> = (props) => {
>
<Box marginBottom={1}>
<Text bold color={theme.text.accent}>
About Qwen Code
{t('About Qwen Code')}
</Text>
</Box>
{fields.map((field: SystemInfoField) => (

View File

@@ -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({
<Box flexDirection="column" flexGrow={1}>
{/* Approval Mode Selection */}
<Text bold={focusSection === 'mode'} wrap="truncate">
{focusSection === 'mode' ? '> ' : ' '}Approval Mode{' '}
{focusSection === 'mode' ? '> ' : ' '}
{t('Approval Mode')}{' '}
<Text color={theme.text.secondary}>{otherScopeModifiedMessage}</Text>
</Text>
<Box height={1} />
@@ -167,15 +169,17 @@ export function ApprovalModeDialog({
{showWorkspacePriorityWarning && (
<>
<Text color={theme.status.warning} wrap="wrap">
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.',
)}
</Text>
<Box height={1} />
</>
)}
<Text color={theme.text.secondary}>
(Use Enter to select, Tab to change focus)
{t('(Use Enter to select, Tab to change focus)')}
</Text>
</Box>
</Box>

View File

@@ -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<AutoAcceptIndicatorProps> = ({
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:

View File

@@ -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 ? (
<Text color={theme.status.warning}>
Press Ctrl+C again to exit.
{t('Press Ctrl+C again to exit.')}
</Text>
) : uiState.ctrlDPressedOnce ? (
<Text color={theme.status.warning}>
Press Ctrl+D again to exit.
{t('Press Ctrl+D again to exit.')}
</Text>
) : uiState.showEscapePrompt ? (
<Text color={theme.text.secondary}>Press Esc again to clear.</Text>
<Text color={theme.text.secondary}>
{t('Press Esc again to clear.')}
</Text>
) : (
!settings.merged.ui?.hideContextSummary && (
<ContextSummaryDisplay
@@ -151,8 +154,8 @@ export const Composer = () => {
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')
}
/>
)}

View File

@@ -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<string, McpClient>) => {
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);

View File

@@ -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<ContextSummaryDisplayProps> = ({
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<ContextSummaryDisplayProps> = ({
}
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<ContextSummaryDisplayProps> = ({
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<ContextSummaryDisplayProps> = ({
// 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<ContextSummaryDisplayProps> = ({
if (isNarrow) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>Using:</Text>
<Text color={theme.text.secondary}>{t('Using:')}</Text>
{summaryParts.map((part, index) => (
<Text key={index} color={theme.text.secondary}>
{' '}- {part}
@@ -115,7 +136,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
return (
<Box>
<Text color={theme.text.secondary}>
Using: {summaryParts.join(' | ')}
{t('Using:')} {summaryParts.join(' | ')}
</Text>
</Box>
);

View File

@@ -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({
<Box marginTop={1} flexDirection="column">
<Text bold={focusedSection === 'scope'}>
{focusedSection === 'scope' ? '> ' : ' '}Apply To
{focusedSection === 'scope' ? '> ' : ' '}
{t('Apply To')}
</Text>
<RadioButtonSelect
items={scopeItems}

View File

@@ -8,6 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type SlashCommand, CommandKind } from '../commands/types.js';
import { t } from '../../i18n/index.js';
interface Help {
commands: readonly SlashCommand[];
@@ -23,46 +24,41 @@ export const Help: React.FC<Help> = ({ commands }) => (
>
{/* Basics */}
<Text bold color={theme.text.primary}>
Basics:
{t('Basics:')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Add context
{t('Add context')}
</Text>
: Use{' '}
<Text bold color={theme.text.accent}>
@
</Text>{' '}
to specify files for context (e.g.,{' '}
<Text bold color={theme.text.accent}>
@src/myFile.ts
</Text>
) 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'),
},
)}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shell mode
{t('Shell mode')}
</Text>
: Execute shell commands via{' '}
<Text bold color={theme.text.accent}>
!
</Text>{' '}
(e.g.,{' '}
<Text bold color={theme.text.accent}>
!npm run start
</Text>
) or use natural language (e.g.{' '}
<Text bold color={theme.text.accent}>
start server
</Text>
).
:{' '}
{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'),
},
)}
</Text>
<Box height={1} />
{/* Commands */}
<Text bold color={theme.text.primary}>
Commands:
{t('Commands:')}
</Text>
{commands
.filter((command) => command.description && !command.hidden)
@@ -97,81 +93,81 @@ export const Help: React.FC<Help> = ({ commands }) => (
{' '}
!{' '}
</Text>
- shell command
- {t('shell command')}
</Text>
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>[MCP]</Text> - Model Context Protocol
command (from external servers)
<Text color={theme.text.secondary}>[MCP]</Text> -{' '}
{t('Model Context Protocol command (from external servers)')}
</Text>
<Box height={1} />
{/* Shortcuts */}
<Text bold color={theme.text.primary}>
Keyboard Shortcuts:
{t('Keyboard Shortcuts:')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Alt+Left/Right
</Text>{' '}
- Jump through words in the input
- {t('Jump through words in the input')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+C
</Text>{' '}
- Close dialogs, cancel requests, or quit application
- {t('Close dialogs, cancel requests, or quit application')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'win32' ? 'Ctrl+Enter' : 'Ctrl+J'}
</Text>{' '}
-{' '}
{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')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Ctrl+L
</Text>{' '}
- Clear the screen
- {t('Clear the screen')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
{process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'}
</Text>{' '}
- Open input in external editor
- {t('Open input in external editor')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Enter
</Text>{' '}
- Send message
- {t('Send message')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Esc
</Text>{' '}
- Cancel operation / Clear input (double press)
- {t('Cancel operation / Clear input (double press)')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Shift+Tab
</Text>{' '}
- Cycle approval modes
- {t('Cycle approval modes')}
</Text>
<Text color={theme.text.primary}>
<Text bold color={theme.text.accent}>
Up/Down
</Text>{' '}
- Cycle through your prompt history
- {t('Cycle through your prompt history')}
</Text>
<Box height={1} />
<Text color={theme.text.primary}>
For a full list of shortcuts, see{' '}
<Text bold color={theme.text.accent}>
docs/keyboard-shortcuts.md
</Text>
{t('For a full list of shortcuts, see {{docPath}}', {
docPath: t('docs/keyboard-shortcuts.md'),
})}
</Text>
</Box>
);

View File

@@ -164,11 +164,6 @@ describe('InputPrompt', () => {
setActiveSuggestionIndex: vi.fn(),
setShowSuggestions: vi.fn(),
handleAutocomplete: vi.fn(),
promptCompletion: {
text: '',
accept: vi.fn(),
clear: vi.fn(),
},
};
mockedUseCommandCompletion.mockReturnValue(mockCommandCompletion);
@@ -215,6 +210,7 @@ describe('InputPrompt', () => {
inputWidth: 80,
suggestionsWidth: 80,
focus: true,
placeholder: ' Type your message or @path/to/file',
};
});
@@ -1955,7 +1951,7 @@ describe('InputPrompt', () => {
unmount();
});
it('expands and collapses long suggestion via Right/Left arrows', async () => {
it.skip('expands and collapses long suggestion via Right/Left arrows', async () => {
props.shellModeActive = false;
const longValue = 'l'.repeat(200);

View File

@@ -12,9 +12,8 @@ import { theme } from '../semantic-colors.js';
import { useInputHistory } from '../hooks/useInputHistory.js';
import type { TextBuffer } from './shared/text-buffer.js';
import { logicalPosToOffset } from './shared/text-buffer.js';
import { cpSlice, cpLen, toCodePoints } from '../utils/textUtils.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import chalk from 'chalk';
import stringWidth from 'string-width';
import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
@@ -28,6 +27,7 @@ import {
parseInputForHighlighting,
buildSegmentsForVisualSlice,
} from '../utils/highlight.js';
import { t } from '../../i18n/index.js';
import {
clipboardHasImage,
saveClipboardImage,
@@ -89,9 +89,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
config,
slashCommands,
commandContext,
placeholder = ' Type your message or @path/to/file',
placeholder,
focus = true,
inputWidth,
suggestionsWidth,
shellModeActive,
setShellModeActive,
@@ -526,16 +525,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
// Handle Tab key for ghost text acceptance
if (
key.name === 'tab' &&
!completion.showSuggestions &&
completion.promptCompletion.text
) {
completion.promptCompletion.accept();
return;
}
if (!shellModeActive) {
if (keyMatchers[Command.REVERSE_SEARCH](key)) {
setCommandSearchActive(true);
@@ -657,18 +646,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Clear ghost text when user types regular characters (not navigation/control keys)
if (
completion.promptCompletion.text &&
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
completion.promptCompletion.clear();
setExpandedSuggestionIndex(-1);
}
},
[
focus,
@@ -703,118 +680,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const getGhostTextLines = useCallback(() => {
if (
!completion.promptCompletion.text ||
!buffer.text ||
!completion.promptCompletion.text.startsWith(buffer.text)
) {
return { inlineGhost: '', additionalLines: [] };
}
const ghostSuffix = completion.promptCompletion.text.slice(
buffer.text.length,
);
if (!ghostSuffix) {
return { inlineGhost: '', additionalLines: [] };
}
const currentLogicalLine = buffer.lines[buffer.cursor[0]] || '';
const cursorCol = buffer.cursor[1];
const textBeforeCursor = cpSlice(currentLogicalLine, 0, cursorCol);
const usedWidth = stringWidth(textBeforeCursor);
const remainingWidth = Math.max(0, inputWidth - usedWidth);
const ghostTextLinesRaw = ghostSuffix.split('\n');
const firstLineRaw = ghostTextLinesRaw.shift() || '';
let inlineGhost = '';
let remainingFirstLine = '';
if (stringWidth(firstLineRaw) <= remainingWidth) {
inlineGhost = firstLineRaw;
} else {
const words = firstLineRaw.split(' ');
let currentLine = '';
let wordIdx = 0;
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
if (stringWidth(prospectiveLine) > remainingWidth) {
break;
}
currentLine = prospectiveLine;
wordIdx++;
}
inlineGhost = currentLine;
if (words.length > wordIdx) {
remainingFirstLine = words.slice(wordIdx).join(' ');
}
}
const linesToWrap = [];
if (remainingFirstLine) {
linesToWrap.push(remainingFirstLine);
}
linesToWrap.push(...ghostTextLinesRaw);
const remainingGhostText = linesToWrap.join('\n');
const additionalLines: string[] = [];
if (remainingGhostText) {
const textLines = remainingGhostText.split('\n');
for (const textLine of textLines) {
const words = textLine.split(' ');
let currentLine = '';
for (const word of words) {
const prospectiveLine = currentLine ? `${currentLine} ${word}` : word;
const prospectiveWidth = stringWidth(prospectiveLine);
if (prospectiveWidth > inputWidth) {
if (currentLine) {
additionalLines.push(currentLine);
}
let wordToProcess = word;
while (stringWidth(wordToProcess) > inputWidth) {
let part = '';
const wordCP = toCodePoints(wordToProcess);
let partWidth = 0;
let splitIndex = 0;
for (let i = 0; i < wordCP.length; i++) {
const char = wordCP[i];
const charWidth = stringWidth(char);
if (partWidth + charWidth > inputWidth) {
break;
}
part += char;
partWidth += charWidth;
splitIndex = i + 1;
}
additionalLines.push(part);
wordToProcess = cpSlice(wordToProcess, splitIndex);
}
currentLine = wordToProcess;
} else {
currentLine = prospectiveLine;
}
}
if (currentLine) {
additionalLines.push(currentLine);
}
}
}
return { inlineGhost, additionalLines };
}, [
completion.promptCompletion.text,
buffer.text,
buffer.lines,
buffer.cursor,
inputWidth,
]);
const { inlineGhost, additionalLines } = getGhostTextLines();
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
if (reverseSearchActive) return reverseSearchCompletion;
@@ -833,13 +698,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
let statusText = '';
if (shellModeActive) {
statusColor = theme.ui.symbol;
statusText = 'Shell mode';
statusText = t('Shell mode');
} else if (showYoloStyling) {
statusColor = theme.status.error;
statusText = 'YOLO mode';
statusText = t('YOLO mode');
} else if (showAutoAcceptStyling) {
statusColor = theme.status.warning;
statusText = 'Accepting edits';
statusText = t('Accepting edits');
}
return (
@@ -887,134 +752,96 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
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(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
});
const currentLineGhost = isOnCursorLine ? inlineGhost : '';
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
if (!currentLineGhost) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
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 (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>
{renderedLine}
{showCursorBeforeGhost &&
(showCursor ? chalk.inverse(' ') : ' ')}
{currentLineGhost && (
<Text color={theme.text.secondary}>
{currentLineGhost}
</Text>
)}
</Text>
</Box>
renderedLine.push(
<Text key={`token-${segIdx}`} color={color}>
{display}
</Text>,
);
})
.concat(
additionalLines.map((ghostLine, index) => {
const padding = Math.max(
0,
inputWidth - stringWidth(ghostLine),
);
return (
<Text
key={`ghost-line-${index}`}
color={theme.text.secondary}
>
{ghostLine}
{' '.repeat(padding)}
</Text>
);
}),
)
});
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') : ' '}
</Text>,
);
}
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>{renderedLine}</Text>
</Box>
);
})
)}
</Box>
</Box>

View File

@@ -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<LoadingIndicatorProps> = ({
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 (

View File

@@ -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%"
>
<Text bold>Select Model</Text>
<Text bold>{t('Select Model')}</Text>
<Box marginTop={1}>
<DescriptiveRadioButtonSelect
items={MODEL_OPTIONS}
@@ -97,7 +98,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
/>
</Box>
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>(Press Esc to close)</Text>
<Text color={theme.text.secondary}>{t('(Press Esc to close)')}</Text>
</Box>
</Box>
);

View File

@@ -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}
>
<Text color={theme.text.primary}>
No API calls have been made in this session.
{t('No API calls have been made in this session.')}
</Text>
</Box>
);
@@ -94,7 +95,7 @@ export const ModelStatsDisplay: React.FC = () => {
paddingX={2}
>
<Text bold color={theme.text.accent}>
Model Stats For Nerds
{t('Model Stats For Nerds')}
</Text>
<Box height={1} />
@@ -102,7 +103,7 @@ export const ModelStatsDisplay: React.FC = () => {
<Box>
<Box width={METRIC_COL_WIDTH}>
<Text bold color={theme.text.primary}>
Metric
{t('Metric')}
</Text>
</Box>
{modelNames.map((name) => (
@@ -125,13 +126,13 @@ export const ModelStatsDisplay: React.FC = () => {
/>
{/* API Section */}
<StatRow title="API" values={[]} isSection />
<StatRow title={t('API')} values={[]} isSection />
<StatRow
title="Requests"
title={t('Requests')}
values={getModelValues((m) => m.api.totalRequests.toLocaleString())}
/>
<StatRow
title="Errors"
title={t('Errors')}
values={getModelValues((m) => {
const errorRate = calculateErrorRate(m);
return (
@@ -146,7 +147,7 @@ export const ModelStatsDisplay: React.FC = () => {
})}
/>
<StatRow
title="Avg Latency"
title={t('Avg Latency')}
values={getModelValues((m) => {
const avgLatency = calculateAverageLatency(m);
return formatDuration(avgLatency);
@@ -156,9 +157,9 @@ export const ModelStatsDisplay: React.FC = () => {
<Box height={1} />
{/* Tokens Section */}
<StatRow title="Tokens" values={[]} isSection />
<StatRow title={t('Tokens')} values={[]} isSection />
<StatRow
title="Total"
title={t('Total')}
values={getModelValues((m) => (
<Text color={theme.status.warning}>
{m.tokens.total.toLocaleString()}
@@ -166,13 +167,13 @@ export const ModelStatsDisplay: React.FC = () => {
))}
/>
<StatRow
title="Prompt"
title={t('Prompt')}
isSubtle
values={getModelValues((m) => m.tokens.prompt.toLocaleString())}
/>
{hasCached && (
<StatRow
title="Cached"
title={t('Cached')}
isSubtle
values={getModelValues((m) => {
const cacheHitRate = calculateCacheHitRate(m);
@@ -186,20 +187,20 @@ export const ModelStatsDisplay: React.FC = () => {
)}
{hasThoughts && (
<StatRow
title="Thoughts"
title={t('Thoughts')}
isSubtle
values={getModelValues((m) => m.tokens.thoughts.toLocaleString())}
/>
)}
{hasTool && (
<StatRow
title="Tool"
title={t('Tool')}
isSubtle
values={getModelValues((m) => m.tokens.tool.toLocaleString())}
/>
)}
<StatRow
title="Output"
title={t('Output')}
isSubtle
values={getModelValues((m) => m.tokens.candidates.toLocaleString())}
/>

View File

@@ -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%"
>
<Text bold color={Colors.AccentBlue}>
OpenAI Configuration Required
{t('OpenAI Configuration Required')}
</Text>
{validationError && (
<Box marginTop={1}>
@@ -214,7 +217,9 @@ export function OpenAIKeyPrompt({
)}
<Box marginTop={1}>
<Text>
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',
)}{' '}
<Text color={Colors.AccentBlue}>
https://bailian.console.aliyun.com/?tab=model#/api-key
</Text>
@@ -225,7 +230,7 @@ export function OpenAIKeyPrompt({
<Text
color={currentField === 'apiKey' ? Colors.AccentBlue : Colors.Gray}
>
API Key:
{t('API Key:')}
</Text>
</Box>
<Box flexGrow={1}>
@@ -240,7 +245,7 @@ export function OpenAIKeyPrompt({
<Text
color={currentField === 'baseUrl' ? Colors.AccentBlue : Colors.Gray}
>
Base URL:
{t('Base URL:')}
</Text>
</Box>
<Box flexGrow={1}>
@@ -255,7 +260,7 @@ export function OpenAIKeyPrompt({
<Text
color={currentField === 'model' ? Colors.AccentBlue : Colors.Gray}
>
Model:
{t('Model:')}
</Text>
</Box>
<Box flexGrow={1}>
@@ -267,7 +272,7 @@ export function OpenAIKeyPrompt({
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press Enter to continue, Tab/ to navigate, Esc to cancel
{t('Press Enter to continue, Tab/↑↓ to navigate, Esc to cancel')}
</Text>
</Box>
</Box>

View File

@@ -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 (
<Box borderStyle="round" flexDirection="column" paddingX={1}>
<Text bold color={theme.status.warning}>
Pro quota limit reached for {failedModel}.
{t('Pro quota limit reached for {{model}}.', { model: failedModel })}
</Text>
<Box marginTop={1}>
<RadioButtonSelect

View File

@@ -12,6 +12,7 @@ import {
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
export enum QuitChoice {
CANCEL = 'cancel',
@@ -39,22 +40,22 @@ export const QuitConfirmationDialog: React.FC<QuitConfirmationDialogProps> = ({
const options: Array<RadioSelectItem<QuitChoice>> = [
{
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<QuitConfirmationDialogProps> = ({
marginLeft={1}
>
<Box flexDirection="column" marginBottom={1}>
<Text>What would you like to do before exiting?</Text>
<Text>{t('What would you like to do before exiting?')}</Text>
</Box>
<RadioButtonSelect items={options} onSelect={onSelect} isFocused />

View File

@@ -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%"
>
<Text bold color={Colors.AccentBlue}>
Qwen OAuth Authentication
{t('Qwen OAuth Authentication')}
</Text>
<Box marginTop={1}>
<Text>Please visit this URL to authorize:</Text>
<Text>{t('Please visit this URL to authorize:')}</Text>
</Box>
<Link url={verificationUrl} fallback={false}>
@@ -66,7 +67,7 @@ function QrCodeDisplay({
</Link>
<Box marginTop={1}>
<Text>Or scan the QR code below:</Text>
<Text>{t('Or scan the QR code below:')}</Text>
</Box>
<Box marginTop={1}>
@@ -103,15 +104,18 @@ function StatusDisplay({
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> Waiting for authorization{dots}
<Spinner type="dots" /> {t('Waiting for authorization')}
{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {formatTime(timeRemaining)}
{t('Time remaining:')} {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>
{t('(Press ESC or CTRL+C to cancel)')}
</Text>
<Text color={Colors.AccentPurple}>(Press ESC or CTRL+C to cancel)</Text>
</Box>
</Box>
);
@@ -215,19 +219,24 @@ export function QwenOAuthProgress({
width="100%"
>
<Text bold color={Colors.AccentRed}>
Qwen OAuth Authentication Timeout
{t('Qwen OAuth Authentication Timeout')}
</Text>
<Box marginTop={1}>
<Text>
{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(),
},
)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press any key to return to authentication type selection.
{t('Press any key to return to authentication type selection.')}
</Text>
</Box>
</Box>
@@ -275,16 +284,17 @@ export function QwenOAuthProgress({
>
<Box>
<Text>
<Spinner type="dots" /> Waiting for Qwen OAuth authentication...
<Spinner type="dots" />
{t('Waiting for Qwen OAuth authentication...')}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
Time remaining: {Math.floor(timeRemaining / 60)}:
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
{(timeRemaining % 60).toString().padStart(2, '0')}
</Text>
<Text color={Colors.AccentPurple}>
(Press ESC or CTRL+C to cancel)
{t('(Press ESC or CTRL+C to cancel)')}
</Text>
</Box>
</Box>

View File

@@ -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<SessionSummaryDisplayProps> = ({
duration,
}) => (
<StatsDisplay title="Agent powering down. Goodbye!" duration={duration} />
<StatsDisplay
title={t('Agent powering down. Goodbye!')}
duration={duration}
/>
);

View File

@@ -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,

Some files were not shown because too many files have changed in this diff Show More