mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-08 10:00:06 +00:00
Merge remote-tracking branch 'origin/main' into fix/pr2371-btw-complete
# Conflicts: # packages/cli/src/ui/AppContainer.tsx # packages/cli/src/ui/hooks/useGeminiStream.ts # packages/cli/src/ui/layouts/DefaultAppLayout.tsx # packages/cli/src/ui/types.ts # packages/core/src/core/client.test.ts
This commit is contained in:
commit
bd77eef46f
406 changed files with 55514 additions and 6431 deletions
3
.github/CODEOWNERS
vendored
3
.github/CODEOWNERS
vendored
|
|
@ -1,6 +1,3 @@
|
|||
* @tanzhenxin @DennisYu07 @gwinthis @LaZzyMan @pomelo-nwu @Mingholy @DragonnZhang
|
||||
# SDK TypeScript package changes require review from Mingholy
|
||||
packages/sdk-typescript/** @Mingholy
|
||||
# vscode-ide-companion and webui packages require review from yiliang114
|
||||
packages/vscode-ide-companion/** @yiliang114
|
||||
packages/webui/** @yiliang114
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -55,9 +55,11 @@ packages/vscode-ide-companion/*.vsix
|
|||
|
||||
# Qwen Code Configs
|
||||
|
||||
.qwen/
|
||||
.qwen/*
|
||||
!.qwen/commands/
|
||||
!.qwen/commands/**
|
||||
!.qwen/skills/
|
||||
!.qwen/skills/**
|
||||
logs/
|
||||
# GHA credentials
|
||||
gha-creds-*.json
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ You are an expert code reviewer. Follow these steps:
|
|||
- Any potential issues or risks
|
||||
|
||||
Keep your review concise but thorough. Focus on:
|
||||
|
||||
- Code correctness
|
||||
- Following project conventions
|
||||
- Performance implications
|
||||
|
|
|
|||
|
|
@ -5,22 +5,26 @@ description: Commit staged changes with an AI-generated commit message and push
|
|||
# Commit and Push
|
||||
|
||||
## Overview
|
||||
|
||||
Generate a clear, concise commit message based on staged changes, confirm with the user, then commit and push.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Check repository status
|
||||
|
||||
- Run `git status` to check:
|
||||
- Are there any staged changes?
|
||||
- Are there unstaged changes?
|
||||
- What is the current branch?
|
||||
|
||||
### 2. Handle unstaged changes
|
||||
|
||||
- If there are unstaged changes, notify the user and list them
|
||||
- Do NOT add or commit unstaged changes
|
||||
- Proceed only with staged changes
|
||||
|
||||
### 3. Review staged changes
|
||||
|
||||
- Run `git diff --staged` to see all staged changes
|
||||
- Analyze the changes in depth to understand:
|
||||
- What files were modified/added/deleted
|
||||
|
|
@ -28,6 +32,7 @@ Generate a clear, concise commit message based on staged changes, confirm with t
|
|||
- The scope and impact of the changes
|
||||
|
||||
### 4. Handle branch logic
|
||||
|
||||
- Get current branch name with `git branch --show-current`
|
||||
- **If current branch is `main` or `master`:**
|
||||
- Generate a proper branch name based on the changes
|
||||
|
|
@ -40,6 +45,7 @@ Generate a clear, concise commit message based on staged changes, confirm with t
|
|||
- Wait for user decision
|
||||
|
||||
### 5. Generate commit message
|
||||
|
||||
- Types: feat, fix, docs, style, refactor, test, chore
|
||||
- Guidelines:
|
||||
- Be clear and concise
|
||||
|
|
@ -49,6 +55,7 @@ Generate a clear, concise commit message based on staged changes, confirm with t
|
|||
- Include a footer explaining the purpose/impact of the changes
|
||||
|
||||
**Format:**
|
||||
|
||||
```
|
||||
<type>(<scope>): <short description>
|
||||
- <detail point 1> (optional)
|
||||
|
|
@ -59,12 +66,14 @@ This <explains the why/impact of the changes>.
|
|||
```
|
||||
|
||||
### 6. Present the result and confirm with user
|
||||
|
||||
- Present the generated commit message
|
||||
- Show which branch will be used
|
||||
- Ask for confirmation: "Proceed with commit and push?"
|
||||
- Wait for user approval
|
||||
|
||||
### 7. Commit and push
|
||||
|
||||
- After user confirms:
|
||||
- `git commit -m "<commit-message>"`
|
||||
- `git push -u origin <branch-name>` (use `-u` for new branches)
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ description: Draft and submit a GitHub issue based on a user-provided idea
|
|||
# Create Issue
|
||||
|
||||
## Overview
|
||||
|
||||
Take the user's idea or bug description, investigate the codebase to understand the full context, draft a GitHub issue for review, and submit it once approved.
|
||||
|
||||
## Input
|
||||
|
||||
The user provides a brief description of a feature request or bug report: {{args}}
|
||||
|
||||
## Steps
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ description: Create a pull request based on staged code changes
|
|||
# Create PR
|
||||
|
||||
## Overview
|
||||
|
||||
Create a well-structured pull request with proper description and title.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Review staged changes**
|
||||
- Review all staged changes to understand what has been done
|
||||
- Do not touch unstaged changes
|
||||
|
|
@ -31,4 +33,4 @@ Create a well-structured pull request with proper description and title.
|
|||
|
||||
## PR Template
|
||||
|
||||
@{.github/pull_request_template.md}
|
||||
@{.github/pull_request_template.md}
|
||||
|
|
|
|||
71
.qwen/skills/docs-audit-and-refresh/SKILL.md
Normal file
71
.qwen/skills/docs-audit-and-refresh/SKILL.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
---
|
||||
name: docs-audit-and-refresh
|
||||
description: Audit the repository's docs/ content against the current codebase, find missing, incorrect, or stale documentation, and refresh the affected pages. Use when the user asks to review docs coverage, find outdated docs, compare docs with the current repo, or fix documentation drift across features, settings, tools, or integrations.
|
||||
---
|
||||
|
||||
# Docs Audit And Refresh
|
||||
|
||||
## Overview
|
||||
|
||||
Audit `docs/` from the repository outward: inspect the current implementation, identify documentation gaps or inaccuracies, and update the relevant pages. Keep the work inside `docs/` and treat code, tests, and current configuration surfaces as the authoritative source.
|
||||
|
||||
Read [references/audit-checklist.md](references/audit-checklist.md) before a broad audit so the scan stays focused on high-signal areas.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Build a current-state inventory
|
||||
|
||||
Inspect the repository areas that define user-facing or developer-facing behavior.
|
||||
|
||||
- Read the relevant code, tests, schemas, and package surfaces.
|
||||
- Focus on shipped behavior, stable configuration, exposed commands, integrations, and developer workflows.
|
||||
- Use the existing docs tree as a map of intended coverage, not as proof that coverage is complete.
|
||||
|
||||
### 2. Compare implementation against `docs/`
|
||||
|
||||
Look for three classes of issues:
|
||||
|
||||
- Missing documentation for an existing feature, setting, tool, or workflow
|
||||
- Incorrect documentation that contradicts the current codebase
|
||||
- Stale documentation that uses old names, defaults, paths, or examples
|
||||
|
||||
Prefer proving a gap with repository evidence before editing. Use current code and tests instead of intuition.
|
||||
|
||||
### 3. Prioritize by reader impact
|
||||
|
||||
Fix the highest-cost issues first:
|
||||
|
||||
1. Broken onboarding, setup, auth, installation, or command flows
|
||||
2. Wrong settings, defaults, paths, or feature behavior
|
||||
3. Entirely missing documentation for a real surface area
|
||||
4. Lower-impact clarity or organization improvements
|
||||
|
||||
### 4. Refresh the docs
|
||||
|
||||
Update the smallest correct set of pages under `docs/`.
|
||||
|
||||
- Edit existing pages first
|
||||
- Add new pages only for clear, durable gaps
|
||||
- Update the nearest `_meta.ts` when adding or moving pages
|
||||
- Keep examples executable and aligned with the current repository structure
|
||||
- Remove dead or misleading text instead of layering warnings on top
|
||||
|
||||
### 5. Validate the refresh
|
||||
|
||||
Before finishing:
|
||||
|
||||
- Search `docs/` for old terminology and replaced config keys
|
||||
- Check neighboring pages for conflicting guidance
|
||||
- Confirm new pages appear in the right `_meta.ts`
|
||||
- Re-read critical examples, commands, and paths against code or tests
|
||||
|
||||
## Audit standards
|
||||
|
||||
- Favor breadth-first discovery, then depth on confirmed gaps.
|
||||
- Do not rewrite large areas without evidence that they are wrong or missing.
|
||||
- Keep README files out of scope for edits; limit changes to `docs/`.
|
||||
- Call out residual gaps if the audit finds issues that are too large to solve in one pass.
|
||||
|
||||
## Deliverable
|
||||
|
||||
Produce a focused docs refresh that makes the current repository more accurate and complete. Summarize the audited surfaces and the concrete pages updated.
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Audit Checklist
|
||||
|
||||
Use this checklist to keep repository-wide documentation audits focused and repeatable.
|
||||
|
||||
## High-signal repository surfaces
|
||||
|
||||
- `packages/cli/**`
|
||||
Inspect commands, flows, prompts, flags, and CLI-facing behavior.
|
||||
- `packages/core/**`
|
||||
Inspect shared behavior, settings, tools, provider integration, and feature semantics.
|
||||
- `packages/sdk-typescript/**` and `packages/sdk-java/**`
|
||||
Inspect SDK setup, usage, and examples that may affect developer docs.
|
||||
- `packages/vscode-ide-companion/**`, `packages/zed-extension/**`, and related integration packages
|
||||
Inspect IDE and extension behavior that should be reflected in user docs.
|
||||
- `docs/**/_meta.ts`
|
||||
Inspect navigation completeness after creating or moving pages.
|
||||
|
||||
## Gap detection prompts
|
||||
|
||||
Ask these questions while comparing the repo to `docs/`:
|
||||
|
||||
- Does a visible feature exist in code but have no page or section in `docs/`?
|
||||
- Does a docs page mention a command, setting, provider, or path that no longer exists?
|
||||
- Do examples still match the current repository layout and command syntax?
|
||||
- Is a page present but hidden or missing from `_meta.ts`?
|
||||
- Do multiple pages describe the same feature inconsistently?
|
||||
|
||||
## Common drift patterns
|
||||
|
||||
- Renamed settings keys or changed defaults
|
||||
- Updated authentication or provider configuration flow
|
||||
- New or removed CLI commands and flags
|
||||
- New tool behavior or approval/sandbox semantics
|
||||
- IDE integration changes that never reached the docs
|
||||
- Features documented in the wrong section, making them hard to find
|
||||
|
||||
## Output standard
|
||||
|
||||
- Prefer a small number of precise edits over a speculative docs rewrite.
|
||||
- Leave a clear summary of what was missing, wrong, or stale.
|
||||
- If the audit uncovers a larger docs reorganization, fix the highest-impact inaccuracies first and note the remaining work.
|
||||
73
.qwen/skills/docs-update-from-diff/SKILL.md
Normal file
73
.qwen/skills/docs-update-from-diff/SKILL.md
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
---
|
||||
name: docs-update-from-diff
|
||||
description: Review local code changes with git diff and update the official docs under docs/ to match. Use when the user asks to document current uncommitted work, sync docs with local changes, update docs after a feature or refactor, or when phrases like "git diff", "local changes", "update docs", or "official docs" appear.
|
||||
---
|
||||
|
||||
# Docs Update From Diff
|
||||
|
||||
## Overview
|
||||
|
||||
Inspect local diffs, derive the documentation impact, and update only the repository's `docs/` pages. Treat the current code as the source of truth and keep changes scoped, specific, and navigable.
|
||||
|
||||
Read [references/docs-surface.md](references/docs-surface.md) before editing if the affected feature does not map cleanly to an existing docs section.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Build the change set
|
||||
|
||||
Start from local Git state, not from assumptions.
|
||||
|
||||
- Inspect `git status --short`, `git diff --stat`, and targeted `git diff` output.
|
||||
- Focus on non-doc changes first so the documentation delta is grounded in code.
|
||||
- Ignore `README.md` and other non-`docs/` content unless they help confirm intent.
|
||||
|
||||
### 2. Derive the docs impact
|
||||
|
||||
For every changed behavior, extract the user-facing or developer-facing facts that documentation must reflect.
|
||||
|
||||
- New command, flag, config key, default, workflow, or limitation
|
||||
- Renamed behavior or removed behavior
|
||||
- Changed examples, paths, or setup steps
|
||||
- New feature that belongs in an existing page but is not mentioned yet
|
||||
|
||||
Prefer updating an existing page over creating a new page. Create a new page only when the feature introduces a stable topic that would make an existing page harder to follow.
|
||||
|
||||
### 3. Find the right docs location
|
||||
|
||||
Map each change to the smallest correct documentation surface:
|
||||
|
||||
- End-user behavior: `docs/users/**`
|
||||
- Developer internals, SDKs, contributor workflow, tooling: `docs/developers/**`
|
||||
- Shared landing or navigation changes: root `docs/**` and `_meta.ts`
|
||||
|
||||
If you add a new page, update the nearest `_meta.ts` in the same docs section so the page is discoverable.
|
||||
|
||||
### 4. Write the update
|
||||
|
||||
Edit documentation with the following bar:
|
||||
|
||||
- State the current behavior, not the implementation history
|
||||
- Use concrete commands, file paths, setting keys, and defaults from the diff
|
||||
- Remove or rewrite stale text instead of stacking caveats on top of it
|
||||
- Keep examples aligned with the current CLI and repository layout
|
||||
- Preserve the repository's existing docs tone and heading structure
|
||||
|
||||
### 5. Cross-check before finishing
|
||||
|
||||
Verify that the updated docs cover the actual delta:
|
||||
|
||||
- Search `docs/` for old names, removed flags, or outdated examples
|
||||
- Confirm links and relative paths still make sense
|
||||
- Confirm any new page is included in the relevant `_meta.ts`
|
||||
- Re-read the changed docs against the code diff, not against memory
|
||||
|
||||
## Practical heuristics
|
||||
|
||||
- If a change affects commands, also check quickstart, workflows, and feature pages for drift.
|
||||
- If a change affects configuration, also check `docs/users/configuration/settings.md`, feature pages, and auth/provider docs.
|
||||
- If a change affects tools or agent behavior, check both `docs/users/features/**` and `docs/developers/tools/**` when relevant.
|
||||
- If tests reveal expected behavior more clearly than implementation code, use tests to confirm wording.
|
||||
|
||||
## Deliverable
|
||||
|
||||
Produce the docs edits under `docs/` that make the current local changes understandable to a reader who has not seen the diff. Keep the final summary short and identify which pages were updated.
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
# Docs Surface Map
|
||||
|
||||
Use this file to choose the correct destination page under `docs/`.
|
||||
|
||||
## Primary sections
|
||||
|
||||
- `docs/users/overview.md`, `quickstart.md`, `common-workflow.md`
|
||||
Good for entry points, first-run guidance, and broad user workflows.
|
||||
- `docs/users/features/*.md`
|
||||
Good for user-visible features such as skills, MCP, sandbox, sub-agents, commands, checkpointing, and approval modes.
|
||||
- `docs/users/configuration/*.md`
|
||||
Good for settings, auth, model providers, themes, trusted folders, `.qwen` files, and similar configuration topics.
|
||||
- `docs/users/integration-*.md` and `docs/users/ide-integration/*.md`
|
||||
Good for IDEs, GitHub Actions, and editor companion behavior.
|
||||
- `docs/users/extension/*.md`
|
||||
Good for extension authoring and extension usage.
|
||||
- `docs/developers/*.md`
|
||||
Good for architecture, contributing workflow, roadmaps, and SDK overviews.
|
||||
- `docs/developers/tools/*.md`
|
||||
Good for tool behavior, tool contracts, and implementation-facing explanations.
|
||||
- `docs/developers/development/*.md`
|
||||
Good for contributor setup, deployment, tests, telemetry, and automation details.
|
||||
|
||||
## Navigation rules
|
||||
|
||||
- Root navigation lives in `docs/_meta.ts`.
|
||||
- Section navigation lives in the nearest `_meta.ts`, for example:
|
||||
- `docs/users/_meta.ts`
|
||||
- `docs/users/features/_meta.ts`
|
||||
- `docs/developers/_meta.ts`
|
||||
- `docs/developers/tools/_meta.ts`
|
||||
- If you create a page and do not add it to the right `_meta.ts`, the docs will be incomplete even if the markdown exists.
|
||||
|
||||
## Placement heuristics
|
||||
|
||||
- Put the change where a reader would naturally look first.
|
||||
- Update multiple pages when a single feature appears in setup, reference, and workflow docs.
|
||||
- Prefer adjusting a nearby existing page instead of creating a top-level page for a small delta.
|
||||
- Avoid duplicating long explanations across pages; add one source page and update nearby pages with short pointers if needed.
|
||||
201
.qwen/skills/qwen-code-claw/SKILL.md
Normal file
201
.qwen/skills/qwen-code-claw/SKILL.md
Normal 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.
|
||||
|
|
@ -211,31 +211,31 @@ This tool is commonly used for visual verification during PR reviews. For the co
|
|||
|
||||
```typescript
|
||||
interface FlowStep {
|
||||
type?: string; // Input text
|
||||
key?: string | string[]; // Key press(es)
|
||||
capture?: string; // Viewport screenshot filename
|
||||
captureFull?: string; // Full scrollback screenshot filename
|
||||
type?: string; // Input text
|
||||
key?: string | string[]; // Key press(es)
|
||||
capture?: string; // Viewport screenshot filename
|
||||
captureFull?: string; // Full scrollback screenshot filename
|
||||
streaming?: {
|
||||
delayMs?: number; // Delay before first capture (default: 0)
|
||||
intervalMs: number; // Interval between captures in ms
|
||||
count: number; // Maximum number of captures
|
||||
gif?: boolean; // Generate animated GIF (default: true)
|
||||
delayMs?: number; // Delay before first capture (default: 0)
|
||||
intervalMs: number; // Interval between captures in ms
|
||||
count: number; // Maximum number of captures
|
||||
gif?: boolean; // Generate animated GIF (default: true)
|
||||
};
|
||||
}
|
||||
|
||||
interface ScenarioConfig {
|
||||
name: string; // Scenario name (also used as screenshot subdirectory name)
|
||||
spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"]
|
||||
flow: FlowStep[]; // Interaction steps
|
||||
name: string; // Scenario name (also used as screenshot subdirectory name)
|
||||
spawn: string[]; // Launch command ["node", "dist/cli.js", "--yolo"]
|
||||
flow: FlowStep[]; // Interaction steps
|
||||
terminal?: {
|
||||
cols?: number; // Number of columns, default 100
|
||||
rows?: number; // Number of rows, default 28
|
||||
theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl
|
||||
chrome?: boolean; // macOS window decorations, default true
|
||||
title?: string; // Window title, default "Terminal"
|
||||
fontSize?: number; // Font size
|
||||
cwd?: string; // Working directory (relative to config file)
|
||||
cols?: number; // Number of columns, default 100
|
||||
rows?: number; // Number of rows, default 28
|
||||
theme?: string; // Theme: dracula|one-dark|github-dark|monokai|night-owl
|
||||
chrome?: boolean; // macOS window decorations, default true
|
||||
title?: string; // Window title, default "Terminal"
|
||||
fontSize?: number; // Font size
|
||||
cwd?: string; // Working directory (relative to config file)
|
||||
};
|
||||
outputDir?: string; // Screenshot output directory (relative to config file)
|
||||
outputDir?: string; // Screenshot output directory (relative to config file)
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,962 +0,0 @@
|
|||
# Qwen Code 0.12.0 MCP & Extension Management 优化方案
|
||||
|
||||
## 问题梳理与解决方案
|
||||
|
||||
根据钉钉文档《0.12.0 体验反馈》中提出的问题,本文件详细分析了每个问题的根本原因,并提供具体的解决方案和代码修改建议。
|
||||
|
||||
---
|
||||
|
||||
## 文档问题概览
|
||||
|
||||
本文档共包含 **6 个问题** (3 个 P1 + 3 个 P2),分为两个主要部分:
|
||||
|
||||
### Part 1: MCP Management TUI (5 个问题)
|
||||
|
||||
- **P1 级别**: 3 个问题
|
||||
- **P2 级别**: 2 个细节问题 (共 10 个小点)
|
||||
|
||||
### Part 2: Extension Management TUI (1 个问题)
|
||||
|
||||
- **P2 级别**: 1 个命令报错问题
|
||||
|
||||
## 问题 1: 【P1】Auth 属于 manage 的一部分,应该加到 manage 里
|
||||
|
||||
### 问题描述
|
||||
|
||||
- **现状**: 当前 MCP Management Dialog 中**没有 OAuth 认证功能**,用户必须使用 `/mcp auth <server-name>` 命令进行认证
|
||||
- **问题**:
|
||||
- Auth 功能独立于 Manage Dialog 之外,用户体验割裂
|
||||
- 需要记住命令行才能认证,不够直观
|
||||
- MCP 管理对话框中只能查看服务器状态和工具,无法进行认证操作
|
||||
- **文档建议**: Auth 应该整合到 manage dialog 中,在 UI 界面内完成所有 MCP 管理操作
|
||||
|
||||
### 根本原因分析
|
||||
|
||||
#### 当前实现
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/commands/mcpCommand.ts
|
||||
const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
subCommands: [manageCommand, authCommand], // auth 作为独立子命令存在
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp', // 默认打开管理对话框
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
#### MCP Management Dialog 现状
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
|
||||
// 当前的步骤类型
|
||||
export const MCP_MANAGEMENT_STEPS = {
|
||||
SERVER_LIST: 'server-list',
|
||||
SERVER_DETAIL: 'server-detail',
|
||||
DISABLE_SCOPE_SELECT: 'disable-scope-select',
|
||||
TOOL_LIST: 'tool-list',
|
||||
TOOL_DETAIL: 'tool-detail',
|
||||
} as const;
|
||||
|
||||
// ServerDetailStep 中的操作选项
|
||||
const actions = [
|
||||
{ label: 'View tools', value: 'view-tools' },
|
||||
{ label: 'Reconnect', value: 'reconnect' },
|
||||
{ label: 'Enable/Disable', value: 'toggle-disable' },
|
||||
// ❌ 缺少 'Authenticate' 选项
|
||||
];
|
||||
```
|
||||
|
||||
#### 问题分析
|
||||
|
||||
1. **UI 层面**: MCP Management Dialog 中没有认证相关的 UI 组件和操作入口
|
||||
2. **代码层面**: OAuth 认证逻辑只在命令行 handler 中实现 (`mcpCommand.ts` 的 `authCommand`)
|
||||
3. **体验层面**: 用户需要在 TUI 和 CLI 之间切换,无法在一个界面内完成所有操作
|
||||
|
||||
### 解决方案
|
||||
|
||||
#### 方案 A: 在 MCP Dialog 中集成完整的 OAuth 认证功能 (强烈推荐)
|
||||
|
||||
**核心思路**:
|
||||
|
||||
- 在 Server Detail 页面添加 "Authenticate" 操作选项
|
||||
- 复用现有的 `MCPOAuthProvider` 和 OAuth 流程
|
||||
- 通过事件系统显示认证过程中的提示信息
|
||||
|
||||
**实现步骤**:
|
||||
|
||||
##### 1. 扩展 MCP_MANAGEMENT_STEPS
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/types.ts
|
||||
export const MCP_MANAGEMENT_STEPS = {
|
||||
SERVER_LIST: 'server-list',
|
||||
SERVER_DETAIL: 'server-detail',
|
||||
DISABLE_SCOPE_SELECT: 'disable-scope-select',
|
||||
TOOL_LIST: 'tool-list',
|
||||
TOOL_DETAIL: 'tool-detail',
|
||||
AUTHENTICATE: 'authenticate', // 新增:认证步骤
|
||||
} as const;
|
||||
```
|
||||
|
||||
##### 2. 在 ServerDetailStep 中添加认证选项
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
type ServerAction =
|
||||
| 'view-tools'
|
||||
| 'reconnect'
|
||||
| 'toggle-disable'
|
||||
| 'authenticate'; // 新增
|
||||
|
||||
const actions = useMemo(() => {
|
||||
const result: Array<{ label: string; value: ServerAction }> = [];
|
||||
|
||||
result.push({ label: t('View Tools'), value: 'view-tools' });
|
||||
|
||||
if (!server.isDisabled && server.status === MCPServerStatus.DISCONNECTED) {
|
||||
result.push({ label: t('Reconnect'), value: 'reconnect' });
|
||||
}
|
||||
|
||||
// 新增:显示认证选项的场景
|
||||
const needsAuth =
|
||||
server.config.oauth?.enabled ||
|
||||
server.status === MCPServerStatus.DISCONNECTED ||
|
||||
server.errorMessage?.includes('401') ||
|
||||
server.errorMessage?.includes('OAuth');
|
||||
|
||||
if (needsAuth) {
|
||||
result.push({
|
||||
label: t('Authenticate'),
|
||||
value: 'authenticate',
|
||||
icon: '🔐', // 可选:添加图标增强视觉提示
|
||||
});
|
||||
}
|
||||
|
||||
result.push({
|
||||
label: server.isDisabled ? t('Enable') : t('Disable'),
|
||||
value: 'toggle-disable',
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
```
|
||||
|
||||
##### 3. 在 MCPManagementDialog 中实现认证逻辑
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
|
||||
import { MCPOAuthProvider, MCPOAuthConfig } from '@qwen-code/qwen-code-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
|
||||
// 新增:处理认证
|
||||
const handleAuthenticate = useCallback(async () => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// 显示开始认证提示
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'info',
|
||||
text: t("Starting OAuth authentication for '{{name}}'...", {
|
||||
name: selectedServer.name,
|
||||
}),
|
||||
},
|
||||
Date.now()
|
||||
);
|
||||
|
||||
// 监听并显示认证过程中的消息
|
||||
const displayListener = (message: string) => {
|
||||
context.ui.addItem({ type: 'info', text: message }, Date.now());
|
||||
};
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
// 准备 OAuth 配置
|
||||
let oauthConfig: MCPOAuthConfig = selectedServer.config.oauth || { enabled: false };
|
||||
|
||||
// 执行认证
|
||||
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
|
||||
await authProvider.authenticate(
|
||||
selectedServer.name,
|
||||
oauthConfig,
|
||||
selectedServer.config.httpUrl || selectedServer.config.url
|
||||
);
|
||||
|
||||
// 认证成功
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'success',
|
||||
text: t("✓ Authentication successful for '{{name}}'", {
|
||||
name: selectedServer.name,
|
||||
}),
|
||||
},
|
||||
Date.now()
|
||||
);
|
||||
|
||||
// 移除消息监听器
|
||||
appEvents.off(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
// 重新加载服务器数据以更新状态
|
||||
await reloadServers();
|
||||
|
||||
// 返回上一级
|
||||
handleNavigateBack();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Authentication failed for '${selectedServer.name}':`,
|
||||
error
|
||||
);
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: 'error',
|
||||
text: t("✗ Authentication failed: {{error}}", {
|
||||
error: getErrorMessage(error),
|
||||
}),
|
||||
},
|
||||
Date.now()
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [config, selectedServer, reloadServers, handleNavigateBack, context]);
|
||||
|
||||
// 在 renderStepContent 中添加认证步骤的处理
|
||||
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
|
||||
// 可以直接执行认证,或者显示一个确认对话框
|
||||
void handleAuthenticate();
|
||||
return <Text>{t('Authenticating...')}</Text>;
|
||||
```
|
||||
|
||||
##### 4. 更新 i18n 翻译文件
|
||||
|
||||
```javascript
|
||||
// packages/cli/src/i18n/locales/en.js
|
||||
{
|
||||
'Authenticate': 'Authenticate',
|
||||
'Authenticate with OAuth': 'Authenticate with OAuth',
|
||||
"Starting OAuth authentication for '{{name}}'...": "Starting OAuth authentication for '{{name}}'...",
|
||||
"✓ Authentication successful for '{{name}}'": "✓ Authentication successful for '{{name}}'",
|
||||
"✗ Authentication failed: {{error}}": "✗ Authentication failed: {{error}}",
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
|
||||
- ✅ 用户体验统一,所有 MCP 管理操作在一个界面完成
|
||||
- ✅ 复用现有 OAuth 认证逻辑,开发成本低
|
||||
- ✅ 直观的视觉反馈,认证过程透明
|
||||
- ✅ 符合现代 UI/UX 设计原则
|
||||
|
||||
**缺点**:
|
||||
|
||||
- ⚠️ 需要处理浏览器跳转和回调 (已有完善实现,风险低)
|
||||
|
||||
#### 方案 B: 保留命令行但改进引导提示
|
||||
|
||||
如果某些场景下确实需要命令行认证 (如自动化脚本),可以:
|
||||
|
||||
- 保留 `/mcp auth` 命令
|
||||
- 在 Dialog 中提供快速复制的命令模板
|
||||
- 添加"Copy Auth Command"按钮
|
||||
|
||||
但这会增加复杂性,不如方案 A 简洁。
|
||||
|
||||
---
|
||||
|
||||
## 问题 2: 【P1】一些异常状态
|
||||
|
||||
### 2.1 禁用之后还可以点击"查看工具",点进去是空的
|
||||
|
||||
#### 问题描述
|
||||
|
||||
- **现象**: MCP Server 被禁用后,仍然可以在 UI 中看到"查看工具"选项,点击进入后显示空列表
|
||||
- **期望**: 禁用后的服务器不应该显示"查看工具"选项,或者应该给出明确的提示信息
|
||||
|
||||
#### 根本原因分析
|
||||
|
||||
当前代码逻辑:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
const actions = useMemo(() => {
|
||||
const result: Array<{ label: string; value: ServerAction }> = [];
|
||||
|
||||
// 无论服务器是否禁用,都添加"查看工具"选项
|
||||
result.push({ label: t('View Tools'), value: 'view-tools' });
|
||||
|
||||
if (server.status === 'disconnected') {
|
||||
result.push({ label: t('Reconnect'), value: 'reconnect' });
|
||||
}
|
||||
|
||||
result.push({
|
||||
label: server.isDisabled ? t('Enable') : t('Disable'),
|
||||
value: 'toggle-disable',
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
```
|
||||
|
||||
问题在于:
|
||||
|
||||
1. 没有根据 `server.isDisabled` 状态过滤操作选项
|
||||
2. 禁用服务器的工具列表获取逻辑可能存在问题
|
||||
3. 缺少用户友好的提示信息
|
||||
|
||||
#### 解决方案
|
||||
|
||||
**方案 A: 禁用时隐藏"查看工具"选项 (推荐)**
|
||||
|
||||
**代码修改**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
const actions = useMemo(() => {
|
||||
const result: Array<{ label: string; value: ServerAction }> = [];
|
||||
|
||||
// 只在服务器启用且已连接时显示"查看工具"选项
|
||||
if (!server.isDisabled && server.status === MCPServerStatus.CONNECTED) {
|
||||
result.push({
|
||||
label: t('View Tools'),
|
||||
value: 'view-tools',
|
||||
disabled: server.toolCount === 0, // 可选:工具数量为 0 时禁用
|
||||
});
|
||||
}
|
||||
|
||||
// 禁用状态下显示提示信息
|
||||
if (server.isDisabled) {
|
||||
result.push({
|
||||
label: t('Enable to view tools'),
|
||||
value: 'toggle-disable',
|
||||
});
|
||||
} else {
|
||||
if (server.status === MCPServerStatus.DISCONNECTED) {
|
||||
result.push({ label: t('Reconnect'), value: 'reconnect' });
|
||||
}
|
||||
|
||||
result.push({
|
||||
label: t('Disable'),
|
||||
value: 'toggle-disable',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
```
|
||||
|
||||
**同时修改 ToolListStep**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
|
||||
export const ToolListStep: React.FC<ToolListStepProps> = ({
|
||||
tools,
|
||||
serverName,
|
||||
onSelect,
|
||||
onBack,
|
||||
}) => {
|
||||
// 添加禁用状态检查
|
||||
if (tools.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('No tools available for this server.')}
|
||||
</Text>
|
||||
{/* 添加提示:服务器可能被禁用 */}
|
||||
<Text color={theme.text.warning}>
|
||||
{t('Note: This server may be disabled. Please enable it in the server settings.')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// ... 其余代码保持不变
|
||||
};
|
||||
```
|
||||
|
||||
**方案 B: 显示友好提示并阻止导航**
|
||||
|
||||
在 `MCPManagementDialog` 中添加拦截逻辑:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
|
||||
const handleViewTools = useCallback(() => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
// 检查服务器是否禁用
|
||||
if (selectedServer.isDisabled) {
|
||||
// 显示提示信息,不执行导航
|
||||
debugLogger.warn(
|
||||
`Cannot view tools for disabled server '${selectedServer.name}'`,
|
||||
);
|
||||
// 可选:在 UI 上显示临时消息
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有工具
|
||||
if (selectedServer.toolCount === 0) {
|
||||
debugLogger.info(`No tools available for server '${selectedServer.name}'`);
|
||||
// 仍然可以进入查看,但会显示空状态提示
|
||||
}
|
||||
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
|
||||
}, [selectedServer, handleNavigateToStep]);
|
||||
```
|
||||
|
||||
#### 推荐方案:方案 A + ToolListStep 的提示增强
|
||||
|
||||
---
|
||||
|
||||
### 2.2 禁用之后还能重新连接
|
||||
|
||||
#### 问题描述
|
||||
|
||||
- **现象**: MCP Server 被禁用后,仍然可以看到"重新连接"选项
|
||||
- **期望**: 禁用之后应该没有"重新连接"入口
|
||||
- **文档建议**: 禁用之后应该没有"重新连接"入口
|
||||
|
||||
#### 根本原因分析
|
||||
|
||||
当前代码逻辑:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
if (server.status === 'disconnected') {
|
||||
result.push({ label: t('Reconnect'), value: 'reconnect' });
|
||||
}
|
||||
```
|
||||
|
||||
问题在于:
|
||||
|
||||
1. 只检查了连接状态,没有检查禁用状态
|
||||
2. 禁用的服务器不应该允许重新连接操作
|
||||
3. 逻辑上矛盾:既然禁用了就不应该尝试连接
|
||||
|
||||
#### 解决方案
|
||||
|
||||
**代码修改**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
const actions = useMemo(() => {
|
||||
const result: Array<{ label: string; value: ServerAction }> = [];
|
||||
|
||||
// View Tools 选项
|
||||
if (!server.isDisabled && server.toolCount > 0) {
|
||||
result.push({ label: t('View Tools'), value: 'view-tools' });
|
||||
}
|
||||
|
||||
// Reconnect 选项:只在未禁用且断开连接时显示
|
||||
if (!server.isDisabled && server.status === MCPServerStatus.DISCONNECTED) {
|
||||
result.push({ label: t('Reconnect'), value: 'reconnect' });
|
||||
}
|
||||
|
||||
// Enable/Disable 选项
|
||||
result.push({
|
||||
label: server.isDisabled ? t('Enable Server') : t('Disable Server'),
|
||||
value: 'toggle-disable',
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
```
|
||||
|
||||
**同时在 ServerListStep 中添加视觉提示**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}>
|
||||
{' '}
|
||||
{t('(disabled - no connection possible)')}
|
||||
</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 问题 3: 【P1】禁用有个选择设置的 dialog,有点费解
|
||||
|
||||
#### 问题描述
|
||||
|
||||
- **现象**: 禁用服务器时会弹出一个对话框让用户选择禁用范围 (user/workspace)
|
||||
- **问题**: 这个选择让用户体验困惑,特别是当 MCP server 在项目级配置时,在用户级别禁用就有点费解
|
||||
- **文档建议**: MCP server 在哪里,就在哪里禁用(如果 MCP server 在项目级,在用户级别禁用就有点费解)
|
||||
|
||||
#### 根本原因分析
|
||||
|
||||
当前实现逻辑:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
|
||||
const handleSelectDisableScope = useCallback(
|
||||
async (scope: 'user' | 'workspace') => {
|
||||
// 允许用户在 user 或 workspace 层面禁用服务器
|
||||
// 即使服务器配置在 workspace 层面,也允许在 user 层面禁用
|
||||
},
|
||||
[config, selectedServer, handleNavigateBack, reloadServers],
|
||||
);
|
||||
```
|
||||
|
||||
问题在于:
|
||||
|
||||
1. 用户可以跨 scope 禁用服务器,造成配置混乱
|
||||
2. 不符合"在哪里配置就在哪里管理"的直觉
|
||||
3. 增加了不必要的复杂性
|
||||
|
||||
#### 解决方案
|
||||
|
||||
**方案 A: 根据服务器来源自动确定禁用 scope (强烈推荐)**
|
||||
|
||||
**核心思路**:
|
||||
|
||||
- User 级别的配置 → 只能在 User 级别禁用
|
||||
- Workspace 级别的配置 → 只能在 Workspace 级别禁用
|
||||
- Extension 级别的配置 → 不允许禁用 (只能卸载扩展)
|
||||
|
||||
**代码修改**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
|
||||
|
||||
// 修改 handleDisable 函数
|
||||
const handleDisable = useCallback(() => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
// 如果服务器已经被禁用,直接启用
|
||||
if (selectedServer.isDisabled) {
|
||||
void handleEnableServer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extension 提供的服务器不允许禁用
|
||||
if (selectedServer.source === 'extension') {
|
||||
debugLogger.warn(
|
||||
`Cannot disable extension-provided server '${selectedServer.name}'`,
|
||||
);
|
||||
// 显示提示信息
|
||||
return;
|
||||
}
|
||||
|
||||
// 根据服务器 scope 直接禁用,不再询问
|
||||
const scope =
|
||||
selectedServer.scope === 'extension'
|
||||
? SettingScope.User
|
||||
: selectedServer.scope === 'workspace'
|
||||
? SettingScope.Workspace
|
||||
: SettingScope.User;
|
||||
|
||||
// 直接执行禁用操作
|
||||
void executeDisable(scope);
|
||||
}, [selectedServer, handleEnableServer]);
|
||||
|
||||
// 新增执行禁用函数
|
||||
const executeDisable = useCallback(
|
||||
async (scope: SettingScope) => {
|
||||
if (!config || !selectedServer) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const settings = loadSettings();
|
||||
const scopeSettings = settings.forScope(scope).settings;
|
||||
const currentExcluded = scopeSettings.mcp?.excluded || [];
|
||||
|
||||
if (!currentExcluded.includes(selectedServer.name)) {
|
||||
const newExcluded = [...currentExcluded, selectedServer.name];
|
||||
settings.setValue(scope, 'mcp.excluded', newExcluded);
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (toolRegistry) {
|
||||
await toolRegistry.disableMcpServer(selectedServer.name);
|
||||
}
|
||||
|
||||
await reloadServers();
|
||||
handleNavigateBack();
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error disabling server '${selectedServer.name}':`,
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[config, selectedServer, reloadServers, handleNavigateBack],
|
||||
);
|
||||
|
||||
// 移除 DisableScopeSelectStep 相关的代码和导航逻辑
|
||||
```
|
||||
|
||||
**同时修改 UI 提示**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Scope:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>
|
||||
{t(server.scope)}
|
||||
{server.source === 'extension' && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}({t('provided by {{name}}', { name: server.config.extensionName })})
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
// 禁用按钮文本根据 scope 调整
|
||||
{server.isDisabled ? (
|
||||
<Text>{t('Enable (will remove from exclusion list)')}</Text>
|
||||
) : server.source === 'extension' ? (
|
||||
<Text color={theme.text.secondary}>{t('Cannot disable extension server')}</Text>
|
||||
) : (
|
||||
<Text>{t('Disable (in {{scope}})', { scope: server.scope })}</Text>
|
||||
)}
|
||||
```
|
||||
|
||||
**方案 B: 保留选择但改进 UX**
|
||||
|
||||
如果确实需要支持跨 scope 禁用 (考虑到某些特殊场景),至少应该:
|
||||
|
||||
1. 明确显示当前服务器的配置位置
|
||||
2. 说明不同选择的影响
|
||||
3. 给出推荐选项
|
||||
|
||||
但这会增加复杂性,不如方案 A 简洁明了。
|
||||
|
||||
#### 推荐方案:方案 A
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
---
|
||||
|
||||
## 问题 6: 【P2】Extension Management - /extension manage 报错
|
||||
|
||||
### 问题描述
|
||||
|
||||
- **现象**: 使用 `/extension manage` 命令时直接报错
|
||||
- **期望**: 应该能正常打开 Extension Management Dialog
|
||||
|
||||
### 根本原因分析
|
||||
|
||||
#### 可能的原因
|
||||
|
||||
1. **命令拼写错误** (最可能)
|
||||
- 正确的命令是 `/extensions manage` (复数形式)
|
||||
- 用户可能输入了 `/extension manage` (单数形式)
|
||||
2. **ExtensionManager 未正确初始化**
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/commands/extensionsCommand.ts#L103-108
|
||||
async function listAction(_context: CommandContext, _args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
return; // ❌ 这里直接返回,没有给用户任何提示
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
3. **环境限制**
|
||||
- 某些环境下无法加载 ExtensionManager
|
||||
- 沙箱模式可能限制扩展管理功能
|
||||
|
||||
#### 当前错误处理问题
|
||||
|
||||
- 如果 `getExtensionManager()` 返回 null 或不是 ExtensionManager 实例
|
||||
- 代码只是记录 debug 日志并静默返回
|
||||
- **用户看不到任何错误提示**,只会感到困惑
|
||||
|
||||
### 解决方案
|
||||
|
||||
#### 方案 A: 改进错误提示 (强烈推荐)
|
||||
|
||||
**代码修改**:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/commands/extensionsCommand.ts
|
||||
async function listAction(context: CommandContext, _args: string) {
|
||||
const extensionManager = context.services.config?.getExtensionManager();
|
||||
|
||||
if (!(extensionManager instanceof ExtensionManager)) {
|
||||
debugLogger.error(
|
||||
`Cannot ${context.invocation?.name} extensions in this environment`,
|
||||
);
|
||||
|
||||
// ✅ 添加用户友好的错误提示
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: t(
|
||||
'Extension management is not available in the current environment. ' +
|
||||
'This feature may not be supported in your current mode or configuration.',
|
||||
),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'dialog' as const,
|
||||
dialog: 'extensions_manage' as const,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案 B: 检查命令拼写并给出提示
|
||||
|
||||
在命令解析层面添加提示:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/commands/registry.ts 或相关位置
|
||||
// 当检测到用户输入 '/extension'(单数) 时,给出提示
|
||||
if (commandName === 'extension') {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: t('Did you mean "/extensions"? (plural form)'),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 方案 C: 同时支持单复数形式
|
||||
|
||||
为了用户体验,可以同时支持两种形式:
|
||||
|
||||
```typescript
|
||||
// packages/cli/src/ui/commands/extensionsCommand.ts
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions', // 主要命令 (复数)
|
||||
aliases: ['extension'], // ✅ 添加别名 (单数)
|
||||
get description() {
|
||||
return t('Manage extensions');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
manageExtensionsCommand,
|
||||
installCommand,
|
||||
exploreExtensionsCommand,
|
||||
],
|
||||
action: async (context, args) =>
|
||||
manageExtensionsCommand.action!(context, args),
|
||||
};
|
||||
```
|
||||
|
||||
**注意**: 需要检查 SlashCommand 类型定义是否支持 `aliases` 属性
|
||||
|
||||
### 推荐方案
|
||||
|
||||
**采用方案 A + 方案 C**:
|
||||
|
||||
1. 改进错误提示,让用户知道发生了什么
|
||||
2. 如果可能,同时支持单复数形式
|
||||
|
||||
---
|
||||
|
||||
## 实施计划
|
||||
|
||||
### Phase 1: 修复异常状态问题 (优先级:高)
|
||||
|
||||
1. **修复问题 2.1**: 禁用后可查看工具
|
||||
- 修改 `ServerDetailStep.tsx` 的操作列表逻辑
|
||||
- 修改 `ToolListStep.tsx` 添加友好提示
|
||||
- 预计工时:2 小时
|
||||
|
||||
2. **修复问题 2.2**: 禁用后可重新连接
|
||||
- 修改 `ServerDetailStep.tsx` 的 reconnect 选项条件
|
||||
- 预计工时:1 小时
|
||||
|
||||
### Phase 2: 在 Dialog 中集成 Auth 功能 (优先级:高)
|
||||
|
||||
3. **修复问题 1**: MCP Dialog 集成 OAuth 认证
|
||||
- 扩展 `MCP_MANAGEMENT_STEPS` 添加认证步骤
|
||||
- 在 `ServerDetailStep` 中添加"Authenticate"选项
|
||||
- 在 `MCPManagementDialog` 中实现认证逻辑
|
||||
- 更新 i18n 翻译文件
|
||||
- 预计工时:4 小时
|
||||
|
||||
### Phase 3: 改进禁用体验 (优先级:中)
|
||||
|
||||
4. **修复问题 3**: 简化禁用流程
|
||||
- 移除 `DisableScopeSelectStep`
|
||||
- 实现自动 scope 判断逻辑
|
||||
- 更新 UI 提示
|
||||
- 预计工时:4 小时
|
||||
|
||||
### Phase 4: UI 细节优化 (优先级:中)
|
||||
|
||||
5. **修复问题 4**: Dialog 1 细节优化
|
||||
- 移除重复的来源显示
|
||||
- 优化错误信息显示逻辑 (只在有错误时显示)
|
||||
- 移除多余的空格
|
||||
- 优化布局紧凑度
|
||||
- 预计工时:3 小时
|
||||
|
||||
6. **修复问题 5**: Dialog 2 细节优化
|
||||
- 统一来源颜色与其他部分一致
|
||||
- 添加功能说明 tooltip
|
||||
- 统一选中色为 theme.text.accent
|
||||
- 优化工具标注文案 (如"destructive, open-world")
|
||||
- 移除不必要的序号
|
||||
- 预计工时:3 小时
|
||||
|
||||
### Phase 5: Extension Management 修复 (优先级:低)
|
||||
|
||||
7. **修复问题 6**: Extension 命令报错
|
||||
- 改进错误提示 (方案 A)
|
||||
- 考虑支持单复数形式 (方案 C)
|
||||
- 预计工时:2 小时
|
||||
|
||||
### Phase 6: 测试与验证 (优先级:高)
|
||||
|
||||
8. **回归测试**
|
||||
- 更新所有相关测试用例
|
||||
- 手动测试各个场景
|
||||
- 确保没有破坏性变更
|
||||
- 预计工时:4 小时
|
||||
|
||||
**总预计工时**: 约 23 小时 (约 3 个工作日)
|
||||
|
||||
---
|
||||
|
||||
## 影响评估
|
||||
|
||||
### 兼容性影响
|
||||
|
||||
- **Breaking Changes**: 无
|
||||
- **Deprecation**: 无
|
||||
- **新功能**: MCP Dialog 集成 OAuth 认证功能
|
||||
|
||||
### 需要更新的文档
|
||||
|
||||
1. `docs/developers/tools/mcp-server.md` - 更新 MCP 管理对话框使用说明
|
||||
2. `docs/users/features/mcp-servers.md` - 更新用户指南
|
||||
3. `docs/users/features/extensions.md` - 更新扩展管理说明
|
||||
4. 内联帮助文本和 i18n 文件
|
||||
|
||||
### 需要更新的测试
|
||||
|
||||
1. `packages/cli/src/ui/commands/mcpCommand.test.ts`
|
||||
2. `packages/cli/src/ui/components/mcp/MCPManagementDialog.test.tsx`
|
||||
3. `packages/cli/src/ui/components/mcp/steps/ServerDetailStep.test.tsx`
|
||||
4. `packages/cli/src/ui/commands/extensionsCommand.test.ts`
|
||||
5. `packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
### 问题 1 验收标准
|
||||
|
||||
- [ ] MCP Management Dialog 中显示"Authenticate"选项 (针对需要认证的服务器)
|
||||
- [ ] 点击认证后能正确启动 OAuth 流程
|
||||
- [ ] 认证过程中显示友好的提示信息
|
||||
- [ ] 认证成功后自动刷新服务器状态
|
||||
- [ ] 认证失败时显示明确的错误信息
|
||||
- [ ] 保留 `/mcp auth` 命令作为备选方案 (可选)
|
||||
|
||||
### 问题 2.1 验收标准
|
||||
|
||||
- [ ] 禁用的服务器不显示"查看工具"选项,或显示友好提示
|
||||
- [ ] 工具列表为空时,明确提示原因
|
||||
- [ ] 用户不会看到空的工具列表页面
|
||||
|
||||
### 问题 2.2 验收标准
|
||||
|
||||
- [ ] 禁用的服务器不显示"重新连接"选项
|
||||
- [ ] UI 逻辑自洽,不会出现矛盾的操作选项
|
||||
- [ ] 禁用状态下只能看到"启用"选项
|
||||
|
||||
### 问题 3 验收标准
|
||||
|
||||
- [ ] 禁用操作一键完成,无需选择 scope
|
||||
- [ ] 禁用范围自动匹配配置范围
|
||||
- [ ] UI 明确显示服务器的配置位置
|
||||
- [ ] 用户体验流畅,无困惑点
|
||||
|
||||
### 问题 4 验收标准 (Dialog 1 细节优化)
|
||||
|
||||
- [ ] 移除重复的来源显示
|
||||
- [ ] 只在有错误时显示"运行 qwen --debug..."提示
|
||||
- [ ] 没有错误时不显示多余的空格
|
||||
- [ ] 布局更加紧凑,接近 claude code 的视觉效果
|
||||
|
||||
### 问题 5 验收标准 (Dialog 2 细节优化)
|
||||
|
||||
- [ ] 来源颜色与其他部分统一
|
||||
- [ ] 添加清晰的功能说明
|
||||
- [ ] 统一选中色为 theme.text.accent
|
||||
- [ ] 工具标注文案更易懂 (如改为"Destructive, Open-world")
|
||||
- [ ] 移除列表项前的序号 (1、2、3...)
|
||||
|
||||
### 问题 6 验收标准 (Extension Management)
|
||||
|
||||
- [ ] `/extensions manage` 命令能正常工作
|
||||
- [ ] 如果 ExtensionManager 不可用,显示明确的错误提示
|
||||
- [ ] 考虑支持 `/extension`(单数) 作为别名 (可选)
|
||||
- [ ] 测试不同环境下的行为 (普通模式、沙箱模式等)
|
||||
|
||||
---
|
||||
|
||||
## 技术细节补充
|
||||
|
||||
### 关键文件清单
|
||||
|
||||
```
|
||||
# MCP Management
|
||||
packages/cli/src/ui/commands/mcpCommand.ts
|
||||
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
|
||||
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
|
||||
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
|
||||
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
|
||||
packages/cli/src/ui/components/mcp/types.ts
|
||||
packages/core/src/tools/mcp-client-manager.ts
|
||||
packages/core/src/config/config.ts
|
||||
|
||||
# Extension Management
|
||||
packages/cli/src/ui/commands/extensionsCommand.ts
|
||||
packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx
|
||||
packages/cli/src/ui/components/extensions/types.ts
|
||||
packages/core/src/extension/extensionManager.ts
|
||||
```
|
||||
|
||||
### 依赖关系
|
||||
|
||||
- MCP Management Dialog 依赖于 Config、ToolRegistry、PromptRegistry
|
||||
- 禁用逻辑涉及 Settings 的多 scope 管理
|
||||
- 状态跟踪通过 `getMCPServerStatus` 和状态监听器实现
|
||||
|
||||
### 潜在风险点
|
||||
|
||||
1. **OAuth 认证流程**: 确保在 Dialog 中集成的认证功能不影响现有命令行认证
|
||||
2. **多 Scope 配置**: 确保自动 scope 判断不会误删其他 scope 的配置
|
||||
3. **Extension 集成**: 确保扩展提供的服务器正确处理
|
||||
4. **环境兼容性**: 确保 Extension Management 在不同环境下都能给出正确的错误提示
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
本文档针对 0.12.0 版本体验反馈中提出的 **6 个问题** (3 个 P1 + 3 个 P2) 进行了详细分析,并提供了具体的解决方案。所有修改都遵循以下原则:
|
||||
|
||||
1. **用户体验优先**: 简化操作流程,减少困惑
|
||||
2. **逻辑一致性**: 确保 UI 状态和行为逻辑自洽
|
||||
3. **向后兼容**: 避免破坏性变更
|
||||
4. **代码质量**: 简化代码结构,提高可维护性
|
||||
5. **错误友好**: 提供清晰、有帮助的错误信息
|
||||
|
||||
建议按优先级分阶段实施,确保每个问题都得到妥善解决。
|
||||
|
|
@ -139,16 +139,16 @@ Logs are timestamped records of specific events. The following events are logged
|
|||
- `qwen-code.config`: This event occurs once at startup with the CLI's configuration.
|
||||
- **Attributes**:
|
||||
- `model` (string)
|
||||
- `embedding_model` (string)
|
||||
- `sandbox_enabled` (boolean)
|
||||
- `core_tools_enabled` (string)
|
||||
- `approval_mode` (string)
|
||||
- `api_key_enabled` (boolean)
|
||||
- `vertex_ai_enabled` (boolean)
|
||||
- `code_assist_enabled` (boolean)
|
||||
- `log_prompts_enabled` (boolean)
|
||||
- `file_filtering_respect_git_ignore` (boolean)
|
||||
- `debug_mode` (boolean)
|
||||
- `truncate_tool_output_threshold` (number)
|
||||
- `truncate_tool_output_lines` (number)
|
||||
- `hooks` (string, comma-separated hook event types, omitted if hooks disabled)
|
||||
- `ide_enabled` (boolean)
|
||||
- `interactive_shell_enabled` (boolean)
|
||||
- `mcp_servers` (string)
|
||||
- `output_format` (string: "text" or "json")
|
||||
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ Creates a new query session with the Qwen Code.
|
|||
| `permissionMode` | `'default' \| 'plan' \| 'auto-edit' \| 'yolo'` | `'default'` | Permission mode controlling tool execution approval. See [Permission Modes](#permission-modes) for details. |
|
||||
| `canUseTool` | `CanUseTool` | - | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See [Custom Permission Handler](#custom-permission-handler). |
|
||||
| `env` | `Record<string, string>` | - | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
|
||||
| `systemPrompt` | `string \| QuerySystemPromptPreset` | - | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. |
|
||||
| `mcpServers` | `Record<string, McpServerConfig>` | - | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like `command`, `args`, `url`, `httpUrl`, etc. SDK servers use `{ type: 'sdk', name: string, instance: Server }`. |
|
||||
| `abortController` | `AbortController` | - | Controller to cancel the query session. Call `abortController.abort()` to terminate the session and cleanup resources. |
|
||||
| `debug` | `boolean` | `false` | Enable debug mode for verbose logging from the CLI process. |
|
||||
|
|
@ -248,6 +249,36 @@ const result = query({
|
|||
});
|
||||
```
|
||||
|
||||
### Override the System Prompt
|
||||
|
||||
```typescript
|
||||
import { query } from '@qwen-code/sdk';
|
||||
|
||||
const result = query({
|
||||
prompt: 'Say hello in one sentence.',
|
||||
options: {
|
||||
systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.',
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Append to the Built-in System Prompt
|
||||
|
||||
```typescript
|
||||
import { query } from '@qwen-code/sdk';
|
||||
|
||||
const result = query({
|
||||
prompt: 'Review the current directory.',
|
||||
options: {
|
||||
systemPrompt: {
|
||||
type: 'preset',
|
||||
preset: 'qwen_code',
|
||||
append: 'Be terse and focus on concrete findings.',
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### With SDK-Embedded MCP Servers
|
||||
|
||||
The SDK provides `tool` and `createSdkMcpServer` to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
|
|||
|
||||
## 2. `read_file` (ReadFile)
|
||||
|
||||
`read_file` reads and returns the content of a specified file. This tool handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges. Other binary file types are generally skipped.
|
||||
`read_file` reads and returns the content of a specified file. This tool handles text files and media files (images, PDFs, audio, video) whose modality is supported by the current model. For text files, it can read specific line ranges. Media files whose modality is not supported by the current model are rejected with a helpful error message. Other binary file types are generally skipped.
|
||||
|
||||
- **Tool name:** `read_file`
|
||||
- **Display name:** ReadFile
|
||||
|
|
@ -35,11 +35,12 @@ Qwen Code provides a comprehensive suite of tools for interacting with the local
|
|||
- `limit` (number, optional): For text files, the maximum number of lines to read. If omitted, reads a default maximum (e.g., 2000 lines) or the entire file if feasible.
|
||||
- **Behavior:**
|
||||
- For text files: Returns the content. If `offset` and `limit` are used, returns only that slice of lines. Indicates if content was truncated due to line limits or line length limits.
|
||||
- For image and PDF files: Returns the file content as a base64-encoded data structure suitable for model consumption.
|
||||
- For media files (images, PDFs, audio, video): If the current model supports the file's modality, returns the file content as a base64-encoded `inlineData` object. If the model does not support the modality, returns an error message with guidance (e.g., suggesting skills or external tools).
|
||||
- For other binary files: Attempts to identify and skip them, returning a message indicating it's a generic binary file.
|
||||
- **Output:** (`llmContent`):
|
||||
- For text files: The file content, potentially prefixed with a truncation message (e.g., `[File content truncated: showing lines 1-100 of 500 total lines...]\nActual file content...`).
|
||||
- For image/PDF files: An object containing `inlineData` with `mimeType` and base64 `data` (e.g., `{ inlineData: { mimeType: 'image/png', data: 'base64encodedstring' } }`).
|
||||
- For supported media files: An object containing `inlineData` with `mimeType` and base64 `data` (e.g., `{ inlineData: { mimeType: 'image/png', data: 'base64encodedstring' } }`).
|
||||
- For unsupported media files: An error message string explaining that the current model does not support this modality, with suggestions for alternatives.
|
||||
- For other binary files: A message like `Cannot display content of binary file: /path/to/data.bin`.
|
||||
- **Confirmation:** No.
|
||||
|
||||
|
|
@ -164,4 +165,63 @@ grep_search(pattern="function", glob="*.js", limit=10)
|
|||
- On failure: An error message explaining the reason (e.g., `Failed to edit, 0 occurrences found...`, `Failed to edit because the text matches multiple locations...`).
|
||||
- **Confirmation:** Yes. Shows a diff of the proposed changes and asks for user approval before writing to the file.
|
||||
|
||||
## File encoding and platform-specific behavior
|
||||
|
||||
### Encoding detection and preservation
|
||||
|
||||
When reading files, Qwen Code detects the file's encoding using a multi-step strategy:
|
||||
|
||||
1. **UTF-8** — tried first (most modern tooling outputs UTF-8)
|
||||
2. **chardet** — statistical detection for non-UTF-8 content
|
||||
3. **System encoding** — falls back to the OS code page (Windows `chcp` / Unix `LANG`)
|
||||
|
||||
Both `write_file` and `edit` preserve the original encoding and BOM (byte order mark) of existing files. If a file was read as GBK with a UTF-8 BOM, it will be written back the same way.
|
||||
|
||||
### Configuring default encoding for new files
|
||||
|
||||
The `defaultFileEncoding` setting controls encoding for **newly created** files (not edits to existing files):
|
||||
|
||||
| Value | Behavior |
|
||||
| ----------- | --------------------------------------------------------------------------- |
|
||||
| _(not set)_ | UTF-8 without BOM, with automatic platform-specific adjustments (see below) |
|
||||
| `utf-8` | UTF-8 without BOM, no automatic adjustments |
|
||||
| `utf-8-bom` | UTF-8 with BOM for all new files |
|
||||
|
||||
Set it in `.qwen/settings.json` or `~/.qwen/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"defaultFileEncoding": "utf-8-bom"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows: CRLF for batch files
|
||||
|
||||
On Windows, `.bat` and `.cmd` files are automatically written with CRLF (`\r\n`) line endings. This is required because `cmd.exe` uses CRLF as its line delimiter — LF-only endings can break multi-line `if`/`else`, `goto` labels, and `for` loops. This applies regardless of encoding settings and only on Windows.
|
||||
|
||||
### Windows: UTF-8 BOM for PowerShell scripts
|
||||
|
||||
On Windows with a **non-UTF-8 system code page** (e.g. GBK/cp936, Big5/cp950, Shift_JIS/cp932), newly created `.ps1` files are automatically written with a UTF-8 BOM. This is necessary because Windows PowerShell 5.1 (the version built into Windows 10/11) reads BOM-less scripts using the system's ANSI code page. Without a BOM, any non-ASCII characters in the script will be misinterpreted.
|
||||
|
||||
This automatic BOM only applies when:
|
||||
|
||||
- The platform is Windows
|
||||
- The system code page is not UTF-8 (not code page 65001)
|
||||
- The file is a new `.ps1` file (existing files keep their original encoding)
|
||||
- The user has **not** explicitly set `defaultFileEncoding` in settings
|
||||
|
||||
PowerShell 7+ (pwsh) defaults to UTF-8 and handles BOM transparently, so the BOM is harmless there.
|
||||
|
||||
If you explicitly set `defaultFileEncoding` to `"utf-8"`, the automatic BOM is disabled — this is an intentional escape hatch for repositories or tooling that reject BOMs.
|
||||
|
||||
### Summary
|
||||
|
||||
| File type | Platform | Automatic behavior |
|
||||
| -------------- | ----------------------------- | --------------------------- |
|
||||
| `.bat`, `.cmd` | Windows | CRLF line endings |
|
||||
| `.ps1` | Windows (non-UTF-8 code page) | UTF-8 BOM on new files |
|
||||
| All others | All | UTF-8 without BOM (default) |
|
||||
|
||||
These file system tools provide a foundation for Qwen Code to understand and interact with your local project context.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -129,7 +129,6 @@ Settings are organized into categories. All settings should be placed within the
|
|||
| -------------------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `model.name` | string | The Qwen model to use for conversations. | `undefined` |
|
||||
| `model.maxSessionTurns` | number | Maximum number of user/model/tool turns to keep in a session. -1 means unlimited. | `-1` |
|
||||
| `model.summarizeToolOutput` | object | Enables or disables the summarization of tool output. You can specify the token budget for the summarization using the `tokenBudget` setting. Note: Currently only the `run_shell_command` tool is supported. For example `{"run_shell_command": {"tokenBudget": 2000}}` | `undefined` |
|
||||
| `model.generationConfig` | object | Advanced overrides passed to the underlying content generator. Supports request controls such as `timeout`, `maxRetries`, `enableCacheControl`, `contextWindowSize` (override model's context window size), `modalities` (override auto-detected input modalities), `customHeaders` (custom HTTP headers for API requests), and `extra_body` (additional body parameters for OpenAI-compatible API requests only), along with fine-tuning knobs under `samplingParams` (for example `temperature`, `top_p`, `max_tokens`). Leave unset to rely on provider defaults. | `undefined` |
|
||||
| `model.chatCompression.contextPercentageThreshold` | number | Sets the threshold for chat history compression as a percentage of the model's total token limit. This is a value between 0 and 1 that applies to both automatic compression and the manual `/compress` command. For example, a value of `0.6` will trigger compression when the chat history exceeds 60% of the token limit. Use `0` to disable compression entirely. | `0.7` |
|
||||
| `model.skipNextSpeakerCheck` | boolean | Skip the next speaker check. | `false` |
|
||||
|
|
@ -213,18 +212,114 @@ If you are experiencing performance issues with file searching (e.g., with `@` c
|
|||
| ------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `tools.sandbox` | boolean or string | Sandbox execution environment (can be a boolean or a path string). | `undefined` | |
|
||||
| `tools.shell.enableInteractiveShell` | boolean | Use `node-pty` for an interactive shell experience. Fallback to `child_process` still applies. | `false` | |
|
||||
| `tools.core` | array of strings | This can be used to restrict the set of built-in tools with an allowlist. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"tools.core": ["run_shell_command(ls -l)"]` will only allow the `ls -l` command to be executed. | `undefined` | |
|
||||
| `tools.exclude` | array of strings | Tool names to exclude from discovery. You can also specify command-specific restrictions for tools that support it, like the `run_shell_command` tool. For example, `"tools.exclude": ["run_shell_command(rm -rf)"]` will block the `rm -rf` command. **Security Note:** Command-specific restrictions in `tools.exclude` for `run_shell_command` are based on simple string matching and can be easily bypassed. This feature is **not a security mechanism** and should not be relied upon to safely execute untrusted code. It is recommended to use `tools.core` to explicitly select commands that can be executed. | `undefined` | |
|
||||
| `tools.allowed` | array of strings | A list of tool names that will bypass the confirmation dialog. This is useful for tools that you trust and use frequently. For example, `["run_shell_command(git)", "run_shell_command(npm test)"]` will skip the confirmation dialog to run any `git` and `npm test` commands. | `undefined` | |
|
||||
| `tools.core` | array of strings | **Deprecated.** Will be removed in next version. Use `permissions.allow` + `permissions.deny` instead. Restricts built-in tools to an allowlist. All tools not in the list are disabled. | `undefined` | |
|
||||
| `tools.exclude` | array of strings | **Deprecated.** Use `permissions.deny` instead. Tool names to exclude from discovery. Automatically migrated to the `permissions` format on first load. | `undefined` | |
|
||||
| `tools.allowed` | array of strings | **Deprecated.** Use `permissions.allow` instead. Tool names that bypass the confirmation dialog. Automatically migrated to the `permissions` format on first load. | `undefined` | |
|
||||
| `tools.approvalMode` | string | Sets the default approval mode for tool usage. | `default` | Possible values: `plan` (analyze only, do not modify files or execute commands), `default` (require approval before file edits or shell commands run), `auto-edit` (automatically approve file edits), `yolo` (automatically approve all tool calls) |
|
||||
| `tools.discoveryCommand` | string | Command to run for tool discovery. | `undefined` | |
|
||||
| `tools.callCommand` | string | Defines a custom shell command for calling a specific tool that was discovered using `tools.discoveryCommand`. The shell command must meet the following criteria: It must take function `name` (exactly as in [function declaration](https://ai.google.dev/gemini-api/docs/function-calling#function-declarations)) as first command line argument. It must read function arguments as JSON on `stdin`, analogous to [`functionCall.args`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functioncall). It must return function output as JSON on `stdout`, analogous to [`functionResponse.response.content`](https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/inference#functionresponse). | `undefined` | |
|
||||
| `tools.useRipgrep` | boolean | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | |
|
||||
| `tools.useBuiltinRipgrep` | boolean | Use the bundled ripgrep binary. When set to `false`, the system-level `rg` command will be used instead. This setting is only effective when `tools.useRipgrep` is `true`. | `true` | |
|
||||
| `tools.enableToolOutputTruncation` | boolean | Enable truncation of large tool outputs. | `true` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputThreshold` | number | Truncate tool output if it is larger than this many characters. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `25000` | Requires restart: Yes |
|
||||
| `tools.truncateToolOutputLines` | number | Maximum lines or entries kept when truncating tool output. Applies to Shell, Grep, Glob, ReadFile and ReadManyFiles tools. | `1000` | Requires restart: Yes |
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> **Migrating from `tools.core` / `tools.exclude` / `tools.allowed`:** These legacy settings are **deprecated** and automatically migrated to the new `permissions` format on first load. Prefer configuring `permissions.allow` / `permissions.deny` directly. Use `/permissions` to manage rules interactively.
|
||||
|
||||
#### permissions
|
||||
|
||||
The permissions system provides fine-grained control over which tools can run, which require confirmation, and which are blocked.
|
||||
|
||||
**Decision priority (highest first): `deny` > `ask` > `allow` > _(default/interactive mode)_**
|
||||
|
||||
The first matching rule wins. Rules use the format `"ToolName"` or `"ToolName(specifier)"`.
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
| ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- | ----------- |
|
||||
| `permissions.allow` | array of strings | Rules for auto-approved tool calls (no confirmation needed). Merged across all scopes (user + project + system). | `undefined` |
|
||||
| `permissions.ask` | array of strings | Rules for tool calls that always require user confirmation. Takes priority over `allow`. | `undefined` |
|
||||
| `permissions.deny` | array of strings | Rules for blocked tool calls. Highest priority — overrides both `allow` and `ask`. | `undefined` |
|
||||
|
||||
**Tool name aliases (any of these work in rules):**
|
||||
|
||||
| Alias | Canonical tool | Notes |
|
||||
| --------------------- | ------------------- | ------------------------- |
|
||||
| `Bash`, `Shell` | `run_shell_command` | |
|
||||
| `Read`, `ReadFile` | `read_file` | Meta-category — see below |
|
||||
| `Edit`, `EditFile` | `edit` | Meta-category — see below |
|
||||
| `Write`, `WriteFile` | `write_file` | |
|
||||
| `Grep`, `SearchFiles` | `grep_search` | |
|
||||
| `Glob`, `FindFiles` | `glob` | |
|
||||
| `ListFiles` | `list_directory` | |
|
||||
| `WebFetch` | `web_fetch` | |
|
||||
| `Agent` | `task` | |
|
||||
| `Skill` | `skill` | |
|
||||
|
||||
**Meta-categories:**
|
||||
|
||||
Some rule names automatically cover multiple tools:
|
||||
|
||||
| Rule name | Tools covered |
|
||||
| --------- | ---------------------------------------------------- |
|
||||
| `Read` | `read_file`, `grep_search`, `glob`, `list_directory` |
|
||||
| `Edit` | `edit`, `write_file` |
|
||||
|
||||
> [!important]
|
||||
> `Read(/path/**)` matches **all four** read tools (file read, grep, glob, and directory listing).
|
||||
> To restrict only file reading, use `ReadFile(/path/**)` or `read_file(/path/**)`.
|
||||
|
||||
**Rule syntax examples:**
|
||||
|
||||
| Rule | Meaning |
|
||||
| ----------------------------- | -------------------------------------------------------------- |
|
||||
| `"Bash"` | All shell commands |
|
||||
| `"Bash(git *)"` | Shell commands starting with `git` (word boundary: NOT `gitk`) |
|
||||
| `"Bash(git push *)"` | Shell commands like `git push origin main` |
|
||||
| `"Bash(npm run *)"` | Any `npm run` script |
|
||||
| `"Read"` | All file read operations (read, grep, glob, list) |
|
||||
| `"Read(./secrets/**)"` | Read any file under `./secrets/` recursively |
|
||||
| `"Edit(/src/**/*.ts)"` | Edit TypeScript files under project root `/src/` |
|
||||
| `"WebFetch(api.example.com)"` | Fetch from `api.example.com` and all its subdomains |
|
||||
| `"mcp__puppeteer"` | All tools from the puppeteer MCP server |
|
||||
|
||||
**Path pattern prefixes:**
|
||||
|
||||
| Prefix | Meaning | Example |
|
||||
| ------ | ------------------------------------- | ------------------- |
|
||||
| `//` | Absolute path from filesystem root | `//etc/passwd` |
|
||||
| `~/` | Relative to home directory | `~/Documents/*.pdf` |
|
||||
| `/` | Relative to project root | `/src/**/*.ts` |
|
||||
| `./` | Relative to current working directory | `./secrets/**` |
|
||||
| (none) | Same as `./` | `secrets/**` |
|
||||
|
||||
**Shell command bypass prevention:**
|
||||
|
||||
Permission rules for `Read`, `Edit`, and `WebFetch` are also enforced when the agent runs equivalent shell commands. For example, if `Read(./.env)` is in `deny`, the agent cannot bypass it via `cat .env` in a shell command. Supported shell commands include `cat`, `grep`, `curl`, `wget`, `cp`, `mv`, `rm`, `chmod`, and many more. Unknown/safe commands (e.g. `git`) are unaffected by file/network rules.
|
||||
|
||||
**Migrating from legacy settings:**
|
||||
|
||||
| Legacy setting | Equivalent `permissions` rule | Notes |
|
||||
| --------------- | ------------------------------- | ------------------------------------------------------------ |
|
||||
| `tools.allowed` | `permissions.allow` | Auto-migrated on first load |
|
||||
| `tools.exclude` | `permissions.deny` | Auto-migrated on first load |
|
||||
| `tools.core` | `permissions.allow` (allowlist) | Auto-migrated; unlisted tools are disabled at registry level |
|
||||
|
||||
**Example configuration:**
|
||||
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(git *)", "Bash(npm run *)", "Read(//Users/alice/code/**)"],
|
||||
"ask": ["Bash(git push *)", "Edit"],
|
||||
"deny": ["Bash(rm -rf *)", "Read(.env)", "WebFetch(malicious.com)"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> [!tip]
|
||||
> Use `/permissions` in the interactive CLI to view, add, and remove rules without editing `settings.json` directly.
|
||||
|
||||
#### mcp
|
||||
|
||||
| Setting | Type | Description | Default |
|
||||
|
|
@ -350,11 +445,6 @@ Here is an example of a `settings.json` file with the nested structure, new as o
|
|||
"maxSessionTurns": 10,
|
||||
"enableOpenAILogging": false,
|
||||
"openAILoggingDir": "~/qwen-logs",
|
||||
"summarizeToolOutput": {
|
||||
"run_shell_command": {
|
||||
"tokenBudget": 100
|
||||
}
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"fileName": ["CONTEXT.md", "QWEN.md"],
|
||||
|
|
@ -419,6 +509,8 @@ Arguments passed directly when running the CLI can override other configurations
|
|||
| `--model` | `-m` | Specifies the Qwen model to use for this session. | Model name | Example: `npm start -- --model qwen3-coder-plus` |
|
||||
| `--prompt` | `-p` | Used to pass a prompt directly to the command. This invokes Qwen Code in a non-interactive mode. | Your prompt text | For scripting examples, use the `--output-format json` flag to get structured output. |
|
||||
| `--prompt-interactive` | `-i` | Starts an interactive session with the provided prompt as the initial input. | Your prompt text | The prompt is processed within the interactive session, not before it. Cannot be used when piping input from stdin. Example: `qwen -i "explain this code"` |
|
||||
| `--system-prompt` | | Overrides the built-in main session system prompt for this run. | Your prompt text | Loaded context files such as `QWEN.md` are still appended after this override. Can be combined with `--append-system-prompt`. |
|
||||
| `--append-system-prompt` | | Appends extra instructions to the main session system prompt for this run. | Your prompt text | Applied after the built-in prompt and loaded context files. Can be combined with `--system-prompt`. See [Headless Mode](../features/headless) for examples. |
|
||||
| `--output-format` | `-o` | Specifies the format of the CLI output for non-interactive mode. | `text`, `json`, `stream-json` | `text`: (Default) The standard human-readable output. `json`: A machine-readable JSON output emitted at the end of execution. `stream-json`: Streaming JSON messages emitted as they occur during execution. For structured output and scripting, use the `--output-format json` or `--output-format stream-json` flag. See [Headless Mode](../features/headless) for detailed information. |
|
||||
| `--input-format` | | Specifies the format consumed from standard input. | `text`, `stream-json` | `text`: (Default) Standard text input from stdin or command-line arguments. `stream-json`: JSON message protocol via stdin for bidirectional communication. Requirement: `--input-format stream-json` requires `--output-format stream-json` to be set. When using `stream-json`, stdin is reserved for protocol messages. See [Headless Mode](../features/headless) for detailed information. |
|
||||
| `--include-partial-messages` | | Include partial assistant messages when using `stream-json` output format. When enabled, emits stream events (message_start, content_block_delta, etc.) as they occur during streaming. | | Default: `false`. Requirement: Requires `--output-format stream-json` to be set. See [Headless Mode](../features/headless) for detailed information about stream events. |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export default {
|
||||
commands: 'Commands',
|
||||
'sub-agents': 'SubAgents',
|
||||
arena: 'Agent Arena',
|
||||
skills: 'Skills',
|
||||
headless: 'Headless Mode',
|
||||
checkpointing: {
|
||||
|
|
|
|||
218
docs/users/features/arena.md
Normal file
218
docs/users/features/arena.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -58,6 +58,40 @@ qwen --resume 123e4567-e89b-12d3-a456-426614174000 -p "Apply the follow-up refac
|
|||
> - Session data is project-scoped JSONL under `~/.qwen/projects/<sanitized-cwd>/chats`.
|
||||
> - Restores conversation history, tool outputs, and chat-compression checkpoints before sending the new prompt.
|
||||
|
||||
## Customize the Main Session Prompt
|
||||
|
||||
You can change the main session system prompt for a single CLI run without editing shared memory files.
|
||||
|
||||
### Override the Built-in System Prompt
|
||||
|
||||
Use `--system-prompt` to replace Qwen Code's built-in main-session prompt for the current run:
|
||||
|
||||
```bash
|
||||
qwen -p "Review this patch" --system-prompt "You are a terse release reviewer. Report only blocking issues."
|
||||
```
|
||||
|
||||
### Append Extra Instructions
|
||||
|
||||
Use `--append-system-prompt` to keep the built-in prompt and add extra instructions for this run:
|
||||
|
||||
```bash
|
||||
qwen -p "Review this patch" --append-system-prompt "Be terse and focus on concrete findings."
|
||||
```
|
||||
|
||||
You can combine both flags when you want a custom base prompt plus an extra run-specific instruction:
|
||||
|
||||
```bash
|
||||
qwen -p "Summarize this repository" \
|
||||
--system-prompt "You are a migration planner." \
|
||||
--append-system-prompt "Return exactly three bullets."
|
||||
```
|
||||
|
||||
> [!note]
|
||||
>
|
||||
> - `--system-prompt` applies only to the current run's main session.
|
||||
> - Loaded memory and context files such as `QWEN.md` are still appended after `--system-prompt`.
|
||||
> - `--append-system-prompt` is applied after the built-in prompt and loaded memory, and can be used together with `--system-prompt`.
|
||||
|
||||
## Output Formats
|
||||
|
||||
Qwen Code supports multiple output formats for different use cases:
|
||||
|
|
@ -189,19 +223,21 @@ qwen -p "Write code" --output-format stream-json --include-partial-messages | jq
|
|||
|
||||
Key command-line options for headless usage:
|
||||
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
| Option | Description | Example |
|
||||
| ---------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--prompt`, `-p` | Run in headless mode | `qwen -p "query"` |
|
||||
| `--output-format`, `-o` | Specify output format (text, json, stream-json) | `qwen -p "query" --output-format json` |
|
||||
| `--input-format` | Specify input format (text, stream-json) | `qwen --input-format text --output-format stream-json` |
|
||||
| `--include-partial-messages` | Include partial messages in stream-json output | `qwen -p "query" --output-format stream-json --include-partial-messages` |
|
||||
| `--system-prompt` | Override the main session system prompt for this run | `qwen -p "query" --system-prompt "You are a terse reviewer."` |
|
||||
| `--append-system-prompt` | Append extra instructions to the main session system prompt for this run | `qwen -p "query" --append-system-prompt "Focus on concrete findings."` |
|
||||
| `--debug`, `-d` | Enable debug mode | `qwen -p "query" --debug` |
|
||||
| `--all-files`, `-a` | Include all files in context | `qwen -p "query" --all-files` |
|
||||
| `--include-directories` | Include additional directories | `qwen -p "query" --include-directories src,docs` |
|
||||
| `--yolo`, `-y` | Auto-approve all actions | `qwen -p "query" --yolo` |
|
||||
| `--approval-mode` | Set approval mode | `qwen -p "query" --approval-mode auto_edit` |
|
||||
| `--continue` | Resume the most recent session for this project | `qwen --continue -p "Pick up where we left off"` |
|
||||
| `--resume [sessionId]` | Resume a specific session (or choose interactively) | `qwen --resume 123e... -p "Finish the refactor"` |
|
||||
|
||||
For complete details on all available configuration options, settings files, and environment variables, see the [Configuration Guide](../configuration/settings).
|
||||
|
||||
|
|
|
|||
|
|
@ -181,6 +181,29 @@ export SANDBOX_SET_UID_GID=false # Disable UID/GID mapping
|
|||
- Container sandbox: add them via `.qwen/sandbox.Dockerfile` or `.qwen/sandbox.bashrc`.
|
||||
- Seatbelt: your host binaries are used, but the sandbox may restrict access to some paths.
|
||||
|
||||
**Java not available in Docker sandbox**
|
||||
|
||||
The official Qwen Code Docker image is intentionally minimal to keep the image small, secure, and fast to pull. Different users require different language runtimes (Java, Python, Node.js, etc.), and bundling all environments into a single image is not practical. Therefore, Java is **not included by default** in the Docker sandbox.
|
||||
|
||||
If your workflow requires Java, you can extend the base image by creating a `.qwen/sandbox.Dockerfile` in your project:
|
||||
|
||||
```dockerfile
|
||||
FROM ghcr.io/qwenlm/qwen-code:latest
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y openjdk-17-jre && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
Then rebuild the sandbox image:
|
||||
|
||||
```bash
|
||||
QWEN_SANDBOX=docker BUILD_SANDBOX=1 qwen -s
|
||||
```
|
||||
|
||||
For more details on customizing the sandbox, see [Customizing the sandbox environment](/developers/tools/sandbox).
|
||||
|
||||
**Network issues**
|
||||
|
||||
- Check sandbox profile allows network.
|
||||
|
|
|
|||
|
|
@ -502,3 +502,10 @@ Always follow these standards:
|
|||
- **Access Control**: Project and user-level separation provides appropriate boundaries
|
||||
- **Sensitive Information**: Avoid including secrets or credentials in agent configurations
|
||||
- **Production Environments**: Consider separate agents for production vs development environments
|
||||
|
||||
## Limits
|
||||
|
||||
The following soft warnings apply to Subagent configurations (no hard limits are enforced):
|
||||
|
||||
- **Description Field**: A warning is shown for descriptions exceeding 1,000 characters
|
||||
- **System Prompt**: A warning is shown for system prompts exceeding 10,000 characters
|
||||
|
|
|
|||
|
|
@ -16,6 +16,30 @@
|
|||
|
||||
### Installation
|
||||
|
||||
#### Install from ACP Registry (Recommend)
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Open your JetBrains IDE and navigate to AI Chat tool window.
|
||||
|
||||
3. Click **Add ACP Agent**, then click **Install**.
|
||||
|
||||

|
||||
|
||||
For users using JetBrains AI Assistant and/or other ACP agents, click **Install From ACP Registry** in Agents List, then install Qwen Code ACP.
|
||||
|
||||

|
||||
|
||||
4. The Qwen Code agent should now be available in the AI Assistant panel.
|
||||
|
||||

|
||||
|
||||
#### Manual Install (for older version of JetBrains IDEs)
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -18,6 +18,24 @@
|
|||
|
||||
### Installation
|
||||
|
||||
#### Install from ACP Registry (Recommend)
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @qwen-code/qwen-code
|
||||
```
|
||||
|
||||
2. Download and install [Zed Editor](https://zed.dev/)
|
||||
|
||||
3. In Zed, click the **settings button** in the top right corner, select **"Add agent"**, choose **"Install from Registry"**, find **Qwen Code**, then click **Install**.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
#### Manual Install
|
||||
|
||||
1. Install Qwen Code CLI:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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`) |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@
|
|||
"maxSessionTurns": 50,
|
||||
"preferredEditor": "vscode",
|
||||
"sandbox": false,
|
||||
"summarizeToolOutput": true,
|
||||
"telemetry": {
|
||||
"enabled": false
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,7 +13,6 @@ import {
|
|||
isSDKAssistantMessage,
|
||||
isSDKResultMessage,
|
||||
type TextBlock,
|
||||
type ContentBlock,
|
||||
type SDKUserMessage,
|
||||
} from '@qwen-code/sdk';
|
||||
import {
|
||||
|
|
@ -149,7 +148,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
describe('Process Lifecycle Monitoring', () => {
|
||||
it('should handle normal process completion', async () => {
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
|
|
@ -158,18 +157,12 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
});
|
||||
|
||||
let completedSuccessfully = false;
|
||||
let receivedAssistantMessage = false;
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 100);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
receivedAssistantMessage = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,6 +173,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
} finally {
|
||||
await q.close();
|
||||
expect(completedSuccessfully).toBe(true);
|
||||
expect(receivedAssistantMessage).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -219,7 +213,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
describe('Input Stream Control', () => {
|
||||
it('should support endInput() method', async () => {
|
||||
const q = query({
|
||||
prompt: 'What is 2 + 2?',
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
|
|
@ -233,13 +227,6 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message) && !endInputCalled) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block: ContentBlock): block is TextBlock =>
|
||||
block.type === 'text',
|
||||
);
|
||||
const text = textBlocks.map((b: TextBlock) => b.text).join('');
|
||||
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
receivedResponse = true;
|
||||
|
||||
// End input after receiving first response
|
||||
|
|
@ -485,7 +472,7 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
const stderrMessages: string[] = [];
|
||||
|
||||
const q = query({
|
||||
prompt: 'Why do we choose to go to the moon?',
|
||||
prompt: 'Say hello',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
cwd: testDir,
|
||||
|
|
@ -497,17 +484,8 @@ describe('AbortController and Process Lifecycle (E2E)', () => {
|
|||
});
|
||||
|
||||
try {
|
||||
for await (const message of q) {
|
||||
if (isSDKAssistantMessage(message)) {
|
||||
const textBlocks = message.message.content.filter(
|
||||
(block): block is TextBlock => block.type === 'text',
|
||||
);
|
||||
const text = textBlocks
|
||||
.map((b) => b.text)
|
||||
.join('')
|
||||
.slice(0, 50);
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
}
|
||||
for await (const _message of q) {
|
||||
// Just consume all messages
|
||||
}
|
||||
} finally {
|
||||
await q.close();
|
||||
|
|
|
|||
870
integration-tests/sdk-typescript/message-event-pairing.test.ts
Normal file
870
integration-tests/sdk-typescript/message-event-pairing.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -154,10 +154,10 @@ describe('Multi-Turn Conversations (E2E)', () => {
|
|||
expect(messages.length).toBeGreaterThan(0);
|
||||
expect(assistantMessages.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Validate content of responses
|
||||
expect(assistantTexts[0]).toMatch(/2/);
|
||||
expect(assistantTexts[1]).toMatch(/4/);
|
||||
expect(assistantTexts[2]).toMatch(/6/);
|
||||
// Validate that we received text responses (may include thinking blocks)
|
||||
// At least some assistant messages should have non-empty text
|
||||
const nonEmptyTexts = assistantTexts.filter((t) => t.length > 0);
|
||||
expect(nonEmptyTexts.length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await q.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ describe('Permission Control (E2E)', () => {
|
|||
prompt: 'Write a js hello world to file.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
cwd: testDir,
|
||||
canUseTool: async (toolName, input) => {
|
||||
toolCalls.push({ toolName, input });
|
||||
|
|
@ -762,8 +763,15 @@ describe('Permission Control (E2E)', () => {
|
|||
it(
|
||||
'should execute read-only tools without confirmation',
|
||||
async () => {
|
||||
// Create a file so the model has something to read
|
||||
await helper.createFile(
|
||||
'read-only-test.txt',
|
||||
'content for read-only test',
|
||||
);
|
||||
|
||||
const q = query({
|
||||
prompt: 'List files in the current directory',
|
||||
prompt:
|
||||
'Use the read_file tool to read the file read-only-test.txt in the current directory.',
|
||||
options: {
|
||||
...SHARED_TEST_OPTIONS,
|
||||
permissionMode: 'default',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -18,7 +18,11 @@ export default defineConfig({
|
|||
globalSetup: './globalSetup.ts',
|
||||
reporters: ['default'],
|
||||
include: ['**/*.test.ts'],
|
||||
exclude: ['**/terminal-bench/*.test.ts', '**/node_modules/**'],
|
||||
exclude: [
|
||||
'**/terminal-bench/*.test.ts',
|
||||
'**/hook-integration/**',
|
||||
'**/node_modules/**',
|
||||
],
|
||||
retry: 2,
|
||||
fileParallelism: true,
|
||||
poolOptions: {
|
||||
|
|
|
|||
34
package-lock.json
generated
34
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
|
|
@ -17284,6 +17284,16 @@
|
|||
"tslib": "2"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-sitter-wasms": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/tree-sitter-wasms/-/tree-sitter-wasms-0.1.13.tgz",
|
||||
"integrity": "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ==",
|
||||
"dev": true,
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"tree-sitter-wasms": "^0.1.11"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz",
|
||||
|
|
@ -18171,6 +18181,12 @@
|
|||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/web-tree-sitter": {
|
||||
"version": "0.24.7",
|
||||
"resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.24.7.tgz",
|
||||
"integrity": "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
||||
|
|
@ -18784,7 +18800,7 @@
|
|||
},
|
||||
"packages/cli": {
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
"@google/genai": "1.30.0",
|
||||
|
|
@ -19441,7 +19457,7 @@
|
|||
},
|
||||
"packages/core": {
|
||||
"name": "@qwen-code/qwen-code-core",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.36.1",
|
||||
|
|
@ -19491,6 +19507,7 @@
|
|||
"tar": "^7.5.2",
|
||||
"undici": "^6.22.0",
|
||||
"uuid": "^9.0.1",
|
||||
"web-tree-sitter": "^0.24.7",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -19504,6 +19521,7 @@
|
|||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"msw": "^2.3.4",
|
||||
"tree-sitter-wasms": "^0.1.13",
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^3.1.1"
|
||||
},
|
||||
|
|
@ -22872,7 +22890,7 @@
|
|||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@qwen-code/qwen-code-test-utils",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
|
|
@ -22884,7 +22902,7 @@
|
|||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "qwen-code-vscode-ide-companion",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
|
|
@ -23132,7 +23150,7 @@
|
|||
},
|
||||
"packages/web-templates": {
|
||||
"name": "@qwen-code/web-templates",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
|
|
@ -23660,7 +23678,7 @@
|
|||
},
|
||||
"packages/webui": {
|
||||
"name": "@qwen-code/webui",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"markdown-it": "^14.1.0"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
|
|
@ -13,7 +13,7 @@
|
|||
"url": "git+https://github.com/QwenLM/qwen-code.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env node scripts/start.js",
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@qwen-code/qwen-code",
|
||||
"version": "0.12.3",
|
||||
"version": "0.13.0",
|
||||
"description": "Qwen Code",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.3"
|
||||
"sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.13.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.14.1",
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -13,12 +13,10 @@ const RESOURCE_NOT_FOUND_CODE = -32002;
|
|||
const INTERNAL_ERROR_CODE = -32603;
|
||||
|
||||
const createFallback = (): FileSystemService => ({
|
||||
readTextFile: vi
|
||||
.fn()
|
||||
.mockResolvedValue({
|
||||
content: '',
|
||||
_meta: { bom: false, encoding: 'utf-8' },
|
||||
}),
|
||||
readTextFile: vi.fn().mockResolvedValue({
|
||||
content: '',
|
||||
_meta: { bom: false, encoding: 'utf-8' },
|
||||
}),
|
||||
writeTextFile: vi.fn().mockResolvedValue({ _meta: undefined }),
|
||||
findFiles: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import type {
|
|||
ToolCallConfirmationDetails,
|
||||
ToolResult,
|
||||
ChatRecord,
|
||||
SubAgentEventEmitter,
|
||||
AgentEventEmitter,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
AuthType,
|
||||
|
|
@ -34,6 +34,7 @@ import {
|
|||
TodoWriteTool,
|
||||
ExitPlanModeTool,
|
||||
readManyFiles,
|
||||
ToolNames,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import { RequestError } from '@agentclientprotocol/sdk';
|
||||
|
|
@ -90,6 +91,14 @@ const debugLogger = createDebugLogger('SESSION');
|
|||
*/
|
||||
export class Session implements SessionContext {
|
||||
private pendingPrompt: AbortController | null = null;
|
||||
/**
|
||||
* Tracks the completion of the current prompt so that the next prompt
|
||||
* can await it. This prevents a new prompt from reading chat history
|
||||
* before the previous prompt's tool results have been added —
|
||||
* a race condition that causes malformed history on Windows where
|
||||
* process termination is slow.
|
||||
*/
|
||||
private pendingPromptCompletion: Promise<void> | null = null;
|
||||
private turn: number = 0;
|
||||
|
||||
// Modular components
|
||||
|
|
@ -143,10 +152,43 @@ export class Session implements SessionContext {
|
|||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
// Install this prompt's AbortController before awaiting the previous
|
||||
// prompt, so that a session/cancel during the wait targets us.
|
||||
this.pendingPrompt?.abort();
|
||||
const pendingSend = new AbortController();
|
||||
this.pendingPrompt = pendingSend;
|
||||
|
||||
// Wait for the previous prompt to finish so chat history is consistent.
|
||||
if (this.pendingPromptCompletion) {
|
||||
try {
|
||||
await this.pendingPromptCompletion;
|
||||
} catch {
|
||||
// Expected: previous prompt was cancelled or errored
|
||||
}
|
||||
}
|
||||
|
||||
// Cancelled while waiting for the previous prompt to finish.
|
||||
if (pendingSend.signal.aborted) {
|
||||
return { stopReason: 'cancelled' };
|
||||
}
|
||||
|
||||
// Track this prompt's completion for the next prompt to await
|
||||
let resolveCompletion!: () => void;
|
||||
this.pendingPromptCompletion = new Promise<void>((resolve) => {
|
||||
resolveCompletion = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.#executePrompt(params, pendingSend);
|
||||
} finally {
|
||||
resolveCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
async #executePrompt(
|
||||
params: PromptRequest,
|
||||
pendingSend: AbortController,
|
||||
): Promise<PromptResponse> {
|
||||
// Increment turn counter for each user prompt
|
||||
this.turn += 1;
|
||||
|
||||
|
|
@ -489,7 +531,7 @@ export class Session implements SessionContext {
|
|||
// Access eventEmitter from TaskTool invocation
|
||||
const taskEventEmitter = (
|
||||
invocation as {
|
||||
eventEmitter: SubAgentEventEmitter;
|
||||
eventEmitter: AgentEventEmitter;
|
||||
}
|
||||
).eventEmitter;
|
||||
|
||||
|
|
@ -498,7 +540,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,
|
||||
|
|
@ -506,24 +548,23 @@ export class Session implements SessionContext {
|
|||
);
|
||||
|
||||
// Set up sub-agent tool tracking
|
||||
subAgentCleanupFunctions = subAgentTracker.setup(
|
||||
subAgentCleanupFunctions = subSubAgentTracker.setup(
|
||||
taskEventEmitter,
|
||||
abortSignal,
|
||||
);
|
||||
}
|
||||
|
||||
const confirmationDetails =
|
||||
await invocation.shouldConfirmExecute(abortSignal);
|
||||
// Use the new permission flow: getDefaultPermission + getConfirmationDetails
|
||||
// ask_user_question must always go through confirmation even in YOLO mode
|
||||
// so the user always has a chance to respond to questions.
|
||||
const isAskUserQuestionTool = fc.name === ToolNames.ASK_USER_QUESTION;
|
||||
const defaultPermission =
|
||||
this.config.getApprovalMode() !== ApprovalMode.YOLO ||
|
||||
isAskUserQuestionTool
|
||||
? await invocation.getDefaultPermission()
|
||||
: 'allow';
|
||||
|
||||
// In YOLO mode, auto-approve everything except ask_user_question
|
||||
// (the user must always have a chance to respond to questions)
|
||||
const isAskUserQuestionTool =
|
||||
confirmationDetails && confirmationDetails.type === 'ask_user_question';
|
||||
const effectiveConfirmationDetails =
|
||||
this.config.getApprovalMode() === ApprovalMode.YOLO &&
|
||||
!isAskUserQuestionTool
|
||||
? false
|
||||
: confirmationDetails;
|
||||
const needsConfirmation = defaultPermission === 'ask';
|
||||
|
||||
// Check for plan mode enforcement - block non-read-only tools
|
||||
// but allow ask_user_question so users can answer clarification questions
|
||||
|
|
@ -532,7 +573,7 @@ export class Session implements SessionContext {
|
|||
isPlanMode &&
|
||||
!isExitPlanModeTool &&
|
||||
!isAskUserQuestionTool &&
|
||||
effectiveConfirmationDetails
|
||||
needsConfirmation
|
||||
) {
|
||||
// In plan mode, block any tool that requires confirmation (write operations)
|
||||
return errorResponse(
|
||||
|
|
@ -543,25 +584,35 @@ export class Session implements SessionContext {
|
|||
);
|
||||
}
|
||||
|
||||
if (effectiveConfirmationDetails) {
|
||||
if (defaultPermission === 'deny') {
|
||||
return errorResponse(
|
||||
new Error(
|
||||
`Tool "${fc.name}" is denied: command substitution is not allowed for security reasons.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (needsConfirmation) {
|
||||
const confirmationDetails =
|
||||
await invocation.getConfirmationDetails(abortSignal);
|
||||
const content: ToolCallContent[] = [];
|
||||
|
||||
if (effectiveConfirmationDetails.type === 'edit') {
|
||||
if (confirmationDetails.type === 'edit') {
|
||||
content.push({
|
||||
type: 'diff',
|
||||
path: effectiveConfirmationDetails.fileName,
|
||||
oldText: effectiveConfirmationDetails.originalContent,
|
||||
newText: effectiveConfirmationDetails.newContent,
|
||||
path: confirmationDetails.fileName,
|
||||
oldText: confirmationDetails.originalContent,
|
||||
newText: confirmationDetails.newContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Add plan content for exit_plan_mode
|
||||
if (effectiveConfirmationDetails.type === 'plan') {
|
||||
if (confirmationDetails.type === 'plan') {
|
||||
content.push({
|
||||
type: 'content',
|
||||
content: {
|
||||
type: 'text',
|
||||
text: effectiveConfirmationDetails.plan,
|
||||
text: confirmationDetails.plan,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -571,7 +622,7 @@ export class Session implements SessionContext {
|
|||
|
||||
const params: RequestPermissionRequest = {
|
||||
sessionId: this.sessionId,
|
||||
options: toPermissionOptions(effectiveConfirmationDetails),
|
||||
options: toPermissionOptions(confirmationDetails),
|
||||
toolCall: {
|
||||
toolCallId: callId,
|
||||
status: 'pending',
|
||||
|
|
@ -595,7 +646,7 @@ export class Session implements SessionContext {
|
|||
.nativeEnum(ToolConfirmationOutcome)
|
||||
.parse(output.outcome.optionId);
|
||||
|
||||
await effectiveConfirmationDetails.onConfirm(outcome, {
|
||||
await confirmationDetails.onConfirm(outcome, {
|
||||
answers: output.answers,
|
||||
});
|
||||
|
||||
|
|
@ -611,6 +662,8 @@ export class Session implements SessionContext {
|
|||
);
|
||||
case ToolConfirmationOutcome.ProceedOnce:
|
||||
case ToolConfirmationOutcome.ProceedAlways:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysProject:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysUser:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysServer:
|
||||
case ToolConfirmationOutcome.ProceedAlwaysTool:
|
||||
case ToolConfirmationOutcome.ModifyWithEditor:
|
||||
|
|
@ -1000,8 +1053,13 @@ function toPermissionOptions(
|
|||
case 'exec':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${confirmation.rootCommand}`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${confirmation.rootCommand}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${confirmation.rootCommand}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
|
|
@ -1009,13 +1067,13 @@ function toPermissionOptions(
|
|||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${confirmation.serverName}`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${confirmation.toolName}`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${confirmation.toolName}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
|
|
@ -1023,8 +1081,13 @@ function toPermissionOptions(
|
|||
case 'info':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow`,
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...basicPermissionOptions,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -330,6 +330,8 @@ export class SubAgentTracker {
|
|||
private toPermissionOptions(
|
||||
confirmation: ToolCallConfirmationDetails,
|
||||
): PermissionOption[] {
|
||||
const hideAlwaysAllow =
|
||||
'hideAlwaysAllow' in confirmation && confirmation.hideAlwaysAllow;
|
||||
switch (confirmation.type) {
|
||||
case 'edit':
|
||||
return [
|
||||
|
|
@ -342,34 +344,56 @@ export class SubAgentTracker {
|
|||
];
|
||||
case 'exec':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: `Always Allow ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...(hideAlwaysAllow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${(confirmation as { rootCommand?: string }).rootCommand ?? 'command'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
]),
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'mcp':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysServer,
|
||||
name: `Always Allow ${(confirmation as { serverName?: string }).serverName ?? 'server'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysTool,
|
||||
name: `Always Allow ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...(hideAlwaysAllow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: `Always Allow in project: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: `Always Allow for user: ${(confirmation as { toolName?: string }).toolName ?? 'tool'}`,
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
]),
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'info':
|
||||
return [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlways,
|
||||
name: 'Always Allow',
|
||||
kind: 'allow_always',
|
||||
},
|
||||
...(hideAlwaysAllow
|
||||
? []
|
||||
: [
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysProject,
|
||||
name: 'Always Allow in project',
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
{
|
||||
optionId: ToolConfirmationOutcome.ProceedAlwaysUser,
|
||||
name: 'Always Allow for user',
|
||||
kind: 'allow_always' as const,
|
||||
},
|
||||
]),
|
||||
...basicPermissionOptions,
|
||||
];
|
||||
case 'plan':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
packages/cli/src/commands/auth.ts
Normal file
77
packages/cli/src/commands/auth.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
500
packages/cli/src/commands/auth/handler.ts
Normal file
500
packages/cli/src/commands/auth/handler.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
421
packages/cli/src/commands/auth/interactiveSelector.test.ts
Normal file
421
packages/cli/src/commands/auth/interactiveSelector.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
166
packages/cli/src/commands/auth/interactiveSelector.ts
Normal file
166
packages/cli/src/commands/auth/interactiveSelector.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
266
packages/cli/src/commands/auth/status.test.ts
Normal file
266
packages/cli/src/commands/auth/status.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -241,6 +241,30 @@ describe('parseArguments', () => {
|
|||
expect(argv.prompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse --system-prompt', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--system-prompt',
|
||||
'You are a test system prompt.',
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.systemPrompt).toBe('You are a test system prompt.');
|
||||
expect(argv.appendSystemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should parse --append-system-prompt', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--append-system-prompt',
|
||||
'Be extra concise.',
|
||||
];
|
||||
const argv = await parseArguments();
|
||||
expect(argv.appendSystemPrompt).toBe('Be extra concise.');
|
||||
expect(argv.systemPrompt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow -r flag as alias for --resume', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
|
|
@ -432,6 +456,21 @@ describe('parseArguments', () => {
|
|||
mockExit.mockRestore();
|
||||
});
|
||||
|
||||
it('should allow --system-prompt and --append-system-prompt together', async () => {
|
||||
process.argv = [
|
||||
'node',
|
||||
'script.js',
|
||||
'--system-prompt',
|
||||
'Override prompt',
|
||||
'--append-system-prompt',
|
||||
'Append prompt',
|
||||
];
|
||||
|
||||
const argv = await parseArguments();
|
||||
expect(argv.systemPrompt).toBe('Override prompt');
|
||||
expect(argv.appendSystemPrompt).toBe('Append prompt');
|
||||
});
|
||||
|
||||
it('should throw an error when include-partial-messages is used without stream-json output', async () => {
|
||||
process.argv = ['node', 'script.js', '--include-partial-messages'];
|
||||
|
||||
|
|
@ -983,7 +1022,7 @@ describe('mergeExcludeTools', () => {
|
|||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toEqual([]);
|
||||
expect(config.getPermissionsDeny()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return default excludes when no excludeTools are specified and it is not interactive', async () => {
|
||||
|
|
@ -992,7 +1031,7 @@ describe('mergeExcludeTools', () => {
|
|||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toEqual(defaultExcludes);
|
||||
expect(config.getPermissionsDeny()).toEqual(defaultExcludes);
|
||||
});
|
||||
|
||||
it('should handle settings with excludeTools but no extensions', async () => {
|
||||
|
|
@ -1000,10 +1039,10 @@ describe('mergeExcludeTools', () => {
|
|||
const argv = await parseArguments();
|
||||
const settings: Settings = { tools: { exclude: ['tool1', 'tool2'] } };
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toEqual(
|
||||
expect(config.getPermissionsDeny()).toEqual(
|
||||
expect.arrayContaining(['tool1', 'tool2']),
|
||||
);
|
||||
expect(config.getExcludeTools()).toHaveLength(2);
|
||||
expect(config.getPermissionsDeny()).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1028,7 +1067,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1047,7 +1086,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
const settings: Settings = {};
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1067,7 +1106,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1084,7 +1123,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1101,7 +1140,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).toContain(EditTool.Name);
|
||||
expect(excludedTools).toContain(WriteFileTool.Name);
|
||||
|
|
@ -1121,7 +1160,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1141,7 +1180,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1154,7 +1193,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1179,7 +1218,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).not.toContain(ShellTool.Name);
|
||||
expect(excludedTools).not.toContain(EditTool.Name);
|
||||
expect(excludedTools).not.toContain(WriteFileTool.Name);
|
||||
|
|
@ -1199,7 +1238,7 @@ describe('Approval mode tool exclusion logic', () => {
|
|||
const settings: Settings = { tools: { exclude: ['custom_tool'] } };
|
||||
const config = await loadCliConfig(settings, argv, undefined, []);
|
||||
|
||||
const excludedTools = config.getExcludeTools();
|
||||
const excludedTools = config.getPermissionsDeny();
|
||||
expect(excludedTools).toContain('custom_tool'); // From settings
|
||||
expect(excludedTools).toContain(ShellTool.Name); // From approval mode
|
||||
expect(excludedTools).not.toContain(EditTool.Name); // Should be allowed in auto-edit
|
||||
|
|
@ -1795,9 +1834,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).not.toContain('replace');
|
||||
expect(config.getPermissionsDeny()).not.toContain('write_file');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in interactive mode with YOLO', async () => {
|
||||
|
|
@ -1805,9 +1844,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js', '--yolo'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).not.toContain('replace');
|
||||
expect(config.getPermissionsDeny()).not.toContain('write_file');
|
||||
});
|
||||
|
||||
it('should exclude interactive tools in non-interactive mode without YOLO', async () => {
|
||||
|
|
@ -1815,9 +1854,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js', '-p', 'test'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).toContain('edit');
|
||||
expect(config.getExcludeTools()).toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).toContain('edit');
|
||||
expect(config.getPermissionsDeny()).toContain('write_file');
|
||||
});
|
||||
|
||||
it('should not exclude interactive tools in non-interactive mode with YOLO', async () => {
|
||||
|
|
@ -1825,9 +1864,9 @@ describe('loadCliConfig tool exclusions', () => {
|
|||
process.argv = ['node', 'script.js', '-p', 'test', '--yolo'];
|
||||
const argv = await parseArguments();
|
||||
const config = await loadCliConfig({}, argv, undefined, []);
|
||||
expect(config.getExcludeTools()).not.toContain('run_shell_command');
|
||||
expect(config.getExcludeTools()).not.toContain('replace');
|
||||
expect(config.getExcludeTools()).not.toContain('write_file');
|
||||
expect(config.getPermissionsDeny()).not.toContain('run_shell_command');
|
||||
expect(config.getPermissionsDeny()).not.toContain('replace');
|
||||
expect(config.getPermissionsDeny()).not.toContain('write_file');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
Config,
|
||||
DEFAULT_QWEN_EMBEDDING_MODEL,
|
||||
FileDiscoveryService,
|
||||
FileEncoding,
|
||||
getAllGeminiMdFilenames,
|
||||
loadServerHierarchicalMemory,
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
|
|
@ -19,7 +18,6 @@ import {
|
|||
Storage,
|
||||
InputFormat,
|
||||
OutputFormat,
|
||||
isToolEnabled,
|
||||
SessionService,
|
||||
ideContextStore,
|
||||
type ResumedSessionData,
|
||||
|
|
@ -31,10 +29,13 @@ import {
|
|||
NativeLspClient,
|
||||
createDebugLogger,
|
||||
NativeLspService,
|
||||
isToolEnabled,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { extensionsCommand } from '../commands/extensions.js';
|
||||
import { hooksCommand } from '../commands/hooks.js';
|
||||
import type { Settings } from './settings.js';
|
||||
import type { Settings, LoadedSettings } from './settings.js';
|
||||
import { SettingScope } from './settings.js';
|
||||
import { authCommand } from '../commands/auth.js';
|
||||
import {
|
||||
resolveCliGenerationConfig,
|
||||
getAuthTypeFromEnv,
|
||||
|
|
@ -52,16 +53,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';
|
||||
|
|
@ -111,6 +112,8 @@ export interface CliArgs {
|
|||
debug: boolean | undefined;
|
||||
prompt: string | undefined;
|
||||
promptInteractive: string | undefined;
|
||||
systemPrompt: string | undefined;
|
||||
appendSystemPrompt: string | undefined;
|
||||
yolo: boolean | undefined;
|
||||
approvalMode: string | undefined;
|
||||
telemetry: boolean | undefined;
|
||||
|
|
@ -290,6 +293,16 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
description:
|
||||
'Execute the provided prompt and continue in interactive mode',
|
||||
})
|
||||
.option('system-prompt', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Override the main session system prompt for this run. Can be combined with --append-system-prompt.',
|
||||
})
|
||||
.option('append-system-prompt', {
|
||||
type: 'string',
|
||||
description:
|
||||
'Append instructions to the main session system prompt for this run. Can be combined with --system-prompt.',
|
||||
})
|
||||
.option('sandbox', {
|
||||
alias: 's',
|
||||
type: 'boolean',
|
||||
|
|
@ -386,6 +399,7 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
description: 'List all available extensions and exit.',
|
||||
})
|
||||
.option('include-directories', {
|
||||
alias: 'add-dir',
|
||||
type: 'array',
|
||||
string: true,
|
||||
description:
|
||||
|
|
@ -557,10 +571,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;
|
||||
|
|
@ -570,6 +587,8 @@ export async function parseArguments(): Promise<CliArgs> {
|
|||
.command(mcpCommand)
|
||||
// Register Extension subcommands
|
||||
.command(extensionsCommand)
|
||||
// Register Auth subcommands
|
||||
.command(authCommand)
|
||||
// Register Hooks subcommands
|
||||
.command(hooksCommand);
|
||||
|
||||
|
|
@ -685,6 +704,7 @@ export async function loadCliConfig(
|
|||
argv: CliArgs,
|
||||
cwd: string = process.cwd(),
|
||||
overrideExtensions?: string[],
|
||||
loadedSettings?: LoadedSettings,
|
||||
): Promise<Config> {
|
||||
const debugMode = isDebugMode(argv);
|
||||
|
||||
|
|
@ -814,64 +834,106 @@ export async function loadCliConfig(
|
|||
// (fallback for edge cases where query/prompt is provided with TEXT output)
|
||||
interactive = false;
|
||||
}
|
||||
// In non-interactive mode, exclude tools that require a prompt.
|
||||
// However, if stream-json input is used, control can be requested via JSON messages,
|
||||
// so tools should not be excluded in that case.
|
||||
const extraExcludes: string[] = [];
|
||||
const resolvedCoreTools = argv.coreTools || settings.tools?.core || [];
|
||||
const resolvedAllowedTools =
|
||||
argv.allowedTools || settings.tools?.allowed || [];
|
||||
const isExplicitlyEnabled = (toolName: ToolName): boolean => {
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedCoreTools, [])) {
|
||||
return true;
|
||||
}
|
||||
// ── Unified permissions construction ─────────────────────────────────────
|
||||
// All permission sources are merged here, before constructing Config.
|
||||
// The resulting three arrays are the single source of truth that Config /
|
||||
// PermissionManager will use.
|
||||
//
|
||||
// Sources (in order of precedence within each list):
|
||||
// 1. settings.permissions.{allow,ask,deny} (persistent, merged by LoadedSettings)
|
||||
// 2. argv.coreTools → allow (allowlist mode: only these tools are available)
|
||||
// 3. argv.allowedTools → allow (auto-approve these tools/commands)
|
||||
// 4. argv.excludeTools → deny (block these tools completely)
|
||||
// 5. Non-interactive mode exclusions → deny (unless explicitly allowed above)
|
||||
|
||||
// Start from settings-level rules.
|
||||
// Read from both new `permissions` and legacy `tools` paths for compatibility.
|
||||
// Note: settings.tools.core / argv.coreTools are intentionally NOT merged into
|
||||
// mergedAllow — they have whitelist semantics (only listed tools are registered),
|
||||
// not auto-approve semantics. They are passed via the `coreTools` Config param
|
||||
// and handled by PermissionManager.coreToolsAllowList.
|
||||
const resolvedCoreTools: string[] = [
|
||||
...(argv.coreTools ?? []),
|
||||
...(settings.tools?.core ?? []),
|
||||
];
|
||||
const mergedAllow: string[] = [
|
||||
...(settings.permissions?.allow ?? []),
|
||||
...(settings.tools?.allowed ?? []),
|
||||
];
|
||||
const mergedAsk: string[] = [...(settings.permissions?.ask ?? [])];
|
||||
const mergedDeny: string[] = [
|
||||
...(settings.permissions?.deny ?? []),
|
||||
...(settings.tools?.exclude ?? []),
|
||||
];
|
||||
|
||||
// argv.allowedTools adds allow rules (auto-approve).
|
||||
for (const t of argv.allowedTools ?? []) {
|
||||
if (t && !mergedAllow.includes(t)) mergedAllow.push(t);
|
||||
}
|
||||
|
||||
// argv.excludeTools adds deny rules.
|
||||
for (const t of argv.excludeTools ?? []) {
|
||||
if (t && !mergedDeny.includes(t)) mergedDeny.push(t);
|
||||
}
|
||||
|
||||
// Helper: check if a tool is explicitly covered by an allow rule OR by the
|
||||
// coreTools whitelist. Uses alias matching for coreTools (via isToolEnabled)
|
||||
// to preserve the original behaviour where "ShellTool", "Shell", and
|
||||
// "run_shell_command" are all accepted as the same tool.
|
||||
const isExplicitlyAllowed = (toolName: ToolName): boolean => {
|
||||
const name = toolName as string;
|
||||
// 1. Check permissions.allow / allowedTools rules.
|
||||
if (
|
||||
mergedAllow.some((rule) => {
|
||||
const openParen = rule.indexOf('(');
|
||||
const ruleName =
|
||||
openParen === -1 ? rule.trim() : rule.substring(0, openParen).trim();
|
||||
return ruleName === name;
|
||||
})
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (resolvedAllowedTools.length > 0) {
|
||||
if (isToolEnabled(toolName, resolvedAllowedTools, [])) {
|
||||
return true;
|
||||
}
|
||||
// 2. Check coreTools whitelist (with alias matching).
|
||||
// If coreTools is non-empty and explicitly includes this tool, it is
|
||||
// considered allowed for non-interactive mode exclusion purposes.
|
||||
if (resolvedCoreTools.length > 0) {
|
||||
return isToolEnabled(toolName, resolvedCoreTools, []);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const excludeUnlessExplicit = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyEnabled(toolName)) {
|
||||
extraExcludes.push(toolName);
|
||||
}
|
||||
};
|
||||
|
||||
// ACP mode check: must include both --acp (current) and --experimental-acp (deprecated).
|
||||
// Without this check, edit, write_file, run_shell_command would be excluded in ACP mode.
|
||||
// In non-interactive mode, tools that require a user prompt are denied unless
|
||||
// the caller has explicitly allowed them. Stream-JSON input is excluded from
|
||||
// this logic because approval can be sent programmatically via JSON messages.
|
||||
const isAcpMode = argv.acp || argv.experimentalAcp;
|
||||
if (!interactive && !isAcpMode && inputFormat !== InputFormat.STREAM_JSON) {
|
||||
const denyUnlessAllowed = (toolName: ToolName): void => {
|
||||
if (!isExplicitlyAllowed(toolName)) {
|
||||
const name = toolName as string;
|
||||
if (!mergedDeny.includes(name)) mergedDeny.push(name);
|
||||
}
|
||||
};
|
||||
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.PLAN:
|
||||
case ApprovalMode.DEFAULT:
|
||||
// In default non-interactive mode, all tools that require approval are excluded,
|
||||
// unless explicitly enabled via coreTools/allowedTools.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
excludeUnlessExplicit(EditTool.Name as ToolName);
|
||||
excludeUnlessExplicit(WriteFileTool.Name as ToolName);
|
||||
// Deny all write/execute tools unless explicitly allowed.
|
||||
denyUnlessAllowed(ShellTool.Name as ToolName);
|
||||
denyUnlessAllowed(EditTool.Name as ToolName);
|
||||
denyUnlessAllowed(WriteFileTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
// In auto-edit non-interactive mode, only tools that still require a prompt are excluded.
|
||||
excludeUnlessExplicit(ShellTool.Name as ToolName);
|
||||
// Only shell requires a prompt in auto-edit mode.
|
||||
denyUnlessAllowed(ShellTool.Name as ToolName);
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
// No extra excludes for YOLO mode.
|
||||
// No extra denials for YOLO mode.
|
||||
break;
|
||||
default:
|
||||
// This should never happen due to validation earlier, but satisfies the linter
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const excludeTools = mergeExcludeTools(
|
||||
settings,
|
||||
extraExcludes.length > 0 ? extraExcludes : undefined,
|
||||
argv.excludeTools,
|
||||
);
|
||||
|
||||
let allowedMcpServers: Set<string> | undefined;
|
||||
let excludedMcpServers: Set<string> | undefined;
|
||||
if (argv.allowedMcpServerNames) {
|
||||
|
|
@ -962,9 +1024,33 @@ export async function loadCliConfig(
|
|||
importFormat: settings.context?.importFormat || 'tree',
|
||||
debugMode,
|
||||
question,
|
||||
systemPrompt: argv.systemPrompt,
|
||||
appendSystemPrompt: argv.appendSystemPrompt,
|
||||
// Legacy fields – kept for backward compatibility with getCoreTools() etc.
|
||||
coreTools: argv.coreTools || settings.tools?.core || undefined,
|
||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||
excludeTools,
|
||||
excludeTools: mergedDeny,
|
||||
// New unified permissions (PermissionManager source of truth).
|
||||
permissions: {
|
||||
allow: mergedAllow.length > 0 ? mergedAllow : undefined,
|
||||
ask: mergedAsk.length > 0 ? mergedAsk : undefined,
|
||||
deny: mergedDeny.length > 0 ? mergedDeny : undefined,
|
||||
},
|
||||
// Permission rule persistence callback (writes to settings files).
|
||||
onPersistPermissionRule: loadedSettings
|
||||
? async (scope, ruleType, rule) => {
|
||||
const settingScope =
|
||||
scope === 'project' ? SettingScope.Workspace : SettingScope.User;
|
||||
const key = `permissions.${ruleType}`;
|
||||
const currentRules: string[] =
|
||||
loadedSettings.forScope(settingScope).settings.permissions?.[
|
||||
ruleType
|
||||
] ?? [];
|
||||
if (!currentRules.includes(rule)) {
|
||||
loadedSettings.setValue(settingScope, key, [...currentRules, rule]);
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||
toolCallCommand: settings.tools?.callCommand,
|
||||
mcpServerCommand: settings.mcp?.serverCommand,
|
||||
|
|
@ -1013,7 +1099,6 @@ export async function loadCliConfig(
|
|||
warnings: resolvedCliConfig.warnings,
|
||||
cliVersion: await getCliVersion(),
|
||||
webSearch: buildWebSearchConfig(argv, settings, selectedAuthType),
|
||||
summarizeToolOutput: settings.model?.summarizeToolOutput,
|
||||
ideMode,
|
||||
chatCompression: settings.model?.chatCompression,
|
||||
folderTrust,
|
||||
|
|
@ -1027,7 +1112,6 @@ export async function loadCliConfig(
|
|||
skipStartupContext: settings.model?.skipStartupContext ?? false,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: appEvents,
|
||||
gitCoAuthor: settings.general?.gitCoAuthor,
|
||||
output: {
|
||||
|
|
@ -1043,11 +1127,22 @@ export async function loadCliConfig(
|
|||
// always be true and the settings file can never disable recording.
|
||||
chatRecording:
|
||||
argv.chatRecording ?? settings.general?.chatRecording ?? true,
|
||||
defaultFileEncoding:
|
||||
settings.general?.defaultFileEncoding ?? FileEncoding.UTF8,
|
||||
defaultFileEncoding: settings.general?.defaultFileEncoding,
|
||||
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) {
|
||||
|
|
@ -1074,16 +1169,3 @@ export async function loadCliConfig(
|
|||
|
||||
return config;
|
||||
}
|
||||
|
||||
function mergeExcludeTools(
|
||||
settings: Settings,
|
||||
extraExcludes?: string[] | undefined,
|
||||
cliExcludeTools?: string[] | undefined,
|
||||
): string[] {
|
||||
const allExcludeTools = new Set([
|
||||
...(cliExcludeTools || []),
|
||||
...(settings.tools?.exclude || []),
|
||||
...(extraExcludes || []),
|
||||
]);
|
||||
return [...allExcludeTools];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ export const V1_TO_V2_MIGRATION_MAP: Record<string, string> = {
|
|||
shellPager: 'tools.shell.pager',
|
||||
shellShowColor: 'tools.shell.showColor',
|
||||
skipNextSpeakerCheck: 'model.skipNextSpeakerCheck',
|
||||
summarizeToolOutput: 'model.summarizeToolOutput',
|
||||
telemetry: 'telemetry',
|
||||
theme: 'ui.theme',
|
||||
toolDiscoveryCommand: 'tools.discoveryCommand',
|
||||
|
|
@ -157,7 +156,6 @@ export const V1_INDICATOR_KEYS = [
|
|||
'shellPager',
|
||||
'shellShowColor',
|
||||
'skipNextSpeakerCheck',
|
||||
'summarizeToolOutput',
|
||||
'toolDiscoveryCommand',
|
||||
'toolCallCommand',
|
||||
'usageStatisticsEnabled',
|
||||
|
|
|
|||
|
|
@ -67,6 +67,74 @@ export const DEFAULT_EXCLUDED_ENV_VARS = ['DEBUG', 'DEBUG_MODE'];
|
|||
export const SETTINGS_VERSION = 3;
|
||||
export const SETTINGS_VERSION_KEY = '$version';
|
||||
|
||||
/**
|
||||
* Migrate legacy tool permission settings (tools.core / tools.allowed / tools.exclude)
|
||||
* to the new permissions.allow / permissions.ask / permissions.deny format.
|
||||
*
|
||||
* Conversion rules:
|
||||
* tools.allowed → permissions.allow (bypass confirmation)
|
||||
* tools.exclude → permissions.deny (block tools)
|
||||
* tools.core → permissions.allow (only listed tools enabled)
|
||||
* + permissions.deny with a wildcard deny-all if needed
|
||||
*
|
||||
* Returns the updated settings object, or null if no migration is needed.
|
||||
*/
|
||||
export function migrateLegacyPermissions(
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const tools = settings['tools'] as Record<string, unknown> | undefined;
|
||||
if (!tools) return null;
|
||||
|
||||
const hasLegacy =
|
||||
Array.isArray(tools['core']) ||
|
||||
Array.isArray(tools['allowed']) ||
|
||||
Array.isArray(tools['exclude']);
|
||||
|
||||
if (!hasLegacy) return null;
|
||||
|
||||
const result = structuredClone(settings) as Record<string, unknown>;
|
||||
const resultTools = result['tools'] as Record<string, unknown>;
|
||||
const permissions = (result['permissions'] as Record<string, unknown>) ?? {};
|
||||
result['permissions'] = permissions;
|
||||
|
||||
const mergeInto = (key: string, items: string[]) => {
|
||||
const existing = Array.isArray(permissions[key])
|
||||
? (permissions[key] as string[])
|
||||
: [];
|
||||
const merged = Array.from(new Set([...existing, ...items]));
|
||||
permissions[key] = merged;
|
||||
};
|
||||
|
||||
// tools.allowed → permissions.allow
|
||||
if (Array.isArray(resultTools['allowed'])) {
|
||||
mergeInto('allow', resultTools['allowed'] as string[]);
|
||||
delete resultTools['allowed'];
|
||||
}
|
||||
|
||||
// tools.exclude → permissions.deny
|
||||
if (Array.isArray(resultTools['exclude'])) {
|
||||
mergeInto('deny', resultTools['exclude'] as string[]);
|
||||
delete resultTools['exclude'];
|
||||
}
|
||||
|
||||
// tools.core → permissions.allow (explicit enables)
|
||||
// IMPORTANT: tools.core has whitelist semantics: "only these tools can run".
|
||||
// To preserve this, we also add deny rules for all tools NOT in the list.
|
||||
// A wildcard deny-all followed by specific allows achieves this because
|
||||
// allow rules take precedence over the catch-all deny in the evaluation order:
|
||||
// deny = [everything not listed], allow = [listed tools]
|
||||
// However, since our priority is deny > allow, we cannot use a blanket deny.
|
||||
// Instead we just migrate to allow (auto-approve) and let the coreTools
|
||||
// semantics continue to work through the Config.getCoreTools() path until
|
||||
// the old API is fully removed.
|
||||
if (Array.isArray(resultTools['core'])) {
|
||||
mergeInto('allow', resultTools['core'] as string[]);
|
||||
delete resultTools['core'];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getSystemSettingsPath(): string {
|
||||
if (process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH']) {
|
||||
return process.env['QWEN_CODE_SYSTEM_SETTINGS_PATH'];
|
||||
|
|
@ -103,10 +171,6 @@ export interface CheckpointingSettings {
|
|||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SummarizeToolOutputSettings {
|
||||
tokenBudget?: number;
|
||||
}
|
||||
|
||||
export interface AccessibilitySettings {
|
||||
enableLoadingPhrases?: boolean;
|
||||
screenReader?: boolean;
|
||||
|
|
|
|||
|
|
@ -181,9 +181,7 @@ describe('SettingsSchema', () => {
|
|||
expect(getSettingsSchema().security.properties.auth.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getSettingsSchema().tools.properties.core.showInDialog).toBe(
|
||||
false,
|
||||
);
|
||||
expect(getSettingsSchema().permissions.showInDialog).toBe(false);
|
||||
expect(getSettingsSchema().mcpServers.showInDialog).toBe(false);
|
||||
expect(getSettingsSchema().telemetry.showInDialog).toBe(false);
|
||||
|
||||
|
|
|
|||
|
|
@ -76,12 +76,98 @@ export interface SettingDefinition {
|
|||
mergeStrategy?: MergeStrategy;
|
||||
/** Enum type options */
|
||||
options?: readonly SettingEnumOption[];
|
||||
/** Schema for array items when type is 'array' */
|
||||
items?: SettingItemDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema definition for array item types.
|
||||
* Supports simple types (string, number, boolean) and complex object types.
|
||||
*/
|
||||
export interface SettingItemDefinition {
|
||||
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||
properties?: Record<
|
||||
string,
|
||||
SettingItemDefinition & {
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
additionalProperties?: SettingItemDefinition;
|
||||
}
|
||||
>;
|
||||
items?: SettingItemDefinition;
|
||||
required?: boolean;
|
||||
enum?: string[];
|
||||
description?: string;
|
||||
additionalProperties?: boolean | SettingItemDefinition;
|
||||
}
|
||||
|
||||
export interface SettingsSchema {
|
||||
[key: string]: SettingDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common items schema for hook definitions.
|
||||
* Used by both UserPromptSubmit and Stop hooks.
|
||||
*/
|
||||
const HOOK_DEFINITION_ITEMS: SettingItemDefinition = {
|
||||
type: 'object',
|
||||
description:
|
||||
'A hook definition with an optional matcher and a list of hook configurations.',
|
||||
properties: {
|
||||
matcher: {
|
||||
type: 'string',
|
||||
description:
|
||||
'An optional matcher pattern to filter when this hook definition applies.',
|
||||
},
|
||||
sequential: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'Whether the hooks should be executed sequentially instead of in parallel.',
|
||||
},
|
||||
hooks: {
|
||||
type: 'array',
|
||||
description: 'The list of hook configurations to execute.',
|
||||
required: true,
|
||||
items: {
|
||||
type: 'object',
|
||||
description:
|
||||
'A hook configuration entry that defines a command to execute.',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
description: 'The type of hook.',
|
||||
enum: ['command'],
|
||||
required: true,
|
||||
},
|
||||
command: {
|
||||
type: 'string',
|
||||
description: 'The command to execute when the hook is triggered.',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'An optional name for the hook.',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
description: 'An optional description of what the hook does.',
|
||||
},
|
||||
timeout: {
|
||||
type: 'number',
|
||||
description: 'Timeout in milliseconds for the hook execution.',
|
||||
},
|
||||
env: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Environment variables to set when executing the hook command.',
|
||||
additionalProperties: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type MemoryImportFormat = 'tree' | 'flat';
|
||||
export type DnsResolutionOrder = 'ipv4first' | 'verbatim';
|
||||
|
||||
|
|
@ -546,17 +632,6 @@ const SETTINGS_SCHEMA = {
|
|||
'Maximum number of user/model/tool turns to keep in a session. -1 means unlimited.',
|
||||
showInDialog: false,
|
||||
},
|
||||
summarizeToolOutput: {
|
||||
type: 'object',
|
||||
label: 'Summarize Tool Output',
|
||||
category: 'Model',
|
||||
requiresRestart: false,
|
||||
default: undefined as
|
||||
| Record<string, { tokenBudget?: number }>
|
||||
| undefined,
|
||||
description: 'Settings for summarizing tool output.',
|
||||
showInDialog: false,
|
||||
},
|
||||
chatCompression: {
|
||||
type: 'object',
|
||||
label: 'Chat Compression',
|
||||
|
|
@ -789,6 +864,55 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
|
||||
permissions: {
|
||||
type: 'object',
|
||||
label: 'Permissions',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: {},
|
||||
description:
|
||||
'Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
allow: {
|
||||
type: 'array',
|
||||
label: 'Allow Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that are auto-approved without confirmation. ' +
|
||||
'Examples: "ShellTool", "Bash(git *)", "ReadFileTool".',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
ask: {
|
||||
type: 'array',
|
||||
label: 'Ask Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that always require user confirmation. ' +
|
||||
'Takes precedence over allow rules.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
deny: {
|
||||
type: 'array',
|
||||
label: 'Deny Rules',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'Tools or commands that are always blocked. Highest priority rule. ' +
|
||||
'Examples: "ShellTool", "Bash(rm -rf *)".',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
tools: {
|
||||
type: 'object',
|
||||
label: 'Tools',
|
||||
|
|
@ -848,32 +972,33 @@ const SETTINGS_SCHEMA = {
|
|||
},
|
||||
},
|
||||
},
|
||||
// Legacy tool permission fields – kept for backward compatibility.
|
||||
// Use permissions.{allow,ask,deny} instead.
|
||||
core: {
|
||||
type: 'array',
|
||||
label: 'Core Tools',
|
||||
label: 'Core Tools (deprecated)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Paths to core tool definitions.',
|
||||
description: 'Deprecated. Use permissions.allow instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
allowed: {
|
||||
type: 'array',
|
||||
label: 'Allowed Tools',
|
||||
label: 'Allowed Tools (deprecated)',
|
||||
category: 'Advanced',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description:
|
||||
'A list of tool names that will bypass the confirmation dialog.',
|
||||
description: 'Deprecated. Use permissions.allow instead.',
|
||||
showInDialog: false,
|
||||
},
|
||||
exclude: {
|
||||
type: 'array',
|
||||
label: 'Exclude Tools',
|
||||
label: 'Exclude Tools (deprecated)',
|
||||
category: 'Tools',
|
||||
requiresRestart: true,
|
||||
default: undefined as string[] | undefined,
|
||||
description: 'Tool names to exclude from discovery.',
|
||||
description: 'Deprecated. Use permissions.deny instead.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.UNION,
|
||||
},
|
||||
|
|
@ -941,15 +1066,6 @@ const SETTINGS_SCHEMA = {
|
|||
'Use the bundled ripgrep binary. When set to false, the system-level "rg" command will be used instead. This setting is only effective when useRipgrep is true.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Truncation',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: false,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Threshold',
|
||||
|
|
@ -1178,6 +1294,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',
|
||||
|
|
@ -1233,6 +1447,7 @@ const SETTINGS_SCHEMA = {
|
|||
'Hooks that execute before agent processing. Can modify prompts or inject context.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
Stop: {
|
||||
type: 'array',
|
||||
|
|
@ -1244,9 +1459,124 @@ const SETTINGS_SCHEMA = {
|
|||
'Hooks that execute after agent processing. Can post-process responses or log interactions.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
items: HOOK_DEFINITION_ITEMS,
|
||||
},
|
||||
Notification: {
|
||||
type: 'array',
|
||||
label: 'Notification Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when notifications are sent.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PreToolUse: {
|
||||
type: 'array',
|
||||
label: 'Pre Tool Use Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute before tool execution.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PostToolUse: {
|
||||
type: 'array',
|
||||
label: 'Post Tool Use Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute after successful tool execution.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PostToolUseFailure: {
|
||||
type: 'array',
|
||||
label: 'Post Tool Use Failure Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when tool execution fails. ',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SessionStart: {
|
||||
type: 'array',
|
||||
label: 'Session Start Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when a new session starts or resumes.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SessionEnd: {
|
||||
type: 'array',
|
||||
label: 'Session End Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute when a session ends.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PreCompact: {
|
||||
type: 'array',
|
||||
label: 'Pre Compact Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description: 'Hooks that execute before conversation compaction.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SubagentStart: {
|
||||
type: 'array',
|
||||
label: 'Subagent Start Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute when a subagent (Task tool call) is started.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
SubagentStop: {
|
||||
type: 'array',
|
||||
label: 'Subagent Stop Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute right before a subagent (Task tool call) concludes its response.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
PermissionRequest: {
|
||||
type: 'array',
|
||||
label: 'Permission Request Hooks',
|
||||
category: 'Advanced',
|
||||
requiresRestart: false,
|
||||
default: [],
|
||||
description:
|
||||
'Hooks that execute when a permission dialog is displayed.',
|
||||
showInDialog: false,
|
||||
mergeStrategy: MergeStrategy.CONCAT,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -467,6 +467,8 @@ describe('gemini.tsx main function kitty protocol', () => {
|
|||
debug: undefined,
|
||||
prompt: undefined,
|
||||
promptInteractive: undefined,
|
||||
systemPrompt: undefined,
|
||||
appendSystemPrompt: undefined,
|
||||
query: undefined,
|
||||
yolo: undefined,
|
||||
approvalMode: undefined,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -348,6 +351,7 @@ export async function main() {
|
|||
argv,
|
||||
process.cwd(),
|
||||
argv.extensions,
|
||||
settings,
|
||||
);
|
||||
|
||||
// Register cleanup for MCP clients as early as possible
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ export default {
|
|||
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
|
||||
'List available skills.': 'Verfügbare Skills auflisten.',
|
||||
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
|
||||
'No tools available': 'Keine Werkzeuge verfügbar',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -376,6 +377,7 @@ export default {
|
|||
'Diese Editoren werden derzeit unterstützt. Bitte beachten Sie, dass einige Editoren nicht im Sandbox-Modus verwendet werden können.',
|
||||
'Your preferred editor is:': 'Ihr bevorzugter Editor ist:',
|
||||
'Manage extensions': 'Erweiterungen verwalten',
|
||||
'Manage installed extensions': 'Installierte Erweiterungen verwalten',
|
||||
'List active extensions': 'Aktive Erweiterungen auflisten',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Erweiterungen aktualisieren. Verwendung: update <Erweiterungsnamen>|--all',
|
||||
|
|
@ -585,6 +587,38 @@ export default {
|
|||
'Fehler beim Konfigurieren von {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Ihr Terminal ist bereits für optimale Erfahrung mit mehrzeiliger Eingabe konfiguriert (Umschalt+Enter und Strg+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Qwen Code-Hooks verwalten',
|
||||
'List all configured hooks': 'Alle konfigurierten Hooks auflisten',
|
||||
'Enable a disabled hook': 'Einen deaktivierten Hook aktivieren',
|
||||
'Disable an active hook': 'Einen aktiven Hook deaktivieren',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Den Nachrichtenverlauf der aktuellen Sitzung in eine Datei exportieren',
|
||||
'Export session to HTML format': 'Sitzung in das HTML-Format exportieren',
|
||||
'Export session to JSON format': 'Sitzung in das JSON-Format exportieren',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Sitzung in das JSONL-Format exportieren (eine Nachricht pro Zeile)',
|
||||
'Export session to markdown format':
|
||||
'Sitzung in das Markdown-Format exportieren',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'Personalisierte Programmier-Einblicke aus Ihrem Chatverlauf generieren',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Eine vorherige Sitzung fortsetzen',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Einen Tool-Aufruf wiederherstellen. Dadurch werden Konversations- und Dateiverlauf auf den Zustand zurückgesetzt, in dem der Tool-Aufruf vorgeschlagen wurde',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Terminal-Typ konnte nicht erkannt werden. Unterstützte Terminals: VS Code, Cursor, Windsurf und Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -1012,6 +1046,8 @@ export default {
|
|||
"Allow execution of: '{{command}}'?":
|
||||
"Ausführung erlauben von: '{{command}}'?",
|
||||
'Yes, allow always ...': 'Ja, immer erlauben ...',
|
||||
'Always allow in this project': 'In diesem Projekt immer erlauben',
|
||||
'Always allow for this user': 'Für diesen Benutzer immer erlauben',
|
||||
'Yes, and auto-accept edits': 'Ja, und Änderungen automatisch akzeptieren',
|
||||
'Yes, and manually approve edits': 'Ja, und Änderungen manuell genehmigen',
|
||||
'No, keep planning (esc)': 'Nein, weiter planen (Esc)',
|
||||
|
|
@ -1180,6 +1216,75 @@ export default {
|
|||
// Dialogs - Permissions
|
||||
// ============================================================================
|
||||
'Manage folder trust settings': 'Ordnervertrauenseinstellungen verwalten',
|
||||
'Manage permission rules': 'Berechtigungsregeln verwalten',
|
||||
Allow: 'Erlauben',
|
||||
Ask: 'Fragen',
|
||||
Deny: 'Verweigern',
|
||||
Workspace: 'Arbeitsbereich',
|
||||
"Qwen Code won't ask before using allowed tools.":
|
||||
'Qwen Code fragt nicht, bevor erlaubte Tools verwendet werden.',
|
||||
'Qwen Code will ask before using these tools.':
|
||||
'Qwen Code fragt, bevor diese Tools verwendet werden.',
|
||||
'Qwen Code is not allowed to use denied tools.':
|
||||
'Qwen Code darf verweigerte Tools nicht verwenden.',
|
||||
'Manage trusted directories for this workspace.':
|
||||
'Vertrauenswürdige Verzeichnisse für diesen Arbeitsbereich verwalten.',
|
||||
'Any use of the {{tool}} tool': 'Jede Verwendung des {{tool}}-Tools',
|
||||
"{{tool}} commands matching '{{pattern}}'":
|
||||
"{{tool}}-Befehle, die '{{pattern}}' entsprechen",
|
||||
'From user settings': 'Aus Benutzereinstellungen',
|
||||
'From project settings': 'Aus Projekteinstellungen',
|
||||
'From session': 'Aus Sitzung',
|
||||
'Project settings (local)': 'Projekteinstellungen (lokal)',
|
||||
'Saved in .qwen/settings.local.json':
|
||||
'Gespeichert in .qwen/settings.local.json',
|
||||
'Project settings': 'Projekteinstellungen',
|
||||
'Checked in at .qwen/settings.json': 'Eingecheckt in .qwen/settings.json',
|
||||
'User settings': 'Benutzereinstellungen',
|
||||
'Saved in at ~/.qwen/settings.json': 'Gespeichert in ~/.qwen/settings.json',
|
||||
'Add a new rule…': 'Neue Regel hinzufügen…',
|
||||
'Add {{type}} permission rule': '{{type}}-Berechtigungsregel hinzufügen',
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
|
||||
'Berechtigungsregeln sind ein Toolname, optional gefolgt von einem Bezeichner in Klammern.',
|
||||
'e.g.,': 'z.B.',
|
||||
or: 'oder',
|
||||
'Enter permission rule…': 'Berechtigungsregel eingeben…',
|
||||
'Enter to submit · Esc to cancel': 'Enter zum Absenden · Esc zum Abbrechen',
|
||||
'Where should this rule be saved?': 'Wo soll diese Regel gespeichert werden?',
|
||||
'Enter to confirm · Esc to cancel':
|
||||
'Enter zum Bestätigen · Esc zum Abbrechen',
|
||||
'Delete {{type}} rule?': '{{type}}-Regel löschen?',
|
||||
'Are you sure you want to delete this permission rule?':
|
||||
'Sind Sie sicher, dass Sie diese Berechtigungsregel löschen möchten?',
|
||||
'Permissions:': 'Berechtigungen:',
|
||||
'(←/→ or tab to cycle)': '(←/→ oder Tab zum Wechseln)',
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
|
||||
'↑↓ navigieren · Enter auswählen · Tippen suchen · Esc abbrechen',
|
||||
'Search…': 'Suche…',
|
||||
'Use /trust to manage folder trust settings for this workspace.':
|
||||
'Verwenden Sie /trust, um die Ordnervertrauenseinstellungen für diesen Arbeitsbereich zu verwalten.',
|
||||
// Workspace directory management
|
||||
'Add directory…': 'Verzeichnis hinzufügen…',
|
||||
'Add directory to workspace': 'Verzeichnis zum Arbeitsbereich hinzufügen',
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
|
||||
'Qwen Code kann Dateien im Arbeitsbereich lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.',
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
|
||||
'Qwen Code kann Dateien in diesem Verzeichnis lesen und Bearbeitungen vornehmen, wenn die automatische Akzeptierung aktiviert ist.',
|
||||
'Enter the path to the directory:': 'Pfad zum Verzeichnis eingeben:',
|
||||
'Enter directory path…': 'Verzeichnispfad eingeben…',
|
||||
'Tab to complete · Enter to add · Esc to cancel':
|
||||
'Tab zum Vervollständigen · Enter zum Hinzufügen · Esc zum Abbrechen',
|
||||
'Remove directory?': 'Verzeichnis entfernen?',
|
||||
'Are you sure you want to remove this directory from the workspace?':
|
||||
'Möchten Sie dieses Verzeichnis wirklich aus dem Arbeitsbereich entfernen?',
|
||||
' (Original working directory)': ' (Ursprüngliches Arbeitsverzeichnis)',
|
||||
' (from settings)': ' (aus Einstellungen)',
|
||||
'Directory does not exist.': 'Verzeichnis existiert nicht.',
|
||||
'Path is not a directory.': 'Pfad ist kein Verzeichnis.',
|
||||
'This directory is already in the workspace.':
|
||||
'Dieses Verzeichnis ist bereits im Arbeitsbereich.',
|
||||
'Already covered by existing directory: {{dir}}':
|
||||
'Bereits durch vorhandenes Verzeichnis abgedeckt: {{dir}}',
|
||||
|
||||
// ============================================================================
|
||||
// Status Bar
|
||||
|
|
@ -1586,6 +1691,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.':
|
||||
|
|
@ -1621,4 +1756,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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export default {
|
|||
'Analyzes the project and creates a tailored QWEN.md file.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'List available Qwen Code tools. Usage: /tools [desc]',
|
||||
'List available skills.': 'List available skills.',
|
||||
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
|
||||
'No tools available': 'No tools available',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -459,6 +460,7 @@ export default {
|
|||
'These editors are currently supported. Please note that some editors cannot be used in sandbox mode.',
|
||||
'Your preferred editor is:': 'Your preferred editor is:',
|
||||
'Manage extensions': 'Manage extensions',
|
||||
'Manage installed extensions': 'Manage installed extensions',
|
||||
'List active extensions': 'List active extensions',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Update extensions. Usage: update <extension-names>|--all',
|
||||
|
|
@ -659,6 +661,37 @@ export default {
|
|||
'Failed to configure {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Manage Qwen Code hooks',
|
||||
'List all configured hooks': 'List all configured hooks',
|
||||
'Enable a disabled hook': 'Enable a disabled hook',
|
||||
'Disable an active hook': 'Disable an active hook',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Export current session message history to a file',
|
||||
'Export session to HTML format': 'Export session to HTML format',
|
||||
'Export session to JSON format': 'Export session to JSON format',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Export session to JSONL format (one message per line)',
|
||||
'Export session to markdown format': 'Export session to markdown format',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'generate personalized programming insights from your chat history',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Resume a previous session',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -1069,6 +1102,8 @@ export default {
|
|||
'No, suggest changes (esc)': 'No, suggest changes (esc)',
|
||||
"Allow execution of: '{{command}}'?": "Allow execution of: '{{command}}'?",
|
||||
'Yes, allow always ...': 'Yes, allow always ...',
|
||||
'Always allow in this project': 'Always allow in this project',
|
||||
'Always allow for this user': 'Always allow for this user',
|
||||
'Yes, and auto-accept edits': 'Yes, and auto-accept edits',
|
||||
'Yes, and manually approve edits': 'Yes, and manually approve edits',
|
||||
'No, keep planning (esc)': 'No, keep planning (esc)',
|
||||
|
|
@ -1233,6 +1268,73 @@ export default {
|
|||
// Dialogs - Permissions
|
||||
// ============================================================================
|
||||
'Manage folder trust settings': 'Manage folder trust settings',
|
||||
'Manage permission rules': 'Manage permission rules',
|
||||
Allow: 'Allow',
|
||||
Ask: 'Ask',
|
||||
Deny: 'Deny',
|
||||
Workspace: 'Workspace',
|
||||
"Qwen Code won't ask before using allowed tools.":
|
||||
"Qwen Code won't ask before using allowed tools.",
|
||||
'Qwen Code will ask before using these tools.':
|
||||
'Qwen Code will ask before using these tools.',
|
||||
'Qwen Code is not allowed to use denied tools.':
|
||||
'Qwen Code is not allowed to use denied tools.',
|
||||
'Manage trusted directories for this workspace.':
|
||||
'Manage trusted directories for this workspace.',
|
||||
'Any use of the {{tool}} tool': 'Any use of the {{tool}} tool',
|
||||
"{{tool}} commands matching '{{pattern}}'":
|
||||
"{{tool}} commands matching '{{pattern}}'",
|
||||
'From user settings': 'From user settings',
|
||||
'From project settings': 'From project settings',
|
||||
'From session': 'From session',
|
||||
'Project settings (local)': 'Project settings (local)',
|
||||
'Saved in .qwen/settings.local.json': 'Saved in .qwen/settings.local.json',
|
||||
'Project settings': 'Project settings',
|
||||
'Checked in at .qwen/settings.json': 'Checked in at .qwen/settings.json',
|
||||
'User settings': 'User settings',
|
||||
'Saved in at ~/.qwen/settings.json': 'Saved in at ~/.qwen/settings.json',
|
||||
'Add a new rule…': 'Add a new rule…',
|
||||
'Add {{type}} permission rule': 'Add {{type}} permission rule',
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.',
|
||||
'e.g.,': 'e.g.,',
|
||||
or: 'or',
|
||||
'Enter permission rule…': 'Enter permission rule…',
|
||||
'Enter to submit · Esc to cancel': 'Enter to submit · Esc to cancel',
|
||||
'Where should this rule be saved?': 'Where should this rule be saved?',
|
||||
'Enter to confirm · Esc to cancel': 'Enter to confirm · Esc to cancel',
|
||||
'Delete {{type}} rule?': 'Delete {{type}} rule?',
|
||||
'Are you sure you want to delete this permission rule?':
|
||||
'Are you sure you want to delete this permission rule?',
|
||||
'Permissions:': 'Permissions:',
|
||||
'(←/→ or tab to cycle)': '(←/→ or tab to cycle)',
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel',
|
||||
'Search…': 'Search…',
|
||||
'Use /trust to manage folder trust settings for this workspace.':
|
||||
'Use /trust to manage folder trust settings for this workspace.',
|
||||
// Workspace directory management
|
||||
'Add directory…': 'Add directory…',
|
||||
'Add directory to workspace': 'Add directory to workspace',
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.',
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.',
|
||||
'Enter the path to the directory:': 'Enter the path to the directory:',
|
||||
'Enter directory path…': 'Enter directory path…',
|
||||
'Tab to complete · Enter to add · Esc to cancel':
|
||||
'Tab to complete · Enter to add · Esc to cancel',
|
||||
'Remove directory?': 'Remove directory?',
|
||||
'Are you sure you want to remove this directory from the workspace?':
|
||||
'Are you sure you want to remove this directory from the workspace?',
|
||||
' (Original working directory)': ' (Original working directory)',
|
||||
' (from settings)': ' (from settings)',
|
||||
'Directory does not exist.': 'Directory does not exist.',
|
||||
'Path is not a directory.': 'Path is not a directory.',
|
||||
'This directory is already in the workspace.':
|
||||
'This directory is already in the workspace.',
|
||||
'Already covered by existing directory: {{dir}}':
|
||||
'Already covered by existing directory: {{dir}}',
|
||||
|
||||
// ============================================================================
|
||||
// Status Bar
|
||||
|
|
@ -1639,6 +1741,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.':
|
||||
|
|
@ -1673,4 +1803,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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export default {
|
|||
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
|
||||
'List available skills.': '利用可能なスキルを一覧表示する。',
|
||||
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
|
||||
'No tools available': '利用可能なツールはありません',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -328,6 +329,7 @@ export default {
|
|||
'ワークスペース内のすべてのディレクトリを表示',
|
||||
'set external editor preference': '外部エディタの設定',
|
||||
'Manage extensions': '拡張機能を管理',
|
||||
'Manage installed extensions': 'インストール済みの拡張機能を管理する',
|
||||
'List active extensions': '有効な拡張機能を一覧表示',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'拡張機能を更新。使い方: update <拡張機能名>|--all',
|
||||
|
|
@ -371,6 +373,38 @@ export default {
|
|||
'{{terminalName}} の設定に失敗しました',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'ターミナルは複数行入力(Shift+Enter と Ctrl+Enter)に最適化されています',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Qwen Code のフックを管理する',
|
||||
'List all configured hooks': '設定済みのフックをすべて表示する',
|
||||
'Enable a disabled hook': '無効なフックを有効にする',
|
||||
'Disable an active hook': '有効なフックを無効にする',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'現在のセッションのメッセージ履歴をファイルにエクスポートする',
|
||||
'Export session to HTML format': 'セッションを HTML 形式でエクスポートする',
|
||||
'Export session to JSON format': 'セッションを JSON 形式でエクスポートする',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'セッションを JSONL 形式でエクスポートする(1 行に 1 メッセージ)',
|
||||
'Export session to markdown format':
|
||||
'セッションを Markdown 形式でエクスポートする',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'チャット履歴からパーソナライズされたプログラミングインサイトを生成する',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': '前のセッションを再開する',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'ツール呼び出しを復元します。これにより、会話とファイルの履歴はそのツール呼び出しが提案された時点の状態に戻ります',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'ターミナルの種類を検出できませんでした。サポートされているターミナル: VS Code、Cursor、Windsurf、Trae',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -751,6 +785,8 @@ export default {
|
|||
'No, suggest changes (esc)': 'いいえ、変更を提案 (Esc)',
|
||||
"Allow execution of: '{{command}}'?": "'{{command}}' の実行を許可しますか?",
|
||||
'Yes, allow always ...': 'はい、常に許可...',
|
||||
'Always allow in this project': 'このプロジェクトで常に許可',
|
||||
'Always allow for this user': 'このユーザーに常に許可',
|
||||
'Yes, and auto-accept edits': 'はい、編集を自動承認',
|
||||
'Yes, and manually approve edits': 'はい、編集を手動承認',
|
||||
'No, keep planning (esc)': 'いいえ、計画を続ける (Esc)',
|
||||
|
|
@ -871,6 +907,73 @@ export default {
|
|||
'Alibaba Cloud ModelStudioの最新Qwen Visionモデル(バージョン: qwen3-vl-plus-2025-09-23)',
|
||||
// Dialogs - Permissions
|
||||
'Manage folder trust settings': 'フォルダ信頼設定を管理',
|
||||
'Manage permission rules': '権限ルールを管理',
|
||||
Allow: '許可',
|
||||
Ask: '確認',
|
||||
Deny: '拒否',
|
||||
Workspace: 'ワークスペース',
|
||||
"Qwen Code won't ask before using allowed tools.":
|
||||
'Qwen Code は許可されたツールを使用する前に確認しません。',
|
||||
'Qwen Code will ask before using these tools.':
|
||||
'Qwen Code はこれらのツールを使用する前に確認します。',
|
||||
'Qwen Code is not allowed to use denied tools.':
|
||||
'Qwen Code は拒否されたツールを使用できません。',
|
||||
'Manage trusted directories for this workspace.':
|
||||
'このワークスペースの信頼済みディレクトリを管理します。',
|
||||
'Any use of the {{tool}} tool': '{{tool}} ツールのすべての使用',
|
||||
"{{tool}} commands matching '{{pattern}}'":
|
||||
"'{{pattern}}' に一致する {{tool}} コマンド",
|
||||
'From user settings': 'ユーザー設定から',
|
||||
'From project settings': 'プロジェクト設定から',
|
||||
'From session': 'セッションから',
|
||||
'Project settings (local)': 'プロジェクト設定(ローカル)',
|
||||
'Saved in .qwen/settings.local.json': '.qwen/settings.local.json に保存',
|
||||
'Project settings': 'プロジェクト設定',
|
||||
'Checked in at .qwen/settings.json': '.qwen/settings.json にチェックイン',
|
||||
'User settings': 'ユーザー設定',
|
||||
'Saved in at ~/.qwen/settings.json': '~/.qwen/settings.json に保存',
|
||||
'Add a new rule…': '新しいルールを追加…',
|
||||
'Add {{type}} permission rule': '{{type}}権限ルールを追加',
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
|
||||
'権限ルールはツール名で、オプションで括弧内に指定子を付けます。',
|
||||
'e.g.,': '例:',
|
||||
or: 'または',
|
||||
'Enter permission rule…': '権限ルールを入力…',
|
||||
'Enter to submit · Esc to cancel': 'Enter で送信 · Esc でキャンセル',
|
||||
'Where should this rule be saved?': 'このルールをどこに保存しますか?',
|
||||
'Enter to confirm · Esc to cancel': 'Enter で確認 · Esc でキャンセル',
|
||||
'Delete {{type}} rule?': '{{type}}ルールを削除しますか?',
|
||||
'Are you sure you want to delete this permission rule?':
|
||||
'この権限ルールを削除してもよろしいですか?',
|
||||
'Permissions:': '権限:',
|
||||
'(←/→ or tab to cycle)': '(←/→ または Tab で切替)',
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
|
||||
'↑↓ でナビゲート · Enter で選択 · 入力で検索 · Esc でキャンセル',
|
||||
'Search…': '検索…',
|
||||
'Use /trust to manage folder trust settings for this workspace.':
|
||||
'/trust を使用してこのワークスペースのフォルダ信頼設定を管理します。',
|
||||
// Workspace directory management
|
||||
'Add directory…': 'ディレクトリを追加…',
|
||||
'Add directory to workspace': 'ワークスペースにディレクトリを追加',
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
|
||||
'Qwen Code はワークスペース内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。',
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
|
||||
'Qwen Code はこのディレクトリ内のファイルを読み取り、自動編集承認が有効な場合は編集を行えます。',
|
||||
'Enter the path to the directory:': 'ディレクトリのパスを入力してください:',
|
||||
'Enter directory path…': 'ディレクトリパスを入力…',
|
||||
'Tab to complete · Enter to add · Esc to cancel':
|
||||
'Tab で補完 · Enter で追加 · Esc でキャンセル',
|
||||
'Remove directory?': 'ディレクトリを削除しますか?',
|
||||
'Are you sure you want to remove this directory from the workspace?':
|
||||
'このディレクトリをワークスペースから削除してもよろしいですか?',
|
||||
' (Original working directory)': ' (元の作業ディレクトリ)',
|
||||
' (from settings)': ' (設定より)',
|
||||
'Directory does not exist.': 'ディレクトリが存在しません。',
|
||||
'Path is not a directory.': 'パスはディレクトリではありません。',
|
||||
'This directory is already in the workspace.':
|
||||
'このディレクトリはすでにワークスペースに含まれています。',
|
||||
'Already covered by existing directory: {{dir}}':
|
||||
'既存のディレクトリによって既にカバーされています: {{dir}}',
|
||||
// Status Bar
|
||||
'Using:': '使用中:',
|
||||
'{{count}} open file': '{{count}} 個のファイルを開いています',
|
||||
|
|
@ -1092,6 +1195,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.':
|
||||
|
|
@ -1125,4 +1257,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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export default {
|
|||
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
|
||||
'List available skills.': 'Listar habilidades disponíveis.',
|
||||
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
|
||||
'No tools available': 'Nenhuma ferramenta disponível',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -401,6 +402,7 @@ export default {
|
|||
'Estes editores são suportados atualmente. Note que alguns editores não podem ser usados no modo sandbox.',
|
||||
'Your preferred editor is:': 'Seu editor preferido é:',
|
||||
'Manage extensions': 'Gerenciar extensões',
|
||||
'Manage installed extensions': 'Gerenciar extensões instaladas',
|
||||
'List active extensions': 'Listar extensões ativas',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Atualizar extensões. Uso: update <nomes-das-extensoes>|--all',
|
||||
|
|
@ -590,6 +592,38 @@ export default {
|
|||
'Falha ao configurar {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Seu terminal já está configurado para uma experiência ideal com entrada multilinhas (Shift+Enter e Ctrl+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Gerenciar hooks do Qwen Code',
|
||||
'List all configured hooks': 'Listar todos os hooks configurados',
|
||||
'Enable a disabled hook': 'Ativar um hook desativado',
|
||||
'Disable an active hook': 'Desativar um hook ativo',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Exportar o histórico de mensagens da sessão atual para um arquivo',
|
||||
'Export session to HTML format': 'Exportar a sessão para o formato HTML',
|
||||
'Export session to JSON format': 'Exportar a sessão para o formato JSON',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Exportar a sessão para o formato JSONL (uma mensagem por linha)',
|
||||
'Export session to markdown format':
|
||||
'Exportar a sessão para o formato Markdown',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'Gerar insights personalizados de programação a partir do seu histórico de chat',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Retomar uma sessão anterior',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Restaurar uma chamada de ferramenta. Isso redefinirá o histórico da conversa e dos arquivos para o estado em que a chamada da ferramenta foi sugerida',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Não foi possível detectar o tipo de terminal. Terminais suportados: VS Code, Cursor, Windsurf e Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -1019,6 +1053,8 @@ export default {
|
|||
"Allow execution of: '{{command}}'?":
|
||||
"Permitir a execução de: '{{command}}'?",
|
||||
'Yes, allow always ...': 'Sim, permitir sempre ...',
|
||||
'Always allow in this project': 'Sempre permitir neste projeto',
|
||||
'Always allow for this user': 'Sempre permitir para este usuário',
|
||||
'Yes, and auto-accept edits': 'Sim, e aceitar edições automaticamente',
|
||||
'Yes, and manually approve edits': 'Sim, e aprovar edições manualmente',
|
||||
'No, keep planning (esc)': 'Não, continuar planejando (esc)',
|
||||
|
|
@ -1185,6 +1221,74 @@ export default {
|
|||
// ============================================================================
|
||||
'Manage folder trust settings':
|
||||
'Gerenciar configurações de confiança de pasta',
|
||||
'Manage permission rules': 'Gerenciar regras de permissão',
|
||||
Allow: 'Permitir',
|
||||
Ask: 'Perguntar',
|
||||
Deny: 'Negar',
|
||||
Workspace: 'Área de trabalho',
|
||||
"Qwen Code won't ask before using allowed tools.":
|
||||
'O Qwen Code não perguntará antes de usar ferramentas permitidas.',
|
||||
'Qwen Code will ask before using these tools.':
|
||||
'O Qwen Code perguntará antes de usar essas ferramentas.',
|
||||
'Qwen Code is not allowed to use denied tools.':
|
||||
'O Qwen Code não tem permissão para usar ferramentas negadas.',
|
||||
'Manage trusted directories for this workspace.':
|
||||
'Gerenciar diretórios confiáveis para esta área de trabalho.',
|
||||
'Any use of the {{tool}} tool': 'Qualquer uso da ferramenta {{tool}}',
|
||||
"{{tool}} commands matching '{{pattern}}'":
|
||||
"Comandos {{tool}} correspondentes a '{{pattern}}'",
|
||||
'From user settings': 'Das configurações do usuário',
|
||||
'From project settings': 'Das configurações do projeto',
|
||||
'From session': 'Da sessão',
|
||||
'Project settings (local)': 'Configurações do projeto (local)',
|
||||
'Saved in .qwen/settings.local.json': 'Salvo em .qwen/settings.local.json',
|
||||
'Project settings': 'Configurações do projeto',
|
||||
'Checked in at .qwen/settings.json': 'Registrado em .qwen/settings.json',
|
||||
'User settings': 'Configurações do usuário',
|
||||
'Saved in at ~/.qwen/settings.json': 'Salvo em ~/.qwen/settings.json',
|
||||
'Add a new rule…': 'Adicionar nova regra…',
|
||||
'Add {{type}} permission rule': 'Adicionar regra de permissão {{type}}',
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
|
||||
'Regras de permissão são um nome de ferramenta, opcionalmente seguido por um especificador entre parênteses.',
|
||||
'e.g.,': 'ex.',
|
||||
or: 'ou',
|
||||
'Enter permission rule…': 'Insira a regra de permissão…',
|
||||
'Enter to submit · Esc to cancel': 'Enter para enviar · Esc para cancelar',
|
||||
'Where should this rule be saved?': 'Onde esta regra deve ser salva?',
|
||||
'Enter to confirm · Esc to cancel':
|
||||
'Enter para confirmar · Esc para cancelar',
|
||||
'Delete {{type}} rule?': 'Excluir regra {{type}}?',
|
||||
'Are you sure you want to delete this permission rule?':
|
||||
'Tem certeza de que deseja excluir esta regra de permissão?',
|
||||
'Permissions:': 'Permissões:',
|
||||
'(←/→ or tab to cycle)': '(←/→ ou Tab para alternar)',
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
|
||||
'↑↓ para navegar · Enter para selecionar · Digite para pesquisar · Esc para cancelar',
|
||||
'Search…': 'Pesquisar…',
|
||||
'Use /trust to manage folder trust settings for this workspace.':
|
||||
'Use /trust para gerenciar as configurações de confiança de pasta desta área de trabalho.',
|
||||
// Workspace directory management
|
||||
'Add directory…': 'Adicionar diretório…',
|
||||
'Add directory to workspace': 'Adicionar diretório à área de trabalho',
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
|
||||
'O Qwen Code pode ler arquivos na área de trabalho e fazer edições quando a aceitação automática está ativada.',
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
|
||||
'O Qwen Code poderá ler arquivos neste diretório e fazer edições quando a aceitação automática está ativada.',
|
||||
'Enter the path to the directory:': 'Insira o caminho do diretório:',
|
||||
'Enter directory path…': 'Insira o caminho do diretório…',
|
||||
'Tab to complete · Enter to add · Esc to cancel':
|
||||
'Tab para completar · Enter para adicionar · Esc para cancelar',
|
||||
'Remove directory?': 'Remover diretório?',
|
||||
'Are you sure you want to remove this directory from the workspace?':
|
||||
'Tem certeza de que deseja remover este diretório da área de trabalho?',
|
||||
' (Original working directory)': ' (Diretório de trabalho original)',
|
||||
' (from settings)': ' (das configurações)',
|
||||
'Directory does not exist.': 'O diretório não existe.',
|
||||
'Path is not a directory.': 'O caminho não é um diretório.',
|
||||
'This directory is already in the workspace.':
|
||||
'Este diretório já está na área de trabalho.',
|
||||
'Already covered by existing directory: {{dir}}':
|
||||
'Já coberto pelo diretório existente: {{dir}}',
|
||||
|
||||
// ============================================================================
|
||||
// Status Bar
|
||||
|
|
@ -1581,6 +1685,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.':
|
||||
|
|
@ -1616,4 +1749,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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ export default {
|
|||
'Анализ проекта и создание адаптированного файла QWEN.md',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
|
||||
'List available skills.': 'Показать доступные навыки.',
|
||||
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
|
||||
'No tools available': 'Нет доступных инструментов',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -398,6 +399,7 @@ export default {
|
|||
'В настоящее время поддерживаются следующие редакторы. Обратите внимание, что некоторые редакторы нельзя использовать в режиме песочницы.',
|
||||
'Your preferred editor is:': 'Ваш предпочитаемый редактор:',
|
||||
'Manage extensions': 'Управление расширениями',
|
||||
'Manage installed extensions': 'Управлять установленными расширениями',
|
||||
'List active extensions': 'Показать активные расширения',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'Обновить расширения. Использование: update <extension-names>|--all',
|
||||
|
|
@ -596,6 +598,38 @@ export default {
|
|||
'Не удалось настроить {{terminalName}}.',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'Ваш терминал уже настроен для оптимальной работы с многострочным вводом (Shift+Enter и Ctrl+Enter).',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': 'Управлять хуками Qwen Code',
|
||||
'List all configured hooks': 'Показать все настроенные хуки',
|
||||
'Enable a disabled hook': 'Включить отключенный хук',
|
||||
'Disable an active hook': 'Отключить активный хук',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'Экспортировать историю сообщений текущей сессии в файл',
|
||||
'Export session to HTML format': 'Экспортировать сессию в формат HTML',
|
||||
'Export session to JSON format': 'Экспортировать сессию в формат JSON',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'Экспортировать сессию в формат JSONL (одно сообщение на строку)',
|
||||
'Export session to markdown format':
|
||||
'Экспортировать сессию в формат Markdown',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'Создать персонализированные инсайты по программированию на основе истории чата',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': 'Продолжить предыдущую сессию',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'Восстановить вызов инструмента. Это вернет историю разговора и файлов к состоянию на момент, когда был предложен этот вызов инструмента',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'Не удалось определить тип терминала. Поддерживаемые терминалы: VS Code, Cursor, Windsurf и Trae.',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -944,6 +978,8 @@ export default {
|
|||
'No, suggest changes (esc)': 'Нет, предложить изменения (esc)',
|
||||
"Allow execution of: '{{command}}'?": "Разрешить выполнение: '{{command}}'?",
|
||||
'Yes, allow always ...': 'Да, всегда разрешать ...',
|
||||
'Always allow in this project': 'Всегда разрешать в этом проекте',
|
||||
'Always allow for this user': 'Всегда разрешать для этого пользователя',
|
||||
'Yes, and auto-accept edits': 'Да, и автоматически принимать правки',
|
||||
'Yes, and manually approve edits': 'Да, и вручную подтверждать правки',
|
||||
'No, keep planning (esc)': 'Нет, продолжить планирование (esc)',
|
||||
|
|
@ -1108,6 +1144,74 @@ export default {
|
|||
// Диалоги - Разрешения
|
||||
// ============================================================================
|
||||
'Manage folder trust settings': 'Управление настройками доверия к папкам',
|
||||
'Manage permission rules': 'Управление правилами разрешений',
|
||||
Allow: 'Разрешить',
|
||||
Ask: 'Спросить',
|
||||
Deny: 'Запретить',
|
||||
Workspace: 'Рабочая область',
|
||||
"Qwen Code won't ask before using allowed tools.":
|
||||
'Qwen Code не будет спрашивать перед использованием разрешённых инструментов.',
|
||||
'Qwen Code will ask before using these tools.':
|
||||
'Qwen Code спросит перед использованием этих инструментов.',
|
||||
'Qwen Code is not allowed to use denied tools.':
|
||||
'Qwen Code не может использовать запрещённые инструменты.',
|
||||
'Manage trusted directories for this workspace.':
|
||||
'Управление доверенными каталогами для этой рабочей области.',
|
||||
'Any use of the {{tool}} tool': 'Любое использование инструмента {{tool}}',
|
||||
"{{tool}} commands matching '{{pattern}}'":
|
||||
"Команды {{tool}}, соответствующие '{{pattern}}'",
|
||||
'From user settings': 'Из пользовательских настроек',
|
||||
'From project settings': 'Из настроек проекта',
|
||||
'From session': 'Из сессии',
|
||||
'Project settings (local)': 'Настройки проекта (локальные)',
|
||||
'Saved in .qwen/settings.local.json': 'Сохранено в .qwen/settings.local.json',
|
||||
'Project settings': 'Настройки проекта',
|
||||
'Checked in at .qwen/settings.json': 'Зафиксировано в .qwen/settings.json',
|
||||
'User settings': 'Пользовательские настройки',
|
||||
'Saved in at ~/.qwen/settings.json': 'Сохранено в ~/.qwen/settings.json',
|
||||
'Add a new rule…': 'Добавить новое правило…',
|
||||
'Add {{type}} permission rule': 'Добавить правило разрешения {{type}}',
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
|
||||
'Правила разрешений — это имя инструмента, за которым может следовать спецификатор в скобках.',
|
||||
'e.g.,': 'напр.',
|
||||
or: 'или',
|
||||
'Enter permission rule…': 'Введите правило разрешения…',
|
||||
'Enter to submit · Esc to cancel': 'Enter для отправки · Esc для отмены',
|
||||
'Where should this rule be saved?': 'Где сохранить это правило?',
|
||||
'Enter to confirm · Esc to cancel':
|
||||
'Enter для подтверждения · Esc для отмены',
|
||||
'Delete {{type}} rule?': 'Удалить правило {{type}}?',
|
||||
'Are you sure you want to delete this permission rule?':
|
||||
'Вы уверены, что хотите удалить это правило разрешения?',
|
||||
'Permissions:': 'Разрешения:',
|
||||
'(←/→ or tab to cycle)': '(←/→ или Tab для переключения)',
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
|
||||
'↑↓ навигация · Enter выбор · Ввод для поиска · Esc отмена',
|
||||
'Search…': 'Поиск…',
|
||||
'Use /trust to manage folder trust settings for this workspace.':
|
||||
'Используйте /trust для управления настройками доверия к папкам этой рабочей области.',
|
||||
// Workspace directory management
|
||||
'Add directory…': 'Добавить каталог…',
|
||||
'Add directory to workspace': 'Добавить каталог в рабочую область',
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
|
||||
'Qwen Code может читать файлы в рабочей области и вносить правки, когда автоприём правок включён.',
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
|
||||
'Qwen Code сможет читать файлы в этом каталоге и вносить правки, когда автоприём правок включён.',
|
||||
'Enter the path to the directory:': 'Введите путь к каталогу:',
|
||||
'Enter directory path…': 'Введите путь к каталогу…',
|
||||
'Tab to complete · Enter to add · Esc to cancel':
|
||||
'Tab для завершения · Enter для добавления · Esc для отмены',
|
||||
'Remove directory?': 'Удалить каталог?',
|
||||
'Are you sure you want to remove this directory from the workspace?':
|
||||
'Вы уверены, что хотите удалить этот каталог из рабочей области?',
|
||||
' (Original working directory)': ' (Исходный рабочий каталог)',
|
||||
' (from settings)': ' (из настроек)',
|
||||
'Directory does not exist.': 'Каталог не существует.',
|
||||
'Path is not a directory.': 'Путь не является каталогом.',
|
||||
'This directory is already in the workspace.':
|
||||
'Этот каталог уже есть в рабочей области.',
|
||||
'Already covered by existing directory: {{dir}}':
|
||||
'Уже охвачен существующим каталогом: {{dir}}',
|
||||
|
||||
// ============================================================================
|
||||
// Строка состояния
|
||||
|
|
@ -1519,6 +1623,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',
|
||||
|
|
@ -1628,4 +1758,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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ export default {
|
|||
'分析项目并创建定制的 QWEN.md 文件',
|
||||
'List available Qwen Code tools. Usage: /tools [desc]':
|
||||
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
|
||||
'List available skills.': '列出可用技能。',
|
||||
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
|
||||
'No tools available': '没有可用工具',
|
||||
'View or change the approval mode for tool usage':
|
||||
|
|
@ -437,6 +438,7 @@ export default {
|
|||
'当前支持以下编辑器。请注意,某些编辑器无法在沙箱模式下使用。',
|
||||
'Your preferred editor is:': '您的首选编辑器是:',
|
||||
'Manage extensions': '管理扩展',
|
||||
'Manage installed extensions': '管理已安装的扩展',
|
||||
'List active extensions': '列出活动扩展',
|
||||
'Update extensions. Usage: update <extension-names>|--all':
|
||||
'更新扩展。用法:update <extension-names>|--all',
|
||||
|
|
@ -623,6 +625,37 @@ export default {
|
|||
'Failed to configure {{terminalName}}.': '配置 {{terminalName}} 失败。',
|
||||
'Your terminal is already configured for an optimal experience with multiline input (Shift+Enter and Ctrl+Enter).':
|
||||
'您的终端已配置为支持多行输入(Shift+Enter 和 Ctrl+Enter)的最佳体验。',
|
||||
// ============================================================================
|
||||
// Commands - Hooks
|
||||
// ============================================================================
|
||||
'Manage Qwen Code hooks': '管理 Qwen Code Hook',
|
||||
'List all configured hooks': '列出所有已配置的 Hook',
|
||||
'Enable a disabled hook': '启用已禁用的 Hook',
|
||||
'Disable an active hook': '禁用已启用的 Hook',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session Export
|
||||
// ============================================================================
|
||||
'Export current session message history to a file':
|
||||
'将当前会话的消息记录导出到文件',
|
||||
'Export session to HTML format': '将会话导出为 HTML 文件',
|
||||
'Export session to JSON format': '将会话导出为 JSON 文件',
|
||||
'Export session to JSONL format (one message per line)':
|
||||
'将会话导出为 JSONL 文件(每行一条消息)',
|
||||
'Export session to markdown format': '将会话导出为 Markdown 文件',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Insights
|
||||
// ============================================================================
|
||||
'generate personalized programming insights from your chat history':
|
||||
'根据你的聊天记录生成个性化编程洞察',
|
||||
|
||||
// ============================================================================
|
||||
// Commands - Session History
|
||||
// ============================================================================
|
||||
'Resume a previous session': '恢复先前会话',
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested':
|
||||
'恢复某次工具调用。这将把对话与文件历史重置到提出该工具调用建议时的状态',
|
||||
'Could not detect terminal type. Supported terminals: VS Code, Cursor, Windsurf, and Trae.':
|
||||
'无法检测终端类型。支持的终端:VS Code、Cursor、Windsurf 和 Trae。',
|
||||
'Terminal "{{terminal}}" is not supported yet.':
|
||||
|
|
@ -1010,6 +1043,8 @@ export default {
|
|||
'No, suggest changes (esc)': '否,建议更改 (esc)',
|
||||
"Allow execution of: '{{command}}'?": "允许执行:'{{command}}'?",
|
||||
'Yes, allow always ...': '是,总是允许 ...',
|
||||
'Always allow in this project': '在本项目中总是允许',
|
||||
'Always allow for this user': '对该用户总是允许',
|
||||
'Yes, and auto-accept edits': '是,并自动接受编辑',
|
||||
'Yes, and manually approve edits': '是,并手动批准编辑',
|
||||
'No, keep planning (esc)': '否,继续规划 (esc)',
|
||||
|
|
@ -1163,6 +1198,71 @@ export default {
|
|||
// Dialogs - Permissions
|
||||
// ============================================================================
|
||||
'Manage folder trust settings': '管理文件夹信任设置',
|
||||
'Manage permission rules': '管理权限规则',
|
||||
Allow: '允许',
|
||||
Ask: '询问',
|
||||
Deny: '拒绝',
|
||||
Workspace: '工作区',
|
||||
"Qwen Code won't ask before using allowed tools.":
|
||||
'Qwen Code 使用已允许的工具前不会询问。',
|
||||
'Qwen Code will ask before using these tools.':
|
||||
'Qwen Code 使用这些工具前会先询问。',
|
||||
'Qwen Code is not allowed to use denied tools.':
|
||||
'Qwen Code 不允许使用被拒绝的工具。',
|
||||
'Manage trusted directories for this workspace.':
|
||||
'管理此工作区的受信任目录。',
|
||||
'Any use of the {{tool}} tool': '{{tool}} 工具的任何使用',
|
||||
"{{tool}} commands matching '{{pattern}}'":
|
||||
"匹配 '{{pattern}}' 的 {{tool}} 命令",
|
||||
'From user settings': '来自用户设置',
|
||||
'From project settings': '来自项目设置',
|
||||
'From session': '来自会话',
|
||||
'Project settings (local)': '项目设置(本地)',
|
||||
'Saved in .qwen/settings.local.json': '保存在 .qwen/settings.local.json',
|
||||
'Project settings': '项目设置',
|
||||
'Checked in at .qwen/settings.json': '保存在 .qwen/settings.json',
|
||||
'User settings': '用户设置',
|
||||
'Saved in at ~/.qwen/settings.json': '保存在 ~/.qwen/settings.json',
|
||||
'Add a new rule…': '添加新规则…',
|
||||
'Add {{type}} permission rule': '添加{{type}}权限规则',
|
||||
'Permission rules are a tool name, optionally followed by a specifier in parentheses.':
|
||||
'权限规则是一个工具名称,可选地后跟括号中的限定符。',
|
||||
'e.g.,': '例如',
|
||||
or: '或',
|
||||
'Enter permission rule…': '输入权限规则…',
|
||||
'Enter to submit · Esc to cancel': '回车提交 · Esc 取消',
|
||||
'Where should this rule be saved?': '此规则应保存在哪里?',
|
||||
'Enter to confirm · Esc to cancel': '回车确认 · Esc 取消',
|
||||
'Delete {{type}} rule?': '删除{{type}}规则?',
|
||||
'Are you sure you want to delete this permission rule?':
|
||||
'确定要删除此权限规则吗?',
|
||||
'Permissions:': '权限:',
|
||||
'(←/→ or tab to cycle)': '(←/→ 或 tab 切换)',
|
||||
'Press ↑↓ to navigate · Enter to select · Type to search · Esc to cancel':
|
||||
'按 ↑↓ 导航 · 回车选择 · 输入搜索 · Esc 取消',
|
||||
'Search…': '搜索…',
|
||||
'Use /trust to manage folder trust settings for this workspace.':
|
||||
'使用 /trust 管理此工作区的文件夹信任设置。',
|
||||
// Workspace directory management
|
||||
'Add directory…': '添加目录…',
|
||||
'Add directory to workspace': '添加工作区目录',
|
||||
'Qwen Code can read files in the workspace, and make edits when auto-accept edits is on.':
|
||||
'Qwen Code 可以读取工作区中的文件,并在自动接受编辑模式开启时进行编辑。',
|
||||
'Qwen Code will be able to read files in this directory and make edits when auto-accept edits is on.':
|
||||
'Qwen Code 将能够读取此目录中的文件,并在自动接受编辑模式开启时进行编辑。',
|
||||
'Enter the path to the directory:': '输入目录路径:',
|
||||
'Enter directory path…': '输入目录路径…',
|
||||
'Tab to complete · Enter to add · Esc to cancel':
|
||||
'Tab 补全 · 回车添加 · Esc 取消',
|
||||
'Remove directory?': '删除目录?',
|
||||
'Are you sure you want to remove this directory from the workspace?':
|
||||
'确定要将此目录从工作区中移除吗?',
|
||||
' (Original working directory)': ' (原始工作目录)',
|
||||
' (from settings)': ' (来自设置)',
|
||||
'Directory does not exist.': '目录不存在。',
|
||||
'Path is not a directory.': '路径不是目录。',
|
||||
'This directory is already in the workspace.': '此目录已在工作区中。',
|
||||
'Already covered by existing directory: {{dir}}': '已被现有目录覆盖:{{dir}}',
|
||||
|
||||
// ============================================================================
|
||||
// Status Bar
|
||||
|
|
@ -1463,6 +1563,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。',
|
||||
|
|
@ -1493,4 +1620,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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -201,6 +201,7 @@ export interface MessageStartStreamEvent {
|
|||
id: string;
|
||||
role: 'assistant';
|
||||
model: string;
|
||||
content: [];
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
uiTelemetryService,
|
||||
FatalInputError,
|
||||
ApprovalMode,
|
||||
SendMessageType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Part } from '@google/genai';
|
||||
import { runNonInteractive } from './nonInteractiveCli.js';
|
||||
|
|
@ -250,7 +251,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockShutdownTelemetry).toHaveBeenCalled();
|
||||
|
|
@ -300,21 +301,21 @@ describe('runNonInteractive', () => {
|
|||
outputUpdateHandler: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
// Verify first call has isContinuation: false
|
||||
// Verify first call has type: UserQuery
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
[{ text: 'Use a tool' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
// Verify second call (after tool execution) has isContinuation: true
|
||||
// Verify second call (after tool execution) has type: ToolResult
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
|
||||
});
|
||||
|
|
@ -383,7 +384,7 @@ describe('runNonInteractive', () => {
|
|||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
{ isContinuation: true },
|
||||
{ type: SendMessageType.ToolResult },
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
|
||||
});
|
||||
|
|
@ -507,7 +508,7 @@ describe('runNonInteractive', () => {
|
|||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
|
|
@ -539,7 +540,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
|
@ -694,7 +695,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// JSON adapter emits array of messages, last one is result with stats
|
||||
|
|
@ -881,7 +882,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
|
||||
|
|
@ -941,7 +942,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
|
||||
|
|
@ -1299,7 +1300,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Message from stream-json input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-envelope',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -1775,7 +1776,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'Simple string content' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-string-content',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
|
||||
// UserMessage with array of text blocks
|
||||
|
|
@ -1808,7 +1809,7 @@ describe('runNonInteractive', () => {
|
|||
[{ text: 'First part' }, { text: 'Second part' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-blocks-content',
|
||||
{ isContinuation: false },
|
||||
{ type: SendMessageType.UserQuery },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
uiTelemetryService,
|
||||
parseAndFormatApiError,
|
||||
createDebugLogger,
|
||||
SendMessageType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { Content, Part, PartListUnion } from '@google/genai';
|
||||
import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js';
|
||||
|
|
@ -265,7 +266,11 @@ export async function runNonInteractive(
|
|||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
{ isContinuation: !isFirstTurn },
|
||||
{
|
||||
type: isFirstTurn
|
||||
? SendMessageType.UserQuery
|
||||
: SendMessageType.ToolResult,
|
||||
},
|
||||
);
|
||||
isFirstTurn = false;
|
||||
|
||||
|
|
@ -385,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();
|
||||
|
|
|
|||
|
|
@ -37,12 +37,33 @@ vi.mock('../ui/commands/ideCommand.js', async () => {
|
|||
vi.mock('../ui/commands/restoreCommand.js', () => ({
|
||||
restoreCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock('../ui/commands/trustCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
trustCommand: {
|
||||
name: 'trust',
|
||||
description: 'Trust command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('../ui/commands/permissionsCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
permissionsCommand: {
|
||||
name: 'permissions',
|
||||
description: 'Permissions command',
|
||||
description: 'Manage permission rules',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../ui/commands/hooksCommand.js', async () => {
|
||||
const { CommandKind } = await import('../ui/commands/types.js');
|
||||
return {
|
||||
hooksCommand: {
|
||||
name: 'hooks',
|
||||
description: 'Hooks command',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
},
|
||||
};
|
||||
|
|
@ -100,6 +121,7 @@ describe('BuiltinCommandLoader', () => {
|
|||
mockConfig = {
|
||||
getFolderTrust: vi.fn().mockReturnValue(true),
|
||||
getUseModelRouter: () => false,
|
||||
getEnableHooks: vi.fn().mockReturnValue(true),
|
||||
} as unknown as Config;
|
||||
|
||||
restoreCommandMock.mockReturnValue({
|
||||
|
|
@ -162,19 +184,19 @@ describe('BuiltinCommandLoader', () => {
|
|||
expect(modelCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include permissions command when folder trust is enabled', async () => {
|
||||
it('should include trust command when folder trust is enabled', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||
expect(permissionsCmd).toBeDefined();
|
||||
const trustCmd = commands.find((c) => c.name === 'trust');
|
||||
expect(trustCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude permissions command when folder trust is disabled', async () => {
|
||||
it('should exclude trust command when folder trust is disabled', async () => {
|
||||
(mockConfig.getFolderTrust as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const permissionsCmd = commands.find((c) => c.name === 'permissions');
|
||||
expect(permissionsCmd).toBeUndefined();
|
||||
const trustCmd = commands.find((c) => c.name === 'trust');
|
||||
expect(trustCmd).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should always include modelCommand', async () => {
|
||||
|
|
@ -184,4 +206,19 @@ describe('BuiltinCommandLoader', () => {
|
|||
expect(modelCmd).toBeDefined();
|
||||
expect(modelCmd?.name).toBe('model');
|
||||
});
|
||||
|
||||
it('should include hooks command when enableHooks is true', async () => {
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const hooksCmd = commands.find((c) => c.name === 'hooks');
|
||||
expect(hooksCmd).toBeDefined();
|
||||
});
|
||||
|
||||
it('should exclude hooks command when enableHooks is false', async () => {
|
||||
(mockConfig.getEnableHooks as Mock).mockReturnValue(false);
|
||||
const loader = new BuiltinCommandLoader(mockConfig);
|
||||
const commands = await loader.loadCommands(new AbortController().signal);
|
||||
const hooksCmd = commands.find((c) => c.name === 'hooks');
|
||||
expect(hooksCmd).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,12 +9,14 @@ 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 { btwCommand } from '../ui/commands/btwCommand.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';
|
||||
|
|
@ -30,6 +32,7 @@ import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
|||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { modelCommand } from '../ui/commands/modelCommand.js';
|
||||
import { permissionsCommand } from '../ui/commands/permissionsCommand.js';
|
||||
import { trustCommand } from '../ui/commands/trustCommand.js';
|
||||
import { quitCommand } from '../ui/commands/quitCommand.js';
|
||||
import { restoreCommand } from '../ui/commands/restoreCommand.js';
|
||||
import { resumeCommand } from '../ui/commands/resumeCommand.js';
|
||||
|
|
@ -62,12 +65,14 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
const allDefinitions: Array<SlashCommand | null> = [
|
||||
aboutCommand,
|
||||
agentsCommand,
|
||||
arenaCommand,
|
||||
approvalModeCommand,
|
||||
authCommand,
|
||||
btwCommand,
|
||||
bugCommand,
|
||||
clearCommand,
|
||||
compressCommand,
|
||||
contextCommand,
|
||||
copyCommand,
|
||||
docsCommand,
|
||||
directoryCommand,
|
||||
|
|
@ -75,14 +80,15 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
|||
exportCommand,
|
||||
extensionsCommand,
|
||||
helpCommand,
|
||||
hooksCommand,
|
||||
...(this.config?.getEnableHooks() ? [hooksCommand] : []),
|
||||
await ideCommand(),
|
||||
initCommand,
|
||||
languageCommand,
|
||||
mcpCommand,
|
||||
memoryCommand,
|
||||
modelCommand,
|
||||
...(this.config?.getFolderTrust() ? [permissionsCommand] : []),
|
||||
permissionsCommand,
|
||||
...(this.config?.getFolderTrust() ? [trustCommand] : []),
|
||||
quitCommand,
|
||||
restoreCommand(this.config),
|
||||
resumeCommand,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
|||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
|
@ -1137,6 +1138,102 @@ describe('DataProcessor', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('generateQualitativeInsights', () => {
|
||||
const mockMetrics = {
|
||||
totalSessions: 5,
|
||||
totalMessages: 50,
|
||||
totalHours: 2,
|
||||
heatmap: { '2025-01-15': 3 },
|
||||
topTools: [['read_file', 10]] as Array<[string, number]>,
|
||||
activeDays: 1,
|
||||
activeHours: { '10': 5 },
|
||||
totalLinesAdded: 100,
|
||||
totalLinesRemoved: 50,
|
||||
totalFiles: 10,
|
||||
streak: { currentStreak: 1, longestStreak: 1, dates: [] },
|
||||
} as unknown as Omit<InsightData, 'facets' | 'qualitative'>;
|
||||
|
||||
const mockFacets: SessionFacets[] = [
|
||||
{
|
||||
session_id: 'test-1',
|
||||
underlying_goal: 'Fix bug',
|
||||
goal_categories: { debugging: 1 },
|
||||
outcome: 'fully_achieved',
|
||||
user_satisfaction_counts: { satisfied: 1 },
|
||||
Qwen_helpfulness: 'very_helpful',
|
||||
session_type: 'single_task',
|
||||
friction_counts: {},
|
||||
friction_detail: '',
|
||||
primary_success: 'correct_code_edits',
|
||||
brief_summary: 'Fixed a bug',
|
||||
},
|
||||
];
|
||||
|
||||
it('should return partial qualitative data when some LLM calls fail', async () => {
|
||||
let callIndex = 0;
|
||||
mockGenerateJson.mockImplementation(() => {
|
||||
callIndex++;
|
||||
if (callIndex % 2 === 0) {
|
||||
return Promise.reject(new Error('LLM timeout'));
|
||||
}
|
||||
return Promise.resolve({ intro: 'test', areas: [], opportunities: [] });
|
||||
});
|
||||
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, mockFacets);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.impressiveWorkflows).toBeDefined();
|
||||
expect(result!.projectAreas).toBeUndefined();
|
||||
expect(result!.futureOpportunities).toBeDefined();
|
||||
expect(result!.frictionPoints).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return undefined when facets are empty', async () => {
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, []);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return full qualitative data when all LLM calls succeed', async () => {
|
||||
mockGenerateJson.mockResolvedValue({ intro: 'test', areas: [] });
|
||||
|
||||
const result = await (
|
||||
dataProcessor as unknown as {
|
||||
generateQualitativeInsights(
|
||||
metrics: Omit<InsightData, 'facets' | 'qualitative'>,
|
||||
facets: SessionFacets[],
|
||||
): Promise<
|
||||
| import('../types/QualitativeInsightTypes.js').QualitativeInsights
|
||||
| undefined
|
||||
>;
|
||||
}
|
||||
).generateQualitativeInsights(mockMetrics, mockFacets);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(mockGenerateJson).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateFacets', () => {
|
||||
it('should skip non-conversational sessions', async () => {
|
||||
const userOnlyRecords: ChatRecord[] = [
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ export class DataProcessor {
|
|||
const generate = async <T>(
|
||||
promptTemplate: string,
|
||||
schema: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
): Promise<T | undefined> => {
|
||||
const prompt = `${promptTemplate}\n\n${commonData}`;
|
||||
try {
|
||||
const result = await this.config.getBaseLlmClient().generateJson({
|
||||
|
|
@ -400,7 +400,7 @@ export class DataProcessor {
|
|||
return result as T;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate insight:', error);
|
||||
throw error;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -71,12 +71,12 @@ export interface InsightAtAGlance {
|
|||
}
|
||||
|
||||
export interface QualitativeInsights {
|
||||
impressiveWorkflows: InsightImpressiveWorkflows;
|
||||
projectAreas: InsightProjectAreas;
|
||||
futureOpportunities: InsightFutureOpportunities;
|
||||
frictionPoints: InsightFrictionPoints;
|
||||
memorableMoment: InsightMemorableMoment;
|
||||
improvements: InsightImprovements;
|
||||
interactionStyle: InsightInteractionStyle;
|
||||
atAGlance: InsightAtAGlance;
|
||||
impressiveWorkflows?: InsightImpressiveWorkflows;
|
||||
projectAreas?: InsightProjectAreas;
|
||||
futureOpportunities?: InsightFutureOpportunities;
|
||||
frictionPoints?: InsightFrictionPoints;
|
||||
memorableMoment?: InsightMemorableMoment;
|
||||
improvements?: InsightImprovements;
|
||||
interactionStyle?: InsightInteractionStyle;
|
||||
atAGlance?: InsightAtAGlance;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ describe('ShellProcessor', () => {
|
|||
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
|
||||
getShouldUseNodePtyShell: vi.fn().mockReturnValue(false),
|
||||
getShellExecutionConfig: vi.fn().mockReturnValue({}),
|
||||
getAllowedTools: vi.fn().mockReturnValue([]),
|
||||
getPermissionsAllow: vi.fn().mockReturnValue([]),
|
||||
// Default: no permission manager (tests that need one set it explicitly)
|
||||
getPermissionManager: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
context = createMockCommandContext({
|
||||
|
|
@ -206,9 +208,11 @@ describe('ShellProcessor', () => {
|
|||
allAllowed: false,
|
||||
disallowedCommands: ['rm -rf /'],
|
||||
});
|
||||
(mockConfig.getAllowedTools as Mock).mockReturnValue([
|
||||
'ShellTool(rm -rf /)',
|
||||
]);
|
||||
// Simulate allowedTools being pre-merged into permissionsAllow by Config,
|
||||
// so PermissionManager returns 'allow' for this command.
|
||||
(mockConfig.getPermissionManager as Mock).mockReturnValue({
|
||||
isCommandAllowed: (_cmd: string) => 'allow',
|
||||
});
|
||||
mockShellExecute.mockReturnValue({
|
||||
result: Promise.resolve({ ...SUCCESS_RESULT, output: 'deleted' }),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,13 +7,11 @@
|
|||
import {
|
||||
ApprovalMode,
|
||||
checkCommandPermissions,
|
||||
doesToolInvocationMatch,
|
||||
escapeShellArg,
|
||||
getShellConfiguration,
|
||||
ShellExecutionService,
|
||||
flatMapTextParts,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { AnyToolInvocation } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { CommandContext } from '../../ui/commands/types.js';
|
||||
import type { IPromptProcessor, PromptPipelineContent } from './types.js';
|
||||
|
|
@ -109,10 +107,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 };
|
||||
},
|
||||
);
|
||||
|
|
@ -126,15 +123,12 @@ export class ShellProcessor implements IPromptProcessor {
|
|||
// Security check on the final, escaped command string.
|
||||
const { allAllowed, disallowedCommands, blockReason, isHardDenial } =
|
||||
checkCommandPermissions(command, config, sessionShellAllowlist);
|
||||
const allowedTools = config.getAllowedTools() || [];
|
||||
const invocation = {
|
||||
params: { command },
|
||||
} as AnyToolInvocation;
|
||||
const isAllowedBySettings = doesToolInvocationMatch(
|
||||
'run_shell_command',
|
||||
invocation,
|
||||
allowedTools,
|
||||
);
|
||||
|
||||
// Determine if this command is explicitly auto-approved via PermissionManager
|
||||
const pm = config.getPermissionManager?.();
|
||||
const isAllowedBySettings = pm
|
||||
? pm.isCommandAllowed(command) === 'allow'
|
||||
: false;
|
||||
|
||||
if (!allAllowed) {
|
||||
if (isHardDenial) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ import {
|
|||
getAllGeminiMdFilenames,
|
||||
ShellExecutionService,
|
||||
Storage,
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { buildResumedHistoryItems } from './utils/resumeHistoryUtils.js';
|
||||
import { validateAuthMethod } from '../config/auth.js';
|
||||
|
|
@ -52,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';
|
||||
|
|
@ -97,6 +100,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';
|
||||
|
|
@ -237,6 +241,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
const { codingPlanUpdateRequest, dismissCodingPlanUpdate } =
|
||||
useCodingPlanUpdates(settings, config, historyManager.addItem);
|
||||
|
||||
const [isTrustDialogOpen, setTrustDialogOpen] = useState(false);
|
||||
const openTrustDialog = useCallback(() => setTrustDialogOpen(true), []);
|
||||
const closeTrustDialog = useCallback(() => setTrustDialogOpen(false), []);
|
||||
|
||||
const [isPermissionsDialogOpen, setPermissionsDialogOpen] = useState(false);
|
||||
const openPermissionsDialog = useCallback(
|
||||
() => setPermissionsDialogOpen(true),
|
||||
|
|
@ -290,7 +298,42 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
);
|
||||
historyManager.loadHistory(historyItems);
|
||||
}
|
||||
|
||||
// Fire SessionStart event after config is initialized
|
||||
const sessionStartSource = resumedSessionData
|
||||
? SessionStartSource.Resume
|
||||
: SessionStartSource.Startup;
|
||||
|
||||
const hookSystem = config.getHookSystem();
|
||||
|
||||
if (hookSystem) {
|
||||
hookSystem
|
||||
.fireSessionStartEvent(sessionStartSource, config.getModel() ?? '')
|
||||
.then(() => {
|
||||
debugLogger.debug('SessionStart event completed successfully');
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLogger.warn(`SessionStart hook failed: ${err}`);
|
||||
});
|
||||
} else {
|
||||
debugLogger.debug(
|
||||
'SessionStart: HookSystem not available, skipping event',
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
// Register SessionEnd cleanup for process exit
|
||||
registerCleanup(async () => {
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionEndEvent(SessionEndReason.PromptInputExit);
|
||||
debugLogger.debug('SessionEnd event completed successfully!!!');
|
||||
} catch (err) {
|
||||
debugLogger.error(`SessionEnd hook failed: ${err}`);
|
||||
}
|
||||
});
|
||||
|
||||
registerCleanup(async () => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
await ideClient.disconnect();
|
||||
|
|
@ -471,6 +514,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
|
||||
const { isModelDialogOpen, openModelDialog, closeModelDialog } =
|
||||
useModelCommand();
|
||||
const { activeArenaDialog, openArenaDialog, closeArenaDialog } =
|
||||
useArenaCommand();
|
||||
|
||||
const {
|
||||
isResumeDialogOpen,
|
||||
|
|
@ -510,6 +555,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openEditorDialog,
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
openTrustDialog,
|
||||
openArenaDialog,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
quit: (messages: HistoryItem[]) => {
|
||||
|
|
@ -534,8 +581,10 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
openEditorDialog,
|
||||
openSettingsDialog,
|
||||
openModelDialog,
|
||||
openArenaDialog,
|
||||
setDebugMessage,
|
||||
dispatchExtensionStateUpdate,
|
||||
openTrustDialog,
|
||||
openPermissionsDialog,
|
||||
openApprovalModeDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
|
|
@ -673,12 +722,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 } =
|
||||
|
|
@ -691,6 +743,14 @@ 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;
|
||||
}
|
||||
}
|
||||
if (
|
||||
streamingState === StreamingState.Responding &&
|
||||
isBtwCommand(submittedValue)
|
||||
|
|
@ -700,7 +760,16 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
}
|
||||
addMessage(submittedValue);
|
||||
},
|
||||
[addMessage, streamingState, submitQuery],
|
||||
[addMessage, agentViewState, streamingState, submitQuery],
|
||||
);
|
||||
|
||||
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)
|
||||
|
|
@ -776,10 +845,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({
|
||||
|
|
@ -1033,16 +1109,23 @@ 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,
|
||||
streamingState,
|
||||
elapsedTime,
|
||||
settings,
|
||||
config,
|
||||
});
|
||||
|
||||
// Dialog close functionality
|
||||
|
|
@ -1058,6 +1141,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
exitEditorDialog,
|
||||
isSettingsDialogOpen,
|
||||
closeSettingsDialog,
|
||||
activeArenaDialog,
|
||||
closeArenaDialog,
|
||||
isFolderTrustDialogOpen,
|
||||
showWelcomeBackDialog,
|
||||
handleWelcomeBackClose,
|
||||
|
|
@ -1332,6 +1417,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isThemeDialogOpen ||
|
||||
isSettingsDialogOpen ||
|
||||
isModelDialogOpen ||
|
||||
isTrustDialogOpen ||
|
||||
activeArenaDialog !== null ||
|
||||
isPermissionsDialogOpen ||
|
||||
isAuthDialogOpen ||
|
||||
isAuthenticating ||
|
||||
|
|
@ -1382,6 +1469,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
isModelDialogOpen,
|
||||
isTrustDialogOpen,
|
||||
activeArenaDialog,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
|
|
@ -1461,6 +1550,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isMcpDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
// Per-task token tracking
|
||||
taskStartTokens,
|
||||
}),
|
||||
[
|
||||
isThemeDialogOpen,
|
||||
|
|
@ -1478,6 +1569,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
quittingMessages,
|
||||
isSettingsDialogOpen,
|
||||
isModelDialogOpen,
|
||||
isTrustDialogOpen,
|
||||
activeArenaDialog,
|
||||
isPermissionsDialogOpen,
|
||||
isApprovalModeDialogOpen,
|
||||
isResumeDialogOpen,
|
||||
|
|
@ -1558,6 +1651,8 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
isMcpDialogOpen,
|
||||
// Feedback dialog
|
||||
isFeedbackDialogOpen,
|
||||
// Per-task token tracking
|
||||
taskStartTokens,
|
||||
],
|
||||
);
|
||||
|
||||
|
|
@ -1577,7 +1672,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openArenaDialog,
|
||||
closeArenaDialog,
|
||||
handleArenaModelsSelected,
|
||||
dismissCodingPlanUpdate,
|
||||
closeTrustDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
|
|
@ -1626,7 +1725,11 @@ export const AppContainer = (props: AppContainerProps) => {
|
|||
exitEditorDialog,
|
||||
closeSettingsDialog,
|
||||
closeModelDialog,
|
||||
openArenaDialog,
|
||||
closeArenaDialog,
|
||||
handleArenaModelsSelected,
|
||||
dismissCodingPlanUpdate,
|
||||
closeTrustDialog,
|
||||
closePermissionsDialog,
|
||||
setShellModeActive,
|
||||
vimHandleInput,
|
||||
|
|
|
|||
395
packages/cli/src/ui/commands/arenaCommand.test.ts
Normal file
395
packages/cli/src/ui/commands/arenaCommand.test.ts
Normal 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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
659
packages/cli/src/ui/commands/arenaCommand.ts
Normal file
659
packages/cli/src/ui/commands/arenaCommand.ts
Normal 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',
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -8,6 +8,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest';
|
|||
import { clearCommand } from './clearCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import {
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
// Mock the telemetry service
|
||||
vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
|
|
@ -26,10 +30,19 @@ describe('clearCommand', () => {
|
|||
let mockContext: CommandContext;
|
||||
let mockResetChat: ReturnType<typeof vi.fn>;
|
||||
let mockStartNewSession: ReturnType<typeof vi.fn>;
|
||||
let mockFireSessionEndEvent: ReturnType<typeof vi.fn>;
|
||||
let mockFireSessionStartEvent: ReturnType<typeof vi.fn>;
|
||||
let mockGetHookSystem: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockResetChat = vi.fn().mockResolvedValue(undefined);
|
||||
mockStartNewSession = vi.fn().mockReturnValue('new-session-id');
|
||||
mockFireSessionEndEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockFireSessionStartEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetHookSystem = vi.fn().mockReturnValue({
|
||||
fireSessionEndEvent: mockFireSessionEndEvent,
|
||||
fireSessionStartEvent: mockFireSessionStartEvent,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
|
|
@ -40,6 +53,12 @@ describe('clearCommand', () => {
|
|||
resetChat: mockResetChat,
|
||||
}) as unknown as GeminiClient,
|
||||
startNewSession: mockStartNewSession,
|
||||
getHookSystem: mockGetHookSystem,
|
||||
getDebugLogger: () => ({
|
||||
warn: vi.fn(),
|
||||
}),
|
||||
getModel: () => 'test-model',
|
||||
getToolRegistry: () => undefined,
|
||||
},
|
||||
},
|
||||
session: {
|
||||
|
|
@ -75,6 +94,50 @@ describe('clearCommand', () => {
|
|||
expect(mockContext.ui.clear).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fire SessionEnd event before clearing and SessionStart event after clearing', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
expect(mockGetHookSystem).toHaveBeenCalled();
|
||||
expect(mockFireSessionEndEvent).toHaveBeenCalledWith(
|
||||
SessionEndReason.Clear,
|
||||
);
|
||||
expect(mockFireSessionStartEvent).toHaveBeenCalledWith(
|
||||
SessionStartSource.Clear,
|
||||
'test-model',
|
||||
);
|
||||
|
||||
// SessionEnd should be called before SessionStart
|
||||
const sessionEndCallOrder =
|
||||
mockFireSessionEndEvent.mock.invocationCallOrder[0];
|
||||
const sessionStartCallOrder =
|
||||
mockFireSessionStartEvent.mock.invocationCallOrder[0];
|
||||
expect(sessionEndCallOrder).toBeLessThan(sessionStartCallOrder);
|
||||
});
|
||||
|
||||
it('should handle hook errors gracefully and continue execution', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
}
|
||||
|
||||
mockFireSessionEndEvent.mockRejectedValue(
|
||||
new Error('SessionEnd hook failed'),
|
||||
);
|
||||
mockFireSessionStartEvent.mockRejectedValue(
|
||||
new Error('SessionStart hook failed'),
|
||||
);
|
||||
|
||||
await clearCommand.action(mockContext, '');
|
||||
|
||||
// Should still complete the clear operation despite hook errors
|
||||
expect(mockStartNewSession).toHaveBeenCalledTimes(1);
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not attempt to reset chat if config service is not available', async () => {
|
||||
if (!clearCommand.action) {
|
||||
throw new Error('clearCommand must have an action.');
|
||||
|
|
|
|||
|
|
@ -7,7 +7,13 @@
|
|||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { uiTelemetryService } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
uiTelemetryService,
|
||||
SessionEndReason,
|
||||
SessionStartSource,
|
||||
ToolNames,
|
||||
SkillTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const clearCommand: SlashCommand = {
|
||||
name: 'clear',
|
||||
|
|
@ -20,11 +26,29 @@ export const clearCommand: SlashCommand = {
|
|||
const { config } = context.services;
|
||||
|
||||
if (config) {
|
||||
// Fire SessionEnd event before clearing (current session ends)
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionEndEvent(SessionEndReason.Clear);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionEnd hook failed: ${err}`);
|
||||
}
|
||||
|
||||
const newSessionId = config.startNewSession();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
|
@ -40,6 +64,18 @@ export const clearCommand: SlashCommand = {
|
|||
} else {
|
||||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
}
|
||||
|
||||
// Fire SessionStart event after clearing (new session starts)
|
||||
try {
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.fireSessionStartEvent(
|
||||
SessionStartSource.Clear,
|
||||
config.getModel() ?? '',
|
||||
);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
}
|
||||
} else {
|
||||
context.ui.setDebugMessage(t('Starting a new session and clearing.'));
|
||||
}
|
||||
|
|
|
|||
376
packages/cli/src/ui/commands/contextCommand.ts
Normal file
376
packages/cli/src/ui/commands/contextCommand.ts
Normal 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());
|
||||
},
|
||||
};
|
||||
|
|
@ -7,6 +7,7 @@
|
|||
import type { SlashCommand, CommandContext } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { loadServerHierarchicalMemory } from '@qwen-code/qwen-code-core';
|
||||
|
|
@ -25,6 +26,44 @@ export function expandHomeDir(p: string): string {
|
|||
return path.normalize(expandedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns directory path completions for the given partial argument.
|
||||
* Supports comma-separated paths by completing only the last segment.
|
||||
*/
|
||||
export function getDirPathCompletions(partialArg: string): string[] {
|
||||
const lastComma = partialArg.lastIndexOf(',');
|
||||
const prefix = lastComma >= 0 ? partialArg.substring(0, lastComma + 1) : '';
|
||||
const partial =
|
||||
lastComma >= 0
|
||||
? partialArg.substring(lastComma + 1).trimStart()
|
||||
: partialArg;
|
||||
|
||||
const trimmed = partial.trim();
|
||||
if (!trimmed) return [];
|
||||
|
||||
const expanded = trimmed.startsWith('~')
|
||||
? trimmed.replace(/^~/, os.homedir())
|
||||
: trimmed;
|
||||
const endsWithSep = expanded.endsWith('/') || expanded.endsWith(path.sep);
|
||||
const searchDir = endsWithSep ? expanded : path.dirname(expanded);
|
||||
const namePrefix = endsWithSep ? '' : path.basename(expanded);
|
||||
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(searchDir, { withFileTypes: true })
|
||||
.filter(
|
||||
(e) =>
|
||||
e.isDirectory() &&
|
||||
e.name.startsWith(namePrefix) &&
|
||||
!e.name.startsWith('.'),
|
||||
)
|
||||
.map((e) => prefix + path.join(searchDir, e.name))
|
||||
.slice(0, 8);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const directoryCommand: SlashCommand = {
|
||||
name: 'directory',
|
||||
altNames: ['dir'],
|
||||
|
|
@ -41,6 +80,8 @@ export const directoryCommand: SlashCommand = {
|
|||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
completion: async (_context: CommandContext, partialArg: string) =>
|
||||
getDirPathCompletions(partialArg),
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
const {
|
||||
ui: { addItem },
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
toJsonl,
|
||||
generateExportFilename,
|
||||
} from '../utils/export/index.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
* Action for the 'md' subcommand - exports session to markdown.
|
||||
|
|
@ -320,30 +321,40 @@ async function exportJsonlAction(
|
|||
*/
|
||||
export const exportCommand: SlashCommand = {
|
||||
name: 'export',
|
||||
description: 'Export current session message history to a file',
|
||||
get description() {
|
||||
return t('Export current session message history to a file');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [
|
||||
{
|
||||
name: 'html',
|
||||
description: 'Export session to HTML format',
|
||||
get description() {
|
||||
return t('Export session to HTML format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportHtmlAction,
|
||||
},
|
||||
{
|
||||
name: 'md',
|
||||
description: 'Export session to markdown format',
|
||||
get description() {
|
||||
return t('Export session to markdown format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportMarkdownAction,
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
description: 'Export session to JSON format',
|
||||
get description() {
|
||||
return t('Export session to JSON format');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportJsonAction,
|
||||
},
|
||||
{
|
||||
name: 'jsonl',
|
||||
description: 'Export session to JSONL format (one message per line)',
|
||||
get description() {
|
||||
return t('Export session to JSONL format (one message per line)');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: exportJsonlAction,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ describe('permissionsCommand', () => {
|
|||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(permissionsCommand.name).toBe('permissions');
|
||||
expect(permissionsCommand.description).toBe('Manage folder trust settings');
|
||||
expect(permissionsCommand.description).toBe('Manage permission rules');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { t } from '../../i18n/index.js';
|
|||
export const permissionsCommand: SlashCommand = {
|
||||
name: 'permissions',
|
||||
get description() {
|
||||
return t('Manage folder trust settings');
|
||||
return t('Manage permission rules');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
CommandKind,
|
||||
} from './types.js';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
async function restoreAction(
|
||||
context: CommandContext,
|
||||
|
|
@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
|
|||
|
||||
return {
|
||||
name: 'restore',
|
||||
description:
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||
get description() {
|
||||
return t(
|
||||
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: restoreAction,
|
||||
completion,
|
||||
|
|
|
|||
35
packages/cli/src/ui/commands/trustCommand.test.ts
Normal file
35
packages/cli/src/ui/commands/trustCommand.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { trustCommand } from './trustCommand.js';
|
||||
import { type CommandContext, CommandKind } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('trustCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(trustCommand.name).toBe('trust');
|
||||
expect(trustCommand.description).toBe('Manage folder trust settings');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
expect(trustCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should return an action to open the trust dialog', () => {
|
||||
const actionResult = trustCommand.action?.(mockContext, '');
|
||||
expect(actionResult).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'trust',
|
||||
});
|
||||
});
|
||||
});
|
||||
21
packages/cli/src/ui/commands/trustCommand.ts
Normal file
21
packages/cli/src/ui/commands/trustCommand.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
export const trustCommand: SlashCommand = {
|
||||
name: 'trust',
|
||||
get description() {
|
||||
return t('Manage folder trust settings');
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'trust',
|
||||
}),
|
||||
};
|
||||
|
|
@ -148,6 +148,10 @@ export interface OpenDialogActionReturn {
|
|||
|
||||
dialog:
|
||||
| 'help'
|
||||
| 'arena_start'
|
||||
| 'arena_select'
|
||||
| 'arena_stop'
|
||||
| 'arena_status'
|
||||
| 'auth'
|
||||
| 'theme'
|
||||
| 'editor'
|
||||
|
|
@ -155,6 +159,7 @@ export interface OpenDialogActionReturn {
|
|||
| 'model'
|
||||
| 'subagent_create'
|
||||
| 'subagent_list'
|
||||
| 'trust'
|
||||
| 'permissions'
|
||||
| 'approval-mode'
|
||||
| 'resume'
|
||||
|
|
|
|||
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal file
287
packages/cli/src/ui/components/BaseTextInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
|||
debugMessage: '',
|
||||
nightly: false,
|
||||
isTrustedFolder: true,
|
||||
taskStartTokens: 0,
|
||||
...overrides,
|
||||
}) as UIState;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -18,8 +18,13 @@ import { SettingsDialog } from './SettingsDialog.js';
|
|||
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
|
||||
import { AuthDialog } from '../auth/AuthDialog.js';
|
||||
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
|
||||
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
|
||||
import { TrustDialog } from './TrustDialog.js';
|
||||
import { PermissionsDialog } from './PermissionsDialog.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 +242,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">
|
||||
|
|
@ -267,15 +315,16 @@ export const DialogManager = ({
|
|||
);
|
||||
}
|
||||
}
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
if (uiState.isTrustDialogOpen) {
|
||||
return (
|
||||
<PermissionsModifyTrustDialog
|
||||
onExit={uiActions.closePermissionsDialog}
|
||||
addItem={addItem}
|
||||
/>
|
||||
<TrustDialog onExit={uiActions.closeTrustDialog} addItem={addItem} />
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState.isPermissionsDialogOpen) {
|
||||
return <PermissionsDialog onExit={uiActions.closePermissionsDialog} />;
|
||||
}
|
||||
|
||||
if (uiState.isSubagentCreateDialogOpen) {
|
||||
return (
|
||||
<AgentCreationWizard
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
import { BtwMessage } from './messages/BtwMessage.js';
|
||||
|
||||
|
|
@ -133,6 +136,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} />
|
||||
)}
|
||||
|
|
@ -192,6 +198,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} />
|
||||
)}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue