merge main

This commit is contained in:
DennisYu07 2026-03-19 17:12:19 +08:00
commit 6f914e4f4e
228 changed files with 28059 additions and 2618 deletions

View file

@ -0,0 +1,201 @@
---
name: qwen-code-claw
description: Use Qwen Code as a Code Agent for code understanding, project generation, features, bug fixes, refactoring, and various programming tasks
---
# Qwen Code Claw
## When to Use This Skill
Use this skill when you need to:
- Understand codebases or ask questions about source code
- Generate new projects or add new features
- Review pull requests in the codebase
- Fix bugs or refactor existing code
- Execute various programming tasks such as code review, testing, documentation generation, etc.
- Collaborate with other tools and agents to complete complex development tasks
## Install
```bash
npm install -g @qwen-code/qwen-code
```
For normal session reuse, prefer a global install over `npx`.
## Authentication
Check if authentication is already configured:
```bash
qwen auth status
```
If authentication exists, skip this section. If not authenticated, check if the `BAILIAN_CODING_PLAN_API_KEY` environment variable exists:
```bash
echo $BAILIAN_CODING_PLAN_API_KEY
```
**If `BAILIAN_CODING_PLAN_API_KEY` exists**, authenticate directly:
```bash
qwen auth coding-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY
```
**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `coding-plan`:
```bash
qwen auth
```
Or configure custom API after launching Qwen Code via `/auth`.
## ACPX Integration
You should always use `acpx` to run Qwen Code over the Agent Client Protocol (ACP) instead of scraping PTY sessions.
To use acpx:
1. **Install acpx globally** (recommended) or use npx:
```bash
npm install -g acpx@latest
```
Or without installing:
```bash
npx acpx@latest
```
2. **Install the acpx skill** for full reference:
```bash
npx acpx@latest --skill install acpx
```
3. **Read the acpx skill reference** for every command, flag, and workflow pattern:
See the [ACPX Reference](#acpx-reference) section below for details
4. **For complete CLI reference with all options and examples**:
https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md
From now on, when you need to delegate work to Qwen Code, use `acpx` instead of spawning raw terminal sessions. For example:
```bash
npx acpx@latest qwen "fix the failing tests"
npx acpx@latest qwen "refactor the auth module"
npx acpx@latest qwen exec "one-shot: summarize this repo"
```
> **Tip:** You should typically use the `--approve-all` flag to prevent task interruptions.
## Practical Workflows
### Persistent Repository Assistant
```bash
acpx qwen 'inspect failing tests and propose a fix plan'
acpx qwen 'apply the smallest safe fix and run tests'
```
### One-Shot Script Steps
```bash
acpx qwen exec 'summarize repo purpose in 3 lines'
```
### Parallel Named Streams
```bash
acpx qwen -s backend 'fix API pagination bug'
acpx qwen -s docs 'draft changelog entry for release'
```
### Queue Follow-ups Without Waiting
```bash
acpx qwen 'run full test suite and investigate failures'
acpx qwen --no-wait 'after tests, summarize root causes and next steps'
```
### Machine-Readable Output for Orchestration
```bash
acpx --format json qwen 'review current branch changes' > events.ndjson
```
### Repository-Wide Review with Permissive Mode
```bash
acpx --cwd ~/repos/my-project --approve-all qwen -s pr-123 \
'review PR #123 for regressions and propose minimal patch'
```
## Approval Modes
- `--approve-all`: No interactive prompts
- `--approve-reads` (default): Auto-approve reads/searches, prompt for writes
- `--deny-all`: Deny all permission requests
If every permission request is denied/cancelled and none are approved, `acpx` exits with permission denied.
## Best Practices
1. Use **named sessions** for organizing different types of development tasks
2. Use `--no-wait` for long-running tasks to avoid blocking
3. Use `--approve-all` for non-interactive batch operations
4. Use `--format json` for automation and script integration
5. Use `--cwd` to manage context across multiple projects
## ACPX Reference
### Built-in Agent Registry
Well-known agent names resolve to commands:
- `qwen``qwen --acp`
### Command Syntax
```bash
# Default (prompt mode, persistent session)
acpx [global options] [prompt text...]
acpx [global options] prompt [options] [prompt text...]
# One-shot execution
acpx [global options] exec [options] [prompt text...]
# Session management
acpx [global options] cancel [-s <name>]
acpx [global options] set-mode <mode> [-s <name>]
acpx [global options] set <key> <value> [-s <name>]
acpx [global options] status [-s <name>]
acpx [global options] sessions [list | new [--name <name>] | close [name] | show [name] | history [name] [--limit <count>]]
acpx [global options] config [show | init]
# With explicit agent
acpx [global options] <agent> [options] [prompt text...]
acpx [global options] <agent> prompt [options] [prompt text...]
acpx [global options] <agent> exec [options] [prompt text...]
```
> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt from stdin.
### Global Options
| Option | Description |
| --------------------- | ------------------------------------------------------------ |
| `--agent <command>` | Raw ACP agent command (fallback mechanism) |
| `--cwd <directory>` | Session working directory |
| `--approve-all` | Auto-approve all requests |
| `--approve-reads` | Auto-approve reads/searches, prompt for writes (default) |
| `--deny-all` | Deny all requests |
| `--format <format>` | Output format: `text`, `json`, `quiet` |
| `--timeout <seconds>` | Maximum wait time (positive integer) |
| `--ttl <seconds>` | Idle TTL for queue owners (default: `300`, `0` disables TTL) |
| `--verbose` | Verbose ACP/debug logs to stderr |
Flags are mutually exclusive where applicable.

View file

@ -21,6 +21,12 @@ Start the CLI and follow the browser flow:
qwen
```
Or authenticate directly without starting a session:
```bash
qwen auth qwen-oauth
```
> [!note]
>
> In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow.
@ -44,6 +50,20 @@ Alibaba Cloud Coding Plan is available in two regions:
### Interactive setup
You can set up Coding Plan authentication in two ways:
**Option A: From the terminal (recommended for first-time setup)**
```bash
# Interactive — prompts for region and API key
qwen auth coding-plan
# Or non-interactive — pass region and key directly
qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx
```
**Option B: Inside a Qwen Code session**
Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command and select **Alibaba Cloud Coding Plan**. Choose your region, then enter your `sk-sp-xxxxxxxxx` key.
After authentication, use the `/model` command to switch between all Alibaba Cloud Coding Plan supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5).
@ -290,6 +310,55 @@ qwen --model "qwen3-coder-plus"
qwen --model "qwen3.5-plus"
```
## `qwen auth` CLI command
In addition to the in-session `/auth` slash command, Qwen Code provides a standalone `qwen auth` CLI command for managing authentication directly from the terminal — without starting an interactive session first.
### Interactive mode
Run `qwen auth` without arguments to get an interactive menu:
```bash
qwen auth
```
You'll see a selector with arrow-key navigation:
```
Select authentication method:
> Qwen OAuth - Free · Up to 1,000 requests/day · Qwen latest models
Alibaba Cloud Coding Plan - Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models
(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)
```
### Subcommands
| Command | Description |
| ---------------------------------------------------- | ------------------------------------------------- |
| `qwen auth` | Interactive authentication setup |
| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth |
| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan |
| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) |
| `qwen auth status` | Show current authentication status |
**Examples:**
```bash
# Authenticate with Qwen OAuth directly
qwen auth qwen-oauth
# Set up Coding Plan interactively (prompts for region and key)
qwen auth coding-plan
# Set up Coding Plan non-interactively (useful for CI/scripting)
qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx
# Check your current auth configuration
qwen auth status
```
## Security notes
- Don't commit API keys to version control.

View file

@ -1,6 +1,7 @@
export default {
commands: 'Commands',
'sub-agents': 'SubAgents',
arena: 'Agent Arena',
skills: 'Skills',
headless: 'Headless Mode',
checkpointing: {

View file

@ -0,0 +1,218 @@
# Agent Arena
> Dispatch multiple AI models simultaneously to execute the same task, compare their solutions side-by-side, and select the best result to apply to your workspace.
> [!warning]
> Agent Arena is experimental. It has [known limitations](#limitations) around display modes and session management.
Agent Arena lets you pit multiple AI models against each other on the same task. Each model runs as a fully independent agent in its own isolated Git worktree, so file operations never interfere. When all agents finish, you compare results and select a winner to merge back into your main workspace.
Unlike [subagents](/users/features/sub-agents), which delegate focused subtasks within a single session, Arena agents are complete, top-level agent instances — each with its own model, context window, and full tool access.
This page covers:
- [When to use Agent Arena](#when-to-use-agent-arena)
- [Starting an arena session](#start-an-arena-session)
- [Interacting with agents](#interact-with-agents), including display modes and navigation
- [Comparing results and selecting a winner](#compare-results-and-select-a-winner)
- [Best practices](#best-practices)
## When to use Agent Arena
Agent Arena is most effective when you want to **evaluate or compare** how different models tackle the same problem. The strongest use cases are:
- **Model benchmarking**: Evaluate different models' capabilities on real tasks in your actual codebase, not synthetic benchmarks
- **Best-of-N selection**: Get multiple independent solutions and pick the best implementation
- **Exploring approaches**: See how different models reason about and solve the same problem — useful for learning and insight
- **Risk reduction**: For critical changes, validate that multiple models converge on a similar approach before committing
Agent Arena uses significantly more tokens than a single session (each agent has its own context window and model calls). It works best when the value of comparison justifies the cost. For routine tasks where you trust your default model, a single session is more efficient.
## Start an arena session
Use the `/arena` slash command to launch a session. Specify the models you want to compete and the task:
```
/arena --models qwen3.5-plus,glm-5,kimi-k2.5 "Refactor the authentication module to use JWT tokens"
```
If you omit `--models`, an interactive model selection dialog appears, letting you pick from your configured providers.
### What happens when you start
1. **Worktree setup**: Qwen Code creates isolated Git worktrees for each agent at `~/.qwen/arena/<session-id>/worktrees/<model-name>/`. Each worktree mirrors your current working directory state exactly — including staged changes, unstaged changes, and untracked files.
2. **Agent spawning**: Each agent starts in its own worktree with full tool access and its configured model. Agents are launched sequentially but execute in parallel.
3. **Execution**: All agents work on the task independently with no shared state or communication. You can monitor their progress and interact with any of them.
4. **Completion**: When all agents finish (or fail), you enter the result comparison phase.
## Interact with agents
### Display modes
Agent Arena currently supports **in-process mode**, where all agents run asynchronously within the same terminal process. A tab bar at the bottom of the terminal lets you switch between agents.
> [!note]
> **Split-pane display modes are planned for the future.** We intend to support tmux-based and iTerm2-based split-pane layouts, where each agent gets its own terminal pane for true side-by-side viewing. Currently, only in-process tab switching is available.
### Navigate between agents
In in-process mode, use keyboard shortcuts to switch between agent views:
| Shortcut | Action |
| :------- | :-------------------------------- |
| `Right` | Switch to the next agent tab |
| `Left` | Switch to the previous agent tab |
| `Up` | Switch focus to the input box |
| `Down` | Switch focus to the agent tab bar |
The tab bar shows each agent's current status:
| Indicator | Meaning |
| :-------- | :--------------------- |
| `●` | Running or idle |
| `✓` | Completed successfully |
| `✗` | Failed |
| `○` | Cancelled |
### Interact with individual agents
When viewing an agent's tab, you can:
- **Send messages** — type in the input area to give the agent additional instructions
- **Approve tool calls** — if an agent requests tool approval, the confirmation dialog appears in its tab
- **View full history** — scroll through the agent's complete conversation, including model output, tool calls, and results
Each agent is a full, independent session. Anything you can do with the main agent, you can do with an arena agent.
## Compare results and select a winner
When all agents complete, the Arena enters the result comparison phase. You'll see:
- **Status summary**: Which agents succeeded, failed, or were cancelled
- **Execution metrics**: Duration, rounds of reasoning, token usage, and tool call counts for each agent
A selection dialog presents the successful agents. Choose one to apply its changes to your main workspace, or discard all results.
### What happens when you select a winner
1. The winning agent's changes are extracted as a diff against the baseline
2. The diff is applied to your main working directory
3. All worktrees and temporary branches are cleaned up automatically
If you want to inspect results before deciding, each agent's full conversation history is available via the tab bar while the selection dialog is active.
## Configuration
Arena behavior can be customized in [settings.json](/users/configuration/settings):
```json
{
"arena": {
"worktreeBaseDir": "~/.qwen/arena",
"maxRoundsPerAgent": 50,
"timeoutSeconds": 600
}
}
```
| Setting | Description | Default |
| :------------------------ | :--------------------------------- | :-------------- |
| `arena.worktreeBaseDir` | Base directory for arena worktrees | `~/.qwen/arena` |
| `arena.maxRoundsPerAgent` | Maximum reasoning rounds per agent | `50` |
| `arena.timeoutSeconds` | Timeout for each agent in seconds | `600` |
## Best practices
### Choose models that complement each other
Arena is most valuable when you compare models with meaningfully different strengths. For example:
```
/arena --models qwen3.5-plus,glm-5,kimi-k2.5 "Optimize the database query layer"
```
Comparing three versions of the same model family yields less insight than comparing across providers.
### Keep tasks self-contained
Arena agents work independently with no communication. Tasks should be fully describable in the prompt without requiring back-and-forth:
**Good**: "Refactor the payment module to use the strategy pattern. Update all tests."
**Less effective**: "Let's discuss how to improve the payment module" — this benefits from conversation, which is better suited to a single session.
### Limit the number of agents
Up to 5 agents can run simultaneously. In practice, 2-3 agents provide the best balance of comparison value to resource cost. More agents means:
- Higher token costs (each agent has its own context window)
- Longer total execution time
- More results to compare
Start with 2-3 and scale up only when the comparison value justifies it.
### Use Arena for high-impact decisions
Arena shines when the stakes justify running multiple models:
- Choosing an architecture for a new module
- Selecting an approach for a complex refactor
- Validating a critical bug fix from multiple angles
For routine changes like renaming a variable or updating a config file, a single session is faster and cheaper.
## Troubleshooting
### Agents failing to start
- Verify that each model in `--models` is properly configured with valid API credentials
- Check that your working directory is a Git repository (worktrees require Git)
- Ensure you have write access to the worktree base directory (`~/.qwen/arena/` by default)
### Worktree creation fails
- Run `git worktree list` to check for stale worktrees from previous sessions
- Clean up stale worktrees with `git worktree prune`
- Ensure your Git version supports worktrees (`git --version`, requires Git 2.5+)
### Agent takes too long
- Increase the timeout: set `arena.timeoutSeconds` in settings
- Reduce task complexity — Arena tasks should be focused and well-defined
- Lower `arena.maxRoundsPerAgent` if agents are spending too many rounds
### Applying winner fails
- Check for uncommitted changes in your main working directory that might conflict
- The diff is applied as a patch — merge conflicts are possible if your working directory changed during the session
## Limitations
Agent Arena is experimental. Current limitations:
- **In-process mode only**: Split-pane display via tmux or iTerm2 is not yet available. All agents run within a single terminal window with tab switching.
- **No diff preview before selection**: You can view each agent's conversation history, but there is no unified diff viewer to compare solutions side-by-side before picking a winner.
- **No worktree retention**: Worktrees are always cleaned up after selection. There is no option to preserve them for further inspection.
- **No session resumption**: Arena sessions cannot be resumed after exiting. If you close the terminal mid-session, worktrees remain on disk and must be cleaned up manually via `git worktree prune`.
- **Maximum 5 agents**: The hard limit of 5 concurrent agents cannot be changed.
- **Git repository required**: Arena requires a Git repository for worktree isolation. It cannot be used in non-Git directories.
## Comparison with other multi-agent modes
Agent Arena is one of several planned multi-agent modes in Qwen Code. **Agent Team** and **Agent Swarm** are not yet implemented — the table below describes their intended design for reference.
| | **Agent Arena** | **Agent Team** (planned) | **Agent Swarm** (planned) |
| :---------------- | :----------------------------------------------------- | :------------------------------------------------- | :------------------------------------------------------- |
| **Goal** | Competitive: Find the best solution to the _same_ task | Collaborative: Tackle _different_ aspects together | Batch parallel: Dynamically spawn workers for bulk tasks |
| **Agents** | Pre-configured models compete independently | Teammates collaborate with assigned roles | Workers spawned on-the-fly, destroyed on completion |
| **Communication** | No inter-agent communication | Direct peer-to-peer messaging | One-way: results aggregated by parent |
| **Isolation** | Full: separate Git worktrees | Independent sessions with shared task list | Lightweight ephemeral context per worker |
| **Output** | One selected solution applied to workspace | Synthesized results from multiple perspectives | Aggregated results from parallel processing |
| **Best for** | Benchmarking, choosing between model approaches | Research, complex collaboration, cross-layer work | Batch operations, data processing, map-reduce tasks |
## Next steps
Explore related approaches for parallel and delegated work:
- **Lightweight delegation**: [Subagents](/users/features/sub-agents) handle focused subtasks within your session — better when you don't need model comparison
- **Manual parallel sessions**: Run multiple Qwen Code sessions yourself in separate terminals with [Git worktrees](https://git-scm.com/docs/git-worktree) for full manual control

View file

@ -33,6 +33,7 @@ Commands for adjusting interface appearance and work environment.
| Command | Description | Usage Examples |
| ------------ | ---------------------------------------- | ----------------------------- |
| `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) |
| `/context` | Show context window usage breakdown | `/context` |
| `/theme` | Change Qwen Code visual theme | `/theme` |
| `/vim` | Turn input area Vim editing mode on/off | `/vim` |
| `/directory` | Manage multi-directory support workspace | `/dir add ./src,./tests` |
@ -94,6 +95,22 @@ Commands for obtaining information and performing system settings.
| `Ctrl/cmd+Z` | Undo input | Text editing |
| `Ctrl/cmd+Shift+Z` | Redo input | Text editing |
### 1.7 CLI Auth Subcommands
In addition to the in-session `/auth` slash command, Qwen Code provides standalone CLI subcommands for managing authentication directly from the terminal:
| Command | Description |
| ---------------------------------------------------- | ------------------------------------------------- |
| `qwen auth` | Interactive authentication setup |
| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth |
| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan |
| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) |
| `qwen auth status` | Show current authentication status |
> [!tip]
>
> These commands run outside of a Qwen Code session. Use them to configure authentication before starting a session, or in scripts and CI environments. See the [Authentication](../configuration/auth) page for full details.
## 2. @ Commands (Introducing Files)
@ commands are used to quickly add local file or directory content to the conversation.

View file

@ -54,7 +54,7 @@ brew install qwen-code
## Step 2: Log in to your account
Qwen Code requires an account to use. When you start an interactive session with the `qwen` command, you'll need to log in:
Qwen Code requires an account to use. When you start an interactive session with the `qwen` command, you'll be prompted to log in:
```bash
# You'll be prompted to log in on first use
@ -74,7 +74,7 @@ Select `Qwen OAuth`, log in to your account and follow the prompts to confirm. O
> [!tip]
>
> If you need to log in again or switch accounts, use the `/auth` command within Qwen Code.
> You can also configure authentication directly from the terminal without starting a session by running `qwen auth`. Use `qwen auth status` to check your current configuration at any time. See the [Authentication](./configuration/auth) page for details.
## Step 3: Start your first session
@ -216,7 +216,9 @@ Here are the most important commands for daily use:
| Command | What it does | Example |
| --------------------- | ------------------------------------------------ | ----------------------------- |
| `qwen` | start Qwen Code | `qwen` |
| `/auth` | Change authentication method | `/auth` |
| `/auth` | Change authentication method (in session) | `/auth` |
| `qwen auth` | Configure authentication from the terminal | `qwen auth` |
| `qwen auth status` | Check current authentication status | `qwen auth status` |
| `/help` | Display help information for available commands | `/help` or `/?` |
| `/compress` | Replace chat history with summary to save Tokens | `/compress` |
| `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) |

View file

@ -59,6 +59,7 @@ export default tseslint.config(
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
'import/namespace': 'off', // Disabled due to https://github.com/import-js/eslint-plugin-import/issues/2866
},
},
{

View file

@ -0,0 +1,870 @@
/**
* E2E tests for message_start and message_stop event pairing
* Ensures that message_start and message_stop events are always paired correctly
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
query,
isSDKPartialAssistantMessage,
isSDKAssistantMessage,
type SDKPartialAssistantMessage,
type TextBlock,
} from '@qwen-code/sdk';
import { SDKTestHelper, createSharedTestOptions } from './test-helper.js';
const SHARED_TEST_OPTIONS = createSharedTestOptions();
describe('Message Start/Stop Event Pairing (E2E)', () => {
let helper: SDKTestHelper;
let testDir: string;
beforeEach(async () => {
helper = new SDKTestHelper();
testDir = await helper.setup('message-event-pairing');
});
afterEach(async () => {
await helper.cleanup();
});
describe('Basic Message Event Pairing', () => {
it('should emit paired message_start and message_stop for single turn', async () => {
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const messageStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
messageStartEvents.push(message);
} else if (message.event.type === 'message_stop') {
messageStopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify message_start and message_stop are paired
expect(messageStartEvents.length).toBeGreaterThan(0);
expect(messageStopEvents.length).toBe(messageStartEvents.length);
});
it('should emit message_start before message_stop', async () => {
const events: Array<{ type: string; timestamp: number }> = [];
const q = query({
prompt: 'Say hello world',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (
message.event.type === 'message_start' ||
message.event.type === 'message_stop'
) {
events.push({
type: message.event.type,
timestamp: Date.now(),
});
}
}
}
} finally {
await q.close();
}
// Verify message_start comes before message_stop
expect(events.length).toBeGreaterThanOrEqual(2);
expect(events[0].type).toBe('message_start');
expect(events[events.length - 1].type).toBe('message_stop');
});
it('should have matching session_id for paired events', async () => {
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const messageStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
messageStartEvents.push(message);
} else if (message.event.type === 'message_stop') {
messageStopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify session_id matches between paired events
expect(messageStartEvents.length).toBeGreaterThan(0);
expect(messageStopEvents.length).toBe(messageStartEvents.length);
expect(messageStartEvents[0].session_id).toBe(
messageStopEvents[0].session_id,
);
});
});
describe('Multi-turn Message Event Pairing', () => {
it('should emit paired events for each turn in multi-turn conversation', async () => {
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const messageStopEvents: SDKPartialAssistantMessage[] = [];
const assistantMessages: string[] = [];
const sessionId = crypto.randomUUID();
const q = query({
prompt: (async function* () {
// First turn
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Say "first"',
},
parent_tool_use_id: null,
};
// Wait a bit for processing
await new Promise((resolve) => setTimeout(resolve, 500));
// Second turn
yield {
type: 'user',
session_id: sessionId,
message: {
role: 'user',
content: 'Say "second"',
},
parent_tool_use_id: null,
};
})(),
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
messageStartEvents.push(message);
} else if (message.event.type === 'message_stop') {
messageStopEvents.push(message);
}
} else if (isSDKAssistantMessage(message)) {
const text = message.message.content
.filter((block): block is TextBlock => block.type === 'text')
.map((block) => block.text)
.join('');
assistantMessages.push(text);
}
}
} finally {
await q.close();
}
// Verify we have paired events for each assistant message
expect(messageStartEvents.length).toBeGreaterThanOrEqual(1);
expect(messageStopEvents.length).toBe(messageStartEvents.length);
});
});
describe('Message Event Pairing with Tool Calls', () => {
it('should emit paired events when tool is used', async () => {
await helper.createFile('test.txt', 'Hello World');
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const messageStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Read the content of test.txt',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
coreTools: ['read_file'],
permissionMode: 'default',
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
messageStartEvents.push(message);
} else if (message.event.type === 'message_stop') {
messageStopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify message_start and message_stop are paired even with tool usage
expect(messageStartEvents.length).toBeGreaterThan(0);
expect(messageStopEvents.length).toBe(messageStartEvents.length);
});
it('should maintain event pairing through multiple tool calls', async () => {
await helper.createFile('file1.txt', 'Content 1');
await helper.createFile('file2.txt', 'Content 2');
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const messageStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Read file1.txt and file2.txt and summarize their contents',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
coreTools: ['read_file'],
permissionMode: 'default',
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
messageStartEvents.push(message);
} else if (message.event.type === 'message_stop') {
messageStopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify events are paired
expect(messageStartEvents.length).toBeGreaterThan(0);
expect(messageStopEvents.length).toBe(messageStartEvents.length);
});
});
describe('Message Event Structure Validation', () => {
it('should have correct message_start event structure', async () => {
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (
isSDKPartialAssistantMessage(message) &&
message.event.type === 'message_start'
) {
messageStartEvents.push(message);
}
}
} finally {
await q.close();
}
expect(messageStartEvents.length).toBeGreaterThan(0);
const startEvent = messageStartEvents[0].event;
expect(startEvent.type).toBe('message_start');
if (startEvent.type === 'message_start') {
expect(startEvent.message).toBeDefined();
expect(startEvent.message.id).toBeDefined();
expect(startEvent.message.role).toBe('assistant');
expect(startEvent.message.model).toBeDefined();
}
});
it('should have correct message_stop event structure', async () => {
const messageStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (
isSDKPartialAssistantMessage(message) &&
message.event.type === 'message_stop'
) {
messageStopEvents.push(message);
}
}
} finally {
await q.close();
}
expect(messageStopEvents.length).toBeGreaterThan(0);
const event = messageStopEvents[0].event;
expect(event.type).toBe('message_stop');
});
it('should have message_start and message_stop paired by count', async () => {
const startEvents: SDKPartialAssistantMessage[] = [];
const stopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello world',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
startEvents.push(message);
} else if (message.event.type === 'message_stop') {
stopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify message_start and message_stop appear in pairs (same count)
expect(startEvents.length).toBeGreaterThan(0);
expect(stopEvents.length).toBe(startEvents.length);
// Verify message_start carries the message id via its nested message.id field
for (const e of startEvents) {
const event = e.event as {
type: 'message_start';
message: { id: string };
};
expect(typeof event.message.id).toBe('string');
expect(event.message.id.length).toBeGreaterThan(0);
}
});
});
describe('Error Scenarios', () => {
it('should still emit message_stop even when query errors', async () => {
const messageStartEvents: SDKPartialAssistantMessage[] = [];
const messageStopEvents: SDKPartialAssistantMessage[] = [];
// Use an invalid tool to trigger an error scenario
const q = query({
prompt: 'Use a non-existent tool',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
coreTools: [], // No tools available
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'message_start') {
messageStartEvents.push(message);
} else if (message.event.type === 'message_stop') {
messageStopEvents.push(message);
}
}
}
} catch {
// Expected to potentially have errors
} finally {
await q.close();
}
// Even in error scenarios, if message_start was emitted, message_stop should also be emitted
if (messageStartEvents.length > 0) {
expect(messageStopEvents.length).toBe(messageStartEvents.length);
}
});
});
describe('Content Block Event Pairing', () => {
it('should emit paired content_block_start and content_block_stop for each content block', async () => {
const contentBlockStartEvents: SDKPartialAssistantMessage[] = [];
const contentBlockStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'content_block_start') {
contentBlockStartEvents.push(message);
} else if (message.event.type === 'content_block_stop') {
contentBlockStopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify content_block_start and content_block_stop are paired
expect(contentBlockStartEvents.length).toBeGreaterThan(0);
expect(contentBlockStopEvents.length).toBe(
contentBlockStartEvents.length,
);
});
it('should emit content_block_start before content_block_stop', async () => {
const events: Array<{ type: string; index: number; timestamp: number }> =
[];
const q = query({
prompt: 'Say hello world',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (
message.event.type === 'content_block_start' ||
message.event.type === 'content_block_stop'
) {
events.push({
type: message.event.type,
index: message.event.index,
timestamp: Date.now(),
});
}
}
}
} finally {
await q.close();
}
// Verify events exist
expect(events.length).toBeGreaterThanOrEqual(2);
// Group events by index
const eventsByIndex = new Map<number, typeof events>();
for (const event of events) {
if (!eventsByIndex.has(event.index)) {
eventsByIndex.set(event.index, []);
}
eventsByIndex.get(event.index)!.push(event);
}
// For each index, verify content_block_start comes before content_block_stop
eventsByIndex.forEach((indexEvents) => {
const startIndex = indexEvents.findIndex(
(e) => e.type === 'content_block_start',
);
const stopIndex = indexEvents.findIndex(
(e) => e.type === 'content_block_stop',
);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(stopIndex).toBeGreaterThanOrEqual(0);
expect(startIndex).toBeLessThan(stopIndex);
});
});
it('should have correct content_block_start event structure', async () => {
const contentBlockStartEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (
isSDKPartialAssistantMessage(message) &&
message.event.type === 'content_block_start'
) {
contentBlockStartEvents.push(message);
}
}
} finally {
await q.close();
}
expect(contentBlockStartEvents.length).toBeGreaterThan(0);
// Verify each content_block_start has correct structure
for (const message of contentBlockStartEvents) {
const event = message.event as {
type: 'content_block_start';
index: number;
content_block: unknown;
};
expect(event.type).toBe('content_block_start');
expect(event).toHaveProperty('index');
expect(typeof event.index).toBe('number');
expect(event.index).toBeGreaterThanOrEqual(0);
expect(event).toHaveProperty('content_block');
expect(event.content_block).toBeDefined();
}
});
it('should have correct content_block_stop event structure', async () => {
const contentBlockStopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (
isSDKPartialAssistantMessage(message) &&
message.event.type === 'content_block_stop'
) {
contentBlockStopEvents.push(message);
}
}
} finally {
await q.close();
}
expect(contentBlockStopEvents.length).toBeGreaterThan(0);
// Verify each content_block_stop has correct structure
for (const message of contentBlockStopEvents) {
const event = message.event as {
type: 'content_block_stop';
index: number;
};
expect(event.type).toBe('content_block_stop');
expect(event).toHaveProperty('index');
expect(typeof event.index).toBe('number');
expect(event.index).toBeGreaterThanOrEqual(0);
}
});
it('should have matching index for paired content_block_start and content_block_stop', async () => {
const startEvents: SDKPartialAssistantMessage[] = [];
const stopEvents: SDKPartialAssistantMessage[] = [];
const q = query({
prompt: 'Say hello world',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
if (message.event.type === 'content_block_start') {
startEvents.push(message);
} else if (message.event.type === 'content_block_stop') {
stopEvents.push(message);
}
}
}
} finally {
await q.close();
}
// Verify events exist and are paired
expect(startEvents.length).toBeGreaterThan(0);
expect(stopEvents.length).toBe(startEvents.length);
// Extract indices from start and stop events
const startIndices = startEvents.map(
(e) => (e.event as { index: number }).index,
);
const stopIndices = stopEvents.map(
(e) => (e.event as { index: number }).index,
);
// Verify each start index has a matching stop index
expect(new Set(stopIndices)).toEqual(new Set(startIndices));
// Verify each index appears the same number of times in both start and stop events
const startIndexCounts = new Map<number, number>();
const stopIndexCounts = new Map<number, number>();
for (const idx of startIndices) {
startIndexCounts.set(idx, (startIndexCounts.get(idx) || 0) + 1);
}
for (const idx of stopIndices) {
stopIndexCounts.set(idx, (stopIndexCounts.get(idx) || 0) + 1);
}
startIndexCounts.forEach((count, idx) => {
expect(stopIndexCounts.get(idx)).toBe(count);
});
});
it('should follow correct event flow: content_block_start -> content_block_delta -> content_block_stop', async () => {
const events: Array<{
type: string;
index: number;
position: number;
}> = [];
const q = query({
prompt: 'Write a short story about a cat',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
let pos = 0;
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
const eventType = message.event.type;
if (
eventType === 'content_block_start' ||
eventType === 'content_block_delta' ||
eventType === 'content_block_stop'
) {
events.push({
type: eventType,
index: (message.event as { index: number }).index,
position: pos++,
});
}
}
}
} finally {
await q.close();
}
expect(events.length).toBeGreaterThanOrEqual(2);
// Pair content_block_start/stop sequentially (not by index, since
// block-type transitions reset the blocks array and reuse index 0).
// Each start is matched with the next stop that follows it.
const starts = events.filter((e) => e.type === 'content_block_start');
const stops = events.filter((e) => e.type === 'content_block_stop');
expect(starts.length).toBe(stops.length);
for (let i = 0; i < starts.length; i++) {
const start = starts[i];
const stop = stops[i];
// start must come before the paired stop
expect(start.position).toBeLessThan(stop.position);
// All deltas between this pair must sit between start and stop
const deltas = events.filter(
(e) =>
e.type === 'content_block_delta' &&
e.position > start.position &&
e.position < stop.position,
);
for (const delta of deltas) {
expect(delta.position).toBeGreaterThan(start.position);
expect(delta.position).toBeLessThan(stop.position);
}
}
});
it('should have content_block_start after message_start and before message_stop', async () => {
const events: Array<{
type: string;
timestamp: number;
}> = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
const eventType = message.event.type;
if (
eventType === 'message_start' ||
eventType === 'message_stop' ||
eventType === 'content_block_start'
) {
events.push({
type: eventType,
timestamp: Date.now(),
});
}
}
}
} finally {
await q.close();
}
// Verify message_start exists
const messageStartIndex = events.findIndex(
(e) => e.type === 'message_start',
);
expect(messageStartIndex).toBeGreaterThanOrEqual(0);
// Verify message_stop exists
const messageStopIndex = events.findIndex(
(e) => e.type === 'message_stop',
);
expect(messageStopIndex).toBeGreaterThanOrEqual(0);
// Verify content_block_start exists
const firstContentBlockStartIndex = events.findIndex(
(e) => e.type === 'content_block_start',
);
expect(firstContentBlockStartIndex).toBeGreaterThanOrEqual(0);
// content_block_start should be after message_start
expect(firstContentBlockStartIndex).toBeGreaterThan(messageStartIndex);
// content_block_start should be before message_stop
expect(firstContentBlockStartIndex).toBeLessThan(messageStopIndex);
});
it('should have content_block_stop after message_start and before message_stop', async () => {
const events: Array<{
type: string;
timestamp: number;
}> = [];
const q = query({
prompt: 'Say hello',
options: {
...SHARED_TEST_OPTIONS,
includePartialMessages: true,
cwd: testDir,
debug: false,
},
});
try {
for await (const message of q) {
if (isSDKPartialAssistantMessage(message)) {
const eventType = message.event.type;
if (
eventType === 'message_start' ||
eventType === 'message_stop' ||
eventType === 'content_block_stop'
) {
events.push({
type: eventType,
timestamp: Date.now(),
});
}
}
}
} finally {
await q.close();
}
// Verify message_start exists
const messageStartIndex = events.findIndex(
(e) => e.type === 'message_start',
);
expect(messageStartIndex).toBeGreaterThanOrEqual(0);
// Verify message_stop exists
const messageStopIndex = events.findIndex(
(e) => e.type === 'message_stop',
);
expect(messageStopIndex).toBeGreaterThanOrEqual(0);
// Verify content_block_stop exists (use reverse find for ES compatibility)
const lastContentBlockStopIndex =
events
.map((e, i) => ({ ...e, originalIndex: i }))
.reverse()
.find((e) => e.type === 'content_block_stop')?.originalIndex ?? -1;
expect(lastContentBlockStopIndex).toBeGreaterThanOrEqual(0);
// content_block_stop should be after message_start
expect(lastContentBlockStopIndex).toBeGreaterThan(messageStartIndex);
// content_block_stop should be before message_stop
expect(lastContentBlockStopIndex).toBeLessThan(messageStopIndex);
});
});
});

View file

@ -0,0 +1,18 @@
import type { ScenarioConfig } from '../scenario-runner.js';
export default {
name: 'pr-2371-review',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{
type: '/review https://github.com/QwenLM/qwen-code/pull/2371',
streaming: {
delayMs: 5000,
intervalMs: 10000, // Every 10s
count: 60, // 10 minutes total (60 * 10s)
gif: true,
},
},
],
} satisfies ScenarioConfig;

View file

@ -36,8 +36,8 @@
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'",
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",

View file

@ -58,11 +58,11 @@ import { AcpFileSystemService } from './service/filesystem.js';
import { Readable, Writable } from 'node:stream';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope } from '../config/settings.js';
import type { ApprovalModeValue } from './session/types.js';
import { z } from 'zod';
import type { CliArgs } from '../config/config.js';
import { loadCliConfig } from '../config/config.js';
import { Session } from './session/Session.js';
import type { ApprovalModeValue } from './session/types.js';
import { formatAcpModelId } from '../utils/acpModelUtils.js';
const debugLogger = createDebugLogger('ACP_AGENT');

View file

@ -16,7 +16,7 @@ import type {
ToolCallConfirmationDetails,
ToolResult,
ChatRecord,
SubAgentEventEmitter,
AgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import {
AuthType,
@ -530,7 +530,7 @@ export class Session implements SessionContext {
// Access eventEmitter from TaskTool invocation
const taskEventEmitter = (
invocation as {
eventEmitter: SubAgentEventEmitter;
eventEmitter: AgentEventEmitter;
}
).eventEmitter;
@ -539,7 +539,7 @@ export class Session implements SessionContext {
const subagentType = (args['subagent_type'] as string) ?? '';
// Create a SubAgentTracker for this tool execution
const subAgentTracker = new SubAgentTracker(
const subSubAgentTracker = new SubAgentTracker(
this,
this.client,
parentToolCallId,
@ -547,7 +547,7 @@ export class Session implements SessionContext {
);
// Set up sub-agent tool tracking
subAgentCleanupFunctions = subAgentTracker.setup(
subAgentCleanupFunctions = subSubAgentTracker.setup(
taskEventEmitter,
abortSignal,
);

View file

@ -10,26 +10,26 @@ import type { SessionContext } from './types.js';
import type {
Config,
ToolRegistry,
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentStreamTextEvent,
AgentEventEmitter,
AgentToolCallEvent,
AgentToolResultEvent,
AgentApprovalRequestEvent,
AgentStreamTextEvent,
ToolEditConfirmationDetails,
ToolInfoConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
AgentEventType,
ToolConfirmationOutcome,
TodoWriteTool,
} from '@qwen-code/qwen-code-core';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import { EventEmitter } from 'node:events';
// Helper to create a mock SubAgentToolCallEvent with required fields
// Helper to create a mock AgentToolCallEvent with required fields
function createToolCallEvent(
overrides: Partial<SubAgentToolCallEvent> & { name: string; callId: string },
): SubAgentToolCallEvent {
overrides: Partial<AgentToolCallEvent> & { name: string; callId: string },
): AgentToolCallEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -40,14 +40,14 @@ function createToolCallEvent(
};
}
// Helper to create a mock SubAgentToolResultEvent with required fields
// Helper to create a mock AgentToolResultEvent with required fields
function createToolResultEvent(
overrides: Partial<SubAgentToolResultEvent> & {
overrides: Partial<AgentToolResultEvent> & {
name: string;
callId: string;
success: boolean;
},
): SubAgentToolResultEvent {
): AgentToolResultEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -56,15 +56,15 @@ function createToolResultEvent(
};
}
// Helper to create a mock SubAgentApprovalRequestEvent with required fields
// Helper to create a mock AgentApprovalRequestEvent with required fields
function createApprovalEvent(
overrides: Partial<SubAgentApprovalRequestEvent> & {
overrides: Partial<AgentApprovalRequestEvent> & {
name: string;
callId: string;
confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails'];
respond: SubAgentApprovalRequestEvent['respond'];
confirmationDetails: AgentApprovalRequestEvent['confirmationDetails'];
respond: AgentApprovalRequestEvent['respond'];
},
): SubAgentApprovalRequestEvent {
): AgentApprovalRequestEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -102,10 +102,10 @@ function createInfoConfirmation(
};
}
// Helper to create a mock SubAgentStreamTextEvent with required fields
// Helper to create a mock AgentStreamTextEvent with required fields
function createStreamTextEvent(
overrides: Partial<SubAgentStreamTextEvent> & { text: string },
): SubAgentStreamTextEvent {
overrides: Partial<AgentStreamTextEvent> & { text: string },
): AgentStreamTextEvent {
return {
subagentId: 'test-subagent',
round: 1,
@ -120,7 +120,7 @@ describe('SubAgentTracker', () => {
let sendUpdateSpy: ReturnType<typeof vi.fn>;
let requestPermissionSpy: ReturnType<typeof vi.fn>;
let tracker: SubAgentTracker;
let eventEmitter: SubAgentEventEmitter;
let eventEmitter: AgentEventEmitter;
let abortController: AbortController;
beforeEach(() => {
@ -151,7 +151,7 @@ describe('SubAgentTracker', () => {
'parent-call-123',
'test-subagent',
);
eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter;
eventEmitter = new EventEmitter() as unknown as AgentEventEmitter;
abortController = new AbortController();
});
@ -169,19 +169,19 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(onSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
@ -193,19 +193,19 @@ describe('SubAgentTracker', () => {
cleanups[0]();
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_WAITING_APPROVAL,
expect.any(Function),
);
expect(offSpy).toHaveBeenCalledWith(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
expect.any(Function),
);
});
@ -222,7 +222,7 @@ describe('SubAgentTracker', () => {
description: 'Reading file',
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
// Allow async operations to complete
await vi.waitFor(() => {
@ -258,7 +258,7 @@ describe('SubAgentTracker', () => {
args: { todos: [] },
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
// Give time for any async operation
await new Promise((resolve) => setTimeout(resolve, 10));
@ -276,7 +276,7 @@ describe('SubAgentTracker', () => {
args: {},
});
eventEmitter.emit(SubAgentEventType.TOOL_CALL, event);
eventEmitter.emit(AgentEventType.TOOL_CALL, event);
await new Promise((resolve) => setTimeout(resolve, 10));
@ -290,7 +290,7 @@ describe('SubAgentTracker', () => {
// First emit tool call to store state
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'read_file',
callId: 'call-123',
@ -306,7 +306,7 @@ describe('SubAgentTracker', () => {
resultDisplay: 'File contents',
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
@ -334,7 +334,7 @@ describe('SubAgentTracker', () => {
resultDisplay: undefined,
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith(
@ -356,7 +356,7 @@ describe('SubAgentTracker', () => {
// Store args via tool call
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: TodoWriteTool.Name,
callId: 'call-todo',
@ -377,7 +377,7 @@ describe('SubAgentTracker', () => {
}),
});
eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent);
eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalledWith({
@ -393,7 +393,7 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.TOOL_CALL,
AgentEventType.TOOL_CALL,
createToolCallEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -402,7 +402,7 @@ describe('SubAgentTracker', () => {
);
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -413,7 +413,7 @@ describe('SubAgentTracker', () => {
// Emit another result for same callId - should not have stored args
sendUpdateSpy.mockClear();
eventEmitter.emit(
SubAgentEventType.TOOL_RESULT,
AgentEventType.TOOL_RESULT,
createToolResultEvent({
name: 'test_tool',
callId: 'call-cleanup',
@ -447,7 +447,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
@ -483,7 +483,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(
@ -504,7 +504,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
@ -525,7 +525,7 @@ describe('SubAgentTracker', () => {
respond: respondSpy,
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
@ -548,7 +548,7 @@ describe('SubAgentTracker', () => {
respond: vi.fn(),
});
eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event);
eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event);
await vi.waitFor(() => {
expect(requestPermissionSpy).toHaveBeenCalled();
@ -572,7 +572,7 @@ describe('SubAgentTracker', () => {
text: 'Hello, this is a response from the model.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -593,15 +593,15 @@ describe('SubAgentTracker', () => {
tracker.setup(eventEmitter, abortController.signal);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'First chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Second chunk ' }),
);
eventEmitter.emit(
SubAgentEventType.STREAM_TEXT,
AgentEventType.STREAM_TEXT,
createStreamTextEvent({ text: 'Third chunk' }),
);
@ -640,7 +640,7 @@ describe('SubAgentTracker', () => {
text: 'This should not be emitted',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await new Promise((resolve) => setTimeout(resolve, 10));
@ -655,7 +655,7 @@ describe('SubAgentTracker', () => {
thought: true,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -680,7 +680,7 @@ describe('SubAgentTracker', () => {
thought: false,
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();
@ -705,7 +705,7 @@ describe('SubAgentTracker', () => {
text: 'Default behavior text.',
});
eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event);
eventEmitter.emit(AgentEventType.STREAM_TEXT, event);
await vi.waitFor(() => {
expect(sendUpdateSpy).toHaveBeenCalled();

View file

@ -5,18 +5,18 @@
*/
import type {
SubAgentEventEmitter,
SubAgentToolCallEvent,
SubAgentToolResultEvent,
SubAgentApprovalRequestEvent,
SubAgentUsageEvent,
SubAgentStreamTextEvent,
AgentEventEmitter,
AgentToolCallEvent,
AgentToolResultEvent,
AgentApprovalRequestEvent,
AgentUsageEvent,
AgentStreamTextEvent,
ToolCallConfirmationDetails,
AnyDeclarativeTool,
AnyToolInvocation,
} from '@qwen-code/qwen-code-core';
import {
SubAgentEventType,
AgentEventType,
ToolConfirmationOutcome,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
@ -106,12 +106,12 @@ export class SubAgentTracker {
/**
* Sets up event listeners for a sub-agent's tool events.
*
* @param eventEmitter - The SubAgentEventEmitter from TaskTool
* @param eventEmitter - The AgentEventEmitter from TaskTool
* @param abortSignal - Signal to abort tracking if parent is cancelled
* @returns Array of cleanup functions to remove listeners
*/
setup(
eventEmitter: SubAgentEventEmitter,
eventEmitter: AgentEventEmitter,
abortSignal: AbortSignal,
): Array<() => void> {
const onToolCall = this.createToolCallHandler(abortSignal);
@ -120,19 +120,19 @@ export class SubAgentTracker {
const onUsageMetadata = this.createUsageMetadataHandler(abortSignal);
const onStreamText = this.createStreamTextHandler(abortSignal);
eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText);
eventEmitter.on(AgentEventType.TOOL_CALL, onToolCall);
eventEmitter.on(AgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.on(AgentEventType.STREAM_TEXT, onStreamText);
return [
() => {
eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText);
eventEmitter.off(AgentEventType.TOOL_CALL, onToolCall);
eventEmitter.off(AgentEventType.TOOL_RESULT, onToolResult);
eventEmitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
eventEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata);
eventEmitter.off(AgentEventType.STREAM_TEXT, onStreamText);
// Clean up any remaining states
this.toolStates.clear();
},
@ -146,7 +146,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolCallEvent;
const event = args[0] as AgentToolCallEvent;
if (abortSignal.aborted) return;
// Look up tool and build invocation for metadata
@ -187,7 +187,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentToolResultEvent;
const event = args[0] as AgentToolResultEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
@ -215,7 +215,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => Promise<void> {
return async (...args: unknown[]) => {
const event = args[0] as SubAgentApprovalRequestEvent;
const event = args[0] as AgentApprovalRequestEvent;
if (abortSignal.aborted) return;
const state = this.toolStates.get(event.callId);
@ -292,7 +292,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentUsageEvent;
const event = args[0] as AgentUsageEvent;
if (abortSignal.aborted) return;
this.messageEmitter.emitUsageMetadata(
@ -312,7 +312,7 @@ export class SubAgentTracker {
abortSignal: AbortSignal,
): (...args: unknown[]) => void {
return (...args: unknown[]) => {
const event = args[0] as SubAgentStreamTextEvent;
const event = args[0] as AgentStreamTextEvent;
if (abortSignal.aborted) return;
// Emit streamed text as agent message or thought based on the flag

View file

@ -5,6 +5,7 @@
*/
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SubagentMeta } from '../types.js';
import type { Usage } from '@agentclientprotocol/sdk';
import { BaseEmitter } from './BaseEmitter.js';
@ -77,7 +78,7 @@ export class MessageEmitter extends BaseEmitter {
usageMetadata: GenerateContentResponseUsageMetadata,
text: string = '',
durationMs?: number,
subagentMeta?: import('../types.js').SubagentMeta,
subagentMeta?: SubagentMeta,
): Promise<void> {
const usage: Usage = {
inputTokens: usageMetadata.promptTokenCount ?? 0,

View file

@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule, Argv } from 'yargs';
import {
handleQwenAuth,
runInteractiveAuth,
showAuthStatus,
} from './auth/handler.js';
import { t } from '../i18n/index.js';
// Define subcommands separately
const qwenOauthCommand = {
command: 'qwen-oauth',
describe: t('Authenticate using Qwen OAuth'),
handler: async () => {
await handleQwenAuth('qwen-oauth', {});
},
};
const codePlanCommand = {
command: 'coding-plan',
describe: t('Authenticate using Alibaba Cloud Coding Plan'),
builder: (yargs: Argv) =>
yargs
.option('region', {
alias: 'r',
describe: t('Region for Coding Plan (china/global)'),
type: 'string',
})
.option('key', {
alias: 'k',
describe: t('API key for Coding Plan'),
type: 'string',
}),
handler: async (argv: { region?: string; key?: string }) => {
const region = argv['region'] as string | undefined;
const key = argv['key'] as string | undefined;
// If region and key are provided, use them directly
if (region && key) {
await handleQwenAuth('coding-plan', { region, key });
} else {
// Otherwise, prompt interactively
await handleQwenAuth('coding-plan', {});
}
},
};
const statusCommand = {
command: 'status',
describe: t('Show current authentication status'),
handler: async () => {
await showAuthStatus();
},
};
export const authCommand: CommandModule = {
command: 'auth',
describe: t(
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
),
builder: (yargs: Argv) =>
yargs
.command(qwenOauthCommand)
.command(codePlanCommand)
.command(statusCommand)
.demandCommand(0) // Don't require a subcommand
.version(false),
handler: async () => {
// This handler is for when no subcommand is provided - show interactive menu
await runInteractiveAuth();
},
};

View file

@ -0,0 +1,500 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
AuthType,
getErrorMessage,
type Config,
type ProviderModelConfig as ModelConfig,
} from '@qwen-code/qwen-code-core';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
import { t } from '../../i18n/index.js';
import {
getCodingPlanConfig,
isCodingPlanConfig,
CodingPlanRegion,
CODING_PLAN_ENV_KEY,
} from '../../constants/codingPlan.js';
import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js';
import { backupSettingsFile } from '../../utils/settingsUtils.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
import type { CliArgs } from '../../config/config.js';
import { InteractiveSelector } from './interactiveSelector.js';
interface QwenAuthOptions {
region?: string;
key?: string;
}
interface CodingPlanSettings {
region?: CodingPlanRegion;
version?: string;
}
interface MergedSettingsWithCodingPlan {
security?: {
auth?: {
selectedType?: string;
};
};
codingPlan?: CodingPlanSettings;
model?: {
name?: string;
};
modelProviders?: Record<string, ModelConfig[]>;
env?: Record<string, string>;
}
/**
* Handles the authentication process based on the specified command and options
*/
export async function handleQwenAuth(
command: 'qwen-oauth' | 'coding-plan',
options: QwenAuthOptions,
) {
try {
const settings = loadSettings();
// Create a minimal argv for config loading
const minimalArgv: CliArgs = {
query: undefined,
model: undefined,
sandbox: undefined,
sandboxImage: undefined,
debug: undefined,
prompt: undefined,
promptInteractive: undefined,
yolo: undefined,
approvalMode: undefined,
telemetry: undefined,
checkpointing: undefined,
telemetryTarget: undefined,
telemetryOtlpEndpoint: undefined,
telemetryOtlpProtocol: undefined,
telemetryLogPrompts: undefined,
telemetryOutfile: undefined,
allowedMcpServerNames: undefined,
allowedTools: undefined,
acp: undefined,
experimentalAcp: undefined,
experimentalLsp: undefined,
experimentalHooks: undefined,
extensions: [],
listExtensions: undefined,
openaiLogging: undefined,
openaiApiKey: undefined,
openaiBaseUrl: undefined,
openaiLoggingDir: undefined,
proxy: undefined,
includeDirectories: undefined,
tavilyApiKey: undefined,
googleApiKey: undefined,
googleSearchEngineId: undefined,
webSearchDefault: undefined,
screenReader: undefined,
inputFormat: undefined,
outputFormat: undefined,
includePartialMessages: undefined,
chatRecording: undefined,
continue: undefined,
resume: undefined,
sessionId: undefined,
maxSessionTurns: undefined,
coreTools: undefined,
excludeTools: undefined,
authType: undefined,
channel: undefined,
systemPrompt: undefined,
appendSystemPrompt: undefined,
};
// Create a minimal config to access settings and storage
const config = await loadCliConfig(
settings.merged,
minimalArgv,
process.cwd(),
[], // No extensions for auth command
);
if (command === 'qwen-oauth') {
await handleQwenOAuth(config, settings);
} else if (command === 'coding-plan') {
await handleCodePlanAuth(config, settings, options);
}
// Exit after authentication is complete
writeStdoutLine(t('Authentication completed successfully.'));
process.exit(0);
} catch (error) {
writeStderrLine(getErrorMessage(error));
process.exit(1);
}
}
/**
* Handles Qwen OAuth authentication
*/
async function handleQwenOAuth(
config: Config,
settings: LoadedSettings,
): Promise<void> {
writeStdoutLine(t('Starting Qwen OAuth authentication...'));
try {
await config.refreshAuth(AuthType.QWEN_OAUTH);
// Persist the auth type
const authTypeScope = getPersistScopeForModelSelection(settings);
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.QWEN_OAUTH,
);
writeStdoutLine(t('Successfully authenticated with Qwen OAuth.'));
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Qwen OAuth: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Handles Alibaba Cloud Coding Plan authentication
*/
async function handleCodePlanAuth(
config: Config,
settings: LoadedSettings,
options: QwenAuthOptions,
): Promise<void> {
const { region, key } = options;
let selectedRegion: CodingPlanRegion;
let selectedKey: string;
// If region and key are provided as options, use them
if (region && key) {
selectedRegion =
region.toLowerCase() === 'global'
? CodingPlanRegion.GLOBAL
: CodingPlanRegion.CHINA;
selectedKey = key;
} else {
// Otherwise, prompt interactively
selectedRegion = await promptForRegion();
selectedKey = await promptForKey();
}
writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...'));
try {
// Get configuration based on region
const { template, version } = getCodingPlanConfig(selectedRegion);
// Get persist scope
const authTypeScope = getPersistScopeForModelSelection(settings);
// Backup settings file before modification
const settingsFile = settings.forScope(authTypeScope);
backupSettingsFile(settingsFile.path);
// Store api-key in settings.env (unified env key)
settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey);
// Sync to process.env immediately so refreshAuth can read the apiKey
process.env[CODING_PLAN_ENV_KEY] = selectedKey;
// Generate model configs from template
const newConfigs = template.map((templateConfig) => ({
...templateConfig,
envKey: CODING_PLAN_ENV_KEY,
}));
// Get existing configs
const existingConfigs =
(settings.merged.modelProviders as Record<string, ModelConfig[]>)?.[
AuthType.USE_OPENAI
] || [];
// Filter out all existing Coding Plan configs (mutually exclusive)
const nonCodingPlanConfigs = existingConfigs.filter(
(existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey),
);
// Add new Coding Plan configs at the beginning
const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs];
// Persist to modelProviders
settings.setValue(
authTypeScope,
`modelProviders.${AuthType.USE_OPENAI}`,
updatedConfigs,
);
// Also persist authType
settings.setValue(
authTypeScope,
'security.auth.selectedType',
AuthType.USE_OPENAI,
);
// Persist coding plan region
settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion);
// Persist coding plan version (single field for backward compatibility)
settings.setValue(authTypeScope, 'codingPlan.version', version);
// If there are configs, use the first one as the model
if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) {
settings.setValue(
authTypeScope,
'model.name',
(updatedConfigs[0] as ModelConfig).id,
);
}
// Refresh auth with the new configuration
await config.refreshAuth(AuthType.USE_OPENAI);
writeStdoutLine(
t('Successfully authenticated with Alibaba Cloud Coding Plan.'),
);
} catch (error) {
writeStderrLine(
t('Failed to authenticate with Coding Plan: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}
/**
* Prompts the user to select a region using an interactive selector
*/
async function promptForRegion(): Promise<CodingPlanRegion> {
const selector = new InteractiveSelector(
[
{
value: CodingPlanRegion.CHINA,
label: t('中国 (China)'),
description: t('阿里云百炼 (aliyun.com)'),
},
{
value: CodingPlanRegion.GLOBAL,
label: t('Global'),
description: t('Alibaba Cloud (alibabacloud.com)'),
},
],
t('Select region for Coding Plan:'),
);
return await selector.select();
}
/**
* Prompts the user to enter an API key
*/
async function promptForKey(): Promise<string> {
// Create a simple password-style input (without echoing characters)
const stdin = process.stdin;
const stdout = process.stdout;
stdout.write(t('Enter your Coding Plan API key: '));
// Set raw mode to capture keystrokes
const wasRaw = stdin.isRaw;
if (stdin.setRawMode) {
stdin.setRawMode(true);
}
stdin.resume();
return new Promise<string>((resolve, reject) => {
let input = '';
const onData = (chunk: string) => {
for (const char of chunk) {
switch (char) {
case '\r': // Enter
case '\n':
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('\n'); // New line after input
resolve(input);
return;
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
if (stdin.setRawMode) {
stdin.setRawMode(wasRaw);
}
stdout.write('^C\n');
reject(new Error('Interrupted'));
return;
case '\x08': // Backspace
case '\x7F': // Delete
if (input.length > 0) {
input = input.slice(0, -1);
// Move cursor back, print space, move back again
stdout.write('\x1B[D \x1B[D');
}
break;
default:
// Add character to input
input += char;
// Print asterisk instead of the actual character for security
stdout.write('*');
break;
}
}
};
stdin.on('data', onData);
});
}
/**
* Runs the interactive authentication flow
*/
export async function runInteractiveAuth() {
const selector = new InteractiveSelector(
[
{
value: 'qwen-oauth' as const,
label: t('Qwen OAuth'),
description: t('Free · Up to 1,000 requests/day · Qwen latest models'),
},
{
value: 'coding-plan' as const,
label: t('Alibaba Cloud Coding Plan'),
description: t(
'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models',
),
},
],
t('Select authentication method:'),
);
const choice = await selector.select();
if (choice === 'coding-plan') {
await handleQwenAuth('coding-plan', {});
} else {
await handleQwenAuth('qwen-oauth', {});
}
}
/**
* Shows the current authentication status
*/
export async function showAuthStatus(): Promise<void> {
try {
const settings = loadSettings();
const mergedSettings = settings.merged as MergedSettingsWithCodingPlan;
writeStdoutLine(t('\n=== Authentication Status ===\n'));
// Check for selected auth type
const selectedType = mergedSettings.security?.auth?.selectedType;
if (!selectedType) {
writeStdoutLine(t('⚠️ No authentication method configured.\n'));
writeStdoutLine(t('Run one of the following commands to get started:\n'));
writeStdoutLine(
t(
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
),
);
writeStdoutLine(
t(
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
),
);
writeStdoutLine(t('Or simply run:'));
writeStdoutLine(
t(' qwen auth - Interactive authentication setup\n'),
);
process.exit(0);
}
// Display status based on auth type
if (selectedType === AuthType.QWEN_OAUTH) {
writeStdoutLine(t('✓ Authentication Method: Qwen OAuth'));
writeStdoutLine(t(' Type: Free tier'));
writeStdoutLine(t(' Limit: Up to 1,000 requests/day'));
writeStdoutLine(t(' Models: Qwen latest models\n'));
} else if (selectedType === AuthType.USE_OPENAI) {
// Check for Coding Plan configuration
const codingPlanRegion = mergedSettings.codingPlan?.region;
const codingPlanVersion = mergedSettings.codingPlan?.version;
const modelName = mergedSettings.model?.name;
// Check if API key is set in environment
const hasApiKey =
!!process.env[CODING_PLAN_ENV_KEY] ||
!!mergedSettings.env?.[CODING_PLAN_ENV_KEY];
if (hasApiKey) {
writeStdoutLine(
t('✓ Authentication Method: Alibaba Cloud Coding Plan'),
);
if (codingPlanRegion) {
const regionDisplay =
codingPlanRegion === CodingPlanRegion.CHINA
? t('中国 (China) - 阿里云百炼')
: t('Global - Alibaba Cloud');
writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay }));
}
if (modelName) {
writeStdoutLine(
t(' Current Model: {{model}}', { model: modelName }),
);
}
if (codingPlanVersion) {
writeStdoutLine(
t(' Config Version: {{version}}', {
version: codingPlanVersion.substring(0, 8) + '...',
}),
);
}
writeStdoutLine(t(' Status: API key configured\n'));
} else {
writeStdoutLine(
t(
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
),
);
writeStdoutLine(
t(' Issue: API key not found in environment or settings\n'),
);
writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n'));
}
} else {
writeStdoutLine(
t('✓ Authentication Method: {{type}}', { type: selectedType }),
);
writeStdoutLine(t(' Status: Configured\n'));
}
process.exit(0);
} catch (error) {
writeStderrLine(
t('Failed to check authentication status: {{error}}', {
error: getErrorMessage(error),
}),
);
process.exit(1);
}
}

View file

@ -0,0 +1,421 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InteractiveSelector } from './interactiveSelector.js';
import { stdin, stdout } from 'node:process';
describe('InteractiveSelector', () => {
const mockOptions = [
{ value: 'option1', label: 'Option 1', description: 'First option' },
{ value: 'option2', label: 'Option 2', description: 'Second option' },
{ value: 'option3', label: 'Option 3', description: 'Third option' },
];
const mockPrompt = 'Select an option:';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('constructor', () => {
it('should create an instance with default prompt', () => {
const selector = new InteractiveSelector(mockOptions);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
it('should create an instance with custom prompt', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
expect(selector).toBeInstanceOf(InteractiveSelector);
});
});
describe('select', () => {
it('should reject if raw mode is not available', async () => {
// Mock stdin without setRawMode
const originalSetRawMode = stdin.setRawMode;
(stdin as any).setRawMode = undefined;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
await expect(selector.select()).rejects.toThrow(
'Raw mode not available. Please run in an interactive terminal.',
);
// Restore
(stdin as any).setRawMode = originalSetRawMode;
});
it('should select first option with Enter key', async () => {
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockSetEncoding = vi.fn();
const mockRemoveListener = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
// Simulate Enter key press
setTimeout(() => callback('\r'), 0);
return stdin;
});
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).setEncoding = mockSetEncoding;
(stdin as any).removeListener = mockRemoveListener;
(stdin as any).on = mockOn;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const result = await selector.select();
expect(result).toBe('option1');
expect(mockSetRawMode).toHaveBeenCalledWith(true);
expect(mockResume).toHaveBeenCalled();
stdoutWriteSpy.mockRestore();
});
it('should select second option after arrow down then Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate arrow down
dataCallback('\x1B[B');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should handle arrow up navigation', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down twice
dataCallback('\x1B[B');
dataCallback('\x1B[B');
// Move up once
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option2');
stdoutWriteSpy.mockRestore();
});
it('should reject with Ctrl+C', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate Ctrl+C
setTimeout(() => dataCallback('\x03'), 0);
await expect(selectPromise).rejects.toThrow('Interrupted');
});
it('should wrap around when navigating past last option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move down past last option (should wrap to first)
dataCallback('\x1B[B');
dataCallback('\x1B[B');
dataCallback('\x1B[B'); // Now at option1 again (wrapped)
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should wrap around when navigating before first option', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Move up from first option (should wrap to last)
dataCallback('\x1B[A');
// Simulate Enter
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option3');
stdoutWriteSpy.mockRestore();
});
it('should ignore arrow left/right keys', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Press arrow right (should be ignored)
dataCallback('\x1B[C');
// Press arrow left (should be ignored)
dataCallback('\x1B[D');
// Press Enter - should still select first option
setTimeout(() => dataCallback('\r'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
it('should handle newline character as Enter', async () => {
let dataCallback!: (chunk: string) => void;
const mockSetRawMode = vi.fn();
const mockResume = vi.fn();
const mockOn = vi.fn((event: any, callback: any) => {
dataCallback = callback;
return stdin;
});
const mockRemoveListener = vi.fn();
(stdin as any).isRaw = false;
(stdin as any).setRawMode = mockSetRawMode;
(stdin as any).resume = mockResume;
(stdin as any).on = mockOn;
(stdin as any).removeListener = mockRemoveListener;
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
const selectPromise = selector.select();
// Simulate newline
setTimeout(() => dataCallback('\n'), 0);
const result = await selectPromise;
expect(result).toBe('option1');
stdoutWriteSpy.mockRestore();
});
});
describe('renderMenu', () => {
it('should render menu with correct formatting', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).renderMenu();
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('Select an option:');
expect(output).toContain('Option 1');
expect(output).toContain('Option 2');
expect(output).toContain('Option 3');
expect(output).toContain('First option');
expect(output).toContain('Second option');
expect(output).toContain('Third option');
expect(output).toContain('↑ ↓');
expect(output).toContain('Enter');
expect(output).toContain('Ctrl+C');
stdoutWriteSpy.mockRestore();
});
it('should highlight selected option', () => {
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(mockOptions, mockPrompt);
(selector as any).selectedIndex = 1;
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
// Selected option should have cyan color code
expect(output).toContain('\x1B[36m');
stdoutWriteSpy.mockRestore();
});
it('should calculate correct total lines', () => {
const selector = new InteractiveSelector(mockOptions, mockPrompt);
// Access private method for testing
(selector as any).calculateTotalLines();
// Expected: 4 (prompt + empty + empty + instructions) + 3 (options) = 7
expect((selector as any).calculateTotalLines()).toBe(7);
});
it('should handle options without descriptions', () => {
const simpleOptions = [
{ value: 'a', label: 'A' },
{ value: 'b', label: 'B' },
];
const stdoutWriteSpy = vi
.spyOn(stdout, 'write')
.mockImplementation(() => true);
const selector = new InteractiveSelector(simpleOptions, mockPrompt);
(selector as any).renderMenu();
const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join('');
expect(output).toContain('A');
expect(output).toContain('B');
stdoutWriteSpy.mockRestore();
});
});
});

View file

@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { stdin, stdout } from 'node:process';
import { t } from '../../i18n/index.js';
/**
* Represents an option in the interactive selector
*/
interface Option<T> {
value: T;
label: string;
description?: string;
}
/**
* Interactive selector that allows users to navigate with arrow keys
*/
export class InteractiveSelector<T> {
private selectedIndex = 0;
private isListening = false;
constructor(
private options: Array<Option<T>>,
private prompt: string = t('Select an option:'),
) {}
/**
* Shows the interactive menu and waits for user selection
*/
async select(): Promise<T> {
return new Promise((resolve, reject) => {
this.isListening = true;
// Display initial menu
this.renderMenu();
// Check if stdin supports raw mode
if (!stdin.setRawMode) {
// Fallback to readline if raw mode is not available (e.g., when piped)
reject(
new Error(
t('Raw mode not available. Please run in an interactive terminal.'),
),
);
return;
}
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
const onData = (chunk: string) => {
if (!this.isListening) return;
for (const char of chunk) {
switch (char) {
case '\x03': // Ctrl+C
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
reject(new Error('Interrupted'));
return;
case '\r': // Enter
case '\n': // Newline
stdin.removeListener('data', onData);
stdin.setRawMode(wasRaw);
resolve(this.options[this.selectedIndex].value);
return;
case '\x1B': // ESC sequence
// Next character will be [, then A, B, C, or D
break;
default:
// Handle other characters if needed
break;
}
}
// Handle escape sequences
if (chunk.startsWith('\x1B')) {
if (chunk === '\x1B[A') {
// Arrow up
this.moveUp();
} else if (chunk === '\x1B[B') {
// Arrow down
this.moveDown();
} else if (chunk === '\x1B[C') {
// Arrow right
// Do nothing for now
} else if (chunk === '\x1B[D') {
// Arrow left
// Do nothing for now
}
}
};
stdin.on('data', onData);
});
}
/**
* Renders the menu to stdout
*/
private renderMenu(): void {
// Calculate how many lines we need to clear
const totalLines = this.calculateTotalLines();
// Clear the screen area we'll be using
if (totalLines > 0) {
stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down
}
// Write the prompt
stdout.write(`${this.prompt}\n\n`);
// Write each option - combine label and description on same line
this.options.forEach((option, index) => {
const isSelected = index === this.selectedIndex;
const indicator = isSelected ? '> ' : ' ';
const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others
const reset = '\x1B[0m';
// Combine label and description in one line
let line = `${indicator}${color}${option.label}`;
if (option.description) {
line += ` - ${option.description}`;
}
line += `${reset}\n`;
stdout.write(line);
});
// Add instructions
stdout.write(
`\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`,
);
}
/**
* Calculates the total number of lines to clear
*/
private calculateTotalLines(): number {
// Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1)
return 4 + this.options.length;
}
/**
* Moves selection up
*/
private moveUp(): void {
this.selectedIndex =
(this.selectedIndex - 1 + this.options.length) % this.options.length;
this.renderMenu();
}
/**
* Moves selection down
*/
private moveDown(): void {
this.selectedIndex = (this.selectedIndex + 1) % this.options.length;
this.renderMenu();
}
}

View file

@ -0,0 +1,266 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { showAuthStatus } from './handler.js';
import { AuthType } from '@qwen-code/qwen-code-core';
import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js';
import type { LoadedSettings } from '../../config/settings.js';
vi.mock('../../config/settings.js', () => ({
loadSettings: vi.fn(),
}));
vi.mock('../../utils/stdioHelpers.js', () => ({
writeStdoutLine: vi.fn(),
writeStderrLine: vi.fn(),
}));
import { loadSettings } from '../../config/settings.js';
import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js';
describe('showAuthStatus', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never);
delete process.env[CODING_PLAN_ENV_KEY];
});
afterEach(() => {
vi.restoreAllMocks();
delete process.env[CODING_PLAN_ENV_KEY];
});
const createMockSettings = (
merged: Record<string, unknown>,
): LoadedSettings =>
({
merged,
system: { settings: {}, path: '/system.json' },
systemDefaults: { settings: {}, path: '/system-defaults.json' },
user: { settings: {}, path: '/user.json' },
workspace: { settings: {}, path: '/workspace.json' },
forScope: vi.fn(),
setValue: vi.fn(),
isTrusted: true,
}) as unknown as LoadedSettings;
it('should show message when no authentication is configured', async () => {
vi.mocked(loadSettings).mockReturnValue(createMockSettings({}));
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('No authentication method configured'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth qwen-oauth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen auth coding-plan'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Qwen OAuth status when configured', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.QWEN_OAUTH,
},
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Qwen OAuth'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Free tier'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('1,000 requests/day'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan status when configured with API key', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'abc123def456',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Alibaba Cloud Coding Plan'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key configured'),
);
expect(process.exit).toHaveBeenCalledWith(0);
});
it('should show Coding Plan as incomplete when API key is missing', async () => {
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'global',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Incomplete'),
);
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('API key not found'),
);
});
it('should show Coding Plan region for china', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('中国 (China)'),
);
});
it('should show Coding Plan region for global', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'global',
},
model: {
name: 'qwen3-coder-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('Global'),
);
});
it('should show current model name', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('qwen3.5-plus'),
);
});
it('should show config version (truncated)', async () => {
process.env[CODING_PLAN_ENV_KEY] = 'test-api-key';
vi.mocked(loadSettings).mockReturnValue(
createMockSettings({
security: {
auth: {
selectedType: AuthType.USE_OPENAI,
},
},
codingPlan: {
region: 'china',
version: 'abc123def456789',
},
model: {
name: 'qwen3.5-plus',
},
}),
);
await showAuthStatus();
expect(writeStdoutLine).toHaveBeenCalledWith(
expect.stringContaining('abc123de...'),
);
});
it('should handle errors and exit with code 1', async () => {
const error = new Error('Settings load failed');
vi.mocked(loadSettings).mockImplementation(() => {
throw error;
});
await showAuthStatus();
expect(writeStderrLine).toHaveBeenCalledWith(
expect.stringContaining('Failed to check authentication status'),
);
expect(process.exit).toHaveBeenCalledWith(1);
});
});

View file

@ -33,6 +33,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { extensionsCommand } from '../commands/extensions.js';
import { hooksCommand } from '../commands/hooks.js';
import { authCommand } from '../commands/auth.js';
import type { Settings } from './settings.js';
import {
resolveCliGenerationConfig,
@ -51,16 +52,16 @@ import { appEvents } from '../utils/events.js';
import { mcpCommand } from '../commands/mcp.js';
// UUID v4 regex pattern for validation
const UUID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SESSION_ID_REGEX =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i;
/**
* Validates if a string is a valid UUID format
* @param value - The string to validate
* @returns True if the string is a valid UUID, false otherwise
* Validates if a string is a valid session ID format.
* Accepts a standard UUID, or a UUID followed by `-agent-{suffix}`
* (used by Arena to give each agent a deterministic session ID).
*/
function isValidUUID(value: string): boolean {
return UUID_REGEX.test(value);
function isValidSessionId(value: string): boolean {
return SESSION_ID_REGEX.test(value);
}
import { isWorkspaceTrusted } from './trustedFolders.js';
@ -568,10 +569,13 @@ export async function parseArguments(): Promise<CliArgs> {
if (argv['sessionId'] && (argv['continue'] || argv['resume'])) {
return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.';
}
if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) {
if (
argv['sessionId'] &&
!isValidSessionId(argv['sessionId'] as string)
) {
return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
if (argv['resume'] && !isValidUUID(argv['resume'] as string)) {
if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) {
return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`;
}
return true;
@ -581,6 +585,8 @@ export async function parseArguments(): Promise<CliArgs> {
.command(mcpCommand)
// Register Extension subcommands
.command(extensionsCommand)
// Register Auth subcommands
.command(authCommand)
// Register Hooks subcommands
.command(hooksCommand);
@ -1058,6 +1064,18 @@ export async function loadCliConfig(
lsp: {
enabled: lspEnabled,
},
agents: settings.agents
? {
displayMode: settings.agents.displayMode,
arena: settings.agents.arena
? {
worktreeBaseDir: settings.agents.arena.worktreeBaseDir,
preserveArtifacts:
settings.agents.arena.preserveArtifacts ?? false,
}
: undefined,
}
: undefined,
});
if (lspEnabled) {

View file

@ -1244,6 +1244,104 @@ const SETTINGS_SCHEMA = {
description: 'Configuration for web search providers.',
showInDialog: false,
},
agents: {
type: 'object',
label: 'Agents',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for multi-agent collaboration features (Arena, Team, Swarm).',
showInDialog: false,
properties: {
displayMode: {
type: 'enum',
label: 'Display Mode',
category: 'Advanced',
requiresRestart: false,
default: undefined as string | undefined,
description:
'Display mode for multi-agent sessions. Currently only "in-process" is supported.',
showInDialog: false,
options: [
{ value: 'in-process', label: 'In-process' },
// { value: 'tmux', label: 'tmux' },
// { value: 'iterm2', label: 'iTerm2' },
],
},
arena: {
type: 'object',
label: 'Arena',
category: 'Advanced',
requiresRestart: false,
default: {},
description: 'Settings for Arena (multi-model competitive execution).',
showInDialog: false,
properties: {
worktreeBaseDir: {
type: 'string',
label: 'Worktree Base Directory',
category: 'Advanced',
requiresRestart: true,
default: undefined as string | undefined,
description:
'Custom base directory for Arena worktrees. Defaults to ~/.qwen/arena.',
showInDialog: false,
},
preserveArtifacts: {
type: 'boolean',
label: 'Preserve Arena Artifacts',
category: 'Advanced',
requiresRestart: false,
default: false,
description:
'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.',
showInDialog: true,
},
maxRoundsPerAgent: {
type: 'number',
label: 'Max Rounds Per Agent',
category: 'Advanced',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Maximum number of rounds (turns) each agent can execute. No limit if unset.',
showInDialog: false,
},
timeoutSeconds: {
type: 'number',
label: 'Timeout (seconds)',
category: 'Advanced',
requiresRestart: false,
default: undefined as number | undefined,
description:
'Total timeout in seconds for the Arena session. No limit if unset.',
showInDialog: false,
},
},
},
team: {
type: 'object',
label: 'Team',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for Agent Team (role-based collaborative execution). Reserved for future use.',
showInDialog: false,
},
swarm: {
type: 'object',
label: 'Swarm',
category: 'Advanced',
requiresRestart: false,
default: {},
description:
'Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.',
showInDialog: false,
},
},
},
hooksConfig: {
type: 'object',
@ -1418,6 +1516,17 @@ const SETTINGS_SCHEMA = {
},
},
},
experimental: {
type: 'object',
label: 'Experimental',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {},
},
} as const satisfies SettingsSchema;
export type SettingsSchemaType = typeof SETTINGS_SCHEMA;

View file

@ -97,7 +97,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{
@ -222,7 +222,7 @@ export function generateCodingPlanTemplate(
extra_body: {
enable_thinking: true,
},
contextWindowSize: 1000000,
contextWindowSize: 196608,
},
},
{

View file

@ -35,6 +35,7 @@ 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 { AgentViewProvider } from './ui/contexts/AgentViewContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { themeManager } from './ui/themes/theme-manager.js';
import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js';
@ -162,13 +163,15 @@ export async function startInteractiveUI(
>
<SessionStatsProvider sessionId={config.getSessionId()}>
<VimModeProvider settings={settings}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
<AgentViewProvider config={config}>
<AppContainer
config={config}
settings={settings}
startupWarnings={startupWarnings}
version={version}
initializationResult={initializationResult}
/>
</AgentViewProvider>
</VimModeProvider>
</SessionStatsProvider>
</KeypressProvider>

View file

@ -1620,6 +1620,36 @@ export default {
'Neue Modellkonfigurationen sind für {{region}} verfügbar. Jetzt aktualisieren?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Kontextnutzung',
'No API response yet. Send a message to see actual usage.':
'Noch keine API-Antwort. Senden Sie eine Nachricht, um die tatsächliche Nutzung anzuzeigen.',
'Estimated pre-conversation overhead':
'Geschätzte Vorabkosten vor der Unterhaltung',
'Context window': 'Kontextfenster',
tokens: 'Tokens',
Used: 'Verwendet',
Free: 'Frei',
'Autocompact buffer': 'Autokomprimierungs-Puffer',
'Usage by category': 'Verwendung nach Kategorie',
'System prompt': 'System-Prompt',
'Built-in tools': 'Integrierte Tools',
'MCP tools': 'MCP-Tools',
'Memory files': 'Speicherdateien',
Skills: 'Fähigkeiten',
Messages: 'Nachrichten',
'Show context window usage breakdown.':
'Zeigt die Aufschlüsselung der Kontextfenster-Nutzung an.',
'Run /context detail for per-item breakdown.':
'Führen Sie /context detail für eine Aufschlüsselung nach Elementen aus.',
active: 'aktiv',
'body loaded': 'Inhalt geladen',
memory: 'Speicher',
'{{region}} configuration updated successfully.':
'{{region}}-Konfiguration erfolgreich aktualisiert.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1655,4 +1685,80 @@ export default {
'↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Qwen-Authentifizierung mit Qwen-OAuth oder Alibaba Cloud Coding Plan konfigurieren',
'Authenticate using Qwen OAuth': 'Mit Qwen OAuth authentifizieren',
'Authenticate using Alibaba Cloud Coding Plan':
'Mit Alibaba Cloud Coding Plan authentifizieren',
'Region for Coding Plan (china/global)':
'Region für Coding Plan (china/global)',
'API key for Coding Plan': 'API-Schlüssel für Coding Plan',
'Show current authentication status':
'Aktuellen Authentifizierungsstatus anzeigen',
'Authentication completed successfully.':
'Authentifizierung erfolgreich abgeschlossen.',
'Starting Qwen OAuth authentication...':
'Qwen OAuth-Authentifizierung wird gestartet...',
'Successfully authenticated with Qwen OAuth.':
'Erfolgreich mit Qwen OAuth authentifiziert.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Authentifizierung mit Qwen OAuth fehlgeschlagen: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Alibaba Cloud Coding Plan-Authentifizierung wird verarbeitet...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Erfolgreich mit Alibaba Cloud Coding Plan authentifiziert.',
'Failed to authenticate with Coding Plan: {{error}}':
'Authentifizierung mit Coding Plan fehlgeschlagen: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Region für Coding Plan auswählen:',
'Enter your Coding Plan API key: ':
'Geben Sie Ihren Coding Plan API-Schlüssel ein: ',
'Select authentication method:': 'Authentifizierungsmethode auswählen:',
'\n=== Authentication Status ===\n': '\n=== Authentifizierungsstatus ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Keine Authentifizierungsmethode konfiguriert.\n',
'Run one of the following commands to get started:\n':
'Führen Sie einen der folgenden Befehle aus, um zu beginnen:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Mit Qwen OAuth authentifizieren (kostenlos)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n',
'Or simply run:': 'Oder einfach ausführen:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Interaktive Authentifizierungseinrichtung\n',
'✓ Authentication Method: Qwen OAuth':
'✓ Authentifizierungsmethode: Qwen OAuth',
' Type: Free tier': ' Typ: Kostenlos',
' Limit: Up to 1,000 requests/day': ' Limit: Bis zu 1.000 Anfragen/Tag',
' Models: Qwen latest models\n': ' Modelle: Qwen neueste Modelle\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Authentifizierungsmethode: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Region: {{region}}',
' Current Model: {{model}}': ' Aktuelles Modell: {{model}}',
' Config Version: {{version}}': ' Konfigurationsversion: {{version}}',
' Status: API key configured\n': ' Status: API-Schlüssel konfiguriert\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Authentifizierungsmethode: Alibaba Cloud Coding Plan (Unvollständig)',
' Issue: API key not found in environment or settings\n':
' Problem: API-Schlüssel nicht in Umgebung oder Einstellungen gefunden\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Führen Sie `qwen auth coding-plan` aus, um neu zu konfigurieren.\n',
'✓ Authentication Method: {{type}}': '✓ Authentifizierungsmethode: {{type}}',
' Status: Configured\n': ' Status: Konfiguriert\n',
'Failed to check authentication status: {{error}}':
'Authentifizierungsstatus konnte nicht überprüft werden: {{error}}',
'Select an option:': 'Option auswählen:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n',
};

View file

@ -1672,6 +1672,34 @@ export default {
'New model configurations are available for {{region}}. Update now?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Context Usage',
'No API response yet. Send a message to see actual usage.':
'No API response yet. Send a message to see actual usage.',
'Estimated pre-conversation overhead': 'Estimated pre-conversation overhead',
'Context window': 'Context window',
tokens: 'tokens',
Used: 'Used',
Free: 'Free',
'Autocompact buffer': 'Autocompact buffer',
'Usage by category': 'Usage by category',
'System prompt': 'System prompt',
'Built-in tools': 'Built-in tools',
'MCP tools': 'MCP tools',
'Memory files': 'Memory files',
Skills: 'Skills',
Messages: 'Messages',
'Show context window usage breakdown.':
'Show context window usage breakdown.',
'Run /context detail for per-item breakdown.':
'Run /context detail for per-item breakdown.',
'body loaded': 'body loaded',
memory: 'memory',
'{{region}} configuration updated successfully.':
'{{region}} configuration updated successfully.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1706,4 +1734,77 @@ export default {
'↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navigate | Enter: Select | Esc: Cancel',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Authenticate using Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Authenticate using Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Region for Coding Plan (china/global)',
'API key for Coding Plan': 'API key for Coding Plan',
'Show current authentication status': 'Show current authentication status',
'Authentication completed successfully.':
'Authentication completed successfully.',
'Starting Qwen OAuth authentication...':
'Starting Qwen OAuth authentication...',
'Successfully authenticated with Qwen OAuth.':
'Successfully authenticated with Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Failed to authenticate with Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Processing Alibaba Cloud Coding Plan authentication...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Successfully authenticated with Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Failed to authenticate with Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Select region for Coding Plan:',
'Enter your Coding Plan API key: ': 'Enter your Coding Plan API key: ',
'Select authentication method:': 'Select authentication method:',
'\n=== Authentication Status ===\n': '\n=== Authentication Status ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ No authentication method configured.\n',
'Run one of the following commands to get started:\n':
'Run one of the following commands to get started:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Or simply run:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Interactive authentication setup\n',
'✓ Authentication Method: Qwen OAuth': '✓ Authentication Method: Qwen OAuth',
' Type: Free tier': ' Type: Free tier',
' Limit: Up to 1,000 requests/day': ' Limit: Up to 1,000 requests/day',
' Models: Qwen latest models\n': ' Models: Qwen latest models\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Authentication Method: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Region: {{region}}',
' Current Model: {{model}}': ' Current Model: {{model}}',
' Config Version: {{version}}': ' Config Version: {{version}}',
' Status: API key configured\n': ' Status: API key configured\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)',
' Issue: API key not found in environment or settings\n':
' Issue: API key not found in environment or settings\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Run `qwen auth coding-plan` to re-configure.\n',
'✓ Authentication Method: {{type}}': '✓ Authentication Method: {{type}}',
' Status: Configured\n': ' Status: Configured\n',
'Failed to check authentication status: {{error}}':
'Failed to check authentication status: {{error}}',
'Select an option:': 'Select an option:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw mode not available. Please run in an interactive terminal.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n',
};

View file

@ -1126,6 +1126,35 @@ export default {
'{{region}} の新しいモデル設定が利用可能です。今すぐ更新しますか?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'{{region}} での認証に成功しました。API キーとモデル設定が settings.json に保存されました(バックアップ済み)。',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'コンテキスト使用量',
'No API response yet. Send a message to see actual usage.':
'API応答はありません。メッセージを送信して実際の使用量を確認してください。',
'Estimated pre-conversation overhead': '推定事前会話オーバーヘッド',
'Context window': 'コンテキストウィンドウ',
tokens: 'トークン',
Used: '使用済み',
Free: '空き',
'Autocompact buffer': '自動圧縮バッファ',
'Usage by category': 'カテゴリ別の使用量',
'System prompt': 'システムプロンプト',
'Built-in tools': '組み込みツール',
'MCP tools': 'MCPツール',
'Memory files': 'メモリファイル',
Skills: 'スキル',
Messages: 'メッセージ',
'Show context window usage breakdown.':
'コンテキストウィンドウの使用状況を表示します。',
'Run /context detail for per-item breakdown.':
'/context detail を実行すると項目ごとの内訳を表示します。',
active: '有効',
'body loaded': '本文読み込み済み',
memory: 'メモリ',
'{{region}} configuration updated successfully.':
'{{region}} の設定が正常に更新されました。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1159,4 +1188,76 @@ export default {
'↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Qwen-OAuth または Alibaba Cloud Coding Plan で Qwen 認証情報を設定する',
'Authenticate using Qwen OAuth': 'Qwen OAuth で認証する',
'Authenticate using Alibaba Cloud Coding Plan':
'Alibaba Cloud Coding Plan で認証する',
'Region for Coding Plan (china/global)':
'Coding Plan のリージョン (china/global)',
'API key for Coding Plan': 'Coding Plan の API キー',
'Show current authentication status': '現在の認証ステータスを表示',
'Authentication completed successfully.': '認証が正常に完了しました。',
'Starting Qwen OAuth authentication...': 'Qwen OAuth 認証を開始しています...',
'Successfully authenticated with Qwen OAuth.':
'Qwen OAuth での認証に成功しました。',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Qwen OAuth での認証に失敗しました: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Alibaba Cloud Coding Plan 認証を処理しています...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Alibaba Cloud Coding Plan での認証に成功しました。',
'Failed to authenticate with Coding Plan: {{error}}':
'Coding Plan での認証に失敗しました: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'グローバル',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Coding Plan のリージョンを選択:',
'Enter your Coding Plan API key: ':
'Coding Plan の API キーを入力してください: ',
'Select authentication method:': '認証方法を選択:',
'\n=== Authentication Status ===\n': '\n=== 認証ステータス ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ 認証方法が設定されていません。\n',
'Run one of the following commands to get started:\n':
'以下のコマンドのいずれかを実行して開始してください:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Qwen OAuth で認証(無料)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Alibaba Cloud Coding Plan で認証\n',
'Or simply run:': 'または以下を実行:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - インタラクティブ認証セットアップ\n',
'✓ Authentication Method: Qwen OAuth': '✓ 認証方法: Qwen OAuth',
' Type: Free tier': ' タイプ: 無料プラン',
' Limit: Up to 1,000 requests/day': ' 制限: 1日最大1,000リクエスト',
' Models: Qwen latest models\n': ' モデル: Qwen 最新モデル\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ 認証方法: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'グローバル - Alibaba Cloud',
' Region: {{region}}': ' リージョン: {{region}}',
' Current Model: {{model}}': ' 現在のモデル: {{model}}',
' Config Version: {{version}}': ' 設定バージョン: {{version}}',
' Status: API key configured\n': ' ステータス: APIキー設定済み\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ 認証方法: Alibaba Cloud Coding Plan不完全',
' Issue: API key not found in environment or settings\n':
' 問題: 環境変数または設定にAPIキーが見つかりません\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' `qwen auth coding-plan` を実行して再設定してください。\n',
'✓ Authentication Method: {{type}}': '✓ 認証方法: {{type}}',
' Status: Configured\n': ' ステータス: 設定済み\n',
'Failed to check authentication status: {{error}}':
'認証ステータスの確認に失敗しました: {{error}}',
'Select an option:': 'オプションを選択:',
'Raw mode not available. Please run in an interactive terminal.':
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
};

View file

@ -1615,6 +1615,35 @@ export default {
'Novas configurações de modelo estão disponíveis para o {{region}}. Atualizar agora?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Uso do Contexto',
'No API response yet. Send a message to see actual usage.':
'Ainda não há resposta da API. Envie uma mensagem para ver o uso real.',
'Estimated pre-conversation overhead': 'Sobrecarga estimada pré-conversa',
'Context window': 'Janela de Contexto',
tokens: 'tokens',
Used: 'Usado',
Free: 'Livre',
'Autocompact buffer': 'Buffer de autocompactação',
'Usage by category': 'Uso por categoria',
'System prompt': 'Prompt do sistema',
'Built-in tools': 'Ferramentas integradas',
'MCP tools': 'Ferramentas MCP',
'Memory files': 'Arquivos de memória',
Skills: 'Habilidades',
Messages: 'Mensagens',
'Show context window usage breakdown.':
'Exibe a divisão de uso da janela de contexto.',
'Run /context detail for per-item breakdown.':
'Execute /context detail para detalhamento por item.',
active: 'ativo',
'body loaded': 'conteúdo carregado',
memory: 'memória',
'{{region}} configuration updated successfully.':
'Configuração do {{region}} atualizada com sucesso.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
@ -1650,4 +1679,78 @@ export default {
'↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Configurar autenticação Qwen com Qwen-OAuth ou Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Autenticar usando Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Autenticar usando Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Região para Coding Plan (china/global)',
'API key for Coding Plan': 'Chave de API para Coding Plan',
'Show current authentication status': 'Mostrar status atual de autenticação',
'Authentication completed successfully.':
'Autenticação concluída com sucesso.',
'Starting Qwen OAuth authentication...':
'Iniciando autenticação Qwen OAuth...',
'Successfully authenticated with Qwen OAuth.':
'Autenticado com sucesso via Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Falha ao autenticar com Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Processando autenticação Alibaba Cloud Coding Plan...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Autenticado com sucesso via Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Falha ao autenticar com Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Global',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Selecione a região para Coding Plan:',
'Enter your Coding Plan API key: ':
'Insira sua chave de API do Coding Plan: ',
'Select authentication method:': 'Selecione o método de autenticação:',
'\n=== Authentication Status ===\n': '\n=== Status de Autenticação ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Nenhum método de autenticação configurado.\n',
'Run one of the following commands to get started:\n':
'Execute um dos seguintes comandos para começar:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Autenticar com Qwen OAuth (gratuito)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Autenticar com Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Ou simplesmente execute:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Configuração interativa de autenticação\n',
'✓ Authentication Method: Qwen OAuth': '✓ Método de autenticação: Qwen OAuth',
' Type: Free tier': ' Tipo: Gratuito',
' Limit: Up to 1,000 requests/day': ' Limite: Até 1.000 solicitações/dia',
' Models: Qwen latest models\n': ' Modelos: Modelos Qwen mais recentes\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Método de autenticação: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Global - Alibaba Cloud',
' Region: {{region}}': ' Região: {{region}}',
' Current Model: {{model}}': ' Modelo atual: {{model}}',
' Config Version: {{version}}': ' Versão da configuração: {{version}}',
' Status: API key configured\n': ' Status: Chave de API configurada\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Método de autenticação: Alibaba Cloud Coding Plan (Incompleto)',
' Issue: API key not found in environment or settings\n':
' Problema: Chave de API não encontrada no ambiente ou configurações\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Execute `qwen auth coding-plan` para reconfigurar.\n',
'✓ Authentication Method: {{type}}': '✓ Método de autenticação: {{type}}',
' Status: Configured\n': ' Status: Configurado\n',
'Failed to check authentication status: {{error}}':
'Falha ao verificar status de autenticação: {{error}}',
'Select an option:': 'Selecione uma opção:',
'Raw mode not available. Please run in an interactive terminal.':
'Modo raw não disponível. Execute em um terminal interativo.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n',
};

View file

@ -1553,6 +1553,32 @@ export default {
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
// ============================================================================
// Context Usage Component
// ============================================================================
'Context Usage': 'Использование контекста',
'No API response yet. Send a message to see actual usage.':
'Пока нет ответа от API. Отправьте сообщение, чтобы увидеть фактическое использование.',
'Estimated pre-conversation overhead':
'Оценочные накладные расходы перед беседой',
'Context window': 'Контекстное окно',
tokens: 'токенов',
Used: 'Использовано',
Free: 'Свободно',
'Autocompact buffer': 'Буфер автоупаковки',
'Usage by category': 'Использование по категориям',
'System prompt': 'Системная подсказка',
'Built-in tools': 'Встроенные инструменты',
'MCP tools': 'Инструменты MCP',
'Memory files': 'Файлы памяти',
Skills: 'Навыки',
Messages: 'Сообщения',
'Show context window usage breakdown.':
'Показать разбивку использования контекстного окна.',
'Run /context detail for per-item breakdown.':
'Выполните /context detail для детализации по элементам.',
active: 'активно',
'body loaded': 'содержимое загружено',
memory: 'память',
// MCP Management Dialog
// ============================================================================
'MCP Management': 'Управление MCP',
@ -1662,4 +1688,77 @@ export default {
'↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: Навигация | Enter: Выбор | Esc: Отмена',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'Настроить аутентификацию Qwen через Qwen-OAuth или Alibaba Cloud Coding Plan',
'Authenticate using Qwen OAuth': 'Аутентификация через Qwen OAuth',
'Authenticate using Alibaba Cloud Coding Plan':
'Аутентификация через Alibaba Cloud Coding Plan',
'Region for Coding Plan (china/global)':
'Регион для Coding Plan (china/global)',
'API key for Coding Plan': 'API-ключ для Coding Plan',
'Show current authentication status':
'Показать текущий статус аутентификации',
'Authentication completed successfully.': 'Аутентификация успешно завершена.',
'Starting Qwen OAuth authentication...':
'Запуск аутентификации Qwen OAuth...',
'Successfully authenticated with Qwen OAuth.':
'Успешная аутентификация через Qwen OAuth.',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Ошибка аутентификации через Qwen OAuth: {{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'Обработка аутентификации Alibaba Cloud Coding Plan...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'Успешная аутентификация через Alibaba Cloud Coding Plan.',
'Failed to authenticate with Coding Plan: {{error}}':
'Ошибка аутентификации через Coding Plan: {{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: 'Глобальный',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': 'Выберите регион для Coding Plan:',
'Enter your Coding Plan API key: ': 'Введите ваш API-ключ Coding Plan: ',
'Select authentication method:': 'Выберите метод аутентификации:',
'\n=== Authentication Status ===\n': '\n=== Статус аутентификации ===\n',
'⚠️ No authentication method configured.\n':
'⚠️ Метод аутентификации не настроен.\n',
'Run one of the following commands to get started:\n':
'Выполните одну из следующих команд для начала:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - Аутентификация через Qwen OAuth (бесплатно)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - Аутентификация через Alibaba Cloud Coding Plan\n',
'Or simply run:': 'Или просто выполните:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - Интерактивная настройка аутентификации\n',
'✓ Authentication Method: Qwen OAuth': '✓ Метод аутентификации: Qwen OAuth',
' Type: Free tier': ' Тип: Бесплатный',
' Limit: Up to 1,000 requests/day': ' Лимит: До 1 000 запросов/день',
' Models: Qwen latest models\n': ' Модели: Последние модели Qwen\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ Метод аутентификации: Alibaba Cloud Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': 'Глобальный - Alibaba Cloud',
' Region: {{region}}': ' Регион: {{region}}',
' Current Model: {{model}}': ' Текущая модель: {{model}}',
' Config Version: {{version}}': ' Версия конфигурации: {{version}}',
' Status: API key configured\n': ' Статус: API-ключ настроен\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ Метод аутентификации: Alibaba Cloud Coding Plan (Не завершён)',
' Issue: API key not found in environment or settings\n':
' Проблема: API-ключ не найден в окружении или настройках\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' Выполните `qwen auth coding-plan` для повторной настройки.\n',
'✓ Authentication Method: {{type}}': '✓ Метод аутентификации: {{type}}',
' Status: Configured\n': ' Статус: Настроено\n',
'Failed to check authentication status: {{error}}':
'Не удалось проверить статус аутентификации: {{error}}',
'Select an option:': 'Выберите вариант:',
'Raw mode not available. Please run in an interactive terminal.':
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n',
};

View file

@ -1496,6 +1496,33 @@ export default {
'{{region}} 有新的模型配置可用。是否立即更新?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'{{region}} 配置更新成功。模型已切换至 "{{model}}"。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json已备份。',
// ============================================================================
// Context Usage
// ============================================================================
'Context Usage': '上下文使用情况',
'Context window': '上下文窗口',
Used: '已用',
Free: '空闲',
'Autocompact buffer': '自动压缩缓冲区',
'Usage by category': '分类用量',
'System prompt': '系统提示',
'Built-in tools': '内置工具',
'MCP tools': 'MCP 工具',
'Memory files': '记忆文件',
Skills: '技能',
Messages: '消息',
tokens: 'tokens',
'Estimated pre-conversation overhead': '预估对话前开销',
'No API response yet. Send a message to see actual usage.':
'暂无 API 响应。发送消息以查看实际使用情况。',
'Show context window usage breakdown.': '显示上下文窗口使用情况分解。',
'Run /context detail for per-item breakdown.':
'运行 /context detail 查看详细分解。',
'body loaded': '内容已加载',
memory: '记忆',
'{{region}} configuration updated successfully.': '{{region}} 配置更新成功。',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':
'成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。',
@ -1526,4 +1553,72 @@ export default {
'↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消',
'↑/↓: Navigate | Enter: Select | Esc: Cancel':
'↑/↓: 导航 | Enter: 选择 | Esc: 取消',
// ============================================================================
// Commands - Auth
// ============================================================================
'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan':
'使用 Qwen OAuth 或阿里云百炼 Coding Plan 配置 Qwen 认证信息',
'Authenticate using Qwen OAuth': '使用 Qwen OAuth 进行认证',
'Authenticate using Alibaba Cloud Coding Plan':
'使用阿里云百炼 Coding Plan 进行认证',
'Region for Coding Plan (china/global)': 'Coding Plan 区域 (china/global)',
'API key for Coding Plan': 'Coding Plan 的 API 密钥',
'Show current authentication status': '显示当前认证状态',
'Authentication completed successfully.': '认证完成。',
'Starting Qwen OAuth authentication...': '正在启动 Qwen OAuth 认证...',
'Successfully authenticated with Qwen OAuth.': '已成功通过 Qwen OAuth 认证。',
'Failed to authenticate with Qwen OAuth: {{error}}':
'Qwen OAuth 认证失败:{{error}}',
'Processing Alibaba Cloud Coding Plan authentication...':
'正在处理阿里云百炼 Coding Plan 认证...',
'Successfully authenticated with Alibaba Cloud Coding Plan.':
'已成功通过阿里云百炼 Coding Plan 认证。',
'Failed to authenticate with Coding Plan: {{error}}':
'Coding Plan 认证失败:{{error}}',
'中国 (China)': '中国 (China)',
'阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)',
Global: '全球',
'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)',
'Select region for Coding Plan:': '选择 Coding Plan 区域:',
'Enter your Coding Plan API key: ': '请输入您的 Coding Plan API 密钥:',
'Select authentication method:': '选择认证方式:',
'\n=== Authentication Status ===\n': '\n=== 认证状态 ===\n',
'⚠️ No authentication method configured.\n': '⚠️ 未配置认证方式。\n',
'Run one of the following commands to get started:\n':
'运行以下命令之一开始配置:\n',
' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)':
' qwen auth qwen-oauth - 使用 Qwen OAuth 认证(免费)',
' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n':
' qwen auth coding-plan - 使用阿里云百炼 Coding Plan 认证\n',
'Or simply run:': '或者直接运行:',
' qwen auth - Interactive authentication setup\n':
' qwen auth - 交互式认证配置\n',
'✓ Authentication Method: Qwen OAuth': '✓ 认证方式Qwen OAuth',
' Type: Free tier': ' 类型:免费版',
' Limit: Up to 1,000 requests/day': ' 限额:每天最多 1,000 次请求',
' Models: Qwen latest models\n': ' 模型Qwen 最新模型\n',
'✓ Authentication Method: Alibaba Cloud Coding Plan':
'✓ 认证方式:阿里云百炼 Coding Plan',
'中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼',
'Global - Alibaba Cloud': '全球 - Alibaba Cloud',
' Region: {{region}}': ' 区域:{{region}}',
' Current Model: {{model}}': ' 当前模型:{{model}}',
' Config Version: {{version}}': ' 配置版本:{{version}}',
' Status: API key configured\n': ' 状态API 密钥已配置\n',
'⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)':
'⚠️ 认证方式:阿里云百炼 Coding Plan不完整',
' Issue: API key not found in environment or settings\n':
' 问题:在环境变量或设置中未找到 API 密钥\n',
' Run `qwen auth coding-plan` to re-configure.\n':
' 运行 `qwen auth coding-plan` 重新配置。\n',
'✓ Authentication Method: {{type}}': '✓ 认证方式:{{type}}',
' Status: Configured\n': ' 状态:已配置\n',
'Failed to check authentication status: {{error}}':
'检查认证状态失败:{{error}}',
'Select an option:': '请选择:',
'Raw mode not available. Please run in an interactive terminal.':
'原始模式不可用。请在交互式终端中运行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭头导航Enter 选择Ctrl+C 退出)\n',
};

View file

@ -282,12 +282,12 @@ export abstract class BaseJsonOutputAdapter {
return;
}
if (lastBlock.type === 'text') {
const index = state.blocks.length - 1;
this.onBlockClosed(state, index, actualParentToolUseId);
this.closeBlock(state, index);
} else if (lastBlock.type === 'thinking') {
const index = state.blocks.length - 1;
const index = state.blocks.length - 1;
if (!state.openBlocks.has(index)) {
return;
}
if (lastBlock.type === 'text' || lastBlock.type === 'thinking') {
this.onBlockClosed(state, index, actualParentToolUseId);
this.closeBlock(state, index);
}
@ -392,7 +392,9 @@ export abstract class BaseJsonOutputAdapter {
}
const message = this.buildMessage(parentToolUseId);
this.emitMessageImpl(message);
if (state.messageStarted) {
this.emitMessageImpl(message);
}
return message;
}
@ -656,12 +658,7 @@ export abstract class BaseJsonOutputAdapter {
parentToolUseId: string,
): CLIAssistantMessage {
const state = this.getMessageState(parentToolUseId);
const message = this.finalizeAssistantMessageInternal(
state,
parentToolUseId,
);
this.updateLastAssistantMessage(message);
return message;
return this.finalizeAssistantMessageInternal(state, parentToolUseId);
}
/**

View file

@ -52,12 +52,10 @@ export class JsonOutputAdapter
}
finalizeAssistantMessage(): CLIAssistantMessage {
const message = this.finalizeAssistantMessageInternal(
return this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
this.updateLastAssistantMessage(message);
return message;
}
emitResult(options: ResultOptions): void {

View file

@ -654,6 +654,24 @@ describe('StreamJsonOutputAdapter', () => {
'Message not started',
);
});
it('should not emit empty assistant message when started but no content processed', () => {
stdoutWriteSpy.mockClear();
adapter.finalizeAssistantMessage();
const assistantCalls = stdoutWriteSpy.mock.calls.filter(
(call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return parsed.type === 'assistant';
} catch {
return false;
}
},
);
expect(assistantCalls).toHaveLength(0);
});
});
describe('emitResult', () => {
@ -1007,56 +1025,68 @@ describe('StreamJsonOutputAdapter', () => {
});
});
describe('message_id in stream events', () => {
describe('content_block event identification', () => {
beforeEach(() => {
adapter = new StreamJsonOutputAdapter(mockConfig, true);
adapter.startAssistantMessage();
});
it('should include message_id in stream events after message starts', () => {
it('should not include message_id in content_block events', () => {
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[]) => {
const contentBlockCalls = calls.filter((call: unknown[]) => {
try {
const parsed = JSON.parse(call[0] as string);
return (
parsed.type === 'stream_event' &&
parsed.event.type === 'content_block_delta'
(parsed.event.type === 'content_block_start' ||
parsed.event.type === 'content_block_delta' ||
parsed.event.type === 'content_block_stop')
);
} 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();
expect(contentBlockCalls.length).toBeGreaterThan(0);
for (const call of contentBlockCalls) {
const parsed = JSON.parse((call as unknown[])[0] as string);
expect(parsed.event.message_id).toBeUndefined();
}
});
it('should identify content_block events by session_id and index', () => {
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();
const parsed = JSON.parse((blockStartCall as unknown[])[0] as string);
expect(parsed.session_id).toBe('test-session-id');
expect(typeof parsed.event.index).toBe('number');
});
});
describe('multiple text blocks', () => {

View file

@ -36,6 +36,8 @@ export class StreamJsonOutputAdapter
extends BaseJsonOutputAdapter
implements JsonOutputAdapterInterface
{
private mainTurnMessageStartEmitted = false;
constructor(
config: Config,
private readonly includePartialMessages: boolean,
@ -68,29 +70,27 @@ export class StreamJsonOutputAdapter
return this.includePartialMessages;
}
override startAssistantMessage(): void {
this.mainTurnMessageStartEmitted = false;
super.startAssistantMessage();
}
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,
const message = this.finalizeAssistantMessageInternal(
this.mainAgentMessageState,
null,
);
for (const index of orderedOpenBlocks) {
this.onBlockClosed(state, index, null);
this.closeBlock(state, index);
if (this.mainTurnMessageStartEmitted && this.includePartialMessages) {
const partial: CLIPartialAssistantMessage = {
type: 'stream_event',
uuid: randomUUID(),
session_id: this.getSessionId(),
parent_tool_use_id: null,
event: { type: 'message_stop' },
};
this.emitMessageImpl(partial);
}
if (state.messageStarted && this.includePartialMessages) {
this.emitStreamEventIfEnabled({ type: 'message_stop' }, null);
}
const message = this.buildMessage(null);
this.updateLastAssistantMessage(message);
this.emitMessageImpl(message);
this.mainTurnMessageStartEmitted = false;
return message;
}
@ -249,14 +249,15 @@ export class StreamJsonOutputAdapter
/**
* Overrides base class hook to emit message_start event when message is started.
* Only emits for main agent, not for subagents.
* Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted),
* so block-type transitions inside a single turn do not produce spurious message_start events.
*/
protected override onEnsureMessageStarted(
state: MessageState,
parentToolUseId: string | null,
): void {
// Only emit message_start for main agent, not for subagents
if (parentToolUseId === null) {
if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) {
this.mainTurnMessageStartEmitted = true;
this.emitStreamEventIfEnabled(
{
type: 'message_start',
@ -264,6 +265,7 @@ export class StreamJsonOutputAdapter
id: state.messageId!,
role: 'assistant',
model: this.config.getModel(),
content: [],
},
},
null,
@ -311,19 +313,12 @@ export class StreamJsonOutputAdapter
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,
event,
};
this.emitMessageImpl(partial);
}

View file

@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
id: string;
role: 'assistant';
model: string;
content: [];
};
}

View file

@ -390,6 +390,16 @@ export async function runNonInteractive(
}
}
} catch (error) {
// Ensure message_start / message_stop (and content_block events) are
// properly paired even when an error aborts the turn mid-stream.
// The call is safe when no message was started (throws → caught) or
// when already finalized (idempotent guard inside the adapter).
try {
adapter.finalizeAssistantMessage();
} catch {
// Expected when no message was started or already finalized
}
// For JSON and STREAM_JSON modes, compute usage from metrics
const message = error instanceof Error ? error.message : String(error);
const metrics = uiTelemetryService.getMetrics();

View file

@ -9,11 +9,13 @@ import type { SlashCommand } from '../ui/commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { agentsCommand } from '../ui/commands/agentsCommand.js';
import { arenaCommand } from '../ui/commands/arenaCommand.js';
import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { contextCommand } from '../ui/commands/contextCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { directoryCommand } from '../ui/commands/directoryCommand.js';
@ -61,11 +63,13 @@ export class BuiltinCommandLoader implements ICommandLoader {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
agentsCommand,
arenaCommand,
approvalModeCommand,
authCommand,
bugCommand,
clearCommand,
compressCommand,
contextCommand,
copyCommand,
docsCommand,
directoryCommand,

View file

@ -109,10 +109,9 @@ export class ShellProcessor implements IPromptProcessor {
return { ...injection, resolvedCommand: undefined };
}
const resolvedCommand = command.replaceAll(
SHORTHAND_ARGS_PLACEHOLDER,
userArgsEscaped,
);
const resolvedCommand = command
.replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}}
.replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS
return { ...injection, resolvedCommand };
},
);

View file

@ -9,6 +9,11 @@ import { render } from 'ink-testing-library';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { App } from './App.js';
import { UIStateContext, type UIState } from './contexts/UIStateContext.js';
import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
import { AgentViewProvider } from './contexts/AgentViewContext.js';
import { StreamingState } from './types.js';
vi.mock('ink', async (importOriginal) => {
@ -43,6 +48,10 @@ vi.mock('./components/Footer.js', () => ({
Footer: () => <Text>Footer</Text>,
}));
vi.mock('./components/agent-view/AgentTabBar.js', () => ({
AgentTabBar: () => null,
}));
describe('App', () => {
const mockUIState: Partial<UIState> = {
streamingState: StreamingState.Idle,
@ -58,13 +67,24 @@ describe('App', () => {
},
};
it('should render main content and composer when not quitting', () => {
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
const mockUIActions = {
refreshStatic: vi.fn(),
} as unknown as UIActions;
const renderWithProviders = (uiState: UIState) =>
render(
<UIActionsContext.Provider value={mockUIActions}>
<AgentViewProvider>
<UIStateContext.Provider value={uiState}>
<App />
</UIStateContext.Provider>
</AgentViewProvider>
</UIActionsContext.Provider>,
);
it('should render main content and composer when not quitting', () => {
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('Composer');
});
@ -75,11 +95,7 @@ describe('App', () => {
quittingMessages: [{ id: 1, type: 'user', text: 'test' }],
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={quittingUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(quittingUIState);
expect(lastFrame()).toContain('Quitting...');
});
@ -90,11 +106,7 @@ describe('App', () => {
dialogsVisible: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={dialogUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(dialogUIState);
expect(lastFrame()).toContain('MainContent');
expect(lastFrame()).toContain('DialogManager');
@ -107,11 +119,7 @@ describe('App', () => {
ctrlCPressedOnce: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={ctrlCUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(ctrlCUIState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit.');
});
@ -123,11 +131,7 @@ describe('App', () => {
ctrlDPressedOnce: true,
} as UIState;
const { lastFrame } = render(
<UIStateContext.Provider value={ctrlDUIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(ctrlDUIState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit.');
});
@ -135,11 +139,7 @@ describe('App', () => {
it('should render ScreenReaderAppLayout when screen reader is enabled', () => {
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true);
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain(
'Notifications\nFooter\nMainContent\nComposer',
@ -149,11 +149,7 @@ describe('App', () => {
it('should render DefaultAppLayout when screen reader is not enabled', () => {
(useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false);
const { lastFrame } = render(
<UIStateContext.Provider value={mockUIState as UIState}>
<App />
</UIStateContext.Provider>,
);
const { lastFrame } = renderWithProviders(mockUIState as UIState);
expect(lastFrame()).toContain('MainContent\nComposer');
});

View file

@ -78,6 +78,21 @@ vi.mock('./hooks/useAutoAcceptIndicator.js');
vi.mock('./hooks/useGitBranchName.js');
vi.mock('./contexts/VimModeContext.js');
vi.mock('./contexts/SessionContext.js');
vi.mock('./contexts/AgentViewContext.js', () => ({
useAgentViewState: vi.fn(() => ({
activeView: 'main',
agents: new Map(),
})),
useAgentViewActions: vi.fn(() => ({
switchToMain: vi.fn(),
switchToAgent: vi.fn(),
switchToNext: vi.fn(),
switchToPrevious: vi.fn(),
registerAgent: vi.fn(),
unregisterAgent: vi.fn(),
unregisterAll: vi.fn(),
})),
}));
vi.mock('./components/shared/text-buffer.js');
vi.mock('./hooks/useLogger.js');
@ -268,7 +283,7 @@ describe('AppContainer State Management', () => {
listSubagents: vi.fn().mockResolvedValue([]),
addChangeListener: vi.fn(),
loadSubagent: vi.fn(),
createSubagentScope: vi.fn(),
createSubagent: vi.fn(),
};
vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue(
mockSubagentManager as SubagentManager,

View file

@ -54,6 +54,7 @@ import { useAuthCommand } from './auth/useAuth.js';
import { useEditorSettings } from './hooks/useEditorSettings.js';
import { useSettingsCommand } from './hooks/useSettingsCommand.js';
import { useModelCommand } from './hooks/useModelCommand.js';
import { useArenaCommand } from './hooks/useArenaCommand.js';
import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js';
import { useResumeCommand } from './hooks/useResumeCommand.js';
import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js';
@ -98,6 +99,7 @@ import {
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import { t } from '../i18n/index.js';
import { useWelcomeBack } from './hooks/useWelcomeBack.js';
import { useDialogClose } from './hooks/useDialogClose.js';
@ -507,6 +509,8 @@ export const AppContainer = (props: AppContainerProps) => {
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
useModelCommand();
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
useArenaCommand();
const {
isResumeDialogOpen,
@ -546,6 +550,7 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openArenaDialog,
openPermissionsDialog,
openApprovalModeDialog,
quit: (messages: HistoryItem[]) => {
@ -570,6 +575,7 @@ export const AppContainer = (props: AppContainerProps) => {
openEditorDialog,
openSettingsDialog,
openModelDialog,
openArenaDialog,
setDebugMessage,
dispatchExtensionStateUpdate,
openPermissionsDialog,
@ -706,12 +712,15 @@ export const AppContainer = (props: AppContainerProps) => {
// Track whether suggestions are visible for Tab key handling
const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false);
// Auto-accept indicator
const agentViewState = useAgentViewState();
// Auto-accept indicator — disabled on agent tabs (agents handle their own)
const showAutoAcceptIndicator = useAutoAcceptIndicator({
config,
addItem: historyManager.addItem,
onApprovalModeChange: handleApprovalModeChange,
shouldBlockTab: () => hasSuggestionsVisible,
disabled: agentViewState.activeView !== 'main',
});
const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } =
@ -724,9 +733,26 @@ export const AppContainer = (props: AppContainerProps) => {
// Callback for handling final submit (must be after addMessage from useMessageQueue)
const handleFinalSubmit = useCallback(
(submittedValue: string) => {
// Route to active in-process agent if viewing a sub-agent tab.
if (agentViewState.activeView !== 'main') {
const agent = agentViewState.agents.get(agentViewState.activeView);
if (agent) {
agent.interactiveAgent.enqueueMessage(submittedValue.trim());
return;
}
}
addMessage(submittedValue);
},
[addMessage],
[addMessage, agentViewState],
);
const handleArenaModelsSelected = useCallback(
(models: string[]) => {
const value = models.join(',');
buffer.setText(`/arena start --models ${value} `);
closeArenaDialog();
},
[buffer, closeArenaDialog],
);
// Welcome back functionality (must be after handleFinalSubmit)
@ -802,10 +828,17 @@ export const AppContainer = (props: AppContainerProps) => {
}
}, [buffer, terminalWidth, terminalHeight]);
// Compute available terminal height based on controls measurement
// agentViewState is declared earlier (before handleFinalSubmit) so it
// is available for input routing. Referenced here for layout computation.
// Compute available terminal height based on controls measurement.
// When in-process agents are present the AgentTabBar renders an extra
// row at the top of the layout; subtract it so downstream consumers
// (shell, transcript, etc.) don't overestimate available space.
const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0;
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - staticExtraHeight - 2,
terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight,
);
config.setShellExecutionConfig({
@ -1059,10 +1092,16 @@ export const AppContainer = (props: AppContainerProps) => {
[historyManager, setShowCommandMigrationNudge, config.storage],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
);
const currentCandidatesTokens = Object.values(
sessionStats.metrics?.models ?? {},
).reduce((acc, model) => acc + (model.tokens?.candidates ?? 0), 0);
const { elapsedTime, currentLoadingPhrase, taskStartTokens } =
useLoadingIndicator(
streamingState,
settings.merged.ui?.customWittyPhrases,
currentCandidatesTokens,
);
useAttentionNotifications({
isFocused,
@ -1085,6 +1124,8 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
isSettingsDialogOpen,
closeSettingsDialog,
activeArenaDialog,
closeArenaDialog,
isFolderTrustDialogOpen,
showWelcomeBackDialog,
handleWelcomeBackClose,
@ -1342,6 +1383,7 @@ export const AppContainer = (props: AppContainerProps) => {
isThemeDialogOpen ||
isSettingsDialogOpen ||
isModelDialogOpen ||
activeArenaDialog !== null ||
isPermissionsDialogOpen ||
isAuthDialogOpen ||
isAuthenticating ||
@ -1392,6 +1434,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
@ -1468,6 +1511,8 @@ export const AppContainer = (props: AppContainerProps) => {
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
taskStartTokens,
}),
[
isThemeDialogOpen,
@ -1485,6 +1530,7 @@ export const AppContainer = (props: AppContainerProps) => {
quittingMessages,
isSettingsDialogOpen,
isModelDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
isApprovalModeDialogOpen,
isResumeDialogOpen,
@ -1562,6 +1608,8 @@ export const AppContainer = (props: AppContainerProps) => {
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
// Per-task token tracking
taskStartTokens,
],
);
@ -1581,6 +1629,9 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,
@ -1630,6 +1681,9 @@ export const AppContainer = (props: AppContainerProps) => {
exitEditorDialog,
closeSettingsDialog,
closeModelDialog,
openArenaDialog,
closeArenaDialog,
handleArenaModelsSelected,
dismissCodingPlanUpdate,
closePermissionsDialog,
setShellModeActive,

View file

@ -0,0 +1,395 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
type ArenaManager,
AgentStatus,
ArenaSessionStatus,
} from '@qwen-code/qwen-code-core';
import { arenaCommand } from './arenaCommand.js';
import type {
CommandContext,
OpenDialogActionReturn,
SlashCommand,
} from './types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
function getArenaSubCommand(
name: 'start' | 'stop' | 'status' | 'select',
): SlashCommand {
const command = arenaCommand.subCommands?.find((item) => item.name === name);
if (!command?.action) {
throw new Error(`Arena subcommand "${name}" is missing an action`);
}
return command;
}
describe('arenaCommand stop subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
setArenaManager: ReturnType<typeof vi.fn>;
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
getAgentsSettings: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
setArenaManager: vi.fn(),
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getAgentsSettings: vi.fn(() => ({})),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena stop',
name: 'arena',
args: 'stop',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns an error when no arena session is running', async () => {
const stopCommand = getArenaSubCommand('stop');
const result = await stopCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No running Arena session found.',
});
});
it('opens stop dialog when a running session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const stopCommand = getArenaSubCommand('stop');
const result = (await stopCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_stop',
});
});
it('opens stop dialog when a completed session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const stopCommand = getArenaSubCommand('stop');
const result = (await stopCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_stop',
});
});
});
describe('arenaCommand status subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena status',
name: 'arena',
args: 'status',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns an error when no arena session exists', async () => {
const statusCommand = getArenaSubCommand('status');
const result = await statusCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No Arena session found. Start one with /arena start.',
});
});
it('opens status dialog when a session exists', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const statusCommand = getArenaSubCommand('status');
const result = (await statusCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_status',
});
});
it('opens status dialog for completed session', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const statusCommand = getArenaSubCommand('status');
const result = (await statusCommand.action!(
mockContext,
'',
)) as OpenDialogActionReturn;
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_status',
});
});
});
describe('arenaCommand select subcommand', () => {
let mockContext: CommandContext;
let mockConfig: {
getArenaManager: ReturnType<typeof vi.fn>;
setArenaManager: ReturnType<typeof vi.fn>;
cleanupArenaRuntime: ReturnType<typeof vi.fn>;
getAgentsSettings: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockConfig = {
getArenaManager: vi.fn(() => null),
setArenaManager: vi.fn(),
cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined),
getAgentsSettings: vi.fn(() => ({})),
};
mockContext = createMockCommandContext({
invocation: {
raw: '/arena select',
name: 'arena',
args: 'select',
},
executionMode: 'interactive',
services: {
config: mockConfig as never,
},
});
});
it('returns error when no arena session exists', async () => {
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
});
it('returns error when arena is still running', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'Arena session is still running. Wait for it to complete or use /arena stop first.',
});
});
it('returns error when all agents failed', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.FAILED,
model: { modelId: 'model-1' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content:
'No successful agent results to select from. All agents failed or were cancelled.\n' +
'Use /arena stop to end the session.',
});
});
it('opens dialog when no args provided and agents have results', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'model-1' },
},
{
agentId: 'agent-2',
status: AgentStatus.COMPLETED,
model: { modelId: 'model-2' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'arena_select',
});
});
it('applies changes directly when model name is provided', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
},
{
agentId: 'agent-2',
status: AgentStatus.COMPLETED,
model: { modelId: 'claude-sonnet', displayName: 'claude-sonnet' },
},
]),
applyAgentResult: vi.fn().mockResolvedValue({ success: true }),
cleanup: vi.fn().mockResolvedValue(undefined),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, 'gpt-4o');
expect(mockManager.applyAgentResult).toHaveBeenCalledWith('agent-1');
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'Applied changes from gpt-4o to workspace. Arena session complete.',
});
});
it('returns error when specified model not found', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o', displayName: 'gpt-4o' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, 'nonexistent');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'No idle agent found matching "nonexistent".',
});
});
it('asks for confirmation when --discard flag is used', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o' },
},
]),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '--discard');
expect(result).toEqual({
type: 'confirm_action',
prompt: 'Discard all Arena results and clean up worktrees?',
originalInvocation: { raw: '/arena select' },
});
});
it('discards results after --discard confirmation', async () => {
const mockManager = {
getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED),
getAgentStates: vi.fn(() => [
{
agentId: 'agent-1',
status: AgentStatus.COMPLETED,
model: { modelId: 'gpt-4o' },
},
]),
cleanup: vi.fn().mockResolvedValue(undefined),
} as unknown as ArenaManager;
mockConfig.getArenaManager = vi.fn(() => mockManager);
mockContext.overwriteConfirmed = true;
const selectCommand = getArenaSubCommand('select');
const result = await selectCommand.action!(mockContext, '--discard');
expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled();
expect(result).toEqual({
type: 'message',
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
});
});

View file

@ -0,0 +1,659 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
CommandContext,
ConfirmActionReturn,
MessageActionReturn,
OpenDialogActionReturn,
SlashCommandActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import {
ArenaManager,
ArenaEventType,
isTerminalStatus,
isSuccessStatus,
ArenaSessionStatus,
AuthType,
createDebugLogger,
stripStartupContext,
type Config,
type ArenaModelConfig,
type ArenaAgentErrorEvent,
type ArenaAgentCompleteEvent,
type ArenaAgentStartEvent,
type ArenaSessionCompleteEvent,
type ArenaSessionErrorEvent,
type ArenaSessionStartEvent,
type ArenaSessionUpdateEvent,
} from '@qwen-code/qwen-code-core';
import {
MessageType,
type ArenaAgentCardData,
type HistoryItemWithoutId,
} from '../types.js';
/**
* Parsed model entry with optional auth type.
*/
interface ParsedModel {
authType?: string;
modelId: string;
}
/**
* Parses arena command arguments.
*
* Supported formats:
* /arena start --models model1,model2 <task>
* /arena start --models authType1:model1,authType2:model2 <task>
*
* Model format: [authType:]modelId
* - "gpt-4o" uses default auth type
* - "openai:gpt-4o" uses "openai" auth type
*/
function parseArenaArgs(args: string): {
models: ParsedModel[];
task: string;
} {
const modelsMatch = args.match(/--models\s+(\S+)/);
let models: ParsedModel[] = [];
let task = args;
if (modelsMatch) {
const modelStrings = modelsMatch[1]!.split(',').filter(Boolean);
models = modelStrings.map((str) => {
// Check for authType:modelId format
const colonIndex = str.indexOf(':');
if (colonIndex > 0) {
return {
authType: str.substring(0, colonIndex),
modelId: str.substring(colonIndex + 1),
};
}
return { modelId: str };
});
task = task.replace(/--models\s+\S+/, '').trim();
}
// Strip surrounding quotes from task
task = task.replace(/^["']|["']$/g, '').trim();
return { models, task };
}
const debugLogger = createDebugLogger('ARENA_COMMAND');
interface ArenaExecutionInput {
task: string;
models: ArenaModelConfig[];
approvalMode?: string;
}
function buildArenaExecutionInput(
parsed: ReturnType<typeof parseArenaArgs>,
config: Config,
): ArenaExecutionInput | MessageActionReturn {
if (!parsed.task) {
return {
type: 'message',
messageType: 'error',
content:
'Usage: /arena start --models model1,model2 <task>\n' +
'\n' +
'Options:\n' +
' --models [authType:]model1,[authType:]model2\n' +
' Models to compete (required, at least 2)\n' +
' Format: authType:modelId or just modelId\n' +
'\n' +
'Examples:\n' +
' /arena start --models openai:gpt-4o,anthropic:claude-3 "implement sorting"\n' +
' /arena start --models qwen-coder-plus,kimi-for-coding "fix the bug"',
};
}
if (parsed.models.length < 2) {
return {
type: 'message',
messageType: 'error',
content:
'Arena requires at least 2 models. Use --models model1,model2 to specify.\n' +
'Format: [authType:]modelId (e.g., openai:gpt-4o or just gpt-4o)',
};
}
// Get the current auth type as default for models without explicit auth type
const contentGeneratorConfig = config.getContentGeneratorConfig();
const defaultAuthType =
contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI;
// Build ArenaModelConfig for each model, resolving display names from
// the model registry when available.
const modelsConfig = config.getModelsConfig();
const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => {
const authType =
(parsedModel.authType as AuthType | undefined) ?? defaultAuthType;
const registryModels = modelsConfig.getAvailableModelsForAuthType(authType);
const resolved = registryModels.find((m) => m.id === parsedModel.modelId);
return {
modelId: parsedModel.modelId,
authType,
displayName: resolved?.label ?? parsedModel.modelId,
};
});
return {
task: parsed.task,
models,
approvalMode: config.getApprovalMode(),
};
}
/**
* Persists a single arena history item to the session JSONL file.
*
* Arena events fire asynchronously (after the slash command's recording
* window has closed), so each item must be recorded individually.
*/
function recordArenaItem(config: Config, item: HistoryItemWithoutId): void {
try {
const chatRecorder = config.getChatRecordingService();
if (!chatRecorder) return;
chatRecorder.recordSlashCommand({
phase: 'result',
rawCommand: '/arena',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
debugLogger.error('Failed to record arena history item');
}
}
function executeArenaCommand(
config: Config,
ui: CommandContext['ui'],
input: ArenaExecutionInput,
): void {
// Capture the main session's chat history so arena agents start with
// conversational context. Strip the leading startup context (env info
// user message + model ack) because each agent generates its own for
// its worktree directory — keeping the parent's would duplicate it.
let chatHistory;
try {
const fullHistory = config.getGeminiClient().getHistory();
chatHistory = stripStartupContext(fullHistory);
} catch {
debugLogger.debug('Could not retrieve chat history for arena agents');
}
const manager = new ArenaManager(config);
const emitter = manager.getEventEmitter();
const detachListeners: Array<() => void> = [];
const agentLabels = new Map<string, string>();
const addArenaMessage = (
type: 'info' | 'warning' | 'error' | 'success',
text: string,
) => {
ui.addItem({ type, text }, Date.now());
};
const addAndRecordArenaMessage = (
type: 'info' | 'warning' | 'error' | 'success',
text: string,
) => {
const item: HistoryItemWithoutId = { type, text };
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
const handleSessionStart = (event: ArenaSessionStartEvent) => {
const modelList = event.models
.map((model, index) => ` ${index + 1}. ${model.modelId}`)
.join('\n');
// SESSION_START fires synchronously before the first await in
// ArenaManager.start(), so the slash command processor's finally
// block already captures this item — no extra recording needed.
addArenaMessage(
MessageType.INFO,
`Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`,
);
};
const handleAgentStart = (event: ArenaAgentStartEvent) => {
agentLabels.set(event.agentId, event.model.modelId);
debugLogger.debug(
`Arena agent started: ${event.model.modelId} (${event.agentId})`,
);
};
const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => {
const attachHintPrefix = 'To view agent panes, run: ';
if (event.message.startsWith(attachHintPrefix)) {
const command = event.message.slice(attachHintPrefix.length).trim();
addAndRecordArenaMessage(
MessageType.INFO,
`Arena panes are running in tmux. Attach with: \`${command}\``,
);
return;
}
if (event.type === 'success') {
addAndRecordArenaMessage(MessageType.SUCCESS, event.message);
} else if (event.type === 'info') {
addAndRecordArenaMessage(MessageType.INFO, event.message);
} else {
addAndRecordArenaMessage(MessageType.WARNING, event.message);
}
};
const handleAgentError = (event: ArenaAgentErrorEvent) => {
const label = agentLabels.get(event.agentId) || event.agentId;
addAndRecordArenaMessage(
MessageType.ERROR,
`[${label}] failed: ${event.error}`,
);
};
const buildAgentCardData = (
result: ArenaAgentCompleteEvent['result'],
): ArenaAgentCardData => ({
label: result.model.modelId,
status: result.status,
durationMs: result.stats.durationMs,
totalTokens: result.stats.totalTokens,
inputTokens: result.stats.inputTokens,
outputTokens: result.stats.outputTokens,
toolCalls: result.stats.toolCalls,
successfulToolCalls: result.stats.successfulToolCalls,
failedToolCalls: result.stats.failedToolCalls,
rounds: result.stats.rounds,
error: result.error,
diff: result.diff,
});
const handleAgentComplete = (event: ArenaAgentCompleteEvent) => {
if (!isTerminalStatus(event.result.status)) {
return;
}
const agent = buildAgentCardData(event.result);
const item = {
type: 'arena_agent_complete',
agent,
} as HistoryItemWithoutId;
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
const handleSessionError = (event: ArenaSessionErrorEvent) => {
addAndRecordArenaMessage(MessageType.ERROR, `${event.error}`);
};
const handleSessionComplete = (event: ArenaSessionCompleteEvent) => {
const item = {
type: 'arena_session_complete',
sessionStatus: event.result.status,
task: event.result.task,
totalDurationMs: event.result.totalDurationMs ?? 0,
agents: event.result.agents.map(buildAgentCardData),
} as HistoryItemWithoutId;
ui.addItem(item, Date.now());
recordArenaItem(config, item);
};
emitter.on(ArenaEventType.SESSION_START, handleSessionStart);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_START, handleSessionStart),
);
emitter.on(ArenaEventType.AGENT_START, handleAgentStart);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_START, handleAgentStart),
);
emitter.on(ArenaEventType.SESSION_UPDATE, handleSessionUpdate);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_UPDATE, handleSessionUpdate),
);
emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_ERROR, handleAgentError),
);
emitter.on(ArenaEventType.AGENT_COMPLETE, handleAgentComplete);
detachListeners.push(() =>
emitter.off(ArenaEventType.AGENT_COMPLETE, handleAgentComplete),
);
emitter.on(ArenaEventType.SESSION_ERROR, handleSessionError);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_ERROR, handleSessionError),
);
emitter.on(ArenaEventType.SESSION_COMPLETE, handleSessionComplete);
detachListeners.push(() =>
emitter.off(ArenaEventType.SESSION_COMPLETE, handleSessionComplete),
);
config.setArenaManager(manager);
const cols = process.stdout.columns || 120;
const rows = Math.max((process.stdout.rows || 40) - 2, 1);
const lifecycle = manager
.start({
task: input.task,
models: input.models,
cols,
rows,
approvalMode: input.approvalMode,
chatHistory,
})
.then(
() => {
debugLogger.debug('Arena agents settled');
},
(error) => {
const message = error instanceof Error ? error.message : String(error);
addAndRecordArenaMessage(MessageType.ERROR, `${message}`);
debugLogger.error('Arena session failed:', error);
// Clear the stored manager so subsequent /arena start calls
// are not blocked by the stale reference after a startup failure.
config.setArenaManager(null);
// Detach listeners on failure — session is done for good.
for (const detach of detachListeners) {
detach();
}
},
);
// NOTE: listeners are NOT detached when start() resolves because agents
// may still be alive (IDLE) and accept follow-up tasks. The listeners
// reference this manager's emitter, so they are garbage collected when
// the manager is cleaned up and replaced.
// Store so that stop can wait for start() to fully unwind before cleanup
manager.setLifecyclePromise(lifecycle);
}
export const arenaCommand: SlashCommand = {
name: 'arena',
description: 'Manage Arena sessions',
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'start',
description:
'Start an Arena session with multiple models competing on the same task',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn | OpenDialogActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content:
'Arena is not supported in non-interactive mode. Use interactive mode to start an Arena session.',
};
}
const { services, ui } = context;
const { config } = services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
// Refuse to start if a session already exists (regardless of status)
const existingManager = config.getArenaManager();
if (existingManager) {
return {
type: 'message',
messageType: 'error',
content:
'An Arena session exists. Use /arena stop or /arena select to end it before starting a new one.',
};
}
const parsed = parseArenaArgs(args);
if (parsed.models.length === 0) {
return {
type: 'dialog',
dialog: 'arena_start',
};
}
const executionInput = buildArenaExecutionInput(parsed, config);
if ('type' in executionInput) {
return executionInput;
}
executeArenaCommand(config, ui, executionInput);
},
},
{
name: 'stop',
description: 'Stop the current Arena session',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content:
'Arena is not supported in non-interactive mode. Use interactive mode to stop an Arena session.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No running Arena session found.',
};
}
return {
type: 'dialog',
dialog: 'arena_stop',
};
},
},
{
name: 'status',
description: 'Show the current Arena session status',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content: 'Arena is not supported in non-interactive mode.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No Arena session found. Start one with /arena start.',
};
}
return {
type: 'dialog',
dialog: 'arena_status',
};
},
},
{
name: 'select',
altNames: ['choose'],
description:
'Select a model result and merge its diff into the current workspace',
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<
| void
| MessageActionReturn
| OpenDialogActionReturn
| ConfirmActionReturn
> => {
const executionMode = context.executionMode ?? 'interactive';
if (executionMode !== 'interactive') {
return {
type: 'message',
messageType: 'error',
content: 'Arena is not supported in non-interactive mode.',
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Configuration not available.',
};
}
const manager = config.getArenaManager();
if (!manager) {
return {
type: 'message',
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
};
}
const sessionStatus = manager.getSessionStatus();
if (
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
return {
type: 'message',
messageType: 'error',
content:
'Arena session is still running. Wait for it to complete or use /arena stop first.',
};
}
// Handle --discard flag before checking for successful agents,
// so users can clean up worktrees even when all agents failed.
const trimmedArgs = args.trim();
if (trimmedArgs === '--discard') {
if (!context.overwriteConfirmed) {
return {
type: 'confirm_action',
prompt: 'Discard all Arena results and clean up worktrees?',
originalInvocation: {
raw: context.invocation?.raw || '/arena select --discard',
},
};
}
await config.cleanupArenaRuntime(true);
return {
type: 'message',
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
};
}
const agents = manager.getAgentStates();
const hasSuccessful = agents.some((a) => isSuccessStatus(a.status));
if (!hasSuccessful) {
return {
type: 'message',
messageType: 'error',
content:
'No successful agent results to select from. All agents failed or were cancelled.\n' +
'Use /arena stop to end the session.',
};
}
// Handle direct model selection via args
if (trimmedArgs) {
const matchingAgent = agents.find(
(a) =>
isSuccessStatus(a.status) &&
a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase(),
);
if (!matchingAgent) {
return {
type: 'message',
messageType: 'error',
content: `No idle agent found matching "${trimmedArgs}".`,
};
}
const label = matchingAgent.model.modelId;
const result = await manager.applyAgentResult(matchingAgent.agentId);
if (!result.success) {
return {
type: 'message',
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
};
}
await config.cleanupArenaRuntime(true);
return {
type: 'message',
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
};
}
// No args → open the select dialog
return {
type: 'dialog',
dialog: 'arena_select',
};
},
},
],
};

View file

@ -58,6 +58,7 @@ describe('clearCommand', () => {
warn: vi.fn(),
}),
getModel: () => 'test-model',
getToolRegistry: () => undefined,
},
},
session: {

View file

@ -11,6 +11,8 @@ import {
uiTelemetryService,
SessionEndReason,
SessionStartSource,
ToolNames,
SkillTool,
} from '@qwen-code/qwen-code-core';
export const clearCommand: SlashCommand = {
@ -38,6 +40,15 @@ export const clearCommand: SlashCommand = {
// Reset UI telemetry metrics for the new session
uiTelemetryService.reset();
// Clear loaded-skills tracking so /context doesn't show stale data
const skillTool = config
.getToolRegistry()
?.getAllTools()
.find((tool) => tool.name === ToolNames.SKILL);
if (skillTool instanceof SkillTool) {
skillTool.clearLoadedSkills();
}
if (newSessionId && context.session.startNewSession) {
context.session.startNewSession(newSessionId);
}

View file

@ -0,0 +1,376 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import {
MessageType,
type HistoryItemContextUsage,
type ContextCategoryBreakdown,
type ContextToolDetail,
type ContextMemoryDetail,
type ContextSkillDetail,
} from '../types.js';
import {
DiscoveredMCPTool,
uiTelemetryService,
getCoreSystemPrompt,
DEFAULT_TOKEN_LIMIT,
ToolNames,
SkillTool,
buildSkillLlmContent,
} from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
/**
* Default compression token threshold (triggers compression at 70% usage).
* The autocompact buffer is (1 - threshold) * contextWindowSize.
*/
const DEFAULT_COMPRESSION_THRESHOLD = 0.7;
/**
* Estimate token count for a string using a character-based heuristic.
* ASCII chars 4 chars/token, CJK/non-ASCII chars 1.5 tokens/char.
*/
function estimateTokens(text: string): number {
if (!text || text.length === 0) return 0;
let asciiChars = 0;
let nonAsciiChars = 0;
for (let i = 0; i < text.length; i++) {
const charCode = text.charCodeAt(i);
if (charCode < 128) {
asciiChars++;
} else {
nonAsciiChars++;
}
}
// CJK and other non-ASCII characters typically produce 1.5-2 tokens each
return Math.ceil(asciiChars / 4 + nonAsciiChars * 1.5);
}
/**
* Parse concatenated memory content into individual file entries.
* Memory content format: "--- Context from: <path> ---\n<content>\n--- End of Context from: <path> ---"
*/
function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] {
if (!memoryContent || memoryContent.trim().length === 0) return [];
const results: ContextMemoryDetail[] = [];
// Use backreference (\1) to ensure start/end path markers match
const regex =
/--- Context from: (.+?) ---\n([\s\S]*?)--- End of Context from: \1 ---/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(memoryContent)) !== null) {
const filePath = match[1]!;
const content = match[2]!;
results.push({
path: filePath,
tokens: estimateTokens(content),
});
}
// If no structured markers found, treat as a single memory block
if (results.length === 0 && memoryContent.trim().length > 0) {
results.push({
path: t('memory'),
tokens: estimateTokens(memoryContent),
});
}
return results;
}
export const contextCommand: SlashCommand = {
name: 'context',
get description() {
return t(
'Show context window usage breakdown. Use "/context detail" for per-item breakdown.',
);
},
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string) => {
const showDetails =
args?.trim().toLowerCase() === 'detail' ||
args?.trim().toLowerCase() === '-d';
const { config } = context.services;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t('Config not loaded.'),
},
Date.now(),
);
return;
}
// --- Gather data ---
const modelName = config.getModel() || 'unknown';
const contentGeneratorConfig = config.getContentGeneratorConfig();
const contextWindowSize =
contentGeneratorConfig.contextWindowSize ?? DEFAULT_TOKEN_LIMIT;
// Total prompt token count from API (most accurate)
const apiTotalTokens = uiTelemetryService.getLastPromptTokenCount();
// Cached content token count — when available (e.g. DashScope prefix caching),
// represents the cached overhead (system prompt + tools). Using this gives a much
// more accurate "Messages" count: promptTokens - cachedTokens = actual history tokens.
const apiCachedTokens = uiTelemetryService.getLastCachedContentTokenCount();
// 1. System prompt tokens (without memory, as memory is counted separately)
const systemPromptText = getCoreSystemPrompt(undefined, modelName);
const systemPromptTokens = estimateTokens(systemPromptText);
// 2. Tool declarations tokens (includes ALL tools: built-in, MCP, skill tool)
const toolRegistry = config.getToolRegistry();
const allTools = toolRegistry ? toolRegistry.getAllTools() : [];
const toolDeclarations = toolRegistry
? toolRegistry.getFunctionDeclarations()
: [];
const toolsJsonStr = JSON.stringify(toolDeclarations);
const allToolsTokens = estimateTokens(toolsJsonStr);
// 3. Per-tool details (for breakdown display)
const builtinTools: ContextToolDetail[] = [];
const mcpTools: ContextToolDetail[] = [];
for (const tool of allTools) {
const toolJsonStr = JSON.stringify(tool.schema);
const tokens = estimateTokens(toolJsonStr);
if (tool instanceof DiscoveredMCPTool) {
mcpTools.push({
name: `${tool.serverName}__${tool.serverToolName || tool.name}`,
tokens,
});
} else if (tool.name !== ToolNames.SKILL) {
// Built-in tool (exclude SkillTool, which is shown under Skills)
builtinTools.push({
name: tool.name,
tokens,
});
}
}
// 4. Memory files
const memoryContent = config.getUserMemory();
const memoryFiles = parseMemoryFiles(memoryContent);
const memoryFilesTokens = memoryFiles.reduce((sum, f) => sum + f.tokens, 0);
// 5. Skills (progressive disclosure)
// Two cost components:
// a) Tool definition: SkillTool's description embeds all skill
// name+description listings plus instruction text — always in context.
// b) Loaded bodies: When the model invokes a skill, the full SKILL.md
// body is injected into the conversation as a tool result. We track
// which skills have been loaded and attribute their body tokens here
// so the "Skills" category accurately reflects the total cost.
const skillTool = allTools.find((tool) => tool.name === ToolNames.SKILL);
const skillToolDefinitionTokens = skillTool
? estimateTokens(JSON.stringify(skillTool.schema))
: 0;
// Determine which skills have been loaded in this session
const loadedSkillNames: ReadonlySet<string> =
skillTool instanceof SkillTool
? skillTool.getLoadedSkillNames()
: new Set();
// Per-skill breakdown: listing cost + body cost for loaded skills
const skillManager = config.getSkillManager();
const skillConfigs = skillManager ? await skillManager.listSkills() : [];
let loadedBodiesTokens = 0;
const skills: ContextSkillDetail[] = skillConfigs.map((skill) => {
const listingTokens = estimateTokens(
`<skill>\n<name>\n${skill.name}\n</name>\n<description>\n${skill.description} (${skill.level})\n</description>\n<location>\n${skill.level}\n</location>\n</skill>`,
);
const isLoaded = loadedSkillNames.has(skill.name);
let bodyTokens: number | undefined;
if (isLoaded && skill.body) {
const baseDir = skill.filePath
? skill.filePath.replace(/\/[^/]+$/, '')
: '';
bodyTokens = estimateTokens(buildSkillLlmContent(baseDir, skill.body));
loadedBodiesTokens += bodyTokens;
}
return {
name: skill.name,
tokens: listingTokens,
loaded: isLoaded,
bodyTokens,
};
});
// Total skills cost = tool definition + loaded bodies
const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens;
// 6. Autocompact buffer
const compressionThreshold =
config.getChatCompression()?.contextPercentageThreshold ??
DEFAULT_COMPRESSION_THRESHOLD;
const autocompactBuffer =
compressionThreshold > 0
? Math.round((1 - compressionThreshold) * contextWindowSize)
: 0;
// 7. Calculate raw overhead
// allToolsTokens includes the skill tool definition; loadedBodiesTokens
// covers the on-demand skill bodies now attributed to Skills.
const rawOverhead =
systemPromptTokens +
allToolsTokens +
memoryFilesTokens +
loadedBodiesTokens;
// 8. Determine total tokens and build breakdown
const isEstimated = apiTotalTokens === 0;
// Sum of MCP tool tokens for category-level display
const mcpToolsTotalTokens = mcpTools.reduce(
(sum, tool) => sum + tool.tokens,
0,
);
let totalTokens: number;
let displaySystemPrompt: number;
let displayBuiltinTools: number;
let displayMcpTools: number;
let displayMemoryFiles: number;
let displaySkills: number;
let messagesTokens: number;
let freeSpace: number;
let detailBuiltinTools: ContextToolDetail[];
let detailMcpTools: ContextToolDetail[];
let detailMemoryFiles: ContextMemoryDetail[];
let detailSkills: ContextSkillDetail[];
if (isEstimated) {
// No API data yet: show raw overhead estimates only.
// Use 0 as totalTokens so the progress bar stays empty —
// avoids showing an inflated estimate that would "decrease"
// once real API data arrives.
totalTokens = 0;
displaySystemPrompt = systemPromptTokens;
// Skills = tool definition + loaded bodies
displaySkills = skillsTokens;
// builtinTools = allTools minus skills-definition minus mcpTools
displayBuiltinTools = Math.max(
0,
allToolsTokens - skillToolDefinitionTokens - mcpToolsTotalTokens,
);
displayMcpTools = mcpToolsTotalTokens;
displayMemoryFiles = memoryFilesTokens;
messagesTokens = 0;
// Free space accounts for the estimated overhead
freeSpace = Math.max(
0,
contextWindowSize - rawOverhead - autocompactBuffer,
);
detailBuiltinTools = builtinTools;
detailMcpTools = mcpTools;
detailMemoryFiles = memoryFiles;
detailSkills = skills;
} else {
// API data available: use actual total with proportional scaling
totalTokens = apiTotalTokens;
// When estimates overshoot API total, scale down proportionally
// so the breakdown categories add up to totalTokens.
const overheadScale =
rawOverhead > totalTokens ? totalTokens / rawOverhead : 1;
displaySystemPrompt = Math.round(systemPromptTokens * overheadScale);
const scaledAllTools = Math.round(allToolsTokens * overheadScale);
displayMemoryFiles = Math.round(memoryFilesTokens * overheadScale);
// Skills = tool definition + loaded bodies (scaled together)
displaySkills = Math.round(skillsTokens * overheadScale);
const scaledMcpTotal = Math.round(mcpToolsTotalTokens * overheadScale);
displayMcpTools = scaledMcpTotal;
// builtinTools = allTools minus skill-definition minus mcpTools
const scaledSkillDefinition = Math.round(
skillToolDefinitionTokens * overheadScale,
);
displayBuiltinTools = Math.max(
0,
scaledAllTools - scaledSkillDefinition - scaledMcpTotal,
);
const scaledOverhead =
displaySystemPrompt +
scaledAllTools +
displayMemoryFiles +
Math.round(loadedBodiesTokens * overheadScale);
// When the API reports cached content tokens (e.g. DashScope prefix caching),
// use them as the actual overhead indicator for a more accurate messages count.
// cachedTokens ≈ system prompt + tools tokens actually served from cache.
// This avoids the "messages = 0" problem caused by estimation overshoot.
if (apiCachedTokens > 0) {
messagesTokens = Math.max(0, totalTokens - apiCachedTokens);
} else {
messagesTokens = Math.max(0, totalTokens - scaledOverhead);
}
freeSpace = Math.max(
0,
contextWindowSize - totalTokens - autocompactBuffer,
);
// Scale detail items to match their parent categories
const scaleDetail = <T extends { tokens: number }>(items: T[]): T[] =>
overheadScale < 1
? items.map((item) => ({
...item,
tokens: Math.round(item.tokens * overheadScale),
}))
: items;
detailBuiltinTools = scaleDetail(builtinTools);
detailMcpTools = scaleDetail(mcpTools);
detailMemoryFiles = scaleDetail(memoryFiles);
detailSkills =
overheadScale < 1
? skills.map((item) => ({
...item,
tokens: Math.round(item.tokens * overheadScale),
bodyTokens: item.bodyTokens
? Math.round(item.bodyTokens * overheadScale)
: undefined,
}))
: skills;
}
const breakdown: ContextCategoryBreakdown = {
systemPrompt: displaySystemPrompt,
builtinTools: displayBuiltinTools,
mcpTools: displayMcpTools,
memoryFiles: displayMemoryFiles,
skills: displaySkills,
messages: messagesTokens,
freeSpace,
autocompactBuffer,
};
const contextUsageItem: HistoryItemContextUsage = {
type: MessageType.CONTEXT_USAGE,
modelName,
totalTokens,
contextWindowSize,
breakdown,
builtinTools: detailBuiltinTools,
mcpTools: detailMcpTools,
memoryFiles: detailMemoryFiles,
skills: detailSkills,
isEstimated,
showDetails,
};
context.ui.addItem(contextUsageItem, Date.now());
},
};

View file

@ -139,6 +139,10 @@ export interface OpenDialogActionReturn {
dialog:
| 'help'
| 'arena_start'
| 'arena_select'
| 'arena_stop'
| 'arena_status'
| 'auth'
| 'theme'
| 'editor'

View file

@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview BaseTextInput shared text input component with rendering
* and common readline keyboard handling.
*
* Provides:
* - Viewport line rendering from a TextBuffer with cursor display
* - Placeholder support when buffer is empty
* - Configurable border/prefix styling
* - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.)
* - An `onKeypress` interceptor so consumers can layer custom behavior
*
* Used by both InputPrompt (with syntax highlighting + complex key handling)
* and AgentComposer (with minimal customization).
*/
import type React from 'react';
import { useCallback } from 'react';
import { Box, Text } from 'ink';
import chalk from 'chalk';
import type { TextBuffer } from './shared/text-buffer.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { cpSlice, cpLen } from '../utils/textUtils.js';
import { theme } from '../semantic-colors.js';
// ─── Types ──────────────────────────────────────────────────
export interface RenderLineOptions {
/** The text content of this visual line. */
lineText: string;
/** Whether the cursor is on this visual line. */
isOnCursorLine: boolean;
/** The cursor column within this visual line (visual col, not logical). */
cursorCol: number;
/** Whether the cursor should be rendered. */
showCursor: boolean;
/** Index of this line within the rendered viewport (0-based). */
visualLineIndex: number;
/** Absolute visual line index (scrollVisualRow + visualLineIndex). */
absoluteVisualIndex: number;
/** The underlying text buffer. */
buffer: TextBuffer;
/** The first visible visual row (scroll offset). */
scrollVisualRow: number;
}
export interface BaseTextInputProps {
/** The text buffer driving this input. */
buffer: TextBuffer;
/** Called when the user submits (Enter). Buffer is cleared automatically. */
onSubmit: (text: string) => void;
/**
* Optional key interceptor. Called before default readline handling.
* Return `true` if the key was handled (skips default processing).
*/
onKeypress?: (key: Key) => boolean;
/** Whether to show the blinking block cursor. Defaults to true. */
showCursor?: boolean;
/** Placeholder text shown when the buffer is empty. */
placeholder?: string;
/** Custom prefix node (defaults to `> `). */
prefix?: React.ReactNode;
/** Border color for the input box. */
borderColor?: string;
/** Whether keyboard handling is active. Defaults to true. */
isActive?: boolean;
/**
* Custom line renderer for advanced rendering (e.g. syntax highlighting).
* When not provided, lines are rendered as plain text with cursor overlay.
*/
renderLine?: (opts: RenderLineOptions) => React.ReactNode;
}
// ─── Default line renderer ──────────────────────────────────
/**
* Renders a single visual line with an inverse-video block cursor.
* Uses codepoint-aware string operations for Unicode/emoji safety.
*/
export function defaultRenderLine({
lineText,
isOnCursorLine,
cursorCol,
showCursor,
}: RenderLineOptions): React.ReactNode {
if (!isOnCursorLine || !showCursor) {
return <Text>{lineText || ' '}</Text>;
}
const len = cpLen(lineText);
// Cursor past end of line — append inverse space
if (cursorCol >= len) {
return (
<Text>
{lineText}
{chalk.inverse(' ') + '\u200B'}
</Text>
);
}
const before = cpSlice(lineText, 0, cursorCol);
const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1);
const after = cpSlice(lineText, cursorCol + 1);
return (
<Text>
{before}
{chalk.inverse(cursorChar)}
{after}
</Text>
);
}
// ─── Component ──────────────────────────────────────────────
export const BaseTextInput: React.FC<BaseTextInputProps> = ({
buffer,
onSubmit,
onKeypress,
showCursor = true,
placeholder,
prefix,
borderColor,
isActive = true,
renderLine = defaultRenderLine,
}) => {
// ── Keyboard handling ──
const handleKey = useCallback(
(key: Key) => {
// Let the consumer intercept first
if (onKeypress?.(key)) {
return;
}
// ── Standard readline shortcuts ──
// Submit (Enter, no modifiers)
if (keyMatchers[Command.SUBMIT](key)) {
if (buffer.text.trim()) {
const text = buffer.text;
buffer.setText('');
onSubmit(text);
}
return;
}
// Newline (Shift+Enter, Ctrl+Enter, Ctrl+J)
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Escape → clear input
if (keyMatchers[Command.ESCAPE](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+C → clear input
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
}
return;
}
// Ctrl+A → home
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
// Ctrl+E → end
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+K → kill to end of line
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
// Ctrl+U → kill to start of line
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
// Ctrl+W / Alt+Backspace → delete word backward
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// Ctrl+X Ctrl+E → open in external editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
}
// Backspace
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
) {
buffer.backspace();
return;
}
// Fallthrough — delegate to buffer's built-in input handler
buffer.handleInput(key);
},
[buffer, onSubmit, onKeypress],
);
useKeypress(handleKey, { isActive });
// ── Rendering ──
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const resolvedBorderColor = borderColor ?? theme.border.focused;
const resolvedPrefix = prefix ?? (
<Text color={theme.text.accent}>{'> '}</Text>
);
return (
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
borderColor={resolvedBorderColor}
>
{resolvedPrefix}
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>{placeholder}</Text>
)
) : (
linesToRender.map((lineText, idx) => {
const absoluteVisualIndex = scrollVisualRow + idx;
const isOnCursorLine = absoluteVisualIndex === cursorVisualRow;
return (
<Box key={idx} height={1}>
{renderLine({
lineText,
isOnCursorLine,
cursorCol: cursorVisualCol,
showCursor,
visualLineIndex: idx,
absoluteVisualIndex,
buffer,
scrollVisualRow,
})}
</Box>
);
})
)}
</Box>
</Box>
);
};

View file

@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
debugMessage: '',
nightly: false,
isTrustedFolder: true,
taskStartTokens: 0,
...overrides,
}) as UIState;

View file

@ -27,7 +27,17 @@ export const Composer = () => {
const uiActions = useUIActions();
const { vimEnabled } = useVimMode();
const { showAutoAcceptIndicator } = uiState;
const { showAutoAcceptIndicator, sessionStats, taskStartTokens } = uiState;
const tokens = Object.values(sessionStats.metrics?.models ?? {}).reduce(
(acc, model) => ({
prompt: acc.prompt + (model.tokens?.prompt ?? 0),
candidates: acc.candidates + (model.tokens?.candidates ?? 0),
}),
{ prompt: 0, candidates: 0 },
);
const taskTokens = tokens.candidates - taskStartTokens;
// State for keyboard shortcuts display toggle
const [showShortcuts, setShowShortcuts] = useState(false);
@ -64,6 +74,7 @@ export const Composer = () => {
: uiState.currentLoadingPhrase
}
elapsedTime={uiState.elapsedTime}
candidatesTokens={taskTokens}
/>
)}
@ -104,8 +115,8 @@ export const Composer = () => {
{/* Exclusive area: only one component visible at a time */}
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
{!showSuggestions &&
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
{uiState.isInputActive &&
!showSuggestions &&
(showShortcuts ? (
<KeyboardShortcuts />
) : (

View file

@ -20,6 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { ArenaStartDialog } from './arena/ArenaStartDialog.js';
import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js';
import { ArenaStopDialog } from './arena/ArenaStopDialog.js';
import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js';
import { ApprovalModeDialog } from './ApprovalModeDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
@ -237,6 +241,49 @@ export const DialogManager = ({
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (uiState.activeArenaDialog === 'start') {
return (
<ArenaStartDialog
onClose={() => uiActions.closeArenaDialog()}
onConfirm={(models) => uiActions.handleArenaModelsSelected?.(models)}
/>
);
}
if (uiState.activeArenaDialog === 'status') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaStatusDialog
manager={arenaManager}
closeArenaDialog={uiActions.closeArenaDialog}
width={mainAreaWidth}
/>
);
}
}
if (uiState.activeArenaDialog === 'stop') {
return (
<ArenaStopDialog
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
if (uiState.activeArenaDialog === 'select') {
const arenaManager = config.getArenaManager();
if (arenaManager) {
return (
<ArenaSelectDialog
manager={arenaManager}
config={config}
addItem={addItem}
closeArenaDialog={uiActions.closeArenaDialog}
/>
);
}
}
if (uiState.isAuthDialogOpen || uiState.authError) {
return (
<Box flexDirection="column">

View file

@ -24,6 +24,7 @@ import {
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
SuccessMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
@ -38,6 +39,8 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core';
import { SkillsList } from './views/SkillsList.js';
import { ToolsList } from './views/ToolsList.js';
import { McpStatus } from './views/McpStatus.js';
import { ContextUsage } from './views/ContextUsage.js';
import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js';
import { InsightProgressMessage } from './messages/InsightProgressMessage.js';
interface HistoryItemDisplayProps {
@ -132,6 +135,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'info' && (
<InfoMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'success' && (
<SuccessMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'warning' && (
<WarningMessage text={itemForDisplay.text} />
)}
@ -191,6 +197,32 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'mcp_status' && (
<McpStatus {...itemForDisplay} serverStatus={getMCPServerStatus} />
)}
{itemForDisplay.type === 'context_usage' && (
<ContextUsage
modelName={itemForDisplay.modelName}
totalTokens={itemForDisplay.totalTokens}
contextWindowSize={itemForDisplay.contextWindowSize}
breakdown={itemForDisplay.breakdown}
builtinTools={itemForDisplay.builtinTools}
mcpTools={itemForDisplay.mcpTools}
memoryFiles={itemForDisplay.memoryFiles}
skills={itemForDisplay.skills}
isEstimated={itemForDisplay.isEstimated}
showDetails={itemForDisplay.showDetails}
/>
)}
{itemForDisplay.type === 'arena_agent_complete' && (
<ArenaAgentCard agent={itemForDisplay.agent} width={boxWidth} />
)}
{itemForDisplay.type === 'arena_session_complete' && (
<ArenaSessionCard
sessionStatus={itemForDisplay.sessionStatus}
task={itemForDisplay.task}
totalDurationMs={itemForDisplay.totalDurationMs}
agents={itemForDisplay.agents}
width={boxWidth}
/>
)}
{itemForDisplay.type === 'insight_progress' && (
<InsightProgressMessage progress={itemForDisplay.progress} />
)}

View file

@ -1957,6 +1957,25 @@ describe('InputPrompt', () => {
});
describe('command search (Ctrl+R when not in shell)', () => {
it('passes newest-first user history to command search', async () => {
props.shellModeActive = false;
props.userMessages = ['oldest', 'middle', 'newest'];
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
const commandSearchCall =
mockedUseReverseSearchCompletion.mock.calls.find(
([, history]) =>
Array.isArray(history) &&
history.length === 3 &&
history.includes('newest'),
);
expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']);
unmount();
});
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js';
import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js';
import { useCommandCompletion } from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@qwen-code/qwen-code-core';
@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useKeypressContext } from '../contexts/KeypressContext.js';
import {
useAgentViewState,
useAgentViewActions,
} from '../contexts/AgentViewContext.js';
import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js';
import { BaseTextInput } from './BaseTextInput.js';
import type { RenderLineOptions } from './BaseTextInput.js';
/**
* Represents an attachment (e.g., pasted image) displayed above the input prompt
@ -78,30 +83,8 @@ export interface InputPromptProps {
isEmbeddedShellFocused?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
export const calculatePromptWidths = (terminalWidth: number) => {
const widthFraction = 0.9;
const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2)
const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! '
const MIN_CONTENT_WIDTH = 2;
const innerContentWidth =
Math.floor(terminalWidth * widthFraction) -
FRAME_PADDING_AND_BORDER -
PROMPT_PREFIX_WIDTH;
const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth);
const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH;
const containerWidth = inputWidth + FRAME_OVERHEAD;
const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0));
return {
inputWidth,
containerWidth,
suggestionsWidth,
frameOverhead: FRAME_OVERHEAD,
} as const;
};
// Re-export from shared utils for backwards compatibility
export { calculatePromptWidths } from '../utils/layoutUtils.js';
// Large paste placeholder thresholds
const LARGE_PASTE_CHAR_THRESHOLD = 1000;
@ -132,6 +115,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const uiState = useUIState();
const uiActions = useUIActions();
const { pasteWorkaround } = useKeypressContext();
const { agents, agentTabBarFocused } = useAgentViewState();
const { setAgentTabBarFocused } = useAgentViewActions();
const hasAgents = agents.size > 0;
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
@ -213,9 +199,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
);
const commandSearchHistory = useMemo(
() => [...userMessages].reverse(),
[userMessages],
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchHistory,
commandSearchActive,
);
@ -225,7 +216,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetCommandSearchCompletionState =
commandSearchCompletion.resetCompletionState;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
@ -351,6 +343,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onChange: customSetTextAndResetCompletionSignal,
});
// When an arena session starts (agents appear), reset history position so
// that pressing down-arrow immediately focuses the agent tab bar instead
// of cycling through input history.
const prevHasAgentsRef = useRef(hasAgents);
useEffect(() => {
if (hasAgents && !prevHasAgentsRef.current) {
inputHistory.resetHistoryNav();
}
prevHasAgentsRef.current = hasAgents;
}, [hasAgents, inputHistory]);
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (justNavigatedHistory) {
@ -411,13 +414,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}, []);
const handleInput = useCallback(
(key: Key) => {
(key: Key): boolean => {
// When the tab bar has focus, block all non-printable keys so arrow
// keys and shortcuts don't interfere. Printable characters fall
// through to BaseTextInput's default handler so the first keystroke
// appears in the input immediately (the tab bar handler releases
// focus on the same event).
if (agentTabBarFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
return false; // let BaseTextInput type the character
}
return true; // consume non-printable keys
}
// TODO(jacobr): this special case is likely not needed anymore.
// We should probably stop supporting paste if the InputPrompt is not
// focused.
/// We want to handle paste even when not focused to support drag and drop.
if (!focus && !key.paste) {
return;
return true;
}
if (key.paste) {
@ -459,18 +479,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// Normal paste handling for small content
buffer.handleInput(key);
}
return;
return true;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
return true;
}
// Handle feedback dialog keyboard interactions when dialog is open
if (uiState.isFeedbackDialogOpen) {
// If it's one of the feedback option keys (1-4), let FeedbackDialog handle it
if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) {
return;
return true;
} else {
// For any other key, close feedback dialog temporarily and continue with normal processing
uiActions.temporaryCloseFeedbackDialog();
@ -496,7 +516,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
setShellModeActive(!shellModeActive);
buffer.setText(''); // Clear the '!' from input
return;
return true;
}
// Toggle keyboard shortcuts display with "?" when buffer is empty
@ -507,7 +527,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onToggleShortcuts
) {
onToggleShortcuts();
return;
return true;
}
// Hide shortcuts on any other key press
@ -537,33 +557,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setReverseSearchActive,
reverseSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (commandSearchActive) {
cancelSearch(
setCommandSearchActive,
commandSearchCompletion.resetCompletionState,
);
return;
return true;
}
if (shellModeActive) {
setShellModeActive(false);
resetEscapeState();
return;
return true;
}
if (completion.showSuggestions) {
completion.resetCompletionState();
setExpandedSuggestionIndex(-1);
resetEscapeState();
return;
return true;
}
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
return true;
}
setEscPressCount(1);
setShowEscapePrompt(true);
@ -579,7 +599,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCompletionState();
resetEscapeState();
}
return;
return true;
}
// Ctrl+Y: Retry the last failed request.
@ -589,19 +609,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// If no failed request exists, a message will be shown to the user.
if (keyMatchers[Command.RETRY_LAST](key)) {
uiActions.handleRetryLastPrompt();
return;
return true;
}
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
setReverseSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.CLEAR_SCREEN](key)) {
onClearScreen();
return;
return true;
}
if (reverseSearchActive || commandSearchActive) {
@ -626,29 +646,29 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (showSuggestions) {
if (keyMatchers[Command.NAVIGATION_UP](key)) {
navigateUp();
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
navigateDown();
return;
return true;
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(-1);
return;
return true;
}
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) {
setExpandedSuggestionIndex(activeSuggestionIndex);
return;
return true;
}
}
if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) {
sc.handleAutocomplete(activeSuggestionIndex);
resetState();
setActive(false);
return;
return true;
}
}
@ -660,7 +680,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(textToSubmit);
resetState();
setActive(false);
return;
return true;
}
// Prevent up/down from falling through to regular history navigation
@ -668,14 +688,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
keyMatchers[Command.NAVIGATION_UP](key) ||
keyMatchers[Command.NAVIGATION_DOWN](key)
) {
return;
return true;
}
}
// If the command is a perfect match, pressing enter should execute it.
if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) {
handleSubmitAndClear(buffer.text);
return;
return true;
}
if (completion.showSuggestions) {
@ -683,12 +703,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (keyMatchers[Command.COMPLETION_UP](key)) {
completion.navigateUp();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
completion.navigateDown();
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
return;
return true;
}
}
@ -703,7 +723,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setExpandedSuggestionIndex(-1); // Reset expansion after selection
}
}
return;
return true;
}
}
@ -711,28 +731,28 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (isAttachmentMode && attachments.length > 0) {
if (key.name === 'left') {
setSelectedAttachmentIndex((i) => Math.max(0, i - 1));
return;
return true;
}
if (key.name === 'right') {
setSelectedAttachmentIndex((i) =>
Math.min(attachments.length - 1, i + 1),
);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
// Exit attachment mode and return to input
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
if (key.name === 'backspace' || key.name === 'delete') {
handleAttachmentDelete(selectedAttachmentIndex);
return;
return true;
}
if (key.name === 'return' || key.name === 'escape') {
setIsAttachmentMode(false);
setSelectedAttachmentIndex(-1);
return;
return true;
}
// For other keys, exit attachment mode and let input handle them
setIsAttachmentMode(false);
@ -753,7 +773,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
) {
setIsAttachmentMode(true);
setSelectedAttachmentIndex(attachments.length - 1);
return;
return true;
}
if (!shellModeActive) {
@ -761,16 +781,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setCommandSearchActive(true);
setTextBeforeReverseSearch(buffer.text);
setCursorPosition(buffer.cursor);
return;
return true;
}
if (keyMatchers[Command.HISTORY_UP](key)) {
inputHistory.navigateUp();
return;
return true;
}
if (keyMatchers[Command.HISTORY_DOWN](key)) {
inputHistory.navigateDown();
return;
return true;
}
// Handle arrow-up/down for history on single-line or at edges
if (
@ -779,27 +799,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0))
) {
inputHistory.navigateUp();
return;
return true;
}
if (
keyMatchers[Command.NAVIGATION_DOWN](key) &&
(buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1)
) {
inputHistory.navigateDown();
return;
if (inputHistory.navigateDown()) {
return true;
}
if (hasAgents) {
setAgentTabBarFocused(true);
return true;
}
return true;
}
} else {
// Shell History Navigation
if (keyMatchers[Command.NAVIGATION_UP](key)) {
const prevCommand = shellHistory.getPreviousCommand();
if (prevCommand !== null) buffer.setText(prevCommand);
return;
return true;
}
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
const nextCommand = shellHistory.getNextCommand();
if (nextCommand !== null) buffer.setText(nextCommand);
return;
return true;
}
}
@ -810,7 +836,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// paste markers may not work reliably and Enter key events can leak from pasted text.
if (pasteWorkaround && recentPasteTime !== null) {
// Paste occurred recently, ignore this submit to prevent auto-execution
return;
return true;
}
const [row, col] = buffer.cursor;
@ -823,65 +849,21 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
handleSubmitAndClear(buffer.text);
}
}
return;
}
// Newline insertion
if (keyMatchers[Command.NEWLINE](key)) {
buffer.newline();
return;
}
// Ctrl+A (Home) / Ctrl+E (End)
if (keyMatchers[Command.HOME](key)) {
buffer.move('home');
return;
}
if (keyMatchers[Command.END](key)) {
buffer.move('end');
return;
}
// Ctrl+C (Clear input)
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
buffer.setText('');
resetCompletionState();
}
return;
}
// Kill line commands
if (keyMatchers[Command.KILL_LINE_RIGHT](key)) {
buffer.killLineRight();
return;
}
if (keyMatchers[Command.KILL_LINE_LEFT](key)) {
buffer.killLineLeft();
return;
}
if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) {
buffer.deleteWordLeft();
return;
}
// External editor
if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) {
buffer.openInExternalEditor();
return;
return true;
}
// Ctrl+V for clipboard image paste
if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) {
handleClipboardImage();
return;
return true;
}
// Handle backspace with placeholder-aware deletion
if (
key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h')
pendingPastes.size > 0 &&
(key.name === 'backspace' ||
key.sequence === '\x7f' ||
(key.ctrl && key.name === 'h'))
) {
const text = buffer.text;
const [row, col] = buffer.cursor;
@ -894,7 +876,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
offset += col;
// Check if we're at the end of any placeholder
let placeholderDeleted = false;
for (const placeholder of pendingPastes.keys()) {
const placeholderStart = offset - placeholder.length;
if (
@ -913,20 +894,22 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (parsed) {
freePlaceholderId(parsed.charCount, parsed.id);
}
placeholderDeleted = true;
break;
return true;
}
}
if (!placeholderDeleted) {
// Normal backspace behavior
buffer.backspace();
}
return;
// No placeholder matched — fall through to BaseTextInput's default backspace
}
// Fall back to the text buffer's default input handling for all other keys
buffer.handleInput(key);
// Ctrl+C with completion active — also reset completion state
if (keyMatchers[Command.CLEAR_INPUT](key)) {
if (buffer.text.length > 0) {
resetCompletionState();
}
// Fall through to BaseTextInput's default CLEAR_INPUT handler
}
// All remaining keys (readline shortcuts, text input) handled by BaseTextInput
return false;
},
[
focus,
@ -964,15 +947,89 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
pendingPastes,
parsePlaceholder,
freePlaceholderId,
agentTabBarFocused,
hasAgents,
setAgentTabBarFocused,
],
);
useKeypress(handleInput, { isActive: !isEmbeddedShellFocused });
const renderLineWithHighlighting = useCallback(
(opts: RenderLineOptions): React.ReactNode => {
const {
lineText,
isOnCursorLine,
cursorCol: cursorVisualColAbsolute,
showCursor: showCursorOpt,
absoluteVisualIndex,
buffer: buf,
} = opts;
const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex];
const [logicalLineIdx, logicalStartCol] = mapEntry;
const logicalLine = buf.lines[logicalLineIdx] || '';
const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx);
const linesToRender = buffer.viewportVisualLines;
const [cursorVisualRowAbsolute, cursorVisualColAbsolute] =
buffer.visualCursor;
const scrollVisualRow = buffer.visualScrollRow;
const visualStart = logicalStartCol;
const visualEnd = logicalStartCol + cpLen(lineText);
const segments = buildSegmentsForVisualSlice(
tokens,
visualStart,
visualEnd,
);
const renderedLine: React.ReactNode[] = [];
let charCount = 0;
segments.forEach((seg, segIdx) => {
const segLen = cpLen(seg.text);
let display = seg.text;
if (isOnCursorLine) {
const segStart = charCount;
const segEnd = segStart + segLen;
if (
cursorVisualColAbsolute >= segStart &&
cursorVisualColAbsolute < segEnd
) {
const charToHighlight = cpSlice(
seg.text,
cursorVisualColAbsolute - segStart,
cursorVisualColAbsolute - segStart + 1,
);
const highlighted = showCursorOpt
? chalk.inverse(charToHighlight)
: charToHighlight;
display =
cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) +
highlighted +
cpSlice(seg.text, cursorVisualColAbsolute - 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>,
);
});
if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return <Text>{renderedLine}</Text>;
},
[],
);
const getActiveCompletion = () => {
if (commandSearchActive) return commandSearchCompletion;
@ -1009,10 +1066,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
const borderColor =
isShellFocused && !isEmbeddedShellFocused
isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused
? (statusColor ?? theme.border.focused)
: theme.border.default;
const prefixNode = (
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text color={theme.text.link} aria-label={SCREEN_READER_USER_PREFIX}>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
);
return (
<>
{attachments.length > 0 && (
@ -1032,142 +1112,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
))}
</Box>
)}
<Box
borderStyle="single"
borderTop={true}
borderBottom={true}
borderLeft={false}
borderRight={false}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmitAndClear}
onKeypress={handleInput}
showCursor={showCursor}
placeholder={placeholder}
prefix={prefixNode}
borderColor={borderColor}
>
<Text
color={statusColor ?? theme.text.accent}
aria-label={statusText || undefined}
>
{shellModeActive ? (
reverseSearchActive ? (
<Text
color={theme.text.link}
aria-label={SCREEN_READER_USER_PREFIX}
>
(r:){' '}
</Text>
) : (
'!'
)
) : commandSearchActive ? (
<Text color={theme.text.accent}>(r:) </Text>
) : showYoloStyling ? (
'*'
) : (
'>'
)}{' '}
</Text>
<Box flexGrow={1} flexDirection="column">
{buffer.text.length === 0 && placeholder ? (
showCursor ? (
<Text>
{chalk.inverse(placeholder.slice(0, 1))}
<Text color={theme.text.secondary}>{placeholder.slice(1)}</Text>
</Text>
) : (
<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;
const renderedLine: React.ReactNode[] = [];
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,
);
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(
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,
);
}
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>,
);
});
if (
isOnCursorLine &&
cursorVisualColAbsolute === cpLen(lineText)
) {
// Add zero-width space after cursor to prevent Ink from trimming trailing whitespace
renderedLine.push(
<Text key={`cursor-end-${cursorVisualColAbsolute}`}>
{showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'}
</Text>,
);
}
return (
<Box key={`line-${visualIdxInRenderedSet}`} height={1}>
<Text>{renderedLine}</Text>
</Box>
);
})
)}
</Box>
</Box>
isActive={!isEmbeddedShellFocused}
renderLine={renderLineWithHighlighting}
/>
{shouldShowSuggestions && (
<Box marginLeft={2} marginRight={2}>
<SuggestionsDisplay

View file

@ -72,7 +72,8 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => {
@ -88,7 +89,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
expect(output).not.toContain('10s');
});
it('should display the currentLoadingPhrase correctly', () => {
@ -112,7 +113,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 1m)');
expect(lastFrame()).toContain('(1m · esc to cancel)');
});
it('should display the elapsedTime correctly in human-readable format', () => {
@ -124,7 +125,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
expect(lastFrame()).toContain('(2m 5s · esc to cancel)');
});
it('should render rightContent when provided', () => {
@ -155,7 +156,7 @@ describe('<LoadingIndicator />', () => {
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
expect(output).toContain('(2s · esc to cancel)');
// Transition to WaitingForConfirmation
rerender(
@ -170,7 +171,7 @@ describe('<LoadingIndicator />', () => {
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
expect(output).not.toContain('15s');
// Transition back to Idle
rerender(
@ -262,7 +263,7 @@ describe('<LoadingIndicator />', () => {
// Check for single line output
expect(output?.includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('(5s · esc to cancel)');
expect(output).toContain('Right');
});
@ -284,8 +285,8 @@ describe('<LoadingIndicator />', () => {
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[0]).not.toContain('5s');
expect(lines[1]).toContain('5s');
expect(lines[2]).toContain('Right');
}
});
@ -308,4 +309,70 @@ describe('<LoadingIndicator />', () => {
expect(lastFrame()?.includes('\n')).toBe(true);
});
});
describe('token display', () => {
it('should display output tokens inline with arrow notation', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={847} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('↓ 847 tokens');
expect(output).not.toContain('↑');
expect(output).toContain('5s');
expect(output).toContain('esc to cancel');
});
it('should not display tokens when output tokens is 0', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={0} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should not display tokens when props are undefined', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
});
it('should hide tokens in narrow terminal', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={500} />,
StreamingState.Responding,
79,
);
const output = lastFrame();
expect(output).not.toContain('↓');
expect(output).not.toContain('tokens');
expect(output).toContain('esc to cancel');
});
it('should show tokens in wide terminal with inline format', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
80,
);
const output = lastFrame();
expect(output).toContain('↓ 5.4k tokens');
});
it('should format tokens inline with time and cancel', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator {...defaultProps} candidatesTokens={5400} />,
StreamingState.Responding,
120,
);
const output = lastFrame();
expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)');
});
});
});

View file

@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { formatDuration, formatTokenCount } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { t } from '../../i18n/index.js';
@ -21,6 +21,7 @@ interface LoadingIndicatorProps {
elapsedTime: number;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
candidatesTokens?: number;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
@ -28,6 +29,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
elapsedTime,
rightContent,
thought,
candidatesTokens,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@ -39,18 +41,26 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText = thought?.subject || currentLoadingPhrase;
const outputTokens = candidatesTokens ?? 0;
const showTokens = !isNarrow && outputTokens > 0;
const timeStr =
elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000);
const tokenStr = showTokens
? ` · ↓ ${formatTokenCount(outputTokens)} tokens`
: '';
const cancelAndTimerContent =
streamingState !== StreamingState.WaitingForConfirmation
? t('(esc to cancel, {{time}})', {
time:
elapsedTime < 60
? `${elapsedTime}s`
: formatDuration(elapsedTime * 1000),
? t('({{time}}{{tokens}} · esc to cancel)', {
time: timeStr,
tokens: tokenStr,
})
: null;
return (
<Box paddingLeft={0} flexDirection="column">
<Box paddingLeft={2} flexDirection="column">
{/* Main loading line */}
<Box
width="100%"

View file

@ -1,6 +1,6 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<LoadingIndicator /> > should truncate long primary text instead of wrapping 1`] = `
"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to
Spinner cancel, 5s)"
" MockResponding This is an extremely long loading phrase that should be truncated in (5s · esc to
Spinner cancel)"
`;

View file

@ -0,0 +1,272 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentChatView displays a single in-process agent's conversation.
*
* Renders the agent's message history using HistoryItemDisplay the same
* component used by the main agent view. AgentMessage[] is converted to
* HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types
* are available without duplicating rendering logic.
*
* Layout:
* - Static area: finalized messages (efficient Ink <Static>)
* - Live area: tool groups still executing / awaiting confirmation
* - Status line: spinner while the agent is running
*
* Model text output is shown only after each round completes (no live
* streaming), which avoids per-chunk re-renders and keeps the display simple.
*/
import { Box, Text, Static } from 'ink';
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
getGitBranch,
type AgentStatusChangeEvent,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { HistoryItemDisplay } from '../HistoryItemDisplay.js';
import { ToolCallStatus } from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import { AgentHeader } from './AgentHeader.js';
// ─── Main Component ─────────────────────────────────────────
interface AgentChatViewProps {
agentId: string;
}
export const AgentChatView = ({ agentId }: AgentChatViewProps) => {
const { agents } = useAgentViewState();
const { setAgentShellFocused } = useAgentViewActions();
const uiState = useUIState();
const { historyRemountKey, availableTerminalHeight, constrainHeight } =
uiState;
const { columns: terminalWidth } = useTerminalSize();
const agent = agents.get(agentId);
const contentWidth = terminalWidth - 4;
// Force re-render on message updates and status changes.
// STREAM_TEXT is deliberately excluded — model text is shown only after
// each round completes (via committed messages), avoiding per-chunk re-renders.
const [, setRenderTick] = useState(0);
const tickRef = useRef(0);
const forceRender = useCallback(() => {
tickRef.current += 1;
setRenderTick(tickRef.current);
}, []);
useEffect(() => {
if (!agent) return;
const emitter = agent.interactiveAgent.getEventEmitter();
if (!emitter) return;
const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender();
const onToolCall = () => forceRender();
const onToolResult = () => forceRender();
const onRoundEnd = () => forceRender();
const onApproval = () => forceRender();
const onOutputUpdate = () => forceRender();
emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.on(AgentEventType.TOOL_CALL, onToolCall);
emitter.on(AgentEventType.TOOL_RESULT, onToolResult);
emitter.on(AgentEventType.ROUND_END, onRoundEnd);
emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
return () => {
emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange);
emitter.off(AgentEventType.TOOL_CALL, onToolCall);
emitter.off(AgentEventType.TOOL_RESULT, onToolResult);
emitter.off(AgentEventType.ROUND_END, onRoundEnd);
emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval);
emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate);
};
}, [agent, forceRender]);
const interactiveAgent = agent?.interactiveAgent;
const messages = interactiveAgent?.getMessages() ?? [];
const pendingApprovals = interactiveAgent?.getPendingApprovals();
const liveOutputs = interactiveAgent?.getLiveOutputs();
const shellPids = interactiveAgent?.getShellPids();
const status = interactiveAgent?.getStatus();
const isRunning =
status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING;
// Derive the active PTY PID: first shell PID among currently-executing tools.
// Resets naturally to undefined when the tool finishes (shellPids cleared).
const activePtyId =
shellPids && shellPids.size > 0
? shellPids.values().next().value
: undefined;
// Track whether the user has toggled input focus into the embedded shell.
// Mirrors the main agent's embeddedShellFocused in AppContainer.
const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false);
// Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation
// when an agent's embedded shell is focused.
useEffect(() => {
setAgentShellFocused(embeddedShellFocused);
return () => setAgentShellFocused(false);
}, [embeddedShellFocused, setAgentShellFocused]);
// Reset focus when the shell exits (activePtyId disappears).
useEffect(() => {
if (!activePtyId) setEmbeddedShellFocusedLocal(false);
}, [activePtyId]);
// Ctrl+F: toggle shell input focus when a PTY is active.
useKeypress(
(key) => {
if (key.ctrl && key.name === 'f') {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocusedLocal((prev) => !prev);
}
}
},
{ isActive: true },
);
// Convert AgentMessage[] → HistoryItem[] via adapter.
// tickRef.current in deps ensures we rebuild when events fire even if
// messages.length and pendingApprovals.size haven't changed (e.g. a
// tool result updates an existing entry in place).
const allItems = useMemo(
() =>
agentMessagesToHistoryItems(
messages,
pendingApprovals ?? new Map(),
liveOutputs,
shellPids,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
agentId,
messages.length,
pendingApprovals?.size,
liveOutputs?.size,
shellPids?.size,
tickRef.current,
],
);
// Split into committed (Static) and pending (live area).
// Any tool_group with an Executing or Confirming tool — plus everything
// after it — stays in the live area so confirmation dialogs remain
// interactive (Ink's <Static> cannot receive input).
const splitIndex = useMemo(() => {
for (let idx = allItems.length - 1; idx >= 0; idx--) {
const item = allItems[idx]!;
if (
item.type === 'tool_group' &&
item.tools.some(
(t) =>
t.status === ToolCallStatus.Executing ||
t.status === ToolCallStatus.Confirming,
)
) {
return idx;
}
}
return allItems.length; // all committed
}, [allItems]);
const committedItems = allItems.slice(0, splitIndex);
const pendingItems = allItems.slice(splitIndex);
const core = interactiveAgent?.getCore();
const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? '';
// Cache the branch — it won't change during the agent's lifetime and
// getGitBranch uses synchronous execSync which blocks the render loop.
const agentGitBranch = useMemo(
() => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''),
// eslint-disable-next-line react-hooks/exhaustive-deps
[agentId],
);
if (!agent || !interactiveAgent || !core) {
return (
<Box marginX={2}>
<Text color={theme.status.error}>
Agent &quot;{agentId}&quot; not found.
</Text>
</Box>
);
}
const agentModelId = core.modelConfig.model ?? '';
return (
<Box flexDirection="column">
{/* Committed message history.
key includes historyRemountKey: when refreshStatic() clears the
terminal it bumps the key, forcing Static to remount and re-emit
all items on the cleared screen. */}
<Static
key={`agent-${agentId}-${historyRemountKey}`}
items={[
<AgentHeader
key="agent-header"
modelId={agentModelId}
modelName={agent.modelName}
workingDirectory={agentWorkingDir}
gitBranch={agentGitBranch}
/>,
...committedItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={false}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
/>
)),
]}
>
{(item) => item}
</Static>
{/* Live area tool groups awaiting confirmation or still executing.
Must remain outside Static so confirmation dialogs are interactive.
Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */}
{pendingItems.map((item) => (
<HistoryItemDisplay
key={item.id}
item={item}
isPending={true}
terminalWidth={terminalWidth}
mainAreaWidth={contentWidth}
availableTerminalHeight={
constrainHeight ? availableTerminalHeight : undefined
}
isFocused={true}
activeShellPtyId={activePtyId ?? null}
embeddedShellFocused={embeddedShellFocused}
/>
))}
{/* Spinner */}
{isRunning && (
<Box marginX={2} marginTop={1}>
<GeminiRespondingSpinner />
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentComposer footer area for in-process agent tabs.
*
* Replaces the main Composer when an agent tab is active so that:
* - The loading indicator reflects the agent's status (not the main agent)
* - The input prompt sends messages to the agent (via enqueueMessage)
* - Keyboard events are scoped no conflict with the main InputPrompt
*
* Wraps its content in a local StreamingContext.Provider so reusable
* components like LoadingIndicator and GeminiRespondingSpinner read the
* agent's derived streaming state instead of the main agent's.
*/
import { Box, Text, useStdin } from 'ink';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AgentStatus,
isTerminalStatus,
ApprovalMode,
APPROVAL_MODES,
} from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
} from '../../contexts/AgentViewContext.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { StreamingState } from '../../types.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useTextBuffer } from '../shared/text-buffer.js';
import { calculatePromptWidths } from '../../utils/layoutUtils.js';
import { BaseTextInput } from '../BaseTextInput.js';
import { LoadingIndicator } from '../LoadingIndicator.js';
import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js';
import { AgentFooter } from './AgentFooter.js';
import { keyMatchers, Command } from '../../keyMatchers.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
// ─── Types ──────────────────────────────────────────────────
interface AgentComposerProps {
agentId: string;
}
// ─── Component ──────────────────────────────────────────────
export const AgentComposer: React.FC<AgentComposerProps> = ({ agentId }) => {
const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } =
useAgentViewState();
const {
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
} = useAgentViewActions();
const agent = agents.get(agentId);
const interactiveAgent = agent?.interactiveAgent;
const config = useConfig();
const { columns: terminalWidth } = useTerminalSize();
const { inputWidth } = calculatePromptWidths(terminalWidth);
const { stdin, setRawMode } = useStdin();
const {
status,
streamingState,
isInputActive,
elapsedTime,
lastPromptTokenCount,
} = useAgentStreamingState(interactiveAgent);
// ── Escape to cancel the active agent round ──
useKeypress(
(key) => {
if (
key.name === 'escape' &&
streamingState === StreamingState.Responding
) {
interactiveAgent?.cancelCurrentRound();
}
},
{
isActive:
streamingState === StreamingState.Responding && !agentShellFocused,
},
);
// ── Shift+Tab to cycle this agent's approval mode ──
const agentApprovalMode =
agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT;
useKeypress(
(key) => {
const isShiftTab = key.shift && key.name === 'tab';
const isWindowsTab =
process.platform === 'win32' &&
key.name === 'tab' &&
!key.ctrl &&
!key.meta;
if (isShiftTab || isWindowsTab) {
const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode);
const nextIndex =
currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length;
setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!);
}
},
{ isActive: !agentShellFocused },
);
// ── Input buffer (independent from main agent) ──
const isValidPath = useCallback((): boolean => false, []);
const buffer = useTextBuffer({
initialText: '',
viewport: { height: 3, width: inputWidth },
stdin,
setRawMode,
isValidPath,
});
// Sync agent buffer text to context so AgentTabBar can guard tab switching
useEffect(() => {
setAgentInputBufferText(buffer.text);
return () => setAgentInputBufferText('');
}, [buffer.text, setAgentInputBufferText]);
// When agent input is not active (agent running, completed, etc.),
// auto-focus the tab bar so arrow keys switch tabs directly.
// We also depend on streamingState so that transitions like
// WaitingForConfirmation → Responding re-trigger the effect — the
// approval keypress releases tab-bar focus (printable char handler),
// but isInputActive stays false throughout, so without this extra
// dependency the focus would never be restored.
useEffect(() => {
if (!isInputActive) {
setAgentTabBarFocused(true);
}
}, [isInputActive, streamingState, setAgentTabBarFocused]);
// ── Focus management between input and tab bar ──
const handleKeypress = useCallback(
(key: Key): boolean => {
// When tab bar has focus, block all non-printable keys so they don't
// act on the hidden buffer. Printable characters fall through to
// BaseTextInput naturally; the tab bar handler releases focus on the
// same event so the keystroke appears in the input immediately.
if (agentTabBarFocused) {
if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
return false; // let BaseTextInput type the character
}
return true; // consume non-printable keys
}
// Down arrow at the bottom edge (or empty buffer) → focus the tab bar
if (keyMatchers[Command.NAVIGATION_DOWN](key)) {
if (
buffer.text === '' ||
buffer.allVisualLines.length === 1 ||
buffer.visualCursor[0] === buffer.allVisualLines.length - 1
) {
setAgentTabBarFocused(true);
return true;
}
}
return false;
},
[buffer, agentTabBarFocused, setAgentTabBarFocused],
);
// ── Message queue (accumulate while streaming, flush as one prompt on idle) ──
const [messageQueue, setMessageQueue] = useState<string[]>([]);
// When agent becomes idle (and not terminal), flush queued messages.
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
messageQueue.length > 0 &&
status !== undefined &&
!isTerminalStatus(status)
) {
const combined = messageQueue.join('\n');
setMessageQueue([]);
interactiveAgent?.enqueueMessage(combined);
}
}, [streamingState, messageQueue, interactiveAgent, status]);
const handleSubmit = useCallback(
(text: string) => {
const trimmed = text.trim();
if (!trimmed || !interactiveAgent) return;
if (streamingState === StreamingState.Idle) {
interactiveAgent.enqueueMessage(trimmed);
} else {
setMessageQueue((prev) => [...prev, trimmed]);
}
},
[interactiveAgent, streamingState],
);
// ── Render ──
const statusLabel = useMemo(() => {
switch (status) {
case AgentStatus.COMPLETED:
return { text: t('Completed'), color: theme.status.success };
case AgentStatus.FAILED:
return {
text: t('Failed: {{error}}', {
error:
interactiveAgent?.getError() ??
interactiveAgent?.getLastRoundError() ??
'unknown',
}),
color: theme.status.error,
};
case AgentStatus.CANCELLED:
return { text: t('Cancelled'), color: theme.text.secondary };
default:
return null;
}
}, [status, interactiveAgent]);
// ── Approval-mode styling (mirrors main InputPrompt) ──
const isYolo = agentApprovalMode === ApprovalMode.YOLO;
const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT;
const statusColor = isYolo
? theme.status.errorDim
: isAutoAccept
? theme.status.warningDim
: undefined;
const inputBorderColor =
!isInputActive || agentTabBarFocused
? theme.border.default
: (statusColor ?? theme.border.focused);
const prefixNode = (
<Text color={statusColor ?? theme.text.accent}>{isYolo ? '*' : '>'} </Text>
);
return (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" marginTop={1}>
{/* Loading indicator mirrors main Composer but reads agent's
streaming state via the overridden StreamingContext. */}
<LoadingIndicator
currentLoadingPhrase={
streamingState === StreamingState.Responding
? t('Thinking…')
: undefined
}
elapsedTime={elapsedTime}
/>
{/* Terminal status for completed/failed agents */}
{statusLabel && (
<Box marginLeft={2}>
<Text color={statusLabel.color}>{statusLabel.text}</Text>
</Box>
)}
<QueuedMessageDisplay messageQueue={messageQueue} />
{/* Input prompt — always visible, like the main Composer */}
<BaseTextInput
buffer={buffer}
onSubmit={handleSubmit}
onKeypress={handleKeypress}
showCursor={isInputActive && !agentTabBarFocused}
placeholder={' ' + t('Send a message to this agent')}
prefix={prefixNode}
borderColor={inputBorderColor}
isActive={isInputActive && !agentShellFocused}
/>
{/* Footer: approval mode + context usage */}
<AgentFooter
approvalMode={agentApprovalMode}
promptTokenCount={lastPromptTokenCount}
contextWindowSize={
config.getContentGeneratorConfig()?.contextWindowSize
}
terminalWidth={terminalWidth}
/>
</Box>
</StreamingContext.Provider>
);
};

View file

@ -0,0 +1,66 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Lightweight footer for agent tabs showing approval mode
* and context usage. Mirrors the main Footer layout but without
* main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.).
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js';
import { ContextUsageDisplay } from '../ContextUsageDisplay.js';
import { theme } from '../../semantic-colors.js';
interface AgentFooterProps {
approvalMode: ApprovalMode | undefined;
promptTokenCount: number;
contextWindowSize: number | undefined;
terminalWidth: number;
}
export const AgentFooter: React.FC<AgentFooterProps> = ({
approvalMode,
promptTokenCount,
contextWindowSize,
terminalWidth,
}) => {
const showApproval =
approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT;
const showContext = promptTokenCount > 0 && contextWindowSize !== undefined;
if (!showApproval && !showContext) {
return null;
}
return (
<Box
justifyContent="space-between"
width="100%"
flexDirection="row"
alignItems="center"
>
<Box marginLeft={2}>
{showApproval ? (
<AutoAcceptIndicator approvalMode={approvalMode} />
) : null}
</Box>
<Box marginRight={2}>
{showContext && (
<Text color={theme.text.accent}>
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
terminalWidth={terminalWidth}
contextWindowSize={contextWindowSize!}
/>
</Text>
)}
</Box>
</Box>
);
};

View file

@ -0,0 +1,64 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Compact header for agent tabs, visually distinct from the
* main view's boxed logo header. Shows model, working directory, and git
* branch in a bordered info panel.
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
interface AgentHeaderProps {
modelId: string;
modelName?: string;
workingDirectory: string;
gitBranch?: string;
}
export const AgentHeader: React.FC<AgentHeaderProps> = ({
modelId,
modelName,
workingDirectory,
gitBranch,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const maxPathLen = Math.max(20, terminalWidth - 12);
const displayPath = shortenPath(tildeifyPath(workingDirectory), maxPathLen);
const modelText =
modelName && modelName !== modelId ? `${modelId} (${modelName})` : modelId;
return (
<Box
flexDirection="column"
marginX={2}
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingX={1}
>
<Text>
<Text color={theme.text.secondary}>{'Model: '}</Text>
<Text color={theme.text.primary}>{modelText}</Text>
</Text>
<Text>
<Text color={theme.text.secondary}>{'Path: '}</Text>
<Text color={theme.text.primary}>{displayPath}</Text>
</Text>
{gitBranch && (
<Text>
<Text color={theme.text.secondary}>{'Branch: '}</Text>
<Text color={theme.text.primary}>{gitBranch}</Text>
</Text>
)}
</Box>
);
};

View file

@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentTabBar horizontal tab strip for in-process agent views.
*
* Rendered at the top of the terminal whenever in-process agents are registered.
*
* On the main tab, Left/Right switch tabs when the input buffer is empty.
* On agent tabs, the tab bar uses an exclusive-focus model:
* - Down arrow at the input's bottom edge focuses the tab bar
* - Left/Right switch tabs only when the tab bar is focused
* - Up arrow or typing returns focus to the input
*
* Tab indicators: running, idle/completed, failed, cancelled
*/
import { Box, Text } from 'ink';
import { useState, useEffect, useCallback } from 'react';
import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core';
import {
useAgentViewState,
useAgentViewActions,
type RegisteredAgent,
} from '../../contexts/AgentViewContext.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { theme } from '../../semantic-colors.js';
// ─── Status Indicators ──────────────────────────────────────
function statusIndicator(agent: RegisteredAgent): {
symbol: string;
color: string;
} {
const status = agent.interactiveAgent.getStatus();
switch (status) {
case AgentStatus.RUNNING:
case AgentStatus.INITIALIZING:
return { symbol: '\u25CF', color: theme.status.warning }; // ● running
case AgentStatus.IDLE:
return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready)
case AgentStatus.COMPLETED:
return { symbol: '\u2713', color: theme.status.success }; // ✓ completed
case AgentStatus.FAILED:
return { symbol: '\u2717', color: theme.status.error }; // ✗ failed
case AgentStatus.CANCELLED:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled
default:
return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback
}
}
// ─── Component ──────────────────────────────────────────────
export const AgentTabBar: React.FC = () => {
const { activeView, agents, agentShellFocused, agentTabBarFocused } =
useAgentViewState();
const { switchToNext, switchToPrevious, setAgentTabBarFocused } =
useAgentViewActions();
const { embeddedShellFocused } = useUIState();
useKeypress(
(key) => {
if (embeddedShellFocused || agentShellFocused) return;
if (!agentTabBarFocused) return;
if (key.name === 'left') {
switchToPrevious();
} else if (key.name === 'right') {
switchToNext();
} else if (key.name === 'up') {
setAgentTabBarFocused(false);
} else if (
key.sequence &&
key.sequence.length === 1 &&
!key.ctrl &&
!key.meta
) {
// Printable character → return focus to input (key falls through
// to BaseTextInput's useKeypress and gets typed normally)
setAgentTabBarFocused(false);
}
},
{ isActive: true },
);
// Subscribe to STATUS_CHANGE events from all agents so the tab bar
// re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED).
// Without this, status indicators would be stale until the next unrelated render.
const [, setTick] = useState(0);
const forceRender = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
const cleanups: Array<() => void> = [];
for (const [, agent] of agents) {
const emitter = agent.interactiveAgent.getEventEmitter();
if (emitter) {
emitter.on(AgentEventType.STATUS_CHANGE, forceRender);
cleanups.push(() =>
emitter.off(AgentEventType.STATUS_CHANGE, forceRender),
);
}
}
return () => cleanups.forEach((fn) => fn());
}, [agents, forceRender]);
const isFocused = agentTabBarFocused;
// Navigation hint varies by context
const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs';
return (
<Box flexDirection="row" paddingX={1}>
{/* Main tab */}
<Box marginRight={1}>
<Text
bold={activeView === 'main'}
dimColor={!isFocused}
backgroundColor={
activeView === 'main' ? theme.border.default : undefined
}
color={
activeView === 'main' ? theme.text.primary : theme.text.secondary
}
>
{' Main '}
</Text>
</Box>
{/* Separator */}
<Text dimColor={!isFocused} color={theme.border.default}>
{'\u2502'}
</Text>
{/* Agent tabs */}
{[...agents.entries()].map(([agentId, agent]) => {
const isActive = activeView === agentId;
const { symbol, color: indicatorColor } = statusIndicator(agent);
return (
<Box key={agentId} marginLeft={1}>
<Text
bold={isActive}
dimColor={!isFocused}
backgroundColor={isActive ? theme.border.default : undefined}
color={isActive ? undefined : agent.color || theme.text.secondary}
>
{` ${agent.modelId} `}
</Text>
<Text dimColor={!isFocused} color={indicatorColor}>
{` ${symbol}`}
</Text>
</Box>
);
})}
{/* Navigation hint */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{hint}</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,510 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';
import type {
AgentMessage,
ToolCallConfirmationDetails,
} from '@qwen-code/qwen-code-core';
import { ToolCallStatus } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
function msg(
role: AgentMessage['role'],
content: string,
extra?: Partial<AgentMessage>,
): AgentMessage {
return { role, content, timestamp: 0, ...extra };
}
const noApprovals = new Map<string, ToolCallConfirmationDetails>();
function toolCallMsg(
callId: string,
toolName: string,
opts?: { description?: string; renderOutputAsMarkdown?: boolean },
): AgentMessage {
return msg('tool_call', `Tool call: ${toolName}`, {
metadata: {
callId,
toolName,
description: opts?.description ?? '',
renderOutputAsMarkdown: opts?.renderOutputAsMarkdown,
},
});
}
function toolResultMsg(
callId: string,
toolName: string,
opts?: {
success?: boolean;
resultDisplay?: string;
outputFile?: string;
},
): AgentMessage {
return msg('tool_result', `Tool ${toolName}`, {
metadata: {
callId,
toolName,
success: opts?.success ?? true,
resultDisplay: opts?.resultDisplay,
outputFile: opts?.outputFile,
},
});
}
// ─── Role mapping ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — role mapping', () => {
it('maps user message', () => {
const items = agentMessagesToHistoryItems(
[msg('user', 'hello')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ type: 'user', text: 'hello' });
});
it('maps plain assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'response')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' });
});
it('maps thought assistant message', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'thinking...', { thought: true })],
noApprovals,
);
expect(items[0]).toMatchObject({
type: 'gemini_thought',
text: 'thinking...',
});
});
it('maps assistant message with error metadata', () => {
const items = agentMessagesToHistoryItems(
[msg('assistant', 'oops', { metadata: { error: true } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'error', text: 'oops' });
});
it('maps info message with no level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'note')],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info', text: 'note' });
});
it.each([
['warning', 'warning'],
['success', 'success'],
['error', 'error'],
] as const)('maps info message with level=%s', (level, expectedType) => {
const items = agentMessagesToHistoryItems(
[msg('info', 'text', { metadata: { level } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: expectedType });
});
it('maps unknown info level → type info', () => {
const items = agentMessagesToHistoryItems(
[msg('info', 'x', { metadata: { level: 'verbose' } })],
noApprovals,
);
expect(items[0]).toMatchObject({ type: 'info' });
});
it('skips unknown roles without crashing', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'before'),
// force an unknown role
{ role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 },
msg('user', 'after'),
],
noApprovals,
);
expect(items).toHaveLength(2);
expect(items[0]).toMatchObject({ type: 'user', text: 'before' });
expect(items[1]).toMatchObject({ type: 'user', text: 'after' });
});
});
// ─── Tool grouping ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool grouping', () => {
it('merges a tool_call + tool_result pair into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')],
noApprovals,
);
expect(items).toHaveLength(1);
expect(items[0]!.type).toBe('tool_group');
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(1);
expect(group.tools[0]!.name).toBe('read_file');
});
it('merges multiple parallel tool calls into one tool_group', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read_file'),
toolCallMsg('c2', 'write_file'),
toolResultMsg('c1', 'read_file'),
toolResultMsg('c2', 'write_file'),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools).toHaveLength(2);
expect(group.tools[0]!.name).toBe('read_file');
expect(group.tools[1]!.name).toBe('write_file');
});
it('preserves tool call order by first appearance', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c2', 'second'),
toolCallMsg('c1', 'first'),
toolResultMsg('c1', 'first'),
toolResultMsg('c2', 'second'),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.name).toBe('second');
expect(group.tools[1]!.name).toBe('first');
});
it('breaks tool groups at non-tool messages', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'tool_a'),
toolResultMsg('c1', 'tool_a'),
msg('assistant', 'between'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c2', 'tool_b'),
],
noApprovals,
);
expect(items).toHaveLength(3);
expect(items[0]!.type).toBe('tool_group');
expect(items[1]!.type).toBe('gemini');
expect(items[2]!.type).toBe('tool_group');
});
it('handles tool_result arriving without a prior tool_call gracefully', () => {
const items = agentMessagesToHistoryItems(
[
toolResultMsg('c1', 'orphan', {
success: true,
resultDisplay: 'output',
}),
],
noApprovals,
);
expect(items).toHaveLength(1);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.callId).toBe('c1');
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
});
// ─── Tool status ─────────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool status', () => {
it('Executing: tool_call with no result yet', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing);
});
it('Success: tool_result with success=true', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Success);
});
it('Error: tool_result with success=false', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'write'),
toolResultMsg('c1', 'write', { success: false }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Error);
});
it('Confirming: tool_call present in pendingApprovals', () => {
const fakeApproval = {} as ToolCallConfirmationDetails;
const approvals = new Map([['c1', fakeApproval]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval);
});
it('Confirming takes priority over Executing', () => {
// pending approval AND no result yet → Confirming, not Executing
const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
approvals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming);
});
});
// ─── Tool metadata ───────────────────────────────────────────
describe('agentMessagesToHistoryItems — tool metadata', () => {
it('forwards resultDisplay from tool_result', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'read'),
toolResultMsg('c1', 'read', {
success: true,
resultDisplay: 'file contents',
}),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('file contents');
});
it('forwards renderOutputAsMarkdown from tool_call', () => {
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }),
toolResultMsg('c1', 'web_fetch', { success: true }),
],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true);
});
it('forwards description from tool_call', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.description).toBe('reading src/index.ts');
});
});
// ─── liveOutputs overlay ─────────────────────────────────────
describe('agentMessagesToHistoryItems — liveOutputs', () => {
it('uses liveOutput as resultDisplay for Executing tools', () => {
const liveOutputs = new Map([['c1', 'live stdout so far']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('live stdout so far');
});
it('ignores liveOutput for completed tools', () => {
const liveOutputs = new Map([['c1', 'stale live output']]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', {
success: true,
resultDisplay: 'final output',
}),
],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBe('final output');
});
it('falls back to entry resultDisplay when no liveOutput for callId', () => {
const liveOutputs = new Map([['other-id', 'unrelated']]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
liveOutputs,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.resultDisplay).toBeUndefined();
});
});
// ─── shellPids overlay ───────────────────────────────────────
describe('agentMessagesToHistoryItems — shellPids', () => {
it('sets ptyId for Executing tools with a known PID', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBe(12345);
});
it('does not set ptyId for completed tools', () => {
const shellPids = new Map([['c1', 12345]]);
const items = agentMessagesToHistoryItems(
[
toolCallMsg('c1', 'shell'),
toolResultMsg('c1', 'shell', { success: true }),
],
noApprovals,
undefined,
shellPids,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
it('does not set ptyId when shellPids is not provided', () => {
const items = agentMessagesToHistoryItems(
[toolCallMsg('c1', 'shell')],
noApprovals,
);
const group = items[0] as Extract<
(typeof items)[0],
{ type: 'tool_group' }
>;
expect(group.tools[0]!.ptyId).toBeUndefined();
});
});
// ─── ID stability ────────────────────────────────────────────
describe('agentMessagesToHistoryItems — ID stability', () => {
it('assigns monotonically increasing IDs', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'u1'),
msg('assistant', 'a1'),
msg('info', 'i1'),
toolCallMsg('c1', 'tool'),
toolResultMsg('c1', 'tool'),
],
noApprovals,
);
const ids = items.map((i) => i.id);
expect(ids).toEqual([0, 1, 2, 3]);
});
it('tool_group consumes one ID regardless of how many calls it contains', () => {
const items = agentMessagesToHistoryItems(
[
msg('user', 'go'),
toolCallMsg('c1', 'tool_a'),
toolCallMsg('c2', 'tool_b'),
toolResultMsg('c1', 'tool_a'),
toolResultMsg('c2', 'tool_b'),
msg('assistant', 'done'),
],
noApprovals,
);
// user=0, tool_group=1, assistant=2
expect(items.map((i) => i.id)).toEqual([0, 1, 2]);
});
it('IDs from a prefix of messages are stable when more messages are appended', () => {
const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')];
const before = agentMessagesToHistoryItems(base, noApprovals);
const after = agentMessagesToHistoryItems(
[...base, msg('info', 'i')],
noApprovals,
);
expect(after[0]!.id).toBe(before[0]!.id);
expect(after[1]!.id).toBe(before[1]!.id);
expect(after[2]!.id).toBe(2);
});
});

View file

@ -0,0 +1,194 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview agentHistoryAdapter converts AgentMessage[] to HistoryItem[].
*
* This adapter bridges the sub-agent data model (AgentMessage[] from
* AgentInteractive) to the shared rendering model (HistoryItem[] consumed by
* HistoryItemDisplay). It lives in the CLI package so that packages/core types
* are never coupled to CLI rendering types.
*
* ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[]
* only ever grows. Index-based IDs are therefore stable Ink's <Static>
* requires items never shift or be removed, which this guarantees.
*/
import type {
AgentMessage,
ToolCallConfirmationDetails,
ToolResultDisplay,
} from '@qwen-code/qwen-code-core';
import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
/**
* Convert AgentMessage[] + pendingApprovals into HistoryItem[].
*
* Consecutive tool_call / tool_result messages are merged into a single
* tool_group HistoryItem. pendingApprovals overlays confirmation state so
* ToolGroupMessage can render confirmation dialogs.
*
* liveOutputs (optional) provides real-time display data for executing tools.
* shellPids (optional) provides PTY PIDs for interactive shell tools so
* HistoryItemDisplay can render ShellInputPrompt on the active shell.
*/
export function agentMessagesToHistoryItems(
messages: readonly AgentMessage[],
pendingApprovals: ReadonlyMap<string, ToolCallConfirmationDetails>,
liveOutputs?: ReadonlyMap<string, ToolResultDisplay>,
shellPids?: ReadonlyMap<string, number>,
): HistoryItem[] {
const items: HistoryItem[] = [];
let nextId = 0;
let i = 0;
while (i < messages.length) {
const msg = messages[i]!;
// ── user ──────────────────────────────────────────────────
if (msg.role === 'user') {
items.push({ type: 'user', text: msg.content, id: nextId++ });
i++;
// ── assistant ─────────────────────────────────────────────
} else if (msg.role === 'assistant') {
if (msg.metadata?.['error']) {
items.push({ type: 'error', text: msg.content, id: nextId++ });
} else if (msg.thought) {
items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ });
} else {
items.push({ type: 'gemini', text: msg.content, id: nextId++ });
}
i++;
// ── info / warning / success / error ──────────────────────
} else if (msg.role === 'info') {
const level = msg.metadata?.['level'] as string | undefined;
const type =
level === 'warning' || level === 'success' || level === 'error'
? level
: 'info';
items.push({ type, text: msg.content, id: nextId++ });
i++;
// ── tool_call / tool_result → tool_group ──────────────────
} else if (msg.role === 'tool_call' || msg.role === 'tool_result') {
const groupId = nextId++;
const callMap = new Map<
string,
{
callId: string;
name: string;
description: string;
resultDisplay: ToolResultDisplay | string | undefined;
outputFile: string | undefined;
renderOutputAsMarkdown: boolean | undefined;
success: boolean | undefined;
}
>();
const callOrder: string[] = [];
while (
i < messages.length &&
(messages[i]!.role === 'tool_call' ||
messages[i]!.role === 'tool_result')
) {
const m = messages[i]!;
const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`;
if (m.role === 'tool_call') {
if (!callMap.has(callId)) callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: (m.metadata?.['description'] as string) ?? '',
resultDisplay: undefined,
outputFile: undefined,
renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as
| boolean
| undefined,
success: undefined,
});
} else {
// tool_result — attach to existing call entry
const entry = callMap.get(callId);
const resultDisplay = m.metadata?.['resultDisplay'] as
| ToolResultDisplay
| string
| undefined;
const outputFile = m.metadata?.['outputFile'] as string | undefined;
const success = m.metadata?.['success'] as boolean;
if (entry) {
entry.success = success;
entry.resultDisplay = resultDisplay;
entry.outputFile = outputFile;
} else {
// Result arrived without a prior tool_call message (shouldn't
// normally happen, but handle gracefully)
callOrder.push(callId);
callMap.set(callId, {
callId,
name: (m.metadata?.['toolName'] as string) ?? 'unknown',
description: '',
resultDisplay,
outputFile,
renderOutputAsMarkdown: undefined,
success,
});
}
}
i++;
}
const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => {
const entry = callMap.get(callId)!;
const approval = pendingApprovals.get(callId);
let status: ToolCallStatus;
if (approval) {
status = ToolCallStatus.Confirming;
} else if (entry.success === undefined) {
status = ToolCallStatus.Executing;
} else if (entry.success) {
status = ToolCallStatus.Success;
} else {
status = ToolCallStatus.Error;
}
// For executing tools, use live output if available (Gap 4)
const resultDisplay =
status === ToolCallStatus.Executing && liveOutputs?.has(callId)
? liveOutputs.get(callId)
: entry.resultDisplay;
return {
callId: entry.callId,
name: entry.name,
description: entry.description,
resultDisplay,
outputFile: entry.outputFile,
renderOutputAsMarkdown: entry.renderOutputAsMarkdown,
status,
confirmationDetails: approval,
ptyId:
status === ToolCallStatus.Executing
? shellPids?.get(callId)
: undefined,
};
});
items.push({ type: 'tool_group', tools, id: groupId });
} else {
// Skip unknown roles
i++;
}
}
return items;
}

View file

@ -0,0 +1,12 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export { AgentTabBar } from './AgentTabBar.js';
export { AgentChatView } from './AgentChatView.js';
export { AgentHeader } from './AgentHeader.js';
export { AgentComposer } from './AgentComposer.js';
export { AgentFooter } from './AgentFooter.js';
export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js';

View file

@ -0,0 +1,290 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import type { ArenaAgentCardData } from '../../types.js';
// ─── Helpers ────────────────────────────────────────────────
// ─── Agent Complete Card ────────────────────────────────────
interface ArenaAgentCardProps {
agent: ArenaAgentCardData;
width?: number;
}
export const ArenaAgentCard: React.FC<ArenaAgentCardProps> = ({
agent,
width,
}) => {
const { icon, text, color } = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.durationMs);
const tokens = agent.totalTokens.toLocaleString();
const inTokens = agent.inputTokens.toLocaleString();
const outTokens = agent.outputTokens.toLocaleString();
return (
<Box flexDirection="column" width={width}>
{/* Line 1: Status icon + text + label + duration */}
<Box>
<Text color={color}>
{icon} {agent.label} · {text} · {duration}
</Text>
</Box>
{/* Line 2: Tokens */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tokens: {tokens} (in {inTokens}, out {outTokens})
</Text>
</Box>
{/* Line 3: Tool Calls with colored success/error counts */}
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
Tool Calls: {agent.toolCalls}
{agent.failedToolCalls > 0 && (
<>
{' '}
(
<Text color={theme.status.success}>
{agent.successfulToolCalls}
</Text>
<Text color={theme.text.secondary}> </Text>
<Text color={theme.status.error}> {agent.failedToolCalls}</Text>)
</>
)}
</Text>
</Box>
{/* Error line (if terminated with error) */}
{agent.error && (
<Box marginLeft={2}>
<Text color={theme.status.error}>{agent.error}</Text>
</Box>
)}
</Box>
);
};
// ─── Session Complete Card ──────────────────────────────────
interface ArenaSessionCardProps {
sessionStatus: string;
task: string;
totalDurationMs: number;
agents: ArenaAgentCardData[];
width?: number;
}
/**
* Pad or truncate a string to a fixed visual width.
*/
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
/**
* Truncate a string to a maximum length, adding ellipsis if truncated.
*/
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
/**
* Calculate diff stats from a unified diff string.
* Returns the stats string and individual counts for colored rendering.
*/
function getDiffStats(diff: string | undefined): {
text: string;
additions: number;
deletions: number;
} {
if (!diff) return { text: '', additions: 0, deletions: 0 };
const lines = diff.split('\n');
let additions = 0;
let deletions = 0;
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions++;
}
}
return { text: `+${additions}/-${deletions}`, additions, deletions };
}
const MAX_MODEL_NAME_LENGTH = 35;
export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({
sessionStatus,
task,
agents,
width,
}) => {
// Truncate task for display
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
// Column widths for the agent table (unified with Arena Results)
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colChanges = 10;
const titleLabel =
sessionStatus === 'idle'
? 'Agents Status · Idle'
: sessionStatus === 'completed'
? 'Arena Complete'
: sessionStatus === 'cancelled'
? 'Arena Cancelled'
: 'Arena Failed';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width={width}
>
{/* Title - neutral color (not green) */}
<Box>
<Text bold color={theme.text.primary}>
{titleLabel}
</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Changes
</Text>
</Box>
</Box>
{/* Table separator */}
<Box>
<Text color={theme.border.default}>
{'─'.repeat((width ?? 60) - 8)}
</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const diffStats = getDiffStats(agent.diff);
return (
<Box key={agent.label}>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(agent.label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(agent.durationMs), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(
agent.totalTokens.toLocaleString(),
colTokens - 1,
'right',
)}
</Text>
</Box>
<Box width={colChanges} justifyContent="flex-end">
{diffStats.additions > 0 || diffStats.deletions > 0 ? (
<Text>
<Text color={theme.status.success}>
+{diffStats.additions}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffStats.deletions}</Text>
</Text>
) : (
<Text color={theme.text.secondary}>-</Text>
)}
</Box>
</Box>
);
})}
<Box height={1} />
{/* Hint */}
{sessionStatus === 'idle' && (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
Switch to an agent tab to continue, or{' '}
<Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
{sessionStatus === 'completed' && (
<Box>
<Text color={theme.text.secondary}>
Run <Text color={theme.text.accent}>/arena select</Text> to pick a
winner.
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,260 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
isSuccessStatus,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
interface ArenaSelectDialogProps {
manager: ArenaManager;
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaSelectDialog({
manager,
config,
addItem,
closeArenaDialog,
}: ArenaSelectDialogProps): React.JSX.Element {
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena select',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onSelect = useCallback(
async (agentId: string) => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
const agent =
mgr.getAgentState(agentId) ??
mgr.getAgentStates().find((item) => item.agentId === agentId);
const label = agent?.model.modelId || agentId;
pushMessage({
messageType: 'info',
content: `Applying changes from ${label}`,
});
const result = await mgr.applyAgentResult(agentId);
if (!result.success) {
pushMessage({
messageType: 'error',
content: `Failed to apply changes from ${label}: ${result.error}`,
});
return;
}
try {
await config.cleanupArenaRuntime(true);
} catch (err) {
pushMessage({
messageType: 'error',
content: `Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`,
});
}
pushMessage({
messageType: 'info',
content: `Applied changes from ${label} to workspace. Arena session complete.`,
});
},
[closeArenaDialog, config, pushMessage],
);
const onDiscard = useCallback(async () => {
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No arena session found. Start one with /arena start.',
});
return;
}
try {
pushMessage({
messageType: 'info',
content: 'Discarding Arena results and cleaning up…',
});
await config.cleanupArenaRuntime(true);
pushMessage({
messageType: 'info',
content: 'Arena results discarded. All worktrees cleaned up.',
});
} catch (err) {
pushMessage({
messageType: 'error',
content: `Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`,
});
}
}, [closeArenaDialog, config, pushMessage]);
const result = manager.getResult();
const agents = manager.getAgentStates();
const items: Array<DescriptiveRadioSelectItem<string>> = useMemo(
() =>
agents.map((agent) => {
const label = agent.model.modelId;
const statusInfo = getArenaStatusLabel(agent.status);
const duration = formatDuration(agent.stats.durationMs);
const tokens = agent.stats.totalTokens.toLocaleString();
// Build diff summary from cached result if available
let diffAdditions = 0;
let diffDeletions = 0;
if (isSuccessStatus(agent.status) && result) {
const agentResult = result.agents.find(
(a) => a.agentId === agent.agentId,
);
if (agentResult?.diff) {
const lines = agentResult.diff.split('\n');
for (const line of lines) {
if (line.startsWith('+') && !line.startsWith('+++')) {
diffAdditions++;
} else if (line.startsWith('-') && !line.startsWith('---')) {
diffDeletions++;
}
}
}
}
// Title: full model name (not truncated)
const title = <Text>{label}</Text>;
// Description: status, time, tokens, changes (unified with Arena Complete columns)
const description = (
<Text>
<Text color={statusInfo.color}>{statusInfo.text}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{duration}</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.text.secondary}>{tokens} tokens</Text>
{(diffAdditions > 0 || diffDeletions > 0) && (
<>
<Text color={theme.text.secondary}> · </Text>
<Text color={theme.status.success}>+{diffAdditions}</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>-{diffDeletions}</Text>
<Text color={theme.text.secondary}> lines</Text>
</>
)}
</Text>
);
return {
key: agent.agentId,
value: agent.agentId,
title,
description,
disabled: !isSuccessStatus(agent.status),
};
}),
[agents, result],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
if (key.name === 'd' && !key.ctrl && !key.meta) {
onDiscard();
}
},
{ isActive: true },
);
const task = result?.task || '';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{/* Neutral title color (not green) */}
<Text bold color={theme.text.primary}>
Arena Results
</Text>
<Box marginTop={1} flexDirection="column">
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text
color={theme.text.primary}
>{`"${task.length > 60 ? task.slice(0, 59) + '…' : task}"`}</Text>
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Select a winner to apply changes:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={items.findIndex((item) => !item.disabled)}
onSelect={(agentId: string) => {
onSelect(agentId);
}}
isFocused={true}
showNumbers={false}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to select, d to discard all, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,161 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import Link from 'ink-link';
import { AuthType } from '@qwen-code/qwen-code-core';
import { useConfig } from '../../contexts/ConfigContext.js';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MultiSelect } from '../shared/MultiSelect.js';
import { t } from '../../../i18n/index.js';
interface ArenaStartDialogProps {
onClose: () => void;
onConfirm: (selectedModels: string[]) => void;
}
const MODEL_PROVIDERS_DOCUMENTATION_URL =
'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders';
export function ArenaStartDialog({
onClose,
onConfirm,
}: ArenaStartDialogProps): React.JSX.Element {
const config = useConfig();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const modelItems = useMemo(() => {
const allModels = config.getAllConfiguredModels();
const selectableModels = allModels.filter((model) => !model.isRuntimeModel);
return selectableModels.map((model) => {
const token = `${model.authType}:${model.id}`;
const isQwenOauth = model.authType === AuthType.QWEN_OAUTH;
return {
key: token,
value: token,
label: `[${model.authType}] ${model.label}`,
disabled: isQwenOauth,
};
});
}, [config]);
const hasDisabledQwenOauth = modelItems.some((item) => item.disabled);
const selectableModelCount = modelItems.filter(
(item) => !item.disabled,
).length;
const needsMoreModels = selectableModelCount < 2;
const shouldShowMoreModelsHint =
selectableModelCount >= 2 && selectableModelCount < 3;
useKeypress(
(key) => {
if (key.name === 'escape') {
onClose();
}
},
{ isActive: true },
);
const handleConfirm = (values: string[]) => {
if (values.length < 2) {
setErrorMessage(
t('Please select at least 2 models to start an Arena session.'),
);
return;
}
setErrorMessage(null);
onConfirm(values);
};
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Select Models')}</Text>
{modelItems.length === 0 ? (
<Box marginTop={1} flexDirection="column">
<Text color={theme.status.warning}>
{t('No models available. Please configure models first.')}
</Text>
</Box>
) : (
<Box marginTop={1}>
<MultiSelect
items={modelItems}
initialIndex={0}
onConfirm={handleConfirm}
showNumbers
showScrollArrows
maxItemsToShow={10}
/>
</Box>
)}
{errorMessage && (
<Box marginTop={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{(hasDisabledQwenOauth || needsMoreModels) && (
<Box marginTop={1} flexDirection="column">
{hasDisabledQwenOauth && (
<Text color={theme.status.warning}>
{t('Note: qwen-oauth models are not supported in Arena.')}
</Text>
)}
{needsMoreModels && (
<>
<Text color={theme.status.warning}>
{t('Arena requires at least 2 models. To add more:')}
</Text>
<Text color={theme.status.warning}>
{t(
' - Run /auth to set up a Coding Plan (includes multiple models)',
)}
</Text>
<Text color={theme.status.warning}>
{t(' - Or configure modelProviders in settings.json')}
</Text>
</>
)}
</Box>
)}
{shouldShowMoreModelsHint && (
<>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Configure more models with the modelProviders guide:')}
</Text>
</Box>
<Box marginTop={0}>
<Link url={MODEL_PROVIDERS_DOCUMENTATION_URL} fallback={false}>
<Text color={theme.text.secondary} underline>
{MODEL_PROVIDERS_DOCUMENTATION_URL}
</Text>
</Link>
</Box>
</>
)}
<Box marginTop={1} flexDirection="column">
<Text color={theme.text.secondary}>
{t('Space to toggle, Enter to confirm, Esc to cancel')}
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,288 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
type ArenaManager,
type ArenaAgentState,
type InProcessBackend,
type AgentStatsSummary,
isSettledStatus,
ArenaSessionStatus,
DISPLAY_MODE,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { formatDuration } from '../../utils/formatters.js';
import { getArenaStatusLabel } from '../../utils/displayUtils.js';
const STATUS_REFRESH_INTERVAL_MS = 2000;
const IN_PROCESS_REFRESH_INTERVAL_MS = 1000;
interface ArenaStatusDialogProps {
manager: ArenaManager;
closeArenaDialog: () => void;
width?: number;
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen - 1) + '…';
}
function pad(
str: string,
len: number,
align: 'left' | 'right' = 'left',
): string {
if (str.length >= len) return str.slice(0, len);
const padding = ' '.repeat(len - str.length);
return align === 'right' ? padding + str : str + padding;
}
function getElapsedMs(agent: ArenaAgentState): number {
if (isSettledStatus(agent.status)) {
return agent.stats.durationMs;
}
return Date.now() - agent.startedAt;
}
function getSessionStatusLabel(status: ArenaSessionStatus): {
text: string;
color: string;
} {
switch (status) {
case ArenaSessionStatus.RUNNING:
return { text: 'Running', color: theme.status.success };
case ArenaSessionStatus.INITIALIZING:
return { text: 'Initializing', color: theme.status.warning };
case ArenaSessionStatus.IDLE:
return { text: 'Idle', color: theme.status.success };
case ArenaSessionStatus.COMPLETED:
return { text: 'Completed', color: theme.status.success };
case ArenaSessionStatus.CANCELLED:
return { text: 'Cancelled', color: theme.status.warning };
case ArenaSessionStatus.FAILED:
return { text: 'Failed', color: theme.status.error };
default:
return { text: String(status), color: theme.text.secondary };
}
}
const MAX_MODEL_NAME_LENGTH = 35;
export function ArenaStatusDialog({
manager,
closeArenaDialog,
width,
}: ArenaStatusDialogProps): React.JSX.Element {
const [tick, setTick] = useState(0);
// Detect in-process backend for live stats reading
const backend = manager.getBackend();
const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS;
const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null;
useEffect(() => {
const interval = isInProcess
? IN_PROCESS_REFRESH_INTERVAL_MS
: STATUS_REFRESH_INTERVAL_MS;
const timer = setInterval(() => {
setTick((prev) => prev + 1);
}, interval);
return () => clearInterval(timer);
}, [isInProcess]);
// Force re-read on every tick
void tick;
const sessionStatus = manager.getSessionStatus();
const sessionLabel = getSessionStatusLabel(sessionStatus);
const agents = manager.getAgentStates();
const task = manager.getTask() ?? '';
// For in-process mode, read live stats directly from AgentInteractive
const liveStats = useMemo(() => {
if (!inProcessBackend) return null;
const statsMap = new Map<string, AgentStatsSummary>();
for (const agent of agents) {
const interactive = inProcessBackend.getAgent(agent.agentId);
if (interactive) {
statsMap.set(agent.agentId, interactive.getStats());
}
}
return statsMap;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inProcessBackend, agents, tick]);
const maxTaskLen = 60;
const displayTask =
task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task;
const colStatus = 14;
const colTime = 8;
const colTokens = 10;
const colRounds = 8;
const colTools = 8;
useKeypress(
(key) => {
if (key.name === 'escape' || key.name === 'q' || key.name === 'return') {
closeArenaDialog();
}
},
{ isActive: true },
);
// Inner content width: total width minus border (2) and paddingX (2*2)
const innerWidth = (width ?? 80) - 6;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingX={2}
paddingY={1}
width="100%"
>
{/* Title */}
<Box>
<Text bold color={theme.text.primary}>
Arena Status
</Text>
<Text color={theme.text.secondary}> · </Text>
<Text color={sessionLabel.color}>{sessionLabel.text}</Text>
</Box>
<Box height={1} />
{/* Task */}
<Box>
<Text>
<Text color={theme.text.secondary}>Task: </Text>
<Text color={theme.text.primary}>&quot;{displayTask}&quot;</Text>
</Text>
</Box>
<Box height={1} />
{/* Table header */}
<Box>
<Box flexGrow={1}>
<Text bold color={theme.text.secondary}>
Agent
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Status
</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Time
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tokens
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Rounds
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
<Text bold color={theme.text.secondary}>
Tools
</Text>
</Box>
</Box>
{/* Separator */}
<Box>
<Text color={theme.border.default}>{'─'.repeat(innerWidth)}</Text>
</Box>
{/* Agent rows */}
{agents.map((agent) => {
const label = agent.model.modelId;
const { text: statusText, color } = getArenaStatusLabel(agent.status);
const elapsed = getElapsedMs(agent);
// Use live stats from AgentInteractive when in-process, otherwise
// fall back to the cached ArenaAgentState.stats (file-polled).
const live = liveStats?.get(agent.agentId);
const totalTokens = live?.totalTokens ?? agent.stats.totalTokens;
const rounds = live?.rounds ?? agent.stats.rounds;
const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls;
const successfulToolCalls =
live?.successfulToolCalls ?? agent.stats.successfulToolCalls;
const failedToolCalls =
live?.failedToolCalls ?? agent.stats.failedToolCalls;
return (
<Box key={agent.agentId} flexDirection="column">
<Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>
{truncate(label, MAX_MODEL_NAME_LENGTH)}
</Text>
</Box>
<Box width={colStatus} justifyContent="flex-end">
<Text color={color}>{statusText}</Text>
</Box>
<Box width={colTime} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(formatDuration(elapsed), colTime - 1, 'right')}
</Text>
</Box>
<Box width={colTokens} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(totalTokens.toLocaleString(), colTokens - 1, 'right')}
</Text>
</Box>
<Box width={colRounds} justifyContent="flex-end">
<Text color={theme.text.primary}>
{pad(String(rounds), colRounds - 1, 'right')}
</Text>
</Box>
<Box width={colTools} justifyContent="flex-end">
{failedToolCalls > 0 ? (
<Text>
<Text color={theme.status.success}>
{successfulToolCalls}
</Text>
<Text color={theme.text.secondary}>/</Text>
<Text color={theme.status.error}>{failedToolCalls}</Text>
</Text>
) : (
<Text
color={
toolCalls > 0 ? theme.status.success : theme.text.primary
}
>
{pad(String(toolCalls), colTools - 1, 'right')}
</Text>
)}
</Box>
</Box>
</Box>
);
})}
{agents.length === 0 && (
<Box>
<Text color={theme.text.secondary}>No agents registered yet.</Text>
</Box>
)}
</Box>
);
}

View file

@ -0,0 +1,213 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import {
ArenaSessionStatus,
createDebugLogger,
type Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { MessageType, type HistoryItemWithoutId } from '../../types.js';
import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js';
import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js';
const debugLogger = createDebugLogger('ARENA_STOP_DIALOG');
type StopAction = 'cleanup' | 'preserve';
interface ArenaStopDialogProps {
config: Config;
addItem: UseHistoryManagerReturn['addItem'];
closeArenaDialog: () => void;
}
export function ArenaStopDialog({
config,
addItem,
closeArenaDialog,
}: ArenaStopDialogProps): React.JSX.Element {
const [isProcessing, setIsProcessing] = useState(false);
const pushMessage = useCallback(
(result: { messageType: 'info' | 'error'; content: string }) => {
const item: HistoryItemWithoutId = {
type:
result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR,
text: result.content,
};
addItem(item, Date.now());
try {
const chatRecorder = config.getChatRecordingService();
chatRecorder?.recordSlashCommand({
phase: 'result',
rawCommand: '/arena stop',
outputHistoryItems: [{ ...item } as Record<string, unknown>],
});
} catch {
// Best-effort recording
}
},
[addItem, config],
);
const onStop = useCallback(
async (action: StopAction) => {
if (isProcessing) return;
setIsProcessing(true);
closeArenaDialog();
const mgr = config.getArenaManager();
if (!mgr) {
pushMessage({
messageType: 'error',
content: 'No running Arena session found.',
});
return;
}
try {
const sessionStatus = mgr.getSessionStatus();
if (
sessionStatus === ArenaSessionStatus.RUNNING ||
sessionStatus === ArenaSessionStatus.INITIALIZING
) {
pushMessage({
messageType: 'info',
content: 'Stopping Arena agents…',
});
await mgr.cancel();
}
await mgr.waitForSettled();
pushMessage({
messageType: 'info',
content: 'Cleaning up Arena resources…',
});
if (action === 'preserve') {
await mgr.cleanupRuntime();
} else {
await mgr.cleanup();
}
config.setArenaManager(null);
if (action === 'preserve') {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. Worktrees and session files were preserved. ' +
'Use /arena select --discard to manually clean up later.',
});
} else {
pushMessage({
messageType: 'info',
content:
'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.',
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
debugLogger.error('Failed to stop Arena session:', error);
pushMessage({
messageType: 'error',
content: `Failed to stop Arena session: ${message}`,
});
}
},
[isProcessing, closeArenaDialog, config, pushMessage],
);
const configPreserve =
config.getAgentsSettings().arena?.preserveArtifacts ?? false;
const items: Array<DescriptiveRadioSelectItem<StopAction>> = useMemo(
() => [
{
key: 'cleanup',
value: 'cleanup' as StopAction,
title: <Text>Stop and clean up</Text>,
description: (
<Text color={theme.text.secondary}>
Remove all worktrees and session files
</Text>
),
},
{
key: 'preserve',
value: 'preserve' as StopAction,
title: <Text>Stop and preserve artifacts</Text>,
description: (
<Text color={theme.text.secondary}>
Keep worktrees and session files for later inspection
</Text>
),
},
],
[],
);
const defaultIndex = configPreserve ? 1 : 0;
useKeypress(
(key) => {
if (key.name === 'escape') {
closeArenaDialog();
}
},
{ isActive: !isProcessing },
);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={theme.text.primary}>
Stop Arena Session
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Choose what to do with Arena artifacts:
</Text>
</Box>
<Box marginTop={1} flexDirection="column">
<DescriptiveRadioButtonSelect
items={items}
initialIndex={defaultIndex}
onSelect={(action: StopAction) => {
onStop(action);
}}
isFocused={!isProcessing}
showNumbers={false}
/>
</Box>
{configPreserve && (
<Box marginTop={1}>
<Text color={theme.text.secondary} dimColor>
Default: preserve (agents.arena.preserveArtifacts is enabled)
</Text>
</Box>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
Enter to confirm, Esc to cancel
</Text>
</Box>
</Box>
);
}

View file

@ -75,7 +75,7 @@ export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix=""
prefix=""
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>

View file

@ -66,7 +66,11 @@ export function DescriptiveRadioButtonSelect<T>({
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" key={item.key}>
<Text color={titleColor}>{item.title}</Text>
<Text color={theme.text.secondary}>{item.description}</Text>
{typeof item.description === 'string' ? (
<Text color={theme.text.secondary}>{item.description}</Text>
) : (
item.description
)}
</Box>
)}
/>

View file

@ -0,0 +1,193 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useSelectionList } from '../../hooks/useSelectionList.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import type { SelectionListItem } from '../../hooks/useSelectionList.js';
export interface MultiSelectItem<T> extends SelectionListItem<T> {
label: string;
}
export interface MultiSelectProps<T> {
items: Array<MultiSelectItem<T>>;
initialIndex?: number;
initialSelectedKeys?: string[];
onConfirm: (selectedValues: T[]) => void;
onChange?: (selectedValues: T[]) => void;
onHighlight?: (value: T) => void;
isFocused?: boolean;
showNumbers?: boolean;
showScrollArrows?: boolean;
maxItemsToShow?: number;
}
const EMPTY_SELECTED_KEYS: string[] = [];
function getSelectedValues<T>(
items: Array<MultiSelectItem<T>>,
selectedKeys: Set<string>,
): T[] {
return items
.filter((item) => selectedKeys.has(item.key))
.map((item) => item.value);
}
export function MultiSelect<T>({
items,
initialIndex = 0,
initialSelectedKeys = EMPTY_SELECTED_KEYS,
onConfirm,
onChange,
onHighlight,
isFocused = true,
showNumbers = true,
showScrollArrows = false,
maxItemsToShow = 10,
}: MultiSelectProps<T>): React.JSX.Element {
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
() => new Set(initialSelectedKeys),
);
const [scrollOffset, setScrollOffset] = useState(0);
useEffect(() => {
setSelectedKeys((prev) => {
const next = new Set(initialSelectedKeys);
if (
prev.size === next.size &&
Array.from(next).every((key) => prev.has(key))
) {
return prev;
}
return next;
});
}, [initialSelectedKeys]);
const { activeIndex } = useSelectionList({
items,
initialIndex,
isFocused,
// Disable numeric quick-select in useSelectionList — in a multi-select
// context, onSelect triggers onConfirm (submit), so numeric keys would
// accidentally submit the dialog instead of toggling checkboxes.
// Numbers are still rendered visually via the showNumbers prop below.
showNumbers: false,
onHighlight,
onSelect: () => {
onConfirm(getSelectedValues(items, selectedKeys));
},
});
const toggleSelectionAtIndex = useCallback(
(index: number) => {
const item = items[index];
if (!item || item.disabled) {
return;
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(item.key)) {
next.delete(item.key);
} else {
next.add(item.key);
}
return next;
});
},
[items],
);
useEffect(() => {
onChange?.(getSelectedValues(items, selectedKeys));
}, [items, selectedKeys, onChange]);
useKeypress(
(key) => {
if (key.name === 'space' || key.sequence === ' ') {
toggleSelectionAtIndex(activeIndex);
}
},
{ isActive: isFocused },
);
useEffect(() => {
const newScrollOffset = Math.max(
0,
Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow),
);
if (activeIndex < scrollOffset) {
setScrollOffset(activeIndex);
} else if (activeIndex >= scrollOffset + maxItemsToShow) {
setScrollOffset(newScrollOffset);
}
}, [activeIndex, items.length, scrollOffset, maxItemsToShow]);
const visibleItems = useMemo(
() => items.slice(scrollOffset, scrollOffset + maxItemsToShow),
[items, scrollOffset, maxItemsToShow],
);
const numberColumnWidth = String(items.length).length;
const hasMoreAbove = scrollOffset > 0;
const hasMoreBelow = scrollOffset + maxItemsToShow < items.length;
const moreAboveCount = scrollOffset;
const moreBelowCount = Math.max(
0,
items.length - (scrollOffset + maxItemsToShow),
);
return (
<Box flexDirection="column">
{showScrollArrows && hasMoreAbove && (
<Text color={theme.text.secondary}> {moreAboveCount} more above</Text>
)}
{visibleItems.map((item, index) => {
const itemIndex = scrollOffset + index;
const isActive = activeIndex === itemIndex;
const isChecked = selectedKeys.has(item.key);
const itemNumberText = `${String(itemIndex + 1).padStart(
numberColumnWidth,
)}.`;
const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]';
let textColor = theme.text.primary;
if (item.disabled) {
textColor = theme.text.secondary;
} else if (isActive) {
textColor = theme.status.success;
} else if (isChecked) {
textColor = theme.text.accent;
}
return (
<Box key={item.key} alignItems="flex-start">
<Box minWidth={4} flexShrink={0}>
<Text color={textColor}>{checkboxText}</Text>
</Box>
{showNumbers && (
<Box marginRight={1} minWidth={itemNumberText.length}>
<Text color={textColor}>{itemNumberText}</Text>
</Box>
)}
<Box flexGrow={1}>
<Text color={textColor}>{item.label}</Text>
</Box>
</Box>
);
})}
{showScrollArrows && hasMoreBelow && (
<Text color={theme.text.secondary}> {moreBelowCount} more below</Text>
)}
</Box>
);
}

View file

@ -1907,8 +1907,8 @@ export function useTextBuffer({
else if (key.ctrl && key.name === 'b') move('left');
else if (key.name === 'right' && !key.meta && !key.ctrl) move('right');
else if (key.ctrl && key.name === 'f') move('right');
else if (key.name === 'up') move('up');
else if (key.name === 'down') move('down');
else if (key.name === 'up' && !key.shift) move('up');
else if (key.name === 'down' && !key.shift) move('down');
else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft');
else if (key.meta && key.name === 'b') move('wordLeft');
else if ((key.ctrl || key.meta) && key.name === 'right')

View file

@ -8,7 +8,7 @@ import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import type {
TaskResultDisplay,
SubagentStatsSummary,
AgentStatsSummary,
Config,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
@ -467,7 +467,7 @@ const ExecutionSummaryDetails: React.FC<{
* Tool usage statistics component
*/
const ToolUsageStats: React.FC<{
executionSummary?: SubagentStatsSummary;
executionSummary?: AgentStatsSummary;
}> = ({ executionSummary }) => {
if (!executionSummary) {
return (

View file

@ -0,0 +1,424 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type {
ContextCategoryBreakdown,
ContextToolDetail,
ContextMemoryDetail,
ContextSkillDetail,
} from '../../types.js';
import { t } from '../../../i18n/index.js';
// Progress bar characters
const FILLED = '\u2588'; // █ - filled block
const BUFFER = '\u2592'; // ▒ - medium shade (autocompact buffer)
const EMPTY = '\u2591'; // ░ - light shade (free space)
const CONTENT_WIDTH = 56;
interface ContextUsageProps {
modelName: string;
totalTokens: number;
contextWindowSize: number;
breakdown: ContextCategoryBreakdown;
builtinTools: ContextToolDetail[];
mcpTools: ContextToolDetail[];
memoryFiles: ContextMemoryDetail[];
skills: ContextSkillDetail[];
/** True when totalTokens is estimated (no API call yet) */
isEstimated?: boolean;
/** When true, show per-item detail breakdowns. Default: false (compact). */
showDetails?: boolean;
}
/**
* Truncate a string to maxLen, appending '…' if truncated.
*/
function truncateName(name: string, maxLen: number): string {
if (name.length <= maxLen) return name;
return name.slice(0, maxLen - 1) + '\u2026';
}
/**
* Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k")
*/
function formatTokens(tokens: number): string {
if (tokens >= 1000) {
return `${(tokens / 1000).toFixed(1)}k`;
}
return `${tokens}`;
}
/**
* Render a three-segment progress bar: used | autocompact buffer | free space.
*/
const ProgressBar: React.FC<{
usedPercentage: number;
bufferPercentage: number;
width: number;
}> = ({ usedPercentage, bufferPercentage, width }) => {
const usedCount = Math.round((Math.min(usedPercentage, 100) / 100) * width);
const bufferCount = Math.round(
(Math.min(bufferPercentage, 100 - usedPercentage) / 100) * width,
);
const freeCount = Math.max(0, width - usedCount - bufferCount);
const usedStr = FILLED.repeat(Math.max(0, usedCount));
const freeStr = EMPTY.repeat(Math.max(0, freeCount));
const bufferStr = BUFFER.repeat(Math.max(0, bufferCount));
// Used color: accent by default, warning/error at high usage.
let usedColor = theme.text.accent;
if (usedPercentage > 80) {
usedColor = theme.status.error;
} else if (usedPercentage > 60) {
usedColor = theme.status.warning;
}
return (
<Text>
<Text color={usedColor}>{usedStr}</Text>
<Text color={theme.text.secondary}>{freeStr}</Text>
<Text color={theme.status.warning}>{bufferStr}</Text>
</Text>
);
};
/**
* A row showing a category with its token count and percentage.
*/
const CategoryRow: React.FC<{
symbol: string;
label: string;
tokens: number;
contextWindowSize: number;
symbolColor?: string;
}> = ({ symbol, label, tokens, contextWindowSize, symbolColor }) => {
const percentage = ((tokens / contextWindowSize) * 100).toFixed(1);
const tokenStr = `${formatTokens(tokens)} ${t('tokens')} (${percentage}%)`;
return (
<Box width={CONTENT_WIDTH}>
<Box width={2}>
<Text color={symbolColor || theme.text.secondary}>{symbol}</Text>
</Box>
<Box width={24}>
<Text color={theme.text.primary}>{label}</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>{tokenStr}</Text>
</Box>
</Box>
);
};
/**
* A detail row for individual items (MCP tools, memory files, skills).
*/
const DETAIL_NAME_MAX_LEN = 30;
const DetailRow: React.FC<{
name: string;
tokens: number;
}> = ({ name, tokens }) => {
const tokenStr =
tokens > 0 ? `${formatTokens(tokens)} ${t('tokens')}` : `0 ${t('tokens')}`;
return (
<Box width={CONTENT_WIDTH} paddingLeft={2}>
<Text color={theme.text.secondary}>{'\u2514'} </Text>
<Box width={32}>
<Text color={theme.text.link}>
{truncateName(name, DETAIL_NAME_MAX_LEN)}
</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>{tokenStr}</Text>
</Box>
</Box>
);
};
export const ContextUsage: React.FC<ContextUsageProps> = ({
modelName,
totalTokens,
contextWindowSize,
breakdown,
builtinTools,
mcpTools,
memoryFiles,
skills,
isEstimated,
showDetails = false,
}) => {
const percentage =
contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0;
// Sort detail items by token count (descending) for better readability
const sortedBuiltinTools = [...builtinTools].sort(
(a, b) => b.tokens - a.tokens,
);
const sortedMcpTools = [...mcpTools].sort((a, b) => b.tokens - a.tokens);
const sortedMemoryFiles = [...memoryFiles].sort(
(a, b) => b.tokens - a.tokens,
);
// Sort skills: loaded first, then by total token cost descending
const sortedSkills = [...skills].sort((a, b) => {
if (a.loaded !== b.loaded) return a.loaded ? -1 : 1;
const aTotal = a.tokens + (a.bodyTokens ?? 0);
const bTotal = b.tokens + (b.bodyTokens ?? 0);
return bTotal - aTotal;
});
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
>
{/* Title */}
<Text bold color={theme.text.accent}>
{t('Context Usage')}
</Text>
<Box height={1} />
{isEstimated ? (
<>
{/* No API data yet — show hint instead of progress bar */}
<Box marginBottom={1}>
<Text color={theme.status.warning} italic>
{t('No API response yet. Send a message to see actual usage.')}
</Text>
</Box>
{/* Estimated overhead categories */}
<Text bold color={theme.text.primary}>
{t('Estimated pre-conversation overhead')}
</Text>
<Text color={theme.text.secondary}>
{t('Model')}: {modelName}
{' '}
{t('Context window')}: {formatTokens(contextWindowSize)}{' '}
{t('tokens')}
</Text>
<Box height={1} />
</>
) : (
<>
{/* Model name + context window info */}
<Box width={CONTENT_WIDTH} marginBottom={1}>
<Text color={theme.text.secondary}>
{t('Model')}: {modelName}
</Text>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>
{t('Context window')}: {formatTokens(contextWindowSize)}{' '}
{t('tokens')}
</Text>
</Box>
</Box>
{/* Progress bar — three segments: used | free | buffer */}
<Box width={CONTENT_WIDTH}>
<ProgressBar
usedPercentage={Math.min(percentage, 100)}
bufferPercentage={
contextWindowSize > 0
? (breakdown.autocompactBuffer / contextWindowSize) * 100
: 0
}
width={CONTENT_WIDTH}
/>
</Box>
<Box height={1} />
{/* Legend — same layout as CategoryRow for alignment */}
<CategoryRow
symbol={FILLED}
label={t('Used')}
tokens={totalTokens}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={EMPTY}
label={t('Free')}
tokens={breakdown.freeSpace}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.secondary}
/>
<CategoryRow
symbol={BUFFER}
label={t('Autocompact buffer')}
tokens={breakdown.autocompactBuffer}
contextWindowSize={contextWindowSize}
symbolColor={theme.status.warning}
/>
<Box height={1} />
{/* Breakdown header */}
<Text bold color={theme.text.primary}>
{t('Usage by category')}
</Text>
</>
)}
<CategoryRow
symbol={FILLED}
label={t('System prompt')}
tokens={breakdown.systemPrompt}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={FILLED}
label={t('Built-in tools')}
tokens={breakdown.builtinTools}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
{breakdown.mcpTools > 0 && (
<CategoryRow
symbol={FILLED}
label={t('MCP tools')}
tokens={breakdown.mcpTools}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
)}
<CategoryRow
symbol={FILLED}
label={t('Memory files')}
tokens={breakdown.memoryFiles}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
<CategoryRow
symbol={FILLED}
label={t('Skills')}
tokens={breakdown.skills}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
{/* Only show Messages when we have real API data */}
{!isEstimated && (
<CategoryRow
symbol={FILLED}
label={t('Messages')}
tokens={breakdown.messages}
contextWindowSize={contextWindowSize}
symbolColor={theme.text.accent}
/>
)}
{showDetails ? (
<>
{/* Built-in tools detail */}
{sortedBuiltinTools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Built-in tools')}
</Text>
{sortedBuiltinTools.map((tool) => (
<DetailRow
key={tool.name}
name={tool.name}
tokens={tool.tokens}
/>
))}
</Box>
)}
{/* MCP Tools detail */}
{sortedMcpTools.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('MCP tools')}
</Text>
{sortedMcpTools.map((tool) => (
<DetailRow
key={tool.name}
name={tool.name}
tokens={tool.tokens}
/>
))}
</Box>
)}
{/* Memory files detail */}
{sortedMemoryFiles.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Memory files')}
</Text>
{sortedMemoryFiles.map((file) => (
<DetailRow
key={file.path}
name={file.path}
tokens={file.tokens}
/>
))}
</Box>
)}
{/* Skills detail */}
{sortedSkills.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
{t('Skills')}
</Text>
{sortedSkills.map((skill) => (
<Box key={skill.name} flexDirection="column">
<Box width={CONTENT_WIDTH} paddingLeft={2}>
<Text color={theme.text.secondary}>{'\u2514'} </Text>
<Box width={32}>
<Text color={theme.text.link}>
{truncateName(skill.name, DETAIL_NAME_MAX_LEN)}
</Text>
{skill.loaded && (
<Text color={theme.status.success}> {t('active')}</Text>
)}
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.text.secondary}>
{formatTokens(skill.tokens)} {t('tokens')}
</Text>
</Box>
</Box>
{skill.loaded &&
skill.bodyTokens != null &&
skill.bodyTokens > 0 && (
<Box width={CONTENT_WIDTH} paddingLeft={4}>
<Text color={theme.text.secondary}>{' \u2514'} </Text>
<Box width={30}>
<Text color={theme.text.secondary} italic>
{t('body loaded')}
</Text>
</Box>
<Box flexGrow={1} justifyContent="flex-end">
<Text color={theme.status.success}>
+{formatTokens(skill.bodyTokens)} {t('tokens')}
</Text>
</Box>
</Box>
)}
</Box>
))}
</Box>
)}
</>
) : (
<Box marginTop={1}>
<Text color={theme.text.secondary} italic>
{t('Run /context detail for per-item breakdown.')}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,308 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview AgentViewContext React context for in-process agent view switching.
*
* Tracks which view is active (main or an agent tab) and the set of registered
* AgentInteractive instances. Consumed by AgentTabBar, AgentChatView, and
* DefaultAppLayout to implement tab-based agent navigation.
*
* Kept separate from UIStateContext to avoid bloating the main state with
* in-process-only concerns and to make the feature self-contained.
*/
import {
createContext,
useContext,
useCallback,
useMemo,
useState,
} from 'react';
import {
type AgentInteractive,
type ApprovalMode,
type Config,
} from '@qwen-code/qwen-code-core';
import { useArenaInProcess } from '../hooks/useArenaInProcess.js';
// ─── Types ──────────────────────────────────────────────────
export interface RegisteredAgent {
interactiveAgent: AgentInteractive;
/** Model identifier shown in tabs and paths (e.g. "glm-5"). */
modelId: string;
/** Human-friendly model name (e.g. "GLM 5"). */
modelName?: string;
color: string;
}
export interface AgentViewState {
/** 'main' or an agentId */
activeView: string;
/** Registered in-process agents keyed by agentId */
agents: ReadonlyMap<string, RegisteredAgent>;
/** Whether any agent tab's embedded shell currently has input focus. */
agentShellFocused: boolean;
/** Current text in the active agent tab's input buffer (empty when on main). */
agentInputBufferText: string;
/** Whether the tab bar has keyboard focus (vs the agent input). */
agentTabBarFocused: boolean;
/** Per-agent approval modes (keyed by agentId). */
agentApprovalModes: ReadonlyMap<string, ApprovalMode>;
}
export interface AgentViewActions {
switchToMain(): void;
switchToAgent(agentId: string): void;
switchToNext(): void;
switchToPrevious(): void;
registerAgent(
agentId: string,
interactiveAgent: AgentInteractive,
modelId: string,
color: string,
modelName?: string,
): void;
unregisterAgent(agentId: string): void;
unregisterAll(): void;
setAgentShellFocused(focused: boolean): void;
setAgentInputBufferText(text: string): void;
setAgentTabBarFocused(focused: boolean): void;
setAgentApprovalMode(agentId: string, mode: ApprovalMode): void;
}
// ─── Context ────────────────────────────────────────────────
const AgentViewStateContext = createContext<AgentViewState | null>(null);
const AgentViewActionsContext = createContext<AgentViewActions | null>(null);
// ─── Defaults (used when no provider is mounted) ────────────
const DEFAULT_STATE: AgentViewState = {
activeView: 'main',
agents: new Map(),
agentShellFocused: false,
agentInputBufferText: '',
agentTabBarFocused: false,
agentApprovalModes: new Map(),
};
const noop = () => {};
const DEFAULT_ACTIONS: AgentViewActions = {
switchToMain: noop,
switchToAgent: noop,
switchToNext: noop,
switchToPrevious: noop,
registerAgent: noop,
unregisterAgent: noop,
unregisterAll: noop,
setAgentShellFocused: noop,
setAgentInputBufferText: noop,
setAgentTabBarFocused: noop,
setAgentApprovalMode: noop,
};
// ─── Hook: useAgentViewState ────────────────────────────────
export function useAgentViewState(): AgentViewState {
return useContext(AgentViewStateContext) ?? DEFAULT_STATE;
}
// ─── Hook: useAgentViewActions ──────────────────────────────
export function useAgentViewActions(): AgentViewActions {
return useContext(AgentViewActionsContext) ?? DEFAULT_ACTIONS;
}
// ─── Provider ───────────────────────────────────────────────
interface AgentViewProviderProps {
config?: Config;
children: React.ReactNode;
}
export function AgentViewProvider({
config,
children,
}: AgentViewProviderProps) {
const [activeView, setActiveView] = useState<string>('main');
const [agents, setAgents] = useState<Map<string, RegisteredAgent>>(
() => new Map(),
);
const [agentShellFocused, setAgentShellFocused] = useState(false);
const [agentInputBufferText, setAgentInputBufferText] = useState('');
const [agentTabBarFocused, setAgentTabBarFocused] = useState(false);
const [agentApprovalModes, setAgentApprovalModes] = useState<
Map<string, ApprovalMode>
>(() => new Map());
// ── Navigation ──
const switchToMain = useCallback(() => {
setActiveView('main');
setAgentTabBarFocused(false);
}, []);
const switchToAgent = useCallback(
(agentId: string) => {
if (agents.has(agentId)) {
setActiveView(agentId);
}
},
[agents],
);
const switchToNext = useCallback(() => {
const ids = ['main', ...agents.keys()];
const currentIndex = ids.indexOf(activeView);
const nextIndex = (currentIndex + 1) % ids.length;
setActiveView(ids[nextIndex]!);
}, [agents, activeView]);
const switchToPrevious = useCallback(() => {
const ids = ['main', ...agents.keys()];
const currentIndex = ids.indexOf(activeView);
const prevIndex = (currentIndex - 1 + ids.length) % ids.length;
setActiveView(ids[prevIndex]!);
}, [agents, activeView]);
// ── Registration ──
const registerAgent = useCallback(
(
agentId: string,
interactiveAgent: AgentInteractive,
modelId: string,
color: string,
modelName?: string,
) => {
setAgents((prev) => {
const next = new Map(prev);
next.set(agentId, {
interactiveAgent,
modelId,
color,
modelName,
});
return next;
});
// Seed approval mode from the agent's own config
const mode = interactiveAgent.getCore().runtimeContext.getApprovalMode();
setAgentApprovalModes((prev) => {
const next = new Map(prev);
next.set(agentId, mode);
return next;
});
},
[],
);
const unregisterAgent = useCallback((agentId: string) => {
setAgents((prev) => {
if (!prev.has(agentId)) return prev;
const next = new Map(prev);
next.delete(agentId);
return next;
});
setAgentApprovalModes((prev) => {
if (!prev.has(agentId)) return prev;
const next = new Map(prev);
next.delete(agentId);
return next;
});
setActiveView((current) => (current === agentId ? 'main' : current));
}, []);
const unregisterAll = useCallback(() => {
setAgents(new Map());
setAgentApprovalModes(new Map());
setActiveView('main');
setAgentTabBarFocused(false);
}, []);
const setAgentApprovalMode = useCallback(
(agentId: string, mode: ApprovalMode) => {
// Update the agent's runtime config so tool scheduling picks it up
const agent = agents.get(agentId);
if (agent) {
agent.interactiveAgent.getCore().runtimeContext.setApprovalMode(mode);
}
// Update UI state
setAgentApprovalModes((prev) => {
const next = new Map(prev);
next.set(agentId, mode);
return next;
});
},
[agents],
);
// ── Memoized values ──
const state: AgentViewState = useMemo(
() => ({
activeView,
agents,
agentShellFocused,
agentInputBufferText,
agentTabBarFocused,
agentApprovalModes,
}),
[
activeView,
agents,
agentShellFocused,
agentInputBufferText,
agentTabBarFocused,
agentApprovalModes,
],
);
const actions: AgentViewActions = useMemo(
() => ({
switchToMain,
switchToAgent,
switchToNext,
switchToPrevious,
registerAgent,
unregisterAgent,
unregisterAll,
setAgentShellFocused,
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
}),
[
switchToMain,
switchToAgent,
switchToNext,
switchToPrevious,
registerAgent,
unregisterAgent,
unregisterAll,
setAgentShellFocused,
setAgentInputBufferText,
setAgentTabBarFocused,
setAgentApprovalMode,
],
);
// ── Arena in-process bridge ──
// Bridge arena manager events to agent registration. The hook is kept
// in its own file for separation of concerns; it's called here so the
// provider is the single owner of agent tab lifecycle.
useArenaInProcess(config ?? null, actions);
return (
<AgentViewStateContext.Provider value={state}>
<AgentViewActionsContext.Provider value={actions}>
{children}
</AgentViewActionsContext.Provider>
</AgentViewStateContext.Provider>
);
}

View file

@ -17,6 +17,7 @@ import {
import { type SettingScope } from '../../config/settings.js';
import { type CodingPlanRegion } from '../../constants/codingPlan.js';
import type { AuthState } from '../types.js';
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
export interface OpenAICredentials {
apiKey: string;
@ -54,6 +55,9 @@ export interface UIActions {
exitEditorDialog: () => void;
closeSettingsDialog: () => void;
closeModelDialog: () => void;
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
closeArenaDialog: () => void;
handleArenaModelsSelected?: (models: string[]) => void;
dismissCodingPlanUpdate: () => void;
closePermissionsDialog: () => void;
setShellModeActive: (value: boolean) => void;

View file

@ -33,6 +33,7 @@ import type { UpdateObject } from '../utils/updateCheck.js';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { type RestartReason } from '../hooks/useIdeTrustListener.js';
import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js';
import { type ArenaDialogType } from '../hooks/useArenaCommand.js';
export interface UIState {
history: HistoryItem[];
@ -52,6 +53,7 @@ export interface UIState {
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
isModelDialogOpen: boolean;
activeArenaDialog: ArenaDialogType;
isPermissionsDialogOpen: boolean;
isApprovalModeDialogOpen: boolean;
isResumeDialogOpen: boolean;
@ -131,6 +133,8 @@ export interface UIState {
isMcpDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
// Per-task token tracking
taskStartTokens: number;
}
export const UIStateContext = createContext<UIState | null>(null);

View file

@ -7,6 +7,7 @@
import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import type { ArenaDialogType } from './useArenaCommand.js';
import {
type Logger,
type Config,
@ -66,6 +67,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([
interface SlashCommandProcessorActions {
openAuthDialog: () => void;
openArenaDialog?: (type: Exclude<ArenaDialogType, null>) => void;
openThemeDialog: () => void;
openEditorDialog: () => void;
openSettingsDialog: () => void;
@ -456,6 +458,18 @@ export const useSlashCommandProcessor = (
return { type: 'handled' };
case 'dialog':
switch (result.dialog) {
case 'arena_start':
actions.openArenaDialog?.('start');
return { type: 'handled' };
case 'arena_select':
actions.openArenaDialog?.('select');
return { type: 'handled' };
case 'arena_stop':
actions.openArenaDialog?.('stop');
return { type: 'handled' };
case 'arena_status':
actions.openArenaDialog?.('status');
return { type: 'handled' };
case 'auth':
actions.openAuthDialog();
return { type: 'handled' };

View file

@ -0,0 +1,166 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview Hook that subscribes to an AgentInteractive's events and
* derives streaming state, elapsed time, input-active flag, and status.
*
* Extracts the common reactivity + derived-state pattern shared by
* AgentComposer and AgentChatView so each component only deals with
* layout and interaction.
*/
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
AgentStatus,
AgentEventType,
isTerminalStatus,
type AgentInteractive,
type AgentEventEmitter,
} from '@qwen-code/qwen-code-core';
import { StreamingState } from '../types.js';
import { useTimer } from './useTimer.js';
// ─── Types ──────────────────────────────────────────────────
export interface AgentStreamingInfo {
/** The agent's current lifecycle status. */
status: AgentStatus | undefined;
/** Derived streaming state for StreamingContext / LoadingIndicator. */
streamingState: StreamingState;
/** Whether the agent can accept user input right now. */
isInputActive: boolean;
/** Seconds elapsed while in Responding state (resets each cycle). */
elapsedTime: number;
/** Prompt token count from the most recent round (for context usage). */
lastPromptTokenCount: number;
}
// ─── Hook ───────────────────────────────────────────────────
/**
* Subscribe to an AgentInteractive's events and derive UI streaming state.
*
* @param interactiveAgent - The agent instance, or undefined if not yet registered.
* @param events - Which event types trigger a re-render. Defaults to
* STATUS_CHANGE, TOOL_WAITING_APPROVAL, and TOOL_RESULT sufficient for
* composer / footer use. Callers like AgentChatView can pass a broader set
* (e.g. include TOOL_CALL, ROUND_END, TOOL_OUTPUT_UPDATE) for richer updates.
*/
export function useAgentStreamingState(
interactiveAgent: AgentInteractive | undefined,
events?: ReadonlyArray<(typeof AgentEventType)[keyof typeof AgentEventType]>,
): AgentStreamingInfo {
// ── Force-render on agent events ──
const [, setTick] = useState(0);
const tickRef = useRef(0);
const forceRender = useCallback(() => {
tickRef.current += 1;
setTick(tickRef.current);
}, []);
// ── Track last prompt token count from USAGE_METADATA events ──
const [lastPromptTokenCount, setLastPromptTokenCount] = useState(
() => interactiveAgent?.getLastPromptTokenCount() ?? 0,
);
const subscribedEvents = events ?? DEFAULT_EVENTS;
useEffect(() => {
if (!interactiveAgent) return;
const emitter: AgentEventEmitter | undefined =
interactiveAgent.getEventEmitter();
if (!emitter) return;
const handler = () => forceRender();
for (const evt of subscribedEvents) {
emitter.on(evt, handler);
}
// Dedicated listener for usage metadata — updates React state directly
// so the token count is available immediately (even if no other event
// triggers a re-render). Prefers totalTokenCount (prompt + output)
// because output becomes history for the next round, matching
// geminiChat.ts.
const usageHandler = (event: {
usage?: { totalTokenCount?: number; promptTokenCount?: number };
}) => {
const count =
event?.usage?.totalTokenCount ?? event?.usage?.promptTokenCount;
if (typeof count === 'number' && count > 0) {
setLastPromptTokenCount(count);
}
};
emitter.on(AgentEventType.USAGE_METADATA, usageHandler);
return () => {
for (const evt of subscribedEvents) {
emitter.off(evt, handler);
}
emitter.off(AgentEventType.USAGE_METADATA, usageHandler);
};
}, [interactiveAgent, forceRender, subscribedEvents]);
// ── Derived state ──
const status = interactiveAgent?.getStatus();
const pendingApprovals = interactiveAgent?.getPendingApprovals();
const hasPendingApprovals =
pendingApprovals !== undefined && pendingApprovals.size > 0;
const streamingState = useMemo(() => {
if (hasPendingApprovals) {
return StreamingState.WaitingForConfirmation;
}
if (status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING) {
return StreamingState.Responding;
}
return StreamingState.Idle;
}, [status, hasPendingApprovals]);
const isInputActive =
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
status !== undefined &&
!isTerminalStatus(status);
// ── Timer (resets each time we enter Responding) ──
const [timerResetKey, setTimerResetKey] = useState(0);
const prevStreamingRef = useRef(streamingState);
useEffect(() => {
if (
streamingState === StreamingState.Responding &&
prevStreamingRef.current !== StreamingState.Responding
) {
setTimerResetKey((k) => k + 1);
}
prevStreamingRef.current = streamingState;
}, [streamingState]);
const elapsedTime = useTimer(
streamingState === StreamingState.Responding,
timerResetKey,
);
return {
status,
streamingState,
isInputActive,
elapsedTime,
lastPromptTokenCount,
};
}
// ─── Defaults ───────────────────────────────────────────────
const DEFAULT_EVENTS = [
AgentEventType.STATUS_CHANGE,
AgentEventType.TOOL_WAITING_APPROVAL,
AgentEventType.TOOL_RESULT,
] as const;

View file

@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useState } from 'react';
export type ArenaDialogType = 'start' | 'select' | 'stop' | 'status' | null;
interface UseArenaCommandReturn {
activeArenaDialog: ArenaDialogType;
openArenaDialog: (type: Exclude<ArenaDialogType, null>) => void;
closeArenaDialog: () => void;
}
export function useArenaCommand(): UseArenaCommandReturn {
const [activeArenaDialog, setActiveArenaDialog] =
useState<ArenaDialogType>(null);
const openArenaDialog = useCallback(
(type: Exclude<ArenaDialogType, null>) => {
setActiveArenaDialog(type);
},
[],
);
const closeArenaDialog = useCallback(() => {
setActiveArenaDialog(null);
}, []);
return {
activeArenaDialog,
openArenaDialog,
closeArenaDialog,
};
}

View file

@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview useArenaInProcess bridges ArenaManager in-process events
* to AgentViewContext agent registration.
*
* Subscribes to `config.onArenaManagerChange()` to react immediately when
* the arena manager is set or cleared. Event listeners are attached to the
* manager's emitter as soon as it appears the backend is resolved lazily
* inside the AGENT_START handler, which only fires after the backend is
* initialized.
*/
import { useEffect, useRef } from 'react';
import {
ArenaEventType,
ArenaSessionStatus,
DISPLAY_MODE,
type ArenaAgentStartEvent,
type ArenaManager,
type ArenaSessionCompleteEvent,
type Config,
type InProcessBackend,
} from '@qwen-code/qwen-code-core';
import type { AgentViewActions } from '../contexts/AgentViewContext.js';
import { theme } from '../semantic-colors.js';
const AGENT_COLORS = [
theme.text.accent,
theme.text.link,
theme.status.success,
theme.status.warning,
theme.text.code,
theme.status.error,
];
/**
* Bridge arena in-process events to agent tab registration/unregistration.
*
* Called by AgentViewProvider accepts config and actions directly so the
* hook has no dependency on AgentViewContext (avoiding a circular import).
*/
export function useArenaInProcess(
config: Config | null,
actions: AgentViewActions,
): void {
const actionsRef = useRef(actions);
actionsRef.current = actions;
useEffect(() => {
if (!config) return;
let detachArenaListeners: (() => void) | null = null;
const retryTimeouts = new Set<ReturnType<typeof setTimeout>>();
/** Remove agent tabs, cancel pending retries, and detach arena events. */
const detachSession = () => {
actionsRef.current.unregisterAll();
for (const t of retryTimeouts) clearTimeout(t);
retryTimeouts.clear();
detachArenaListeners?.();
detachArenaListeners = null;
};
/** Attach to an arena manager's event emitter. The backend is resolved
* lazily we only need it when registering agents, not at subscribe
* time. This avoids the race where setArenaManager fires before
* manager.start() initializes the backend. */
const attachSession = (manager: ArenaManager) => {
const emitter = manager.getEventEmitter();
let colorIndex = 0;
const nextColor = () => AGENT_COLORS[colorIndex++ % AGENT_COLORS.length]!;
/** Resolve the InProcessBackend, or null if not applicable. */
const getInProcessBackend = (): InProcessBackend | null => {
const backend = manager.getBackend();
if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return null;
return backend as InProcessBackend;
};
// Register agents that already started (events may have fired before
// the callback was attached).
const inProcessBackend = getInProcessBackend();
if (inProcessBackend) {
for (const agentState of manager.getAgentStates()) {
const interactive = inProcessBackend.getAgent(agentState.agentId);
if (interactive) {
actionsRef.current.registerAgent(
agentState.agentId,
interactive,
agentState.model.modelId,
nextColor(),
agentState.model.displayName,
);
}
}
}
// AGENT_START fires *before* backend.spawnAgent() creates the
// AgentInteractive, so getAgent() may return undefined. Retry briefly.
const MAX_RETRIES = 20;
const RETRY_MS = 50;
const onAgentStart = (event: ArenaAgentStartEvent) => {
const tryRegister = (retriesLeft: number) => {
const backend = getInProcessBackend();
if (!backend) return; // not an in-process session
const interactive = backend.getAgent(event.agentId);
if (interactive) {
actionsRef.current.registerAgent(
event.agentId,
interactive,
event.model.modelId,
nextColor(),
event.model.displayName,
);
return;
}
if (retriesLeft > 0) {
const timeout = setTimeout(() => {
retryTimeouts.delete(timeout);
tryRegister(retriesLeft - 1);
}, RETRY_MS);
retryTimeouts.add(timeout);
}
};
tryRegister(MAX_RETRIES);
};
const onSessionComplete = (event: ArenaSessionCompleteEvent) => {
// IDLE means agents finished but the session is still alive for
// follow-up interaction — keep the tab bar.
if (event.result.status === ArenaSessionStatus.IDLE) return;
detachSession();
};
const onSessionError = () => detachSession();
emitter.on(ArenaEventType.AGENT_START, onAgentStart);
emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
emitter.on(ArenaEventType.SESSION_ERROR, onSessionError);
detachArenaListeners = () => {
emitter.off(ArenaEventType.AGENT_START, onAgentStart);
emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete);
emitter.off(ArenaEventType.SESSION_ERROR, onSessionError);
};
};
const handleManagerChange = (manager: ArenaManager | null) => {
detachSession();
if (manager) {
attachSession(manager);
}
};
// Subscribe to future changes.
config.onArenaManagerChange(handleManagerChange);
// Handle the case where a manager already exists when we mount.
const current = config.getArenaManager();
if (current) {
attachSession(current);
}
return () => {
config.onArenaManagerChange(null);
detachSession();
};
}, [config]);
}

View file

@ -19,6 +19,8 @@ export interface UseAutoAcceptIndicatorArgs {
addItem?: (item: HistoryItemWithoutId, timestamp: number) => void;
onApprovalModeChange?: (mode: ApprovalMode) => void;
shouldBlockTab?: () => boolean;
/** When true, the keyboard handler is disabled (e.g. agent tab is active). */
disabled?: boolean;
}
export function useAutoAcceptIndicator({
@ -26,6 +28,7 @@ export function useAutoAcceptIndicator({
addItem,
onApprovalModeChange,
shouldBlockTab,
disabled,
}: UseAutoAcceptIndicatorArgs): ApprovalMode {
const currentConfigValue = config.getApprovalMode();
const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] =
@ -78,7 +81,7 @@ export function useAutoAcceptIndicator({
}
}
},
{ isActive: true },
{ isActive: !disabled },
);
return showAutoAcceptIndicator;

View file

@ -7,6 +7,7 @@
import { useCallback } from 'react';
import { SettingScope } from '../../config/settings.js';
import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core';
import type { ArenaDialogType } from './useArenaCommand.js';
// OpenAICredentials type (previously imported from OpenAIKeyPrompt)
interface OpenAICredentials {
apiKey: string;
@ -42,6 +43,10 @@ export interface DialogCloseOptions {
isSettingsDialogOpen: boolean;
closeSettingsDialog: () => void;
// Arena dialogs
activeArenaDialog: ArenaDialogType;
closeArenaDialog: () => void;
// Folder trust dialog
isFolderTrustDialogOpen: boolean;
@ -83,6 +88,11 @@ export function useDialogClose(options: DialogCloseOptions) {
return true;
}
if (options.activeArenaDialog !== null) {
options.closeArenaDialog();
return true;
}
if (options.isFolderTrustDialogOpen) {
// FolderTrustDialog doesn't expose close function, but ESC would prevent exit
// We follow the same pattern - prevent exit behavior

View file

@ -203,6 +203,7 @@ describe('useGeminiStream', () => {
.fn()
.mockReturnValue(contentGeneratorConfig),
getMaxSessionTurns: vi.fn(() => 50),
getArenaAgentClient: vi.fn(() => null),
} as unknown as Config;
mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);

View file

@ -430,6 +430,12 @@ export const useGeminiStream = (
isSubmittingQueryRef.current = false;
abortControllerRef.current?.abort();
// Report cancellation to arena status reporter (if in arena mode).
// This is needed because cancellation during tool execution won't
// flow through sendMessageStream where the inline reportCancelled()
// lives — tools get cancelled and handleCompletedTools returns early.
config.getArenaAgentClient()?.reportCancelled();
// Log API cancellation
const prompt_id = config.getSessionId() + '########' + getPromptCount();
const cancellationEvent = new ApiCancelEvent(
@ -1433,6 +1439,9 @@ export const useGeminiStream = (
role: 'user',
parts: combinedParts,
});
// Report cancellation to arena (safety net — cancelOngoingRequest
config.getArenaAgentClient()?.reportCancelled();
}
const callIdsToMarkAsSubmitted = geminiTools.map(
@ -1469,6 +1478,7 @@ export const useGeminiStream = (
geminiClient,
performMemoryRefresh,
modelSwitchedFromQuotaError,
config,
],
);

View file

@ -18,6 +18,7 @@ export interface UseInputHistoryReturn {
handleSubmit: (value: string) => void;
navigateUp: () => boolean;
navigateDown: () => boolean;
resetHistoryNav: () => void;
}
export function useInputHistory({
@ -107,5 +108,6 @@ export function useInputHistory({
handleSubmit,
navigateUp,
navigateDown,
resetHistoryNav,
};
}

View file

@ -133,4 +133,119 @@ describe('useLoadingIndicator', () => {
});
expect(result.current.elapsedTime).toBe(0);
});
describe('token tracking', () => {
it('should capture token snapshot when task starts', () => {
const { result, rerender } = renderHook(
({ streamingState, currentCandidatesTokens }) =>
useLoadingIndicator(
streamingState,
undefined,
currentCandidatesTokens,
),
{
initialProps: {
streamingState: StreamingState.Idle,
currentCandidatesTokens: 100,
},
},
);
expect(result.current.taskStartTokens).toBe(0);
act(() => {
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 100,
});
});
expect(result.current.taskStartTokens).toBe(100);
});
it('should reset token snapshot when transitioning from Responding to Idle', async () => {
const { result, rerender } = renderHook(
({ streamingState, currentCandidatesTokens }) =>
useLoadingIndicator(
streamingState,
undefined,
currentCandidatesTokens,
),
{
initialProps: {
streamingState: StreamingState.Idle,
currentCandidatesTokens: 0,
},
},
);
act(() => {
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 0,
});
});
expect(result.current.taskStartTokens).toBe(0);
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 500,
});
});
act(() => {
rerender({
streamingState: StreamingState.Idle,
currentCandidatesTokens: 500,
});
});
expect(result.current.taskStartTokens).toBe(0);
});
it('should reset token snapshot when transitioning from WaitingForConfirmation to Responding', async () => {
const { result, rerender } = renderHook(
({ streamingState, currentCandidatesTokens }) =>
useLoadingIndicator(
streamingState,
undefined,
currentCandidatesTokens,
),
{
initialProps: {
streamingState: StreamingState.Responding,
currentCandidatesTokens: 100,
},
},
);
expect(result.current.taskStartTokens).toBe(100);
await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 500,
});
});
act(() => {
rerender({
streamingState: StreamingState.WaitingForConfirmation,
currentCandidatesTokens: 500,
});
});
act(() => {
rerender({
streamingState: StreamingState.Responding,
currentCandidatesTokens: 500,
});
});
expect(result.current.taskStartTokens).toBe(500);
});
});
});

View file

@ -7,11 +7,12 @@
import { StreamingState } from '../types.js';
import { useTimer } from './useTimer.js';
import { usePhraseCycler } from './usePhraseCycler.js';
import { useState, useEffect, useRef } from 'react'; // Added useRef
import { useState, useEffect, useRef } from 'react';
export const useLoadingIndicator = (
streamingState: StreamingState,
customWittyPhrases?: string[],
currentCandidatesTokens?: number,
) => {
const [timerResetKey, setTimerResetKey] = useState(0);
const isTimerActive = streamingState === StreamingState.Responding;
@ -27,6 +28,7 @@ export const useLoadingIndicator = (
);
const [retainedElapsedTime, setRetainedElapsedTime] = useState(0);
const [taskStartTokens, setTaskStartTokens] = useState(0);
const prevStreamingStateRef = useRef<StreamingState | null>(null);
useEffect(() => {
@ -35,21 +37,26 @@ export const useLoadingIndicator = (
streamingState === StreamingState.Responding
) {
setTimerResetKey((prevKey) => prevKey + 1);
setRetainedElapsedTime(0); // Clear retained time when going back to responding
setRetainedElapsedTime(0);
setTaskStartTokens(currentCandidatesTokens ?? 0);
} else if (
streamingState === StreamingState.Idle &&
prevStreamingStateRef.current === StreamingState.Responding
) {
setTimerResetKey((prevKey) => prevKey + 1); // Reset timer when becoming idle from responding
setTimerResetKey((prevKey) => prevKey + 1);
setRetainedElapsedTime(0);
setTaskStartTokens(0);
} else if (
streamingState === StreamingState.Responding &&
prevStreamingStateRef.current !== StreamingState.Responding
) {
setTaskStartTokens(currentCandidatesTokens ?? 0);
} else if (streamingState === StreamingState.WaitingForConfirmation) {
// Capture the time when entering WaitingForConfirmation
// elapsedTimeFromTimer will hold the last value from when isTimerActive was true.
setRetainedElapsedTime(elapsedTimeFromTimer);
}
prevStreamingStateRef.current = streamingState;
}, [streamingState, elapsedTimeFromTimer]);
}, [streamingState, elapsedTimeFromTimer, currentCandidatesTokens]);
return {
elapsedTime:
@ -57,5 +64,6 @@ export const useLoadingIndicator = (
? retainedElapsedTime
: elapsedTimeFromTimer,
currentLoadingPhrase,
taskStartTokens,
};
};

View file

@ -5,6 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useEffect, useState } from 'react';
import { renderHook, act } from '@testing-library/react';
import {
useSelectionList,
@ -915,6 +916,37 @@ describe('useSelectionList', () => {
expect(result.current.activeIndex).toBe(2);
});
it('should handle equivalent items regenerated on each render', () => {
const { result } = renderHook(() => {
const [tick, setTick] = useState(0);
const regeneratedItems = [
{ value: 'A', key: 'A' },
{ value: 'B', disabled: true, key: 'B' },
{ value: 'C', key: 'C' },
];
const selection = useSelectionList({
items: regeneratedItems,
onSelect: mockOnSelect,
initialIndex: 0,
});
useEffect(() => {
if (tick === 0) {
setTick(1);
}
}, [tick]);
return {
tick,
activeIndex: selection.activeIndex,
};
});
expect(result.current.tick).toBe(1);
expect(result.current.activeIndex).toBe(0);
});
});
describe('Manual Control', () => {

View file

@ -133,6 +133,27 @@ const computeInitialIndex = <T>(
return targetIndex;
};
const areItemsStructurallyEqual = <T>(
a: Array<SelectionListItem<T>>,
b: Array<SelectionListItem<T>>,
): boolean => {
if (a === b) {
return true;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i]?.key !== b[i]?.key || a[i]?.disabled !== b[i]?.disabled) {
return false;
}
}
return true;
};
function selectionListReducer<T>(
state: SelectionListState<T>,
action: SelectionListAction<T>,
@ -176,22 +197,30 @@ function selectionListReducer<T>(
case 'INITIALIZE': {
const { initialIndex, items } = action.payload;
const initialIndexChanged = initialIndex !== state.initialIndex;
const activeKey =
initialIndex === state.initialIndex &&
state.activeIndex !== state.initialIndex
!initialIndexChanged && state.activeIndex !== state.initialIndex
? state.items[state.activeIndex]?.key
: undefined;
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
const itemsStructurallyEqual = areItemsStructurallyEqual(
items,
state.items,
);
if (items === state.items && initialIndex === state.initialIndex) {
if (
!initialIndexChanged &&
targetIndex === state.activeIndex &&
itemsStructurallyEqual
) {
return state;
}
const targetIndex = computeInitialIndex(initialIndex, items, activeKey);
return {
...state,
items,
items: itemsStructurallyEqual ? state.items : items,
activeIndex: targetIndex,
initialIndex,
pendingHighlight: false,
};
}

View file

@ -5,36 +5,77 @@
*/
import type React from 'react';
import { useEffect, useRef } from 'react';
import { Box } from 'ink';
import { MainContent } from '../components/MainContent.js';
import { DialogManager } from '../components/DialogManager.js';
import { Composer } from '../components/Composer.js';
import { ExitWarning } from '../components/ExitWarning.js';
import { AgentTabBar } from '../components/agent-view/AgentTabBar.js';
import { AgentChatView } from '../components/agent-view/AgentChatView.js';
import { AgentComposer } from '../components/agent-view/AgentComposer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAgentViewState } from '../contexts/AgentViewContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const DefaultAppLayout: React.FC = () => {
const uiState = useUIState();
const { refreshStatic } = useUIActions();
const { activeView, agents } = useAgentViewState();
const { columns: terminalWidth } = useTerminalSize();
const hasAgents = agents.size > 0;
const isAgentTab = activeView !== 'main' && agents.has(activeView);
// Clear terminal on view switch so previous view's <Static> output
// is removed. refreshStatic clears the terminal and bumps the
// historyRemountKey so MainContent's <Static> re-renders all items
// when switching back.
const prevViewRef = useRef(activeView);
useEffect(() => {
if (prevViewRef.current !== activeView) {
prevViewRef.current = activeView;
refreshStatic();
}
}, [activeView, refreshStatic]);
return (
<Box flexDirection="column" width={terminalWidth}>
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
{uiState.dialogsVisible ? (
<Box marginX={2} flexDirection="column" width={uiState.mainAreaWidth}>
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
{isAgentTab ? (
<>
{/* Agent view: chat history + agent-specific composer */}
<AgentChatView agentId={activeView} />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
<AgentComposer key={activeView} agentId={activeView} />
<ExitWarning />
</Box>
) : (
<Composer />
)}
</>
) : (
<>
{/* Main view: conversation history + main composer / dialogs */}
<MainContent />
<Box flexDirection="column" ref={uiState.mainControlsRef}>
{uiState.dialogsVisible ? (
<Box
marginX={2}
flexDirection="column"
width={uiState.mainAreaWidth}
>
<DialogManager
terminalWidth={uiState.terminalWidth}
addItem={uiState.historyManager.addItem}
/>
</Box>
) : (
<Composer />
)}
<ExitWarning />
</Box>
</>
)}
<ExitWarning />
</Box>
{/* Tab bar: visible whenever in-process agents exist and input is active */}
{hasAgents && !uiState.dialogsVisible && <AgentTabBar />}
</Box>
);
};

View file

@ -11,6 +11,7 @@ import type {
ToolCallConfirmationDetails,
ToolConfirmationOutcome,
ToolResultDisplay,
AgentStatus,
} from '@qwen-code/qwen-code-core';
import type { PartListUnion } from '@google/genai';
import { type ReactNode } from 'react';
@ -128,6 +129,11 @@ export type HistoryItemWarning = HistoryItemBase & {
text: string;
};
export type HistoryItemSuccess = HistoryItemBase & {
type: 'success';
text: string;
};
export type HistoryItemRetryCountdown = HistoryItemBase & {
type: 'retry_countdown';
text: string;
@ -256,6 +262,89 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showTips: boolean;
};
// --- Context Usage types ---
export interface ContextCategoryBreakdown {
systemPrompt: number;
builtinTools: number;
mcpTools: number;
memoryFiles: number;
skills: number;
messages: number;
freeSpace: number;
autocompactBuffer: number;
}
export interface ContextToolDetail {
name: string;
tokens: number;
}
export interface ContextMemoryDetail {
path: string;
tokens: number;
}
export interface ContextSkillDetail {
name: string;
/** Token cost of the skill listing (name+description) in the tool definition */
tokens: number;
/** Whether this skill has been invoked and its full body loaded into context */
loaded?: boolean;
/** Token cost of the loaded SKILL.md body (only set when loaded is true) */
bodyTokens?: number;
}
export type HistoryItemContextUsage = HistoryItemBase & {
type: 'context_usage';
modelName: string;
totalTokens: number;
contextWindowSize: number;
breakdown: ContextCategoryBreakdown;
builtinTools: ContextToolDetail[];
mcpTools: ContextToolDetail[];
memoryFiles: ContextMemoryDetail[];
skills: ContextSkillDetail[];
/** True when totalTokens is estimated (no API call yet) rather than from API response */
isEstimated?: boolean;
/** When true, show per-item detail sections (tools, memory, skills). Default: false (compact). */
showDetails?: boolean;
};
/**
* Arena agent completion card data.
*/
export interface ArenaAgentCardData {
label: string;
status: AgentStatus;
durationMs: number;
totalTokens: number;
inputTokens: number;
outputTokens: number;
toolCalls: number;
successfulToolCalls: number;
failedToolCalls: number;
rounds: number;
error?: string;
diff?: string;
}
export type HistoryItemArenaAgentComplete = HistoryItemBase & {
type: 'arena_agent_complete';
agent: ArenaAgentCardData;
};
export type HistoryItemArenaSessionComplete = HistoryItemBase & {
type: 'arena_session_complete';
sessionStatus: string;
task: string;
totalDurationMs: number;
agents: ArenaAgentCardData[];
};
/**
* Insight progress message.
*/
export type HistoryItemInsightProgress = HistoryItemBase & {
type: 'insight_progress';
progress: InsightProgressProps;
@ -275,6 +364,7 @@ export type HistoryItemWithoutId =
| HistoryItemInfo
| HistoryItemError
| HistoryItemWarning
| HistoryItemSuccess
| HistoryItemRetryCountdown
| HistoryItemAbout
| HistoryItemHelp
@ -290,6 +380,9 @@ export type HistoryItemWithoutId =
| HistoryItemToolsList
| HistoryItemSkillsList
| HistoryItemMcpStatus
| HistoryItemContextUsage
| HistoryItemArenaAgentComplete
| HistoryItemArenaSessionComplete
| HistoryItemInsightProgress;
export type HistoryItem = HistoryItemWithoutId & { id: number };
@ -297,6 +390,7 @@ export type HistoryItem = HistoryItemWithoutId & { id: number };
// Message types used by internal command feedback (subset of HistoryItem types)
export enum MessageType {
INFO = 'info',
SUCCESS = 'success',
ERROR = 'error',
WARNING = 'warning',
USER = 'user',
@ -313,6 +407,9 @@ export enum MessageType {
TOOLS_LIST = 'tools_list',
SKILLS_LIST = 'skills_list',
MCP_STATUS = 'mcp_status',
CONTEXT_USAGE = 'context_usage',
ARENA_AGENT_COMPLETE = 'arena_agent_complete',
ARENA_SESSION_COMPLETE = 'arena_session_complete',
INSIGHT_PROGRESS = 'insight_progress',
}

View file

@ -103,7 +103,7 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = (
<Text key={key} color={theme.text.accent}>
<Text key={key} color={theme.text.code}>
{codeMatch[2]}
</Text>
);

View file

@ -5,6 +5,34 @@
*/
import { theme } from '../semantic-colors.js';
import { AgentStatus } from '@qwen-code/qwen-code-core';
// --- Status Labels ---
export interface StatusLabel {
icon: string;
text: string;
color: string;
}
export function getArenaStatusLabel(status: AgentStatus): StatusLabel {
switch (status) {
case AgentStatus.IDLE:
return { icon: '✓', text: 'Idle', color: theme.status.success };
case AgentStatus.COMPLETED:
return { icon: '✓', text: 'Done', color: theme.status.success };
case AgentStatus.CANCELLED:
return { icon: '⊘', text: 'Cancelled', color: theme.status.warning };
case AgentStatus.FAILED:
return { icon: '✗', text: 'Failed', color: theme.status.error };
case AgentStatus.RUNNING:
return { icon: '○', text: 'Running', color: theme.text.secondary };
case AgentStatus.INITIALIZING:
return { icon: '○', text: 'Initializing', color: theme.text.secondary };
default:
return { icon: '○', text: status, color: theme.text.secondary };
}
}
// --- Thresholds ---
export const TOOL_SUCCESS_RATE_HIGH = 95;

View file

@ -6,10 +6,395 @@
import { randomUUID } from 'node:crypto';
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
import type { GenerateContentResponseUsageMetadata } from '@google/genai';
import type { SessionContext } from '../../../acp-integration/session/types.js';
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
import type { ExportMessage, ExportSessionData } from './types.js';
import type {
ExportMessage,
ExportSessionData,
ExportMetadata,
} from './types.js';
/**
* File operation statistics extracted from tool calls.
*/
interface FileOperationStats {
filesWritten: number;
linesAdded: number;
linesRemoved: number;
writtenFilePaths: Set<string>;
}
/**
* Tool call arguments index for matching tool_result records.
*/
interface ToolCallArgsIndex {
byId: Map<string, Record<string, unknown>>;
byName: Map<string, Array<Record<string, unknown>>>;
}
/**
* Extracts tool name from a ChatRecord's function response.
*/
function extractToolNameFromRecord(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.name) {
return part.functionResponse.name;
}
}
return undefined;
}
/**
* Extracts call ID from a ChatRecord's function response.
*/
function extractFunctionResponseId(record: ChatRecord): string | undefined {
if (!record.message?.parts) {
return undefined;
}
for (const part of record.message.parts) {
if ('functionResponse' in part && part.functionResponse?.id) {
return part.functionResponse.id;
}
}
return undefined;
}
/**
* Normalizes function call args into a plain object.
*/
function normalizeFunctionCallArgs(
args: unknown,
): Record<string, unknown> | undefined {
if (args && typeof args === 'object') {
return args as Record<string, unknown>;
}
if (typeof args === 'string') {
try {
const parsed = JSON.parse(args) as unknown;
if (parsed && typeof parsed === 'object') {
return parsed as Record<string, unknown>;
}
} catch {
// Ignore parse errors and treat as unavailable args
}
}
return undefined;
}
/**
* Builds an index of assistant tool calls for later tool_result arg resolution.
*/
function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex {
const byId = new Map<string, Record<string, unknown>>();
const byName = new Map<string, Array<Record<string, unknown>>>();
for (const record of records) {
if (record.type !== 'assistant' || !record.message?.parts) continue;
for (const part of record.message.parts) {
if (!('functionCall' in part) || !part.functionCall?.name) continue;
const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args);
if (!normalizedArgs) continue;
const toolName = part.functionCall.name;
const callId =
typeof part.functionCall.id === 'string' ? part.functionCall.id : null;
if (callId) {
byId.set(callId, normalizedArgs);
}
const queue = byName.get(toolName) ?? [];
queue.push(normalizedArgs);
byName.set(toolName, queue);
}
}
return { byId, byName };
}
/**
* Calculate file operation statistics from ChatRecords.
* Uses toolCallResult from tool_result records for accurate statistics.
*/
function calculateFileStats(records: ChatRecord[]): FileOperationStats {
const argsIndex = buildToolCallArgsIndex(records);
const byNameCursor = new Map<string, number>();
const stats: FileOperationStats = {
filesWritten: 0,
linesAdded: 0,
linesRemoved: 0,
writtenFilePaths: new Set(),
};
for (const record of records) {
if (record.type !== 'tool_result' || !record.toolCallResult) continue;
const toolName = extractToolNameFromRecord(record);
const callId =
record.toolCallResult.callId ?? extractFunctionResponseId(record);
const argsFromId =
callId && argsIndex.byId.has(callId)
? argsIndex.byId.get(callId)
: undefined;
let args = argsFromId;
if (!args && toolName) {
const queue = argsIndex.byName.get(toolName);
if (queue && queue.length > 0) {
const cursor = byNameCursor.get(toolName) ?? 0;
args = queue[cursor];
byNameCursor.set(toolName, cursor + 1);
}
}
const { resultDisplay } = record.toolCallResult;
// Track file locations from resultDisplay
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
'fileName' in resultDisplay
) {
const display = resultDisplay as {
fileName: string;
fileDiff?: string;
originalContent?: string | null;
newContent?: string;
diffStat?: { model_added_lines?: number; model_removed_lines?: number };
};
// Determine operation type based on content fields
const hasOriginalContent = 'originalContent' in display;
const hasNewContent = 'newContent' in display;
// For write/edit operations, use full path from args if available
let filePath: string;
if (typeof display.fileName === 'string') {
// Prefer args.file_path for full path, fallback to fileName (which may be basename)
filePath =
(args?.['file_path'] as string) ||
(args?.['absolute_path'] as string) ||
display.fileName;
} else {
// Fallback if fileName is not a string
filePath = 'unknown';
}
if (hasOriginalContent || hasNewContent) {
// This is a write/edit operation
stats.filesWritten++;
stats.writtenFilePaths.add(filePath);
// Calculate line changes
if (display.diffStat) {
// Use diffStat if available for accurate counts
stats.linesAdded += display.diffStat.model_added_lines ?? 0;
stats.linesRemoved += display.diffStat.model_removed_lines ?? 0;
} else {
// Fallback: count lines in content
const oldText = String(display.originalContent ?? '');
const newText = String(display.newContent ?? '');
// Count non-empty lines
const oldLines = oldText
.split('\n')
.filter((line) => line.length > 0).length;
const newLines = newText
.split('\n')
.filter((line) => line.length > 0).length;
stats.linesAdded += newLines;
stats.linesRemoved += oldLines;
}
}
}
}
return stats;
}
/**
* Extracts token usage from TaskResultDisplay executionSummary.
*/
function extractTaskToolTokens(record: ChatRecord): number {
if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) {
return 0;
}
const { resultDisplay } = record.toolCallResult;
if (
typeof resultDisplay === 'object' &&
'type' in resultDisplay &&
resultDisplay.type === 'task_execution' &&
'executionSummary' in resultDisplay
) {
const summary = resultDisplay.executionSummary as {
totalTokens?: number;
inputTokens?: number;
outputTokens?: number;
thoughtTokens?: number;
cachedTokens?: number;
};
// Use totalTokens if available, otherwise sum individual token counts
if (typeof summary.totalTokens === 'number') {
return summary.totalTokens;
}
// Fallback: sum available token counts
return (
(summary.inputTokens ?? 0) +
(summary.outputTokens ?? 0) +
(summary.thoughtTokens ?? 0) +
(summary.cachedTokens ?? 0)
);
}
return 0;
}
/**
* Calculate token statistics from ChatRecords.
* Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage.
* Uses the last assistant record that has both totalTokenCount and contextWindowSize for calculating context usage percent.
*/
function calculateTokenStats(records: ChatRecord[]): {
totalTokens: number;
contextUsagePercent?: number;
contextWindowSize?: number;
} {
let totalTokens = 0;
// Track the last assistant record that has BOTH totalTokenCount and contextWindowSize
// to ensure the percentage calculation uses values from the same record
let lastValidRecord: {
totalTokenCount: number;
contextWindowSize: number;
} | null = null;
// Aggregate usageMetadata from all assistant records
for (const record of records) {
if (record.type === 'assistant') {
if (record.usageMetadata) {
totalTokens += record.usageMetadata.totalTokenCount ?? 0;
}
// Only update lastValidRecord when BOTH values are present in the same record
if (
record.usageMetadata?.totalTokenCount !== undefined &&
record.contextWindowSize !== undefined
) {
lastValidRecord = {
totalTokenCount: record.usageMetadata.totalTokenCount,
contextWindowSize: record.contextWindowSize,
};
}
}
// Include TaskTool token usage from executionSummary
const taskTokens = extractTaskToolTokens(record);
if (taskTokens > 0) {
totalTokens += taskTokens;
}
}
// Use last valid record's values for context usage calculation
// This represents how much of the context window is being used by the total tokens
if (lastValidRecord) {
const percent =
(lastValidRecord.totalTokenCount / lastValidRecord.contextWindowSize) *
100;
return {
totalTokens,
contextUsagePercent: Math.round(percent * 10) / 10,
contextWindowSize: lastValidRecord.contextWindowSize,
};
}
// Fallback: return the contextWindowSize from the last assistant record even if no valid pair found
// (for display purposes only, without percentage)
const lastAssistantRecord = [...records]
.reverse()
.find((r) => r.type === 'assistant' && r.contextWindowSize !== undefined);
return {
totalTokens,
contextWindowSize: lastAssistantRecord?.contextWindowSize,
};
}
/**
* Extract session metadata from ChatRecords.
*/
async function extractMetadata(
conversation: {
sessionId: string;
startTime: string;
messages: ChatRecord[];
},
config: Config,
): Promise<ExportMetadata> {
const { sessionId, startTime, messages } = conversation;
// Extract basic info from the first record
const firstRecord = messages[0];
const cwd = firstRecord?.cwd ?? '';
const gitBranch = firstRecord?.gitBranch;
// Get git repository name
let gitRepo: string | undefined;
if (cwd) {
const { getGitRepoName } = await import('@qwen-code/qwen-code-core');
gitRepo = getGitRepoName(cwd);
}
// Try to get model from assistant messages
let model: string | undefined;
for (const record of messages) {
if (record.type === 'assistant' && record.model) {
model = record.model;
break;
}
}
// Get channel from config
const channel = config.getChannel?.();
// Count user prompts
const promptCount = messages.filter((m) => m.type === 'user').length;
// Calculate file stats from original ChatRecords
const fileStats = calculateFileStats(messages);
// Calculate token stats from original ChatRecords
// contextWindowSize is retrieved from the last assistant record for accuracy
const tokenStats = calculateTokenStats(messages);
return {
sessionId,
startTime,
exportTime: new Date().toISOString(),
cwd,
gitRepo,
gitBranch,
model,
channel,
promptCount,
contextUsagePercent: tokenStats.contextUsagePercent,
contextWindowSize: tokenStats.contextWindowSize,
totalTokens: tokenStats.totalTokens,
filesWritten: fileStats.writtenFilePaths.size,
linesAdded: fileStats.linesAdded,
linesRemoved: fileStats.linesRemoved,
uniqueFiles: Array.from(fileStats.writtenFilePaths),
};
}
/**
* Export session context that captures session updates into export messages.
@ -24,6 +409,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant' | 'thinking';
parts: Array<{ text: string }>;
timestamp: number;
usageMetadata?: GenerateContentResponseUsageMetadata;
} | null = null;
private activeRecordId: string | null = null;
private activeRecordTimestamp: string | null = null;
@ -39,9 +425,37 @@ class ExportSessionContext implements SessionContext {
case 'user_message_chunk':
this.handleMessageChunk('user', update.content);
break;
case 'agent_message_chunk':
this.handleMessageChunk('assistant', update.content);
case 'agent_message_chunk': {
// Extract usageMetadata from _meta if available
const usageMeta = update._meta as
| {
usage?: {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
thoughtTokens?: number;
cachedReadTokens?: number;
};
}
| undefined;
const usageMetadata: GenerateContentResponseUsageMetadata | undefined =
usageMeta?.usage
? {
promptTokenCount: usageMeta.usage.inputTokens,
candidatesTokenCount: usageMeta.usage.outputTokens,
totalTokenCount: usageMeta.usage.totalTokens,
thoughtsTokenCount: usageMeta.usage.thoughtTokens,
cachedContentTokenCount: usageMeta.usage.cachedReadTokens,
}
: undefined;
this.handleMessageChunk(
'assistant',
update.content,
'assistant',
usageMetadata,
);
break;
}
case 'agent_thought_chunk':
this.handleMessageChunk('assistant', update.content, 'thinking');
break;
@ -79,6 +493,7 @@ class ExportSessionContext implements SessionContext {
role: 'user' | 'assistant',
content: { type: string; text?: string },
messageRole: 'user' | 'assistant' | 'thinking' = role,
usageMetadata?: GenerateContentResponseUsageMetadata,
): void {
if (content.type !== 'text' || !content.text) return;
@ -98,12 +513,17 @@ class ExportSessionContext implements SessionContext {
this.currentMessage.role === messageRole
) {
this.currentMessage.parts.push({ text: content.text });
// Merge usageMetadata if provided (for assistant messages)
if (usageMetadata && role === 'assistant') {
this.currentMessage.usageMetadata = usageMetadata;
}
} else {
this.currentMessage = {
type: role,
role: messageRole,
parts: [{ text: content.text }],
timestamp: Date.now(),
...(usageMetadata && role === 'assistant' ? { usageMetadata } : {}),
};
}
}
@ -205,7 +625,7 @@ class ExportSessionContext implements SessionContext {
if (!this.currentMessage) return;
const uuid = this.getMessageUuid();
this.messages.push({
const exportMessage: ExportMessage = {
uuid,
sessionId: this.sessionId,
timestamp: this.getMessageTimestamp(),
@ -214,7 +634,17 @@ class ExportSessionContext implements SessionContext {
role: this.currentMessage.role,
parts: this.currentMessage.parts,
},
});
};
// Add usageMetadata for assistant messages
if (
this.currentMessage.type === 'assistant' &&
this.currentMessage.usageMetadata
) {
exportMessage.usageMetadata = this.currentMessage.usageMetadata;
}
this.messages.push(exportMessage);
this.currentMessage = null;
}
@ -258,9 +688,13 @@ export async function collectSessionData(
// Get the export messages
const messages = exportContext.getMessages();
// Extract metadata from conversation
const metadata = await extractMetadata(conversation, config);
return {
sessionId: conversation.sessionId,
startTime: conversation.startTime,
messages,
metadata,
};
}

View file

@ -36,6 +36,7 @@ export function injectDataIntoHtmlTemplate(
sessionId: string;
startTime: string;
messages: unknown[];
metadata?: unknown;
},
): string {
const jsonData = JSON.stringify(data, null, 2);

View file

@ -12,15 +12,60 @@ import type { ExportSessionData } from '../types.js';
*/
export function toJsonl(sessionData: ExportSessionData): string {
const lines: string[] = [];
const sourceMetadata = sessionData.metadata;
// Add session metadata as the first line
lines.push(
JSON.stringify({
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
}),
);
const metadata: Record<string, unknown> = {
type: 'session_metadata',
sessionId: sessionData.sessionId,
startTime: sessionData.startTime,
};
// Add all metadata fields if available
if (sourceMetadata?.exportTime) {
metadata['exportTime'] = sourceMetadata.exportTime;
}
if (sourceMetadata?.cwd) {
metadata['cwd'] = sourceMetadata.cwd;
}
if (sourceMetadata?.gitRepo) {
metadata['gitRepo'] = sourceMetadata.gitRepo;
}
if (sourceMetadata?.gitBranch) {
metadata['gitBranch'] = sourceMetadata.gitBranch;
}
if (sourceMetadata?.model) {
metadata['model'] = sourceMetadata.model;
}
if (sourceMetadata?.channel) {
metadata['channel'] = sourceMetadata.channel;
}
if (sourceMetadata?.promptCount !== undefined) {
metadata['promptCount'] = sourceMetadata.promptCount;
}
if (sourceMetadata?.contextUsagePercent !== undefined) {
metadata['contextUsagePercent'] = sourceMetadata.contextUsagePercent;
}
if (sourceMetadata?.contextWindowSize !== undefined) {
metadata['contextWindowSize'] = sourceMetadata.contextWindowSize;
}
if (sourceMetadata?.totalTokens !== undefined) {
metadata['totalTokens'] = sourceMetadata.totalTokens;
}
if (sourceMetadata?.filesWritten !== undefined) {
metadata['filesWritten'] = sourceMetadata.filesWritten;
}
if (sourceMetadata?.linesAdded !== undefined) {
metadata['linesAdded'] = sourceMetadata.linesAdded;
}
if (sourceMetadata?.linesRemoved !== undefined) {
metadata['linesRemoved'] = sourceMetadata.linesRemoved;
}
if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) {
metadata['uniqueFiles'] = sourceMetadata.uniqueFiles;
}
lines.push(JSON.stringify(metadata));
// Add each message as a separate line
for (const message of sessionData.messages) {

View file

@ -11,12 +11,82 @@ import type { ExportSessionData, ExportMessage } from '../types.js';
*/
export function toMarkdown(sessionData: ExportSessionData): string {
const lines: string[] = [];
const metadata = sessionData.metadata;
// Add header with metadata
lines.push('# Chat Session Export\n');
lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``);
lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`);
lines.push(`- **Exported**: ${new Date().toISOString()}`);
lines.push(
`- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`,
);
lines.push('');
// Add context info
if (metadata?.cwd) {
lines.push(`- **Working Directory**: \`${sanitizeText(metadata.cwd)}\``);
}
if (metadata?.gitRepo) {
lines.push(`- **Git Repository**: ${sanitizeText(metadata.gitRepo)}`);
}
if (metadata?.gitBranch) {
lines.push(`- **Git Branch**: \`${sanitizeText(metadata.gitBranch)}\``);
}
lines.push('');
// Add model info
if (metadata?.model) {
lines.push(`- **Model**: ${sanitizeText(metadata.model)}`);
}
if (metadata?.channel) {
lines.push(`- **Channel**: ${sanitizeText(metadata.channel)}`);
}
if (metadata?.promptCount !== undefined) {
lines.push(`- **Prompt Count**: ${metadata.promptCount}`);
}
lines.push('');
// Add token stats
if (metadata?.totalTokens !== undefined) {
lines.push(`- **Total Tokens**: ${metadata.totalTokens}`);
}
if (metadata?.contextWindowSize !== undefined) {
lines.push(`- **Context Window Size**: ${metadata.contextWindowSize}`);
}
if (metadata?.contextUsagePercent !== undefined) {
lines.push(`- **Context Usage**: ${metadata.contextUsagePercent}%`);
}
lines.push('');
// Add file operation stats
if (metadata?.filesWritten !== undefined) {
lines.push(`- **Files Written**: ${metadata.filesWritten}`);
}
if (metadata?.linesAdded !== undefined) {
lines.push(`- **Lines Added**: ${metadata.linesAdded}`);
}
if (metadata?.linesRemoved !== undefined) {
lines.push(`- **Lines Removed**: ${metadata.linesRemoved}`);
}
// Add unique files list if available
if (metadata?.uniqueFiles && metadata.uniqueFiles.length > 0) {
lines.push('');
lines.push('<details>');
lines.push(
`<summary><strong>Unique Files Referenced (${metadata.uniqueFiles.length})</strong></summary>`,
);
lines.push('');
for (const file of metadata.uniqueFiles) {
lines.push(`- \`${sanitizeText(file)}\``);
}
lines.push('</details>');
}
lines.push('\n---\n');
// Process each message

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