diff --git a/docs/design/adaptive-output-token-escalation/adaptive-output-token-escalation-design.md b/docs/design/adaptive-output-token-escalation/adaptive-output-token-escalation-design.md index b24e6c10f..8b72bd190 100644 --- a/docs/design/adaptive-output-token-escalation/adaptive-output-token-escalation-design.md +++ b/docs/design/adaptive-output-token-escalation/adaptive-output-token-escalation-design.md @@ -1,6 +1,6 @@ # Adaptive Output Token Escalation Design -> Reduces GPU slot over-reservation by ~4x through a "low default + escalate on truncation" strategy for output tokens. +> Reduces GPU slot over-reservation by ~4x through a "low default + escalate on truncation" strategy for output tokens, with multi-turn recovery for responses that exceed even the escalated limit. ## Problem @@ -8,61 +8,81 @@ Every API request reserves a fixed GPU slot proportional to `max_tokens`. The pr ## Solution -Use a capped default of **8K** output tokens. When a response is truncated (the model hits `max_tokens`), automatically retry once with an escalated limit of **64K**. Since <1% of requests are actually truncated, this reduces average slot reservation significantly while preserving output quality for long responses. +Use a capped default of **8K** output tokens. When a response is truncated (the model hits `max_tokens`): + +1. **Escalate** to the model's full output limit (with 64K as a floor for unknown models) +2. If still truncated, **recover** by keeping the partial response in history and injecting a continuation message, up to 3 times +3. If recovery is exhausted, fall back to the tool scheduler's truncation guidance + +Since <1% of requests are actually truncated, this reduces average slot reservation significantly while preserving output quality for long responses. ## Architecture ``` - ┌─────────────────────────┐ - │ Request starts │ - │ max_tokens = 8K │ - └───────────┬─────────────┘ - │ - ▼ - ┌─────────────────────────┐ - │ Stream response │ - └───────────┬─────────────┘ - │ - ┌─────────┴─────────┐ - │ │ - finish_reason finish_reason - != MAX_TOKENS == MAX_TOKENS - │ │ - ▼ ▼ - ┌───────────┐ ┌─────────────────────┐ - │ Done │ │ Check conditions: │ - └───────────┘ │ - No user override? │ - │ - No env override? │ - │ - Not already │ - │ escalated? │ - └─────────┬───────────┘ - YES │ NO - ┌─────────┴────┐ - │ │ - ▼ ▼ - ┌─────────────┐ ┌──────────┐ - │ Pop partial │ │ Done │ - │ model resp │ │ (truncd) │ - │ from history│ └──────────┘ - │ │ - │ Yield RETRY │ - │ event │ - │ │ - │ Re-send │ - │ max_tokens │ - │ = 64K │ - └─────────────┘ +Request (max_tokens = 8K) +│ +▼ +┌─────────────────────────┐ +│ Response truncated? │──── No ──▶ Done ✓ +│ (MAX_TOKENS) │ +└───────────┬──────────────┘ + │ Yes + ▼ +┌──────────────────────────────────────────────────┐ +│ Layer 1: Escalate to model output limit │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Pop partial response from history │ │ +│ │ RETRY (isContinuation: false → reset UI) │ │ +│ │ Re-send at max(64K, model output limit) │ │ +│ └────────────────────────────────────────────┘ │ +└───────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────┐ +│ Still truncated? │──── No ──▶ Done ✓ +│ (MAX_TOKENS) │ +└───────────┬──────────────┘ + │ Yes + ▼ +┌──────────────────────────────────────────────────┐ +│ Layer 2: Multi-turn recovery (up to 3×) │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Keep partial response in history │ │ +│ │ Push user message: "Resume directly..." │ │ +│ │ RETRY (isContinuation: true → keep UI buf) │ │ +│ │ Re-send with updated history │ │ +│ │ Model continues from where it left off │ │ +│ └──────────────┬─────────────────────────────┘ │ +│ │ │ +│ ┌──────┴──────┐ │ +│ │ Succeeded? │── Yes ──▶ Done ✓ │ +│ └──────┬──────┘ │ +│ │ No (still truncated) │ +│ ▼ │ +│ attempt < 3? ── Yes ──▶ loop back ↑ │ +└───────────┬──────────────────────────────────────┘ + │ No (exhausted) + ▼ +┌──────────────────────────────────────────────────┐ +│ Layer 3: Tool scheduler fallback │ +│ ┌────────────────────────────────────────────┐ │ +│ │ Reject truncated Edit/Write tool calls │ │ +│ │ Return guidance: "You MUST split into │ │ +│ │ smaller parts — write skeleton first, │ │ +│ │ then edit incrementally." │ │ +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ ``` ## Token limit determination The effective `max_tokens` is resolved in the following priority order: -| Priority | Source | Value (known model) | Value (unknown model) | Escalation behavior | -| ----------- | ---------------------------------------------------- | ---------------------------- | --------------------- | ------------------------------ | -| 1 (highest) | User config (`samplingParams.max_tokens`) | `min(userValue, modelLimit)` | `userValue` | No escalation | -| 2 | Environment variable (`QWEN_CODE_MAX_OUTPUT_TOKENS`) | `min(envValue, modelLimit)` | `envValue` | No escalation | -| 3 (lowest) | Capped default | `min(modelLimit, 8K)` | `min(32K, 8K)` = 8K | Escalates to 64K on truncation | +| Priority | Source | Value (known model) | Value (unknown model) | Escalation behavior | +| ----------- | ---------------------------------------------------- | ---------------------------- | --------------------- | ----------------------------------------------- | +| 1 (highest) | User config (`samplingParams.max_tokens`) | `min(userValue, modelLimit)` | `userValue` | No escalation | +| 2 | Environment variable (`QWEN_CODE_MAX_OUTPUT_TOKENS`) | `min(envValue, modelLimit)` | `envValue` | No escalation | +| 3 (lowest) | Capped default | `min(modelLimit, 8K)` | `min(32K, 8K)` = 8K | Escalates to model limit (64K floor) + recovery | A "known model" is one that has an explicit entry in `OUTPUT_PATTERNS` (checked via `hasExplicitOutputLimit()`). For known models, the effective value is always capped at the model's declared output limit to avoid API errors. Unknown models (custom deployments, self-hosted endpoints) pass the user's value through directly, since the backend may support larger limits. @@ -88,9 +108,25 @@ The escalation logic lives in `geminiChat.ts`, placed **outside** the main retry 3. Guard checks pass: - maxTokensEscalated === false (prevent infinite escalation) - hasUserMaxTokensOverride === false (respect user intent) -4. Pop the partial model response from chat history -5. Yield RETRY event → UI discards partial output -6. Re-send the same request with maxOutputTokens: 64K +4. Compute escalated limit: max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output')) +5. Pop the partial model response from chat history +6. Yield RETRY event (isContinuation: false) → UI discards partial output and resets buffers +7. Re-send the same request with maxOutputTokens: escalatedLimit +``` + +### Recovery steps (geminiChat.ts) + +If the escalated response is also truncated (finishReason === MAX_TOKENS), the recovery loop runs up to `MAX_OUTPUT_RECOVERY_ATTEMPTS` (3) times: + +``` +1. Partial model response is already in history (pushed by processStreamResponse) +2. Push a recovery user message: OUTPUT_RECOVERY_MESSAGE +3. Yield RETRY event (isContinuation: true) → UI keeps text buffer for continuation +4. Re-send with updated history (model sees its partial output + recovery instruction) +5. If still truncated and attempts remain, loop back to step 1 +6. If recovery attempt throws (empty response, network error): + - Pop the dangling recovery message from history + - Break out of recovery loop ``` ### State cleanup on RETRY (turn.ts) @@ -102,14 +138,26 @@ When the `Turn` class receives a RETRY event, it clears accumulated state to pre - `debugResponses` — cleared to avoid stale debug data - `finishReason` — reset to `undefined` so the new response's finish reason is used +The `isContinuation` flag is passed through to the UI so it can decide whether to reset text buffers (escalation) or keep them (recovery). + ## Constants -Defined in `tokenLimits.ts`: +Defined in `geminiChat.ts` and `tokenLimits.ts`: -| Constant | Value | Purpose | -| --------------------------- | ------ | ------------------------------------------------------- | -| `CAPPED_DEFAULT_MAX_TOKENS` | 8,000 | Default output token limit when no user override is set | -| `ESCALATED_MAX_TOKENS` | 64,000 | Output token limit used on truncation retry | +| Constant | Value | Purpose | +| ------------------------------ | ------ | ------------------------------------------------------- | +| `CAPPED_DEFAULT_MAX_TOKENS` | 8,000 | Default output token limit when no user override is set | +| `ESCALATED_MAX_TOKENS` | 64,000 | Floor for escalation (used when model limit is unknown) | +| `MAX_OUTPUT_RECOVERY_ATTEMPTS` | 3 | Max multi-turn recovery attempts after escalation | + +The effective escalated limit is `max(ESCALATED_MAX_TOKENS, tokenLimit(model, 'output'))`: + +| Model | Escalated limit | +| ---------------- | --------------- | +| Claude Opus 4.6 | 131,072 (128K) | +| GPT-5 / o-series | 131,072 (128K) | +| Qwen3.x | 65,536 (64K) | +| Unknown models | 64,000 (floor) | ## Design decisions @@ -119,20 +167,22 @@ Defined in `tokenLimits.ts`: - 8K provides reasonable headroom for slightly longer responses without triggering unnecessary retries - Reduces average slot reservation from 32K to 8K (4x improvement) -### Why 64K escalated limit? +### Why escalate to model limit instead of fixed 64K? -- Covers the vast majority of long outputs that were truncated at 8K -- Matches the output limit of many modern models (Claude Sonnet, Gemini 3.x, Qwen3.x) -- Higher values (e.g., 128K) would negate slot optimization benefits for the <1% of requests that escalate +- Models with higher output limits (Claude Opus 128K, GPT-5 128K) were constrained to 64K unnecessarily +- Using the model's actual limit captures the vast majority of long outputs without a second retry +- `ESCALATED_MAX_TOKENS` (64K) serves as a floor for unknown models where `tokenLimit()` returns the default 32K -### Why not progressive escalation (8K → 16K → 32K → 64K)? +### Why multi-turn recovery instead of progressive escalation? -- Each retry adds latency (the full response must be regenerated) -- A single retry is the simplest approach that captures almost all cases -- The <1% truncation rate at 8K means almost no requests need escalation; those that do are likely to need significantly more than 16K +- Progressive escalation (8K → 16K → 32K → 64K) requires regenerating the full response each time +- Multi-turn recovery keeps the partial response and lets the model continue, saving tokens and latency +- Recovery messages are cheap (~40 tokens each) compared to regenerating large responses +- The 3-attempt limit prevents infinite loops while covering most practical cases ### Why is escalation outside the retry loop? - Truncation is a success case, not an error - Errors from the escalated stream (rate limits, network failures) should propagate directly rather than being silently retried with incorrect parameters - Keeps the retry loop focused on its original purpose (transient error recovery) +- Recovery errors are caught separately to avoid aborting the entire conversation diff --git a/docs/design/session-recap/session-recap-design.md b/docs/design/session-recap/session-recap-design.md index d3fe6fde9..af93fcc81 100644 --- a/docs/design/session-recap/session-recap-design.md +++ b/docs/design/session-recap/session-recap-design.md @@ -1,6 +1,6 @@ # Session Recap Design -> A one-line "where did I leave off" summary surfaced when the user +> A brief (1-2 sentence) "where did I leave off" summary surfaced when the user > returns to an idle session, either on demand (`/recap`) or after the > terminal has been blurred for 5+ minutes. @@ -11,7 +11,7 @@ pages of history to remember **what they were doing and what came next** is a real friction point. Just reloading messages does not solve this UX problem. -The goal is to proactively surface a one-line recap when the user +The goal is to proactively surface a brief 1-2 sentence recap when the user returns: - **High-level task** (what they are doing) → **next step** (what to do next). @@ -41,7 +41,7 @@ command ignores that setting. │ isIdle = streamingState === Idle │ │ │ │ │ ├─→ useAwaySummary({enabled, config, isFocused, isIdle, │ -│ │ │ setAwayRecapItem}) │ +│ │ │ addItem}) │ │ │ └─→ 5 min blur timer + idle/dedupe gates │ │ │ │ │ │ │ ↓ │ @@ -57,25 +57,25 @@ command ignores that setting. │ GeminiClient.generateContent │ │ (fastModel + tools:[]) │ │ │ -│ setAwayRecapItem({type: 'away_recap', text}) │ -│ └─→ DefaultAppLayout renders AwayRecapMessage │ -│ as a sticky banner above the Composer │ -│ (dim color + "※ recap:" prefix) │ +│ addItem({type: 'away_recap', text}) ─→ HistoryItemDisplay │ +│ └─ AwayRecapMessage rendered inline like any other history │ +│ item (※ + bold "recap: " + italic content, all dim); │ +│ scrolls naturally with the conversation. Mirrors Claude │ +│ Code's away_summary system message. │ └────────────────────────────────────────────────────────────────────────┘ ``` ### Files -| File | Responsibility | -| ------------------------------------------------------------ | --------------------------------------------------- | -| `packages/core/src/services/sessionRecap.ts` | One-shot LLM call + history filter + tag extraction | -| `packages/cli/src/ui/hooks/useAwaySummary.ts` | Auto-trigger React hook | -| `packages/cli/src/ui/commands/recapCommand.ts` | `/recap` manual entry point | -| `packages/cli/src/ui/components/messages/StatusMessages.tsx` | `AwayRecapMessage` dim renderer (`※ recap:` prefix) | -| `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type | -| `packages/cli/src/ui/layouts/DefaultAppLayout.tsx` | Sticky-banner placement above the Composer | -| `packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx` | Same placement under screen-reader mode | -| `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` setting | +| File | Responsibility | +| ------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| `packages/core/src/services/sessionRecap.ts` | One-shot LLM call + history filter + tag extraction | +| `packages/cli/src/ui/hooks/useAwaySummary.ts` | Auto-trigger React hook | +| `packages/cli/src/ui/commands/recapCommand.ts` | `/recap` manual entry point | +| `packages/cli/src/ui/components/messages/StatusMessages.tsx` | `AwayRecapMessage` renderer (`※` + bold `recap:` + italic content, all dim) | +| `packages/cli/src/ui/types.ts` | `HistoryItemAwayRecap` type | +| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | Dispatches `away_recap` history items to the renderer | +| `packages/cli/src/config/settingsSchema.ts` | `general.showSessionRecap` + `general.sessionRecapAwayThresholdMinutes` settings | ## Prompt Design @@ -93,7 +93,7 @@ recap, not a leak. Bullets below correspond 1:1 with `RECAP_SYSTEM_PROMPT`: -- Exactly one short sentence (≤ 80 chars), plain prose (no markdown / lists / headings). +- Under 40 words, 1-2 plain sentences (no markdown / lists / headings). For Chinese, treat the budget as roughly 80 characters total. - First sentence: the high-level task. Then: the concrete next step. - Explicitly forbid: listing what was done, reciting tool calls, status reports. - Match the dominant language of the conversation (English or Chinese). @@ -128,7 +128,7 @@ the model's reasoning preamble is worse than showing no recap at all. | ------------------- | ------------------------------ | ----------------------------------------------------- | | `model` | `getFastModel() ?? getModel()` | Recap doesn't need a frontier model | | `tools` | `[]` | One-shot query, no tool use | -| `maxOutputTokens` | `300` | Headroom for one short sentence + tags | +| `maxOutputTokens` | `300` | Headroom for 1-2 short sentences + tags | | `temperature` | `0.3` | Mostly deterministic, with a bit of natural variation | | `systemInstruction` | The recap-only prompt above | Replaces the main agent's role definition | @@ -170,17 +170,18 @@ response. | `recapPendingRef` | Whether an LLM call is in flight | | `inFlightRef` | The current in-flight `AbortController` | -`useEffect` deps: `[enabled, config, isFocused, isIdle, setAwayRecapItem]`. +`useEffect` deps: `[enabled, config, isFocused, isIdle, addItem, thresholdMs]`. -| Event | Action | -| -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `!enabled \|\| !config` | Abort in-flight call + clear `inFlightRef` + clear `blurredAtRef` | -| `!isFocused` and `blurredAtRef === null` | Set `blurredAtRef = Date.now()` | -| `isFocused` and `blurredAtRef === null` | Return early (no blur cycle to handle — first render or right after a brief-blur reset) | -| `isFocused` and blur duration < 5 min | Clear `blurredAtRef`, wait for next blur cycle | -| `isFocused` and blur ≥ 5 min and `recapPendingRef` | Return (dedupe) | -| `isFocused` and blur ≥ 5 min and `!isIdle` | **Preserve** `blurredAtRef` and wait for the turn to finish (`isIdle` is in the deps, so the effect re-fires when streaming completes) | -| `isFocused` and all conditions met | Clear `blurredAtRef`, set `recapPendingRef = true`, create `AbortController`, send the LLM request | +| Event | Action | +| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `!enabled \|\| !config` | Abort in-flight call + clear `inFlightRef` + clear `blurredAtRef` | +| `!isFocused` and `blurredAtRef === null` | Set `blurredAtRef = Date.now()` | +| `isFocused` and `blurredAtRef === null` | Return early (no blur cycle to handle — first render or right after a brief-blur reset) | +| `isFocused` and blur duration < 5 min | Clear `blurredAtRef`, wait for next blur cycle | +| `isFocused` and blur ≥ 5 min and `recapPendingRef` | Return (dedupe) | +| `isFocused` and blur ≥ 5 min and `!isIdle` | **Preserve** `blurredAtRef` and wait for the turn to finish (`isIdle` is in the deps, so the effect re-fires when streaming completes) | +| `isFocused` and blur ≥ 5 min and `shouldFireRecap` returns false | Clear `blurredAtRef` and return — conversation hasn't moved enough since the last recap (≥ 2 user turns required, mirrors Claude Code) | +| `isFocused` and all conditions met | Clear `blurredAtRef`, set `recapPendingRef = true`, create `AbortController`, send the LLM request | The `.then` callback **re-checks** `isIdleRef.current`: if the user has started a new turn while the LLM was running, the late-arriving recap @@ -205,10 +206,11 @@ and a null `pendingItem`. ### User-facing knobs -| Setting | Default | Notes | -| -------------------------- | ------- | ----------------------------------------------------------------- | -| `general.showSessionRecap` | `false` | Auto-trigger only. Manual `/recap` ignores this. | -| `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. | +| Setting | Default | Notes | +| ------------------------------------------ | ------- | ----------------------------------------------------------------------------------- | +| `general.showSessionRecap` | `false` | Auto-trigger only. Manual `/recap` ignores this. | +| `general.sessionRecapAwayThresholdMinutes` | `5` | Minutes blurred before auto-recap fires on focus-in. Matches Claude Code's default. | +| `fastModel` | unset | Recommended (e.g. `qwen3-coder-flash`) for fast and cheap recaps. | ### Model fallback diff --git a/docs/design/slash-command/phase2-technical-design.md b/docs/design/slash-command/phase2-technical-design.md new file mode 100644 index 000000000..2e45b9da4 --- /dev/null +++ b/docs/design/slash-command/phase2-technical-design.md @@ -0,0 +1,688 @@ +# Phase 2 技术设计文档:能力扩展 + +## 1. 设计目标与约束 + +### 1.1 目标 + +- 将 13 个 built-in 命令的 `supportedModes` 扩展到包含 `non_interactive` 和/或 `acp` +- 确保每个扩展命令在 ACP/non-interactive 路径下返回适合 IDE 消费的文本内容 +- 打通 prompt command 的模型调用通路(`SkillTool` 消费 `getModelInvocableCommands()`) +- 实现 mid-input slash command 基础检测 + +### 1.2 硬性约束 + +- **interactive 路径零退化**:所有扩展命令的现有 interactive 行为严格不变,只在 action 内部新增模式分支,不触碰 interactive 路径代码 +- **实现策略:模式分支,而非双注册**:13 个命令均采用在 `action` 内部增加 `executionMode` 判断的方式,不使用 Phase 1 设计文档 §10.2 描述的双注册模式(双注册仅在 interactive 和 non-interactive 逻辑差异极大时才有必要,本阶段命令复杂度不达到该门槛) +- **ACP 消息格式**:ACP 路径返回的文本内容不含 ANSI 样式,以 Markdown 或纯文本为宜,面向 IDE 插件消费 +- **跳过环境相关副作用**:打开浏览器(`open()`)、操作剪贴板(`copyToClipboard()`)等依赖图形环境的操作,在 non-interactive/ACP 路径下必须跳过 + +--- + +## 2. Phase 1 完成后的基础状态 + +Phase 1 结束后的架构要点(Phase 2 直接在此基础上扩展): + +- `commandType` 字段已从 `SlashCommand` 接口中删除,所有命令改用显式 `supportedModes` +- `getEffectiveSupportedModes()` 为两级推断:显式 `supportedModes` → `CommandKind` 兜底 +- `CommandService.getCommandsForMode(mode)` 取代原 `ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE` 白名单 +- `btw`、`bug`、`compress`、`context`、`init`、`summary` 已在 Phase 1 中扩展到全模式,**不在本阶段列表中** +- `createNonInteractiveUI()` 中各方法均为 no-op:`addItem`、`clear`、`setDebugMessage`、`setPendingItem`、`reloadCommands` 均静默忽略调用 + +--- + +## 3. 变更范围总览 + +本阶段共涉及 13 个命令,按实现复杂度分为四类: + +| 类别 | 命令 | 变更要点 | +| ---------- | -------------------------------------------- | ------------------------------------------------------------------------------------ | +| **A 类** | `export` | 只改 `supportedModes`,action 所有路径已返回合法类型 | +| **仅交互** | `plan`、`statusline` | 设计决策:这两个命令语义上与交互界面紧密耦合,保持 `supportedModes: ['interactive']` | +| **A+ 类** | `language` | 改 `supportedModes` + 少量 non-interactive 分支处理 | +| **仅交互** | `copy`、`restore` | 设计决策:剥贴板和快照恢复本质上是交互操作,保持 `supportedModes: ['interactive']` | +| **A' 类** | `model`、`approval-mode` | 有参数路径已返回 `message`,无参数路径需新增 non-interactive 分支(现触发 dialog) | +| **B 类** | `about`、`stats`、`insight`、`docs`、`clear` | action 所有路径均无返回值或调用 `addItem`/`clear`,需新增完整 non-interactive 分支 | + +--- + +## 4. A 类:只改 `supportedModes` + +这三个命令的所有 `action` 路径已经返回 `message` 或 `submit_prompt`,完全无 UI 依赖,`handleCommandResult` 可直接处理。 + +### 4.1 `/export`(及子命令) + +**当前状态**:`supportedModes: ['interactive']`,所有子命令 action 均返回 `MessageActionReturn`。 + +**变更**:将父命令及所有四个子命令(`md`、`html`、`json`、`jsonl`)的 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 + +**ACP 消息内容**:action 现有返回内容已包含完整文件路径(如 `Session exported to markdown: qwen-export-2024-01-01T12-00-00.md`),对 IDE 消费友好,无需修改文本。 + +> **注意**:`/export` 父命令本身没有 `action`,只有子命令。将父命令 `supportedModes` 改为全模式后,`parseSlashCommand` 能够匹配子命令路由,但若用户只输入 `/export` 不带子命令,`commandToExecute.action` 为 undefined,`handleSlashCommand` 返回 `no_command`,调用方会显示可用子命令提示。这是预期行为。 + +### 4.2 `/plan` + +**当前状态**:`supportedModes: ['interactive']`,action 所有路径返回 `MessageActionReturn` 或 `SubmitPromptActionReturn`。 + +**设计决策**:`/plan` 是引导用户进行多轮交互规划的命令,语义上与交互界面紧密耦合。经讨论决定保持 `supportedModes: ['interactive']`,不扩展至 non-interactive/acp 模式。 + +### 4.3 `/statusline` + +**当前状态**:`supportedModes: ['interactive']`,action 始终返回 `SubmitPromptActionReturn`(将 subagent 调用 prompt 提交给模型)。 + +**设计决策**:`/statusline` 是触发 subagent 对当前状态进行总结的命令,语义上与交互界面紧密耦合。经讨论决定保持 `supportedModes: ['interactive']`,不扩展至 non-interactive/acp 模式。 + +--- + +## 5. A+ 类:少量 non-interactive 分支处理 + +### 5.1 `/language` + +**当前状态**:action 所有路径均返回 `MessageActionReturn`(读取/设置语言设置)。 + +**需要处理的副作用**:`setUiLanguage()` 内调用 `context.ui.reloadCommands()`,在非交互 UI 中已是 no-op,无需额外处理。 + +**变更**: + +- 将父命令及子命令(`ui`、`output`,以及 `SUPPORTED_LANGUAGES` 动态生成的子命令)的 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +- action 无需添加模式分支,现有返回文本已适合机器消费。 + +**ACP 语义说明**:在 non-interactive(单次调用)中执行 `/language ui zh-CN` 会修改持久化设置(写入 settings 文件),该变更对后续 session 生效,本次 session 内 i18n 也立即生效。这与用户预期一致。 + +### 5.2 `/copy` + +**当前状态**:action 调用 `copyToClipboard()`,在 ACP/headless 环境中可能抛出异常或无声失败(clipboard 不可用)。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在 action 内新增模式分支: + +```typescript +// 获取 last AI message(现有逻辑,可复用) +if (context.executionMode !== 'interactive') { + // 非交互/ACP:跳过剪贴板,返回内容本身 + if (!lastAiOutput) { + return { + type: 'message', + messageType: 'info', + content: 'No output in history.', + }; + } + return { + type: 'message', + messageType: 'info', + content: lastAiOutput, + }; +} +// interactive 路径:原有剪贴板逻辑不变 +await copyToClipboard(lastAiOutput); +return { + type: 'message', + messageType: 'info', + content: 'Last output copied to the clipboard', +}; +``` + +**ACP 语义**:IDE 收到最后一条模型输出的原文,可自行决定是否写入剪贴板或展示给用户。 + +### 5.3 `/restore` + +**当前状态**:`supportedModes: ['interactive']`。 + +**设计决策**:快照恢复进一步会重新执行工具调用,语义上与交互界面紧密耦合。经讨论决定保持 `supportedModes: ['interactive']`,不扩展至 non-interactive/acp 模式。 + +**ACP 语义**:checkpoint 的 git 状态恢复和 gemini client history 设置均作为副作用执行;IDE 收到确认消息后可提示用户"状态已恢复",工具重执行由 IDE 自行决定是否触发。 + +--- + +## 6. A' 类:无参数 dialog 路径的 non-interactive 处理 + +### 6.1 `/model` + +**当前状态**: + +| 输入 | 当前行为 | +| -------------------------------- | -------------------------------------------------------------------------------- | +| `/model`(无参数) | → `{ type: 'dialog', dialog: 'model' }`(non-interactive 下变 unsupported) | +| `/model ` | 未实现(只有 `--fast` 分支) | +| `/model --fast`(无 model name) | → `{ type: 'dialog', dialog: 'fast-model' }`(non-interactive 下变 unsupported) | +| `/model --fast ` | → `MessageActionReturn` ✅ | + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在 action 内各 dialog 路径前插入 non-interactive 分支: + +```typescript +// 无参数路径(原返回 dialog: 'model') +if (!args.trim()) { + if (context.executionMode !== 'interactive') { + const currentModel = config.getModel() ?? 'unknown'; + return { + type: 'message', + messageType: 'info', + content: `Current model: ${currentModel}\nUse "/model " to switch models.`, + }; + } + return { type: 'dialog', dialog: 'model' }; +} + +// --fast 无参数路径(原返回 dialog: 'fast-model') +if (args.startsWith('--fast') && !modelName) { + if (context.executionMode !== 'interactive') { + const fastModel = context.services.settings?.merged?.fastModel ?? 'not set'; + return { + type: 'message', + messageType: 'info', + content: `Current fast model: ${fastModel}\nUse "/model --fast " to set fast model.`, + }; + } + return { type: 'dialog', dialog: 'fast-model' }; +} +``` + +**ACP 语义**:IDE 展示当前模型名称,供用户参考;切换模型通过带参数调用实现(`/model `)。 + +> **注意**:`/model `(不带 `--fast`)目前没有实现设置当前 session 模型的逻辑,只有 `--fast ` 有。如果 Phase 2 要支持 ACP 下切换主模型,需要同步实现 `/model ` 的 set 逻辑。本设计预留此路径但标记为 Phase 2 可选项,优先保证"查看当前模型"的 read-only 路径。 + +### 6.2 `/approval-mode` + +**当前状态**: + +| 输入 | 当前行为 | +| -------------------------- | ----------------------------------------------------------------------------------- | +| `/approval-mode`(无参数) | → `{ type: 'dialog', dialog: 'approval-mode' }`(non-interactive 下变 unsupported) | +| `/approval-mode ` | → `MessageActionReturn` ✅ | +| `/approval-mode ` | → `MessageActionReturn`(error)✅ | + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在无参数路径(`!args.trim()`)插入 non-interactive 分支: + +```typescript +if (!args.trim()) { + if (context.executionMode !== 'interactive') { + const currentMode = config?.getApprovalMode() ?? 'unknown'; + return { + type: 'message', + messageType: 'info', + content: `Current approval mode: ${currentMode}\nAvailable modes: ${APPROVAL_MODES.join(', ')}\nUse "/approval-mode " to change.`, + }; + } + return { type: 'dialog', dialog: 'approval-mode' }; +} +``` + +--- + +## 7. B 类:需要完整 non-interactive 分支 + +这五个命令的 action 在 interactive 模式下通过 `context.ui.addItem()` 渲染 React 组件或调用 `context.ui.clear()`,返回值为 `void`。在 non-interactive 中,这些调用均为 no-op,导致 `handleSlashCommand` 将无返回值处理为 `"Command executed successfully."`,无实际内容输出。 + +**实现原则**:在 action **顶部**检查 `executionMode`,非 interactive 时 **提前 return** 包含实际内容的 `message`,interactive 路径代码完全不触碰。 + +### 7.1 `/about`(altName: `status`) + +**数据来源**:`getExtendedSystemInfo(context)` 返回 `ExtendedSystemInfo`,包含:`cliVersion`、`osPlatform`、`osArch`、`osRelease`、`nodeVersion`、`modelVersion`、`selectedAuthType`、`ideClient`、`sessionId`、`memoryUsage`、`baseUrl`、`apiKeyEnvKey`、`gitCommit`、`fastModel`。所有字段在 non-interactive 中均可获取(context.services.config 和 settings 均已注入)。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 在 `getExtendedSystemInfo` 调用后,interactive 路径之前插入模式分支: + +```typescript +action: async (context) => { + const systemInfo = await getExtendedSystemInfo(context); + + if (context.executionMode !== 'interactive') { + const lines = [ + `Qwen Code v${systemInfo.cliVersion}`, + `Model: ${systemInfo.modelVersion}`, + `Fast Model: ${systemInfo.fastModel ?? 'not set'}`, + `Auth: ${systemInfo.selectedAuthType}`, + `Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`, + `Node.js: ${systemInfo.nodeVersion}`, + `Session: ${systemInfo.sessionId}`, + ...(systemInfo.gitCommit ? [`Git commit: ${systemInfo.gitCommit}`] : []), + ...(systemInfo.ideClient ? [`IDE: ${systemInfo.ideClient}`] : []), + ]; + return { + type: 'message', + messageType: 'info', + content: lines.join('\n'), + }; + } + + // interactive 路径:原有 addItem 逻辑不变 + const aboutItem: Omit = { type: MessageType.ABOUT, systemInfo }; + context.ui.addItem(aboutItem, Date.now()); +}, +``` + +### 7.2 `/stats`(及子命令 `model`、`tools`) + +**数据来源**:`context.session.stats`(`SessionStatsState`)包含 `sessionStartTime`、`metrics`(`SessionMetrics`:`models`、`tools`、`files`)、`promptCount`。在 non-interactive 中,`sessionStartTime` 为当前调用时刻,`metrics` 来自 `uiTelemetryService.getMetrics()`(本次调用的累积值,通常为零),`promptCount` 为 1。 + +**变更**: + +1. 将父命令 `stats` 及子命令 `model`、`tools` 的 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 父命令和每个子命令的 action 均插入模式分支,提前返回文本格式统计: + +```typescript +// /stats 主命令 +action: (context) => { + if (context.executionMode !== 'interactive') { + const now = new Date(); + const { sessionStartTime, promptCount, metrics } = context.session.stats; + if (!sessionStartTime) { + return { type: 'message', messageType: 'error', content: 'Session start time unavailable.' }; + } + const wallDuration = now.getTime() - sessionStartTime.getTime(); + + // 汇总所有 model 的 token 数 + let totalPromptTokens = 0, totalCandidateTokens = 0, totalRequests = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalPromptTokens += modelMetrics.tokens.prompt; + totalCandidateTokens += modelMetrics.tokens.candidates; + totalRequests += modelMetrics.api.totalRequests; + } + + const lines = [ + `Session duration: ${formatDuration(wallDuration)}`, + `Prompts: ${promptCount}`, + `API requests: ${totalRequests}`, + `Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`, + `Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`, + `Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`, + ]; + return { type: 'message', messageType: 'info', content: lines.join('\n') }; + } + + // interactive 路径:原有 addItem 逻辑不变 + const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration) }; + context.ui.addItem(statsItem, Date.now()); +}, +``` + +子命令 `model` 和 `tools` 也各自插入模式分支,返回对应维度的文本统计(model 维度按 model name 列出 token 用量;tools 维度列出各 tool 调用次数)。 + +**说明**:在 non-interactive 单次调用中,metrics 通常为零(新 session),但结构完整,不影响格式。ACP Session 中可能有累积值,有实际意义。 + +### 7.3 `/insight` + +**当前状态**:action 返回 `void`,通过 `addItem` 展示进度和结果,最后调用 `open(outputPath)` 打开浏览器。核心逻辑是 `insightGenerator.generateStaticInsight()` 生成 HTML 文件。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 按 `executionMode` 三路分叉: + - `non_interactive`:同步生成,忽略进度回调,不开浏览器,直接返回 `message`(文件路径) + - `acp`:异步启动生成,通过 `stream_messages` 将进度(`encodeInsightProgressMessage`)和完成(`encodeInsightReadyMessage`)推送给 IDE + - `interactive`:原有 `addItem` + `setPendingItem` + `open()` 逻辑不变 + +```typescript +// non_interactive 路径 +if (context.executionMode === 'non_interactive') { + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + () => {}, // no-op progress + ); + return { + type: 'message', + messageType: 'info', + content: t('Insight report generated at: {{path}}', { path: outputPath }), + }; +} + +// acp 路径:stream_messages +if (context.executionMode === 'acp') { + // ... 构造 streamMessages async generator,yield encodeInsightProgressMessage / encodeInsightReadyMessage ... + return { type: 'stream_messages', messages: streamMessages() }; +} + +// interactive 路径:原有实现不变 +``` + +**设计理由**:`non_interactive` 模式(CLI 管道)不支持 `stream_messages`,只能返回单条 `message`;ACP 模式(IDE 插件)能消费 `stream_messages` 并实时展示进度,因此为其保留 streaming 路径。 + +**ACP 消息格式**:`encodeInsightProgressMessage(stage, progress, detail?)` 产生 IDE 可解析的进度条消息;`encodeInsightReadyMessage(outputPath)` 通知 IDE 文件已就绪,由 IDE 决定如何展示链接。 + +### 7.4 `/docs` + +**当前状态**:action 返回 `void`,通过 `addItem` 显示消息并调用 `open(docsUrl)` 打开浏览器。有一个 `SANDBOX` 环境变量分支(沙盒下只 addItem,不开浏览器)。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 修改 action 返回类型为 `Promise`。 +3. 在 action 开头插入 non-interactive 分支: + +```typescript +action: async (context) => { + const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; + const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; + + if (context.executionMode !== 'interactive') { + // 非交互/ACP:直接返回 URL,不打开浏览器,不调用 addItem + return { + type: 'message', + messageType: 'info', + content: `Qwen Code documentation: ${docsUrl}`, + }; + } + + // interactive 路径:原有 SANDBOX 判断 + addItem + open() 不变 + if (process.env['SANDBOX'] && ...) { + context.ui.addItem(...); + } else { + context.ui.addItem(...); + await open(docsUrl); + } +}, +``` + +### 7.5 `/clear`(altNames: `reset`、`new`) + +**当前状态**:action 执行以下操作并返回 `void`: + +1. `config.getHookSystem()?.fireSessionEndEvent()` — 触发 hook(有副作用) +2. `config.startNewSession()` — 开始新 session ID(有副作用) +3. `uiTelemetryService.reset()` — 重置 telemetry 计数器(有副作用) +4. `skillTool.clearLoadedSkills()` — 清除 skill 缓存(有副作用) +5. `context.ui.clear()` — 清空终端 UI(**UI 副作用,non-interactive 下为 no-op**) +6. `geminiClient.resetChat()` — 重置 chat 历史(有副作用) +7. `config.getHookSystem()?.fireSessionStartEvent()` — 触发 hook(有副作用) + +**non-interactive/ACP 语义分析**: + +- `ui.clear()` 在 non-interactive 中已是 no-op,不需要处理 +- `geminiClient.resetChat()`:在 ACP Session 中是有意义的副作用(清空 chat 历史),应保留;在 non-interactive 单次调用中,每次调用都是全新 session,`resetChat` 语义重复但无害 +- `config.startNewSession()`:在 ACP 中有意义(开始新的 session ID);在 non-interactive 单次调用中同样语义重复但无害 +- `fireSessionEndEvent` / `fireSessionStartEvent`:在 ACP 中有意义(触发 hook) + +**决策**:non-interactive/ACP 路径保留所有有意义的副作用(resetChat、startNewSession、hook events),仅跳过 `ui.clear()`(已是 no-op)并返回上下文边界标记 message。 + +**变更**: + +1. 将 `supportedModes` 改为 `['interactive', 'non_interactive', 'acp']`。 +2. 修改 action 返回类型为 `Promise`。 +3. 在 action 内,`context.ui.clear()` 调用后(或替代它)根据模式分支: + +```typescript +action: async (context, _args) => { + const { config } = context.services; + + if (config) { + config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Clear).catch(...); + + const newSessionId = config.startNewSession(); + uiTelemetryService.reset(); + + const skillTool = config.getToolRegistry()?.getAllTools().find(...); + if (skillTool instanceof SkillTool) skillTool.clearLoadedSkills(); + + if (newSessionId && context.session.startNewSession) { + context.session.startNewSession(newSessionId); + } + + // ui.clear() 在非交互下已是 no-op,但依然调用(不需要条件分支) + context.ui.clear(); + + const geminiClient = config.getGeminiClient(); + if (geminiClient) { + await geminiClient.resetChat(); + } + + config.getHookSystem()?.fireSessionStartEvent(...).catch(...); + } else { + context.ui.clear(); + } + + // 根据模式决定返回值 + if (context.executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'info', + content: 'Context cleared. Previous messages are no longer in context.', + }; + } + // interactive 路径:void(不返回,React UI 由 ui.clear() 驱动更新) +}, +``` + +**ACP 语义**:IDE 收到上下文边界标记后,可将其作为 session 分隔符展示(如"新会话开始"提示),并清空本地 chat 历史缓存。 + +--- + +## 8. `handleCommandResult` 变更 + +**结论:无需修改。** + +Phase 2 所有命令变更后,non-interactive/ACP 路径的返回类型均为 `message` 或 `submit_prompt`,均已在 `handleCommandResult` 的 switch 中正确处理。 + +--- + +## 9. `createNonInteractiveUI()` 变更 + +**结论:无需修改。** + +当前 no-op 实现已足够。`addItem`、`clear`、`setPendingItem` 等 no-op 在 B 类命令的 non-interactive 路径中不会被调用(因为提前 return);interactive 路径中不受影响。 + +--- + +## 10. Phase 2.2:prompt command 模型调用打通 + +Phase 1 中 `CommandService.getModelInvocableCommands()` 已实现,`BundledSkillLoader`、`FileCommandLoader`(用户/项目命令)、`McpPromptLoader` 已设置 `modelInvocable: true`。 + +Phase 2.2 的工作是将 `SkillTool` 从只消费 `SkillManager.listSkills()` 改为同时消费 `CommandService.getModelInvocableCommands()`,统一模型可调用命令的入口。 + +**变更文件**:`packages/core/src/tools/SkillTool.ts`(或对应路径) + +**具体变更**: + +1. `SkillTool` 在初始化时接收 `CommandService`(或其 `getModelInvocableCommands()` 的结果)作为依赖注入 +2. 在构建 tool description 时,合并 `listSkills()` 和 `getModelInvocableCommands()` 的结果 +3. 确保 built-in commands(`modelInvocable: false`)不出现在 tool description 中 + +> **注**:`SkillTool` 的具体实现依赖 `packages/core` 内部架构,详细设计在本文档中仅描述接口变更,实现细节需结合 core 包的现有结构确定。 + +--- + +## 11. Phase 2.3:mid-input slash command 检测(基础版) + +在 `InputPrompt` 组件中检测光标附近的 slash token(不限于行首),触发补全菜单。 + +**检测规则**: + +- 当光标前存在以 `/` 开头、不含空格的 token 时,触发命令补全 +- 补全候选来自 `getCommandsForMode('interactive')` 的可见命令列表 +- 补全菜单展示命令名 + description(不含 argumentHint 等,Phase 3 补充) + +> 本功能为 UI 层变更,属于 Phase 2.3 独立子任务,不影响其他 Phase 2.1/2.2 的实施。 + +--- + +## 12. 文件变更总览 + +### 12.1 命令文件变更(Phase 2.1) + +| 文件 | 变更类型 | 具体内容 | +| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| `exportCommand.ts` | A 类 | 父命令 + 4 个子命令:`supportedModes` → all modes | +| `planCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `statuslineCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `languageCommand.ts` | A+ 类 | 父命令 + `ui`/`output` 子命令 + 动态 language 子命令:`supportedModes` → all modes | +| `copyCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `restoreCommand.ts` | 仅交互 | 设计决策:保持 `supportedModes: ['interactive']`,未变更 | +| `modelCommand.ts` | A' 类 | `supportedModes` → all modes + 无参数/无 fast model 路径新增非交互分支 | +| `approvalModeCommand.ts` | A' 类 | `supportedModes` → all modes + 无参数路径新增非交互分支 | +| `aboutCommand.ts` | B 类 | `supportedModes` → all modes + 非交互路径返回 `message`(版本/模型/环境摘要) | +| `statsCommand.ts` | B 类 | `supportedModes` → all modes + 非交互路径返回 `message`(stats 文本);子命令同步处理 | +| `insightCommand.ts` | B 类 | `supportedModes` → all modes + `non_interactive` 路径同步生成返回 `message`(文件路径);`acp` 路径返回 `stream_messages` 带进度推送 | +| `docsCommand.ts` | B 类 | `supportedModes` → all modes + 非交互路径返回 `message`(文档 URL),不打开浏览器 | +| `clearCommand.ts` | B 类 | `supportedModes` → all modes + action 末尾根据模式返回 `message` 或 `void` | + +### 12.2 其他文件变更 + +| 文件 | 变更内容 | +| --------------------------------------------------- | ----------------------------------------------------------------- | +| `packages/core/src/tools/SkillTool.ts` | Phase 2.2:接入 `getModelInvocableCommands()`(详细设计另行确定) | +| `packages/cli/src/ui/InputPrompt.tsx`(或同等组件) | Phase 2.3:mid-input slash 检测逻辑 | + +### 12.3 不变的文件 + +- `packages/cli/src/nonInteractiveCliCommands.ts`(`handleCommandResult`、`handleSlashCommand` 无需修改) +- `packages/cli/src/ui/noninteractive/nonInteractiveUi.ts`(stub UI 无需修改) +- `packages/cli/src/services/commandUtils.ts`(`filterCommandsForMode`、`getEffectiveSupportedModes` 无需修改) +- `packages/cli/src/services/CommandService.ts`(`getCommandsForMode`、`getModelInvocableCommands` 已在 Phase 1 实现) + +--- + +## 13. 测试策略 + +### 13.1 命令单元测试 + +为每个变更的命令在同目录下新增或更新测试文件(`*.test.ts`),覆盖以下 case: + +**A/A+ 类命令**(`export`、`language`): + +- `supportedModes` 正确包含 `non_interactive` 和 `acp` +- 在 `executionMode: 'non_interactive'` 下,action 返回 `MessageActionReturn` 或 `SubmitPromptActionReturn`,不调用 `ui.addItem` 或 `ui.clear` +- Interactive 路径行为与重构前完全一致(快照测试) + +**仅交互命令**(`plan`、`statusline`、`copy`、`restore`): + +- `supportedModes` 为 `['interactive']`,这是设计决策 +- 验证 non-interactive 下执行时正确返回 `unsupported` + +**A' 类命令**(`model`、`approval-mode`): + +- 无参数 + `executionMode: 'non_interactive'` → 返回当前状态 `message`,不返回 `dialog` +- 有参数 + `executionMode: 'non_interactive'` → 原有 `message` 逻辑正常执行 +- Interactive 路径:无参数 → `dialog`,有参数 → `message`(不变) + +**B 类命令**(`about`、`stats`、`insight`、`docs`、`clear`): + +- `executionMode: 'non_interactive'` 下,action 返回 `MessageActionReturn`,不调用任何 `ui.*` 方法 +- 返回的 `content` 字符串包含预期的关键字段(版本号、模型名、URL 等) +- Interactive 路径:`ui.addItem` 被调用,`action` 返回 `void`(不变) + +**`clear` 的特殊 case**: + +- `executionMode: 'non_interactive'` 下,`geminiClient.resetChat()` 仍被调用(副作用保留) +- 返回上下文边界 `message`,内容为 `'Context cleared. Previous messages are no longer in context.'` + +### 13.2 集成测试(`handleSlashCommand`) + +在 `nonInteractiveCli.test.ts` 或新建的集成测试文件中: + +- `handleSlashCommand('/about', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 包含版本号 }` +- `handleSlashCommand('/stats', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 包含 'Session duration' }` +- `handleSlashCommand('/docs', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 包含 'qwenlm.github.io' }` +- `handleSlashCommand('/clear', ...)` 在 non-interactive 模式下返回 `{ type: 'message', content: 'Context cleared.' }` +- `handleSlashCommand('/plan', ...)` 在 non-interactive 模式下返回 `unsupported`(仅交互命令) +- 现有 non-interactive 命令(`btw`、`bug` 等)行为无退化 + +### 13.3 `commandUtils` 测试 + +`commandUtils.test.ts` 中新增(或已有的测试继续覆盖): + +- 扩展后的命令(`export`、`language` 等)均能通过 `filterCommandsForMode(commands, 'non_interactive')` 和 `filterCommandsForMode(commands, 'acp')` 的过滤 +- 仅交互命令(`plan`、`statusline`、`copy`、`restore`)在 `filterCommandsForMode(commands, 'non_interactive')` 下被正确过滤掉 + +--- + +## 14. 行为影响分析 + +| 场景 | Phase 2 前行为 | Phase 2 后行为 | 性质 | +| -------------------------------------------- | --------------------------------------------------------- | ---------------------------------- | ------------------ | +| non-interactive 下执行 `/export md` | ❌ unsupported(被过滤) | ✅ 返回文件路径 message | 能力扩展 | +| non-interactive 下执行 `/plan ` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/statusline` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/language ui zh-CN` | ❌ unsupported | ✅ 设置语言,返回确认 message | 能力扩展 | +| non-interactive 下执行 `/copy` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/restore`(无参数) | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/restore ` | ❌ unsupported | ❌ unsupported(设计决策:仅交互) | 不变 | +| non-interactive 下执行 `/model` | ❌ unsupported(dialog) | ✅ 返回当前模型名称 | 能力扩展 | +| non-interactive 下执行 `/model ` | ❌ unsupported | 🔄 Phase 2 可选:实现切换逻辑 | 能力扩展(可选) | +| non-interactive 下执行 `/approval-mode` | ❌ unsupported(dialog) | ✅ 返回当前审批模式 | 能力扩展 | +| non-interactive 下执行 `/approval-mode yolo` | ❌ unsupported | ✅ 设置模式,返回确认 | 能力扩展 | +| non-interactive 下执行 `/about` | ❌ 返回 "Command executed successfully."(addItem no-op) | ✅ 返回版本/模型/环境摘要 | Bug fix + 能力扩展 | +| non-interactive 下执行 `/stats` | ❌ 返回 "Command executed successfully." | ✅ 返回 session 统计文本 | Bug fix + 能力扩展 | +| non-interactive 下执行 `/insight` | ❌ 返回 "Command executed successfully."(生成但无输出) | ✅ 生成并返回文件路径 | Bug fix + 能力扩展 | +| non-interactive 下执行 `/docs` | ❌ 返回 "Command executed successfully." | ✅ 返回文档 URL | Bug fix + 能力扩展 | +| non-interactive 下执行 `/clear` | ❌ 返回 "Command executed successfully." | ✅ 返回上下文边界 message | Bug fix + 能力扩展 | +| interactive 下执行任意以上命令 | ✅ 原有行为 | ✅ 原有行为(零退化) | 不变 | + +--- + +## 15. 实施顺序 + +建议按以下顺序实施,每组可独立 commit 和 review: + +**Batch 1**(~30min):A 类 — 只改 `supportedModes` + +修改 `exportCommand.ts`(及其子命令),验证测试通过。 + +**Batch 2**(~45min):A+ 类 — 少量分支 + +修改 `languageCommand.ts`,为有副作用的路径添加非交互分支,更新对应测试。(`copyCommand.ts` 和 `restoreCommand.ts` 经讨论保持仅交互。) + +**Batch 3**(~45min):A' 类 — dialog 路径 + +修改 `modelCommand.ts`、`approvalModeCommand.ts`,为无参数路径添加非交互分支,更新对应测试。 + +**Batch 4**(~1.5h):B 类 — 完整分支 + +修改 `aboutCommand.ts`、`statsCommand.ts`(含子命令)、`docsCommand.ts`。 + +**Batch 5**(~1h):B 类特殊 — `insightCommand.ts`、`clearCommand.ts` + +这两个命令副作用较多,单独一个 commit,更新对应测试和集成测试。 + +**Batch 6**(~2h):Phase 2.2 — prompt command 模型调用打通 + +修改 `SkillTool`,接入 `getModelInvocableCommands()`,更新 SkillTool 测试。 + +**Batch 7**(~2h):Phase 2.3 — mid-input slash 检测 + +修改 `InputPrompt` 组件,新增补全触发逻辑和 UI 测试。 + +**Batch 8**(~30min):全量测试 + 类型检查 + +运行 `npm run typecheck`、`cd packages/cli && npx vitest run`,修复剩余问题。 + +--- + +## 16. 验收 Checklist + +**Phase 2.1 命令扩展** + +- [ ] A 类:`/export`(及子命令)、`/plan`、`/statusline` 在 non-interactive 和 acp 模式下可正常执行并返回有意义输出 +- [ ] A+ 类:`/language`(及子命令)在 non-interactive 下正常执行,设置持久化 +- [ ] A+ 类:`/copy` 在 non-interactive/acp 下返回最后 AI 输出文本(不操作剪贴板) +- [ ] A+ 类:`/restore` 无参数时在 non-interactive 下返回 checkpoint 列表;有参数时恢复状态并返回确认 message(不返回 `type: 'tool'`) +- [ ] A' 类:`/model` 无参数时在 non-interactive/acp 下返回当前模型名(不触发 dialog);`/model --fast ` 正常设置 +- [ ] A' 类:`/approval-mode` 无参数时在 non-interactive/acp 下返回当前模式(不触发 dialog);有参数时正常设置 +- [ ] B 类:`/about` 在 non-interactive/acp 下返回包含版本号、模型名的纯文本摘要 +- [ ] B 类:`/stats`(含子命令)在 non-interactive/acp 下返回纯文本统计数据 +- [ ] B 类:`/insight` 在 non-interactive/acp 下生成 insight 文件并返回文件路径(不打开浏览器) +- [ ] B 类:`/docs` 在 non-interactive/acp 下返回文档 URL(不打开浏览器) +- [ ] B 类:`/clear` 在 non-interactive/acp 下返回上下文边界标记 message,`geminiClient.resetChat()` 正常执行 +- [ ] 所有 13 个命令在 interactive 模式下行为与重构前完全一致(无退化) +- [ ] TypeScript 编译无错误(`npm run typecheck`) +- [ ] `npm run lint` 无新增错误 +- [ ] 所有现有测试通过(`cd packages/cli && npx vitest run`) + +**Phase 2.2 模型调用** + +- [ ] 模型在对话中可以通过 `SkillTool` 调用 bundled skill、file command(用户/项目)、MCP prompt +- [ ] 模型不可以调用 built-in commands +- [ ] `SkillTool` 的 tool description 包含所有 `modelInvocable: true` 命令的名称和 description + +**Phase 2.3 mid-input slash** + +- [ ] 在输入框正文中输入 `/` 后触发命令补全菜单(不限行首) +- [ ] 补全菜单展示命令名 + description +- [ ] 补全选中后正确填充到输入框 diff --git a/docs/design/slash-command/roadmap.md b/docs/design/slash-command/roadmap.md index b62e79ff1..106778327 100644 --- a/docs/design/slash-command/roadmap.md +++ b/docs/design/slash-command/roadmap.md @@ -107,52 +107,91 @@ #### 2.1 扩展 non-interactive / acp 可用命令集 -将以下命令的 `supportedModes` 扩展到包含 `non_interactive` 和 `acp`,并确保其 action 实现可在无 UI 环境运行: +**ACP 语义设计原则** -**直接可扩展**(action 已无 UI 依赖): +将命令扩展到 ACP/non-interactive 模式前,需遵循以下设计原则: -- `/export`:文件 I/O,返回 `message` -- `/memory`:文件 I/O,返回 `message` -- `/plan`:返回 `submit_prompt` -- `/tools`:改为返回 `message`(文本列表,替换 UI 渲染) -- `/stats`:改为返回 `message`(文本格式,替换 UI 渲染) +1. **接收方不同**:ACP 模式下消息的接收方是 IDE(Zed/VS Code 插件),而非终端用户。消息内容以纯文本或 Markdown 格式为宜,不应包含 terminal 专用的 ANSI 样式。 +2. **实现策略是增加模式分支,而非替换**:正确做法是在命令的 `action` 内部新增模式判断——interactive 路径保持现有 UI 渲染逻辑不变,non_interactive/acp 路径返回适合机器消费的 `message` 或 `submit_prompt`。两条路径共存于同一个 `action` 函数中。 +3. **有状态操作需说明语义**:在单次非交互调用中(如 CLI `-p` 参数),`/model set`、`/language set` 等有状态命令的变更仅在本次 session 内有效,应在命令响应文本中注明。 +4. **只读 vs 有副作用**:只读命令(如 `/about`、`/stats`)直接返回当前状态文本;有副作用命令(如 `/model set`、`/language set`)需在响应中确认操作结果。 +5. **避免环境相关副作用**:打开浏览器(`/docs`、`/insight`)、操作剪贴板(`/copy`)等依赖图形环境的操作,在 non_interactive/acp 路径下应跳过,改为在响应文本中返回相关 URL 或内容本身。 -**需要 local 子命令拆分**(当前只有 `local-jsx` 壳): +**待扩展命令总览** -| 命令 | 新增的 local 子命令 | -| -------------- | ----------------------------------------------------------------------------- | -| `/model` | `show`(当前模型)、`list`(可选列表)、`set `(切换) | -| `/permissions` | `show`(当前权限模式)、`set `(设置) | -| `/mcp` | `list`(MCP 服务列表)、`show `(服务详情)、`status`(所有服务状态) | -| `/memory` | 已有 `show`/`add`/`refresh`(确认 non-interactive 下可用) | +> 注:`btw`、`bug`、`compress`、`context`、`init`、`summary` 已在 Phase 1 中扩展到全模式,不在本阶段列表中。 -> **注意**:上述 UI 壳命令不会被删除,`/model` 不带子命令时仍然打开 dialog(interactive 模式)。新增子命令是 **在现有命令上追加**,不是替换。 +以下 13 个命令将在 Phase 2 中扩展到 `non_interactive` 和 `acp` 模式: + +**A 类:action 已返回 `message` 或 `submit_prompt`,只需扩展 `supportedModes` 并设计 ACP 消息内容** + +| 命令 | 返回类型 | ACP/non-interactive 处理要点 | +| ------------- | --------------- | -------------------------------------------------- | +| `/copy` | `message` | ACP 下无剪贴板,改为在响应文本中返回内容本身或提示 | +| `/export` | `message` | 返回导出文件的完整路径 | +| `/plan` | `submit_prompt` | 无需改动,直接扩展模式 | +| `/restore` | `message` | 返回恢复操作的结果描述 | +| `/language` | `message` | 返回当前语言设置或变更确认文本 | +| `/statusline` | `submit_prompt` | 无需改动,直接扩展模式 | + +**A' 类:有参数时正常执行,无参数时触发 dialog(需增加无参数路径的 non-interactive 处理)** + +| 命令 | 无参数 interactive 行为 | 无参数 non_interactive/acp 行为 | +| ---------------- | ----------------------- | ------------------------------- | +| `/model` | 打开模型选择 dialog | 返回当前模型名称及说明文本 | +| `/approval-mode` | 打开审批模式 dialog | 返回当前审批模式及说明文本 | + +**B 类:action 内部使用 `context.ui.addItem()` 渲染 React 组件,需增加模式分支返回纯文本** + +| 命令 | interactive 行为 | non_interactive/acp 返回内容 | +| ---------- | ------------------------- | ----------------------------------------------------------------------------------- | +| `/about` | 渲染版本/配置 React 组件 | 版本号、当前模型、关键配置的纯文本摘要 | +| `/stats` | 渲染 token/费用统计组件 | session 统计数据的纯文本格式 | +| `/insight` | 渲染分析组件 + 打开浏览器 | `non_interactive` 同步生成返回文件路径;`acp` 通过 `stream_messages` 推送进度和结果 | +| `/docs` | 渲染文档入口 + 打开浏览器 | 返回文档 URL,不打开浏览器 | + +**C 类:特殊处理** + +| 命令 | interactive 行为 | non_interactive/acp 行为 | +| -------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- | +| `/clear` | 调用 `context.ui.clear()` 清空终端显示 | 返回上下文边界标记 message,内容为 `"Context cleared. Previous messages are no longer in context."` | #### 2.2 prompt command 模型调用打通 - 在 `CommandService`(或 `CommandRegistry`)中实现 `getModelInvocableCommands()`,返回所有 `modelInvocable: true` 的命令 -- 将 `BundledSkillLoader`、`FileCommandLoader`(用户/项目命令)、`McpPromptLoader` 加载的命令标记为 `modelInvocable: true` +- 将 `BundledSkillLoader`、`FileCommandLoader`(用户/项目命令)加载的命令标记为 `modelInvocable: true` +- **MCP prompt 不标记为 `modelInvocable`**:MCP prompt 通过独立的 MCP tool call 机制由模型调用,无需经过 `SkillTool` 中转 - 改造 `SkillTool`:从只消费 `SkillManager.listSkills()` 改为同时消费 `CommandService.getModelInvocableCommands()` - 构建统一的模型可调用命令描述,注入 `SkillTool` 的 description #### 2.3 mid-input slash command 检测(基础版) - 在 `InputPrompt` 中检测光标附近的 slash token(不限于行首) -- 检测到 slash token 后触发补全菜单(展示命令名 + description) -- 补全菜单弹出位置跟随光标 -- **不**包含 argument hints、source badge 等(Phase 3 做) +- 检测到 slash token 后通过 inline ghost text 提示最佳匹配命令名(Tab 接受) +- **不**包含 dropdown 补全菜单、argument hints、source badge 等(Phase 3 做) +- ghost text 候选集仅限 `modelInvocable: true` 的命令(skill / file command) ### 验收标准 -- [ ] `/export`、`/memory`、`/plan`、`/tools`、`/stats` 在 non-interactive 模式下可正常执行并返回结构化输出 -- [ ] `/model show`、`/model set ` 在 non-interactive / acp 下可执行 -- [ ] `/permissions show`、`/permissions set ` 在 non-interactive / acp 下可执行 -- [ ] `/mcp list`、`/mcp show ` 在 non-interactive / acp 下可执行 -- [ ] 模型在对话中可以通过 `SkillTool` 调用 bundled skill、file command(用户/项目)、MCP prompt +**2.1 命令扩展** + +- [ ] A 类:`/copy`、`/export`、`/plan`、`/restore`、`/language`、`/statusline` 在 non-interactive 和 acp 模式下可正常执行并返回有意义的文本输出 +- [ ] A' 类:`/model`、`/approval-mode` 无参数时在 non-interactive/acp 下返回当前状态文本(不触发 dialog);有参数时执行变更并返回确认文本 +- [ ] B 类:`/about`、`/stats`、`/docs` 在 non-interactive/acp 下返回纯文本,`/docs` 不打开浏览器;`/insight` 在 `non_interactive` 下同步生成并返回文件路径 message,在 `acp` 下通过 `stream_messages` 推送进度 +- [ ] C 类:`/clear` 在 non-interactive/acp 下返回上下文边界标记 message,不调用 `context.ui.clear()` +- [ ] 所有扩展命令在 interactive 模式下行为与重构前完全一致(无退化) + +**2.2 模型调用** + +- [ ] 模型在对话中可以通过 `SkillTool` 调用 bundled skill、file command(用户/项目) +- [ ] MCP prompt 不经过 `SkillTool`,通过 MCP tool call 机制由模型原生调用 - [ ] 模型不可以调用 built-in commands(`userInvocable: true`,`modelInvocable: false`) -- [ ] mid-input slash:在正文中输入 `/` 后触发命令补全菜单 - [ ] `SkillTool` 的 description 包含所有 `modelInvocable` 命令的描述 +**2.3 mid-input slash** + +- [ ] mid-input slash:在正文中输入 `/` 后通过 inline ghost text 提示最佳匹配命令(Tab 接受) + --- ## Phase 3:体验对齐(补全增强 + Claude Code 命令补齐) diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 449974285..6dc6d1d02 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -77,15 +77,16 @@ Settings are organized into categories. All settings should be placed within the #### general -| Setting | Type | Description | Default | -| ------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | -| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | -| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | -| `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` | -| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | -| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | -| `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | +| Setting | Type | Description | Default | +| ------------------------------------------ | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `general.preferredEditor` | string | The preferred editor to open files in. | `undefined` | +| `general.vimMode` | boolean | Enable Vim keybindings. | `false` | +| `general.enableAutoUpdate` | boolean | Enable automatic update checks and installations on startup. | `true` | +| `general.showSessionRecap` | boolean | Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use `/recap` to trigger manually regardless of this setting. | `false` | +| `general.sessionRecapAwayThresholdMinutes` | number | Minutes the terminal must be blurred before an auto-recap fires on focus-in. Only used when `showSessionRecap` is enabled. | `5` | +| `general.gitCoAuthor` | boolean | Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code. | `true` | +| `general.checkpointing.enabled` | boolean | Enable session checkpointing for recovery. | `false` | +| `general.defaultFileEncoding` | string | Default encoding for new files. Use `"utf-8"` (default) for UTF-8 without BOM, or `"utf-8-bom"` for UTF-8 with BOM. Only change this if your project specifically requires BOM. | `"utf-8"` | #### output @@ -108,6 +109,7 @@ Settings are organized into categories. All settings should be placed within the | `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | | `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` | +| `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | | `enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` | | `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` | | `ui.accessibility.screenReader` | boolean | Enables screen reader mode, which adjusts the TUI for better compatibility with screen readers. | `false` | @@ -552,25 +554,26 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe ### Environment Variables Table -| Variable | Description | Notes | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | -| `QWEN_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | -| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | -| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | -| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | -| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | -| `QWEN_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | -| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | -| `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | -| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | -| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | -| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | -| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | -| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | -| `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | -| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | -| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | +| Variable | Description | Notes | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `QWEN_TELEMETRY_ENABLED` | Set to `true` or `1` to enable telemetry. Any other value is treated as disabling it. | Overrides the `telemetry.enabled` setting. | +| `QWEN_TELEMETRY_TARGET` | Sets the telemetry target (`local` or `gcp`). | Overrides the `telemetry.target` setting. | +| `QWEN_TELEMETRY_OTLP_ENDPOINT` | Sets the OTLP endpoint for telemetry. | Overrides the `telemetry.otlpEndpoint` setting. | +| `QWEN_TELEMETRY_OTLP_PROTOCOL` | Sets the OTLP protocol (`grpc` or `http`). | Overrides the `telemetry.otlpProtocol` setting. | +| `QWEN_TELEMETRY_LOG_PROMPTS` | Set to `true` or `1` to enable or disable logging of user prompts. Any other value is treated as disabling it. | Overrides the `telemetry.logPrompts` setting. | +| `QWEN_TELEMETRY_OUTFILE` | Sets the file path to write telemetry to when the target is `local`. | Overrides the `telemetry.outfile` setting. | +| `QWEN_TELEMETRY_USE_COLLECTOR` | Set to `true` or `1` to enable or disable using an external OTLP collector. Any other value is treated as disabling it. | Overrides the `telemetry.useCollector` setting. | +| `QWEN_SANDBOX` | Alternative to the `sandbox` setting in `settings.json`. | Accepts `true`, `false`, `docker`, `podman`, or a custom command string. | +| `QWEN_SANDBOX_IMAGE` | Overrides sandbox image selection for Docker/Podman. | Takes precedence over `tools.sandboxImage`. | +| `SEATBELT_PROFILE` | (macOS specific) Switches the Seatbelt (`sandbox-exec`) profile on macOS. | `permissive-open`: (Default) Restricts writes to the project folder (and a few other folders, see `packages/cli/src/utils/sandbox-macos-permissive-open.sb`) but allows other operations. `strict`: Uses a strict profile that declines operations by default. ``: Uses a custom profile. To define a custom profile, create a file named `sandbox-macos-.sb` in your project's `.qwen/` directory (e.g., `my-project/.qwen/sandbox-macos-custom.sb`). | +| `DEBUG` or `DEBUG_MODE` | (often used by underlying libraries or the CLI itself) Set to `true` or `1` to enable verbose debug logging, which can be helpful for troubleshooting. | **Note:** These variables are automatically excluded from project `.env` files by default to prevent interference with the CLI behavior. Use `.qwen/.env` files if you need to set these for Qwen Code specifically. | +| `NO_COLOR` | Set to any value to disable all color output in the CLI. | | +| `CLI_TITLE` | Set to a string to customize the title of the CLI. | | +| `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | +| `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | +| `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | +| `QWEN_CODE_UNATTENDED_RETRY` | Set to `true` or `1` to enable persistent retry mode. When enabled, transient API capacity errors (HTTP 429 Rate Limit and 529 Overloaded) are retried indefinitely with exponential backoff (capped at 5 minutes per retry) and heartbeat keepalives every 30 seconds on stderr. | Designed for CI/CD pipelines and background automation where long-running tasks should survive temporary API outages. Must be set explicitly — `CI=true` alone does **not** activate this mode. See [Headless Mode](../features/headless#persistent-retry-mode) for details. Example: `export QWEN_CODE_UNATTENDED_RETRY=1` | +| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | ## Command-Line Arguments diff --git a/docs/users/features/arena.md b/docs/users/features/arena.md index 7b53238c7..67c879f9a 100644 --- a/docs/users/features/arena.md +++ b/docs/users/features/arena.md @@ -90,8 +90,9 @@ When all agents complete, the Arena enters the result comparison phase. You'll s - **Status summary**: Which agents succeeded, failed, or were cancelled - **Execution metrics**: Duration, rounds of reasoning, token usage, and tool call counts for each agent +- **Arena comparison summary**: Files changed in common vs. by one agent only, line-change counts, token efficiency, and a high-level approach summary generated from each agent's diff, metrics, and conversation history -A selection dialog presents the successful agents. Choose one to apply its changes to your main workspace, or discard all results. +A selection dialog presents the successful agents. Choose one to apply its changes to your main workspace, or discard all results. Press `p` to toggle a quick preview for the highlighted agent, or `d` to toggle that agent's detailed diff before selecting a winner. ### What happens when you select a winner @@ -99,7 +100,7 @@ A selection dialog presents the successful agents. Choose one to apply its chang 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. +If you want to inspect the complete reasoning path before deciding, each agent's full conversation history is still available via the tab bar while the selection dialog is active. ## Configuration diff --git a/docs/users/features/headless.md b/docs/users/features/headless.md index 12172f121..e6e0492d5 100644 --- a/docs/users/features/headless.md +++ b/docs/users/features/headless.md @@ -310,6 +310,67 @@ echo "Recent usage trends:" tail -5 usage.log ``` +## Persistent Retry Mode + +When Qwen Code runs in CI/CD pipelines or as a background daemon, a brief API outage (rate limiting or overload) should not kill a multi-hour task. **Persistent retry mode** makes Qwen Code retry transient API errors indefinitely until the service recovers. + +### How it works + +- **Transient errors only**: HTTP 429 (Rate Limit) and 529 (Overloaded) are retried indefinitely. Other errors (400, 500, etc.) still fail normally. +- **Exponential backoff with cap**: Retry delays grow exponentially but are capped at **5 minutes** per retry. +- **Heartbeat keepalive**: During long waits, a status line is printed to stderr every **30 seconds** to prevent CI runners from killing the process due to inactivity. +- **Graceful degradation**: Non-transient errors and interactive mode are completely unaffected. + +### Activation + +Set the `QWEN_CODE_UNATTENDED_RETRY` environment variable to `true` or `1` (strict match, case-sensitive): + +```bash +export QWEN_CODE_UNATTENDED_RETRY=1 +``` + +> [!important] +> Persistent retry requires an **explicit opt-in**. `CI=true` alone does **not** activate it — silently turning a fast-fail CI job into an infinite-wait job would be dangerous. Always set `QWEN_CODE_UNATTENDED_RETRY` explicitly in your pipeline configuration. + +### Examples + +#### GitHub Actions + +```yaml +- name: Automated code review + env: + QWEN_CODE_UNATTENDED_RETRY: '1' + run: | + qwen -p "Review all files in src/ for security issues" \ + --output-format json \ + --yolo > review.json +``` + +#### Overnight batch processing + +```bash +export QWEN_CODE_UNATTENDED_RETRY=1 +qwen -p "Migrate all callback-style functions to async/await in src/" --yolo +``` + +#### Background daemon + +```bash +QWEN_CODE_UNATTENDED_RETRY=1 nohup qwen -p "Audit all dependencies for known CVEs" \ + --output-format json > audit.json 2> audit.log & +``` + +### Monitoring + +During persistent retry, heartbeat messages are printed to **stderr**: + +``` +[qwen-code] Waiting for API capacity... attempt 3, retry in 45s +[qwen-code] Waiting for API capacity... attempt 3, retry in 15s +``` + +These messages keep CI runners alive and let you monitor progress. They do not appear in stdout, so JSON output piped to other tools remains clean. + ## Resources - [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide diff --git a/eslint.config.js b/eslint.config.js index c7638b82c..27214f6d6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -168,6 +168,11 @@ export default tseslint.config( plugins: { vitest, }, + languageOptions: { + globals: { + ...globals.vitest, + }, + }, rules: { ...vitest.configs.recommended.rules, 'vitest/expect-expect': 'off', diff --git a/integration-tests/cli/settings-migration.test.ts b/integration-tests/cli/settings-migration.test.ts index 3be7cee24..181dcb2ad 100644 --- a/integration-tests/cli/settings-migration.test.ts +++ b/integration-tests/cli/settings-migration.test.ts @@ -83,10 +83,10 @@ describe('settings-migration', () => { // Write V1 settings directly (overwrites the one created by setup) overwriteSettingsFile(rig, v1Settings); - // Run CLI with --help to trigger migration without API calls - // We expect this to fail due to missing API key, but migration should still occur + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls. + // `--help` is intentionally side-effect-free and does not load settings. try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail, we just need the settings file to be processed } @@ -126,9 +126,9 @@ describe('settings-migration', () => { // Use fixture with arrays, null values, and string booleans overwriteSettingsFile(rig, v1ArrayAndNullSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -151,9 +151,9 @@ describe('settings-migration', () => { // Use fixture where V1 flat keys (ui, general) conflict with V2/V3 nested structure overwriteSettingsFile(rig, v1ParentCollisionSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -178,9 +178,9 @@ describe('settings-migration', () => { // Use fixture with $version as string and string boolean values overwriteSettingsFile(rig, v1VersionStringSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -215,9 +215,9 @@ describe('settings-migration', () => { // Write V2 settings directly (overwrites the one created by setup) overwriteSettingsFile(rig, v2Settings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -292,9 +292,9 @@ describe('settings-migration', () => { overwriteSettingsFile(rig, cleanV2Settings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -320,9 +320,9 @@ describe('settings-migration', () => { overwriteSettingsFile(rig, legacyVersionWithoutMigratableKeys); - // Run CLI with --help to trigger settings load/write path + // Run CLI with `mcp list` to trigger settings load/write path try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -361,9 +361,9 @@ describe('settings-migration', () => { }; overwriteSettingsFile(rig, mixedNonBooleanDisableSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -426,9 +426,9 @@ describe('settings-migration', () => { // Use fixture with both disable* and enable* keys overwriteSettingsFile(rig, v2PreexistingEnableSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -491,9 +491,9 @@ describe('settings-migration', () => { // Use fixture with V3 format but still has legacy disable* keys overwriteSettingsFile(rig, v3LegacyDisableSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -545,9 +545,9 @@ describe('settings-migration', () => { // Use fixture with future version ($version: 999) overwriteSettingsFile(rig, v999FutureVersionSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -571,23 +571,23 @@ describe('settings-migration', () => { overwriteSettingsFile(rig, v1Settings); - // Run CLI multiple times with --help + // Run CLI multiple times with `mcp list` try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const firstRunSettings = readSettingsFile(rig); try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } const secondRunSettings = readSettingsFile(rig); try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } @@ -606,9 +606,9 @@ describe('settings-migration', () => { // Use v1ComplexSettings fixture which has custom user settings overwriteSettingsFile(rig, v1ComplexSettings); - // Run CLI with --help to trigger migration without API calls + // Run CLI with `mcp list` to trigger loadSettings() + migration without API calls try { - await rig.runCommand(['--help']); + await rig.runCommand(['mcp', 'list']); } catch { // Expected to potentially fail } diff --git a/package-lock.json b/package-lock.json index 9c18517c6..98747357c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.14.5", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.14.5", + "version": "0.15.0", "workspaces": [ "packages/*", "packages/channels/base", @@ -236,7 +236,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -712,7 +711,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -736,7 +734,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2178,7 +2175,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3601,7 +3597,6 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4073,7 +4068,6 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4084,7 +4078,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4290,7 +4283,6 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4536,7 +4528,6 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -4687,7 +4678,6 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4861,7 +4851,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5276,7 +5265,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/array-includes": { "version": "3.1.9", @@ -5824,7 +5814,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6485,6 +6474,7 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7562,7 +7552,6 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8267,6 +8256,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8328,6 +8318,7 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -8337,6 +8328,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8346,6 +8338,7 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -8553,6 +8546,7 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", + "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8571,6 +8565,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", + "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8579,13 +8574,15 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -9642,7 +9639,6 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -10620,7 +10616,6 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11500,6 +11495,7 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -12682,7 +12678,8 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/path-type": { "version": "3.0.0", @@ -12845,6 +12842,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -12879,7 +12877,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13039,7 +13036,6 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13355,7 +13351,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13366,7 +13361,6 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13444,7 +13438,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14628,7 +14621,6 @@ "integrity": "sha512-fIQnFtpksRRgHR1CO1onGX3djaog4qsW/c5U8arqYTkUEr2TaWpn05mIJDOBoPJFlOdqFrB4Ttv0PZJxV7avhw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -15317,7 +15309,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15517,8 +15508,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -15526,7 +15516,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15685,7 +15674,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16009,6 +15997,7 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16051,7 +16040,6 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16165,7 +16153,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16179,7 +16166,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16698,7 +16684,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16869,14 +16854,13 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, "packages/channels/base": { "name": "@qwen-code/channel-base", - "version": "0.14.5", + "version": "0.15.0", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1" }, @@ -16886,7 +16870,7 @@ }, "packages/channels/dingtalk": { "name": "@qwen-code/channel-dingtalk", - "version": "0.14.5", + "version": "0.15.0", "dependencies": { "@qwen-code/channel-base": "file:../base", "dingtalk-stream-sdk-nodejs": "^2.0.4" @@ -16897,7 +16881,7 @@ }, "packages/channels/plugin-example": { "name": "@qwen-code/channel-plugin-example", - "version": "0.14.5", + "version": "0.15.0", "dependencies": { "@qwen-code/channel-base": "file:../base", "ws": "^8.18.0" @@ -16911,7 +16895,7 @@ }, "packages/channels/telegram": { "name": "@qwen-code/channel-telegram", - "version": "0.14.5", + "version": "0.15.0", "dependencies": { "@qwen-code/channel-base": "file:../base", "grammy": "^1.41.1", @@ -16924,7 +16908,7 @@ }, "packages/channels/weixin": { "name": "@qwen-code/channel-weixin", - "version": "0.14.5", + "version": "0.15.0", "dependencies": { "@qwen-code/channel-base": "file:../base" }, @@ -16934,7 +16918,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.14.5", + "version": "0.15.0", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", @@ -17040,7 +17024,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -17594,7 +17577,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.14.5", + "version": "0.15.0", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -17699,7 +17682,6 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", - "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -18094,7 +18076,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -18857,7 +18838,6 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -19338,7 +19318,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -20465,7 +20444,6 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -21043,7 +21021,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.14.5", + "version": "0.15.0", "license": "LICENSE", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -21290,7 +21268,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.14.5", + "version": "0.15.0", "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -21702,7 +21680,6 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -21819,7 +21796,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.14.5", + "version": "0.15.0", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" @@ -22676,7 +22653,6 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22691,7 +22667,6 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/package.json b/package.json index b4260277d..ad3892ac8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.14.5", + "version": "0.15.0", "engines": { "node": ">=20.0.0" }, @@ -18,7 +18,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.14.5" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.0" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json index ab46ce467..4d193ed0b 100644 --- a/packages/channels/base/package.json +++ b/packages/channels/base/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-base", - "version": "0.14.5", + "version": "0.15.0", "description": "Base channel infrastructure for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json index 34b11eeee..c97719d1f 100644 --- a/packages/channels/dingtalk/package.json +++ b/packages/channels/dingtalk/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-dingtalk", - "version": "0.14.5", + "version": "0.15.0", "description": "DingTalk channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json index 9763a6a98..6265a1d72 100644 --- a/packages/channels/plugin-example/package.json +++ b/packages/channels/plugin-example/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-plugin-example", - "version": "0.14.5", + "version": "0.15.0", "private": true, "type": "module", "main": "dist/index.js", diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json index 09a855a4e..5016c57a0 100644 --- a/packages/channels/telegram/package.json +++ b/packages/channels/telegram/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-telegram", - "version": "0.14.5", + "version": "0.15.0", "description": "Telegram channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json index 927b8d613..d980edc77 100644 --- a/packages/channels/weixin/package.json +++ b/packages/channels/weixin/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/channel-weixin", - "version": "0.14.5", + "version": "0.15.0", "description": "WeChat (Weixin) channel adapter for Qwen Code", "type": "module", "main": "dist/index.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index cdf7647eb..cc6d5d578 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.14.5", + "version": "0.15.0", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.14.5" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.15.0" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index ee79e0d59..e40378bf9 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -14,6 +14,7 @@ import { qwenOAuth2Events, MCPServerConfig, SessionService, + SESSION_TITLE_MAX_LENGTH, tokenLimit, type Config, type ConversationRecord, @@ -296,17 +297,29 @@ class QwenAgent implements Agent { ): Promise { const cwd = params.cwd || process.cwd(); const numericCursor = params.cursor ? Number(params.cursor) : undefined; + + // The ACP spec's ListSessionsRequest doesn't include a page-size field, + // so the SDK's zod validator strips any top-level `size` the client sends + // before it reaches this handler. Carry page size through `_meta.size` + // (same pattern filesystem.ts uses for `_meta.bom` / `_meta.encoding`). + const metaSize = params._meta?.['size']; + const size = + typeof metaSize === 'number' && metaSize > 0 + ? Math.floor(metaSize) + : undefined; + const result = await runWithAcpRuntimeOutputDir(this.settings, cwd, () => { const sessionService = new SessionService(cwd); return sessionService.listSessions({ cursor: Number.isNaN(numericCursor) ? undefined : numericCursor, + size, }); }); const sessions: SessionInfo[] = result.items.map((item) => ({ cwd: item.cwd, sessionId: item.sessionId, - title: item.prompt || '(session)', + title: item.customTitle || item.prompt || '(session)', updatedAt: new Date(item.mtime).toISOString(), })); @@ -403,19 +416,74 @@ class QwenAgent implements Agent { method: string, params: Record, ): Promise> { - if (method === 'getAccountInfo') { - const sessionId = params['sessionId'] as string | undefined; - const session = sessionId ? this.sessions.get(sessionId) : undefined; - const config = session ? session.getConfig() : this.config; - const cfg = config.getContentGeneratorConfig(); - return { - authType: cfg?.authType ?? config.getAuthType() ?? null, - model: cfg?.model ?? config.getModel() ?? null, - baseUrl: cfg?.baseUrl ?? null, - apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, - }; + const cwd = (params['cwd'] as string) || process.cwd(); + const SESSION_ID_RE = /^[0-9a-fA-F-]{32,36}$/; + + switch (method) { + case 'deleteSession': { + const sessionId = params['sessionId'] as string; + if (!sessionId || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const success = await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + return sessionService.removeSession(sessionId); + }, + ); + return { success }; + } + case 'renameSession': { + const sessionId = params['sessionId'] as string; + const title = params['title'] as string; + if (!sessionId || !SESSION_ID_RE.test(sessionId)) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + if (!title || typeof title !== 'string') { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing title', + ); + } + if (title.length > SESSION_TITLE_MAX_LENGTH) { + throw RequestError.invalidParams( + undefined, + `Title too long (max ${SESSION_TITLE_MAX_LENGTH} chars)`, + ); + } + const success = await runWithAcpRuntimeOutputDir( + this.settings, + cwd, + async () => { + const sessionService = new SessionService(cwd); + return sessionService.renameSession(sessionId, title); + }, + ); + return { success }; + } + case 'getAccountInfo': { + const sessionId = params['sessionId'] as string | undefined; + const session = sessionId ? this.sessions.get(sessionId) : undefined; + const config = session ? session.getConfig() : this.config; + const cfg = config.getContentGeneratorConfig(); + return { + authType: cfg?.authType ?? config.getAuthType() ?? null, + model: cfg?.model ?? config.getModel() ?? null, + baseUrl: cfg?.baseUrl ?? null, + apiKeyEnvKey: cfg?.apiKeyEnvKey ?? null, + }; + } + default: + throw RequestError.methodNotFound(method); } - throw RequestError.methodNotFound(method); } // --- private helpers --- @@ -550,11 +618,8 @@ class QwenAgent implements Agent { await geminiClient.initialize(); } - const chat = geminiClient.getChat(); - const session = new Session( sessionId, - chat, config, this.connection, this.settings, diff --git a/packages/cli/src/acp-integration/session/HistoryReplayer.ts b/packages/cli/src/acp-integration/session/HistoryReplayer.ts index 2c3f5d1d2..20af40b6b 100644 --- a/packages/cli/src/acp-integration/session/HistoryReplayer.ts +++ b/packages/cli/src/acp-integration/session/HistoryReplayer.ts @@ -7,6 +7,7 @@ import type { ChatRecord, AgentResultDisplay, + SlashCommandRecordPayload, NotificationRecordPayload, } from '@qwen-code/qwen-code-core'; import type { @@ -90,8 +91,14 @@ export class HistoryReplayer { await this.replayToolResult(record); break; + case 'system': + if (record.subtype === 'slash_command') { + await this.replaySlashCommandResult(record); + } + // Other system subtypes (compression, telemetry, at_command) are skipped. + break; + default: - // Skip system records (compression, telemetry, slash commands) break; } this.setActiveRecordId(null); @@ -224,6 +231,29 @@ export class HistoryReplayer { } } + /** + * Replays a slash_command system record by re-emitting its output as an + * agent message chunk. This allows Zed to reconstruct the correct turn + * structure (user → agent) on session resume without polluting model context. + */ + private async replaySlashCommandResult(record: ChatRecord): Promise { + const payload = record.systemPayload as + | SlashCommandRecordPayload + | undefined; + if (payload?.phase !== 'result' || !payload.outputHistoryItems?.length) { + return; + } + for (const item of payload.outputHistoryItems) { + const text = typeof item['text'] === 'string' ? item['text'] : ''; + if (text) { + await this.messageEmitter.emitAgentMessage( + text.replace(/\n/g, ' \n'), + record.timestamp, + ); + } + } + } + /** * Extracts tool name from a chat record's function response. */ diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 740e84eb3..ea8a44dd6 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -56,7 +56,10 @@ describe('Session', () => { let currentAuthType: AuthType; let switchModelSpy: ReturnType; let getAvailableCommandsSpy: ReturnType; - let mockToolRegistry: { getTool: ReturnType }; + let mockToolRegistry: { + getTool: ReturnType; + ensureTool: ReturnType; + }; beforeEach(() => { currentModel = 'qwen3-code-plus'; currentAuthType = AuthType.USE_OPENAI; @@ -73,11 +76,21 @@ describe('Session', () => { getHistory: vi.fn().mockReturnValue([]), } as unknown as GeminiChat; - mockToolRegistry = { getTool: vi.fn() }; + mockToolRegistry = { + getTool: vi.fn(), + // #executePrompt → #buildInitialSystemReminders calls + // getToolRegistry().ensureTool(ToolNames.AGENT) on every session.prompt(), + // so the default mock must provide it (#1151 / #3479). + ensureTool: vi.fn().mockResolvedValue(true), + }; const fileService = { shouldGitIgnoreFile: vi.fn().mockReturnValue(false) }; mockConfig = { setApprovalMode: vi.fn(), + // #buildInitialSystemReminders branches on ApprovalMode.PLAN on every + // session.prompt(), so the default must be defined. Individual tests + // that care override via `mockConfig.getApprovalMode = vi.fn()...`. + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), switchModel: switchModelSpy, getModel: vi.fn().mockImplementation(() => currentModel), getSessionId: vi.fn().mockReturnValue('test-session-id'), @@ -91,6 +104,12 @@ describe('Session', () => { recordToolResult: vi.fn(), }), getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + // #buildInitialSystemReminders iterates listSubagents() on every + // session.prompt(). Default to an empty list so tests that don't + // exercise subagent reminders don't need to stub it (#1151 / #3479). + getSubagentManager: vi.fn().mockReturnValue({ + listSubagents: vi.fn().mockResolvedValue([]), + }), getFileService: vi.fn().mockReturnValue(fileService), getFileFilteringRespectGitIgnore: vi.fn().mockReturnValue(true), getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), @@ -98,6 +117,9 @@ describe('Session', () => { getDebugMode: vi.fn().mockReturnValue(false), getAuthType: vi.fn().mockImplementation(() => currentAuthType), isCronEnabled: vi.fn().mockReturnValue(false), + getGeminiClient: vi + .fn() + .mockReturnValue({ getChat: vi.fn().mockReturnValue(mockChat) }), } as unknown as Config; mockClient = { @@ -118,7 +140,6 @@ describe('Session', () => { session = new Session( 'test-session-id', - mockChat, mockConfig, mockClient, mockSettings, @@ -134,9 +155,7 @@ describe('Session', () => { mockConfig = undefined as unknown as Config; mockClient = undefined as unknown as AgentSideConnection; mockSettings = undefined as unknown as LoadedSettings; - mockToolRegistry = undefined as unknown as { - getTool: ReturnType; - }; + mockToolRegistry = undefined as unknown as typeof mockToolRegistry; vi.restoreAllMocks(); vi.clearAllTimers(); }); @@ -291,7 +310,6 @@ describe('Session', () => { core.Storage.setRuntimeBaseDir(runtimeDir); session = new Session( 'test-session-id', - mockChat, mockConfig, mockClient, mockSettings, @@ -1063,5 +1081,107 @@ describe('Session', () => { }); }); }); + + describe('system reminders', () => { + // Captures the `message` parts fed into chat.sendMessageStream on the + // first turn so individual tests can assert what the model saw. + const captureFirstTurnMessage = () => { + const capture: { parts: Array<{ text?: string }> } = { parts: [] }; + (mockChat.sendMessageStream as ReturnType) = vi + .fn() + .mockImplementation(async (_model, req) => { + capture.parts = req.message ?? []; + return createEmptyStream(); + }); + return capture; + }; + + const stubEmptySubagents = () => { + (mockConfig as unknown as Record)[ + 'getSubagentManager' + ] = vi.fn().mockReturnValue({ + listSubagents: vi.fn().mockResolvedValue([]), + }); + // ensureTool is called on the result of getToolRegistry(); add it. + ( + mockToolRegistry as unknown as { ensureTool: () => Promise } + ).ensureTool = vi.fn().mockResolvedValue(true); + }; + + it('prepends plan-mode reminder when approval mode is PLAN (#1151)', async () => { + stubEmptySubagents(); + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN); + const capture = captureFirstTurnMessage(); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'research this' }], + }); + + const reminderPart = capture.parts.find( + (p) => p.text && p.text.includes('Plan mode is active'), + ); + expect(reminderPart).toBeTruthy(); + expect(reminderPart!.text).toContain('exit_plan_mode'); + // Reminder comes before the user text, matching client.ts ordering. + const reminderIdx = capture.parts.indexOf(reminderPart!); + const userIdx = capture.parts.findIndex( + (p) => p.text === 'research this', + ); + expect(reminderIdx).toBeLessThan(userIdx); + }); + + it('does not prepend plan-mode reminder in default approval mode', async () => { + stubEmptySubagents(); + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.DEFAULT); + const capture = captureFirstTurnMessage(); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hi' }], + }); + + const hasPlanReminder = capture.parts.some( + (p) => p.text && p.text.includes('Plan mode is active'), + ); + expect(hasPlanReminder).toBe(false); + }); + + it('prepends subagent reminder when user-level subagents exist', async () => { + (mockConfig as unknown as Record)[ + 'getSubagentManager' + ] = vi.fn().mockReturnValue({ + listSubagents: vi.fn().mockResolvedValue([ + { name: 'researcher', level: 'user' }, + { name: 'planner', level: 'project' }, + // builtin entries are filtered out, matching client.ts:853. + { name: 'builtin-helper', level: 'builtin' }, + ]), + }); + ( + mockToolRegistry as unknown as { ensureTool: () => Promise } + ).ensureTool = vi.fn().mockResolvedValue(true); + mockConfig.getApprovalMode = vi + .fn() + .mockReturnValue(ApprovalMode.DEFAULT); + const capture = captureFirstTurnMessage(); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hi' }], + }); + + const reminder = capture.parts.find( + (p) => + p.text && + p.text.includes('researcher') && + p.text.includes('planner'), + ); + expect(reminder).toBeTruthy(); + expect(reminder!.text).not.toContain('builtin-helper'); + }); + }); }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index e905f9c6b..9c4d0f999 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -50,6 +50,9 @@ import { createHookOutput, generateToolUseId, MessageBusType, + getPlanModeSystemReminder, + getSubagentSystemReminder, + getArenaSystemReminder, } from '@qwen-code/qwen-code-core'; import { RequestError } from '@agentclientprotocol/sdk'; @@ -144,7 +147,6 @@ export class Session implements SessionContext { constructor( id: string, - private readonly chat: GeminiChat, readonly config: Config, private readonly client: AgentSideConnection, private readonly settings: LoadedSettings, @@ -291,7 +293,9 @@ export class Session implements SessionContext { // Increment turn counter for each user prompt this.turn += 1; - const chat = this.chat; + // Always fetch the current chat from GeminiClient so that /clear's + // resetChat() (which replaces the chat instance) is reflected here. + const chat = this.config.getGeminiClient()!.getChat(); const promptId = this.config.getSessionId() + '########' + this.turn; // Extract text from all text blocks to construct the full prompt text for logging @@ -393,6 +397,16 @@ export class Session implements SessionContext { } } + // Prepend session-level system reminders (plan mode / subagent / + // arena) so the model sees them, matching the behaviour of + // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, + // plan mode in ACP has no effect because the model never learns it + // should avoid edits (#1151). + const systemReminders = await this.#buildInitialSystemReminders(); + if (systemReminders.length > 0) { + parts = [...systemReminders, ...parts]; + } + let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -578,12 +592,12 @@ export class Session implements SessionContext { // Get response text from the chat history const history = chat.getHistory(); const lastModelMessage = history - .filter((msg) => msg.role === 'model') + .filter((msg: Content) => msg.role === 'model') .pop(); const responseText = lastModelMessage?.parts - ?.filter((p): p is { text: string } => 'text' in p) - .map((p) => p.text) + ?.filter((p: Part): p is { text: string } & Part => 'text' in p) + .map((p: { text: string }) => p.text) .join('') || '[no response text]'; const response = await messageBus.request< @@ -865,9 +879,12 @@ export class Session implements SessionContext { _meta: { source: 'cron' }, }); + // Prepend session-level system reminders (same rationale as the + // user-query path in #executePrompt). + const cronReminders = await this.#buildInitialSystemReminders(); let nextMessage: Content | null = { role: 'user', - parts: [{ text: prompt }], + parts: [...cronReminders, { text: prompt }], }; while (nextMessage !== null) { @@ -878,14 +895,17 @@ export class Session implements SessionContext { null; const streamStartTime = Date.now(); - const responseStream = await this.chat.sendMessageStream( - this.config.getModel(), - { - message: nextMessage.parts ?? [], - config: { abortSignal: ac.signal }, - }, - promptId, - ); + const responseStream = await this.config + .getGeminiClient()! + .getChat() + .sendMessageStream( + this.config.getModel(), + { + message: nextMessage.parts ?? [], + config: { abortSignal: ac.signal }, + }, + promptId, + ); nextMessage = null; for await (const resp of responseStream) { @@ -1083,6 +1103,51 @@ export class Session implements SessionContext { await this.sendUpdate(update); } + /** + * Assemble the per-turn system reminders the model needs to see at the + * start of a user query or cron fire. Mirrors the subagent/plan/arena + * branches in `GeminiClient.sendMessageStream` (`client.ts:848-878`) — + * the ACP path bypasses that code, so without this helper plan mode is + * silently inert (#1151) and subagent/arena sessions lose context. + * + * Scope note: the `relevantAutoMemory` reminder is intentionally NOT + * included here. Managed auto-memory requires a prefetch pipeline that + * lives in `GeminiClient`, and porting it into the ACP path is tracked + * separately as part of the broader middleware-alignment work. + */ + async #buildInitialSystemReminders(): Promise { + const reminders: Part[] = []; + + const hasAgentTool = await this.config + .getToolRegistry() + .ensureTool(ToolNames.AGENT); + const subagents = (await this.config.getSubagentManager().listSubagents()) + .filter((subagent) => subagent.level !== 'builtin') + .map((subagent) => subagent.name); + if (hasAgentTool && subagents.length > 0) { + reminders.push({ text: getSubagentSystemReminder(subagents) }); + } + + if (this.config.getApprovalMode() === ApprovalMode.PLAN) { + reminders.push({ + text: getPlanModeSystemReminder(this.config.getSdkMode?.()), + }); + } + + const arenaManager = this.config.getArenaManager?.(); + if (arenaManager) { + try { + const sessionDir = arenaManager.getArenaSessionDir(); + const configPath = `${sessionDir}/config.json`; + reminders.push({ text: getArenaSystemReminder(configPath) }); + } catch { + // Arena config not yet initialized — skip (matches client.ts). + } + } + + return reminders; + } + private async runTool( abortSignal: AbortSignal, promptId: string, @@ -1691,44 +1756,59 @@ export class Session implements SessionContext { return normalizePartList(result.content); case 'message': { - await this.client.extNotification('_qwencode/slash_command', { - sessionId: this.sessionId, - command: originalPrompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '), - messageType: result.messageType, - message: result.content || '', - }); - if (result.messageType === 'error') { // Throw error to stop execution throw new Error(result.content || 'Slash command failed.'); } - // For info messages, return null to indicate command was handled + // Emit the message as an agent message chunk so Zed renders it in the + // chat UI. extNotification only goes to the ACP debug log and is not + // rendered by Zed. + // Replace bare \n with Markdown hard line-breaks (two trailing spaces) + // so Zed's Markdown renderer preserves the line structure. + const rendered = (result.content || '').replace(/\n/g, ' \n'); + await this.messageEmitter.emitAgentMessage(rendered); + // Write a system/slash_command record so history replay on restart can + // re-emit this message. system records are skipped by + // buildApiHistoryFromConversation, so this won't pollute model context. + this.config.getChatRecordingService()?.recordSlashCommand({ + phase: 'result', + rawCommand: originalPrompt + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join(' '), + outputHistoryItems: [ + { type: 'assistant', text: result.content || '' }, + ], + }); return null; } case 'stream_messages': { // Command returns multiple messages via async generator (ACP-preferred) - const command = originalPrompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '); - - // Stream all messages to the client + // Stream all messages to the client as agent message chunks. + const chunks: string[] = []; for await (const msg of result.messages) { - await this.client.extNotification('_qwencode/slash_command', { - sessionId: this.sessionId, - command, - messageType: msg.messageType, - message: msg.content, - }); - - // If we encounter an error message, throw after sending if (msg.messageType === 'error') { throw new Error(msg.content || 'Slash command failed.'); } + await this.messageEmitter.emitAgentMessage( + (msg.content || '').replace(/\n/g, ' \n'), + ); + chunks.push(msg.content || ''); + } + // Write a system/slash_command record for history replay (same reason as + // 'message' case — system records are invisible to model history). + if (chunks.length > 0) { + this.config.getChatRecordingService()?.recordSlashCommand({ + phase: 'result', + rawCommand: originalPrompt + .filter((b) => b.type === 'text') + .map((b) => (b.type === 'text' ? b.text : '')) + .join(' '), + outputHistoryItems: [ + { type: 'assistant', text: chunks.join('\n') }, + ], + }); } // All messages sent successfully, return null to indicate command was handled diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index f131b41e8..dd3421b6c 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -17,7 +17,7 @@ import { isCodingPlanConfig, CodingPlanRegion, CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; +} from '@qwen-code/qwen-code-core'; import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { backupSettingsFile } from '../../utils/settingsUtils.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index d087d8d55..ca5a52718 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -6,8 +6,7 @@ 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 { AuthType, CODING_PLAN_ENV_KEY } from '@qwen-code/qwen-code-core'; import type { LoadedSettings } from '../../config/settings.js'; vi.mock('../../config/settings.js', () => ({ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f6844f688..d85d11941 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -62,7 +62,7 @@ const SESSION_ID_REGEX = * Accepts a standard UUID, or a UUID followed by `-agent-{suffix}` * (used by Arena to give each agent a deterministic session ID). */ -function isValidSessionId(value: string): boolean { +export function isValidSessionId(value: string): boolean { return SESSION_ID_REGEX.test(value); } @@ -524,13 +524,6 @@ export async function parseArguments(): Promise { coerce: (tools: string[]) => tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), }) - .option('allowed-tools', { - type: 'array', - string: true, - description: 'Tools to allow, will bypass confirmation', - coerce: (tools: string[]) => - tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), - }) .option('disabled-slash-commands', { type: 'array', string: true, @@ -542,6 +535,13 @@ export async function parseArguments(): Promise { coerce: (names: string[]) => names.flatMap((n) => n.split(',').map((t) => t.trim())), }) + .option('allowed-tools', { + type: 'array', + string: true, + description: 'Tools to allow, will bypass confirmation', + coerce: (tools: string[]) => + tools.flatMap((tool) => tool.split(',').map((t) => t.trim())), + }) .option('auth-type', { type: 'string', choices: [ @@ -612,9 +612,7 @@ export async function parseArguments(): Promise { ) { return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; } - 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").`; - } + // --resume accepts either a session UUID or a custom title if (argv['jsonFd'] != null && argv['jsonFile'] != null) { return '--json-fd and --json-file are mutually exclusive. Use one or the other.'; } @@ -1082,6 +1080,9 @@ export async function loadCliConfig( } if (argv.resume) { + // By the time we get here, argv.resume has been resolved to a valid + // session UUID by gemini.tsx (which handles custom title lookup and + // the interactive picker for ambiguous matches). sessionId = argv.resume; sessionData = await sessionService.loadSession(argv.resume); if (!sessionData) { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ba9bd4869..8aa7517a6 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -335,7 +335,17 @@ const SETTINGS_SCHEMA = { // Manual `/recap` works regardless. default: false, description: - 'Auto-show a one-line "where you left off" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use /recap to trigger manually regardless of this setting.', + 'Auto-show a one-line "where you left off" recap when returning to the terminal after being away. Off by default. Use /recap to trigger manually regardless of this setting.', + showInDialog: true, + }, + sessionRecapAwayThresholdMinutes: { + type: 'number', + label: 'Session Recap Away Threshold (minutes)', + category: 'General', + requiresRestart: false, + default: 5, + description: + "How many minutes the terminal must be blurred before an auto-recap fires on the next focus-in. Matches Claude Code's default of 5 minutes; raise if you briefly alt-tab and do not want recaps to pile up.", showInDialog: true, }, gitCoAuthor: { @@ -697,6 +707,16 @@ const SETTINGS_SCHEMA = { 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).', showInDialog: true, }, + shellOutputMaxLines: { + type: 'number', + label: 'Shell Output Max Lines', + category: 'UI', + requiresRestart: false, + default: 5, + description: + 'Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.', + showInDialog: true, + }, }, }, @@ -1106,6 +1126,36 @@ const SETTINGS_SCHEMA = { }, }, + slashCommands: { + type: 'object', + label: 'Slash Commands', + category: 'Advanced', + requiresRestart: true, + default: {}, + description: + 'Configuration for slash commands exposed by the CLI. Useful for ' + + 'locking down the command surface in multi-tenant or enterprise ' + + 'deployments.', + showInDialog: false, + properties: { + disabled: { + type: 'array', + label: 'Disabled Slash Commands', + category: 'Advanced', + requiresRestart: true, + default: undefined as string[] | undefined, + description: + 'Slash command names to hide and refuse to execute. Matched ' + + 'case-insensitively against the final command name (for extension ' + + 'commands this is the disambiguated form, e.g. "myext.deploy"). ' + + 'Merged as a union across settings scopes, so workspace settings ' + + 'can add to but not remove entries defined in system/user settings.', + showInDialog: false, + mergeStrategy: MergeStrategy.UNION, + }, + }, + }, + permissions: { type: 'object', label: 'Permissions', @@ -1155,36 +1205,6 @@ const SETTINGS_SCHEMA = { }, }, - slashCommands: { - type: 'object', - label: 'Slash Commands', - category: 'Advanced', - requiresRestart: true, - default: {}, - description: - 'Configuration for slash commands exposed by the CLI. Useful for ' + - 'locking down the command surface in multi-tenant or enterprise ' + - 'deployments.', - showInDialog: false, - properties: { - disabled: { - type: 'array', - label: 'Disabled Slash Commands', - category: 'Advanced', - requiresRestart: true, - default: undefined as string[] | undefined, - description: - 'Slash command names to hide and refuse to execute. Matched ' + - 'case-insensitively against the final command name (for extension ' + - 'commands this is the disambiguated form, e.g. "myext.deploy"). ' + - 'Merged as a union across settings scopes, so workspace settings ' + - 'can add to but not remove entries defined in system/user settings.', - showInDialog: false, - mergeStrategy: MergeStrategy.UNION, - }, - }, - }, - tools: { type: 'object', label: 'Tools', diff --git a/packages/cli/src/core/theme.test.ts b/packages/cli/src/core/theme.test.ts index c0c4a3cab..a94c67c44 100644 --- a/packages/cli/src/core/theme.test.ts +++ b/packages/cli/src/core/theme.test.ts @@ -12,6 +12,7 @@ vi.mock('../ui/themes/theme-manager.js', () => ({ themeManager: { findThemeByName: (...args: unknown[]) => mockFindThemeByName(...args), }, + AUTO_THEME_NAME: 'auto', })); vi.mock('../i18n/index.js', () => ({ @@ -61,4 +62,11 @@ describe('validateTheme', () => { const result = validateTheme(settings as never); expect(result).toBeNull(); }); + + it('should return null when theme is set to auto', () => { + const settings = { merged: { ui: { theme: 'auto' } } }; + const result = validateTheme(settings as never); + expect(result).toBeNull(); + expect(mockFindThemeByName).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/core/theme.ts b/packages/cli/src/core/theme.ts index 7acb4abd2..11123a16b 100644 --- a/packages/cli/src/core/theme.ts +++ b/packages/cli/src/core/theme.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { themeManager } from '../ui/themes/theme-manager.js'; +import { themeManager, AUTO_THEME_NAME } from '../ui/themes/theme-manager.js'; import { type LoadedSettings } from '../config/settings.js'; import { t } from '../i18n/index.js'; @@ -15,7 +15,11 @@ import { t } from '../i18n/index.js'; */ export function validateTheme(settings: LoadedSettings): string | null { const effectiveTheme = settings.merged.ui?.theme; - if (effectiveTheme && !themeManager.findThemeByName(effectiveTheme)) { + if ( + effectiveTheme && + effectiveTheme !== AUTO_THEME_NAME && + !themeManager.findThemeByName(effectiveTheme) + ) { return t('Theme "{{themeName}}" not found.', { themeName: effectiveTheme, }); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 131f56d55..59f3b4b1d 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -12,6 +12,7 @@ import { logUserPrompt, QWEN_CODE_SIMPLE_ENV_VAR, Storage, + SessionService, type Config, createDebugLogger, } from '@qwen-code/qwen-code-core'; @@ -45,7 +46,7 @@ import { VimModeProvider } from './ui/contexts/VimModeContext.js'; import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; import { BackgroundAgentViewProvider } from './ui/contexts/BackgroundAgentViewContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; -import { themeManager } from './ui/themes/theme-manager.js'; +import { themeManager, AUTO_THEME_NAME } from './ui/themes/theme-manager.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; import { checkForUpdates } from './ui/utils/updateCheck.js'; import { @@ -334,14 +335,21 @@ export async function main() { // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui?.customThemes); - if (settings.merged.ui?.theme) { - if (!themeManager.setActiveTheme(settings.merged.ui?.theme)) { + const configuredTheme = settings.merged.ui?.theme; + if (configuredTheme && configuredTheme !== AUTO_THEME_NAME) { + if (!themeManager.setActiveTheme(configuredTheme)) { // If the theme is not found during initial load, log a warning and continue. // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. - writeStderrLine( - `Warning: Theme "${settings.merged.ui?.theme}" not found.`, - ); + writeStderrLine(`Warning: Theme "${configuredTheme}" not found.`); } + } else { + // 'auto' or unset: resolve a synchronous baseline (COLORFGBG + macOS) + // so non-interactive runs and any pre-render UI (e.g. the --resume + // session picker) already have a sensible theme. The interactive + // startup block refines this with an OSC 11 probe later on, which is + // intentionally deferred to run inside the early-capture window so + // terminal response bytes cannot leak into the TUI input. + themeManager.setActiveTheme(AUTO_THEME_NAME); } // hop into sandbox if we are outside and sandboxing is enabled @@ -434,23 +442,52 @@ export async function main() { } } - // Handle --resume without a session ID by showing the session picker. - // Set the runtime output dir early so the picker can find sessions stored - // under a custom runtimeOutputDir (setRuntimeBaseDir is idempotent and will - // be called again inside loadCliConfig). - if (argv.resume === '') { + // Handle --resume without a session ID, or with a custom title, by showing + // the session picker. Set the runtime output dir early so the picker can find + // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is + // idempotent and will be called again inside loadCliConfig). + if (argv.resume !== undefined) { Storage.setRuntimeBaseDir( settings.merged.advanced?.runtimeOutputDir, process.cwd(), ); - const selectedSessionId = await showResumeSessionPicker(); - if (!selectedSessionId) { - // User cancelled or no sessions available - process.exit(0); + + let resolvedSessionId: string | undefined; + + if (argv.resume === '') { + // No argument — show picker + resolvedSessionId = await showResumeSessionPicker(); + } else if (!cliConfig.isValidSessionId(argv.resume)) { + // Non-UUID argument — treat as custom title search + const sessionService = new SessionService(process.cwd()); + const matches = await sessionService.findSessionsByTitle(argv.resume); + if (matches.length === 1) { + resolvedSessionId = matches[0].sessionId; + } else if (matches.length > 1) { + // Multiple matches — show picker to let user choose + writeStderrLine( + `Multiple sessions found with title "${argv.resume}". Please select one:`, + ); + resolvedSessionId = await showResumeSessionPicker( + process.cwd(), + matches, + ); + } + // matches.length === 0 → resolvedSessionId stays undefined, handled below } - // Update argv with the selected session ID - argv = { ...argv, resume: selectedSessionId }; + if (resolvedSessionId !== undefined) { + argv = { ...argv, resume: resolvedSessionId }; + } else if (argv.resume === '' || !cliConfig.isValidSessionId(argv.resume)) { + // User cancelled the picker or no sessions found for the title + if (argv.resume !== '') { + writeStderrLine(`No saved session found with title "${argv.resume}".`); + process.exit(1); + } else { + process.exit(0); + } + } + // else: argv.resume is already a valid UUID, pass through to loadCliConfig } // We are now past the logic handling potentially launching a child process @@ -492,6 +529,7 @@ export async function main() { const wasRaw = process.stdin.isRaw; let kittyProtocolDetectionComplete: Promise | undefined; + let themeAutoDetectionComplete: Promise | undefined; if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { // Set this as early as possible to avoid spurious characters from // input showing up in the output. @@ -512,6 +550,24 @@ export async function main() { // Detect and enable Kitty keyboard protocol once at startup. kittyProtocolDetectionComplete = detectAndEnableKittyProtocol(); + + // Auto-detect theme (OSC 11 + COLORFGBG + macOS) when the user has + // opted into 'auto' or has not configured a theme at all. Kicked off + // here without awaiting so the OSC 11 timeout overlaps with the + // heavier startup work below (initializeApp, warnings) instead of + // blocking the critical path. The synchronous baseline picked above + // keeps the active theme valid in the meantime; this probe only + // refines it. Running inside the early-capture window is deliberate: + // the filter in startEarlyInputCapture absorbs the OSC 11 response + // bytes so they cannot leak into the TUI input, even though our + // probe attaches its own listener to parse the RGB value. + if (!configuredTheme || configuredTheme === AUTO_THEME_NAME) { + themeAutoDetectionComplete = themeManager + .resolveAutoThemeAsync() + .catch((err) => { + debugLogger.warn('Async theme auto-detection failed:', err); + }); + } } setMaxSizedBoxDebugging(isDebugMode); @@ -563,6 +619,11 @@ export async function main() { if (config.isInteractive()) { // Need kitty detection to be complete before we can start the interactive UI. await kittyProtocolDetectionComplete; + // Drain the auto-theme probe before render so the OSC 11 response is + // absorbed by the early-capture filter (which is closed inside + // startInteractiveUI) and so the first paint uses the refined theme + // when the probe finishes in time. + await themeAutoDetectionComplete; await startInteractiveUI( config, settings, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a8fe75003..d4b6e94bf 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -348,6 +348,8 @@ export default { 'Tool Schema Compliance': 'Werkzeug-Schema-Konformität', // Settings enum options 'Auto (detect from system)': 'Automatisch (vom System erkennen)', + 'Auto (detect terminal theme)': 'Automatisch (Terminal-Theme erkennen)', + Auto: 'Automatisch', Text: 'Text', JSON: 'JSON', Plan: 'Plan', diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 5683a01ce..04b195449 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -432,6 +432,8 @@ export default { 'Tool Schema Compliance': 'Tool Schema Compliance', // Settings enum options 'Auto (detect from system)': 'Auto (detect from system)', + 'Auto (detect terminal theme)': 'Auto (detect terminal theme)', + Auto: 'Auto', Text: 'Text', JSON: 'JSON', Plan: 'Plan', diff --git a/packages/cli/src/i18n/locales/fr.js b/packages/cli/src/i18n/locales/fr.js index 95cf5f935..138f7bf8d 100644 --- a/packages/cli/src/i18n/locales/fr.js +++ b/packages/cli/src/i18n/locales/fr.js @@ -443,6 +443,8 @@ export default { 'Vision Model Preview': 'Aperçu du modèle de vision', 'Tool Schema Compliance': 'Conformité au schéma des outils', 'Auto (detect from system)': 'Auto (détecter depuis le système)', + 'Auto (detect terminal theme)': 'Auto (détecter le thème du terminal)', + Auto: 'Auto', Text: 'Texte', JSON: 'JSON', Plan: 'Plan', diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 36e79f50f..2c7225639 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -315,6 +315,8 @@ export default { 'Vision Model Preview': 'ビジョンモデルプレビュー', 'Tool Schema Compliance': 'ツールスキーマ準拠', 'Auto (detect from system)': '自動(システムから検出)', + 'Auto (detect terminal theme)': '自動(端末テーマを検出)', + Auto: '自動', 'check session stats. Usage: /stats [model|tools]': 'セッション統計を確認。使い方: /stats [model|tools]', 'Show model-specific usage statistics.': 'モデル別の使用統計を表示', diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index beb49eb54..db681a19a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -374,6 +374,8 @@ export default { // Settings enum options 'Auto (detect from system)': 'Automático (detectar do sistema)', + 'Auto (detect terminal theme)': 'Automático (detectar tema do terminal)', + Auto: 'Automático', Text: 'Texto', JSON: 'JSON', Plan: 'Planejamento', diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 9a9f0f1e8..ed2af31b3 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -369,6 +369,8 @@ export default { 'Tool Schema Compliance': 'Соответствие схеме инструмента', // Варианты перечислений настроек 'Auto (detect from system)': 'Авто (определить из системы)', + 'Auto (detect terminal theme)': 'Авто (определить тему терминала)', + Auto: 'Авто', Text: 'Текст', JSON: 'JSON', Plan: 'План', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index bf182ddda..a1e7df51a 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -413,6 +413,8 @@ export default { 'Tool Schema Compliance': '工具 Schema 兼容性', // Settings enum options 'Auto (detect from system)': '自动(从系统检测)', + 'Auto (detect terminal theme)': '自动(检测终端主题)', + Auto: '自动', Text: '文本', JSON: 'JSON', Plan: '规划', diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index c12c910bc..e2988b612 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -152,6 +152,9 @@ describe('runNonInteractive', () => { isInteractive: vi.fn().mockReturnValue(false), isCronEnabled: vi.fn().mockReturnValue(false), getCronScheduler: vi.fn().mockReturnValue(null), + setModelInvocableCommandsProvider: vi.fn(), + setModelInvocableCommandsExecutor: vi.fn(), + getDisabledSlashCommands: vi.fn().mockReturnValue([]), getBackgroundTaskRegistry: vi.fn().mockReturnValue({ setNotificationCallback: vi.fn(), setRegisterCallback: vi.fn(), @@ -159,7 +162,6 @@ describe('runNonInteractive', () => { hasUnfinalizedAgents: vi.fn().mockReturnValue(false), abortAll: vi.fn(), }), - getDisabledSlashCommands: vi.fn().mockReturnValue([]), } as unknown as Config; mockSettings = { diff --git a/packages/cli/src/nonInteractiveCliCommands.test.ts b/packages/cli/src/nonInteractiveCliCommands.test.ts index 1e83abcad..8e40b633d 100644 --- a/packages/cli/src/nonInteractiveCliCommands.test.ts +++ b/packages/cli/src/nonInteractiveCliCommands.test.ts @@ -43,6 +43,8 @@ describe('handleSlashCommand', () => { getFolderTrustFeature: vi.fn().mockReturnValue(false), getFolderTrust: vi.fn().mockReturnValue(false), getProjectRoot: vi.fn().mockReturnValue('/test/project'), + setModelInvocableCommandsProvider: vi.fn(), + setModelInvocableCommandsExecutor: vi.fn(), getDisabledSlashCommands: vi.fn().mockReturnValue([]), storage: {}, } as unknown as Config; @@ -86,7 +88,7 @@ describe('handleSlashCommand', () => { name: 'help', description: 'Show help', kind: CommandKind.BUILT_IN, - // No commandType → falls back to BUILT_IN → interactive only + // No supportedModes → BUILT_IN fallback → interactive only action: vi.fn(), }; mockGetCommands.mockReturnValue([mockHelpCommand]); @@ -135,7 +137,6 @@ describe('handleSlashCommand', () => { name: 'init', description: 'Initialize project', kind: CommandKind.BUILT_IN, - commandType: 'local' as const, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: vi.fn().mockResolvedValue({ type: 'message', @@ -163,7 +164,6 @@ describe('handleSlashCommand', () => { name: 'btw', description: 'Ask a side question', kind: CommandKind.BUILT_IN, - commandType: 'local' as const, supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: vi.fn().mockResolvedValue({ type: 'message', @@ -276,4 +276,67 @@ describe('handleSlashCommand', () => { expect(result.messageType).toBe('info'); } }); + + describe('disabled slash commands', () => { + const mockDisabledCommand = { + name: 'help', + description: 'Show help', + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: vi.fn().mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'Help content', + }), + }; + + it('should return unsupported with disabled reason for a disabled command', async () => { + mockGetCommands.mockReturnValue([mockDisabledCommand]); + vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']); + + const result = await handleSlashCommand( + '/help', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('unsupported'); + if (result.type === 'unsupported') { + expect(result.reason).toContain('disabled'); + expect(result.originalType).toBe('filtered_command'); + } + }); + + it('should match disabled command names case-insensitively', async () => { + mockGetCommands.mockReturnValue([mockDisabledCommand]); + vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['HELP']); + + const result = await handleSlashCommand( + '/help', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('unsupported'); + if (result.type === 'unsupported') { + expect(result.reason).toContain('disabled'); + } + }); + + it('should still return no_command for genuinely unknown commands even with a denylist', async () => { + mockGetCommands.mockReturnValue([mockDisabledCommand]); + vi.mocked(mockConfig.getDisabledSlashCommands).mockReturnValue(['help']); + + const result = await handleSlashCommand( + '/unknowncommand', + abortController, + mockConfig, + mockSettings, + ); + + expect(result.type).toBe('no_command'); + }); + }); }); diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 5a5fc69ac..a3b49a403 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -16,6 +16,7 @@ import { CommandService } from './services/CommandService.js'; import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; import { BundledSkillLoader } from './services/BundledSkillLoader.js'; import { FileCommandLoader } from './services/FileCommandLoader.js'; +import { SkillCommandLoader } from './services/SkillCommandLoader.js'; import { type CommandContext, type SlashCommand, @@ -200,15 +201,72 @@ export const handleSlashCommand = async ( const allLoaders = [ new BuiltinCommandLoader(config), new BundledSkillLoader(config), + new SkillCommandLoader(config), new FileCommandLoader(config), ]; + // Build the disabled-command set (case-insensitive). + const disabledSlashCommandsRaw = config.getDisabledSlashCommands(); + const disabledNameSet = new Set(); + for (const name of disabledSlashCommandsRaw) { + const trimmed = name.trim(); + if (trimmed) disabledNameSet.add(trimmed.toLowerCase()); + } + const isDisabled = (cmd: { name: string; altNames?: readonly string[] }) => + disabledNameSet.has(cmd.name.toLowerCase()) || + (cmd.altNames ?? []).some((a) => disabledNameSet.has(a.toLowerCase())); + + // Load the full command set (unfiltered by the denylist) so that the + // fallback existence check below can distinguish a disabled command from a + // truly unknown one. Without this, a disabled command would fall through to + // `no_command` and be forwarded to the model as plain prompt text. const commandService = await CommandService.create( allLoaders, abortController.signal, ); + // Register model-invocable commands provider so SkillTool description stays + // up-to-date in non-interactive / ACP mode. + config.setModelInvocableCommandsProvider(() => + commandService.getModelInvocableCommands().map((cmd) => ({ + name: cmd.name, + description: + typeof cmd.description === 'string' ? cmd.description : cmd.description, + })), + ); + // Register executor so SkillTool can invoke model-invocable commands + // (e.g. MCP prompts) that are not file-based skills. + config.setModelInvocableCommandsExecutor( + async (name: string, args: string = '') => { + const commands = commandService.getModelInvocableCommands(); + const cmd = commands.find((c) => c.name === name); + if (!cmd?.action) return null; + const minimalContext = { + executionMode, + invocation: { + raw: args ? `/${name} ${args}` : `/${name}`, + name, + args, + }, + services: { config, settings, git: undefined, logger: null }, + } as unknown as CommandContext; + const result = await cmd.action(minimalContext, args); + if (!result || result.type !== 'submit_prompt') return null; + const content = result.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((p) => + typeof p === 'string' ? p : ((p as { text?: string }).text ?? ''), + ) + .join(''); + } + return null; + }, + ); const allCommands = commandService.getCommands(); - const filteredCommands = commandService.getCommandsForMode(executionMode); + const filteredCommands = commandService + .getCommandsForMode(executionMode) + .filter((cmd) => !isDisabled(cmd)); // First, try to parse with filtered commands const { commandToExecute, args } = parseSlashCommand( @@ -224,11 +282,26 @@ export const handleSlashCommand = async ( ); if (knownCommand) { + // Derive the token the user actually typed (e.g. "about" when the + // primary name is "status") to surface a helpful error message. + const typedToken = + rawQuery.trim().substring(1).trim().split(/\s+/)[0] ?? + knownCommand.name; + if (isDisabled(knownCommand)) { + return { + type: 'unsupported', + reason: t( + 'The command "/{{command}}" is disabled by the current configuration.', + { command: typedToken }, + ), + originalType: 'filtered_command', + }; + } // Command exists but is not allowed in this mode return { type: 'unsupported', reason: t('The command "/{{command}}" is not supported in this mode.', { - command: knownCommand.name, + command: typedToken, }), originalType: 'filtered_command', }; @@ -304,10 +377,18 @@ export const getAvailableCommands = async ( const loaders = [ new BuiltinCommandLoader(config), new BundledSkillLoader(config), + new SkillCommandLoader(config), new FileCommandLoader(config), ]; - const commandService = await CommandService.create(loaders, abortSignal); + const disabledSlashCommands = config.getDisabledSlashCommands(); + const commandService = await CommandService.create( + loaders, + abortSignal, + disabledSlashCommands.length > 0 + ? new Set(disabledSlashCommands) + : undefined, + ); return commandService.getCommandsForMode(mode) as SlashCommand[]; } catch (error) { // Handle errors gracefully - log and return empty array diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 05bd26981..cdde266b8 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -15,6 +15,7 @@ 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 { deleteCommand } from '../ui/commands/deleteCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; import { contextCommand } from '../ui/commands/contextCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; @@ -41,6 +42,7 @@ import { permissionsCommand } from '../ui/commands/permissionsCommand.js'; import { trustCommand } from '../ui/commands/trustCommand.js'; import { quitCommand } from '../ui/commands/quitCommand.js'; import { recapCommand } from '../ui/commands/recapCommand.js'; +import { renameCommand } from '../ui/commands/renameCommand.js'; import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { resumeCommand } from '../ui/commands/resumeCommand.js'; import { settingsCommand } from '../ui/commands/settingsCommand.js'; @@ -97,6 +99,7 @@ export class BuiltinCommandLoader implements ICommandLoader { compressCommand, contextCommand, copyCommand, + deleteCommand, docsCommand, doctorCommand, directoryCommand, @@ -120,6 +123,7 @@ export class BuiltinCommandLoader implements ICommandLoader { ...(this.config?.getFolderTrust() ? [trustCommand] : []), quitCommand, recapCommand, + renameCommand, restoreCommand(this.config), resumeCommand, skillsCommand, diff --git a/packages/cli/src/services/BundledSkillLoader.ts b/packages/cli/src/services/BundledSkillLoader.ts index faa39910d..5ea6682b5 100644 --- a/packages/cli/src/services/BundledSkillLoader.ts +++ b/packages/cli/src/services/BundledSkillLoader.ts @@ -65,8 +65,8 @@ export class BundledSkillLoader implements ICommandLoader { kind: CommandKind.SKILL, source: 'bundled-skill' as const, sourceLabel: 'Skill', - commandType: 'prompt' as const, - modelInvocable: true, + modelInvocable: !skill.disableModelInvocation, + whenToUse: skill.whenToUse, action: async (context, _args): Promise => { // Resolve template variables in skill body let body = skill.body; diff --git a/packages/cli/src/services/CommandService.test.ts b/packages/cli/src/services/CommandService.test.ts index 122a741ff..545eb22ad 100644 --- a/packages/cli/src/services/CommandService.test.ts +++ b/packages/cli/src/services/CommandService.test.ts @@ -310,80 +310,6 @@ describe('CommandService', () => { expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); - describe('disabledNames filtering', () => { - it('should omit commands whose names are in the disabled set', async () => { - const loader = new MockCommandLoader([ - mockCommandA, - mockCommandB, - mockCommandC, - ]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['command-b']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - expect(names).toEqual(expect.arrayContaining(['command-a', 'command-c'])); - expect(names).not.toContain('command-b'); - }); - - it('should match disabled names case-insensitively', async () => { - const loader = new MockCommandLoader([mockCommandA, mockCommandB]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['COMMAND-A']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - expect(names).toEqual(['command-b']); - }); - - it('should ignore empty entries and whitespace in the disabled set', async () => { - const loader = new MockCommandLoader([mockCommandA, mockCommandB]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['', ' ', ' command-a ']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - expect(names).toEqual(['command-b']); - }); - - it('should be a no-op when disabledNames is undefined or empty', async () => { - const loader = new MockCommandLoader([mockCommandA, mockCommandB]); - const undefinedResult = await CommandService.create( - [loader], - new AbortController().signal, - ); - expect(undefinedResult.getCommands()).toHaveLength(2); - - const emptyResult = await CommandService.create( - [new MockCommandLoader([mockCommandA, mockCommandB])], - new AbortController().signal, - new Set(), - ); - expect(emptyResult.getCommands()).toHaveLength(2); - }); - - it('should disable extension commands by their renamed (final) name', async () => { - const builtin = createMockCommand('deploy', CommandKind.BUILT_IN); - const extension = { - ...createMockCommand('deploy', CommandKind.FILE), - extensionName: 'firebase', - description: '[firebase] Deploy to Firebase', - }; - const loader = new MockCommandLoader([builtin, extension]); - const service = await CommandService.create( - [loader], - new AbortController().signal, - new Set(['firebase.deploy']), - ); - const names = service.getCommands().map((cmd) => cmd.name); - // Built-in /deploy remains; the renamed extension command is gone. - expect(names).toEqual(['deploy']); - }); - }); - it('should handle multiple secondary conflicts with incrementing suffixes', async () => { // User has /deploy, /gcp.deploy, and /gcp.deploy1 const userCommand1 = createMockCommand('deploy', CommandKind.FILE); @@ -419,4 +345,59 @@ describe('CommandService', () => { expect(deployExtension).toBeDefined(); expect(deployExtension?.description).toBe('[gcp] Deploy to Google Cloud'); }); + + describe('disabled commands (disabledNames parameter)', () => { + it('should exclude commands whose names are in the disabledNames set', async () => { + const mockLoader = new MockCommandLoader([ + mockCommandA, + mockCommandB, + mockCommandC, + ]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + new Set(['command-a']), + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(2); + expect(commands.find((c) => c.name === 'command-a')).toBeUndefined(); + expect(commands.find((c) => c.name === 'command-b')).toBeDefined(); + expect(commands.find((c) => c.name === 'command-c')).toBeDefined(); + }); + + it('should match disabled names case-insensitively', async () => { + const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + new Set(['COMMAND-A', 'Command-B']), + ); + + const commands = service.getCommands(); + expect(commands).toHaveLength(0); + }); + + it('should not filter any commands when disabledNames is empty', async () => { + const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + new Set(), + ); + + expect(service.getCommands()).toHaveLength(2); + }); + + it('should not filter any commands when disabledNames is undefined', async () => { + const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]); + const service = await CommandService.create( + [mockLoader], + new AbortController().signal, + undefined, + ); + + expect(service.getCommands()).toHaveLength(2); + }); + }); }); diff --git a/packages/cli/src/services/CommandService.ts b/packages/cli/src/services/CommandService.ts index d4bcc6641..8fa67b7b4 100644 --- a/packages/cli/src/services/CommandService.ts +++ b/packages/cli/src/services/CommandService.ts @@ -34,9 +34,8 @@ export class CommandService { * * This factory method orchestrates the entire command loading process. It * runs all provided loaders in parallel, aggregates their results, handles - * name conflicts for extension commands by renaming them, optionally filters - * out disabled commands, and then returns a fully constructed - * `CommandService` instance. + * name conflicts for extension commands by renaming them, and then returns a + * fully constructed `CommandService` instance. * * Conflict resolution: * - Extension commands that conflict with existing commands are renamed to @@ -102,8 +101,12 @@ export class CommandService { if (trimmed) normalizedDisabled.add(trimmed.toLowerCase()); } if (normalizedDisabled.size > 0) { - for (const name of Array.from(commandMap.keys())) { - if (normalizedDisabled.has(name.toLowerCase())) { + for (const [name, cmd] of Array.from(commandMap.entries())) { + const matchesPrimary = normalizedDisabled.has(name.toLowerCase()); + const matchesAlias = (cmd.altNames ?? []).some((a) => + normalizedDisabled.has(a.toLowerCase()), + ); + if (matchesPrimary || matchesAlias) { commandMap.delete(name); } } diff --git a/packages/cli/src/services/FileCommandLoader.ts b/packages/cli/src/services/FileCommandLoader.ts index e9fc82355..ba03c9567 100644 --- a/packages/cli/src/services/FileCommandLoader.ts +++ b/packages/cli/src/services/FileCommandLoader.ts @@ -354,6 +354,9 @@ export class FileCommandLoader implements ICommandLoader { typeof validDef.frontmatter.description === 'string' ? validDef.frontmatter.description : undefined, + whenToUse: validDef.frontmatter?.when_to_use, + disableModelInvocation: + validDef.frontmatter?.['disable-model-invocation'], }; // Use factory to create command diff --git a/packages/cli/src/services/McpPromptLoader.ts b/packages/cli/src/services/McpPromptLoader.ts index c18e67e88..d195c96f8 100644 --- a/packages/cli/src/services/McpPromptLoader.ts +++ b/packages/cli/src/services/McpPromptLoader.ts @@ -48,8 +48,6 @@ export class McpPromptLoader implements ICommandLoader { kind: CommandKind.MCP_PROMPT, source: 'mcp-prompt' as const, sourceLabel: `MCP: ${serverName}`, - commandType: 'prompt' as const, - modelInvocable: true, subCommands: [ { name: 'help', diff --git a/packages/cli/src/services/SkillCommandLoader.test.ts b/packages/cli/src/services/SkillCommandLoader.test.ts new file mode 100644 index 000000000..00c189123 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.test.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SkillCommandLoader } from './SkillCommandLoader.js'; +import { CommandKind } from '../ui/commands/types.js'; +import type { Config, SkillConfig } from '@qwen-code/qwen-code-core'; + +function makeSkill(overrides: Partial = {}): SkillConfig { + return { + name: 'my-skill', + description: 'My skill description', + level: 'user', + filePath: '/home/user/.qwen/skills/my-skill/SKILL.md', + body: 'Skill body content.', + ...overrides, + }; +} + +describe('SkillCommandLoader', () => { + let mockConfig: Config; + let mockSkillManager: { listSkills: ReturnType }; + + beforeEach(() => { + vi.clearAllMocks(); + mockSkillManager = { + listSkills: vi.fn().mockResolvedValue([]), + }; + mockConfig = { + getSkillManager: vi.fn().mockReturnValue(mockSkillManager), + getBareMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + }); + + const signal = new AbortController().signal; + + it('should return empty array when config is null', async () => { + const loader = new SkillCommandLoader(null); + expect(await loader.loadCommands(signal)).toEqual([]); + }); + + it('should return empty array when SkillManager is not available', async () => { + const config = { + getSkillManager: vi.fn().mockReturnValue(null), + getBareMode: vi.fn().mockReturnValue(false), + } as unknown as Config; + const loader = new SkillCommandLoader(config); + expect(await loader.loadCommands(signal)).toEqual([]); + }); + + it('should return empty array in bare mode', async () => { + (mockConfig.getBareMode as ReturnType).mockReturnValue(true); + const loader = new SkillCommandLoader(mockConfig); + expect(await loader.loadCommands(signal)).toEqual([]); + expect(mockSkillManager.listSkills).not.toHaveBeenCalled(); + }); + + it('should query user, project, and extension levels', async () => { + const loader = new SkillCommandLoader(mockConfig); + await loader.loadCommands(signal); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ level: 'user' }); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ + level: 'project', + }); + expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ + level: 'extension', + }); + }); + + it('should load user skill as slash command with correct properties', async () => { + const skill = makeSkill({ level: 'user' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(1); + const cmd = commands[0]; + expect(cmd.name).toBe('my-skill'); + expect(cmd.description).toBe('My skill description'); + expect(cmd.kind).toBe(CommandKind.SKILL); + expect(cmd.source).toBe('skill-dir-command'); + expect(cmd.sourceLabel).toBe('User'); + expect(cmd.modelInvocable).toBe(true); + }); + + it('should load project skill with sourceLabel "Project"', async () => { + const skill = makeSkill({ level: 'project' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'project' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].sourceLabel).toBe('Project'); + expect(commands[0].source).toBe('skill-dir-command'); + expect(commands[0].modelInvocable).toBe(true); + }); + + it('should submit skill body as prompt', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + const result = await commands[0].action!( + { invocation: { raw: '/my-skill', args: '' } } as never, + '', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'Skill body content.' }], + }); + }); + + it('should append raw invocation when args are provided', async () => { + const skill = makeSkill(); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + const result = await commands[0].action!( + { invocation: { raw: '/my-skill foo', args: 'foo' } } as never, + 'foo', + ); + + expect(result).toEqual({ + type: 'submit_prompt', + content: [{ text: 'Skill body content.\n\n/my-skill foo' }], + }); + }); + + it('should return empty array when listSkills throws', async () => { + mockSkillManager.listSkills.mockRejectedValue(new Error('load failed')); + const loader = new SkillCommandLoader(mockConfig); + expect(await loader.loadCommands(signal)).toEqual([]); + }); + + describe('extension skills', () => { + it('should be modelInvocable when description is present', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: 'Use tmux for interactive commands', + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(true); + expect(commands[0].source).toBe('plugin-command'); + expect(commands[0].sourceLabel).toBe('Extension: superpowers-lab'); + }); + + it('should be modelInvocable when whenToUse is present', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: '', + whenToUse: 'Use when you need tmux', + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(true); + }); + + it('should NOT be modelInvocable when description and whenToUse are absent', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: '', + whenToUse: undefined, + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(false); + }); + + it('should NOT be modelInvocable when disableModelInvocation is true, even with description', async () => { + const skill = makeSkill({ + level: 'extension', + extensionName: 'superpowers-lab', + description: 'Some description', + disableModelInvocation: true, + }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(false); + }); + + it('should use "Extension: unknown" as sourceLabel when extensionName is absent', async () => { + const skill = makeSkill({ level: 'extension', description: 'foo' }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'extension' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].sourceLabel).toBe('Extension: unknown'); + }); + }); + + describe('user/project skill disableModelInvocation', () => { + it('user skill with disableModelInvocation:true should NOT be modelInvocable', async () => { + const skill = makeSkill({ level: 'user', disableModelInvocation: true }); + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => + Promise.resolve(level === 'user' ? [skill] : []), + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands[0].modelInvocable).toBe(false); + }); + }); + + it('should aggregate skills from all levels', async () => { + mockSkillManager.listSkills.mockImplementation( + ({ level }: { level: string }) => { + if (level === 'user') + return Promise.resolve([ + makeSkill({ name: 'user-skill', level: 'user' }), + ]); + if (level === 'project') + return Promise.resolve([ + makeSkill({ name: 'proj-skill', level: 'project' }), + ]); + if (level === 'extension') + return Promise.resolve([ + makeSkill({ + name: 'ext-skill', + level: 'extension', + description: 'foo', + }), + ]); + return Promise.resolve([]); + }, + ); + + const loader = new SkillCommandLoader(mockConfig); + const commands = await loader.loadCommands(signal); + + expect(commands).toHaveLength(3); + expect(commands.map((c) => c.name)).toEqual([ + 'user-skill', + 'proj-skill', + 'ext-skill', + ]); + }); +}); diff --git a/packages/cli/src/services/SkillCommandLoader.ts b/packages/cli/src/services/SkillCommandLoader.ts new file mode 100644 index 000000000..58e297633 --- /dev/null +++ b/packages/cli/src/services/SkillCommandLoader.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; +import { + createDebugLogger, + appendToLastTextPart, +} from '@qwen-code/qwen-code-core'; +import type { ICommandLoader } from './types.js'; +import type { + SlashCommand, + SlashCommandActionReturn, + CommandSource, +} from '../ui/commands/types.js'; +import { CommandKind } from '../ui/commands/types.js'; + +const debugLogger = createDebugLogger('SKILL_COMMAND_LOADER'); + +/** + * Loads user-level, project-level, and extension-level skills as slash + * commands, making them directly invocable via /. + * + * - User/project skills: always model-invocable (same as bundled), unless + * disable-model-invocation is set. + * - Extension skills: model-invocable only when description or whenToUse is + * present (same rule as plugin commands), unless disable-model-invocation + * is set. + */ +export class SkillCommandLoader implements ICommandLoader { + constructor(private readonly config: Config | null) {} + + async loadCommands(_signal: AbortSignal): Promise { + if (this.config?.getBareMode?.()) { + debugLogger.debug('Bare mode enabled, skipping skill commands'); + return []; + } + + const skillManager = this.config?.getSkillManager(); + if (!skillManager) { + debugLogger.debug('SkillManager not available, skipping skill commands'); + return []; + } + + try { + const [userSkills, projectSkills, extensionSkills] = await Promise.all([ + skillManager.listSkills({ level: 'user' }), + skillManager.listSkills({ level: 'project' }), + skillManager.listSkills({ level: 'extension' }), + ]); + + const allSkills = [...userSkills, ...projectSkills, ...extensionSkills]; + + debugLogger.debug( + `Loaded ${userSkills.length} user + ${projectSkills.length} project + ${extensionSkills.length} extension skill(s) as slash commands`, + ); + + return allSkills.map((skill) => { + const isExtension = skill.level === 'extension'; + + // Extension skills need explicit description or whenToUse to be + // model-invocable (same rule as plugin commands). + // User/project skills are always model-invocable. + const modelInvocable = skill.disableModelInvocation + ? false + : isExtension + ? !!(skill.description || skill.whenToUse) + : true; + + const sourceLabel = isExtension + ? `Extension: ${skill.extensionName ?? 'unknown'}` + : skill.level === 'project' + ? 'Project' + : 'User'; + + return { + name: skill.name, + description: skill.description, + kind: CommandKind.SKILL, + source: (isExtension + ? 'plugin-command' + : 'skill-dir-command') as CommandSource, + sourceLabel, + modelInvocable, + whenToUse: skill.whenToUse, + action: async (context, _args): Promise => { + const body = skill.body; + + const content = context.invocation?.args + ? appendToLastTextPart([{ text: body }], context.invocation.raw) + : [{ text: body }]; + + return { + type: 'submit_prompt', + content, + }; + }, + }; + }); + } catch (error) { + debugLogger.error('Failed to load skill commands:', error); + return []; + } + } +} diff --git a/packages/cli/src/services/command-factory.ts b/packages/cli/src/services/command-factory.ts index 9293e97ac..1aa363254 100644 --- a/packages/cli/src/services/command-factory.ts +++ b/packages/cli/src/services/command-factory.ts @@ -37,6 +37,8 @@ import { AtFileProcessor } from './prompt-processors/atFileProcessor.js'; export interface CommandDefinition { prompt: string; description?: string; + whenToUse?: string; + disableModelInvocation?: boolean; } const debugLogger = createDebugLogger('COMMAND_FACTORY'); @@ -116,8 +118,10 @@ export function createSlashCommandFromDefinition( ? 'plugin-command' : 'skill-dir-command') as CommandSource, sourceLabel: extensionName ? `Plugin: ${extensionName}` : 'Custom', - commandType: 'prompt' as const, - modelInvocable: !extensionName, + modelInvocable: definition.disableModelInvocation + ? false + : !extensionName || !!(definition.description || definition.whenToUse), + whenToUse: definition.whenToUse, action: async ( context: CommandContext, _args: string, diff --git a/packages/cli/src/services/commandUtils.test.ts b/packages/cli/src/services/commandUtils.test.ts index 0287e0a2a..7f6ef24ce 100644 --- a/packages/cli/src/services/commandUtils.test.ts +++ b/packages/cli/src/services/commandUtils.test.ts @@ -22,18 +22,14 @@ function makeCmd(overrides: Partial): SlashCommand { } describe('getEffectiveSupportedModes', () => { - // ── Priority 1: explicit supportedModes ─────────────────────────────── - it('explicit supportedModes overrides commandType inference', () => { - const cmd = makeCmd({ - commandType: 'local', - supportedModes: ['interactive'], - }); + // ── Explicit supportedModes ──────────────────────────────────────────── + it('uses explicit supportedModes when declared', () => { + const cmd = makeCmd({ supportedModes: ['interactive'] }); expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']); }); - it('explicit supportedModes can expand to all modes even for local-jsx', () => { + it('supportedModes can declare all modes', () => { const cmd = makeCmd({ - commandType: 'local-jsx', supportedModes: ['interactive', 'non_interactive', 'acp'], }); expect(getEffectiveSupportedModes(cmd)).toEqual([ @@ -48,45 +44,13 @@ describe('getEffectiveSupportedModes', () => { expect(getEffectiveSupportedModes(cmd)).toEqual([]); }); - // ── Priority 2: commandType inference ───────────────────────────────── - it('commandType: prompt infers all modes', () => { - const cmd = makeCmd({ kind: CommandKind.SKILL, commandType: 'prompt' }); - expect(getEffectiveSupportedModes(cmd)).toEqual([ - 'interactive', - 'non_interactive', - 'acp', - ]); - }); - - it('commandType: local infers interactive only (conservative default)', () => { - const cmd = makeCmd({ commandType: 'local' }); - expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']); - }); - - it('commandType: local-jsx infers interactive only', () => { - const cmd = makeCmd({ commandType: 'local-jsx' }); - expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']); - }); - - it('commandType: local with explicit supportedModes can unlock non_interactive', () => { - const cmd = makeCmd({ - commandType: 'local', - supportedModes: ['interactive', 'non_interactive', 'acp'], - }); - expect(getEffectiveSupportedModes(cmd)).toEqual([ - 'interactive', - 'non_interactive', - 'acp', - ]); - }); - - // ── Priority 3: CommandKind fallback (backward compat) ──────────────── - it('no commandType, CommandKind.BUILT_IN falls back to interactive only', () => { + // ── CommandKind fallback (no supportedModes) ─────────────────────────── + it('CommandKind.BUILT_IN without supportedModes falls back to interactive only', () => { const cmd = makeCmd({ kind: CommandKind.BUILT_IN }); expect(getEffectiveSupportedModes(cmd)).toEqual(['interactive']); }); - it('no commandType, CommandKind.FILE falls back to all modes', () => { + it('CommandKind.FILE without supportedModes falls back to all modes', () => { const cmd = makeCmd({ kind: CommandKind.FILE }); expect(getEffectiveSupportedModes(cmd)).toEqual([ 'interactive', @@ -95,7 +59,7 @@ describe('getEffectiveSupportedModes', () => { ]); }); - it('no commandType, CommandKind.SKILL falls back to all modes', () => { + it('CommandKind.SKILL without supportedModes falls back to all modes', () => { const cmd = makeCmd({ kind: CommandKind.SKILL }); expect(getEffectiveSupportedModes(cmd)).toEqual([ 'interactive', @@ -104,7 +68,7 @@ describe('getEffectiveSupportedModes', () => { ]); }); - it('no commandType, CommandKind.MCP_PROMPT falls back to all modes (fixes original bug)', () => { + it('CommandKind.MCP_PROMPT without supportedModes falls back to all modes (fixes original bug)', () => { const cmd = makeCmd({ kind: CommandKind.MCP_PROMPT }); expect(getEffectiveSupportedModes(cmd)).toEqual([ 'interactive', @@ -118,28 +82,26 @@ describe('filterCommandsForMode', () => { const commands: SlashCommand[] = [ makeCmd({ name: 'init', - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'], }), makeCmd({ name: 'model', - commandType: 'local-jsx', - // no explicit supportedModes → interactive only + supportedModes: ['interactive'], }), makeCmd({ name: 'review', kind: CommandKind.SKILL, - commandType: 'prompt', + supportedModes: ['interactive', 'non_interactive', 'acp'], }), makeCmd({ name: 'gh-prompt', kind: CommandKind.MCP_PROMPT, - commandType: 'prompt', + supportedModes: ['interactive', 'non_interactive', 'acp'], }), makeCmd({ name: 'my-script', kind: CommandKind.FILE, - commandType: 'prompt', + supportedModes: ['interactive', 'non_interactive', 'acp'], }), ]; @@ -154,7 +116,7 @@ describe('filterCommandsForMode', () => { ]); }); - it('non_interactive mode excludes local-jsx commands', () => { + it('non_interactive mode excludes interactive-only commands', () => { const result = filterCommandsForMode(commands, 'non_interactive'); expect(result.map((c) => c.name)).toEqual([ 'init', @@ -164,7 +126,7 @@ describe('filterCommandsForMode', () => { ]); }); - it('acp mode excludes local-jsx commands', () => { + it('acp mode excludes interactive-only commands', () => { const result = filterCommandsForMode(commands, 'acp'); expect(result.map((c) => c.name)).toEqual([ 'init', @@ -182,20 +144,22 @@ describe('filterCommandsForMode', () => { it('does not filter hidden commands (hidden filtering is caller responsibility)', () => { const withHidden = [ ...commands, - makeCmd({ name: 'hidden-cmd', commandType: 'local', hidden: true }), + makeCmd({ + name: 'hidden-cmd', + hidden: true, + // no supportedModes → BUILT_IN fallback → interactive only + }), ]; const result = filterCommandsForMode(withHidden, 'non_interactive'); // filterCommandsForMode does NOT filter hidden — it only filters by mode - // hidden-cmd has commandType: 'local' but no supportedModes, so it's interactive only expect(result.some((c) => c.name === 'hidden-cmd')).toBe(false); }); - it('hidden local command with explicit supportedModes still passes mode filter', () => { + it('hidden command with explicit all-mode supportedModes still passes mode filter', () => { const withHidden = [ ...commands, makeCmd({ name: 'hidden-cmd', - commandType: 'local', hidden: true, supportedModes: ['interactive', 'non_interactive', 'acp'], }), @@ -206,7 +170,9 @@ describe('filterCommandsForMode', () => { }); it('returns empty array when no commands match', () => { - const jsxOnly = [makeCmd({ name: 'model', commandType: 'local-jsx' })]; + const jsxOnly = [ + makeCmd({ name: 'model', supportedModes: ['interactive'] }), + ]; expect(filterCommandsForMode(jsxOnly, 'non_interactive')).toEqual([]); }); }); diff --git a/packages/cli/src/services/commandUtils.ts b/packages/cli/src/services/commandUtils.ts index ddd60aae3..3793c8491 100644 --- a/packages/cli/src/services/commandUtils.ts +++ b/packages/cli/src/services/commandUtils.ts @@ -20,50 +20,29 @@ import { /** * Returns the effective list of execution modes for a command. * - * Priority (highest to lowest): - * 1. Explicit `supportedModes` declaration on the command - * 2. Inference from `commandType` - * 3. Fallback based on `CommandKind` (backward-compat for commands that - * have not yet been migrated to declare commandType) + * All commands must explicitly declare `supportedModes` (Phase 2+ requirement). + * If a command omits it, this function falls back to a conservative default + * based on `CommandKind` — built-in commands default to interactive-only, + * while file/skill/mcp-prompt commands default to all modes. * * @param cmd The slash command to evaluate. * @returns The list of execution modes in which the command is available. */ export function getEffectiveSupportedModes(cmd: SlashCommand): ExecutionMode[] { - // Priority 1: explicit declaration wins + // Explicit declaration is always authoritative. if (cmd.supportedModes !== undefined) { return cmd.supportedModes; } - // Priority 2: infer from commandType - if (cmd.commandType !== undefined) { - switch (cmd.commandType) { - case 'prompt': - // prompt commands have no UI dependency — available in all modes - return ['interactive', 'non_interactive', 'acp']; - case 'local': - // local commands default to interactive only (conservative). - // Commands that are verified headless-friendly must explicitly declare - // supportedModes (mirrors Claude Code's supportsNonInteractive: true). - return ['interactive']; - case 'local-jsx': - // local-jsx commands always require the React/Ink runtime - return ['interactive']; - default: - return ['interactive']; - } - } - - // Priority 3: backward-compat fallback based on CommandKind. - // This branch should not be hit once all commands declare commandType. + // Fallback based on CommandKind for commands that omit supportedModes. + // Built-in commands without a declaration are conservative (interactive only). + // File / skill / MCP-prompt commands retain their historical all-mode behavior. switch (cmd.kind) { case CommandKind.BUILT_IN: - // Conservative default for unmigrated built-in commands return ['interactive']; case CommandKind.FILE: case CommandKind.SKILL: case CommandKind.MCP_PROMPT: - // These kinds have always been available in all modes return ['interactive', 'non_interactive', 'acp']; default: return ['interactive']; diff --git a/packages/cli/src/services/markdown-command-parser.ts b/packages/cli/src/services/markdown-command-parser.ts index 5d4a3b7df..bfe5a3e2b 100644 --- a/packages/cli/src/services/markdown-command-parser.ts +++ b/packages/cli/src/services/markdown-command-parser.ts @@ -18,7 +18,10 @@ export const MarkdownCommandDefSchema = z.object({ frontmatter: z .object({ description: z.string().optional(), + when_to_use: z.string().optional(), + 'disable-model-invocation': z.boolean().optional(), }) + .passthrough() .optional(), prompt: z.string({ required_error: 'The prompt content is required.', diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 2abf7be99..a63ebb484 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -29,6 +29,7 @@ export const createMockCommandContext = ( overrides: DeepPartial = {}, ): CommandContext => { const defaultMocks: CommandContext = { + executionMode: 'interactive', invocation: { raw: '', name: '', @@ -59,14 +60,13 @@ export const createMockCommandContext = ( setBtwItem: vi.fn(), cancelBtw: vi.fn(), btwAbortControllerRef: { current: null }, - awayRecapItem: null, - setAwayRecapItem: vi.fn(), isIdleRef: { current: true }, loadHistory: vi.fn(), toggleVimEnabled: vi.fn(), extensionsUpdateState: new Map(), setExtensionsUpdateState: vi.fn(), reloadCommands: vi.fn(), + setSessionName: vi.fn(), // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any, session: { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5b6e19358..51b02abbb 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -72,6 +72,7 @@ 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 { useDeleteCommand } from './hooks/useDeleteCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; import { useVimMode } from './contexts/VimModeContext.js'; import { CompactModeProvider } from './contexts/CompactModeContext.js'; @@ -324,6 +325,14 @@ export const AppContainer = (props: AppContainerProps) => { config, ); historyManager.loadHistory(historyItems); + + // Restore session name tag from custom title + const title = config + .getSessionService() + .getSessionTitle(config.getSessionId()); + if (title) { + setSessionName(title); + } } // Fire SessionStart event after config is initialized @@ -570,18 +579,33 @@ export const AppContainer = (props: AppContainerProps) => { const { activeArenaDialog, openArenaDialog, closeArenaDialog } = useArenaCommand(); + // Session name state (set via /rename, restored on /resume) + const [sessionName, setSessionName] = useState(null); + const { isResumeDialogOpen, + resumeMatchedSessions, openResumeDialog, closeResumeDialog, - handleResume: handleResumeInner, + handleResume, } = useResumeCommand({ config, historyManager, startNewSession, + setSessionName, remount: refreshStatic, }); + const { + isDeleteDialogOpen, + openDeleteDialog, + closeDeleteDialog, + handleDelete, + } = useDeleteCommand({ + config, + addItem: historyManager.addItem, + }); + const { toggleVimEnabled } = useVimMode(); const { @@ -631,6 +655,8 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, + handleResume, + openDeleteDialog, }), [ openAuthDialog, @@ -652,6 +678,8 @@ export const AppContainer = (props: AppContainerProps) => { openMcpDialog, openHooksDialog, openResumeDialog, + handleResume, + openDeleteDialog, ], ); @@ -662,8 +690,6 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, commandContext, shellConfirmationRequest, confirmationRequest, @@ -683,22 +709,7 @@ export const AppContainer = (props: AppContainerProps) => { extensionsUpdateStateInternal, isConfigInitialized, logger, - ); - - // Wrap handleResume so the sticky recap from the previous session - // doesn't carry over into the new one. Only clear after the inner - // handler confirms a session was actually loaded — otherwise (no - // session data, missing deps) we'd drop the current session's recap - // for no reason. - const handleResume = useCallback( - async (sessionId: string): Promise => { - const switched = await handleResumeInner(sessionId); - if (switched) { - setAwayRecapItem(null); - } - return switched; - }, - [handleResumeInner, setAwayRecapItem], + setSessionName, ); // onDebugMessage should log to debug logfile, not update footer debugMessage @@ -783,6 +794,8 @@ export const AppContainer = (props: AppContainerProps) => { activePtyId, loopDetectionConfirmationRequest, pendingToolCalls, + streamingResponseLengthRef, + isReceivingContent, } = useGeminiStream( config.getGeminiClient(), historyManager.history, @@ -1254,7 +1267,7 @@ export const AppContainer = (props: AppContainerProps) => { setControlsHeight(fullFooterMeasurement.height); } } - }, [buffer, terminalWidth, terminalHeight, awayRecapItem, btwItem]); + }, [buffer, terminalWidth, terminalHeight, btwItem]); // agentViewState is declared earlier (before handleFinalSubmit) so it // is available for input routing. Referenced here for layout computation. @@ -1287,7 +1300,10 @@ export const AppContainer = (props: AppContainerProps) => { config, isFocused, isIdle: streamingState === StreamingState.Idle, - setAwayRecapItem, + addItem: historyManager.addItem, + history: historyManager.history, + awayThresholdMinutes: + settings.merged.general?.sessionRecapAwayThresholdMinutes, }); // Context file names computation @@ -2017,6 +2033,7 @@ export const AppContainer = (props: AppContainerProps) => { isHooksDialogOpen || isApprovalModeDialogOpen || isResumeDialogOpen || + isDeleteDialogOpen || isExtensionsManagerDialogOpen || bgTasksDialogOpen; dialogsVisibleRef.current = dialogsVisible; @@ -2061,6 +2078,8 @@ export const AppContainer = (props: AppContainerProps) => { isPermissionsDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, + resumeMatchedSessions, + isDeleteDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -2110,8 +2129,6 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, nightly, branchName, sessionStats, @@ -2143,6 +2160,12 @@ export const AppContainer = (props: AppContainerProps) => { isFeedbackDialogOpen, // Per-task token tracking taskStartTokens, + // Real-time token display + streamingResponseLengthRef, + isReceivingContent, + // Session name + sessionName, + setSessionName, // Prompt suggestion promptSuggestion, dismissPromptSuggestion, @@ -2170,6 +2193,8 @@ export const AppContainer = (props: AppContainerProps) => { isPermissionsDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, + resumeMatchedSessions, + isDeleteDialogOpen, slashCommands, pendingSlashCommandHistoryItems, commandContext, @@ -2218,8 +2243,6 @@ export const AppContainer = (props: AppContainerProps) => { btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, nightly, branchName, sessionStats, @@ -2253,6 +2276,12 @@ export const AppContainer = (props: AppContainerProps) => { isFeedbackDialogOpen, // Per-task token tracking taskStartTokens, + // Real-time token display + streamingResponseLengthRef, + isReceivingContent, + // Session name + sessionName, + setSessionName, // Prompt suggestion promptSuggestion, dismissPromptSuggestion, @@ -2316,6 +2345,10 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, closeResumeDialog, handleResume, + // Delete session dialog + openDeleteDialog, + closeDeleteDialog, + handleDelete, // Feedback dialog openFeedbackDialog, closeFeedbackDialog, @@ -2376,6 +2409,10 @@ export const AppContainer = (props: AppContainerProps) => { openResumeDialog, closeResumeDialog, handleResume, + // Delete session dialog + openDeleteDialog, + closeDeleteDialog, + handleDelete, // Feedback dialog openFeedbackDialog, closeFeedbackDialog, diff --git a/packages/cli/src/ui/auth/AuthDialog.tsx b/packages/cli/src/ui/auth/AuthDialog.tsx index c5ef088c6..f7fb402b2 100644 --- a/packages/cli/src/ui/auth/AuthDialog.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.tsx @@ -6,7 +6,11 @@ import type React from 'react'; import { useState } from 'react'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { + AuthType, + CodingPlanRegion, + isCodingPlanConfig, +} from '@qwen-code/qwen-code-core'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { theme } from '../semantic-colors.js'; @@ -18,10 +22,6 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { t } from '../../i18n/index.js'; -import { - CodingPlanRegion, - isCodingPlanConfig, -} from '../../constants/codingPlan.js'; import { ALIBABA_STANDARD_API_KEY_ENDPOINTS, type AlibabaStandardRegion, diff --git a/packages/cli/src/ui/auth/useAuth.ts b/packages/cli/src/ui/auth/useAuth.ts index 86c3857aa..eb32d7f87 100644 --- a/packages/cli/src/ui/auth/useAuth.ts +++ b/packages/cli/src/ui/auth/useAuth.ts @@ -15,6 +15,10 @@ import { AuthType, getErrorMessage, logAuth, + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, } from '@qwen-code/qwen-code-core'; import { useCallback, useEffect, useState } from 'react'; import type { LoadedSettings } from '../../config/settings.js'; @@ -29,12 +33,6 @@ import { useQwenAuth } from '../hooks/useQwenAuth.js'; import { AuthState, MessageType } from '../types.js'; import type { HistoryItem } from '../types.js'; import { t } from '../../i18n/index.js'; -import { - getCodingPlanConfig, - isCodingPlanConfig, - CodingPlanRegion, - CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; import { backupSettingsFile } from '../../utils/settingsUtils.js'; import { ALIBABA_STANDARD_API_KEY_ENDPOINTS, diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 4920f2404..53371e629 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -280,4 +280,65 @@ describe('aboutCommand', () => { expect.any(Number), ); }); + + describe('non-interactive mode', () => { + it('should return text summary without calling addItem', async () => { + if (!aboutCommand.action) { + throw new Error('The about command must have an action.'); + } + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + } as unknown as Partial); + // Attach a spy to the non-interactive context's ui + nonInteractiveContext.ui.addItem = vi.fn(); + + const result = await aboutCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('test-version'), + }); + expect(result).toEqual( + expect.objectContaining({ + content: expect.stringContaining('test-model'), + }), + ); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should include git commit and IDE when available', async () => { + if (!aboutCommand.action) throw new Error('No action'); + + vi.mocked(systemInfoUtils.getExtendedSystemInfo).mockResolvedValue({ + cliVersion: 'test-version', + osPlatform: 'test-os', + osArch: 'x64', + osRelease: '22.0.0', + nodeVersion: 'v20.0.0', + npmVersion: '10.0.0', + sandboxEnv: 'no sandbox', + modelVersion: 'test-model', + selectedAuthType: 'test-auth', + ideClient: 'vscode', + sessionId: 'sess-1', + memoryUsage: '100 MB', + baseUrl: undefined, + gitCommit: 'abc1234', + }); + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + } as unknown as Partial); + + const result = (await aboutCommand.action(nonInteractiveContext, '')) as { + type: string; + content: string; + }; + + expect(result.content).toContain('abc1234'); + expect(result.content).toContain('vscode'); + }); + }); }); diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 60fce9f96..d6bcbcca3 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -17,15 +17,37 @@ export const aboutCommand: SlashCommand = { return t('show version info'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context) => { const systemInfo = await getExtendedSystemInfo(context); + if (context.executionMode !== 'interactive') { + const lines = [ + `Qwen Code v${systemInfo.cliVersion}`, + `Model: ${systemInfo.modelVersion}`, + `Fast Model: ${systemInfo.fastModel ?? 'not set'}`, + `Auth: ${systemInfo.selectedAuthType}`, + `Platform: ${systemInfo.osPlatform} ${systemInfo.osArch} (${systemInfo.osRelease})`, + `Node.js: ${systemInfo.nodeVersion}`, + `Session: ${systemInfo.sessionId}`, + ...(systemInfo.gitCommit + ? [`Git commit: ${systemInfo.gitCommit}`] + : []), + ...(systemInfo.ideClient ? [`IDE: ${systemInfo.ideClient}`] : []), + ]; + return { + type: 'message' as const, + messageType: 'info' as const, + content: lines.join('\n'), + }; + } + const aboutItem: Omit = { type: MessageType.ABOUT, systemInfo, }; context.ui.addItem(aboutItem, Date.now()); + return; }, }; diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index 94b8d2cb0..118cc9c55 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -17,7 +17,7 @@ export const agentsCommand: SlashCommand = { return t('Manage subagents for specialized task delegation.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ { name: 'manage', @@ -25,7 +25,7 @@ export const agentsCommand: SlashCommand = { return t('Manage existing subagents (view, edit, delete).'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'subagent_list', @@ -37,7 +37,7 @@ export const agentsCommand: SlashCommand = { return t('Create a new subagent with guided setup.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'subagent_create', diff --git a/packages/cli/src/ui/commands/approvalModeCommand.ts b/packages/cli/src/ui/commands/approvalModeCommand.ts index b66cc8a38..e96695680 100644 --- a/packages/cli/src/ui/commands/approvalModeCommand.ts +++ b/packages/cli/src/ui/commands/approvalModeCommand.ts @@ -34,14 +34,15 @@ export const approvalModeCommand: SlashCommand = { return t('View or change the approval mode for tool usage'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, ): Promise => { const mode = parseApprovalModeArg(args); - // If no argument provided, open the dialog + // If no argument provided, open dialog in interactive mode; + // in non-interactive/ACP, return current state instead if (!args.trim()) { return { type: 'dialog', diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index f5fbb04c4..1ce8b94e2 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -276,6 +276,9 @@ function executeArenaCommand( rounds: result.stats.rounds, error: result.error, diff: result.diff, + diffSummary: result.diffSummary, + modifiedFiles: result.modifiedFiles, + approachSummary: result.approachSummary, }); const handleAgentComplete = (event: ArenaAgentCompleteEvent) => { @@ -384,14 +387,14 @@ export const arenaCommand: SlashCommand = { name: 'arena', description: 'Manage Arena sessions', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ { name: 'start', description: 'Start an Arena session with multiple models competing on the same task', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, @@ -448,7 +451,7 @@ export const arenaCommand: SlashCommand = { name: 'stop', description: 'Stop the current Arena session', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, ): Promise => { @@ -490,7 +493,7 @@ export const arenaCommand: SlashCommand = { name: 'status', description: 'Show the current Arena session status', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, ): Promise => { @@ -533,7 +536,7 @@ export const arenaCommand: SlashCommand = { description: 'Select a model result and merge its diff into the current workspace', kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index e7abf552f..73e3e029d 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -15,7 +15,7 @@ export const authCommand: SlashCommand = { return t('Configure authentication information for login'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'auth', diff --git a/packages/cli/src/ui/commands/btwCommand.test.ts b/packages/cli/src/ui/commands/btwCommand.test.ts index 5d2b42572..5aebbbb1a 100644 --- a/packages/cli/src/ui/commands/btwCommand.test.ts +++ b/packages/cli/src/ui/commands/btwCommand.test.ts @@ -434,109 +434,4 @@ describe('btwCommand', () => { expect(mockContext.ui.setBtwItem).toHaveBeenCalledTimes(2); }); }); - - describe('non-interactive mode', () => { - let nonInteractiveContext: CommandContext; - - beforeEach(() => { - nonInteractiveContext = createMockCommandContext({ - executionMode: 'non_interactive', - services: { - config: createConfig(), - }, - }); - }); - - it('should return info message on success', async () => { - mockRunForkedAgent.mockResolvedValue({ - text: 'the answer', - usage: { inputTokens: 5, outputTokens: 2, cacheHitTokens: 0 }, - }); - - const result = await btwCommand.action!( - nonInteractiveContext, - 'my question', - ); - - expect(result).toEqual({ - type: 'message', - messageType: 'info', - content: 'btw> my question\nthe answer', - }); - }); - - it('should return error message on failure', async () => { - mockRunForkedAgent.mockRejectedValue(new Error('network error')); - - const result = await btwCommand.action!( - nonInteractiveContext, - 'my question', - ); - - expect(result).toEqual({ - type: 'message', - messageType: 'error', - content: 'Failed to answer btw question: network error', - }); - }); - }); - - describe('acp mode', () => { - let acpContext: CommandContext; - - beforeEach(() => { - acpContext = createMockCommandContext({ - executionMode: 'acp', - services: { - config: createConfig(), - }, - }); - }); - - it('should return stream_messages generator on success', async () => { - mockRunForkedAgent.mockResolvedValue({ - text: 'streamed answer', - usage: { inputTokens: 5, outputTokens: 3, cacheHitTokens: 0 }, - }); - - const result = (await btwCommand.action!(acpContext, 'my question')) as { - type: string; - messages: AsyncGenerator; - }; - - expect(result.type).toBe('stream_messages'); - - const messages = []; - for await (const msg of result.messages) { - messages.push(msg); - } - - expect(messages).toEqual([ - { messageType: 'info', content: 'Thinking...' }, - { messageType: 'info', content: 'btw> my question\nstreamed answer' }, - ]); - }); - - it('should yield error message on failure', async () => { - mockRunForkedAgent.mockRejectedValue(new Error('api failure')); - - const result = (await btwCommand.action!(acpContext, 'my question')) as { - type: string; - messages: AsyncGenerator; - }; - - const messages = []; - for await (const msg of result.messages) { - messages.push(msg); - } - - expect(messages).toEqual([ - { messageType: 'info', content: 'Thinking...' }, - { - messageType: 'error', - content: 'Failed to answer btw question: api failure', - }, - ]); - }); - }); }); diff --git a/packages/cli/src/ui/commands/btwCommand.ts b/packages/cli/src/ui/commands/btwCommand.ts index 80d0c6260..801b6a275 100644 --- a/packages/cli/src/ui/commands/btwCommand.ts +++ b/packages/cli/src/ui/commands/btwCommand.ts @@ -123,15 +123,12 @@ export const btwCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local', - supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, ): Promise => { const question = args.trim(); - const executionMode = context.executionMode ?? 'interactive'; - const abortSignal = context.abortSignal ?? new AbortController().signal; if (!question) { return { @@ -152,53 +149,17 @@ export const btwCommand: SlashCommand = { }; } - // ACP mode: return a stream_messages async generator - if (executionMode === 'acp') { - const messages = async function* () { - try { - yield { - messageType: 'info' as const, - content: t('Thinking...'), - }; - - const answer = await askBtw(context, question, abortSignal); - - yield { - messageType: 'info' as const, - content: `btw> ${question}\n${answer}`, - }; - } catch (error) { - yield { - messageType: 'error' as const, - content: formatBtwError(error), - }; - } + const model = config.getModel(); + if (!model) { + return { + type: 'message', + messageType: 'error', + content: t('No model configured.'), }; - - return { type: 'stream_messages', messages: messages() }; - } - - // Non-interactive mode: return a simple message result - if (executionMode === 'non_interactive') { - try { - const answer = await askBtw(context, question, abortSignal); - return { - type: 'message', - messageType: 'info', - content: `btw> ${question}\n${answer}`, - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: formatBtwError(error), - }; - } } // Interactive mode: use dedicated btwItem state for the fixed bottom area. // This does NOT occupy pendingItem, so the main conversation is never blocked. - // Cancel any previous in-flight btw before starting a new one. ui.cancelBtw(); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 543741b13..ffd932f1b 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -21,7 +21,6 @@ export const bugCommand: SlashCommand = { return t('submit a bug report'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 61e66b53e..6b84ededc 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -227,4 +227,65 @@ describe('clearCommand', () => { expect(mockResetChat).not.toHaveBeenCalled(); expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1); }); + + describe('non-interactive mode', () => { + let nonInteractiveContext: ReturnType; + + beforeEach(() => { + nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getHookSystem: mockGetHookSystem, + startNewSession: mockStartNewSession, + getGeminiClient: vi.fn().mockReturnValue({ + resetChat: mockResetChat, + } as unknown as GeminiClient), + getModel: vi.fn().mockReturnValue('test-model'), + getApprovalMode: vi.fn().mockReturnValue('default'), + getToolRegistry: vi.fn().mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([]), + }), + }, + }, + session: { + startNewSession: vi.fn(), + }, + }); + }); + + it('should return context boundary message in non-interactive mode', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + const result = await clearCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Context cleared. Previous messages are no longer in context.', + }); + }); + + it('should still call resetChat in non-interactive mode', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + await clearCommand.action(nonInteractiveContext, ''); + + expect(mockResetChat).toHaveBeenCalledTimes(1); + }); + + it('should still fire session events in non-interactive mode', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + await clearCommand.action(nonInteractiveContext, ''); + + expect(mockFireSessionEndEvent).toHaveBeenCalledWith( + SessionEndReason.Clear, + ); + expect(mockFireSessionStartEvent).toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index f55b3beed..dbf9a0d41 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -22,7 +22,7 @@ export const clearCommand: SlashCommand = { return t('Clear conversation history and free up context'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context, _args) => { const { config } = context.services; @@ -83,5 +83,14 @@ export const clearCommand: SlashCommand = { context.ui.setDebugMessage(t('Starting a new session and clearing.')); context.ui.clear(); } + + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'info' as const, + content: 'Context cleared. Previous messages are no longer in context.', + }; + } + return; }, }; diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index b3cbd8c8b..4178bf1be 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -17,7 +17,6 @@ export const compressCommand: SlashCommand = { return t('Compresses the context by replacing it with a summary.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context) => { const { ui } = context; diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index 67a4fc611..af3973396 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -308,6 +308,166 @@ export async function collectContextData( }; } +/** + * Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k") + */ +function fmtTokens(tokens: number): string { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return `${tokens}`; +} + +/** + * Format a category row as text: " label .............. 1.2k tokens (3.4%)" + */ +function fmtCategoryRow( + label: string, + tokens: number, + contextWindowSize: number, + indent = ' ', +): string { + const percentage = ((tokens / contextWindowSize) * 100).toFixed(1); + const right = `${fmtTokens(tokens)} tokens (${percentage}%)`; + const leftPart = `${indent}${label}`; + const totalWidth = 56; + const dots = Math.max(1, totalWidth - leftPart.length - right.length); + return `${leftPart}${' '.repeat(dots)}${right}`; +} + +/** + * Convert a HistoryItemContextUsage to a human-readable text string, + * mirroring the layout of the interactive ContextUsage component. + */ +export function formatContextUsageText(data: HistoryItemContextUsage): string { + const { + modelName, + totalTokens, + contextWindowSize, + breakdown, + builtinTools, + mcpTools, + memoryFiles, + skills, + isEstimated, + showDetails, + } = data; + + const lines: string[] = []; + lines.push('## Context Usage'); + lines.push(''); + + if (isEstimated) { + lines.push('*No API response yet. Send a message to see actual usage.*'); + lines.push(''); + lines.push('**Estimated pre-conversation overhead**'); + lines.push( + `Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`, + ); + lines.push(''); + } else { + lines.push( + `Model: ${modelName} Context window: ${fmtTokens(contextWindowSize)} tokens`, + ); + lines.push(''); + lines.push(fmtCategoryRow('Used', totalTokens, contextWindowSize)); + lines.push(fmtCategoryRow('Free', breakdown.freeSpace, contextWindowSize)); + lines.push( + fmtCategoryRow( + 'Autocompact buffer', + breakdown.autocompactBuffer, + contextWindowSize, + ), + ); + lines.push(''); + lines.push('**Usage by category**'); + } + + lines.push( + fmtCategoryRow('System prompt', breakdown.systemPrompt, contextWindowSize), + ); + lines.push( + fmtCategoryRow('Built-in tools', breakdown.builtinTools, contextWindowSize), + ); + if (breakdown.mcpTools > 0) { + lines.push( + fmtCategoryRow('MCP tools', breakdown.mcpTools, contextWindowSize), + ); + } + lines.push( + fmtCategoryRow('Memory files', breakdown.memoryFiles, contextWindowSize), + ); + lines.push(fmtCategoryRow('Skills', breakdown.skills, contextWindowSize)); + if (!isEstimated) { + lines.push( + fmtCategoryRow('Messages', breakdown.messages, contextWindowSize), + ); + } + + if (showDetails) { + const sortedBuiltin = [...builtinTools].sort((a, b) => b.tokens - a.tokens); + const sortedMcp = [...mcpTools].sort((a, b) => b.tokens - a.tokens); + const sortedMemory = [...memoryFiles].sort((a, b) => b.tokens - a.tokens); + const sortedSkills = [...skills].sort((a, b) => { + if (a.loaded !== b.loaded) return a.loaded ? -1 : 1; + return b.tokens + (b.bodyTokens ?? 0) - (a.tokens + (a.bodyTokens ?? 0)); + }); + + if (sortedBuiltin.length > 0) { + lines.push(''); + lines.push('**Built-in tools**'); + for (const tool of sortedBuiltin) { + lines.push( + fmtCategoryRow(tool.name, tool.tokens, contextWindowSize, ' └ '), + ); + } + } + if (sortedMcp.length > 0) { + lines.push(''); + lines.push('**MCP tools**'); + for (const tool of sortedMcp) { + lines.push( + fmtCategoryRow(tool.name, tool.tokens, contextWindowSize, ' └ '), + ); + } + } + if (sortedMemory.length > 0) { + lines.push(''); + lines.push('**Memory files**'); + for (const file of sortedMemory) { + lines.push( + fmtCategoryRow(file.path, file.tokens, contextWindowSize, ' └ '), + ); + } + } + if (sortedSkills.length > 0) { + lines.push(''); + lines.push('**Skills**'); + for (const skill of sortedSkills) { + const label = skill.loaded ? `${skill.name} (active)` : skill.name; + lines.push( + fmtCategoryRow(label, skill.tokens, contextWindowSize, ' └ '), + ); + if (skill.loaded && skill.bodyTokens && skill.bodyTokens > 0) { + lines.push( + fmtCategoryRow( + 'body loaded', + skill.bodyTokens, + contextWindowSize, + ' └ ', + ), + ); + } + } + } + } else { + lines.push(''); + lines.push('*Run /context detail for per-item breakdown.*'); + } + + return lines.join('\n'); +} + export const contextCommand: SlashCommand = { name: 'context', get description() { @@ -316,7 +476,6 @@ export const contextCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context: CommandContext, args?: string) => { const showDetails = @@ -351,7 +510,7 @@ export const contextCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: JSON.stringify(contextUsageItem, null, 2), + content: formatContextUsageText(contextUsageItem), }; } }, @@ -362,7 +521,6 @@ export const contextCommand: SlashCommand = { return t('Show per-item context usage breakdown.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context: CommandContext) => { // Delegate to main action with 'detail' arg to show detailed view diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 4eec750cc..634986902 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -15,7 +15,7 @@ export const copyCommand: SlashCommand = { return t('Copy the last result or code snippet to clipboard'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context, _args): Promise => { const chat = await context.services.config?.getGeminiClient()?.getChat(); const history = chat?.getHistory(); diff --git a/packages/cli/src/ui/commands/deleteCommand.test.ts b/packages/cli/src/ui/commands/deleteCommand.test.ts new file mode 100644 index 000000000..81c4bfd34 --- /dev/null +++ b/packages/cli/src/ui/commands/deleteCommand.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { deleteCommand } from './deleteCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('deleteCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have the correct name and description', () => { + expect(deleteCommand.name).toBe('delete'); + expect(deleteCommand.description).toBe('Delete a previous session'); + }); + + it('should return a dialog action to open the delete dialog', async () => { + const result = await deleteCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'delete', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/deleteCommand.ts b/packages/cli/src/ui/commands/deleteCommand.ts new file mode 100644 index 000000000..194283a63 --- /dev/null +++ b/packages/cli/src/ui/commands/deleteCommand.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +export const deleteCommand: SlashCommand = { + name: 'delete', + kind: CommandKind.BUILT_IN, + get description() { + return t('Delete a previous session'); + }, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'delete', + }), +}; diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 70acf6463..d28e7a3b9 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -74,7 +74,7 @@ export const directoryCommand: SlashCommand = { return t('Manage workspace directories'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ { name: 'add', @@ -84,7 +84,7 @@ export const directoryCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, completion: async (_context: CommandContext, partialArg: string) => getDirPathCompletions(partialArg), action: async (context: CommandContext, args: string) => { @@ -224,7 +224,7 @@ export const directoryCommand: SlashCommand = { return t('Show all directories in the workspace'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext) => { const { ui: { addItem }, diff --git a/packages/cli/src/ui/commands/docsCommand.test.ts b/packages/cli/src/ui/commands/docsCommand.test.ts index b38b64dd6..fce3c29fa 100644 --- a/packages/cli/src/ui/commands/docsCommand.test.ts +++ b/packages/cli/src/ui/commands/docsCommand.test.ts @@ -96,4 +96,24 @@ describe('docsCommand', () => { // 'open' should be called in this specific sandbox case expect(open).toHaveBeenCalledWith(docsUrl); }); + + describe('non-interactive mode', () => { + it('should return docs URL without opening browser', async () => { + if (!docsCommand.action) throw new Error('Command has no action'); + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + }); + + const result = await docsCommand.action(nonInteractiveContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('qwenlm.github.io'), + }); + expect(open).not.toHaveBeenCalled(); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/docsCommand.ts b/packages/cli/src/ui/commands/docsCommand.ts index bde817c7e..6de9c492c 100644 --- a/packages/cli/src/ui/commands/docsCommand.ts +++ b/packages/cli/src/ui/commands/docsCommand.ts @@ -20,11 +20,20 @@ export const docsCommand: SlashCommand = { return t('open full Qwen Code documentation in your browser'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: async (context: CommandContext): Promise => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: async (context: CommandContext) => { const langPath = getCurrentLanguage()?.startsWith('zh') ? 'zh' : 'en'; const docsUrl = `https://qwenlm.github.io/qwen-code-docs/${langPath}`; + // Non-interactive/ACP: return URL directly, no browser, no addItem + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'info' as const, + content: `Qwen Code documentation: ${docsUrl}`, + }; + } + if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { context.ui.addItem( { @@ -50,5 +59,6 @@ export const docsCommand: SlashCommand = { ); await open(docsUrl); } + return; }, }; diff --git a/packages/cli/src/ui/commands/doctorCommand.ts b/packages/cli/src/ui/commands/doctorCommand.ts index d44d9ed5c..975a319ca 100644 --- a/packages/cli/src/ui/commands/doctorCommand.ts +++ b/packages/cli/src/ui/commands/doctorCommand.ts @@ -16,6 +16,7 @@ export const doctorCommand: SlashCommand = { return t('Run installation and environment diagnostics'); }, kind: CommandKind.BUILT_IN, + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context) => { const executionMode = context.executionMode ?? 'interactive'; const abortSignal = context.abortSignal; diff --git a/packages/cli/src/ui/commands/editorCommand.ts b/packages/cli/src/ui/commands/editorCommand.ts index 54a4097a9..9ecf5ba99 100644 --- a/packages/cli/src/ui/commands/editorCommand.ts +++ b/packages/cli/src/ui/commands/editorCommand.ts @@ -17,7 +17,7 @@ export const editorCommand: SlashCommand = { return t('set external editor preference'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'editor', diff --git a/packages/cli/src/ui/commands/exportCommand.ts b/packages/cli/src/ui/commands/exportCommand.ts index a7cc8f8bc..b86e8aa6e 100644 --- a/packages/cli/src/ui/commands/exportCommand.ts +++ b/packages/cli/src/ui/commands/exportCommand.ts @@ -325,7 +325,8 @@ export const exportCommand: SlashCommand = { return t('Export current session message history to a file'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: exportHtmlAction, subCommands: [ { name: 'html', @@ -333,7 +334,7 @@ export const exportCommand: SlashCommand = { return t('Export session to HTML format'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportHtmlAction, }, { @@ -342,7 +343,7 @@ export const exportCommand: SlashCommand = { return t('Export session to markdown format'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportMarkdownAction, }, { @@ -351,7 +352,7 @@ export const exportCommand: SlashCommand = { return t('Export session to JSON format'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportJsonAction, }, { @@ -360,7 +361,7 @@ export const exportCommand: SlashCommand = { return t('Export session to JSONL format (one message per line)'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: exportJsonlAction, }, ], diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index a8661400a..49702e662 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -216,7 +216,7 @@ const exploreExtensionsCommand: SlashCommand = { return t('Open extensions page in your browser'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: exploreAction, completion: completeExtensionsExplore, }; @@ -227,7 +227,7 @@ const manageExtensionsCommand: SlashCommand = { return t('Manage installed extensions'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: listAction, }; @@ -237,7 +237,7 @@ const installCommand: SlashCommand = { return t('Install an extension from a git repo or local path'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: installAction, }; @@ -247,7 +247,7 @@ export const extensionsCommand: SlashCommand = { return t('Manage extensions'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [ manageExtensionsCommand, installCommand, diff --git a/packages/cli/src/ui/commands/helpCommand.ts b/packages/cli/src/ui/commands/helpCommand.ts index 0c4d528a3..659158b7c 100644 --- a/packages/cli/src/ui/commands/helpCommand.ts +++ b/packages/cli/src/ui/commands/helpCommand.ts @@ -13,7 +13,7 @@ export const helpCommand: SlashCommand = { name: 'help', altNames: ['?'], kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, get description() { return t('for help on Qwen Code'); }, diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index f6b28fefe..c0474a756 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -43,7 +43,7 @@ const listCommand: SlashCommand = { return t('List all configured hooks'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, _args: string, @@ -186,7 +186,7 @@ export const hooksCommand: SlashCommand = { return t('Manage Qwen Code hooks'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index 9d75fcc34..5b9919067 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -143,7 +143,7 @@ export const ideCommand = async (): Promise => { return t('manage IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): SlashCommandActionReturn => ({ type: 'message', @@ -161,7 +161,7 @@ export const ideCommand = async (): Promise => { return t('manage IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, subCommands: [], }; @@ -171,7 +171,7 @@ export const ideCommand = async (): Promise => { return t('check status of IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (): Promise => { const { messageType, content } = await getIdeStatusMessageWithFiles(ideClient); @@ -192,7 +192,7 @@ export const ideCommand = async (): Promise => { }); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context) => { const installer = getIdeInstaller(currentIDE); const isSandBox = !!process.env['SANDBOX']; @@ -280,7 +280,7 @@ export const ideCommand = async (): Promise => { return t('enable IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, @@ -305,7 +305,7 @@ export const ideCommand = async (): Promise => { return t('disable IDE integration'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext) => { context.services.settings.setValue( SettingScope.User, diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index cf6af99bb..15f89de82 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -23,7 +23,6 @@ export const initCommand: SlashCommand = { return t('Analyzes the project and creates a tailored QWEN.md file.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, diff --git a/packages/cli/src/ui/commands/insightCommand.test.ts b/packages/cli/src/ui/commands/insightCommand.test.ts index 272ce9d73..43752ec4c 100644 --- a/packages/cli/src/ui/commands/insightCommand.test.ts +++ b/packages/cli/src/ui/commands/insightCommand.test.ts @@ -171,4 +171,62 @@ describe('insightCommand', () => { ), }); }); + + it('non_interactive: returns message with output path and does not open browser', async () => { + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + const result = await insightCommand.action(nonInteractiveContext, ''); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'info', + }); + expect((result as { content: string }).content).toContain( + path.resolve('runtime-output', 'insights', 'insight-2026-03-05.html'), + ); + expect(open).not.toHaveBeenCalled(); + }); + + it('non_interactive: returns error message when generation fails', async () => { + mockGenerateStaticInsight.mockRejectedValue(new Error('disk full')); + + const nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: {} as CommandContext['services']['config'], + }, + ui: { + addItem: vi.fn(), + setPendingItem: vi.fn(), + setDebugMessage: vi.fn(), + }, + } as unknown as CommandContext); + + if (!insightCommand.action) { + throw new Error('insight command must have action'); + } + + const result = await insightCommand.action(nonInteractiveContext, ''); + + expect(result).toMatchObject({ + type: 'message', + messageType: 'error', + }); + expect((result as { content: string }).content).toContain('disk full'); + expect(open).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/commands/insightCommand.ts b/packages/cli/src/ui/commands/insightCommand.ts index 5c957d1df..6f1ed8915 100644 --- a/packages/cli/src/ui/commands/insightCommand.ts +++ b/packages/cli/src/ui/commands/insightCommand.ts @@ -29,19 +29,53 @@ export const insightCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context: CommandContext) => { try { context.ui.setDebugMessage(t('Generating insights...')); const projectsDir = join(Storage.getRuntimeBaseDir(), 'projects'); if (!context.services.config) { + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: 'Config service is not available.', + }; + } throw new Error('Config service is not available'); } const insightGenerator = new StaticInsightGenerator( context.services.config, ); + if (context.executionMode === 'non_interactive') { + // non_interactive: run synchronously and return a single message + try { + const outputPath = await insightGenerator.generateStaticInsight( + projectsDir, + () => { + // progress callback is no-op in non_interactive mode + }, + ); + return { + type: 'message' as const, + messageType: 'info' as const, + content: t('Insight report generated at: {{path}}', { + path: outputPath, + }), + }; + } catch (error) { + return { + type: 'message' as const, + messageType: 'error' as const, + content: t('Failed to generate insights: {{error}}', { + error: (error as Error).message, + }), + }; + } + } + if (context.executionMode === 'acp') { const pendingMessages: Array<{ messageType: 'info' | 'error'; @@ -215,6 +249,14 @@ export const insightCommand: SlashCommand = { } catch (error) { context.ui.setPendingItem(null); + if (context.executionMode !== 'interactive') { + return { + type: 'message' as const, + messageType: 'error' as const, + content: `Failed to generate insights: ${(error as Error).message}`, + }; + } + context.ui.addItem( { type: MessageType.ERROR, @@ -228,5 +270,6 @@ export const insightCommand: SlashCommand = { logger.error('Insight generation error:', error); return; } + return; }, }; diff --git a/packages/cli/src/ui/commands/languageCommand.ts b/packages/cli/src/ui/commands/languageCommand.ts index c459959be..7a5834a81 100644 --- a/packages/cli/src/ui/commands/languageCommand.ts +++ b/packages/cli/src/ui/commands/languageCommand.ts @@ -183,7 +183,7 @@ export const languageCommand: SlashCommand = { return t('View or change the language setting'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, @@ -269,7 +269,7 @@ export const languageCommand: SlashCommand = { return t('Set UI language'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, @@ -324,7 +324,7 @@ export const languageCommand: SlashCommand = { }); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context, args) => { if (args.trim()) { return { @@ -348,7 +348,7 @@ export const languageCommand: SlashCommand = { return t('Set LLM output language'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async ( context: CommandContext, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index a751c5abd..6e3c3d0d0 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -14,7 +14,7 @@ export const mcpCommand: SlashCommand = { return t('Open MCP management dialog'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (): Promise => ({ type: 'dialog', dialog: 'mcp', diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index fc10061a3..65c27a601 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -14,7 +14,7 @@ export const memoryCommand: SlashCommand = { return t('Open the memory manager.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async () => ({ type: 'dialog', dialog: 'memory', diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index b39f1a6aa..7da549519 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -139,4 +139,57 @@ describe('modelCommand', () => { content: 'Authentication type not available.', }); }); + + describe('non-interactive mode', () => { + it('should return current model without triggering dialog when no args', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'qwen-max', + authType: AuthType.QWEN_OAUTH, + }), + getModel: vi.fn().mockReturnValue('qwen-max'), + }, + }, + }); + + const result = await modelCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('qwen-max'), + }); + expect((result as { type: string }).type).toBe('message'); + }); + + it('should return current fast model without triggering dialog for --fast no args', async () => { + mockContext = createMockCommandContext({ + executionMode: 'non_interactive', + invocation: { args: '--fast' }, + services: { + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'qwen-max', + authType: AuthType.QWEN_OAUTH, + }), + getModel: vi.fn().mockReturnValue('qwen-max'), + }, + settings: { + merged: { fastModel: 'qwen-turbo' } as Record, + }, + }, + }); + + const result = await modelCommand.action!(mockContext, '--fast'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: expect.stringContaining('qwen-turbo'), + }); + }); + }); }); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index c0af230bd..1ee5e6083 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -21,7 +21,7 @@ export const modelCommand: SlashCommand = { return t('Switch the model for this session (--fast for suggestion model)'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, completion: async (_context, partialArg) => { if (partialArg && '--fast'.startsWith(partialArg)) { return [ @@ -54,7 +54,16 @@ export const modelCommand: SlashCommand = { if (args.startsWith('--fast')) { const modelName = args.replace('--fast', '').trim(); if (!modelName) { - // Open model dialog in fast-model mode + // Open model dialog in fast-model mode (interactive) or return current fast model (non-interactive) + if (context.executionMode !== 'interactive') { + const fastModel = + context.services.settings?.merged?.fastModel ?? 'not set'; + return { + type: 'message', + messageType: 'info', + content: `Current fast model: ${fastModel}\nUse "/model --fast " to set fast model.`, + }; + } return { type: 'dialog', dialog: 'fast-model', @@ -101,6 +110,39 @@ export const modelCommand: SlashCommand = { }; } + // Non-interactive/ACP: set model if an arg was provided, otherwise show current model + if (context.executionMode !== 'interactive') { + const modelName = args.trim(); + if (modelName) { + // /model — set the main model + if (!settings) { + return { + type: 'message', + messageType: 'error', + content: t('Settings service not available.'), + }; + } + settings.setValue( + getPersistScopeForModelSelection(settings), + 'model.name', + modelName, + ); + await config.setModel(modelName); + return { + type: 'message', + messageType: 'info', + content: t('Model') + ': ' + modelName, + }; + } + // /model with no args — show current model + const currentModel = config.getModel() ?? 'unknown'; + return { + type: 'message', + messageType: 'info', + content: `Current model: ${currentModel}\nUse "/model " to switch models or "/model --fast " to set the fast model.`, + }; + } + return { type: 'dialog', dialog: 'model', diff --git a/packages/cli/src/ui/commands/permissionsCommand.ts b/packages/cli/src/ui/commands/permissionsCommand.ts index f1ab21915..e96448b85 100644 --- a/packages/cli/src/ui/commands/permissionsCommand.ts +++ b/packages/cli/src/ui/commands/permissionsCommand.ts @@ -14,7 +14,7 @@ export const permissionsCommand: SlashCommand = { return t('Manage permission rules'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'permissions', diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index 6dba0f0c6..8839cd809 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -20,7 +20,7 @@ export const planCommand: SlashCommand = { return t('Switch to plan mode or exit plan mode'); }, kind: CommandKind.BUILT_IN, - commandType: 'local', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, args: string, diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 9d7f7623d..6d0ef5e67 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -15,7 +15,7 @@ export const quitCommand: SlashCommand = { return t('exit the cli'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (context) => { const now = Date.now(); const { sessionStartTime } = context.session.stats; diff --git a/packages/cli/src/ui/commands/recapCommand.ts b/packages/cli/src/ui/commands/recapCommand.ts index 0380919c2..a56b52533 100644 --- a/packages/cli/src/ui/commands/recapCommand.ts +++ b/packages/cli/src/ui/commands/recapCommand.ts @@ -65,7 +65,7 @@ export const recapCommand: SlashCommand = { type: 'away_recap', text: recap.text, }; - context.ui.setAwayRecapItem(item); + context.ui.addItem(item, Date.now()); return; } diff --git a/packages/cli/src/ui/commands/renameCommand.test.ts b/packages/cli/src/ui/commands/renameCommand.test.ts new file mode 100644 index 000000000..bc334c8b3 --- /dev/null +++ b/packages/cli/src/ui/commands/renameCommand.test.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renameCommand } from './renameCommand.js'; +import { type CommandContext } from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +describe('renameCommand', () => { + let mockContext: CommandContext; + + beforeEach(() => { + mockContext = createMockCommandContext(); + }); + + it('should have the correct name and description', () => { + expect(renameCommand.name).toBe('rename'); + expect(renameCommand.description).toBe('Rename the current conversation'); + }); + + it('should return error when config is not available', async () => { + mockContext.services.config = null; + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config is not available.', + }); + }); + + it('should return error when no name is provided and auto-generate fails', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(true), + }), + getGeminiClient: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + }), + getContentGenerator: vi.fn(), + getModel: vi.fn(), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not generate a title. Usage: /rename ', + }); + }); + + it('should return error when only whitespace is provided and auto-generate fails', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(true), + }), + getGeminiClient: vi.fn().mockReturnValue({ + getHistory: vi.fn().mockReturnValue([]), + }), + getContentGenerator: vi.fn(), + getModel: vi.fn(), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, ' '); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Could not generate a title. Usage: /rename ', + }); + }); + + it('should rename via ChatRecordingService when available', async () => { + const mockRecordCustomTitle = vi.fn().mockReturnValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue({ + recordCustomTitle: mockRecordCustomTitle, + }), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(true), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(mockRecordCustomTitle).toHaveBeenCalledWith('my-feature'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "my-feature"', + }); + }); + + it('should fall back to SessionService when ChatRecordingService is unavailable', async () => { + const mockRenameSession = vi.fn().mockResolvedValue(true); + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: mockRenameSession, + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(mockRenameSession).toHaveBeenCalledWith( + 'test-session-id', + 'my-feature', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Session renamed to "my-feature"', + }); + }); + + it('should return error when SessionService fallback fails', async () => { + const mockConfig = { + getChatRecordingService: vi.fn().mockReturnValue(undefined), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getSessionService: vi.fn().mockReturnValue({ + renameSession: vi.fn().mockResolvedValue(false), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await renameCommand.action!(mockContext, 'my-feature'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Failed to rename session.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/renameCommand.ts b/packages/cli/src/ui/commands/renameCommand.ts new file mode 100644 index 000000000..e1a594331 --- /dev/null +++ b/packages/cli/src/ui/commands/renameCommand.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/genai'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + getResponseText, + SESSION_TITLE_MAX_LENGTH, +} from '@qwen-code/qwen-code-core'; +import type { SlashCommand, SlashCommandActionReturn } from './types.js'; +import { CommandKind } from './types.js'; +import { t } from '../../i18n/index.js'; + +const MAX_TITLE_LENGTH = SESSION_TITLE_MAX_LENGTH; + +/** + * Extracts a short text summary from conversation history for title generation. + * Takes the last few user/assistant messages, truncated to ~1000 chars. + */ +function extractConversationText(history: Content[]): string { + const texts: string[] = []; + // Walk backwards to get the most recent context + for (let i = history.length - 1; i >= 0 && texts.length < 6; i--) { + const content = history[i]; + const role = content.role === 'user' ? 'User' : 'Assistant'; + for (const part of content.parts ?? []) { + if ('text' in part && part.text) { + texts.unshift(`${role}: ${part.text}`); + break; + } + } + } + const joined = texts.join('\n'); + return joined.length > 1000 ? joined.slice(-1000) : joined; +} + +/** + * Calls the LLM to generate a short session title from conversation history. + */ +async function generateSessionTitle( + config: Config, + signal?: AbortSignal, +): Promise { + try { + const history = config.getGeminiClient().getHistory(true); + const conversationText = extractConversationText(history); + if (!conversationText) { + return null; + } + + const response = await config.getContentGenerator().generateContent( + { + model: config.getModel(), + contents: [ + { + role: 'user', + parts: [{ text: conversationText }], + }, + ], + config: { + systemInstruction: { + role: 'user', + parts: [ + { + text: 'Generate a short kebab-case name (2-4 words) that captures the main topic of this conversation. Use lowercase words separated by hyphens. Examples: "fix-login-bug", "add-auth-feature", "refactor-api-client". Reply with ONLY the kebab-case name, nothing else.', + }, + ], + }, + abortSignal: signal, + }, + }, + 'rename_generate_title', + ); + + const text = getResponseText(response)?.trim(); + if (!text) { + return null; + } + // Clean up: take first line, remove quotes/backticks + const cleaned = text.split('\n')[0].replace(/["`']/g, '').trim(); + return cleaned.length > 0 && cleaned.length <= MAX_TITLE_LENGTH + ? cleaned + : null; + } catch { + return null; + } +} + +export const renameCommand: SlashCommand = { + name: 'rename', + altNames: ['tag'], + kind: CommandKind.BUILT_IN, + get description() { + return t('Rename the current conversation'); + }, + action: async (context, args): Promise => { + const { config } = context.services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config is not available.'), + }; + } + + let name = args.trim().replace(/[\r\n]+/g, ' '); + + // If no name provided, auto-generate one from conversation history + if (!name) { + const dots = ['.', '..', '...']; + let dotIndex = 0; + const baseText = t('Generating session name'); + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + const timer = setInterval(() => { + dotIndex = (dotIndex + 1) % dots.length; + context.ui.setPendingItem({ + type: 'info', + text: baseText + dots[dotIndex], + }); + }, 500); + const generated = await generateSessionTitle(config, context.abortSignal); + clearInterval(timer); + context.ui.setPendingItem(null); + if (!generated) { + return { + type: 'message', + messageType: 'error', + content: t('Could not generate a title. Usage: /rename '), + }; + } + name = generated; + } + + if (name.length > MAX_TITLE_LENGTH) { + return { + type: 'message', + messageType: 'error', + content: t('Name is too long. Maximum {{max}} characters.', { + max: String(MAX_TITLE_LENGTH), + }), + }; + } + + // Record the custom title in the current session's JSONL file + const chatRecordingService = config.getChatRecordingService(); + if (chatRecordingService) { + const ok = chatRecordingService.recordCustomTitle(name); + if (!ok) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to rename session.'), + }; + } + } else { + // Fallback: write via SessionService for non-recording sessions + const sessionId = config.getSessionId(); + const sessionService = config.getSessionService(); + const success = await sessionService.renameSession(sessionId, name); + if (!success) { + return { + type: 'message', + messageType: 'error', + content: t('Failed to rename session.'), + }; + } + } + + // Update the UI tag in the input prompt + context.ui.setSessionName(name); + + return { + type: 'message', + messageType: 'info', + content: t('Session renamed to "{{name}}"', { name }), + }; + }, +}; diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index 051627c5f..827f92fd8 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -151,7 +151,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: restoreAction, completion, }; diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts index 7fe14ab09..fed298b8c 100644 --- a/packages/cli/src/ui/commands/resumeCommand.test.ts +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -4,35 +4,167 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { resumeCommand } from './resumeCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +vi.mock('../../config/config.js', () => ({ + isValidSessionId: vi.fn((value: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + value, + ), + ), +})); + describe('resumeCommand', () => { let mockContext: CommandContext; beforeEach(() => { + vi.clearAllMocks(); mockContext = createMockCommandContext(); }); - it('should return a dialog action to open the resume dialog', async () => { - // Ensure the command has an action to test. - if (!resumeCommand.action) { - throw new Error('The resume command must have an action.'); - } - - const result = await resumeCommand.action(mockContext, ''); - - // Assert that the action returns the correct object to trigger the resume dialog. - expect(result).toEqual({ - type: 'dialog', - dialog: 'resume', - }); - }); - it('should have the correct name and description', () => { expect(resumeCommand.name).toBe('resume'); expect(resumeCommand.description).toBe('Resume a previous session'); }); + + it('should have "continue" as an alias', () => { + expect(resumeCommand.altNames).toContain('continue'); + }); + + it('should return dialog action when no args provided', async () => { + const result = await resumeCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + }); + }); + + it('should return error when config is not available and args given', async () => { + mockContext.services.config = null; + + const result = await resumeCommand.action!(mockContext, 'some-arg'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Config is not available.', + }); + }); + + it('should resume directly when valid UUID is provided and session exists', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(true), + }), + getTargetDir: vi.fn().mockReturnValue('/test'), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, sessionId); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + sessionId, + }); + }); + + it('should return error when valid UUID is provided but session does not exist', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + }), + getTargetDir: vi.fn().mockReturnValue('/test'), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, sessionId); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: `No session found with ID "${sessionId}".`, + }); + }); + + it('should resume directly when custom title has single match', async () => { + const matchedSessionId = '550e8400-e29b-41d4-a716-446655440000'; + + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + findSessionsByTitle: vi + .fn() + .mockResolvedValue([{ sessionId: matchedSessionId }]), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, 'my-feature'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + sessionId: matchedSessionId, + }); + }); + + it('should show picker when custom title has multiple matches', async () => { + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + findSessionsByTitle: vi + .fn() + .mockResolvedValue([{ sessionId: 'id-1' }, { sessionId: 'id-2' }]), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, 'shared-name'); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'resume', + matchedSessions: [{ sessionId: 'id-1' }, { sessionId: 'id-2' }], + }); + }); + + it('should return error when custom title has no matches', async () => { + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ + sessionExists: vi.fn().mockResolvedValue(false), + findSessionsByTitle: vi.fn().mockResolvedValue([]), + }), + }; + + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, 'nonexistent'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No session found with title "nonexistent".', + }); + }); }); diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 4f0fa7dd1..4644d3394 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -6,17 +6,69 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; +import { isValidSessionId } from '../../config/config.js'; import { t } from '../../i18n/index.js'; export const resumeCommand: SlashCommand = { name: 'resume', + altNames: ['continue'], kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, get description() { return t('Resume a previous session'); }, - action: async (): Promise => ({ - type: 'dialog', - dialog: 'resume', - }), + action: async (context, args): Promise => { + const arg = args.trim(); + + // No argument — show picker + if (!arg) { + return { type: 'dialog', dialog: 'resume' }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Config is not available.'), + }; + } + + // Try as session UUID + if (isValidSessionId(arg)) { + const sessionService = config.getSessionService(); + const exists = await sessionService.sessionExists(arg); + if (exists) { + return { type: 'dialog', dialog: 'resume', sessionId: arg }; + } + return { + type: 'message', + messageType: 'error', + content: t('No session found with ID "{{id}}".', { id: arg }), + }; + } + + // Try as custom title + const sessionService = config.getSessionService(); + const matches = await sessionService.findSessionsByTitle(arg); + + if (matches.length === 1) { + return { + type: 'dialog', + dialog: 'resume', + sessionId: matches[0].sessionId, + }; + } + + if (matches.length > 1) { + // Multiple matches — show picker with only the matching sessions + return { type: 'dialog', dialog: 'resume', matchedSessions: matches }; + } + + return { + type: 'message', + messageType: 'error', + content: t('No session found with title "{{title}}".', { title: arg }), + }; + }, }; diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index 0f9e79344..37f7002e7 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -14,7 +14,7 @@ export const settingsCommand: SlashCommand = { return t('View and edit Qwen Code settings'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'settings', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index 2102d30d7..aabbbff8e 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -99,7 +99,7 @@ export const setupGithubCommand: SlashCommand = { return t('Set up GitHub Actions'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async ( context: CommandContext, ): Promise => { diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 79b45e4e5..801da28c9 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -24,7 +24,7 @@ export const skillsCommand: SlashCommand = { return t('List available skills.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext, args?: string) => { const rawArgs = args?.trim() ?? ''; const [skillName = ''] = rawArgs.split(/\s+/); diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 485fcf693..356422dfd 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -75,4 +75,75 @@ describe('statsCommand', () => { expect.any(Number), ); }); + + describe('non-interactive mode', () => { + let nonInteractiveContext: ReturnType; + + beforeEach(() => { + nonInteractiveContext = createMockCommandContext({ + executionMode: 'non_interactive', + }); + nonInteractiveContext.session.stats.sessionStartTime = startTime; + }); + + it('should return text stats without calling addItem', async () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + const result = (await statsCommand.action(nonInteractiveContext, '')) as { + type: string; + messageType: string; + content: string; + }; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('info'); + expect(result.content).toContain('Session duration'); + expect(result.content).toContain('Prompts'); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('should return error if sessionStartTime is not available', async () => { + if (!statsCommand.action) throw new Error('Command has no action'); + + nonInteractiveContext.session.stats.sessionStartTime = undefined; + + const result = (await statsCommand.action(nonInteractiveContext, '')) as { + type: string; + messageType: string; + }; + + expect(result.type).toBe('message'); + expect(result.messageType).toBe('error'); + }); + + it('stats model subcommand should return text in non-interactive mode', async () => { + const modelSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'model', + ); + if (!modelSubCommand?.action) throw new Error('Subcommand has no action'); + + const result = (await modelSubCommand.action( + nonInteractiveContext, + '', + )) as { type: string; content: string }; + + expect(result.type).toBe('message'); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + + it('stats tools subcommand should return text in non-interactive mode', async () => { + const toolsSubCommand = statsCommand.subCommands?.find( + (sc) => sc.name === 'tools', + ); + if (!toolsSubCommand?.action) throw new Error('Subcommand has no action'); + + const result = (await toolsSubCommand.action( + nonInteractiveContext, + '', + )) as { type: string; content: string }; + + expect(result.type).toBe('message'); + expect(nonInteractiveContext.ui.addItem).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 0ee487ecc..6d96bccf9 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -10,6 +10,7 @@ import { formatDuration } from '../utils/formatters.js'; import { type CommandContext, type SlashCommand, + type MessageActionReturn, CommandKind, } from './types.js'; import { t } from '../../i18n/index.js'; @@ -21,11 +22,20 @@ export const statsCommand: SlashCommand = { return t('check session stats. Usage: /stats [model|tools]'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: (context: CommandContext) => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext): MessageActionReturn | void => { const now = new Date(); const { sessionStartTime } = context.session.stats; if (!sessionStartTime) { + if (context.executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'error', + content: t( + 'Session start time is unavailable, cannot calculate stats.', + ), + }; + } context.ui.addItem( { type: MessageType.ERROR, @@ -37,6 +47,30 @@ export const statsCommand: SlashCommand = { } const wallDuration = now.getTime() - sessionStartTime.getTime(); + if (context.executionMode !== 'interactive') { + const { promptCount, metrics } = context.session.stats; + let totalPromptTokens = 0; + let totalCandidateTokens = 0; + let totalRequests = 0; + for (const modelMetrics of Object.values(metrics.models)) { + totalPromptTokens += modelMetrics.tokens.prompt; + totalCandidateTokens += modelMetrics.tokens.candidates; + totalRequests += modelMetrics.api.totalRequests; + } + return { + type: 'message', + messageType: 'info', + content: [ + `Session duration: ${formatDuration(wallDuration)}`, + `Prompts: ${promptCount}`, + `API requests: ${totalRequests}`, + `Tokens — prompt: ${totalPromptTokens}, output: ${totalCandidateTokens}`, + `Tool calls: ${metrics.tools.totalCalls} (${metrics.tools.totalSuccess} ok, ${metrics.tools.totalFail} fail)`, + `Files: +${metrics.files.totalLinesAdded} / -${metrics.files.totalLinesRemoved} lines`, + ].join('\n'), + }; + } + const statsItem: HistoryItemStats = { type: MessageType.STATS, duration: formatDuration(wallDuration), @@ -51,8 +85,27 @@ export const statsCommand: SlashCommand = { return t('Show model-specific usage statistics.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: (context: CommandContext) => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext): MessageActionReturn | void => { + if (context.executionMode !== 'interactive') { + const { metrics } = context.session.stats; + const lines: string[] = []; + for (const [modelName, modelMetrics] of Object.entries( + metrics.models, + )) { + lines.push( + `${modelName}: prompt=${modelMetrics.tokens.prompt}, output=${modelMetrics.tokens.candidates}, cached=${modelMetrics.tokens.cached}`, + ); + } + if (lines.length === 0) { + lines.push('No model usage data yet.'); + } + return { + type: 'message', + messageType: 'info', + content: lines.join('\n'), + }; + } context.ui.addItem( { type: MessageType.MODEL_STATS, @@ -67,8 +120,21 @@ export const statsCommand: SlashCommand = { return t('Show tool-specific usage statistics.'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', - action: (context: CommandContext) => { + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + action: (context: CommandContext): MessageActionReturn | void => { + if (context.executionMode !== 'interactive') { + const { metrics } = context.session.stats; + const { tools } = metrics; + const toolNames = Object.keys(tools.byName); + const content = + toolNames.length > 0 + ? [ + `Tool calls: ${tools.totalCalls} total (${tools.totalSuccess} ok, ${tools.totalFail} fail)`, + ...toolNames.map((name) => ` ${name}`), + ].join('\n') + : 'No tool usage data yet.'; + return { type: 'message', messageType: 'info', content }; + } context.ui.addItem( { type: MessageType.TOOL_STATS, diff --git a/packages/cli/src/ui/commands/statuslineCommand.ts b/packages/cli/src/ui/commands/statuslineCommand.ts index 759503e46..7e2a1fdeb 100644 --- a/packages/cli/src/ui/commands/statuslineCommand.ts +++ b/packages/cli/src/ui/commands/statuslineCommand.ts @@ -14,7 +14,7 @@ export const statuslineCommand: SlashCommand = { return t("Set up Qwen Code's status line UI"); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (_context, args): SubmitPromptActionReturn => { const prompt = args.trim() || 'Configure my statusLine from my shell PS1 configuration'; diff --git a/packages/cli/src/ui/commands/summaryCommand.ts b/packages/cli/src/ui/commands/summaryCommand.ts index eaf011d7e..3d0c53b42 100644 --- a/packages/cli/src/ui/commands/summaryCommand.ts +++ b/packages/cli/src/ui/commands/summaryCommand.ts @@ -23,7 +23,6 @@ export const summaryCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local', supportedModes: ['interactive', 'non_interactive', 'acp'] as const, action: async (context): Promise => { const { config } = context.services; diff --git a/packages/cli/src/ui/commands/terminalSetupCommand.ts b/packages/cli/src/ui/commands/terminalSetupCommand.ts index 22888fd90..2f25f3495 100644 --- a/packages/cli/src/ui/commands/terminalSetupCommand.ts +++ b/packages/cli/src/ui/commands/terminalSetupCommand.ts @@ -23,7 +23,7 @@ export const terminalSetupCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (): Promise => { try { diff --git a/packages/cli/src/ui/commands/themeCommand.ts b/packages/cli/src/ui/commands/themeCommand.ts index 5761ee13e..b73531057 100644 --- a/packages/cli/src/ui/commands/themeCommand.ts +++ b/packages/cli/src/ui/commands/themeCommand.ts @@ -14,7 +14,7 @@ export const themeCommand: SlashCommand = { return t('change the theme'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'theme', diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index 49123623f..2ea09d060 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -18,7 +18,7 @@ export const toolsCommand: SlashCommand = { return t('list available Qwen Code tools. Usage: /tools [desc]'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context: CommandContext, args?: string): Promise => { const subCommand = args?.trim(); diff --git a/packages/cli/src/ui/commands/trustCommand.ts b/packages/cli/src/ui/commands/trustCommand.ts index 5a213cc2b..c47ee7148 100644 --- a/packages/cli/src/ui/commands/trustCommand.ts +++ b/packages/cli/src/ui/commands/trustCommand.ts @@ -14,7 +14,7 @@ export const trustCommand: SlashCommand = { return t('Manage folder trust settings'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: (): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'trust', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index aa5065be9..01e6d6564 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -6,12 +6,16 @@ import type { MutableRefObject, ReactNode } from 'react'; import type { Content, PartListUnion } from '@google/genai'; -import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core'; +import type { + Config, + GitService, + Logger, + SessionListItem, +} from '@qwen-code/qwen-code-core'; import type { HistoryItemWithoutId, HistoryItem, HistoryItemBtw, - HistoryItemAwayRecap, ConfirmationRequest, } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; @@ -76,10 +80,6 @@ export interface CommandContext { cancelBtw: () => void; /** Ref to the btw AbortController, set by btwCommand so cancelBtw can abort it. */ btwAbortControllerRef: MutableRefObject; - /** The current away-recap item rendered as a sticky banner above the input box. */ - awayRecapItem: HistoryItemAwayRecap | null; - /** Sets the away-recap item independently of the main history. */ - setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void; /** Ref to whether the agent stream is currently idle (no model turn in flight). */ isIdleRef: MutableRefObject; /** @@ -91,6 +91,7 @@ export interface CommandContext { toggleVimEnabled: () => Promise; setGeminiMdFileCount: (count: number) => void; reloadCommands: () => void; + setSessionName: (name: string | null) => void; extensionsUpdateState: Map; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void; @@ -153,6 +154,12 @@ export interface StreamMessagesActionReturn { export interface OpenDialogActionReturn { type: 'dialog'; + /** Optional session ID to pass directly to the dialog handler (e.g., for /resume ). */ + sessionId?: string; + + /** Pre-filtered sessions for the picker (e.g., multiple title matches from /resume ). */ + matchedSessions?: SessionListItem[]; + dialog: | 'help' | 'arena_start' @@ -172,6 +179,7 @@ export interface OpenDialogActionReturn { | 'permissions' | 'approval-mode' | 'resume' + | 'delete' | 'extensions_manage' | 'hooks' | 'mcp'; @@ -266,23 +274,6 @@ export type CommandSource = // | 'plugin-skill' // | 'dynamic-skill' -/** - * The execution type of a slash command, describing *how* it runs. - * - * - prompt: Produces a submit_prompt — content is sent to the model. - * Default supportedModes: all. Default modelInvocable: true. - * - * - local: Runs local logic with no React/Ink UI dependency. - * Can return message, stream_messages, submit_prompt, tool, etc. - * Default supportedModes: ['interactive'] — must explicitly declare - * supportedModes to unlock other modes (mirrors Claude Code's - * supportsNonInteractive: true pattern). - * - * - local-jsx: Depends on React/Ink UI (dialogs, JSX components, etc.). - * Default supportedModes: ['interactive'] only. - */ -export type CommandType = 'prompt' | 'local' | 'local-jsx'; - export interface CommandCompletionItem { value: string; label?: string; @@ -321,17 +312,11 @@ export interface SlashCommand { */ sourceLabel?: string; - /** - * How this command executes. Set by built-in command files (local/local-jsx) - * or by Loaders (prompt). Used by getEffectiveSupportedModes() to infer - * which execution modes are supported. - */ - commandType?: CommandType; - // ── Phase 1: mode capability ─────────────────────────────────────────── /** * Which execution modes this command is available in. - * Explicit declaration takes priority over commandType inference. + * Explicit declaration is always authoritative. If omitted, the system falls + * back to a conservative default based on CommandKind. * See getEffectiveSupportedModes() in commandUtils.ts for the full logic. */ supportedModes?: ExecutionMode[]; diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index 28e30806c..0999357ae 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -14,7 +14,7 @@ export const vimCommand: SlashCommand = { return t('toggle vim mode on/off'); }, kind: CommandKind.BUILT_IN, - commandType: 'local-jsx', + supportedModes: ['interactive'] as const, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index c13f4f606..1821aacee 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -11,7 +11,7 @@ import type { AnsiOutput, AnsiToken, } from '@qwen-code/qwen-code-core'; -import { formatDuration, formatMemoryUsage } from '../utils/formatters.js'; +import { formatMemoryUsage } from '../utils/formatters.js'; import { theme } from '../semantic-colors.js'; import { MaxSizedBox } from './shared/MaxSizedBox.js'; @@ -62,23 +62,18 @@ export const AnsiOutputText: React.FC<AnsiOutputProps> = ({ export interface ShellStatsBarProps { totalLines?: number; totalBytes?: number; - timeoutMs?: number; displayHeight?: number; } export const ShellStatsBar: React.FC<ShellStatsBarProps> = ({ totalLines, totalBytes, - timeoutMs, displayHeight = DEFAULT_HEIGHT, }) => { const parts: string[] = []; if (totalLines && totalLines > displayHeight) { parts.push(`+${totalLines - displayHeight} lines`); } - if (timeoutMs) { - parts.push(`timeout ${formatDuration(timeoutMs)}`); - } if (totalBytes && totalBytes > 0) { parts.push(formatMemoryUsage(totalBytes)); } diff --git a/packages/cli/src/ui/components/ApiKeyInput.tsx b/packages/cli/src/ui/components/ApiKeyInput.tsx index bf885b30d..8ccc616f1 100644 --- a/packages/cli/src/ui/components/ApiKeyInput.tsx +++ b/packages/cli/src/ui/components/ApiKeyInput.tsx @@ -11,7 +11,7 @@ import { TextInput } from './shared/TextInput.js'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { t } from '../../i18n/index.js'; -import { CodingPlanRegion } from '../../constants/codingPlan.js'; +import { CodingPlanRegion } from '@qwen-code/qwen-code-core'; import Link from 'ink-link'; interface ApiKeyInputProps { diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx index 0254a2012..fa051e7e0 100644 --- a/packages/cli/src/ui/components/AppHeader.tsx +++ b/packages/cli/src/ui/components/AppHeader.tsx @@ -5,13 +5,12 @@ */ import { Box } from 'ink'; -import { AuthType } from '@qwen-code/qwen-code-core'; +import { AuthType, isCodingPlanConfig } from '@qwen-code/qwen-code-core'; import { Header, AuthDisplayType } from './Header.js'; import { Tips } from './Tips.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; -import { isCodingPlanConfig } from '../../constants/codingPlan.js'; interface AppHeaderProps { version: string; diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index fc9763cbb..a0e44591a 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -27,6 +27,7 @@ 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 stringWidth from 'string-width'; import { cpSlice, cpLen } from '../utils/textUtils.js'; import { theme } from '../semantic-colors.js'; @@ -69,6 +70,8 @@ export interface BaseTextInputProps { prefix?: React.ReactNode; /** Border color for the input box. */ borderColor?: string; + /** Label rendered on the top border line (right-aligned). Plain string for width calculation. */ + topRightLabel?: string; /** Whether keyboard handling is active. Defaults to true. */ isActive?: boolean; /** @@ -129,6 +132,7 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({ placeholder, prefix, borderColor, + topRightLabel, isActive = true, renderLine = defaultRenderLine, }) => { @@ -246,47 +250,61 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({ <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; + const columns = process.stdout.columns || 80; + // Build the top border line: ─────── label ── + // Label takes: 1 space + text + 1 space + 2 trailing dashes = label.length + 4 + const labelWidth = topRightLabel ? stringWidth(topRightLabel) + 4 : 0; + const dashCount = Math.max(1, columns - labelWidth); + const topBorderLine = topRightLabel + ? `${'─'.repeat(dashCount)} ${topRightLabel} ${'─'.repeat(2)}` + : '─'.repeat(columns); - return ( - <Box key={idx} height={1}> - {renderLine({ - lineText, - isOnCursorLine, - cursorCol: cursorVisualCol, - showCursor, - visualLineIndex: idx, - absoluteVisualIndex, - buffer, - scrollVisualRow, - })} - </Box> - ); - }) - )} + return ( + <Box flexDirection="column"> + <Text color={resolvedBorderColor} wrap="truncate-end"> + {topBorderLine} + </Text> + <Box + borderStyle="single" + borderTop={false} + 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> </Box> ); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 5d969de5c..449d042f8 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -112,6 +112,9 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState => nightly: false, isTrustedFolder: true, taskStartTokens: 0, + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + pendingGeminiHistoryItems: [], ...overrides, }) as UIState; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 4dca07f0b..263988686 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -15,7 +15,7 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; -import { StreamingState } from '../types.js'; +import { StreamingState, type HistoryItemToolGroup } from '../types.js'; import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; import { FeedbackDialog } from '../FeedbackDialog.js'; import { t } from '../../i18n/index.js'; @@ -27,17 +27,40 @@ export const Composer = () => { const uiActions = useUIActions(); const { vimEnabled } = useVimMode(); - const { showAutoAcceptIndicator, sessionStats, taskStartTokens } = uiState; + const { + showAutoAcceptIndicator, + streamingResponseLengthRef, + isReceivingContent, + } = 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 }, - ); + // Real-time token animation is performed inside LoadingIndicator itself, so + // the 100ms polling only re-renders that one component — keeping InputPrompt + // and Footer static avoids terminal flicker during streaming. + const isStreaming = + uiState.streamingState === StreamingState.Responding || + uiState.streamingState === StreamingState.WaitingForConfirmation; - const taskTokens = tokens.candidates - taskStartTokens; + // Aggregate agent tool tokens from executing tool calls. Only changes when + // a subagent reports progress, so it doesn't drive the animation loop. + let agentTokens = 0; + for (const item of uiState.pendingGeminiHistoryItems ?? []) { + if (item.type === 'tool_group') { + const toolGroup = item as HistoryItemToolGroup; + for (const tool of toolGroup.tools) { + const display = tool.resultDisplay; + if ( + typeof display === 'object' && + display !== null && + 'type' in display && + display.type === 'task_execution' && + 'tokenCount' in display && + typeof display.tokenCount === 'number' + ) { + agentTokens += display.tokenCount; + } + } + } + } // State for keyboard shortcuts display toggle const [showShortcuts, setShowShortcuts] = useState(false); @@ -74,7 +97,10 @@ export const Composer = () => { : uiState.currentLoadingPhrase } elapsedTime={uiState.elapsedTime} - candidatesTokens={taskTokens} + candidatesTokens={agentTokens} + streamingCharsRef={streamingResponseLengthRef} + isStreaming={isStreaming} + isReceivingContent={isReceivingContent} /> )} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index a02985926..919c53a4b 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -46,6 +46,7 @@ import { SessionPicker } from './SessionPicker.js'; import { MemoryDialog } from './MemoryDialog.js'; import { BackgroundTasksDialog } from './background-view/BackgroundTasksDialog.js'; import { useBackgroundAgentViewState } from '../contexts/BackgroundAgentViewContext.js'; +import { t } from '../../i18n/index.js'; interface DialogManagerProps { addItem: UseHistoryManagerReturn['addItem']; @@ -382,6 +383,19 @@ export const DialogManager = ({ currentBranch={uiState.branchName} onSelect={uiActions.handleResume} onCancel={uiActions.closeResumeDialog} + initialSessions={uiState.resumeMatchedSessions} + /> + ); + } + + if (uiState.isDeleteDialogOpen) { + return ( + <SessionPicker + sessionService={config.getSessionService()} + currentBranch={uiState.branchName} + onSelect={uiActions.handleDelete} + onCancel={uiActions.closeDeleteDialog} + title={t('Delete Session')} /> ); } diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index f9ea0afba..a37544db0 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -28,6 +28,7 @@ import { ErrorMessage, RetryCountdownMessage, SuccessMessage, + AwayRecapMessage, } from './messages/StatusMessages.js'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; @@ -285,6 +286,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({ {itemForDisplay.type === 'memory_saved' && ( <MemorySavedMessage item={itemForDisplay} /> )} + {itemForDisplay.type === 'away_recap' && ( + <AwayRecapMessage text={itemForDisplay.text} /> + )} </Box> ); }; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index bd5bd371b..1691c0099 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -245,20 +245,6 @@ describe('InputPrompt', () => { unmount(); }); - it('fills the prompt suggestion on right arrow without submitting', async () => { - const { stdin, unmount } = renderWithProviders( - <InputPrompt {...props} promptSuggestion="commit this" />, - ); - await wait(350); - - stdin.write('\u001B[C'); // right arrow - await wait(); - - expect(mockBuffer.insert).toHaveBeenCalledWith('commit this'); - expect(props.onSubmit).not.toHaveBeenCalled(); - unmount(); - }); - it('does not accept a prompt suggestion while command completion is active', async () => { mockCommandCompletion.showSuggestions = true; mockCommandCompletion.suggestions = [ diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 030090971..b4b495c65 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -201,6 +201,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ !justNavigatedHistory, ); + // Ref so renderLineWithHighlighting (stable useCallback) can access fresh ghost text + const midInputGhostTextRef = useRef<{ + text: string; + insertPosition: number; + } | null>(null); + midInputGhostTextRef.current = completion.midInputGhostText; + const reverseSearchCompletion = useReverseSearchCompletion( buffer, shellHistoryData, @@ -824,6 +831,18 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } } + // Accept mid-input ghost text with Tab (when no dropdown is visible) + if ( + key.name === 'tab' && + !key.paste && + !key.shift && + !completion.showSuggestions && + midInputGhostTextRef.current + ) { + buffer.insert(midInputGhostTextRef.current.text); + return true; + } + // Attachment mode handling - process before history navigation if (isAttachmentMode && attachments.length > 0) { if (key.name === 'left') { @@ -1171,12 +1190,31 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ }); if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) { - // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace - renderedLine.push( - <Text key={`cursor-end-${cursorVisualColAbsolute}`}> - {showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'} - </Text>, - ); + // Check for mid-input ghost text (only renders when cursor is at end of input) + const ghostText = midInputGhostTextRef.current; + if (ghostText && showCursorOpt && ghostText.text.length > 0) { + // First ghost char: inverted (as cursor). Rest: dimmed gray. + const firstChar = ghostText.text[0]!; + const rest = ghostText.text.slice(firstChar.length); + renderedLine.push( + <Text key="ghost-cursor">{chalk.inverse(firstChar)}</Text>, + ); + if (rest.length > 0) { + renderedLine.push( + <Text key="ghost-rest" color={theme.text.secondary}> + {rest} + </Text>, + ); + } + renderedLine.push(<Text key="ghost-zwsp">{`\u200B`}</Text>); + } else { + // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace + renderedLine.push( + <Text key={`cursor-end-${cursorVisualColAbsolute}`}> + {showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'} + </Text>, + ); + } } return <Text>{renderedLine}</Text>; @@ -1283,6 +1321,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({ } prefix={prefixNode} borderColor={borderColor} + topRightLabel={uiState.sessionName || undefined} isActive={!isEmbeddedShellFocused} renderLine={renderLineWithHighlighting} /> diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index c608f4a4e..66bd0792b 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -374,5 +374,29 @@ describe('<LoadingIndicator />', () => { const output = lastFrame(); expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)'); }); + + it('should show ↑ arrow when waiting for API response', () => { + const { lastFrame } = renderWithContext( + <LoadingIndicator + {...defaultProps} + candidatesTokens={500} + isReceivingContent={false} + />, + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).toContain('↑ 500 tokens'); + expect(output).not.toContain('↓'); + }); + + it('should show ↓ arrow when receiving content (default)', () => { + const { lastFrame } = renderWithContext( + <LoadingIndicator {...defaultProps} candidatesTokens={500} />, + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).toContain('↓ 500 tokens'); + expect(output).not.toContain('↑'); + }); }); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 7b6f2f06f..617f879e5 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -6,6 +6,7 @@ import type { ThoughtSummary } from '@qwen-code/qwen-code-core'; import type React from 'react'; +import { useRef } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; @@ -13,6 +14,7 @@ import { StreamingState } from '../types.js'; import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { formatDuration, formatTokenCount } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { useAnimationFrame } from '../hooks/useAnimationFrame.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { t } from '../../i18n/index.js'; @@ -22,6 +24,21 @@ interface LoadingIndicatorProps { rightContent?: React.ReactNode; thought?: ThoughtSummary | null; candidatesTokens?: number; + /** + * Live-updating character counter for the streaming response. When provided + * together with `isStreaming`, the indicator animates a token estimate + * (chars / 4) internally, so the animation never re-renders `Composer` or + * the input prompt. + */ + streamingCharsRef?: React.RefObject<number>; + /** Whether to poll `streamingCharsRef` (true during Responding/WaitingForConfirmation). */ + isStreaming?: boolean; + /** + * True when receiving content (shows ↓ arrow), false when waiting for API + * response (shows ↑ arrow). + * @default true + */ + isReceivingContent?: boolean; } export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ @@ -30,25 +47,40 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({ rightContent, thought, candidatesTokens, + streamingCharsRef, + isStreaming, + isReceivingContent = true, }) => { const streamingState = useStreamingContext(); const { columns: terminalWidth } = useTerminalSize(); const isNarrow = isNarrowWidth(terminalWidth); + // Animate the streaming-chars counter locally so only this component + // re-renders on each animation frame (100ms ≈ spinner cadence). Siblings + // like InputPrompt / Footer stay static, which eliminates terminal flicker + // during streaming output. + const fallbackRef = useRef(0); + const animatedChars = useAnimationFrame( + streamingCharsRef ?? fallbackRef, + streamingCharsRef && isStreaming ? 100 : null, + ); + if (streamingState === StreamingState.Idle) { return null; } const primaryText = thought?.subject || currentLoadingPhrase; - const outputTokens = candidatesTokens ?? 0; + const streamingTokens = streamingCharsRef ? Math.round(animatedChars / 4) : 0; + const outputTokens = (candidatesTokens ?? 0) + streamingTokens; const showTokens = !isNarrow && outputTokens > 0; + const tokenArrow = isReceivingContent ? '↓' : '↑'; const timeStr = elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000); const tokenStr = showTokens - ? ` · ↓ ${formatTokenCount(outputTokens)} tokens` + ? ` · ${tokenArrow} ${formatTokenCount(outputTokens)} tokens` : ''; const cancelAndTimerContent = diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx index 591c2a280..9e871ad3f 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.test.tsx @@ -8,7 +8,17 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; import { ModelStatsDisplay } from './ModelStatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; -import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { + ModelMetrics, + ModelMetricsCore, + SessionMetrics, +} from '../contexts/SessionContext.js'; +import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; + +const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({ + ...core, + bySource: { [MAIN_SOURCE]: core }, +}); // Mock the context to provide controlled data for testing vi.mock('../contexts/SessionContext.js', async (importOriginal) => { @@ -73,7 +83,7 @@ describe('<ModelStatsDisplay />', () => { it('should not display conditional rows if no model has data for them', () => { const { lastFrame } = renderWithMockedStats({ models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, tokens: { prompt: 10, @@ -83,7 +93,7 @@ describe('<ModelStatsDisplay />', () => { thoughts: 0, tool: 0, }, - }, + }), }, tools: { totalCalls: 0, @@ -105,7 +115,7 @@ describe('<ModelStatsDisplay />', () => { it('should display conditional rows if at least one model has data', () => { const { lastFrame } = renderWithMockedStats({ models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, tokens: { prompt: 10, @@ -115,8 +125,8 @@ describe('<ModelStatsDisplay />', () => { thoughts: 2, tool: 0, }, - }, - 'gemini-2.5-flash': { + }), + 'gemini-2.5-flash': mainOnly({ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 50 }, tokens: { prompt: 5, @@ -126,7 +136,7 @@ describe('<ModelStatsDisplay />', () => { thoughts: 0, tool: 3, }, - }, + }), }, tools: { totalCalls: 0, @@ -148,7 +158,7 @@ describe('<ModelStatsDisplay />', () => { it('should display stats for multiple models correctly', () => { const { lastFrame } = renderWithMockedStats({ models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 1000 }, tokens: { prompt: 100, @@ -158,8 +168,8 @@ describe('<ModelStatsDisplay />', () => { thoughts: 10, tool: 5, }, - }, - 'gemini-2.5-flash': { + }), + 'gemini-2.5-flash': mainOnly({ api: { totalRequests: 20, totalErrors: 2, totalLatencyMs: 500 }, tokens: { prompt: 200, @@ -169,7 +179,7 @@ describe('<ModelStatsDisplay />', () => { thoughts: 20, tool: 10, }, - }, + }), }, tools: { totalCalls: 0, @@ -190,7 +200,7 @@ describe('<ModelStatsDisplay />', () => { it('should handle large values without wrapping or overlapping', () => { const { lastFrame } = renderWithMockedStats({ models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 999999999, totalErrors: 123456789, @@ -204,7 +214,7 @@ describe('<ModelStatsDisplay />', () => { thoughts: 111111111, tool: 222222222, }, - }, + }), }, tools: { totalCalls: 0, @@ -222,7 +232,7 @@ describe('<ModelStatsDisplay />', () => { it('should display a single model correctly', () => { const { lastFrame } = renderWithMockedStats({ models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, tokens: { prompt: 10, @@ -232,7 +242,7 @@ describe('<ModelStatsDisplay />', () => { thoughts: 2, tool: 1, }, - }, + }), }, tools: { totalCalls: 0, @@ -249,4 +259,70 @@ describe('<ModelStatsDisplay />', () => { expect(output).not.toContain('gemini-2.5-flash'); expect(output).toMatchSnapshot(); }); + + describe('Subagent source attribution', () => { + const baseTools: SessionMetrics['tools'] = { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }; + const baseFiles: SessionMetrics['files'] = { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }; + const makeCore = (reqs: number): ModelMetricsCore => ({ + api: { totalRequests: reqs, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 10, + candidates: 20, + total: 30, + cached: 0, + thoughts: 0, + tool: 0, + }, + }); + + it('collapses the column header when only main is a source', () => { + const { lastFrame } = renderWithMockedStats({ + models: { 'glm-5': mainOnly(makeCore(1)) }, + tools: baseTools, + files: baseFiles, + }); + const output = lastFrame(); + expect(output).toContain('glm-5'); + expect(output).not.toContain('glm-5 (main)'); + }); + + it('renders distinct columns for main and subagent when same model has multiple sources', () => { + const mainCore = makeCore(1); + const echoerCore = makeCore(1); + const { lastFrame } = renderWithMockedStats({ + models: { + 'glm-5': { + api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 200 }, + tokens: { + prompt: 20, + candidates: 40, + total: 60, + cached: 0, + thoughts: 0, + tool: 0, + }, + bySource: { + [MAIN_SOURCE]: mainCore, + echoer: echoerCore, + }, + }, + }, + tools: baseTools, + files: baseFiles, + }); + const output = lastFrame(); + expect(output).toContain('glm-5 (main)'); + expect(output).toContain('glm-5 (echoer)'); + }); + }); }); diff --git a/packages/cli/src/ui/components/ModelStatsDisplay.tsx b/packages/cli/src/ui/components/ModelStatsDisplay.tsx index c34905981..389c4221f 100644 --- a/packages/cli/src/ui/components/ModelStatsDisplay.tsx +++ b/packages/cli/src/ui/components/ModelStatsDisplay.tsx @@ -13,12 +13,17 @@ import { calculateCacheHitRate, calculateErrorRate, } from '../utils/computeStats.js'; -import type { ModelMetrics } from '../contexts/SessionContext.js'; +import type { ModelMetricsCore } from '../contexts/SessionContext.js'; import { useSessionStats } from '../contexts/SessionContext.js'; +import { flattenModelsBySource } from '../utils/modelsBySource.js'; import { t } from '../../i18n/index.js'; const METRIC_COL_WIDTH = 28; -const MODEL_COL_WIDTH = 22; +// 28 + 2*24 = 76, fitting the 76-column panel at 80-column terminal width +// when the session has a single (model, source) pair split into two columns. +// Sessions with three or more sources will exceed the panel — acceptable per +// the design doc, which accepts the crowded layout for many-subagent cases. +const MODEL_COL_WIDTH = 24; interface StatRowProps { title: string; @@ -59,11 +64,9 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({ }) => { const { stats } = useSessionStats(); const { models } = stats.metrics; - const activeModels = Object.entries(models).filter( - ([, metrics]) => metrics.api.totalRequests > 0, - ); + const entries = flattenModelsBySource(models); - if (activeModels.length === 0) { + if (entries.length === 0) { return ( <Box borderStyle="round" @@ -79,19 +82,15 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({ ); } - const modelNames = activeModels.map(([name]) => name); - const getModelValues = ( - getter: (metrics: ModelMetrics) => string | React.ReactElement, - ) => activeModels.map(([, metrics]) => getter(metrics)); + getter: (metrics: ModelMetricsCore) => string | React.ReactElement, + ) => entries.map(({ metrics }) => getter(metrics)); - const hasThoughts = activeModels.some( - ([, metrics]) => metrics.tokens.thoughts > 0, - ); - const hasTool = activeModels.some(([, metrics]) => metrics.tokens.tool > 0); - const hasCached = activeModels.some( - ([, metrics]) => metrics.tokens.cached > 0, + const hasThoughts = entries.some( + ({ metrics }) => metrics.tokens.thoughts > 0, ); + const hasTool = entries.some(({ metrics }) => metrics.tokens.tool > 0); + const hasCached = entries.some(({ metrics }) => metrics.tokens.cached > 0); return ( <Box @@ -114,10 +113,10 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({ {t('Metric')} </Text> </Box> - {modelNames.map((name) => ( - <Box width={MODEL_COL_WIDTH} key={name}> + {entries.map(({ key, label }) => ( + <Box width={MODEL_COL_WIDTH} key={key}> <Text bold color={theme.text.primary}> - {name} + {label} </Text> </Box> ))} diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx index 8c2703973..33b62c06b 100644 --- a/packages/cli/src/ui/components/SessionPicker.tsx +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -25,11 +25,22 @@ export interface SessionPickerProps { onCancel: () => void; currentBranch?: string; + /** + * Custom title for the picker header. Defaults to "Resume Session". + */ + title?: string; + /** * Scroll mode. When true, keep selection centered (fullscreen-style). * Defaults to true so dialog + standalone behave identically. */ centerSelection?: boolean; + + /** + * Pre-filtered sessions to display instead of loading all sessions. + * When provided, skips initial load and disables pagination. + */ + initialSessions?: SessionData[]; } const PREFIX_CHARS = { @@ -81,7 +92,7 @@ function SessionListItemView({ ? prefixChars.scrollDown : prefixChars.normal; - const promptText = session.prompt || '(empty prompt)'; + const promptText = session.customTitle || session.prompt || '(empty prompt)'; const truncatedPrompt = truncateText(promptText, maxPromptWidth); return ( @@ -122,7 +133,9 @@ export function SessionPicker(props: SessionPickerProps) { onSelect, onCancel, currentBranch, + title, centerSelection = true, + initialSessions, } = props; const { columns: width, rows: height } = useTerminalSize(); @@ -146,6 +159,7 @@ export function SessionPicker(props: SessionPickerProps) { onCancel, maxVisibleItems, centerSelection, + initialSessions, isActive: true, }); @@ -167,7 +181,7 @@ export function SessionPicker(props: SessionPickerProps) { {/* Header row */} <Box paddingX={1}> <Text bold color={theme.text.primary}> - {t('Resume Session')} + {title ?? t('Resume Session')} </Text> {picker.filterByBranch && currentBranch && ( <Text color={theme.text.secondary}> diff --git a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx index 305b50b2c..a86e64eac 100644 --- a/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/SessionSummaryDisplay.test.tsx @@ -8,9 +8,19 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi } from 'vitest'; import { SessionSummaryDisplay } from './SessionSummaryDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; -import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { + ModelMetrics, + ModelMetricsCore, + SessionMetrics, +} from '../contexts/SessionContext.js'; +import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; import { ConfigContext } from '../contexts/ConfigContext.js'; +const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({ + ...core, + bySource: { [MAIN_SOURCE]: core }, +}); + vi.mock('../contexts/SessionContext.js', async (importOriginal) => { const actual = await importOriginal<typeof SessionContext>(); return { @@ -57,7 +67,7 @@ describe('<SessionSummaryDisplay />', () => { it('renders the summary display with a title', () => { const metrics: SessionMetrics = { models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 10, totalErrors: 1, totalLatencyMs: 50234 }, tokens: { prompt: 1000, @@ -67,7 +77,7 @@ describe('<SessionSummaryDisplay />', () => { thoughts: 300, tool: 200, }, - }, + }), }, tools: { totalCalls: 0, diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 244258d79..ba9a3f21f 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -6,7 +6,11 @@ import { useState } from 'react'; import { render, Box, useApp } from 'ink'; -import { getGitBranch, SessionService } from '@qwen-code/qwen-code-core'; +import { + getGitBranch, + SessionService, + type SessionListItem, +} from '@qwen-code/qwen-code-core'; import { KeypressProvider } from '../contexts/KeypressContext.js'; import { SessionPicker } from './SessionPicker.js'; import { writeStdoutLine } from '../../utils/stdioHelpers.js'; @@ -16,6 +20,7 @@ interface StandalonePickerScreenProps { onSelect: (sessionId: string) => void; onCancel: () => void; currentBranch?: string; + initialSessions?: SessionListItem[]; } function StandalonePickerScreen({ @@ -23,6 +28,7 @@ function StandalonePickerScreen({ onSelect, onCancel, currentBranch, + initialSessions, }: StandalonePickerScreenProps): React.JSX.Element { const { exit } = useApp(); const [isExiting, setIsExiting] = useState(false); @@ -49,6 +55,7 @@ function StandalonePickerScreen({ }} currentBranch={currentBranch} centerSelection={true} + initialSessions={initialSessions} /> ); } @@ -67,6 +74,7 @@ function clearScreen(): void { */ export async function showResumeSessionPicker( cwd: string = process.cwd(), + initialSessions?: SessionListItem[], ): Promise<string | undefined> { const sessionService = new SessionService(cwd); const hasSession = await sessionService.loadLastSession(); @@ -104,6 +112,7 @@ export async function showResumeSessionPicker( selectedId = undefined; }} currentBranch={getGitBranch(cwd)} + initialSessions={initialSessions} /> </KeypressProvider>, { diff --git a/packages/cli/src/ui/components/StatsDisplay.test.tsx b/packages/cli/src/ui/components/StatsDisplay.test.tsx index 6820171cb..b75b3c2dc 100644 --- a/packages/cli/src/ui/components/StatsDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.test.tsx @@ -8,7 +8,20 @@ import { render } from 'ink-testing-library'; import { describe, it, expect, vi } from 'vitest'; import { StatsDisplay } from './StatsDisplay.js'; import * as SessionContext from '../contexts/SessionContext.js'; -import type { SessionMetrics } from '../contexts/SessionContext.js'; +import type { + ModelMetrics, + ModelMetricsCore, + SessionMetrics, +} from '../contexts/SessionContext.js'; +import { MAIN_SOURCE } from '@qwen-code/qwen-code-core'; + +// Wraps a core metrics object as a ModelMetrics with a single `main` source +// bucket, matching the shape produced by processing an API call with no +// subagent attribution. Used to keep fixtures terse. +const mainOnly = (core: ModelMetricsCore): ModelMetrics => ({ + ...core, + bySource: { [MAIN_SOURCE]: core }, +}); // Mock the context to provide controlled data for testing vi.mock('../contexts/SessionContext.js', async (importOriginal) => { @@ -69,7 +82,7 @@ describe('<StatsDisplay />', () => { it('renders a table with two models correctly', () => { const metrics: SessionMetrics = { models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 3, totalErrors: 0, totalLatencyMs: 15000 }, tokens: { prompt: 1000, @@ -79,8 +92,8 @@ describe('<StatsDisplay />', () => { thoughts: 100, tool: 50, }, - }, - 'gemini-2.5-flash': { + }), + 'gemini-2.5-flash': mainOnly({ api: { totalRequests: 5, totalErrors: 1, totalLatencyMs: 4500 }, tokens: { prompt: 25000, @@ -90,7 +103,7 @@ describe('<StatsDisplay />', () => { thoughts: 2000, tool: 1000, }, - }, + }), }, tools: { totalCalls: 0, @@ -119,7 +132,7 @@ describe('<StatsDisplay />', () => { it('renders all sections when all data is present', () => { const metrics: SessionMetrics = { models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, tokens: { prompt: 100, @@ -129,7 +142,7 @@ describe('<StatsDisplay />', () => { thoughts: 0, tool: 0, }, - }, + }), }, tools: { totalCalls: 2, @@ -202,7 +215,7 @@ describe('<StatsDisplay />', () => { it('hides Efficiency section when cache is not used', () => { const metrics: SessionMetrics = { models: { - 'gemini-2.5-pro': { + 'gemini-2.5-pro': mainOnly({ api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, tokens: { prompt: 100, @@ -212,7 +225,7 @@ describe('<StatsDisplay />', () => { thoughts: 0, tool: 0, }, - }, + }), }, tools: { totalCalls: 0, @@ -350,6 +363,154 @@ describe('<StatsDisplay />', () => { }); }); + describe('Subagent source attribution', () => { + const baseTools: SessionMetrics['tools'] = { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { accept: 0, reject: 0, modify: 0 }, + byName: {}, + }; + const baseFiles: SessionMetrics['files'] = { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }; + const coreMetrics = (reqs: number, tokens: number): ModelMetricsCore => ({ + api: { totalRequests: reqs, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: tokens, + candidates: tokens, + total: tokens * 2, + cached: 0, + thoughts: 0, + tool: 0, + }, + }); + + it('renders a plain model name when only main is a source', () => { + const metrics: SessionMetrics = { + models: { 'glm-5': mainOnly(coreMetrics(1, 100)) }, + tools: baseTools, + files: baseFiles, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('glm-5'); + expect(output).not.toContain('glm-5 (main)'); + expect(output).not.toContain('(main)'); + }); + + it('shows main and subagent suffixes when the same model has multiple sources', () => { + const mainCore = coreMetrics(2, 200); + const echoerCore = coreMetrics(1, 40); + const metrics: SessionMetrics = { + models: { + 'glm-5': { + api: { + totalRequests: + mainCore.api.totalRequests + echoerCore.api.totalRequests, + totalErrors: 0, + totalLatencyMs: 200, + }, + tokens: { + prompt: mainCore.tokens.prompt + echoerCore.tokens.prompt, + candidates: + mainCore.tokens.candidates + echoerCore.tokens.candidates, + total: mainCore.tokens.total + echoerCore.tokens.total, + cached: 0, + thoughts: 0, + tool: 0, + }, + bySource: { + [MAIN_SOURCE]: mainCore, + echoer: echoerCore, + }, + }, + }, + tools: baseTools, + files: baseFiles, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('glm-5 (main)'); + expect(output).toContain('glm-5 (echoer)'); + }); + + it('labels main rows session-wide when a subagent uses a different model', () => { + // Session has two models: glm-5 used only by main, qwen-plus used only by + // a subagent. Even though glm-5 has a single main source, it must still + // render with `(main)` because the session-wide rule triggers on qwen-plus. + const metrics: SessionMetrics = { + models: { + 'glm-5': mainOnly(coreMetrics(2, 200)), + 'qwen-plus': { + api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 40, + candidates: 40, + total: 80, + cached: 0, + thoughts: 0, + tool: 0, + }, + bySource: { + researcher: coreMetrics(1, 40), + }, + }, + }, + tools: baseTools, + files: baseFiles, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('glm-5 (main)'); + expect(output).toContain('qwen-plus (researcher)'); + // The bare `glm-5` label (not followed by a space + `(`) must not appear + // as a row label in this session. + expect(output).not.toMatch(/glm-5\s{2,}/); + }); + + it('shows distinct rows when two subagents share a model', () => { + const alphaCore = coreMetrics(1, 10); + const bravoCore = coreMetrics(1, 20); + const metrics: SessionMetrics = { + models: { + 'glm-5': { + api: { totalRequests: 2, totalErrors: 0, totalLatencyMs: 100 }, + tokens: { + prompt: 30, + candidates: 30, + total: 60, + cached: 0, + thoughts: 0, + tool: 0, + }, + bySource: { + alpha: alphaCore, + bravo: bravoCore, + }, + }, + }, + tools: baseTools, + files: baseFiles, + }; + + const { lastFrame } = renderWithMockedStats(metrics); + const output = lastFrame(); + + expect(output).toContain('glm-5 (alpha)'); + expect(output).toContain('glm-5 (bravo)'); + expect(output).not.toContain('glm-5 (main)'); + }); + }); + describe('Title Rendering', () => { const zeroMetrics: SessionMetrics = { models: {}, diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 48ed58f18..2a766f698 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -19,6 +19,7 @@ import { USER_AGREEMENT_RATE_MEDIUM, } from '../utils/displayUtils.js'; import { computeSessionStats } from '../utils/computeStats.js'; +import { flattenModelsBySource } from '../utils/modelsBySource.js'; import { t } from '../../i18n/index.js'; // A more flexible and powerful StatRow component @@ -75,11 +76,17 @@ const ModelUsageTable: React.FC<{ totalCachedTokens: number; cacheEfficiency: number; }> = ({ models, totalCachedTokens, cacheEfficiency }) => { - const nameWidth = 25; + // 35 + 8 + 15 + 15 = 73, fitting within the 76-column panel allocated + // when the terminal is at the default 80-column width. Subagent labels + // longer than 35 characters will wrap — acceptable cosmetic trade-off + // given the alternative is overflowing the panel border. + const nameWidth = 35; const requestsWidth = 8; const inputTokensWidth = 15; const outputTokensWidth = 15; + const entries = flattenModelsBySource(models); + return ( <Box flexDirection="column" marginTop={1}> {/* Header */} @@ -117,24 +124,22 @@ const ModelUsageTable: React.FC<{ ></Box> {/* Rows */} - {Object.entries(models).map(([name, modelMetrics]) => ( - <Box key={name}> + {entries.map(({ key, label, metrics }) => ( + <Box key={key}> <Box width={nameWidth}> - <Text color={theme.text.primary}>{name.replace('-001', '')}</Text> + <Text color={theme.text.primary}>{label}</Text> </Box> <Box width={requestsWidth} justifyContent="flex-end"> - <Text color={theme.text.primary}> - {modelMetrics.api.totalRequests} - </Text> + <Text color={theme.text.primary}>{metrics.api.totalRequests}</Text> </Box> <Box width={inputTokensWidth} justifyContent="flex-end"> <Text color={theme.status.warning}> - {modelMetrics.tokens.prompt.toLocaleString()} + {metrics.tokens.prompt.toLocaleString()} </Text> </Box> <Box width={outputTokensWidth} justifyContent="flex-end"> <Text color={theme.status.warning}> - {modelMetrics.tokens.candidates.toLocaleString()} + {metrics.tokens.candidates.toLocaleString()} </Text> </Box> </Box> diff --git a/packages/cli/src/ui/components/ThemeDialog.tsx b/packages/cli/src/ui/components/ThemeDialog.tsx index a2ade610b..d5b71811d 100644 --- a/packages/cli/src/ui/components/ThemeDialog.tsx +++ b/packages/cli/src/ui/components/ThemeDialog.tsx @@ -8,7 +8,11 @@ import type React from 'react'; import { useCallback, useState } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js'; +import { + themeManager, + DEFAULT_THEME, + AUTO_THEME_NAME, +} from '../themes/theme-manager.js'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { DiffRenderer } from './messages/DiffRenderer.js'; import { colorizeCode } from '../utils/CodeColorizer.js'; @@ -42,10 +46,11 @@ export function ThemeDialog({ SettingScope.User, ); - // Track the currently highlighted theme name + // Track the currently highlighted theme name. An unset theme means + // auto-detection is in effect, so reflect that by highlighting Auto. const [highlightedThemeName, setHighlightedThemeName] = useState< string | undefined - >(settings.merged.ui?.theme || DEFAULT_THEME.name); + >(settings.merged.ui?.theme || AUTO_THEME_NAME); // Generate theme items filtered by selected scope const customThemes = @@ -57,8 +62,15 @@ export function ThemeDialog({ .filter((theme) => theme.type !== 'custom'); const customThemeNames = Object.keys(customThemes); const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); - // Generate theme items + // Generate theme items with "Auto" at the top const themeItems = [ + { + label: t('Auto (detect terminal theme)'), + value: AUTO_THEME_NAME, + themeNameDisplay: t('Auto'), + themeTypeDisplay: t('Auto'), + key: AUTO_THEME_NAME, + }, ...builtInThemes.map((theme) => ({ label: theme.name, value: theme.name, @@ -224,10 +236,13 @@ export function ThemeDialog({ </Text> {/* Get the Theme object for the highlighted theme, fall back to default if not found */} {(() => { + // For 'auto', show the currently resolved theme (set by onHighlight → applyTheme) const previewTheme = - themeManager.getTheme( - highlightedThemeName || DEFAULT_THEME.name, - ) || DEFAULT_THEME; + highlightedThemeName === AUTO_THEME_NAME + ? themeManager.getActiveTheme() + : themeManager.getTheme( + highlightedThemeName || DEFAULT_THEME.name, + ) || DEFAULT_THEME; return ( <Box borderStyle="single" diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 4df29a062..6cd627160 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -19,39 +19,39 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-collapsed-match 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── (r:) commit ──────────────────────────────────────────────────────────────────────────────────────────────────── git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-expanded-match 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── (r:) commit ──────────────────────────────────────────────────────────────────────────────────────────────────── git commit -m "feat: add search" in src/app" `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── > Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── ! Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── * Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` -"──────────────────────────────────────────────────────────────────────────────────────────────────── +"──────────────────────────────────────────────────────────────────────────────── > Type your message or @path/to/file ────────────────────────────────────────────────────────────────────────────────────────────────────" `; diff --git a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap index efc0862b5..970b5159d 100644 --- a/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ModelStatsDisplay.test.tsx.snap @@ -28,20 +28,20 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one │ │ │ Model Stats For Nerds │ │ │ -│ Metric gemini-2.5-pro gemini-2.5-flash │ +│ Metric gemini-2.5-pro gemini-2.5-flash │ │ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ API │ -│ Requests 1 1 │ -│ Errors 0 (0.0%) 0 (0.0%) │ -│ Avg Latency 100ms 50ms │ +│ Requests 1 1 │ +│ Errors 0 (0.0%) 0 (0.0%) │ +│ Avg Latency 100ms 50ms │ │ │ │ Tokens │ -│ Total 30 15 │ -│ ↳ Prompt 10 5 │ -│ ↳ Cached 5 (50.0%) 0 (0.0%) │ -│ ↳ Thoughts 2 0 │ -│ ↳ Tool 0 3 │ -│ ↳ Output 20 10 │ +│ Total 30 15 │ +│ ↳ Prompt 10 5 │ +│ ↳ Cached 5 (50.0%) 0 (0.0%) │ +│ ↳ Thoughts 2 0 │ +│ ↳ Tool 0 3 │ +│ ↳ Output 20 10 │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -51,20 +51,20 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc │ │ │ Model Stats For Nerds │ │ │ -│ Metric gemini-2.5-pro gemini-2.5-flash │ +│ Metric gemini-2.5-pro gemini-2.5-flash │ │ ────────────────────────────────────────────────────────────────────────────────────────────── │ │ API │ -│ Requests 10 20 │ -│ Errors 1 (10.0%) 2 (10.0%) │ -│ Avg Latency 100ms 25ms │ +│ Requests 10 20 │ +│ Errors 1 (10.0%) 2 (10.0%) │ +│ Avg Latency 100ms 25ms │ │ │ │ Tokens │ -│ Total 300 600 │ -│ ↳ Prompt 100 200 │ -│ ↳ Cached 50 (50.0%) 100 (50.0%) │ -│ ↳ Thoughts 10 20 │ -│ ↳ Tool 5 10 │ -│ ↳ Output 200 400 │ +│ Total 300 600 │ +│ ↳ Prompt 100 200 │ +│ ↳ Cached 50 (50.0%) 100 (50.0%) │ +│ ↳ Thoughts 10 20 │ +│ ↳ Tool 5 10 │ +│ ↳ Output 200 400 │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap index dfa39ba81..bbb95094c 100644 --- a/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SessionSummaryDisplay.test.tsx.snap @@ -18,9 +18,9 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1` │ » Tool Time: 0s (0.0%) │ │ │ │ │ -│ Model Usage Reqs Input Tokens Output Tokens │ -│ ─────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 10 1,000 2,000 │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 10 1,000 2,000 │ │ │ │ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap index 8106d1f5d..c0cb471f9 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatsDisplay.test.tsx.snap @@ -118,9 +118,9 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti │ » Tool Time: 0s (0.0%) │ │ │ │ │ -│ Model Usage Reqs Input Tokens Output Tokens │ -│ ─────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 100 100 │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 100 100 │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; @@ -202,10 +202,10 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = ` │ » Tool Time: 0s (0.0%) │ │ │ │ │ -│ Model Usage Reqs Input Tokens Output Tokens │ -│ ─────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 3 1,000 2,000 │ -│ gemini-2.5-flash 5 25,000 15,000 │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 3 1,000 2,000 │ +│ gemini-2.5-flash 5 25,000 15,000 │ │ │ │ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │ │ │ @@ -232,9 +232,9 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] = │ » Tool Time: 123ms (55.2%) │ │ │ │ │ -│ Model Usage Reqs Input Tokens Output Tokens │ -│ ─────────────────────────────────────────────────────────────── │ -│ gemini-2.5-pro 1 100 100 │ +│ Model Usage Reqs Input Tokens Output Tokens │ +│ ───────────────────────────────────────────────────────────────────────── │ +│ gemini-2.5-pro 1 100 100 │ │ │ │ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │ │ │ diff --git a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap index 479bfe3c1..d9628dc11 100644 --- a/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ThemeDialog.test.tsx.snap @@ -18,18 +18,18 @@ exports[`ThemeDialog Snapshots > should render correctly in theme selection mode │ │ │ > Select Theme Preview │ │ ▲ ┌─────────────────────────────────────────────────┐ │ -│ 1. Qwen Light Light │ │ │ -│ › 2. Qwen Dark Dark │ 1 # function │ │ -│ 3. ANSI Dark │ 2 def fibonacci(n): │ │ -│ 4. Atom One Dark │ 3 a, b = 0, 1 │ │ -│ 5. Ayu Dark │ 4 for _ in range(n): │ │ -│ 6. Default Dark │ 5 a, b = b, a + b │ │ -│ 7. Dracula Dark │ 6 return a │ │ -│ 8. GitHub Dark │ │ │ -│ 9. Shades Of Purple Dark │ 1 - print("Hello, " + name) │ │ -│ 10. ANSI Light Light │ 1 + print(f"Hello, {name}!") │ │ -│ 11. Ayu Light Light │ │ │ -│ 12. Default Light Light └─────────────────────────────────────────────────┘ │ +│ › 1. Auto Auto │ │ │ +│ 2. Qwen Light Light │ 1 # function │ │ +│ 3. Qwen Dark Dark │ 2 def fibonacci(n): │ │ +│ 4. ANSI Dark │ 3 a, b = 0, 1 │ │ +│ 5. Atom One Dark │ 4 for _ in range(n): │ │ +│ 6. Ayu Dark │ 5 a, b = b, a + b │ │ +│ 7. Default Dark │ 6 return a │ │ +│ 8. Dracula Dark │ │ │ +│ 9. GitHub Dark │ 1 - print("Hello, " + name) │ │ +│ 10. Shades Of Purple Dark │ 1 + print(f"Hello, {name}!") │ │ +│ 11. ANSI Light Light │ │ │ +│ 12. Ayu Light Light └─────────────────────────────────────────────────┘ │ │ ▼ │ │ │ │ (Use Enter to select, Tab to configure scope) │ diff --git a/packages/cli/src/ui/components/arena/ArenaCards.test.tsx b/packages/cli/src/ui/components/arena/ArenaCards.test.tsx new file mode 100644 index 000000000..d566b8314 --- /dev/null +++ b/packages/cli/src/ui/components/arena/ArenaCards.test.tsx @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AgentStatus } from '@qwen-code/qwen-code-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { ArenaSessionCard } from './ArenaCards.js'; +import type { ArenaAgentCardData } from '../../types.js'; + +describe('ArenaSessionCard', () => { + it('renders the comparison summary sections from agent results', () => { + const agents: ArenaAgentCardData[] = [ + { + label: 'qwen-coder-plus', + status: AgentStatus.IDLE, + durationMs: 12_000, + totalTokens: 45_000, + inputTokens: 30_000, + outputTokens: 15_000, + toolCalls: 12, + successfulToolCalls: 12, + failedToolCalls: 0, + rounds: 3, + diffSummary: { + files: [ + { path: 'src/auth.ts', additions: 200, deletions: 80 }, + { path: 'tests/auth.test.ts', additions: 45, deletions: 9 }, + ], + additions: 245, + deletions: 89, + }, + modifiedFiles: ['src/auth.ts', 'tests/auth.test.ts'], + approachSummary: 'Refactored with JWT strategy pattern.', + }, + { + label: 'gpt-4o', + status: AgentStatus.IDLE, + durationMs: 10_000, + totalTokens: 38_000, + inputTokens: 25_000, + outputTokens: 13_000, + toolCalls: 8, + successfulToolCalls: 8, + failedToolCalls: 0, + rounds: 2, + diffSummary: { + files: [ + { path: 'src/auth.ts', additions: 120, deletions: 40 }, + { path: 'src/middleware.ts', additions: 69, deletions: 27 }, + ], + additions: 189, + deletions: 67, + }, + modifiedFiles: ['src/auth.ts', 'src/middleware.ts'], + approachSummary: 'Made inline changes with validation layer.', + }, + ]; + + const { lastFrame } = renderWithProviders( + <ArenaSessionCard + sessionStatus="idle" + task="Refactor authentication" + totalDurationMs={12_000} + agents={agents} + width={100} + />, + ); + + const output = lastFrame(); + expect(output).toContain('Arena Comparison Summary'); + expect(output).not.toContain('Status Time Tokens Changes'); + expect(output).toContain('Status Summary:'); + expect(output).toContain('qwen-coder-plus: Idle'); + expect(output).toContain('gpt-4o: Idle'); + expect(output).toContain('Files Modified:'); + expect(output).toContain('common: src/auth.ts'); + expect(output).toContain('qwen-coder-plus-only: tests/auth.test.ts'); + expect(output).toContain('gpt-4o-only: src/middleware.ts'); + expect(output).toContain('Approach Summary:'); + expect(output).toContain('Refactored with JWT strategy pattern.'); + expect(output).toContain('Token Efficiency:'); + expect(output).toContain('45,000 tokens'); + expect(output).toContain('45,000 tokens · runtime 12.0s'); + expect(output).not.toContain('45,000 tokens · runtime 12.0s · 12 tools'); + expect(output).not.toContain('Quick Preview:'); + expect(output).not.toContain('[View Detailed Diff]'); + expect(output).not.toContain('[Select Winner →]'); + }); + + it('hides empty per-agent unique file groups', () => { + const agents: ArenaAgentCardData[] = [ + { + label: 'gemma4:31b', + status: AgentStatus.IDLE, + durationMs: 10_000, + totalTokens: 10_000, + inputTokens: 7_000, + outputTokens: 3_000, + toolCalls: 2, + successfulToolCalls: 2, + failedToolCalls: 0, + rounds: 1, + diffSummary: { + files: [{ path: 'reader.py', additions: 20, deletions: 0 }], + additions: 20, + deletions: 0, + }, + modifiedFiles: ['reader.py'], + approachSummary: 'Created a reader.', + }, + { + label: 'qwen2.5:14b', + status: AgentStatus.IDLE, + durationMs: 8_000, + totalTokens: 8_000, + inputTokens: 6_000, + outputTokens: 2_000, + toolCalls: 2, + successfulToolCalls: 2, + failedToolCalls: 0, + rounds: 1, + diffSummary: { + files: [{ path: 'reader.py', additions: 22, deletions: 0 }], + additions: 22, + deletions: 0, + }, + modifiedFiles: ['reader.py'], + approachSummary: 'Created a reader.', + }, + ]; + + const { lastFrame } = renderWithProviders( + <ArenaSessionCard + sessionStatus="idle" + task="Create a reader" + totalDurationMs={10_000} + agents={agents} + width={100} + />, + ); + + const output = lastFrame(); + expect(output).toContain('common: reader.py'); + expect(output).not.toContain('only gemma4:31b: none'); + expect(output).not.toContain('only qwen2.5:14b: none'); + }); +}); diff --git a/packages/cli/src/ui/components/arena/ArenaCards.tsx b/packages/cli/src/ui/components/arena/ArenaCards.tsx index 1ad7d8e2a..b39933a3e 100644 --- a/packages/cli/src/ui/components/arena/ArenaCards.tsx +++ b/packages/cli/src/ui/components/arena/ArenaCards.tsx @@ -10,6 +10,7 @@ import { theme } from '../../semantic-colors.js'; import { formatDuration } from '../../utils/formatters.js'; import { getArenaStatusLabel } from '../../utils/displayUtils.js'; import type { ArenaAgentCardData } from '../../types.js'; +import type { ArenaDiffSummary } from '@qwen-code/qwen-code-core'; // ─── Helpers ──────────────────────────────────────────────── @@ -84,36 +85,25 @@ interface ArenaSessionCardProps { width?: number; } -/** - * Pad or truncate a string to a fixed visual width. - */ -function pad( - str: string, - len: number, - align: 'left' | 'right' = 'left', -): string { - if (str.length >= len) return str.slice(0, len); - const padding = ' '.repeat(len - str.length); - return align === 'right' ? padding + str : str + padding; -} - -/** - * Truncate a string to a maximum length, adding ellipsis if truncated. - */ -function truncate(str: string, maxLen: number): string { - if (str.length <= maxLen) return str; - return str.slice(0, maxLen - 1) + '…'; -} - /** * Calculate diff stats from a unified diff string. * Returns the stats string and individual counts for colored rendering. */ -function getDiffStats(diff: string | undefined): { +function getDiffStats( + diff: string | undefined, + diffSummary?: ArenaDiffSummary, +): { text: string; additions: number; deletions: number; } { + if (diffSummary) { + return { + text: `+${diffSummary.additions}/-${diffSummary.deletions}`, + additions: diffSummary.additions, + deletions: diffSummary.deletions, + }; + } if (!diff) return { text: '', additions: 0, deletions: 0 }; const lines = diff.split('\n'); let additions = 0; @@ -128,33 +118,71 @@ function getDiffStats(diff: string | undefined): { return { text: `+${additions}/-${deletions}`, additions, deletions }; } -const MAX_MODEL_NAME_LENGTH = 35; +const MAX_FILE_LIST_ITEMS = 4; + +function formatFileList(files: string[] | undefined): string { + if (!files || files.length === 0) { + return 'none'; + } + const visible = files.slice(0, MAX_FILE_LIST_ITEMS); + const suffix = + files.length > MAX_FILE_LIST_ITEMS + ? `, +${files.length - MAX_FILE_LIST_ITEMS} more` + : ''; + return `${visible.join(', ')}${suffix}`; +} + +function getAgentFiles(agent: ArenaAgentCardData): string[] { + return ( + agent.modifiedFiles ?? + agent.diffSummary?.files.map((file) => file.path) ?? + [] + ); +} + +function getComparisonFileGroups( + agents: ArenaAgentCardData[], +): Array<{ label: string; files: string[] }> { + const counts = new Map<string, number>(); + for (const agent of agents) { + for (const file of new Set(getAgentFiles(agent))) { + counts.set(file, (counts.get(file) ?? 0) + 1); + } + } + + const common = [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([file]) => file) + .sort(); + const groups = [{ label: 'common', files: common }]; + + for (const agent of agents) { + const unique = getAgentFiles(agent) + .filter((file) => counts.get(file) === 1) + .sort(); + if (unique.length > 0) { + groups.push({ label: `${agent.label}-only`, files: unique }); + } + } + + return groups; +} + +function getTreeBranch(index: number, total: number): string { + return index === total - 1 ? '└─' : '├─'; +} export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({ sessionStatus, - task, agents, width, }) => { - // Truncate task for display - const maxTaskLen = 60; - const displayTask = - task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task; - - // Column widths for the agent table (unified with Arena Results) - const colStatus = 14; - const colTime = 8; - const colTokens = 10; - const colChanges = 10; - const titleLabel = - sessionStatus === 'idle' - ? 'Agents Status · Idle' - : sessionStatus === 'completed' - ? 'Arena Complete' - : sessionStatus === 'cancelled' - ? 'Arena Cancelled' - : 'Arena Failed'; + sessionStatus === 'idle' || sessionStatus === 'completed' + ? 'Arena Comparison Summary' + : sessionStatus === 'cancelled' + ? 'Arena Cancelled' + : 'Arena Failed'; return ( <Box @@ -174,96 +202,109 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({ <Box height={1} /> - {/* Task */} - <Box> - <Text> - <Text color={theme.text.secondary}>Task: </Text> - <Text color={theme.text.primary}>"{displayTask}"</Text> - </Text> - </Box> - - <Box height={1} /> - - {/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */} - <Box> - <Box flexGrow={1}> - <Text bold color={theme.text.secondary}> - Agent - </Text> - </Box> - <Box width={colStatus} justifyContent="flex-end"> - <Text bold color={theme.text.secondary}> - Status - </Text> - </Box> - <Box width={colTime} justifyContent="flex-end"> - <Text bold color={theme.text.secondary}> - Time - </Text> - </Box> - <Box width={colTokens} justifyContent="flex-end"> - <Text bold color={theme.text.secondary}> - Tokens - </Text> - </Box> - <Box width={colChanges} justifyContent="flex-end"> - <Text bold color={theme.text.secondary}> - Changes - </Text> - </Box> - </Box> - - {/* Table separator */} - <Box> - <Text color={theme.border.default}> - {'─'.repeat((width ?? 60) - 8)} - </Text> - </Box> - - {/* Agent rows */} - {agents.map((agent) => { - const { text: statusText, color } = getArenaStatusLabel(agent.status); - const diffStats = getDiffStats(agent.diff); - return ( - <Box key={agent.label}> - <Box flexGrow={1}> - <Text color={theme.text.primary}> - {truncate(agent.label, MAX_MODEL_NAME_LENGTH)} - </Text> - </Box> - <Box width={colStatus} justifyContent="flex-end"> - <Text color={color}>{statusText}</Text> - </Box> - <Box width={colTime} justifyContent="flex-end"> - <Text color={theme.text.primary}> - {pad(formatDuration(agent.durationMs), colTime - 1, 'right')} - </Text> - </Box> - <Box width={colTokens} justifyContent="flex-end"> - <Text color={theme.text.primary}> - {pad( - agent.totalTokens.toLocaleString(), - colTokens - 1, - 'right', - )} - </Text> - </Box> - <Box width={colChanges} justifyContent="flex-end"> - {diffStats.additions > 0 || diffStats.deletions > 0 ? ( - <Text> - <Text color={theme.status.success}> - +{diffStats.additions} + {(sessionStatus === 'idle' || sessionStatus === 'completed') && ( + <> + <Box flexDirection="column"> + <Text bold color={theme.text.primary}> + Status Summary: + </Text> + {agents.map((agent, index) => { + const { text: statusText, color } = getArenaStatusLabel( + agent.status, + ); + return ( + <Box key={agent.label} marginLeft={2}> + <Text color={theme.text.secondary}> + {index === agents.length - 1 ? '└─' : '├─'} {agent.label} + :{' '} </Text> - <Text color={theme.text.secondary}>/</Text> - <Text color={theme.status.error}>-{diffStats.deletions}</Text> - </Text> - ) : ( - <Text color={theme.text.secondary}>-</Text> - )} - </Box> + <Text color={color}>{statusText}</Text> + </Box> + ); + })} </Box> - ); - })} + + <Box height={1} /> + + <Box flexDirection="column"> + <Text bold color={theme.text.primary}> + Files Modified: + </Text> + {getComparisonFileGroups(agents).map((group, index, groups) => ( + <Box key={group.label} marginLeft={2}> + <Text color={theme.text.secondary}> + {getTreeBranch(index, groups.length)} {group.label}:{' '} + </Text> + <Text color={theme.text.primary}> + {formatFileList(group.files)} + </Text> + </Box> + ))} + </Box> + + <Box height={1} /> + + <Box flexDirection="column"> + <Text bold color={theme.text.primary}> + Approach Summary: + </Text> + {agents.map((agent, index) => { + const diffStats = getDiffStats(agent.diff, agent.diffSummary); + const files = getAgentFiles(agent).length; + const branch = index === agents.length - 1 ? '└─' : '├─'; + const summary = + agent.approachSummary ?? 'No approach summary available.'; + return ( + <Box key={agent.label} marginLeft={2}> + <Text> + <Text color={theme.text.secondary}> + {branch} {agent.label}:{' '} + </Text> + <Text color={theme.text.primary}>{summary} </Text> + <Text color={theme.text.secondary}>(</Text> + <Text color={theme.text.accent}>{files}</Text> + <Text color={theme.text.secondary}> + {files === 1 ? ' file, ' : ' files, '} + </Text> + <Text color={theme.status.success}> + +{diffStats.additions} + </Text> + <Text color={theme.text.secondary}> </Text> + <Text color={theme.status.error}> + -{diffStats.deletions} + </Text> + <Text color={theme.text.secondary}> lines, </Text> + <Text color={theme.text.accent}>{agent.toolCalls}</Text> + <Text color={theme.text.secondary}> + {agent.toolCalls === 1 ? ' tool call)' : ' tool calls)'} + </Text> + </Text> + </Box> + ); + })} + </Box> + + <Box height={1} /> + + <Box flexDirection="column"> + <Text bold color={theme.text.primary}> + Token Efficiency: + </Text> + {agents.map((agent, index) => ( + <Box key={agent.label} marginLeft={2}> + <Text color={theme.text.secondary}> + {index === agents.length - 1 ? '└─' : '├─'} {agent.label} + :{' '} + </Text> + <Text color={theme.text.primary}> + {agent.totalTokens.toLocaleString()} tokens · runtime{' '} + {formatDuration(agent.durationMs)} + </Text> + </Box> + ))} + </Box> + </> + )} <Box height={1} /> @@ -271,9 +312,8 @@ export const ArenaSessionCard: React.FC<ArenaSessionCardProps> = ({ {sessionStatus === 'idle' && ( <Box flexDirection="column"> <Text color={theme.text.secondary}> - Switch to an agent tab to continue, or{' '} - <Text color={theme.text.accent}>/arena select</Text> to pick a - winner. + Run <Text color={theme.text.accent}>/arena select</Text> to view + detailed diff or pick a winner. </Text> </Box> )} diff --git a/packages/cli/src/ui/components/arena/ArenaSelectDialog.test.tsx b/packages/cli/src/ui/components/arena/ArenaSelectDialog.test.tsx new file mode 100644 index 000000000..efcfc9165 --- /dev/null +++ b/packages/cli/src/ui/components/arena/ArenaSelectDialog.test.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + AgentStatus, + ArenaSessionStatus, + type ArenaManager, + type Config, +} from '@qwen-code/qwen-code-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; +import { ArenaSelectDialog } from './ArenaSelectDialog.js'; + +describe('ArenaSelectDialog', () => { + it('toggles quick preview and detailed diff for the highlighted agent', async () => { + const result = { + sessionId: 'arena-1', + task: 'Update auth', + status: ArenaSessionStatus.IDLE, + agents: [ + { + agentId: 'model-1', + model: { modelId: 'model-1', authType: 'openai' }, + status: AgentStatus.IDLE, + worktree: { + id: 'w1', + name: 'model-1', + path: '/tmp/model-1', + branch: 'arena/model-1', + isActive: true, + createdAt: 1, + }, + stats: { + rounds: 1, + totalTokens: 1000, + inputTokens: 700, + outputTokens: 300, + durationMs: 2000, + toolCalls: 2, + successfulToolCalls: 2, + failedToolCalls: 0, + }, + diff: `diff --git a/src/auth.ts b/src/auth.ts +--- a/src/auth.ts ++++ b/src/auth.ts +@@ -1 +1 @@ +-old ++new`, + diffSummary: { + files: [{ path: 'src/auth.ts', additions: 1, deletions: 1 }], + additions: 1, + deletions: 1, + }, + modifiedFiles: ['src/auth.ts'], + approachSummary: 'Updated the auth implementation inline.', + startedAt: 1, + }, + ], + startedAt: 1, + wasRepoInitialized: false, + }; + + const manager = { + getResult: vi.fn(() => result), + getAgentStates: vi.fn(() => [ + { + agentId: 'model-1', + model: { modelId: 'model-1', authType: 'openai' }, + status: AgentStatus.IDLE, + stats: result.agents[0]!.stats, + }, + ]), + getAgentState: vi.fn(), + applyAgentResult: vi.fn(), + } as unknown as ArenaManager; + + const config = { + getArenaManager: () => manager, + cleanupArenaRuntime: vi.fn(), + getChatRecordingService: () => undefined, + } as unknown as Config; + + const { lastFrame, stdin } = renderWithProviders( + <ArenaSelectDialog + manager={manager} + config={config} + addItem={vi.fn()} + closeArenaDialog={vi.fn()} + />, + ); + + stdin.write('p'); + await waitFor(() => { + expect(lastFrame()).toContain('Quick Preview · model-1'); + }); + expect(lastFrame()).toContain('Updated the auth implementation inline.'); + + stdin.write('d'); + await waitFor(() => { + expect(lastFrame()).toContain('Detailed Diff · model-1'); + }); + expect(lastFrame()).toContain('diff --git a/src/auth.ts b/src/auth.ts'); + }); +}); diff --git a/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx index 88fe5a507..7ec69499f 100644 --- a/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx @@ -5,12 +5,13 @@ */ import type React from 'react'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { type ArenaManager, isSuccessStatus, type Config, + type ArenaAgentResult, } from '@qwen-code/qwen-code-core'; import { theme } from '../../semantic-colors.js'; import { useKeypress } from '../../hooks/useKeypress.js'; @@ -134,6 +135,17 @@ export function ArenaSelectDialog({ const result = manager.getResult(); const agents = manager.getAgentStates(); + const firstSelectableAgentId = agents.find((agent) => + isSuccessStatus(agent.status), + )?.agentId; + const [selectedAgentId, setSelectedAgentId] = useState<string | undefined>( + firstSelectableAgentId, + ); + const [showPreview, setShowPreview] = useState(false); + const [showDetailedDiff, setShowDetailedDiff] = useState(false); + const selectedResult = result?.agents.find( + (agent) => agent.agentId === selectedAgentId, + ); const items: Array<DescriptiveRadioSelectItem<string>> = useMemo( () => @@ -146,11 +158,16 @@ export function ArenaSelectDialog({ // Build diff summary from cached result if available let diffAdditions = 0; let diffDeletions = 0; + let fileCount = 0; if (isSuccessStatus(agent.status) && result) { const agentResult = result.agents.find( (a) => a.agentId === agent.agentId, ); - if (agentResult?.diff) { + if (agentResult?.diffSummary) { + diffAdditions = agentResult.diffSummary.additions; + diffDeletions = agentResult.diffSummary.deletions; + fileCount = agentResult.diffSummary.files.length; + } else if (agentResult?.diff) { const lines = agentResult.diff.split('\n'); for (const line of lines) { if (line.startsWith('+') && !line.startsWith('+++')) { @@ -160,6 +177,7 @@ export function ArenaSelectDialog({ } } } + fileCount = agentResult?.modifiedFiles?.length ?? fileCount; } // Title: full model name (not truncated) @@ -173,6 +191,12 @@ export function ArenaSelectDialog({ <Text color={theme.text.secondary}>{duration}</Text> <Text color={theme.text.secondary}> · </Text> <Text color={theme.text.secondary}>{tokens} tokens</Text> + {fileCount > 0 && ( + <> + <Text color={theme.text.secondary}> · </Text> + <Text color={theme.text.secondary}>{fileCount} files</Text> + </> + )} {(diffAdditions > 0 || diffDeletions > 0) && ( <> <Text color={theme.text.secondary}> · </Text> @@ -201,7 +225,13 @@ export function ArenaSelectDialog({ if (key.name === 'escape') { closeArenaDialog(); } + if (key.name === 'p' && !key.ctrl && !key.meta) { + setShowPreview((current) => !current); + } if (key.name === 'd' && !key.ctrl && !key.meta) { + setShowDetailedDiff((current) => !current); + } + if (key.name === 'x' && !key.ctrl && !key.meta) { onDiscard(); } }, @@ -245,16 +275,136 @@ export function ArenaSelectDialog({ onSelect={(agentId: string) => { onSelect(agentId); }} + onHighlight={(agentId: string) => { + setSelectedAgentId(agentId); + }} isFocused={true} showNumbers={false} /> </Box> + {showPreview && selectedResult && ( + <ArenaAgentPreview result={selectedResult} /> + )} + + {showDetailedDiff && selectedResult && ( + <ArenaAgentDetailedDiff result={selectedResult} /> + )} + <Box marginTop={1}> <Text color={theme.text.secondary}> - Enter to select, d to discard all, Esc to cancel + p preview, d detailed diff, Enter select winner, x discard all, Esc + cancel </Text> </Box> </Box> ); } + +function ArenaAgentPreview({ + result, +}: { + result: ArenaAgentResult; +}): React.JSX.Element { + const fileSummary = result.diffSummary?.files ?? []; + return ( + <Box marginTop={1} flexDirection="column"> + <Text bold color={theme.text.primary}> + Quick Preview · {result.model.modelId} + </Text> + <Box marginLeft={2}> + <Text color={theme.text.secondary}>Approach: </Text> + <Text color={theme.text.primary}> + {result.approachSummary ?? 'No approach summary available.'} + </Text> + </Box> + <Box marginLeft={2}> + <Text color={theme.text.secondary}>Major files: </Text> + <Text color={theme.text.primary}> + {formatFileList(fileSummary.map((file) => file.path))} + </Text> + </Box> + <Box marginLeft={2}> + <Text color={theme.text.secondary}>Metrics: </Text> + <Text color={theme.text.primary}> + {result.stats.totalTokens.toLocaleString()} tokens ·{' '} + {formatDuration(result.stats.durationMs)} · {result.stats.toolCalls}{' '} + tools + </Text> + </Box> + </Box> + ); +} + +function ArenaAgentDetailedDiff({ + result, +}: { + result: ArenaAgentResult; +}): React.JSX.Element { + const diffLines = getVisibleDiffLines(result.diff); + return ( + <Box marginTop={1} flexDirection="column"> + <Text bold color={theme.text.primary}> + Detailed Diff · {result.model.modelId} + </Text> + {diffLines.length === 0 ? ( + <Box marginLeft={2}> + <Text color={theme.text.secondary}>No diff available.</Text> + </Box> + ) : ( + <Box marginLeft={2} flexDirection="column"> + {diffLines.map((line, index) => ( + <Text key={`${index}-${line}`} color={getDiffLineColor(line)}> + {line} + </Text> + ))} + </Box> + )} + </Box> + ); +} + +function formatFileList(files: string[]): string { + if (files.length === 0) { + return 'none'; + } + const visible = files.slice(0, 6); + const suffix = + files.length > visible.length + ? `, +${files.length - visible.length} more` + : ''; + return `${visible.join(', ')}${suffix}`; +} + +function getVisibleDiffLines(diff: string | undefined): string[] { + if (!diff) { + return []; + } + const lines = diff.split('\n'); + const maxLines = 180; + if (lines.length <= maxLines) { + return lines; + } + return [ + ...lines.slice(0, maxLines), + `... truncated ${lines.length - maxLines} diff lines`, + ]; +} + +function getDiffLineColor(line: string): string { + if (line.startsWith('+') && !line.startsWith('+++')) { + return theme.status.success; + } + if (line.startsWith('-') && !line.startsWith('---')) { + return theme.status.error; + } + if ( + line.startsWith('diff --git') || + line.startsWith('@@') || + line.startsWith('---') || + line.startsWith('+++') + ) { + return theme.text.accent; + } + return theme.text.secondary; +} diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx index 42b82957f..0977752d7 100644 --- a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -27,7 +27,9 @@ const COPY_FEEDBACK_MS = 2000; /** * Wrap an OSC sequence for terminal multiplexers so the host terminal * receives it. tmux requires a DCS passthrough with inner ESCs doubled; - * GNU screen uses a plain DCS envelope. + * GNU screen uses a plain DCS envelope. Note: tmux 3.3+ defaults + * `allow-passthrough` to off — users on default configs will not see + * the hyperlink until they set `set -g allow-passthrough on`. */ function wrapForMultiplexer(osc: string): string { if (process.env['TMUX']) { @@ -39,6 +41,34 @@ function wrapForMultiplexer(osc: string): string { return osc; } +/** + * Strip C0 control characters and DEL so an untrusted string can be safely + * embedded inside an OSC escape. Without this a `\x07` (BEL) or `\x1b` (ESC) + * in the input would prematurely terminate the OSC sequence and leak the + * tail bytes to the terminal as interpretable escape codes. + */ +function sanitizeForOsc(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x00-\x1f\x7f]/g, ''); +} + +/** + * Wrap a URL in an OSC 8 hyperlink escape sequence. Supported terminals + * (iTerm2, WezTerm, Kitty, Windows Terminal, VS Code, GNOME Terminal, …) + * render it as a clickable link; terminals without OSC 8 support ignore + * the escapes and print the raw text. BEL (\x07) terminates the OSC + * sequence — more broadly supported than ST (ESC \\). + * + * Inside tmux / screen the OSC sequence is wrapped in a DCS passthrough + * envelope (see `wrapForMultiplexer`) so the multiplexer forwards it to + * the host terminal instead of eating it. + */ +function osc8Hyperlink(url: string, label = url): string { + const safeUrl = sanitizeForOsc(url); + const safeLabel = sanitizeForOsc(label); + return wrapForMultiplexer(`\x1b]8;;${safeUrl}\x07${safeLabel}\x1b]8;;\x07`); +} + /** * Copy a string to the user's clipboard using the OSC 52 terminal escape * sequence. Works through SSH and most web terminals (iTerm2, Windows @@ -260,6 +290,12 @@ export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({ </Box> )} + {authUrl && ( + <Box> + <Text color={theme.text.accent}>{osc8Hyperlink(authUrl)}</Text> + </Box> + )} + {/* Action hints */} <Box flexDirection="column"> {authState === 'authenticating' && ( diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx new file mode 100644 index 000000000..e0180c3d1 --- /dev/null +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.test.tsx @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import { CompactToolGroupDisplay } from './CompactToolGroupDisplay.js'; +import { ToolCallStatus } from '../../types.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; + +// ToolStatusIndicator pulls in GeminiRespondingSpinner which requires +// StreamingContext; stub it out so we can test the elapsed/timeout +// plumbing in isolation. +vi.mock('../shared/ToolStatusIndicator.js', () => ({ + ToolStatusIndicator: () => <Text>•</Text>, + STATUS_INDICATOR_WIDTH: 2, +})); + +const NOW = 1_700_000_000_000; + +function shellTool( + overrides: Partial<IndividualToolCallDisplay> = {}, +): IndividualToolCallDisplay { + return { + callId: 'c1', + name: 'Shell', + description: 'sleep 10', + status: ToolCallStatus.Executing, + executionStartTime: NOW, + resultDisplay: undefined, + confirmationDetails: undefined, + ...overrides, + }; +} + +describe('<CompactToolGroupDisplay />', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('surfaces shell timeoutMs inline via ToolElapsedTime', () => { + const tool = shellTool({ + resultDisplay: { + ansiOutput: [], + totalLines: 0, + totalBytes: 0, + timeoutMs: 30_000, + }, + }); + const { lastFrame } = render( + <CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />, + ); + expect(lastFrame()).toContain('(0s · timeout 30s)'); + }); + + it('falls back to quiet elapsed-only when no timeout is surfaced', () => { + const tool = shellTool({ + resultDisplay: { + ansiOutput: [], + totalLines: 0, + totalBytes: 0, + }, + }); + const { lastFrame } = render( + <CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />, + ); + // Sub-3s without a timeout budget → indicator is quiet. + expect(lastFrame()).not.toContain('timeout'); + expect(lastFrame()).not.toContain('0s'); + }); + + it('ignores non-ansi resultDisplay shapes', () => { + const tool = shellTool({ + resultDisplay: 'plain text output', + }); + const { lastFrame, rerender } = render( + <CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />, + ); + vi.advanceTimersByTime(5_000); + rerender(<CompactToolGroupDisplay toolCalls={[tool]} contentWidth={80} />); + // No timeout in display → legacy 3s-threshold elapsed. + expect(lastFrame()).toContain('5s'); + expect(lastFrame()).not.toContain('timeout'); + }); +}); diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx index 2514f8bec..063a0c283 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import type { IndividualToolCallDisplay } from '../../types.js'; import { ToolCallStatus } from '../../types.js'; +import type { AnsiOutputDisplay } from '@qwen-code/qwen-code-core'; import { SHELL_COMMAND_NAME, SHELL_NAME } from '../../constants.js'; import { theme } from '../../semantic-colors.js'; import { t } from '../../../i18n/index.js'; @@ -47,6 +48,24 @@ function getActiveTool( ); } +// Pull the configured shell timeout off an AnsiOutputDisplay result so +// ToolElapsedTime can surface it inline (matches the expanded +// ToolMessage path). Non-ansi resultDisplay → undefined → legacy +// quiet-then-elapsed behavior. +function getShellTimeoutMs( + tool: IndividualToolCallDisplay, +): number | undefined { + const display = tool.resultDisplay; + if ( + typeof display === 'object' && + display !== null && + 'ansiOutput' in display + ) { + return (display as AnsiOutputDisplay).timeoutMs; + } + return undefined; +} + export const CompactToolGroupDisplay: React.FC< CompactToolGroupDisplayProps > = ({ toolCalls, contentWidth }) => { @@ -107,6 +126,7 @@ export const CompactToolGroupDisplay: React.FC< <ToolElapsedTime status={overallStatus} executionStartTime={activeTool.executionStartTime} + timeoutMs={getShellTimeoutMs(activeTool)} /> </Box> diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx index e4be0b79c..a75493558 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -125,11 +125,22 @@ export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => ( /> ); +// Mirrors Claude Code's away-summary rendering: a `※` prefix in a fixed +// 2-column gutter, then bold "recap: " label and italic content, all +// dim-colored. Rendered as a regular history item so it scrolls with +// the conversation instead of pinning above the input. export const AwayRecapMessage: React.FC<StatusTextProps> = ({ text }) => ( - <StatusMessage - text={text} - prefix="※ recap:" - prefixColor={theme.text.secondary} - textColor={theme.text.secondary} - /> + <Box flexDirection="row"> + <Box width={2} flexShrink={0}> + <Text color={theme.text.secondary}>※</Text> + </Box> + <Text wrap="wrap"> + <Text color={theme.text.secondary} bold> + recap:{' '} + </Text> + <Text color={theme.text.secondary} italic> + {text} + </Text> + </Text> + </Box> ); diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 5056ce269..a8e57c8a1 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -38,9 +38,11 @@ vi.mock('../AnsiOutput.js', () => ({ AnsiOutputText: function MockAnsiOutputText({ data, maxWidth, + availableTerminalHeight, }: { data: AnsiOutput; maxWidth: number; + availableTerminalHeight?: number; }) { // Simple serialization for snapshot stability const serialized = data @@ -48,12 +50,19 @@ vi.mock('../AnsiOutput.js', () => ({ .join('\n'); return ( <Text> - MockAnsiOutput:{serialized}:width={maxWidth} + MockAnsiOutput:{serialized}:width={maxWidth}:height= + {availableTerminalHeight ?? 'undef'} </Text> ); }, - ShellStatsBar: function MockShellStatsBar() { - return null; + ShellStatsBar: function MockShellStatsBar({ + displayHeight, + }: { + displayHeight?: number; + }) { + return ( + <Text>MockShellStatsBar:displayHeight={displayHeight ?? 'undef'}</Text> + ); }, })); @@ -331,6 +340,288 @@ describe('<ToolMessage />', () => { expect(lastFrame()).toContain('width='); }); + it('caps shell ANSI output to default 5 lines when not forced', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const { lastFrame } = renderWithContext( + <ToolMessage + {...baseProps} + name="Shell" + resultDisplay={ansiOutputDisplay} + availableTerminalHeight={100} + />, + StreamingState.Idle, + ); + const output = lastFrame()!; + expect(output).toContain('height=5'); + expect(output).toContain('MockShellStatsBar:displayHeight=5'); + }); + + it('does not cap non-shell ANSI output', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const { lastFrame } = renderWithContext( + <ToolMessage + {...baseProps} + name="some-other-tool" + resultDisplay={ansiOutputDisplay} + availableTerminalHeight={100} + />, + StreamingState.Idle, + ); + const output = lastFrame()!; + // availableHeight = 100 - STATIC_HEIGHT(1) - RESERVED_LINE_COUNT(5) = 94 + expect(output).toContain('height=94'); + }); + + it('bypasses cap when forceShowResult is true', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const { lastFrame } = renderWithContext( + <ToolMessage + {...baseProps} + name="Shell" + resultDisplay={ansiOutputDisplay} + availableTerminalHeight={100} + forceShowResult={true} + />, + StreamingState.Idle, + ); + const output = lastFrame()!; + // availableHeight = 100 - STATIC_HEIGHT(1) - RESERVED_LINE_COUNT(5) = 94 + expect(output).toContain('height=94'); + }); + + it('disables cap when ui.shellOutputMaxLines is 0', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const settingsWithDisabledCap = { + merged: { ui: { shellOutputMaxLines: 0 } }, + } as unknown as LoadedSettings; + const { lastFrame } = render( + <CompactModeProvider value={{ compactMode: false }}> + <SettingsContext.Provider value={settingsWithDisabledCap}> + <StreamingContext.Provider value={StreamingState.Idle}> + <ToolMessage + {...baseProps} + name="Shell" + resultDisplay={ansiOutputDisplay} + availableTerminalHeight={100} + /> + </StreamingContext.Provider> + </SettingsContext.Provider> + </CompactModeProvider>, + ); + const output = lastFrame()!; + expect(output).toContain('height=94'); + }); + + it('respects user-configured cap value', () => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const settingsWithCustomCap = { + merged: { ui: { shellOutputMaxLines: 12 } }, + } as unknown as LoadedSettings; + const { lastFrame } = render( + <CompactModeProvider value={{ compactMode: false }}> + <SettingsContext.Provider value={settingsWithCustomCap}> + <StreamingContext.Provider value={StreamingState.Idle}> + <ToolMessage + {...baseProps} + name="Shell" + resultDisplay={ansiOutputDisplay} + availableTerminalHeight={100} + /> + </StreamingContext.Provider> + </SettingsContext.Provider> + </CompactModeProvider>, + ); + const output = lastFrame()!; + expect(output).toContain('height=12'); + }); + + it('caps shell completed string output (returnDisplayMessage path)', () => { + // shell.ts emits the final result as a plain string via + // `returnDisplayMessage = result.output`, so the completed shell + // tool flows through StringResultRenderer, not the ANSI branch. + // The cap must still apply. + const longString = Array.from( + { length: 30 }, + (_, i) => `line ${i + 1}`, + ).join('\n'); + const { lastFrame } = renderWithContext( + <ToolMessage + {...baseProps} + name="Shell" + resultDisplay={longString} + status={ToolCallStatus.Success} + availableTerminalHeight={100} + />, + StreamingState.Idle, + ); + const output = lastFrame()!; + // With cap=5, the string path should show the last 5 content rows + // (the +1 height compensates for MaxSizedBox's overflow banner row, + // matching the ANSI path's 5 content rows + stats bar). + expect(output).not.toContain('line 1\n'); + expect(output).not.toContain('line 10'); + expect(output).toContain('line 26'); + expect(output).toContain('line 27'); + expect(output).toContain('line 28'); + expect(output).toContain('line 29'); + expect(output).toContain('line 30'); + }); + + it.each([ + ['negative', -1], + ['fractional', 1.5], + ['NaN-via-string', 'abc' as unknown as number], + ])('clamps %s shellOutputMaxLines to a safe value', (_label, badValue) => { + const ansiOutputDisplay: AnsiOutputDisplay = { + ansiOutput: [ + [ + { + text: 'a', + fg: '', + bg: '', + bold: false, + italic: false, + underline: false, + dim: false, + inverse: false, + }, + ], + ], + totalLines: 50, + }; + const settingsWithBadCap = { + merged: { ui: { shellOutputMaxLines: badValue } }, + } as unknown as LoadedSettings; + const { lastFrame } = render( + <CompactModeProvider value={{ compactMode: false }}> + <SettingsContext.Provider value={settingsWithBadCap}> + <StreamingContext.Provider value={StreamingState.Idle}> + <ToolMessage + {...baseProps} + name="Shell" + resultDisplay={ansiOutputDisplay} + availableTerminalHeight={100} + /> + </StreamingContext.Provider> + </SettingsContext.Provider> + </CompactModeProvider>, + ); + const output = lastFrame()!; + // -1 → 0 → cap disabled (height=94) + // 1.5 → 1 → cap to 1 (height=1) + // 'abc' → NaN → 0 → cap disabled (height=94) + if ( + typeof badValue === 'number' && + Number.isFinite(badValue) && + badValue > 0 + ) { + expect(output).toContain(`height=${Math.floor(badValue)}`); + } else { + expect(output).toContain('height=94'); + } + }); + + it('does not cap non-shell string output', () => { + const longString = Array.from( + { length: 30 }, + (_, i) => `line ${i + 1}`, + ).join('\n'); + const { lastFrame } = renderWithContext( + <ToolMessage + {...baseProps} + name="some-other-tool" + resultDisplay={longString} + status={ToolCallStatus.Success} + availableTerminalHeight={100} + />, + StreamingState.Idle, + ); + const output = lastFrame()!; + // availableHeight = 94, well above 30 lines → all visible + expect(output).toContain('line 1'); + expect(output).toContain('line 30'); + }); + it('renders rejected plan content with plan text still visible', () => { const planResultDisplay = { type: 'plan_summary' as const, diff --git a/packages/cli/src/ui/components/messages/ToolMessage.tsx b/packages/cli/src/ui/components/messages/ToolMessage.tsx index 8e37d4cae..64f616cfc 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.tsx @@ -41,6 +41,7 @@ import { ToolElapsedTime } from '../shared/ToolElapsedTime.js'; const STATIC_HEIGHT = 1; const RESERVED_LINE_COUNT = 5; // for tool name, status, padding etc. const MIN_LINES_SHOWN = 2; // show at least this many lines +const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5; // Large threshold to ensure we don't cause performance issues for very large // outputs that will get truncated further MaxSizedBox anyway. @@ -146,7 +147,6 @@ const useResultDisplayRenderer = ( stats: { totalLines: display.totalLines, totalBytes: display.totalBytes, - timeoutMs: display.timeoutMs, }, }; } @@ -313,6 +313,21 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ } }, [resultDisplay]); + // Shell tools surface their configured timeout via AnsiOutputDisplay as + // soon as streaming starts. Feed it into ToolElapsedTime so the budget is + // shown inline (`(elapsed · timeout N)`) instead of in a separate stats + // row. + const shellTimeoutMs = React.useMemo(() => { + if ( + typeof resultDisplay === 'object' && + resultDisplay !== null && + 'ansiOutput' in resultDisplay + ) { + return (resultDisplay as AnsiOutputDisplay).timeoutMs; + } + return undefined; + }, [resultDisplay]); + React.useEffect(() => { if (!lastUpdateTime) { return; @@ -345,6 +360,33 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ MIN_LINES_SHOWN + 1, // enforce minimum lines shown ) : undefined; + // Cap inline shell output. Applies to both the streaming ANSI display and + // the completed string display (shell.ts emits the final result as a plain + // string via `returnDisplayMessage = result.output`). ShellStatsBar surfaces + // hidden lines via `+N lines` for ANSI; MaxSizedBox handles overflow for string. + const isShellTool = name === SHELL_COMMAND_NAME || name === SHELL_NAME; + const rawShellCap = + settings.merged.ui?.shellOutputMaxLines ?? DEFAULT_SHELL_OUTPUT_MAX_LINES; + // Defensive: clamp non-negative integers; treat negatives / NaN / fractions + // as the user's clear intent (0 = disable, otherwise floor to whole rows). + const shellOutputMaxLines = Math.max(0, Math.floor(rawShellCap || 0)); + const isCappingShell = + isShellTool && + shellOutputMaxLines > 0 && + !forceShowResult && + !isThisShellFocused; + const shellCapHeight = isCappingShell + ? Math.min(availableHeight ?? shellOutputMaxLines, shellOutputMaxLines) + : availableHeight; + // String path: MaxSizedBox reserves one row for its overflow banner when + // content overflows (see MaxSizedBox.tsx visibleContentHeight = max - 1), + // so passing the bare cap shows N-1 content rows. ANSI pre-slices to N + // (no MaxSizedBox overflow) and renders N rows + the ShellStatsBar line. + // +1 keeps the two paths visually symmetric at N visible content rows. + const shellStringCapHeight = + isCappingShell && shellCapHeight !== undefined + ? shellCapHeight + 1 + : availableHeight; const innerWidth = contentWidth - STATUS_INDICATOR_WIDTH; // Long tool call response in MarkdownDisplay doesn't respect availableTerminalHeight properly, @@ -382,6 +424,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ <ToolElapsedTime status={status} executionStartTime={executionStartTime} + timeoutMs={shellTimeoutMs} /> {emphasis === 'high' && <TrailingIndicator />} </Box> @@ -420,13 +463,13 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ <> <AnsiOutputText data={effectiveDisplayRenderer.data} - availableTerminalHeight={availableHeight} + availableTerminalHeight={shellCapHeight} maxWidth={innerWidth} /> {effectiveDisplayRenderer.stats && ( <ShellStatsBar {...effectiveDisplayRenderer.stats} - displayHeight={availableHeight} + displayHeight={shellCapHeight} /> )} </> @@ -435,7 +478,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({ <StringResultRenderer data={effectiveDisplayRenderer.data} renderAsMarkdown={renderOutputAsMarkdown} - availableHeight={availableHeight} + availableHeight={shellStringCapHeight} childWidth={innerWidth} /> )} diff --git a/packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx b/packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx new file mode 100644 index 000000000..e4a157454 --- /dev/null +++ b/packages/cli/src/ui/components/shared/ToolElapsedTime.test.tsx @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { ToolCallStatus } from '../../types.js'; +import { ToolElapsedTime } from './ToolElapsedTime.js'; + +describe('<ToolElapsedTime />', () => { + const NOW = 1_700_000_000_000; + + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders nothing for non-executing status', () => { + const { lastFrame } = render( + <ToolElapsedTime + status={ToolCallStatus.Success} + executionStartTime={NOW} + />, + ); + expect(lastFrame()).toBe(''); + }); + + it('stays quiet for the first 3s when no timeout is set', () => { + const { lastFrame, rerender } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + />, + ); + expect(lastFrame()).toBe(''); + + vi.advanceTimersByTime(2_000); + rerender( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + />, + ); + expect(lastFrame()).toBe(''); + }); + + it('shows elapsed seconds past the 3s threshold (no timeout)', () => { + const { lastFrame, rerender } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + />, + ); + vi.advanceTimersByTime(5_000); + rerender( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + />, + ); + expect(lastFrame()).toContain('5s'); + }); + + it('renders combined (elapsed · timeout N) from t=0 when timeout is set', () => { + const { lastFrame } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={30_000} + />, + ); + expect(lastFrame()).toContain('(0s · timeout 30s)'); + }); + + it('keeps fractional timeout precision', () => { + const { lastFrame } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={5_500} + />, + ); + expect(lastFrame()).toContain('(0s · timeout 5.5s)'); + }); + + it('advances elapsed inside the combined format', () => { + const { lastFrame, rerender } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={30_000} + />, + ); + vi.advanceTimersByTime(7_000); + rerender( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={30_000} + />, + ); + expect(lastFrame()).toContain('(7s · timeout 30s)'); + }); + + it('formats combined output once elapsed crosses into the minute range', () => { + const { lastFrame, rerender } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={5 * 60 * 1000} + />, + ); + vi.advanceTimersByTime(65_000); + rerender( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={5 * 60 * 1000} + />, + ); + expect(lastFrame()).toContain('(1m 5s · timeout 5m)'); + }); + + it('ignores non-positive timeouts (falls back to elapsed-only mode)', () => { + const { lastFrame } = render( + <ToolElapsedTime + status={ToolCallStatus.Executing} + executionStartTime={NOW} + timeoutMs={0} + />, + ); + // With no effective timeout, sub-3s = quiet. + expect(lastFrame()).toBe(''); + }); +}); diff --git a/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx b/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx index d4740d89b..5c0e09875 100644 --- a/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx +++ b/packages/cli/src/ui/components/shared/ToolElapsedTime.tsx @@ -9,39 +9,34 @@ import { useState, useEffect } from 'react'; import { Box, Text } from 'ink'; import { ToolCallStatus } from '../../types.js'; import { theme } from '../../semantic-colors.js'; - -/** - * Formats elapsed seconds as compact text. - * Under 60s: "3s", "45s". - * 60s+: "1m", "1m 30s", "2h 15m". - */ -export function formatElapsed(seconds: number): string { - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) { - return remainingSeconds > 0 - ? `${minutes}m ${remainingSeconds}s` - : `${minutes}m`; - } - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`; -} +import { formatDuration } from '../../utils/formatters.js'; interface ToolElapsedTimeProps { status: ToolCallStatus; executionStartTime?: number; + /** + * When provided, the elapsed indicator becomes a combined budget display: + * `(elapsed · timeout N)` visible from t=0 so the timeout is always on + * screen. When absent, the indicator keeps the 3-second quiet threshold + * and renders just the elapsed time. + */ + timeoutMs?: number; } /** - * Right-aligned elapsed-time indicator for an executing tool. Renders - * nothing until the tool has been running for at least 3 seconds, so quick - * tools stay visually quiet. + * Right-aligned elapsed-time indicator for an executing tool. + * + * Two modes: + * - no `timeoutMs`: suppressed for the first 3 seconds so fast tools stay + * visually quiet. + * - with `timeoutMs`: rendered as `(elapsed · timeout N)` from t=0 so the + * user can see both how long the tool has been running and how much + * budget remains. */ export const ToolElapsedTime: React.FC<ToolElapsedTimeProps> = ({ status, executionStartTime, + timeoutMs, }) => { const [elapsedSeconds, setElapsedSeconds] = useState(0); @@ -57,11 +52,21 @@ export const ToolElapsedTime: React.FC<ToolElapsedTimeProps> = ({ return () => clearInterval(interval); }, [status, executionStartTime]); - if (status !== ToolCallStatus.Executing || elapsedSeconds < 3) return null; + if (status !== ToolCallStatus.Executing) return null; + + const hasTimeout = timeoutMs != null && timeoutMs > 0; + if (!hasTimeout && elapsedSeconds < 3) return null; + + const elapsedStr = formatDuration(elapsedSeconds * 1000, { + hideTrailingZeros: true, + }); + const label = hasTimeout + ? `(${elapsedStr} · timeout ${formatDuration(timeoutMs, { hideTrailingZeros: true })})` + : elapsedStr; return ( <Box flexShrink={0} marginLeft={1}> - <Text color={theme.text.secondary}>{formatElapsed(elapsedSeconds)}</Text> + <Text color={theme.text.secondary}>{label}</Text> </Box> ); }; diff --git a/packages/cli/src/ui/contexts/SessionContext.test.tsx b/packages/cli/src/ui/contexts/SessionContext.test.tsx index f1550e255..6145a5e03 100644 --- a/packages/cli/src/ui/contexts/SessionContext.test.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.test.tsx @@ -73,6 +73,7 @@ describe('SessionStatsContext', () => { thoughts: 20, tool: 10, }, + bySource: {}, }, }, tools: { @@ -151,6 +152,7 @@ describe('SessionStatsContext', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { @@ -192,6 +194,7 @@ describe('SessionStatsContext', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, }; diff --git a/packages/cli/src/ui/contexts/SessionContext.tsx b/packages/cli/src/ui/contexts/SessionContext.tsx index 9fdb4fa25..8d5a103ed 100644 --- a/packages/cli/src/ui/contexts/SessionContext.tsx +++ b/packages/cli/src/ui/contexts/SessionContext.tsx @@ -17,6 +17,7 @@ import { import type { SessionMetrics, ModelMetrics, + ModelMetricsCore, ToolCallStats, } from '@qwen-code/qwen-code-core'; import { uiTelemetryService } from '@qwen-code/qwen-code-core'; @@ -28,7 +29,10 @@ export enum ToolCallDecision { AUTO_ACCEPT = 'auto_accept', } -function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean { +function areModelMetricsCoreEqual( + a: ModelMetricsCore, + b: ModelMetricsCore, +): boolean { if ( a.api.totalRequests !== b.api.totalRequests || a.api.totalErrors !== b.api.totalErrors || @@ -49,6 +53,23 @@ function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean { return true; } +function areModelMetricsEqual(a: ModelMetrics, b: ModelMetrics): boolean { + if (!areModelMetricsCoreEqual(a, b)) return false; + + const aKeys = Object.keys(a.bySource); + const bKeys = Object.keys(b.bySource); + if (aKeys.length !== bKeys.length) return false; + + for (const key of aKeys) { + const aSource = a.bySource[key]; + const bSource = b.bySource[key]; + if (!bSource || !areModelMetricsCoreEqual(aSource, bSource)) { + return false; + } + } + return true; +} + function areToolCallStatsEqual(a: ToolCallStats, b: ToolCallStats): boolean { if ( a.count !== b.count || @@ -138,7 +159,7 @@ function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean { return true; } -export type { SessionMetrics, ModelMetrics }; +export type { SessionMetrics, ModelMetrics, ModelMetricsCore }; export interface SessionStatsState { sessionId: string; diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index f068e16d1..5aac4e66a 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -13,9 +13,9 @@ import { type AuthType, type EditorType, type ApprovalMode, + type CodingPlanRegion, } from '@qwen-code/qwen-code-core'; import { type SettingScope } from '../../config/settings.js'; -import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import { type AlibabaStandardRegion } from '../../constants/alibabaStandardApiKey.js'; import type { AuthState } from '../types.js'; import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; @@ -101,6 +101,10 @@ export interface UIActions { openResumeDialog: () => void; closeResumeDialog: () => void; handleResume: (sessionId: string) => void; + // Delete session dialog + openDeleteDialog: () => void; + closeDeleteDialog: () => void; + handleDelete: (sessionId: string) => void; // Feedback dialog openFeedbackDialog: () => void; closeFeedbackDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 7922723b4..b2450ec2c 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -8,7 +8,6 @@ import { createContext, useContext } from 'react'; import type { HistoryItem, HistoryItemBtw, - HistoryItemAwayRecap, ThoughtSummary, ShellConfirmationRequest, ConfirmationRequest, @@ -26,6 +25,7 @@ import type { IdeContext, ApprovalMode, IdeInfo, + SessionListItem, } from '@qwen-code/qwen-code-core'; import type { DOMElement } from 'ink'; import type { SessionStatsState } from '../contexts/SessionContext.js'; @@ -62,6 +62,8 @@ export interface UIState { isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; isResumeDialogOpen: boolean; + resumeMatchedSessions: SessionListItem[] | undefined; + isDeleteDialogOpen: boolean; slashCommands: readonly SlashCommand[]; pendingSlashCommandHistoryItems: HistoryItemWithoutId[]; commandContext: CommandContext; @@ -111,8 +113,6 @@ export interface UIState { btwItem: HistoryItemBtw | null; setBtwItem: (item: HistoryItemBtw | null) => void; cancelBtw: () => void; - awayRecapItem: HistoryItemAwayRecap | null; - setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void; nightly: boolean; branchName: string | undefined; sessionStats: SessionStatsState; @@ -147,6 +147,13 @@ export interface UIState { isFeedbackDialogOpen: boolean; // Per-task token tracking taskStartTokens: number; + // Real-time token display: ref to streaming output char length (polled, not state) + streamingResponseLengthRef: React.RefObject<number>; + // True = receiving content (↓), false = waiting for API response (↑) + isReceivingContent: boolean; + // Session custom name (set via /rename) + sessionName: string | null; + setSessionName: (name: string | null) => void; // Prompt suggestion promptSuggestion: string | null; /** Dismiss prompt suggestion (clears state, aborts speculation) */ diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index e7fc91a0b..135ec7d11 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -25,13 +25,13 @@ import { SlashCommandStatus, ToolConfirmationOutcome, IdeClient, + type SessionListItem, } from '@qwen-code/qwen-code-core'; import { useSessionStats } from '../contexts/SessionContext.js'; import type { Message, HistoryItemWithoutId, HistoryItemBtw, - HistoryItemAwayRecap, SlashCommandProcessorResult, HistoryItem, ConfirmationRequest, @@ -44,6 +44,7 @@ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; import { BundledSkillLoader } from '../../services/BundledSkillLoader.js'; import { FileCommandLoader } from '../../services/FileCommandLoader.js'; import { McpPromptLoader } from '../../services/McpPromptLoader.js'; +import { SkillCommandLoader } from '../../services/SkillCommandLoader.js'; import { parseSlashCommand } from '../../utils/commands.js'; import { isBtwCommand } from '../utils/commandUtils.js'; import { clearScreen } from '../../utils/stdioHelpers.js'; @@ -73,6 +74,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ 'reset', 'new', 'resume', + 'delete', 'btw', ]); @@ -87,7 +89,9 @@ interface SlashCommandProcessorActions { openTrustDialog: () => void; openPermissionsDialog: () => void; openApprovalModeDialog: () => void; - openResumeDialog: () => void; + openResumeDialog: (matchedSessions?: SessionListItem[]) => void; + handleResume: (sessionId: string) => void; + openDeleteDialog: () => void; quit: (messages: HistoryItem[]) => void; setDebugMessage: (message: string) => void; dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void; @@ -118,6 +122,7 @@ export const useSlashCommandProcessor = ( extensionsUpdateState: Map<string, ExtensionUpdateStatus>, isConfigInitialized: boolean, logger: Logger | null, + setSessionName?: (name: string | null) => void, ) => { const { stats: sessionStats, startNewSession } = useSessionStats(); const [commands, setCommands] = useState<readonly SlashCommand[]>([]); @@ -156,9 +161,6 @@ export const useSlashCommandProcessor = ( const [btwItem, setBtwItem] = useState<HistoryItemBtw | null>(null); const btwAbortControllerRef = useRef<AbortController | null>(null); - const [awayRecapItem, setAwayRecapItem] = - useState<HistoryItemAwayRecap | null>(null); - const cancelBtw = useCallback(() => { btwAbortControllerRef.current?.abort(); btwAbortControllerRef.current = null; @@ -261,7 +263,6 @@ export const useSlashCommandProcessor = ( ); const commandContext = useMemo( (): CommandContext => ({ - executionMode: 'interactive', services: { config, settings, @@ -272,10 +273,10 @@ export const useSlashCommandProcessor = ( addItem, clear: () => { cancelBtw(); - setAwayRecapItem(null); clearItems(); clearScreen(); refreshStatic(); + setSessionName?.(null); }, loadHistory, setDebugMessage: actions.setDebugMessage, @@ -285,12 +286,11 @@ export const useSlashCommandProcessor = ( setBtwItem, cancelBtw, btwAbortControllerRef, - awayRecapItem, - setAwayRecapItem, isIdleRef, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, + setSessionName: setSessionName ?? (() => {}), extensionsUpdateState, dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate, addConfirmUpdateExtensionRequest: @@ -301,6 +301,7 @@ export const useSlashCommandProcessor = ( sessionShellAllowlist, startNewSession, }, + executionMode: 'interactive' as const, }), [ config, @@ -319,12 +320,11 @@ export const useSlashCommandProcessor = ( btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, toggleVimEnabled, sessionShellAllowlist, setGeminiMdFileCount, reloadCommands, + setSessionName, extensionsUpdateState, isIdleRef, ], @@ -360,6 +360,7 @@ export const useSlashCommandProcessor = ( new McpPromptLoader(config), new BuiltinCommandLoader(config), new BundledSkillLoader(config), + new SkillCommandLoader(config), new FileCommandLoader(config), ]; const disabled = config?.getDisabledSlashCommands() ?? []; @@ -368,6 +369,53 @@ export const useSlashCommandProcessor = ( controller.signal, disabled.length > 0 ? new Set(disabled) : undefined, ); + // Register model-invocable commands provider so SkillTool can include + // bundled skills, file commands, and MCP prompts in its description. + if (config) { + config.setModelInvocableCommandsProvider(() => + commandService.getModelInvocableCommands().map((cmd) => ({ + name: cmd.name, + description: + typeof cmd.description === 'string' + ? cmd.description + : cmd.description, + })), + ); + // Register executor so SkillTool can actually invoke model-invocable + // commands (e.g. MCP prompts) that are not file-based skills. + config.setModelInvocableCommandsExecutor( + async (name: string, args: string = '') => { + const commands = commandService.getModelInvocableCommands(); + const cmd = commands.find((c) => c.name === name); + if (!cmd?.action) return null; + // Build a minimal context; submit_prompt actions only need + // invocation + services.config, not UI state. + const minimalContext = { + executionMode: 'non_interactive' as const, + invocation: { + raw: args ? `/${name} ${args}` : `/${name}`, + name, + args, + }, + services: { config, settings, git: gitService, logger: null }, + } as unknown as Parameters<typeof cmd.action>[0]; + const result = await cmd.action(minimalContext, args); + if (!result || result.type !== 'submit_prompt') return null; + const content = result.content; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .map((p) => + typeof p === 'string' + ? p + : ((p as { text?: string }).text ?? ''), + ) + .join(''); + } + return null; + }, + ); + } // Avoid overwriting newer results from a subsequent effect run if (!controller.signal.aborted) { setCommands(commandService.getCommandsForMode('interactive')); @@ -382,7 +430,7 @@ export const useSlashCommandProcessor = ( return () => { controller.abort(); }; - }, [config, reloadTrigger, isConfigInitialized]); + }, [config, reloadTrigger, isConfigInitialized, settings, gitService]); const handleSlashCommand = useCallback( async ( @@ -568,7 +616,14 @@ export const useSlashCommandProcessor = ( actions.openApprovalModeDialog(); return { type: 'handled' }; case 'resume': - actions.openResumeDialog(); + if (result.sessionId) { + actions.handleResume(result.sessionId); + } else { + actions.openResumeDialog(result.matchedSessions); + } + return { type: 'handled' }; + case 'delete': + actions.openDeleteDialog(); return { type: 'handled' }; case 'extensions_manage': actions.openExtensionsManagerDialog(); @@ -794,8 +849,6 @@ export const useSlashCommandProcessor = ( btwItem, setBtwItem, cancelBtw, - awayRecapItem, - setAwayRecapItem, commandContext, shellConfirmationRequest, confirmationRequest, diff --git a/packages/cli/src/ui/hooks/useAnimationFrame.ts b/packages/cli/src/ui/hooks/useAnimationFrame.ts new file mode 100644 index 000000000..c29f3ed80 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAnimationFrame.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useState } from 'react'; + +/** + * Hook that polls a ref at a fixed interval and smoothly animates the + * displayed value toward the real value. This avoids jarring jumps when + * large chunks of characters arrive at once (e.g. tool call args JSON). + * + * Animation rules (matching Claude Code's SpinnerAnimationRow): + * - Gap < 70: increment by 3 per frame + * - Gap 70–200: increment by ~20% of gap per frame + * - Gap > 200: increment by 50 per frame + * + * When the real value decreases (e.g. ref reset to 0), the displayed + * value snaps immediately — animation only applies to increases. + * + * Pass `null` as intervalMs to pause polling entirely. + * + * @param watchRef - The ref to poll for changes. + * @param intervalMs - How often to check (ms), or null to pause. + * @returns The smoothly animated value. + */ +export function useAnimationFrame( + watchRef: React.RefObject<number>, + intervalMs: number | null = 50, +): number { + const [displayValue, setDisplayValue] = useState(() => watchRef.current); + const displayRef = useRef(watchRef.current); + const targetRef = useRef(watchRef.current); + + // Snap down synchronously on render when the external ref drops below the + // last displayed value (e.g. ref reset to 0 at the start of a new turn). + // Without this, the previous turn's count would briefly flash before the + // next interval tick fires. Idempotent under StrictMode double-render. + const currentTarget = watchRef.current; + if (currentTarget < displayRef.current) { + displayRef.current = currentTarget; + targetRef.current = currentTarget; + } + + useEffect(() => { + if (intervalMs === null) return; + + // Re-sync when the interval resumes or the ref changed externally + // (e.g. ref reset to 0 at new turn start while paused). + const current = watchRef.current; + if (current !== targetRef.current) { + targetRef.current = current; + // Snap down immediately (reset), animate up + if (current < displayRef.current) { + displayRef.current = current; + setDisplayValue(current); + } + } + + const id = setInterval(() => { + const realValue = watchRef.current; + targetRef.current = realValue; + + // Snap down immediately on reset + if (realValue < displayRef.current) { + displayRef.current = realValue; + setDisplayValue(realValue); + return; + } + + const gap = realValue - displayRef.current; + if (gap <= 0) return; + + // Smooth interpolation: small gaps crawl, large gaps leap + let increment: number; + if (gap < 70) { + increment = 3; + } else if (gap <= 200) { + increment = Math.max(3, Math.round(gap * 0.2)); + } else { + increment = 50; + } + + const next = Math.min(displayRef.current + increment, realValue); + displayRef.current = next; + setDisplayValue(next); + }, intervalMs); + + return () => clearInterval(id); + }, [watchRef, intervalMs]); + + // Return the lower of state vs current ref so a freshly reset ref is + // reflected immediately, before setDisplayValue catches up next tick. + return Math.min(displayValue, currentTarget); +} diff --git a/packages/cli/src/ui/hooks/useAwaySummary.test.ts b/packages/cli/src/ui/hooks/useAwaySummary.test.ts new file mode 100644 index 000000000..d5c28076d --- /dev/null +++ b/packages/cli/src/ui/hooks/useAwaySummary.test.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import * as core from '@qwen-code/qwen-code-core'; +import { useAwaySummary } from './useAwaySummary.js'; +import type { HistoryItem } from '../types.js'; + +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual< + typeof import('@qwen-code/qwen-code-core') + >('@qwen-code/qwen-code-core'); + return { + ...actual, + generateSessionRecap: vi.fn(), + }; +}); + +const generateSessionRecapMock = vi.mocked(core.generateSessionRecap); + +function makeConfig(recordSlashCommand = vi.fn()) { + return { + getChatRecordingService: vi.fn().mockReturnValue({ + recordSlashCommand, + }), + } as unknown as core.Config; +} + +function userMsg(text: string): HistoryItem { + return { id: Math.random(), type: 'user', text }; +} + +const THREE_USER_HISTORY: HistoryItem[] = [ + userMsg('one'), + userMsg('two'), + userMsg('three'), +]; + +beforeEach(() => { + vi.useFakeTimers(); + generateSessionRecapMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useAwaySummary', () => { + it('records the auto-fired recap to chatRecordingService so it survives /resume', async () => { + const recordSlashCommand = vi.fn(); + const config = makeConfig(recordSlashCommand); + const addItem = vi.fn(); + generateSessionRecapMock.mockResolvedValue({ + text: 'recap text', + modelUsed: 'fast', + }); + + // Mount blurred to set the away-start timestamp. + const { rerender } = renderHook( + ({ isFocused }: { isFocused: boolean }) => + useAwaySummary({ + enabled: true, + config, + isFocused, + isIdle: true, + addItem, + history: THREE_USER_HISTORY, + awayThresholdMinutes: 0.1, // 6 s + }), + { initialProps: { isFocused: false } }, + ); + + // Advance past the threshold while still blurred. + vi.advanceTimersByTime(7000); + + // Focus comes back — should kick off the LLM call. + rerender({ isFocused: true }); + + // Drain the resolved promise + microtasks. + await vi.waitFor(() => { + expect(addItem).toHaveBeenCalledTimes(1); + }); + + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ type: 'away_recap', text: 'recap text' }), + expect.any(Number), + ); + expect(recordSlashCommand).toHaveBeenCalledWith( + expect.objectContaining({ + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [ + expect.objectContaining({ type: 'away_recap', text: 'recap text' }), + ], + }), + ); + }); + + it('skips the recap when shouldFireRecap returns false (no new user turns since last recap)', async () => { + const recordSlashCommand = vi.fn(); + const config = makeConfig(recordSlashCommand); + const addItem = vi.fn(); + generateSessionRecapMock.mockResolvedValue({ + text: 'should not appear', + modelUsed: 'fast', + }); + + const historyWithRecentRecap: HistoryItem[] = [ + ...THREE_USER_HISTORY, + { id: 999, type: 'away_recap', text: 'previous recap' }, + // Fewer than 2 user messages since the recap → gated. + userMsg('only one new turn'), + ]; + + const { rerender } = renderHook( + ({ isFocused }: { isFocused: boolean }) => + useAwaySummary({ + enabled: true, + config, + isFocused, + isIdle: true, + addItem, + history: historyWithRecentRecap, + awayThresholdMinutes: 0.1, + }), + { initialProps: { isFocused: false } }, + ); + + vi.advanceTimersByTime(7000); + rerender({ isFocused: true }); + + // Give any pending microtasks a chance to flush — they shouldn't. + await Promise.resolve(); + await Promise.resolve(); + + expect(generateSessionRecapMock).not.toHaveBeenCalled(); + expect(addItem).not.toHaveBeenCalled(); + expect(recordSlashCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useAwaySummary.ts b/packages/cli/src/ui/hooks/useAwaySummary.ts index ff58b365a..1c251ad41 100644 --- a/packages/cli/src/ui/hooks/useAwaySummary.ts +++ b/packages/cli/src/ui/hooks/useAwaySummary.ts @@ -6,16 +6,62 @@ import { useEffect, useRef } from 'react'; import { generateSessionRecap, type Config } from '@qwen-code/qwen-code-core'; -import type { HistoryItemAwayRecap } from '../types.js'; +import type { + HistoryItem, + HistoryItemAwayRecap, + HistoryItemWithoutId, +} from '../types.js'; -const AWAY_THRESHOLD_MS = 5 * 60 * 1000; +const DEFAULT_AWAY_THRESHOLD_MINUTES = 5; + +// Dedup thresholds, matching Claude Code's `Sc1`/`Rc1`: +// - need at least MIN_USER_MESSAGES_TO_FIRE user turns total +// - if a recap is already in history, need at least +// MIN_USER_MESSAGES_SINCE_LAST_RECAP new user turns since then before +// another can fire. Prevents back-to-back recaps when the user briefly +// alt-tabs twice without doing any new work in between. +const MIN_USER_MESSAGES_TO_FIRE = 3; +const MIN_USER_MESSAGES_SINCE_LAST_RECAP = 2; export interface UseAwaySummaryOptions { enabled: boolean; config: Config | null; isFocused: boolean; isIdle: boolean; - setAwayRecapItem: (item: HistoryItemAwayRecap | null) => void; + addItem: (item: HistoryItemWithoutId, baseTimestamp: number) => number; + /** + * The current chat history. Read at fire time only (via a ref) to apply + * the dedup gate; not added to the effect's deps so it doesn't re-fire + * on every history change. + */ + history: HistoryItem[]; + /** + * Minutes the terminal must be blurred before an auto-recap fires on + * the next focus-in. Falsy / non-positive values fall back to the + * 5-minute default (matching Claude Code). + */ + awayThresholdMinutes?: number; +} + +/** + * Whether enough new user activity has happened since the last recap to + * justify another one. Mirrors Claude Code's `Ic1` gate. + */ +function shouldFireRecap(history: HistoryItem[]): boolean { + let userMessageCount = 0; + let lastRecapIndex = -1; + for (let i = 0; i < history.length; i++) { + const item = history[i]; + if (item.type === 'user') userMessageCount++; + if (item.type === 'away_recap') lastRecapIndex = i; + } + if (userMessageCount < MIN_USER_MESSAGES_TO_FIRE) return false; + if (lastRecapIndex === -1) return true; + let userSinceLast = 0; + for (let i = lastRecapIndex + 1; i < history.length; i++) { + if (history[i].type === 'user') userSinceLast++; + } + return userSinceLast >= MIN_USER_MESSAGES_SINCE_LAST_RECAP; } /** @@ -27,7 +73,15 @@ export interface UseAwaySummaryOptions { * a single back-and-forth produces at most one recap. */ export function useAwaySummary(options: UseAwaySummaryOptions): void { - const { enabled, config, isFocused, isIdle, setAwayRecapItem } = options; + const { + enabled, + config, + isFocused, + isIdle, + addItem, + history, + awayThresholdMinutes, + } = options; const blurredAtRef = useRef<number | null>(null); const recapPendingRef = useRef(false); @@ -36,6 +90,18 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { const isIdleRef = useRef(isIdle); isIdleRef.current = isIdle; + // Latest history snapshot, read at fire time only — keeps history out + // of the effect's deps so we don't re-evaluate on every message. + const historyRef = useRef(history); + historyRef.current = history; + + const thresholdMs = + (awayThresholdMinutes && awayThresholdMinutes > 0 + ? awayThresholdMinutes + : DEFAULT_AWAY_THRESHOLD_MINUTES) * + 60 * + 1000; + useEffect(() => { if (!enabled || !config) { inFlightRef.current?.abort(); @@ -54,7 +120,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { const blurredAt = blurredAtRef.current; if (blurredAt === null) return; - if (Date.now() - blurredAt < AWAY_THRESHOLD_MS) { + if (Date.now() - blurredAt < thresholdMs) { // Brief blur; reset and wait for the next away cycle. blurredAtRef.current = null; return; @@ -65,6 +131,14 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { // (with isIdle in the deps) when the streaming turn finishes. if (!isIdleRef.current) return; + // Skip if the conversation hasn't moved enough since the last recap — + // a brief alt-tab cycle right after a recap shouldn't produce a near- + // duplicate one. + if (!shouldFireRecap(historyRef.current)) { + blurredAtRef.current = null; + return; + } + blurredAtRef.current = null; recapPendingRef.current = true; const controller = new AbortController(); @@ -78,7 +152,21 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { type: 'away_recap', text: recap.text, }; - setAwayRecapItem(item); + addItem(item, Date.now()); + + // Mirror the recording the slash-command processor does for + // manual `/recap`, so the auto-fired recap also survives `/resume`. + // Only record the `result` phase — recording an `invocation` + // would replay a fake `> /recap` user line on resume. + try { + config.getChatRecordingService?.()?.recordSlashCommand({ + phase: 'result', + rawCommand: '/recap', + outputHistoryItems: [{ ...item } as Record<string, unknown>], + }); + } catch { + // Recap is best-effort — never let a recording failure surface. + } }) .finally(() => { if (inFlightRef.current === controller) { @@ -86,7 +174,7 @@ export function useAwaySummary(options: UseAwaySummaryOptions): void { } recapPendingRef.current = false; }); - }, [enabled, config, isFocused, isIdle, setAwayRecapItem]); + }, [enabled, config, isFocused, isIdle, addItem, thresholdMs]); useEffect( () => () => { diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts index c61e8c990..a657fd0bb 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.test.ts @@ -11,8 +11,8 @@ import { CODING_PLAN_ENV_KEY, getCodingPlanConfig, CodingPlanRegion, -} from '../../constants/codingPlan.js'; -import { AuthType } from '@qwen-code/qwen-code-core'; + AuthType, +} from '@qwen-code/qwen-code-core'; // Get region configs for testing const chinaConfig = getCodingPlanConfig(CodingPlanRegion.CHINA); diff --git a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts index 1d341b31f..6c8e2b4c1 100644 --- a/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts +++ b/packages/cli/src/ui/hooks/useCodingPlanUpdates.ts @@ -6,15 +6,15 @@ import { useCallback, useEffect, useState } from 'react'; import type { Config, ModelProvidersConfig } from '@qwen-code/qwen-code-core'; -import { AuthType } from '@qwen-code/qwen-code-core'; -import type { LoadedSettings } from '../../config/settings.js'; -import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { + AuthType, isCodingPlanConfig, getCodingPlanConfig, CodingPlanRegion, CODING_PLAN_ENV_KEY, -} from '../../constants/codingPlan.js'; +} from '@qwen-code/qwen-code-core'; +import type { LoadedSettings } from '../../config/settings.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; import { t } from '../../i18n/index.js'; export interface CodingPlanUpdateRequest { diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index 8cb273c98..cedea5c56 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -9,7 +9,11 @@ import type { Suggestion } from '../components/SuggestionsDisplay.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { logicalPosToOffset } from '../components/shared/text-buffer.js'; -import { isSlashCommand } from '../utils/commandUtils.js'; +import { + isSlashCommand, + findMidInputSlashCommand, + getBestSlashCommandMatch, +} from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; @@ -35,6 +39,8 @@ export interface UseCommandCompletionReturn { navigateUp: () => void; navigateDown: () => void; handleAutocomplete: (indexToUse: number) => void; + /** Inline ghost text for mid-input slash commands (not at line start). */ + midInputGhostText: { text: string; insertPosition: number } | null; } export function useCommandCompletion( @@ -186,8 +192,12 @@ export function useCommandCompletion( let start = completionStart; let end = completionEnd; if (completionMode === CompletionMode.SLASH) { - start = slashCompletionRange.completionStart; - end = slashCompletionRange.completionEnd; + // slashCompletionRange positions are relative to the query string. + // completionStart is the line-column offset where the query begins + // (0 for line-start slash commands, tokenStart for mid-input tokens). + const lineOffset = completionStart; + start = lineOffset + slashCompletionRange.completionStart; + end = lineOffset + slashCompletionRange.completionEnd; } if (start === -1 || end === -1) { @@ -228,6 +238,32 @@ export function useCommandCompletion( ], ); + // Inline ghost text for mid-input slash commands (not at line start). + // Computed synchronously via useMemo to avoid one-frame flicker. + const midInputGhostText = useMemo((): { + text: string; + insertPosition: number; + } | null => { + if (!active || reverseSearchActive) return null; + const cursorOffset = logicalPosToOffset(buffer.lines, cursorRow, cursorCol); + const midCmd = findMidInputSlashCommand(buffer.text, cursorOffset); + if (!midCmd) return null; + const match = getBestSlashCommandMatch( + midCmd.partialCommand, + slashCommands, + ); + if (!match) return null; + return { text: match.suffix, insertPosition: cursorOffset }; + }, [ + buffer.text, + buffer.lines, + cursorRow, + cursorCol, + slashCommands, + active, + reverseSearchActive, + ]); + return { suggestions, activeSuggestionIndex, @@ -241,5 +277,6 @@ export function useCommandCompletion( navigateUp, navigateDown, handleAutocomplete, + midInputGhostText, }; } diff --git a/packages/cli/src/ui/hooks/useDeleteCommand.ts b/packages/cli/src/ui/hooks/useDeleteCommand.ts new file mode 100644 index 000000000..668dd41bf --- /dev/null +++ b/packages/cli/src/ui/hooks/useDeleteCommand.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; +import type { Config } from '@qwen-code/qwen-code-core'; +import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import { t } from '../../i18n/index.js'; + +export interface UseDeleteCommandOptions { + config: Config | null; + addItem: UseHistoryManagerReturn['addItem']; +} + +export interface UseDeleteCommandResult { + isDeleteDialogOpen: boolean; + openDeleteDialog: () => void; + closeDeleteDialog: () => void; + handleDelete: (sessionId: string) => void; +} + +export function useDeleteCommand( + options?: UseDeleteCommandOptions, +): UseDeleteCommandResult { + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + + const openDeleteDialog = useCallback(() => { + setIsDeleteDialogOpen(true); + }, []); + + const closeDeleteDialog = useCallback(() => { + setIsDeleteDialogOpen(false); + }, []); + + const { config, addItem } = options ?? {}; + + const handleDelete = useCallback( + async (sessionId: string) => { + if (!config) { + return; + } + + // Close dialog immediately. + closeDeleteDialog(); + + // Prevent deleting the current session. + if (sessionId === config.getSessionId()) { + addItem?.( + { + type: 'info', + text: t('Cannot delete the current active session.'), + }, + Date.now(), + ); + return; + } + + try { + const sessionService = config.getSessionService(); + const success = await sessionService.removeSession(sessionId); + + if (success) { + addItem?.( + { + type: 'info', + text: t('Session deleted successfully.'), + }, + Date.now(), + ); + } else { + addItem?.( + { + type: 'error', + text: t('Failed to delete session. Session not found.'), + }, + Date.now(), + ); + } + } catch { + addItem?.( + { + type: 'error', + text: t('Failed to delete session.'), + }, + Date.now(), + ); + } + }, + [closeDeleteDialog, config, addItem], + ); + + return { + isDeleteDialogOpen, + openDeleteDialog, + closeDeleteDialog, + handleDelete, + }; +} diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 9d4156159..d680a736d 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -241,6 +241,12 @@ export const useGeminiStream = ( const processedMemoryToolsRef = useRef<Set<string>>(new Set()); const submitPromptOnCompleteRef = useRef<(() => Promise<void>) | null>(null); const modelOverrideRef = useRef<string | undefined>(undefined); + // --- Real-time token display --- + // Accumulates output character count across the whole turn (not per API call). + // Uses a ref to avoid re-renders on every text_delta. + const streamingResponseLengthRef = useRef(0); + // Tracks whether we are receiving content (↓) or waiting for API (↑). + const [isReceivingContent, setIsReceivingContent] = useState(false); const { startNewPrompt, getPromptCount, @@ -671,6 +677,9 @@ export const useGeminiStream = ( // Prevents additional output after a user initiated cancel. return ''; } + // Track output chars for real-time token estimation & mark as receiving. + streamingResponseLengthRef.current += eventValue.length; + setIsReceivingContent(true); let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue; if ( pendingHistoryItemRef.current?.type !== 'gemini' && @@ -1138,6 +1147,14 @@ export const useGeminiStream = ( break; case ServerGeminiEventType.ToolCallRequest: toolCallRequests.push(event.value); + // Count tool call args JSON toward token estimation (matches + // Claude Code's input_json_delta handling). + try { + const argsJson = JSON.stringify(event.value.args); + streamingResponseLengthRef.current += argsJson.length; + } catch { + // Best-effort — don't block on serialization errors + } break; case ServerGeminiEventType.UserCancelled: handleUserCancelledEvent(userMessageTimestamp); @@ -1173,10 +1190,26 @@ export const useGeminiStream = ( loopDetectedRef.current = true; break; case ServerGeminiEventType.Retry: - // Clear any pending partial content from the failed attempt - if (pendingHistoryItemRef.current) { - setPendingHistoryItem(null); + // On fresh restart (escalation / rate-limit / invalid stream), + // clear pending content and buffers to discard the failed attempt. + // On continuation (recovery), keep the pending gemini item AND + // buffers so the model's continuation text appends to them — + // otherwise handleContentEvent would see a null pending item, + // create a fresh one, and reset the buffer to just the new chunk, + // losing the partial text we meant to preserve. + if (!event.isContinuation) { + if (pendingHistoryItemRef.current) { + setPendingHistoryItem(null); + } + geminiMessageBuffer = ''; + thoughtBuffer = ''; } + // Always discard tool call requests from the truncated/failed + // attempt to prevent duplicate execution after escalation or + // recovery. The recovery path now skips turns that already + // contain a functionCall (see geminiChat.ts), so this only + // clears stale requests from pre-RETRY accumulation. + toolCallRequests.length = 0; // Show retry info if available (rate-limit / throttling errors) if (event.retryInfo) { startRetryCountdown(event.retryInfo); @@ -1386,6 +1419,13 @@ export const useGeminiStream = ( setIsResponding(true); setInitError(null); + // Entering "requesting" phase — no content yet for this API call. + setIsReceivingContent(false); + // Reset char counter only on new user queries; tool-result continuations + // keep accumulating so the token count only goes up within a turn. + if (submitType !== SendMessageType.ToolResult) { + streamingResponseLengthRef.current = 0; + } try { // Emit user message to dual output sidecar (if enabled). @@ -1977,5 +2017,7 @@ export const useGeminiStream = ( handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, + streamingResponseLengthRef, + isReceivingContent, }; }; diff --git a/packages/cli/src/ui/hooks/useLaunchEditor.ts b/packages/cli/src/ui/hooks/useLaunchEditor.ts index 809e8a3d6..839a0ff05 100644 --- a/packages/cli/src/ui/hooks/useLaunchEditor.ts +++ b/packages/cli/src/ui/hooks/useLaunchEditor.ts @@ -1,58 +1,23 @@ import { useCallback } from 'react'; import { useStdin } from 'ink'; import type { EditorType } from '@qwen-code/qwen-code-core'; -import { - editorCommands, - commandExists as coreCommandExists, -} from '@qwen-code/qwen-code-core'; +import { getEditorExecutable } from '@qwen-code/qwen-code-core'; import { spawnSync } from 'child_process'; import { useSettings } from '../contexts/SettingsContext.js'; -/** - * Cache for command existence checks to avoid repeated execSync calls. - */ -const commandExistsCache = new Map<string, boolean>(); - -/** - * Check if a command exists in the system with caching. - * Results are cached to improve performance in test environments. - */ -function commandExists(cmd: string): boolean { - if (commandExistsCache.has(cmd)) { - return commandExistsCache.get(cmd)!; - } - - const exists = coreCommandExists(cmd); - commandExistsCache.set(cmd, exists); - return exists; -} -/** - * Get the actual executable command for an editor type. - */ -function getExecutableCommand(editorType: EditorType): string { - const commandConfig = editorCommands[editorType]; - const commands = - process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - - const availableCommand = commands.find((cmd) => commandExists(cmd)); - - if (!availableCommand) { - throw new Error( - `No available editor command found for ${editorType}. ` + - `Tried: ${commands.join(', ')}. ` + - `Please install one of these editors or set a different preferredEditor in settings.`, - ); - } - - return availableCommand; -} - /** * Determines the editor command to use based on user preferences and platform. */ function getEditorCommand(preferredEditor?: EditorType): string { if (preferredEditor) { - return getExecutableCommand(preferredEditor); + const execCmd = getEditorExecutable(preferredEditor); + if (!execCmd) { + throw new Error( + `No available editor found for ${preferredEditor}. ` + + `Please install a supported editor or set a different preferredEditor in settings.`, + ); + } + return execCmd; } // Platform-specific defaults with UI preference for macOS diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index ee144c4ec..4d5a5a68d 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -51,6 +51,9 @@ vi.mock('@qwen-code/qwen-code-core', () => { }) ); } + getSessionTitle(_sessionId: string) { + return undefined; + } } return { @@ -167,9 +170,7 @@ describe('useResumeCommand', () => { act(() => { // Start resume but do not await it yet — we want to assert the dialog // closes immediately before the async session load completes. - resumePromise = result.current.handleResume('session-2') as unknown as - | Promise<void> - | undefined; + resumePromise = result.current.handleResume('session-2'); }); expect(result.current.isResumeDialogOpen).toBe(false); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 3b25e0054..037b75f3c 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -8,6 +8,7 @@ import { useState, useCallback } from 'react'; import { SessionService, type Config, + type SessionListItem, SessionStartSource, type PermissionMode, } from '@qwen-code/qwen-code-core'; @@ -18,41 +19,55 @@ export interface UseResumeCommandOptions { config: Config | null; historyManager: Pick<UseHistoryManagerReturn, 'clearItems' | 'loadHistory'>; startNewSession: (sessionId: string) => void; + setSessionName?: (name: string | null) => void; remount?: () => void; } export interface UseResumeCommandResult { isResumeDialogOpen: boolean; - openResumeDialog: () => void; + /** Pre-filtered sessions for the picker (when multiple title matches). */ + resumeMatchedSessions: SessionListItem[] | undefined; + openResumeDialog: (matchedSessions?: SessionListItem[]) => void; closeResumeDialog: () => void; /** - * Resolves to `true` when the target session was actually loaded, or - * `false` when the call short-circuited (missing dependencies or no - * session data found). Callers can use the boolean to gate cleanup - * that should only happen on a successful session switch. + * Async — the implementation awaits SessionService and SessionStart hooks. + * Callers that need to chain post-resume work should `await` it; pure + * fire-and-forget callers (the resume dialog's `onSelect`) can ignore the + * promise. */ - handleResume: (sessionId: string) => Promise<boolean>; + handleResume: (sessionId: string) => Promise<void>; } export function useResumeCommand( options?: UseResumeCommandOptions, ): UseResumeCommandResult { const [isResumeDialogOpen, setIsResumeDialogOpen] = useState(false); + const [resumeMatchedSessions, setResumeMatchedSessions] = useState< + SessionListItem[] | undefined + >(); - const openResumeDialog = useCallback(() => { - setIsResumeDialogOpen(true); - }, []); + const openResumeDialog = useCallback( + (matchedSessions?: SessionListItem[]) => { + setResumeMatchedSessions(matchedSessions); + setIsResumeDialogOpen(true); + }, + [], + ); const closeResumeDialog = useCallback(() => { setIsResumeDialogOpen(false); + setResumeMatchedSessions(undefined); }, []); - const { config, historyManager, startNewSession, remount } = options ?? {}; + const { config, historyManager, startNewSession, setSessionName, remount } = + options ?? {}; + const hasHistoryManager = !!historyManager; + const { clearItems, loadHistory } = historyManager || {}; const handleResume = useCallback( - async (sessionId: string): Promise<boolean> => { - if (!config || !historyManager || !startNewSession) { - return false; + async (sessionId: string) => { + if (!config || !hasHistoryManager || !startNewSession) { + return; } // Close dialog immediately to prevent input capture during async operations. @@ -63,16 +78,20 @@ export function useResumeCommand( const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { - return false; + return; } // Start new session in UI context. startNewSession(sessionId); + // Restore session name tag from custom title. + const customTitle = sessionService.getSessionTitle(sessionId); + setSessionName?.(customTitle ?? null); + // Reset UI history. const uiHistoryItems = buildResumedHistoryItems(sessionData, config); - historyManager.clearItems(); - historyManager.loadHistory(uiHistoryItems); + clearItems?.(); + loadHistory?.(uiHistoryItems); // Update session history core. config.startNewSession(sessionId, sessionData); @@ -93,13 +112,22 @@ export function useResumeCommand( // Refresh terminal UI. remount?.(); - return true; }, - [closeResumeDialog, config, historyManager, startNewSession, remount], + [ + closeResumeDialog, + config, + hasHistoryManager, + clearItems, + loadHistory, + startNewSession, + setSessionName, + remount, + ], ); return { isResumeDialogOpen, + resumeMatchedSessions, openResumeDialog, closeResumeDialog, handleResume, diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts index 7d451466a..98bba2f65 100644 --- a/packages/cli/src/ui/hooks/useSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -37,6 +37,13 @@ export interface UseSessionPickerOptions { * If false, uses follow mode (scrolls when selection reaches edge). */ centerSelection?: boolean; + /** + * Pre-filtered sessions to display instead of loading from sessionService. + * When provided, skips the initial listSessions() call and disables + * pagination (load-more). Used by /resume <title> when multiple sessions + * match the given title. + */ + initialSessions?: SessionListItem[]; /** * Enable/disable input handling. */ @@ -63,16 +70,18 @@ export function useSessionPicker({ onCancel, maxVisibleItems, centerSelection = false, + initialSessions, isActive = true, }: UseSessionPickerOptions): UseSessionPickerResult { + const hasInitialSessions = initialSessions !== undefined; const [selectedIndex, setSelectedIndex] = useState(0); - const [sessionState, setSessionState] = useState<SessionState>({ - sessions: [], - hasMore: true, - nextCursor: undefined, - }); + const [sessionState, setSessionState] = useState<SessionState>( + hasInitialSessions + ? { sessions: initialSessions, hasMore: false, nextCursor: undefined } + : { sessions: [], hasMore: true, nextCursor: undefined }, + ); const [filterByBranch, setFilterByBranch] = useState(false); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(!hasInitialSessions); // For follow mode (non-centered) const [followScrollOffset, setFollowScrollOffset] = useState(0); @@ -112,9 +121,9 @@ export function useSessionPicker({ const showScrollDown = scrollOffset + maxVisibleItems < filteredSessions.length; - // Initial load + // Initial load — skip when pre-filtered sessions are provided useEffect(() => { - if (!sessionService) { + if (!sessionService || hasInitialSessions) { return; } @@ -134,7 +143,7 @@ export function useSessionPicker({ }; void loadInitialSessions(); - }, [sessionService]); + }, [sessionService, hasInitialSessions]); const loadMoreSessions = useCallback(async () => { if (!sessionService || !sessionState.hasMore || isLoadingMoreRef.current) { diff --git a/packages/cli/src/ui/hooks/useThemeCommand.ts b/packages/cli/src/ui/hooks/useThemeCommand.ts index e0f0383cb..b7b61384a 100644 --- a/packages/cli/src/ui/hooks/useThemeCommand.ts +++ b/packages/cli/src/ui/hooks/useThemeCommand.ts @@ -5,7 +5,7 @@ */ import { useState, useCallback } from 'react'; -import { themeManager } from '../themes/theme-manager.js'; +import { themeManager, AUTO_THEME_NAME } from '../themes/theme-manager.js'; import type { LoadedSettings, SettingScope } from '../../config/settings.js'; // Import LoadedSettings, AppSettings, MergedSetting import { type HistoryItem, MessageType } from '../types.js'; import process from 'node:process'; @@ -92,10 +92,11 @@ export const useThemeCommand = ( ...(loadedSettings.user.settings.ui?.customThemes || {}), ...(loadedSettings.workspace.settings.ui?.customThemes || {}), }; - // Only allow selecting themes available in the merged custom themes or built-in themes + // Only allow selecting themes available in the merged custom themes, built-in themes, or 'auto' + const isAuto = themeName === AUTO_THEME_NAME; const isBuiltIn = themeManager.findThemeByName(themeName); const isCustom = themeName && mergedCustomThemes[themeName]; - if (!isBuiltIn && !isCustom) { + if (!isAuto && !isBuiltIn && !isCustom) { setThemeError( t('Theme "{{themeName}}" not found in selected scope.', { themeName: themeName ?? '', diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 918090828..88efdbefd 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -12,7 +12,6 @@ import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; import { BtwMessage } from '../components/messages/BtwMessage.js'; -import { AwayRecapMessage } from '../components/messages/StatusMessages.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { AgentComposer } from '../components/agent-view/AgentComposer.js'; @@ -70,11 +69,6 @@ export const DefaultAppLayout: React.FC = () => { </Box> ) : ( <> - {uiState.awayRecapItem && ( - <Box marginX={2} width={uiState.mainAreaWidth}> - <AwayRecapMessage text={uiState.awayRecapItem.text} /> - </Box> - )} {uiState.btwItem && ( <Box marginX={2} width={uiState.mainAreaWidth}> <BtwMessage diff --git a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx index 006894db8..e601db4b9 100644 --- a/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx +++ b/packages/cli/src/ui/layouts/ScreenReaderAppLayout.tsx @@ -13,7 +13,6 @@ import { Composer } from '../components/Composer.js'; import { Footer } from '../components/Footer.js'; import { ExitWarning } from '../components/ExitWarning.js'; import { BtwMessage } from '../components/messages/BtwMessage.js'; -import { AwayRecapMessage } from '../components/messages/StatusMessages.js'; import { useUIState } from '../contexts/UIStateContext.js'; export const ScreenReaderAppLayout: React.FC = () => { @@ -36,11 +35,6 @@ export const ScreenReaderAppLayout: React.FC = () => { </Box> ) : ( <> - {uiState.awayRecapItem && ( - <Box marginX={2} width={uiState.mainAreaWidth}> - <AwayRecapMessage text={uiState.awayRecapItem.text} /> - </Box> - )} {uiState.btwItem && ( <Box marginX={2} width={uiState.mainAreaWidth}> <BtwMessage diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index 7003f741a..52ba3701a 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -24,12 +24,11 @@ export function createNonInteractiveUI(): CommandContext['ui'] { setBtwItem: (_item) => {}, cancelBtw: () => {}, btwAbortControllerRef: { current: null }, - awayRecapItem: null, - setAwayRecapItem: (_item) => {}, isIdleRef: { current: true }, toggleVimEnabled: async () => false, setGeminiMdFileCount: (_count) => {}, reloadCommands: () => {}, + setSessionName: () => {}, extensionsUpdateState: new Map(), dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {}, diff --git a/packages/cli/src/ui/themes/detect-terminal-theme.test.ts b/packages/cli/src/ui/themes/detect-terminal-theme.test.ts new file mode 100644 index 000000000..078787d50 --- /dev/null +++ b/packages/cli/src/ui/themes/detect-terminal-theme.test.ts @@ -0,0 +1,380 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as childProcess from 'node:child_process'; + +vi.mock('node:child_process'); + +describe('detectTerminalTheme', () => { + const originalPlatform = process.platform; + const originalEnv = { ...process.env }; + + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + process.env = { ...originalEnv }; + delete process.env['COLORFGBG']; + }); + + afterEach(() => { + Object.defineProperty(process, 'platform', { value: originalPlatform }); + process.env = originalEnv; + }); + + // --------------------------------------------------------------------------- + // parseOscRgb + themeFromOscColor (pure, synchronous) + // --------------------------------------------------------------------------- + + describe('parseOscRgb', () => { + it('should parse rgb:RRRR/GGGG/BBBB format', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('rgb:0000/0000/0000'); + expect(rgb).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it('should parse short hex components (rgb:RR/GG/BB)', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('rgb:ff/ff/ff'); + expect(rgb).toEqual({ r: 1, g: 1, b: 1 }); + }); + + it('should parse #RRGGBB format', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('#000000'); + expect(rgb).toEqual({ r: 0, g: 0, b: 0 }); + }); + + it('should parse #RRRRGGGGBBBB format', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + const rgb = parseOscRgb('#ffffffffffff'); + expect(rgb).toEqual({ r: 1, g: 1, b: 1 }); + }); + + it('should return undefined for invalid data', async () => { + const { parseOscRgb } = await import('./detect-terminal-theme.js'); + expect(parseOscRgb('garbage')).toBeUndefined(); + expect(parseOscRgb('')).toBeUndefined(); + }); + }); + + describe('themeFromOscColor', () => { + it('should return "dark" for a dark background', async () => { + const { themeFromOscColor } = await import('./detect-terminal-theme.js'); + // Pure black background + expect(themeFromOscColor('rgb:0000/0000/0000')).toBe('dark'); + // Typical dark terminal (e.g., #1e1e2e) + expect(themeFromOscColor('rgb:1e1e/1e1e/2e2e')).toBe('dark'); + }); + + it('should return "light" for a light background', async () => { + const { themeFromOscColor } = await import('./detect-terminal-theme.js'); + // Pure white background + expect(themeFromOscColor('rgb:ffff/ffff/ffff')).toBe('light'); + // Typical light terminal (e.g., #fafafa) + expect(themeFromOscColor('rgb:fafa/fafa/fafa')).toBe('light'); + }); + + it('should return undefined for unparseable data', async () => { + const { themeFromOscColor } = await import('./detect-terminal-theme.js'); + expect(themeFromOscColor('not-a-color')).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // detectOsc11Theme (async, TTY interaction) + // --------------------------------------------------------------------------- + + describe('detectOsc11Theme', () => { + const forceTTY = () => { + const origStdinTTY = process.stdin.isTTY; + const origStdoutTTY = process.stdout.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + configurable: true, + }); + return () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: origStdinTTY, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: origStdoutTTY, + configurable: true, + }); + }; + }; + + it('should return undefined when stdin is not a TTY', async () => { + const origIsTTY = process.stdin.isTTY; + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + configurable: true, + }); + + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const result = await detectOsc11Theme(); + expect(result).toBeUndefined(); + + Object.defineProperty(process.stdin, 'isTTY', { + value: origIsTTY, + configurable: true, + }); + }); + + it('should resolve "dark" when terminal reports a dark background', async () => { + const restoreTTY = forceTTY(); + const writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const baseline = process.stdin.listenerCount('data'); + + try { + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const promise = detectOsc11Theme(); + // Listener must be attached synchronously so the response is captured. + expect(process.stdin.listenerCount('data')).toBe(baseline + 1); + expect(writeSpy).toHaveBeenCalledWith('\x1b]11;?\x07'); + + process.stdin.emit( + 'data', + Buffer.from('\x1b]11;rgb:0000/0000/0000\x07'), + ); + + await expect(promise).resolves.toBe('dark'); + // Regression guard: listener must be removed on every exit path. + expect(process.stdin.listenerCount('data')).toBe(baseline); + } finally { + restoreTTY(); + } + }); + + it('should resolve undefined on timeout and remove its data listener', async () => { + vi.useFakeTimers(); + const restoreTTY = forceTTY(); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const baseline = process.stdin.listenerCount('data'); + + try { + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const promise = detectOsc11Theme(); + expect(process.stdin.listenerCount('data')).toBe(baseline + 1); + + await vi.advanceTimersByTimeAsync(250); + + await expect(promise).resolves.toBeUndefined(); + // Regression guard: the listener-leak that motivated earlier fixes + // in this PR (OSC 11 bytes bleeding into the input box) only + // happens when the timeout path forgets to detach. + expect(process.stdin.listenerCount('data')).toBe(baseline); + } finally { + restoreTTY(); + vi.useRealTimers(); + } + }); + + it('should reassemble OSC 11 responses split across multiple data events', async () => { + const restoreTTY = forceTTY(); + vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + + try { + const { detectOsc11Theme } = await import('./detect-terminal-theme.js'); + const promise = detectOsc11Theme(); + // Split a pure-white response across two chunks. + process.stdin.emit('data', Buffer.from('\x1b]11;rgb:ffff/')); + process.stdin.emit('data', Buffer.from('ffff/ffff\x07')); + + await expect(promise).resolves.toBe('light'); + } finally { + restoreTTY(); + } + }); + }); + + // --------------------------------------------------------------------------- + // detectMacOSTheme (sync) + // --------------------------------------------------------------------------- + + describe('detectMacOSTheme', () => { + it('should return "dark" when macOS dark mode is active', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockReturnValue('Dark\n'); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBe('dark'); + }); + + it('should return "light" when macOS light mode is active', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + throw new Error('The domain/default pair does not exist'); + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBe('light'); + }); + + it('should return "light" when the "does not exist" message is on stderr only', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + const err = new Error('Command failed') as Error & { + stderr?: string; + }; + err.stderr = + 'The domain/default pair of (kCFPreferencesAnyApplication, AppleInterfaceStyle) does not exist\n'; + throw err; + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBe('light'); + }); + + it('should return undefined on timeout (do not assume Light Mode)', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + throw new Error('Command failed: defaults read -g AppleInterfaceStyle'); + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBeUndefined(); + }); + + it('should return undefined when `defaults` is not on PATH', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockImplementation(() => { + const err = new Error('spawnSync defaults ENOENT') as Error & { + code?: string; + }; + err.code = 'ENOENT'; + throw err; + }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBeUndefined(); + }); + + it('should return undefined on non-macOS platforms', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + + const { detectMacOSTheme } = await import('./detect-terminal-theme.js'); + expect(detectMacOSTheme()).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // detectFromColorFgBg (sync) + // --------------------------------------------------------------------------- + + describe('detectFromColorFgBg', () => { + it('should return "dark" when background is dark (COLORFGBG=15;0)', async () => { + process.env['COLORFGBG'] = '15;0'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('dark'); + }); + + it('should return "light" when background is light (COLORFGBG=0;15)', async () => { + process.env['COLORFGBG'] = '0;15'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('light'); + }); + + it('should return "light" when background is 7 (light gray)', async () => { + process.env['COLORFGBG'] = '0;7'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('light'); + }); + + it('should return "dark" when background is 8 (dark gray)', async () => { + process.env['COLORFGBG'] = '15;8'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('dark'); + }); + + it('should handle three-part format (fg;extra;bg)', async () => { + process.env['COLORFGBG'] = '15;0;0'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBe('dark'); + }); + + it('should return undefined when COLORFGBG is not set', async () => { + delete process.env['COLORFGBG']; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBeUndefined(); + }); + + it('should return undefined when COLORFGBG has invalid value', async () => { + process.env['COLORFGBG'] = 'invalid'; + const { detectFromColorFgBg } = await import( + './detect-terminal-theme.js' + ); + expect(detectFromColorFgBg()).toBeUndefined(); + }); + }); + + // --------------------------------------------------------------------------- + // detectTerminalTheme (sync entry point) + // --------------------------------------------------------------------------- + + describe('detectTerminalTheme (sync)', () => { + it('should prefer COLORFGBG over macOS detection', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockReturnValue('Dark\n'); + process.env['COLORFGBG'] = '0;15'; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('light'); + }); + + it('should fall back to macOS when COLORFGBG is not set', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + vi.mocked(childProcess.execSync).mockReturnValue('Dark\n'); + delete process.env['COLORFGBG']; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('dark'); + }); + + it('should fall back to COLORFGBG on non-macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + process.env['COLORFGBG'] = '0;15'; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('light'); + }); + + it('should default to dark when no detection method works', async () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + delete process.env['COLORFGBG']; + + const { detectTerminalTheme } = await import( + './detect-terminal-theme.js' + ); + expect(detectTerminalTheme()).toBe('dark'); + }); + }); +}); diff --git a/packages/cli/src/ui/themes/detect-terminal-theme.ts b/packages/cli/src/ui/themes/detect-terminal-theme.ts new file mode 100644 index 000000000..eee30ae1b --- /dev/null +++ b/packages/cli/src/ui/themes/detect-terminal-theme.ts @@ -0,0 +1,274 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; +import process from 'node:process'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; + +const debugLogger = createDebugLogger('THEME_DETECT'); + +export type DetectedTheme = 'dark' | 'light'; + +// --------------------------------------------------------------------------- +// OSC 11 – query terminal background color +// --------------------------------------------------------------------------- + +/** Timeout (ms) for the OSC 11 query. */ +const OSC11_TIMEOUT_MS = 200; + +interface Rgb { + r: number; + g: number; + b: number; +} + +/** + * Normalises a variable-length hex colour component (1–4 hex digits) to + * the [0, 1] range. For example "ff" → 1, "8000" → 0.5 (≈ 32768/65535). + */ +function hexComponent(hex: string): number { + const max = 16 ** hex.length - 1; // 1-digit → 15, 4-digit → 65535 + return parseInt(hex, 16) / max; +} + +/** + * Parses an XParseColor RGB string returned by OSC 11. + * + * Accepted formats: + * - `rgb:RRRR/GGGG/BBBB` (1–4 hex digits per component) + * - `#RRGGBB` or `#RRRRGGGGBBBB` (equal-length triplets) + */ +export function parseOscRgb(data: string): Rgb | undefined { + // rgb:R/G/B + const rgbMatch = + /^rgba?:([0-9a-f]{1,4})\/([0-9a-f]{1,4})\/([0-9a-f]{1,4})/i.exec(data); + if (rgbMatch) { + return { + r: hexComponent(rgbMatch[1]!), + g: hexComponent(rgbMatch[2]!), + b: hexComponent(rgbMatch[3]!), + }; + } + + // #RRGGBB or #RRRRGGGGBBBB + const hashMatch = /^#([0-9a-f]+)$/i.exec(data); + if (hashMatch && hashMatch[1]!.length % 3 === 0) { + const hex = hashMatch[1]!; + const n = hex.length / 3; + return { + r: hexComponent(hex.slice(0, n)), + g: hexComponent(hex.slice(n, 2 * n)), + b: hexComponent(hex.slice(2 * n)), + }; + } + + return undefined; +} + +/** + * Converts an OSC 11 colour response into a dark/light theme decision + * using ITU-R BT.709 relative luminance. + */ +export function themeFromOscColor(data: string): DetectedTheme | undefined { + const rgb = parseOscRgb(data); + if (!rgb) return undefined; + const luminance = 0.2126 * rgb.r + 0.7152 * rgb.g + 0.0722 * rgb.b; + return luminance > 0.5 ? 'light' : 'dark'; +} + +/** + * Sends an OSC 11 query (`ESC ] 11 ; ? BEL`) to the terminal and waits + * for the response containing the background colour. + * + * The caller is responsible for having stdin in raw mode with an active + * consumer (so the stream is in flowing mode). This probe only attaches + * an extra listener to parse the OSC 11 response — it does NOT flip raw + * mode or resume/pause stdin, because doing so interleaves with other + * early-startup stdin consumers (kitty protocol detection, early input + * capture) and causes terminal response bytes to leak into the TUI. + * + * Returns `undefined` when stdin/stdout is not a TTY or when no response + * arrives within {@link OSC11_TIMEOUT_MS}. + */ +export function detectOsc11Theme(): Promise<DetectedTheme | undefined> { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return Promise.resolve(undefined); + } + + return new Promise<DetectedTheme | undefined>((resolve) => { + const stdin = process.stdin; + let resolved = false; + let buffer = ''; + + const finish = (result: DetectedTheme | undefined) => { + if (resolved) return; + resolved = true; + clearTimeout(timer); + stdin.removeListener('data', onData); + resolve(result); + }; + + const timer = setTimeout(() => finish(undefined), OSC11_TIMEOUT_MS); + + const onData = (data: Buffer) => { + buffer += data.toString(); + // OSC response: ESC ] 11 ; <data> BEL or ESC ] 11 ; <data> ST + // eslint-disable-next-line no-control-regex + const match = /\x1b\]11;(.*?)(?:\x07|\x1b\\)/.exec(buffer); + if (match) { + finish(themeFromOscColor(match[1]!)); + } + }; + + stdin.on('data', onData); + process.stdout.write('\x1b]11;?\x07'); + }); +} + +// --------------------------------------------------------------------------- +// Synchronous detection helpers +// --------------------------------------------------------------------------- + +/** + * Detects the macOS system appearance using `defaults read -g AppleInterfaceStyle`. + * Returns 'dark' if Dark Mode is active, 'light' when `defaults` reports the key + * is missing (the canonical macOS Light Mode signal), and undefined for any + * other failure (timeout, `defaults` not on PATH, killed by signal, …) so the + * caller can continue its fallback chain instead of pinning to Light. + * Returns undefined on non-macOS platforms. + */ +export function detectMacOSTheme(): DetectedTheme | undefined { + if (process.platform !== 'darwin') { + return undefined; + } + + try { + const result = execSync('defaults read -g AppleInterfaceStyle', { + encoding: 'utf-8', + timeout: 3000, + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + return result.toLowerCase() === 'dark' ? 'dark' : 'light'; + } catch (error) { + const err = error as { stderr?: string | Buffer; message?: string }; + const stderr = + typeof err.stderr === 'string' + ? err.stderr + : (err.stderr?.toString?.() ?? ''); + const message = err.message ?? ''; + // Only the explicit "… does not exist" error confirms Light Mode. Any + // other failure is inconclusive — returning undefined lets the caller + // fall through to the next detection layer (or the default-dark). + if (/does not exist/i.test(stderr) || /does not exist/i.test(message)) { + return 'light'; + } + return undefined; + } +} + +/** + * Detects theme from the COLORFGBG environment variable. + * + * COLORFGBG is set by some terminals (e.g., rxvt, xterm, iTerm2, Konsole) + * in the format "foreground;background" where values are ANSI color indices (0-15). + * + * A dark background (0-6, 8) → dark theme. + * A light background (7, 9-15) → light theme. + */ +export function detectFromColorFgBg(): DetectedTheme | undefined { + const colorFgBg = process.env['COLORFGBG']; + if (!colorFgBg) { + return undefined; + } + + const parts = colorFgBg.split(';'); + const bgStr = parts[parts.length - 1]; + if (bgStr === undefined) { + return undefined; + } + + const bg = parseInt(bgStr, 10); + if (isNaN(bg)) { + return undefined; + } + + if (bg === 7 || (bg >= 9 && bg <= 15)) { + return 'light'; + } + + return 'dark'; +} + +// --------------------------------------------------------------------------- +// Public entry points +// --------------------------------------------------------------------------- + +/** + * Synchronous theme detection (for theme dialog live-preview). + * + * Order: COLORFGBG → macOS system appearance → default dark. + */ +export function detectTerminalTheme(): DetectedTheme { + const colorFgBgResult = detectFromColorFgBg(); + if (colorFgBgResult) { + debugLogger.info(`Detected theme from COLORFGBG: ${colorFgBgResult}`); + return colorFgBgResult; + } + + const macResult = detectMacOSTheme(); + if (macResult) { + debugLogger.info( + `Detected theme from macOS system appearance: ${macResult}`, + ); + return macResult; + } + + debugLogger.info('Could not detect terminal theme, defaulting to dark'); + return 'dark'; +} + +/** + * Asynchronous theme detection (for startup). + * + * Checks cheap synchronous sources first (COLORFGBG) so we never pay the + * ~200 ms OSC 11 timeout when a fast answer is already available. OSC 11 is + * tried only when no synchronous source provides an answer. + * + * Order: COLORFGBG → OSC 11 → macOS system appearance → default dark. + */ +export async function detectTerminalThemeAsync(): Promise<DetectedTheme> { + // Fast path: COLORFGBG is instant and terminal-specific. + const colorFgBgResult = detectFromColorFgBg(); + if (colorFgBgResult) { + debugLogger.info( + `Detected theme from COLORFGBG (async path): ${colorFgBgResult}`, + ); + return colorFgBgResult; + } + + // OSC 11 directly reads the terminal background colour. It is the most + // universal method but requires a TTY and may block up to OSC11_TIMEOUT_MS. + const osc11Result = await detectOsc11Theme(); + if (osc11Result) { + debugLogger.info( + `Detected theme from OSC 11 background query: ${osc11Result}`, + ); + return osc11Result; + } + + // Remaining synchronous fallbacks (macOS → default dark). + const macResult = detectMacOSTheme(); + if (macResult) { + debugLogger.info( + `Detected theme from macOS system appearance: ${macResult}`, + ); + return macResult; + } + + debugLogger.info('Could not detect terminal theme, defaulting to dark'); + return 'dark'; +} diff --git a/packages/cli/src/ui/themes/theme-manager.test.ts b/packages/cli/src/ui/themes/theme-manager.test.ts index 75c6b761d..df9a59613 100644 --- a/packages/cli/src/ui/themes/theme-manager.test.ts +++ b/packages/cli/src/ui/themes/theme-manager.test.ts @@ -10,11 +10,16 @@ if (process.env['NO_COLOR'] !== undefined) { } import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { themeManager, DEFAULT_THEME } from './theme-manager.js'; +import { + themeManager, + DEFAULT_THEME, + AUTO_THEME_NAME, +} from './theme-manager.js'; import type { CustomTheme } from './theme.js'; import * as fs from 'node:fs'; import * as os from 'node:os'; import type * as osActual from 'node:os'; +import * as detectModule from './detect-terminal-theme.js'; vi.mock('node:fs'); vi.mock('node:os', async (importOriginal) => { @@ -25,6 +30,10 @@ vi.mock('node:os', async (importOriginal) => { platform: vi.fn(() => 'linux'), }; }); +vi.mock('./detect-terminal-theme.js', () => ({ + detectTerminalTheme: vi.fn(() => 'dark'), + detectTerminalThemeAsync: vi.fn(async () => 'dark'), +})); const validCustomTheme: CustomTheme = { type: 'custom', @@ -46,9 +55,14 @@ const validCustomTheme: CustomTheme = { describe('ThemeManager', () => { beforeEach(() => { - // Reset themeManager state + // Reset themeManager state. themeManager is a module-level singleton, + // so the cached async auto-detection result would otherwise leak across + // tests and make ordering load-bearing. themeManager.loadCustomThemes({}); themeManager.setActiveTheme(DEFAULT_THEME.name); + ( + themeManager as unknown as { cachedAutoDetection: unknown } + ).cachedAutoDetection = undefined; }); afterEach(() => { @@ -114,6 +128,63 @@ describe('ThemeManager', () => { } }); + describe('auto theme detection', () => { + it('should select Qwen Dark when terminal is detected as dark', () => { + vi.mocked(detectModule.detectTerminalTheme).mockReturnValue('dark'); + const result = themeManager.setActiveTheme(AUTO_THEME_NAME); + expect(result).toBe(true); + expect(themeManager.getActiveTheme().name).toBe('Qwen Dark'); + }); + + it('should select Qwen Light when terminal is detected as light', () => { + vi.mocked(detectModule.detectTerminalTheme).mockReturnValue('light'); + const result = themeManager.setActiveTheme(AUTO_THEME_NAME); + expect(result).toBe(true); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + }); + + it('should always return true for auto theme', () => { + expect(themeManager.setActiveTheme(AUTO_THEME_NAME)).toBe(true); + }); + + it('should resolve async auto theme with Qwen Light for light', async () => { + vi.mocked(detectModule.detectTerminalThemeAsync).mockResolvedValue( + 'light', + ); + await themeManager.resolveAutoThemeAsync(); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + }); + + it('should resolve async auto theme with Qwen Dark for dark', async () => { + vi.mocked(detectModule.detectTerminalThemeAsync).mockResolvedValue( + 'dark', + ); + await themeManager.resolveAutoThemeAsync(); + expect(themeManager.getActiveTheme().name).toBe('Qwen Dark'); + }); + + it('should reuse the async-detected value when auto is re-selected', async () => { + // Startup: async probe (e.g. OSC 11) reports light. + vi.mocked(detectModule.detectTerminalThemeAsync).mockResolvedValue( + 'light', + ); + await themeManager.resolveAutoThemeAsync(); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + + // User switches to another theme via /theme. + themeManager.setActiveTheme('Ayu'); + expect(themeManager.getActiveTheme().name).toBe('Ayu'); + + // Switching back to Auto must not regress: even if the sync detector + // disagrees (OSC 11 is unavailable in-session), the cached async + // result wins so the preview stays consistent with startup. + vi.mocked(detectModule.detectTerminalTheme).mockReturnValue('dark'); + themeManager.setActiveTheme(AUTO_THEME_NAME); + expect(themeManager.getActiveTheme().name).toBe('Qwen Light'); + expect(detectModule.detectTerminalTheme).not.toHaveBeenCalled(); + }); + }); + describe('when loading a theme from a file', () => { const mockThemePath = './my-theme.json'; const mockTheme: CustomTheme = { diff --git a/packages/cli/src/ui/themes/theme-manager.ts b/packages/cli/src/ui/themes/theme-manager.ts index e4d8c3dfa..e8806dba3 100644 --- a/packages/cli/src/ui/themes/theme-manager.ts +++ b/packages/cli/src/ui/themes/theme-manager.ts @@ -28,6 +28,10 @@ import { ANSILight } from './ansi-light.js'; import { NoColorTheme } from './no-color.js'; import process from 'node:process'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { + detectTerminalTheme, + detectTerminalThemeAsync, +} from './detect-terminal-theme.js'; const debugLogger = createDebugLogger('THEME_MANAGER'); @@ -38,6 +42,7 @@ export interface ThemeDisplay { } export const DEFAULT_THEME: Theme = QwenDark; +export const AUTO_THEME_NAME = 'auto'; class ThemeManager { private readonly availableThemes: Theme[]; @@ -114,9 +119,16 @@ class ThemeManager { /** * Sets the active theme. * @param themeName The name of the theme to set as active. + * If themeName is 'auto', detects the terminal theme and selects + * Qwen Dark or Qwen Light accordingly. * @returns True if the theme was successfully set, false otherwise. */ setActiveTheme(themeName: string | undefined): boolean { + if (themeName === AUTO_THEME_NAME) { + this.activeTheme = this.resolveAutoTheme(); + debugLogger.info(`Auto-detected theme: ${this.activeTheme.name}`); + return true; + } const theme = this.findThemeByName(themeName); if (!theme) { return false; @@ -125,6 +137,39 @@ class ThemeManager { return true; } + /** + * Cached auto-detection result. Populated by the async probe at startup + * (which includes OSC 11) and reused by subsequent sync resolutions so + * reselecting Auto in the /theme dialog never contradicts what was shown + * when the app first rendered. + */ + private cachedAutoDetection: 'dark' | 'light' | undefined; + + /** + * Detects the terminal's dark/light preference (synchronous) and returns + * the corresponding Qwen theme. + * Used by the theme dialog for instant preview. Prefers the cached + * async-detected value when available so we stay consistent with the + * OSC 11 probe performed at startup. + */ + private resolveAutoTheme(): Theme { + const detected = this.cachedAutoDetection ?? detectTerminalTheme(); + return detected === 'light' ? QwenLight : QwenDark; + } + + /** + * Asynchronous auto-detection that includes an OSC 11 probe. + * Intended for startup where a short async delay (~200 ms) is acceptable. + * The resolved value is cached so later sync resolutions (e.g. the /theme + * dialog reselecting Auto) stay in sync with what the probe detected. + */ + async resolveAutoThemeAsync(): Promise<void> { + const detected = await detectTerminalThemeAsync(); + this.cachedAutoDetection = detected; + this.activeTheme = detected === 'light' ? QwenLight : QwenDark; + debugLogger.info(`Auto-detected theme (async): ${this.activeTheme.name}`); + } + /** * Gets the currently active theme. * @returns The active theme. diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 21c65bb22..775537da9 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -12,6 +12,7 @@ import type { ToolConfirmationOutcome, ToolResultDisplay, AgentStatus, + ArenaDiffSummary, } from '@qwen-code/qwen-code-core'; import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; @@ -354,6 +355,9 @@ export interface ArenaAgentCardData { rounds: number; error?: string; diff?: string; + diffSummary?: ArenaDiffSummary; + modifiedFiles?: string[]; + approachSummary?: string; } export type HistoryItemArenaAgentComplete = HistoryItemBase & { @@ -390,9 +394,9 @@ export type HistoryItemBtw = HistoryItemBase & { /** * Away-summary recap shown when the user returns to the session after a - * period of inactivity (or via /recap). Rendered as a sticky banner above - * the input box (NOT part of the scrolling history), so it is intentionally - * excluded from the HistoryItemWithoutId union. + * period of inactivity (or via /recap). Rendered inline as a regular + * history item (matching Claude Code's away_summary message); scrolls + * with the conversation, no sticky pinning. */ export type HistoryItemAwayRecap = HistoryItemBase & { type: 'away_recap'; @@ -484,6 +488,7 @@ export type HistoryItemWithoutId = | HistoryItemInsightProgress | HistoryItemBtw | HistoryItemMemorySaved + | HistoryItemAwayRecap | HistoryItemUserPromptSubmitBlocked | HistoryItemStopHookLoop | HistoryItemStopHookSystemMessage diff --git a/packages/cli/src/ui/utils/commandUtils.test.ts b/packages/cli/src/ui/utils/commandUtils.test.ts index 766a72ed3..4273bc1ea 100644 --- a/packages/cli/src/ui/utils/commandUtils.test.ts +++ b/packages/cli/src/ui/utils/commandUtils.test.ts @@ -14,6 +14,7 @@ import { copyToClipboard, getUrlOpenCommand, CodePage, + findMidInputSlashCommand, } from './commandUtils.js'; // Mock child_process @@ -487,3 +488,51 @@ describe('commandUtils', () => { }); }); }); + +describe('findMidInputSlashCommand', () => { + it('returns null when input starts with / (handled by start-of-line completion)', () => { + expect(findMidInputSlashCommand('/review', 7)).toBeNull(); + }); + + it('returns null when cursor is before the slash token', () => { + // "hello /review", cursor at position 3 (inside "hello") + expect(findMidInputSlashCommand('hello /review', 3)).toBeNull(); + }); + + it('returns match when cursor is exactly at the end of the token', () => { + // "hello /re", cursor at end (offset=9) + const result = findMidInputSlashCommand('hello /re', 9); + expect(result).toEqual({ + token: '/re', + startPos: 6, + partialCommand: 're', + }); + }); + + it('returns null when cursor is inside the token (not at the end)', () => { + // "hello /review", cursor at offset 9 (inside 'review') + // slashPos=6, fullCommand="review"(len=6), end=13 → 9 !== 13 → null + expect(findMidInputSlashCommand('hello /review', 9)).toBeNull(); + }); + + it('returns null when cursor has moved past the token into a space', () => { + // "hello /review ", cursor at offset 14 (after the trailing space) + expect(findMidInputSlashCommand('hello /review ', 14)).toBeNull(); + }); + + it('returns match for empty partial (cursor immediately after /)', () => { + // partialCommand="" → getBestSlashCommandMatch will return null, but + // findMidInputSlashCommand itself should return the match object + const result = findMidInputSlashCommand('hello /', 7); + expect(result).toEqual({ + token: '/', + startPos: 6, + partialCommand: '', + }); + }); + + it('returns null when / is not preceded by whitespace', () => { + // "hello/review", no space before slash + expect(findMidInputSlashCommand('hello/review', 12)).toBeNull(); + }); +}); diff --git a/packages/cli/src/ui/utils/commandUtils.ts b/packages/cli/src/ui/utils/commandUtils.ts index 9436447f7..18f74015c 100644 --- a/packages/cli/src/ui/utils/commandUtils.ts +++ b/packages/cli/src/ui/utils/commandUtils.ts @@ -7,6 +7,7 @@ import type { SpawnOptions } from 'node:child_process'; import { spawn } from 'node:child_process'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import type { SlashCommand } from '../commands/types.js'; /** * Common Windows console code pages (CP) used for encoding conversions. @@ -183,3 +184,83 @@ export const getUrlOpenCommand = (): string => { } return openCmd; }; + +/** + * Represents a slash command token found mid-input (not at position 0). + * e.g., in "hello /st", startPos=6, partialCommand="st" + */ +export type MidInputSlashCommand = { + /** Full token including slash, e.g. "/st" */ + token: string; + /** Position of the "/" in the full input string */ + startPos: number; + /** Command portion without slash, e.g. "st" */ + partialCommand: string; +}; + +/** + * Finds a slash command token that appears mid-input (not at position 0). + * Only triggers when the "/" is preceded by whitespace and the cursor is + * right at or within the partial command (no text between cursor and slash). + * + * Returns null when input starts with "/" (handled by start-of-line completion). + */ +export function findMidInputSlashCommand( + input: string, + cursorOffset: number, +): MidInputSlashCommand | null { + // Start-of-line slash handled by existing dropdown completion + if (input.startsWith('/')) return null; + + const beforeCursor = input.slice(0, cursorOffset); + + // Match: whitespace then "/" then optional command chars, anchored at end + // Capture whitespace instead of lookbehind to avoid JSC JIT regression + const match = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]*)$/); + if (!match || match.index === undefined) return null; + + const slashPos = match.index + 1; // +1 to skip the captured whitespace char + const textAfterSlash = input.slice(slashPos + 1); + + // Extend to next space (or end of input) to find the full command name + const commandMatch = textAfterSlash.match(/^[a-zA-Z0-9_:-]*/); + const fullCommand = commandMatch ? commandMatch[0] : ''; + + // Only show ghost text when cursor is exactly at the end of the token. + // If the cursor is inside the token or past it, return null. + if (cursorOffset !== slashPos + 1 + fullCommand.length) return null; + + return { + token: '/' + fullCommand, + startPos: slashPos, + partialCommand: input.slice(slashPos + 1, cursorOffset), + }; +} + +/** + * Finds the best (alphabetically first) prefix-matching command for a partial + * command string. Returns the completion suffix and full command name, or null. + * + * e.g. partialCommand="st" → { suffix: "ats", fullCommand: "stats" } + */ +export function getBestSlashCommandMatch( + partialCommand: string, + commands: readonly SlashCommand[], +): { suffix: string; fullCommand: string } | null { + if (!partialCommand) return null; + const query = partialCommand.toLowerCase(); + let best: { suffix: string; fullCommand: string } | null = null; + for (const cmd of commands) { + // Only suggest model-invocable commands for mid-input completion, + // since built-in commands typed in the middle of text won't be executed. + if (!cmd.modelInvocable) continue; + const name = cmd.name.toLowerCase(); + if (name.startsWith(query) && name !== query) { + const suffix = cmd.name.slice(partialCommand.length); + if (!best || cmd.name < best.fullCommand) { + best = { suffix, fullCommand: cmd.name }; + } + } + } + return best; +} diff --git a/packages/cli/src/ui/utils/computeStats.test.ts b/packages/cli/src/ui/utils/computeStats.test.ts index db8d4cc9b..b0199816d 100644 --- a/packages/cli/src/ui/utils/computeStats.test.ts +++ b/packages/cli/src/ui/utils/computeStats.test.ts @@ -12,13 +12,13 @@ import { computeSessionStats, } from './computeStats.js'; import type { - ModelMetrics, + ModelMetricsCore, SessionMetrics, } from '../contexts/SessionContext.js'; describe('calculateErrorRate', () => { it('should return 0 if totalRequests is 0', () => { - const metrics: ModelMetrics = { + const metrics: ModelMetricsCore = { api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 }, tokens: { prompt: 0, @@ -33,7 +33,7 @@ describe('calculateErrorRate', () => { }); it('should calculate the error rate correctly', () => { - const metrics: ModelMetrics = { + const metrics: ModelMetricsCore = { api: { totalRequests: 10, totalErrors: 2, totalLatencyMs: 0 }, tokens: { prompt: 0, @@ -50,7 +50,7 @@ describe('calculateErrorRate', () => { describe('calculateAverageLatency', () => { it('should return 0 if totalRequests is 0', () => { - const metrics: ModelMetrics = { + const metrics: ModelMetricsCore = { api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 1000 }, tokens: { prompt: 0, @@ -65,7 +65,7 @@ describe('calculateAverageLatency', () => { }); it('should calculate the average latency correctly', () => { - const metrics: ModelMetrics = { + const metrics: ModelMetricsCore = { api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 1500 }, tokens: { prompt: 0, @@ -82,7 +82,7 @@ describe('calculateAverageLatency', () => { describe('calculateCacheHitRate', () => { it('should return 0 if prompt tokens is 0', () => { - const metrics: ModelMetrics = { + const metrics: ModelMetricsCore = { api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 }, tokens: { prompt: 0, @@ -97,7 +97,7 @@ describe('calculateCacheHitRate', () => { }); it('should calculate the cache hit rate correctly', () => { - const metrics: ModelMetrics = { + const metrics: ModelMetricsCore = { api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 }, tokens: { prompt: 200, @@ -162,6 +162,7 @@ describe('computeSessionStats', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { @@ -200,6 +201,7 @@ describe('computeSessionStats', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { diff --git a/packages/cli/src/ui/utils/computeStats.ts b/packages/cli/src/ui/utils/computeStats.ts index cc0d870e7..f5bd45239 100644 --- a/packages/cli/src/ui/utils/computeStats.ts +++ b/packages/cli/src/ui/utils/computeStats.ts @@ -7,24 +7,24 @@ import type { SessionMetrics, ComputedSessionStats, - ModelMetrics, + ModelMetricsCore, } from '../contexts/SessionContext.js'; -export function calculateErrorRate(metrics: ModelMetrics): number { +export function calculateErrorRate(metrics: ModelMetricsCore): number { if (metrics.api.totalRequests === 0) { return 0; } return (metrics.api.totalErrors / metrics.api.totalRequests) * 100; } -export function calculateAverageLatency(metrics: ModelMetrics): number { +export function calculateAverageLatency(metrics: ModelMetricsCore): number { if (metrics.api.totalRequests === 0) { return 0; } return metrics.api.totalLatencyMs / metrics.api.totalRequests; } -export function calculateCacheHitRate(metrics: ModelMetrics): number { +export function calculateCacheHitRate(metrics: ModelMetricsCore): number { if (metrics.tokens.prompt === 0) { return 0; } diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 09173e10e..7ef73fd94 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -154,6 +154,32 @@ describe('formatters', () => { it('should handle negative durations', () => { expect(formatDuration(-100)).toBe('0s'); }); + + describe('with hideTrailingZeros', () => { + it('drops .0 suffix for whole seconds under a minute', () => { + expect(formatDuration(5000, { hideTrailingZeros: true })).toBe('5s'); + expect(formatDuration(10000, { hideTrailingZeros: true })).toBe('10s'); + expect(formatDuration(30000, { hideTrailingZeros: true })).toBe('30s'); + }); + + it('keeps fractional seconds under a minute', () => { + expect(formatDuration(5500, { hideTrailingZeros: true })).toBe('5.5s'); + expect(formatDuration(12345, { hideTrailingZeros: true })).toBe( + '12.3s', + ); + }); + + it('does not affect ms-range output', () => { + expect(formatDuration(500, { hideTrailingZeros: true })).toBe('500ms'); + }); + + it('does not affect multi-unit output', () => { + expect(formatDuration(123000, { hideTrailingZeros: true })).toBe( + '2m 3s', + ); + expect(formatDuration(3600000, { hideTrailingZeros: true })).toBe('1h'); + }); + }); }); describe('formatTokenCount', () => { diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index 38afaaa30..36ed878d4 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -65,7 +65,19 @@ export const formatTokenCount = (count: number): string => { return `${Math.floor(count / 1000)}k`; }; -export const formatDuration = (milliseconds: number): string => { +export interface FormatDurationOptions { + /** + * When true, drops a trailing `.0` in the sub-minute range so that whole + * seconds render as `5s` rather than `5.0s`. Non-integer values keep their + * decimal (e.g. `5.5s`). Matches Claude Code's `ShellTimeDisplay` style. + */ + hideTrailingZeros?: boolean; +} + +export const formatDuration = ( + milliseconds: number, + options?: FormatDurationOptions, +): string => { if (milliseconds <= 0) { return '0s'; } @@ -77,7 +89,11 @@ export const formatDuration = (milliseconds: number): string => { const totalSeconds = milliseconds / 1000; if (totalSeconds < 60) { - return `${totalSeconds.toFixed(1)}s`; + const formatted = totalSeconds.toFixed(1); + if (options?.hideTrailingZeros && formatted.endsWith('.0')) { + return `${formatted.slice(0, -2)}s`; + } + return `${formatted}s`; } const hours = Math.floor(totalSeconds / 3600); diff --git a/packages/cli/src/ui/utils/modelsBySource.test.ts b/packages/cli/src/ui/utils/modelsBySource.test.ts new file mode 100644 index 000000000..e59871b86 --- /dev/null +++ b/packages/cli/src/ui/utils/modelsBySource.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { + MAIN_SOURCE, + type ModelMetrics, + type ModelMetricsCore, +} from '@qwen-code/qwen-code-core'; +import { flattenModelsBySource } from './modelsBySource.js'; + +const emptyCore = (): ModelMetricsCore => ({ + api: { totalRequests: 0, totalErrors: 0, totalLatencyMs: 0 }, + tokens: { + prompt: 0, + candidates: 0, + total: 0, + cached: 0, + thoughts: 0, + tool: 0, + }, +}); + +const coreWithRequests = (requests: number): ModelMetricsCore => ({ + ...emptyCore(), + api: { totalRequests: requests, totalErrors: 0, totalLatencyMs: 0 }, +}); + +const makeModel = ( + bySource: Record<string, ModelMetricsCore>, +): ModelMetrics => { + const totalRequests = Object.values(bySource).reduce( + (sum, m) => sum + m.api.totalRequests, + 0, + ); + return { + ...emptyCore(), + api: { totalRequests, totalErrors: 0, totalLatencyMs: 0 }, + bySource, + }; +}; + +describe('flattenModelsBySource', () => { + it('omits models with zero requests', () => { + const entries = flattenModelsBySource({ + 'idle-model': makeModel({}), + }); + expect(entries).toEqual([]); + }); + + it('collapses to plain label when no non-main source exists in the session', () => { + const entries = flattenModelsBySource({ + 'glm-5': makeModel({ [MAIN_SOURCE]: coreWithRequests(3) }), + 'qwen-max': makeModel({ [MAIN_SOURCE]: coreWithRequests(2) }), + }); + expect(entries.map((e) => e.label)).toEqual(['glm-5', 'qwen-max']); + expect(entries.map((e) => e.key)).toEqual(['glm-5', 'qwen-max']); + }); + + it('splits every row when any model has a non-main source (session-wide rule)', () => { + const entries = flattenModelsBySource({ + 'glm-5': makeModel({ [MAIN_SOURCE]: coreWithRequests(2) }), + 'qwen-plus': makeModel({ researcher: coreWithRequests(1) }), + }); + expect(entries.map((e) => e.label)).toEqual([ + 'glm-5 (main)', + 'qwen-plus (researcher)', + ]); + }); + + it('orders sources with MAIN_SOURCE first then alphabetically', () => { + const entries = flattenModelsBySource({ + 'glm-5': makeModel({ + bravo: coreWithRequests(1), + [MAIN_SOURCE]: coreWithRequests(2), + alpha: coreWithRequests(1), + }), + }); + expect(entries.map((e) => e.label)).toEqual([ + 'glm-5 (main)', + 'glm-5 (alpha)', + 'glm-5 (bravo)', + ]); + }); + + it('produces distinct keys when two raw model names normalize to the same label', () => { + // `normalizeModelName` strips `-001`, so `foo` and `foo-001` both render + // as the label `foo`. The React key must still be unique across entries. + const entries = flattenModelsBySource({ + foo: makeModel({ [MAIN_SOURCE]: coreWithRequests(1) }), + 'foo-001': makeModel({ [MAIN_SOURCE]: coreWithRequests(1) }), + }); + expect(entries).toHaveLength(2); + expect(entries.map((e) => e.label)).toEqual(['foo', 'foo']); + const keys = entries.map((e) => e.key); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(['foo', 'foo-001']); + }); + + it('produces distinct keys across (model, source) pairs in the split case', () => { + const entries = flattenModelsBySource({ + 'glm-5': makeModel({ + [MAIN_SOURCE]: coreWithRequests(1), + alpha: coreWithRequests(1), + }), + 'qwen-plus': makeModel({ + alpha: coreWithRequests(1), + }), + }); + const keys = entries.map((e) => e.key); + expect(new Set(keys).size).toBe(keys.length); + expect(keys).toEqual(['glm-5::main', 'glm-5::alpha', 'qwen-plus::alpha']); + }); + + it('falls back to the aggregate when bySource is empty (defensive)', () => { + // Callers shouldn't hit this, but the helper should still produce a + // usable row rather than dropping the model. + const entries = flattenModelsBySource({ + 'glm-5': { + ...coreWithRequests(1), + bySource: {}, + }, + }); + expect(entries).toHaveLength(1); + expect(entries[0]?.label).toBe('glm-5'); + expect(entries[0]?.key).toBe('glm-5'); + }); +}); diff --git a/packages/cli/src/ui/utils/modelsBySource.ts b/packages/cli/src/ui/utils/modelsBySource.ts new file mode 100644 index 000000000..2937024e8 --- /dev/null +++ b/packages/cli/src/ui/utils/modelsBySource.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + MAIN_SOURCE, + type ModelMetrics, + type ModelMetricsCore, +} from '@qwen-code/qwen-code-core'; + +/** + * One entry in the flattened view of the `models` metric map. Each entry + * corresponds to a single row (in `StatsDisplay`) or column (in + * `ModelStatsDisplay`). + */ +export interface ModelSourceEntry { + /** + * Stable React key built from the raw model name + source. Guaranteed + * unique across the returned array, even when two raw model names + * normalize to the same display label (e.g. `foo` and `foo-001`). + */ + key: string; + /** + * Display label. Either the bare (possibly normalized) model name for + * the single-source collapse case, or `${modelName} (${source})` when + * the model has any non-main source. + */ + label: string; + /** Backing metrics — either the model aggregate or one source bucket. */ + metrics: ModelMetricsCore; +} + +/** + * Flattens `SessionMetrics.models` into a list of `(label, metrics)` entries + * suitable for rendering one per row/column. + * + * Rules (matching the design doc `3215-subagent-stats-attribution.md`): + * - Collapse is decided **session-wide**: if NO model in the entire session + * has any non-main source, every row renders with the plain model name + * (existing UX preserved). + * - If ANY model in the session has a non-main source, EVERY row across + * ALL models renders with a `${model} (${source})` label — including the + * `(main)` rows — so the user can directly compare attribution across the + * whole stats panel. This matches the issue mockup, which shows + * `qwen-max (main)` alongside `qwen-plus (researcher)`. + * - Within the split case, sources under a given model are sorted with + * `MAIN_SOURCE` first (if present), then the rest alphabetically. + * - Models with zero requests (aggregate) are omitted. + * - If `bySource` is somehow empty (defensive — callers shouldn't hit this), + * fall back to the aggregate row with the plain model name. + */ +export function flattenModelsBySource( + models: Record<string, ModelMetrics>, +): ModelSourceEntry[] { + const sessionHasNonMainSource = Object.values(models).some((modelMetrics) => + Object.keys(modelMetrics.bySource).some((source) => source !== MAIN_SOURCE), + ); + + const result: ModelSourceEntry[] = []; + + for (const [modelName, modelMetrics] of Object.entries(models)) { + if (modelMetrics.api.totalRequests <= 0) continue; + + const displayName = normalizeModelName(modelName); + const sourceNames = Object.keys(modelMetrics.bySource); + + if (sourceNames.length === 0) { + result.push({ + key: modelName, + label: displayName, + metrics: modelMetrics, + }); + continue; + } + + if (!sessionHasNonMainSource) { + // Collapse session-wide: only main sources exist, render aggregate + // with plain model names so the existing UX is preserved. + result.push({ + key: modelName, + label: displayName, + metrics: modelMetrics.bySource[MAIN_SOURCE] ?? modelMetrics, + }); + continue; + } + + const sortedSources = sortSources(sourceNames); + for (const source of sortedSources) { + result.push({ + key: `${modelName}::${source}`, + label: `${displayName} (${source})`, + metrics: modelMetrics.bySource[source], + }); + } + } + + return result; +} + +/** + * Strips the Gemini `-001` version suffix from model names for display. + * Historically the StatsDisplay summary table normalized model names this + * way; keep the behavior but apply it to the model portion only so subagent + * names that happen to contain `-001` are not mangled. + */ +function normalizeModelName(modelName: string): string { + return modelName.replace('-001', ''); +} + +/** + * `MAIN_SOURCE` first (if present), then the rest alphabetically. + */ +function sortSources(sources: string[]): string[] { + const main: string[] = []; + const rest: string[] = []; + for (const source of sources) { + if (source === MAIN_SOURCE) { + main.push(source); + } else { + rest.push(source); + } + } + rest.sort((a, b) => a.localeCompare(b)); + return [...main, ...rest]; +} diff --git a/packages/cli/src/utils/nonInteractiveHelpers.test.ts b/packages/cli/src/utils/nonInteractiveHelpers.test.ts index b69056707..0a6015a85 100644 --- a/packages/cli/src/utils/nonInteractiveHelpers.test.ts +++ b/packages/cli/src/utils/nonInteractiveHelpers.test.ts @@ -36,50 +36,44 @@ import { } from './nonInteractiveHelpers.js'; // Mock dependencies -vi.mock('../nonInteractiveCliCommands.js', async () => { - const { filterCommandsForMode } = await import('../services/commandUtils.js'); - return { - getAvailableCommands: vi - .fn() - .mockImplementation( - async ( - _config: unknown, - _signal: AbortSignal, - mode: string = 'acp', - ) => { - // Simulate capability-based filtering with commandType / supportedModes - // Delegate to production filterCommandsForMode to avoid logic divergence - const allCommands = [ - { name: 'help', commandType: 'local-jsx' }, - { name: 'commit', commandType: 'prompt' }, - { name: 'memory', commandType: 'local' }, - { - name: 'init', - commandType: 'local', - supportedModes: ['interactive', 'non_interactive', 'acp'], - }, - { - name: 'summary', - commandType: 'local', - supportedModes: ['interactive', 'non_interactive', 'acp'], - }, - { - name: 'compress', - commandType: 'local', - supportedModes: ['interactive', 'non_interactive', 'acp'], - }, - ]; +vi.mock('../nonInteractiveCliCommands.js', () => ({ + getAvailableCommands: vi + .fn() + .mockImplementation( + async (_config: unknown, _signal: AbortSignal, mode: string = 'acp') => { + const allCommands = [ + { + name: 'help', + supportedModes: ['interactive'] as const, + }, + { + name: 'commit', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + { + name: 'memory', + supportedModes: ['interactive'] as const, + }, + { + name: 'init', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + { + name: 'summary', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + { + name: 'compress', + supportedModes: ['interactive', 'non_interactive', 'acp'] as const, + }, + ] as const; - return filterCommandsForMode( - allCommands as unknown as Parameters< - typeof filterCommandsForMode - >[0], - mode as Parameters<typeof filterCommandsForMode>[1], - ); - }, - ), - }; -}); + return allCommands.filter((cmd) => + (cmd.supportedModes as readonly string[]).includes(mode), + ); + }, + ), +})); vi.mock('../ui/utils/computeStats.js', () => ({ computeSessionStats: vi.fn().mockReturnValue({ @@ -358,6 +352,7 @@ describe('computeUsageFromMetrics', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { @@ -400,6 +395,7 @@ describe('computeUsageFromMetrics', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, 'model-2': { api: { totalRequests: 1, totalErrors: 0, totalLatencyMs: 100 }, @@ -411,6 +407,7 @@ describe('computeUsageFromMetrics', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { @@ -453,6 +450,7 @@ describe('computeUsageFromMetrics', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { diff --git a/packages/cli/src/utils/systemInfoFields.ts b/packages/cli/src/utils/systemInfoFields.ts index ec40afada..c935f0386 100644 --- a/packages/cli/src/utils/systemInfoFields.ts +++ b/packages/cli/src/utils/systemInfoFields.ts @@ -6,7 +6,7 @@ import type { ExtendedSystemInfo } from './systemInfo.js'; import { t } from '../i18n/index.js'; -import { isCodingPlanConfig } from '../constants/codingPlan.js'; +import { isCodingPlanConfig } from '@qwen-code/qwen-code-core'; /** * Field configuration for system information display diff --git a/packages/core/package.json b/packages/core/package.json index 5041ff427..33bc22cc4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.14.5", + "version": "0.15.0", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/agents/arena/ArenaAgentClient.test.ts b/packages/core/src/agents/arena/ArenaAgentClient.test.ts index 6ab61039c..5959d68cf 100644 --- a/packages/core/src/agents/arena/ArenaAgentClient.test.ts +++ b/packages/core/src/agents/arena/ArenaAgentClient.test.ts @@ -42,6 +42,7 @@ const createMockMetrics = ( thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { @@ -462,6 +463,7 @@ describe('ArenaAgentClient', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, 'model-b': { api: { @@ -477,6 +479,7 @@ describe('ArenaAgentClient', () => { thoughts: 0, tool: 0, }, + bySource: {}, }, }, tools: { diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index a21f15d63..6f756c892 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -11,6 +11,7 @@ import * as os from 'node:os'; import { ArenaManager } from './ArenaManager.js'; import { ArenaEventType } from './arena-events.js'; import { ArenaSessionStatus, ARENA_MAX_AGENTS } from './types.js'; +import { AgentStatus } from '../runtime/agent-types.js'; const hoistedMockSetupWorktrees = vi.hoisted(() => vi.fn()); const hoistedMockCleanupSession = vi.hoisted(() => vi.fn()); @@ -374,6 +375,156 @@ describe('ArenaManager', () => { }); describe('active session lifecycle', () => { + it('collects diff summaries and fallback approach summaries', async () => { + const manager = new ArenaManager(mockConfig as never); + mockBackend.setAutoExit(false); + hoistedMockGetWorktreeDiff.mockResolvedValue(`diff --git a/src/auth.ts b/src/auth.ts +index 111..222 100644 +--- a/src/auth.ts ++++ b/src/auth.ts +@@ -1 +1,2 @@ +-old ++new ++extra`); + + const startPromise = manager.start(createValidStartOptions()); + await waitForCondition( + () => mockBackend.spawnAgent.mock.calls.length >= 2, + ); + + const agentsDir = path.join( + os.tmpdir(), + 'arena-mock', + 'testsess', + 'agents', + ); + await fs.mkdir(agentsDir, { recursive: true }); + for (const modelId of ['model-1', 'model-2']) { + await fs.writeFile( + path.join(agentsDir, `${modelId}.json`), + JSON.stringify({ + agentId: modelId, + status: AgentStatus.COMPLETED, + updatedAt: Date.now(), + rounds: 1, + stats: { + rounds: 1, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + durationMs: 0, + toolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + }, + finalSummary: null, + error: null, + }), + 'utf-8', + ); + } + + const result = await startPromise; + + expect(result.agents).toHaveLength(2); + expect(result.agents[0]?.modifiedFiles).toEqual(['src/auth.ts']); + expect(result.agents[0]?.diffSummary).toEqual({ + files: [{ path: 'src/auth.ts', additions: 2, deletions: 1 }], + additions: 2, + deletions: 1, + }); + expect(result.agents[0]?.approachSummary).toBe( + 'Changed 1 file with 0 tool calls (+2/-1).', + ); + }); + + it('uses each in-process agent generator for semantic approach summaries', async () => { + const mainGenerateContent = vi.fn(); + const model1GenerateContent = vi.fn().mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + summary: 'Model 1 used a strategy pattern.', + }), + }, + ], + }, + }, + ], + }); + const model2GenerateContent = vi.fn().mockResolvedValue({ + candidates: [ + { + content: { + parts: [ + { + text: JSON.stringify({ + summary: 'Model 2 made inline edits.', + }), + }, + ], + }, + }, + ], + }); + const config = { + ...mockConfig, + getContentGenerator: () => ({ + generateContent: mainGenerateContent, + }), + }; + mockBackend.type = 'in-process'; + mockBackend.setAutoExit(false); + const agentInteractives = new Map< + string, + ReturnType<typeof createMockInteractive> + >(); + mockBackend.getAgent.mockImplementation((agentId: string) => + agentInteractives.get(agentId), + ); + mockBackend.getAgentContentGenerator.mockImplementation( + (agentId: string) => + agentId === 'model-1' + ? { generateContent: model1GenerateContent } + : { generateContent: model2GenerateContent }, + ); + mockBackend.spawnAgent.mockImplementation( + async (config: { agentId: string }) => { + agentInteractives.set( + config.agentId, + createMockInteractive(config.agentId), + ); + }, + ); + const manager = new ArenaManager(config as never); + + const result = await manager.start(createValidStartOptions()); + + expect(mainGenerateContent).not.toHaveBeenCalled(); + expect(model1GenerateContent).toHaveBeenCalledTimes(1); + expect(model2GenerateContent).toHaveBeenCalledTimes(1); + expect(model1GenerateContent.mock.calls[0]?.[0].model).toBe('model-1'); + expect(model2GenerateContent.mock.calls[0]?.[0].model).toBe('model-2'); + + const model1Prompt = model1GenerateContent.mock.calls[0]?.[0].contents[0] + .parts[0].text as string; + const model2Prompt = model2GenerateContent.mock.calls[0]?.[0].contents[0] + .parts[0].text as string; + expect(model1Prompt).toContain('"agentId": "model-1"'); + expect(model1Prompt).not.toContain('"agentId": "model-2"'); + expect(model2Prompt).toContain('"agentId": "model-2"'); + expect(model2Prompt).not.toContain('"agentId": "model-1"'); + expect(result.agents[0]?.approachSummary).toBe( + 'Model 1 used a strategy pattern.', + ); + expect(result.agents[1]?.approachSummary).toBe( + 'Model 2 made inline edits.', + ); + }); + it('cancel should stop backend and move session to CANCELLED', async () => { const manager = new ArenaManager(mockConfig as never); @@ -434,7 +585,7 @@ function createMockBackend() { let autoExit = true; const backend = { - type: 'tmux' as const, + type: 'tmux' as 'tmux' | 'in-process', init: vi.fn().mockResolvedValue(undefined), spawnAgent: vi.fn(async (config: { agentId: string }) => { // By default, simulate immediate agent termination so tests @@ -461,6 +612,8 @@ function createMockBackend() { writeToAgent: vi.fn().mockReturnValue(false), resizeAll: vi.fn(), getAttachHint: vi.fn().mockReturnValue(null), + getAgent: vi.fn().mockReturnValue(undefined), + getAgentContentGenerator: vi.fn().mockReturnValue(undefined), /** Disable automatic agent exit for tests that need to control timing. */ setAutoExit(value: boolean) { autoExit = value; @@ -469,6 +622,36 @@ function createMockBackend() { return backend; } +function createMockInteractive(agentId: string) { + const emitter = { + on: vi.fn(), + off: vi.fn(), + }; + return { + getMessages: vi.fn().mockReturnValue([ + { + role: 'assistant', + content: `${agentId} final response`, + timestamp: Date.now(), + }, + ]), + getStatus: vi.fn().mockReturnValue(AgentStatus.IDLE), + getStats: vi.fn().mockReturnValue({ + rounds: 1, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + totalToolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + totalDurationMs: 1, + }), + getLastRoundError: vi.fn().mockReturnValue(undefined), + getError: vi.fn().mockReturnValue(undefined), + getEventEmitter: vi.fn().mockReturnValue(emitter), + }; +} + function createValidStartOptions() { return { models: [ diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 6a386158f..41a788249 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -9,11 +9,13 @@ import * as path from 'node:path'; import { GitWorktreeService } from '../../services/gitWorktreeService.js'; import { Storage } from '../../config/storage.js'; import type { Config } from '../../config/config.js'; +import type { ContentGenerator } from '../../core/contentGenerator.js'; import { getCoreSystemPrompt } from '../../core/prompts.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { isNodeError } from '../../utils/errors.js'; import { atomicWriteJSON } from '../../utils/atomicFileWrite.js'; import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import { getResponseText } from '../../utils/partUtils.js'; import { ArenaEventEmitter, ArenaEventType } from './arena-events.js'; import type { AgentSpawnConfig, Backend, DisplayMode } from '../index.js'; import { detectBackend, DISPLAY_MODE } from '../index.js'; @@ -51,10 +53,30 @@ import { makeArenaSessionEndedEvent, } from '../../telemetry/index.js'; import type { ArenaSessionEndedStatus } from '../../telemetry/index.js'; +import { + buildFallbackApproachSummary, + summarizeUnifiedDiff, +} from './diff-summary.js'; const debugLogger = createDebugLogger('ARENA'); const ARENA_POLL_INTERVAL_MS = 500; +const ARENA_SUMMARY_TIMEOUT_MS = 20_000; +const ARENA_SUMMARY_MAX_DIFF_CHARS = 6_000; +const ARENA_SUMMARY_MAX_TRANSCRIPT_CHARS = 6_000; + +interface ArenaTranscriptEntry { + role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'info'; + content: string; + thought?: boolean; + metadata?: Record<string, unknown>; + timestamp: number; +} + +interface ArenaSummaryInput { + result: ArenaAgentResult; + transcript?: ArenaTranscriptEntry[]; +} /** * ArenaManager orchestrates multi-model competitive execution. @@ -1438,6 +1460,9 @@ export class ArenaManager { ...agent.stats, ...statusFile.stats, }; + if (statusFile.finalSummary) { + agent.accumulatedText = statusFile.finalSummary; + } // Detect state transitions from the sideband status file const resolved = this.resolveTransition( @@ -1605,15 +1630,163 @@ export class ArenaManager { } } + private getAgentTranscript( + agentId: string, + ): ArenaTranscriptEntry[] | undefined { + if (this.backend?.type !== DISPLAY_MODE.IN_PROCESS) { + return undefined; + } + + const interactive = (this.backend as InProcessBackend).getAgent(agentId); + const messages = interactive?.getMessages(); + if (!messages || messages.length === 0) { + return undefined; + } + + return messages.map((message) => ({ + role: message.role, + content: message.content, + thought: message.thought, + metadata: message.metadata, + timestamp: message.timestamp, + })); + } + + private getFinalTextFromTranscript( + transcript: ArenaTranscriptEntry[] | undefined, + ): string | undefined { + if (!transcript) return undefined; + + for (let i = transcript.length - 1; i >= 0; i--) { + const message = transcript[i]!; + if ( + message.role === 'assistant' && + !message.thought && + message.content.trim() + ) { + return message.content.trim(); + } + } + + return undefined; + } + + private async addApproachSummaries( + summaryInputs: ArenaSummaryInput[], + ): Promise<void> { + await Promise.all( + summaryInputs.map(async (summaryInput) => { + summaryInput.result.approachSummary = + await this.generateAgentApproachSummary(summaryInput); + }), + ); + } + + private getAgentSummaryGenerator( + agentId: string, + ): ContentGenerator | undefined { + if (this.backend?.type !== DISPLAY_MODE.IN_PROCESS) { + return undefined; + } + + return (this.backend as InProcessBackend).getAgentContentGenerator(agentId); + } + + private async generateAgentApproachSummary( + summaryInput: ArenaSummaryInput, + ): Promise<string> { + const { result } = summaryInput; + const generator = this.getAgentSummaryGenerator(result.agentId); + if (!generator) { + return buildFallbackApproachSummary(result); + } + + const abortController = new AbortController(); + const timeout = setTimeout( + () => abortController.abort(), + ARENA_SUMMARY_TIMEOUT_MS, + ); + + try { + const response = await generator.generateContent( + { + model: result.model.modelId, + contents: [ + { + role: 'user', + parts: [ + { + text: this.buildAgentApproachSummaryPrompt(summaryInput), + }, + ], + }, + ], + config: { + abortSignal: abortController.signal, + thinkingConfig: { includeThoughts: false }, + }, + }, + 'arena_approach_summary', + ); + + return ( + parseApproachSummaryResponse(getResponseText(response) ?? '')?.trim() || + buildFallbackApproachSummary(result) + ); + } catch (error) { + debugLogger.error( + `Failed to generate Arena approach summary for ${result.agentId}:`, + error, + ); + return buildFallbackApproachSummary(result); + } finally { + clearTimeout(timeout); + } + } + + private buildAgentApproachSummaryPrompt({ + result: agent, + transcript, + }: ArenaSummaryInput): string { + const payload = { + task: this.arenaConfig?.task ?? '', + instruction: + 'Summarize this Arena agent approach for user comparison. Use git diff as the source of truth for what changed. Use transcript/finalText only to infer intent and architectural decisions. Do not pick a winner. Return only compact JSON: {"summary":"one sentence summary"}.', + agent: { + agentId: agent.agentId, + model: agent.model.modelId, + status: agent.status, + metrics: { + files: agent.diffSummary?.files.length ?? 0, + additions: agent.diffSummary?.additions ?? 0, + deletions: agent.diffSummary?.deletions ?? 0, + tokens: agent.stats.totalTokens, + durationMs: agent.stats.durationMs, + toolCalls: agent.stats.toolCalls, + }, + files: agent.diffSummary?.files ?? [], + finalText: truncateForPrompt(agent.finalText ?? '', 2_000), + transcript: truncateForPrompt(formatTranscript(transcript), 6_000), + diff: truncateForPrompt(agent.diff ?? '', ARENA_SUMMARY_MAX_DIFF_CHARS), + }, + }; + + return JSON.stringify(payload, null, 2); + } + private async collectResults(): Promise<ArenaSessionResult> { if (!this.arenaConfig) { throw new Error('Arena config not initialized'); } const agents: ArenaAgentResult[] = []; + const summaryInputs: ArenaSummaryInput[] = []; for (const agent of this.agents.values()) { const result = this.buildAgentResult(agent); + const transcript = this.getAgentTranscript(agent.agentId); + result.finalText = + result.finalText ?? this.getFinalTextFromTranscript(transcript); // Get diff for agents that finished their task (IDLE or COMPLETED) if (isSuccessStatus(agent.status)) { @@ -1621,6 +1794,10 @@ export class ArenaManager { result.diff = await this.worktreeService.getWorktreeDiff( agent.worktree.path, ); + result.diffSummary = summarizeUnifiedDiff(result.diff); + result.modifiedFiles = result.diffSummary.files.map( + (file) => file.path, + ); } catch (error) { debugLogger.error( `Failed to get diff for agent ${agent.agentId}:`, @@ -1628,10 +1805,17 @@ export class ArenaManager { ); } } + result.diffSummary ??= summarizeUnifiedDiff(result.diff); + result.modifiedFiles ??= result.diffSummary.files.map( + (file) => file.path, + ); agents.push(result); + summaryInputs.push({ result, transcript }); } + await this.addApproachSummaries(summaryInputs); + const endedAt = Date.now(); return { @@ -1646,3 +1830,73 @@ export class ArenaManager { }; } } + +function truncateForPrompt(text: string, maxChars: number): string { + if (text.length <= maxChars) return text; + return `${text.slice(0, maxChars)}\n...[truncated]`; +} + +function formatTranscript( + transcript: ArenaTranscriptEntry[] | undefined, +): string { + if (!transcript || transcript.length === 0) { + return ''; + } + + const lines: string[] = []; + for (const entry of transcript) { + if (entry.thought) continue; + const metadata = entry.metadata ?? {}; + const toolName = + typeof metadata['toolName'] === 'string' + ? metadata['toolName'] + : undefined; + const success = + typeof metadata['success'] === 'boolean' + ? metadata['success'] + : undefined; + const label = toolName ? `${entry.role}:${toolName}` : entry.role; + const suffix = + success === undefined ? '' : ` (${success ? 'ok' : 'failed'})`; + lines.push(`${label}${suffix}: ${entry.content}`); + } + + return truncateForPrompt( + lines.join('\n'), + ARENA_SUMMARY_MAX_TRANSCRIPT_CHARS, + ); +} + +function parseApproachSummaryResponse(text: string): string | undefined { + const jsonText = extractJsonObject(text); + if (!jsonText) { + return undefined; + } + + try { + const parsed = JSON.parse(jsonText) as unknown; + if (!isRecord(parsed)) { + return undefined; + } + const summary = parsed['summary']; + if (typeof summary === 'string') { + return summary; + } + } catch { + return undefined; + } + return undefined; +} + +function extractJsonObject(text: string): string | null { + const firstBrace = text.indexOf('{'); + const lastBrace = text.lastIndexOf('}'); + if (firstBrace < 0 || lastBrace <= firstBrace) { + return null; + } + return text.slice(firstBrace, lastBrace + 1); +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/core/src/agents/arena/diff-summary.test.ts b/packages/core/src/agents/arena/diff-summary.test.ts new file mode 100644 index 000000000..57c070ad3 --- /dev/null +++ b/packages/core/src/agents/arena/diff-summary.test.ts @@ -0,0 +1,138 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { AgentStatus } from '../runtime/agent-types.js'; +import { + buildFallbackApproachSummary, + summarizeUnifiedDiff, +} from './diff-summary.js'; +import type { ArenaAgentResult } from './types.js'; + +describe('summarizeUnifiedDiff', () => { + it('parses file and line counts from a unified diff', () => { + const summary = summarizeUnifiedDiff(`diff --git a/src/auth.ts b/src/auth.ts +index 111..222 100644 +--- a/src/auth.ts ++++ b/src/auth.ts +@@ -1,3 +1,4 @@ + const a = 1; +-const b = 2; ++const b = 3; ++const c = 4; +diff --git a/tests/auth.test.ts b/tests/auth.test.ts +index 333..444 100644 +--- a/tests/auth.test.ts ++++ b/tests/auth.test.ts +@@ -10,2 +10,2 @@ +-old ++new`); + + expect(summary).toEqual({ + files: [ + { path: 'src/auth.ts', additions: 2, deletions: 1 }, + { path: 'tests/auth.test.ts', additions: 1, deletions: 1 }, + ], + additions: 3, + deletions: 2, + }); + }); + + it('returns zero counts for an empty diff', () => { + expect(summarizeUnifiedDiff('')).toEqual({ + files: [], + additions: 0, + deletions: 0, + }); + }); + + it('parses repeated diff header text without relying on regex backtracking', () => { + const repeated = Array.from({ length: 200 }, () => 'a b/a').join(''); + const path = `${repeated}.ts`; + const summary = summarizeUnifiedDiff(`diff --git a/${path} b/${path} +--- a/${path} ++++ b/${path} +@@ -1 +1 @@ +-old ++new`); + + expect(summary).toEqual({ + files: [{ path, additions: 1, deletions: 1 }], + additions: 1, + deletions: 1, + }); + }); + + it('includes binary diffs without textual line changes', () => { + const summary = + summarizeUnifiedDiff(`diff --git a/assets/logo.png b/assets/logo.png +new file mode 100644 +index 0000000..abc1234 +Binary files /dev/null and b/assets/logo.png differ`); + + expect(summary).toEqual({ + files: [{ path: 'assets/logo.png', additions: 0, deletions: 0 }], + additions: 0, + deletions: 0, + }); + }); + + it('includes rename-only diffs without textual line changes', () => { + const summary = summarizeUnifiedDiff(`diff --git a/src/old.ts b/src/new.ts +similarity index 100% +rename from src/old.ts +rename to src/new.ts`); + + expect(summary).toEqual({ + files: [{ path: 'src/new.ts', additions: 0, deletions: 0 }], + additions: 0, + deletions: 0, + }); + }); + + it('includes mode-only diffs without textual line changes', () => { + const summary = + summarizeUnifiedDiff(`diff --git a/scripts/run.sh b/scripts/run.sh +old mode 100644 +new mode 100755`); + + expect(summary).toEqual({ + files: [{ path: 'scripts/run.sh', additions: 0, deletions: 0 }], + additions: 0, + deletions: 0, + }); + }); +}); + +describe('buildFallbackApproachSummary', () => { + it('summarizes changed files and tool usage', () => { + const result = { + status: AgentStatus.IDLE, + stats: { toolCalls: 3 }, + diffSummary: { + files: [{ path: 'src/auth.ts', additions: 2, deletions: 1 }], + additions: 2, + deletions: 1, + }, + } as unknown as ArenaAgentResult; + + expect(buildFallbackApproachSummary(result)).toBe( + 'Changed 1 file with 3 tool calls (+2/-1).', + ); + }); + + it('reports no changes when the diff is empty', () => { + const result = { + status: AgentStatus.IDLE, + stats: { toolCalls: 0 }, + diffSummary: { files: [], additions: 0, deletions: 0 }, + } as unknown as ArenaAgentResult; + + expect(buildFallbackApproachSummary(result)).toBe( + 'No code changes detected.', + ); + }); +}); diff --git a/packages/core/src/agents/arena/diff-summary.ts b/packages/core/src/agents/arena/diff-summary.ts new file mode 100644 index 000000000..0304e2122 --- /dev/null +++ b/packages/core/src/agents/arena/diff-summary.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ArenaAgentResult, + ArenaDiffSummary, + ArenaFileChangeSummary, +} from './types.js'; +import { isSuccessStatus } from '../runtime/agent-types.js'; + +/** + * Parse a unified git diff into file-level and aggregate line-change stats. + */ +export function summarizeUnifiedDiff( + diff: string | undefined, +): ArenaDiffSummary { + if (!diff) { + return { files: [], additions: 0, deletions: 0 }; + } + + const files: ArenaFileChangeSummary[] = []; + let current: ArenaFileChangeSummary | undefined; + + const finishFile = () => { + if (!current) return; + files.push(current); + current = undefined; + }; + + const ensureFile = (path: string) => { + if (!current) { + current = { path, additions: 0, deletions: 0 }; + return; + } + current.path = path; + }; + + for (const line of diff.split('\n')) { + const gitPath = parseDiffGitPath(line); + if (gitPath) { + finishFile(); + current = { + path: gitPath, + additions: 0, + deletions: 0, + }; + continue; + } + + if (line.startsWith('+++ ')) { + const path = normalizeDiffPath(line.slice(4)); + if (path !== '/dev/null') { + ensureFile(path); + } + continue; + } + + if (line.startsWith('--- ')) { + const path = normalizeDiffPath(line.slice(4)); + if (!current && path !== '/dev/null') { + ensureFile(path); + } + continue; + } + + if (!current) continue; + + if (line.startsWith('+')) { + current.additions++; + } else if (line.startsWith('-')) { + current.deletions++; + } + } + + finishFile(); + + return { + files, + additions: files.reduce((sum, file) => sum + file.additions, 0), + deletions: files.reduce((sum, file) => sum + file.deletions, 0), + }; +} + +/** + * Build a deterministic approach summary when semantic LLM summarization is + * unavailable or returns unusable output. + */ +export function buildFallbackApproachSummary(result: ArenaAgentResult): string { + if (!isSuccessStatus(result.status)) { + const suffix = result.error ? `: ${result.error}` : ''; + return `Did not produce an applicable result${suffix}.`; + } + + const diffSummary = + result.diffSummary ?? summarizeUnifiedDiff(result.diff ?? ''); + if (diffSummary.files.length === 0) { + return 'No code changes detected.'; + } + + const fileWord = diffSummary.files.length === 1 ? 'file' : 'files'; + const toolWord = result.stats.toolCalls === 1 ? 'tool call' : 'tool calls'; + return `Changed ${diffSummary.files.length} ${fileWord} with ${result.stats.toolCalls} ${toolWord} (${formatLineStats(diffSummary.additions, diffSummary.deletions)}).`; +} + +export function formatLineStats(additions: number, deletions: number): string { + if (additions === 0 && deletions === 0) { + return 'no line changes'; + } + return `+${additions}/-${deletions}`; +} + +function normalizeDiffPath(path: string): string { + const trimmed = path.trim(); + if (trimmed === '/dev/null') { + return trimmed; + } + return trimmed.replace(/^[ab]\//, ''); +} + +function parseDiffGitPath(line: string): string | undefined { + const prefix = 'diff --git a/'; + const separator = ' b/'; + if (!line.startsWith(prefix)) { + return undefined; + } + + const separatorIndex = line.lastIndexOf(separator); + if (separatorIndex < prefix.length) { + return undefined; + } + + const pathStart = separatorIndex + separator.length; + if (pathStart >= line.length) { + return undefined; + } + + return line.slice(pathStart); +} diff --git a/packages/core/src/agents/arena/index.ts b/packages/core/src/agents/arena/index.ts index e744250c7..d5e245a64 100644 --- a/packages/core/src/agents/arena/index.ts +++ b/packages/core/src/agents/arena/index.ts @@ -7,6 +7,7 @@ // Arena-specific exports export * from './types.js'; export * from './arena-events.js'; +export * from './diff-summary.js'; export * from './ArenaManager.js'; export * from './ArenaAgentClient.js'; diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts index 5b9a9ecab..b53cf4c04 100644 --- a/packages/core/src/agents/arena/types.ts +++ b/packages/core/src/agents/arena/types.ts @@ -92,6 +92,30 @@ export interface ArenaAgentStats { failedToolCalls: number; } +/** + * Per-file change counts parsed from an agent's unified diff. + */ +export interface ArenaFileChangeSummary { + /** Repository-relative file path */ + path: string; + /** Added lines in this file */ + additions: number; + /** Removed lines in this file */ + deletions: number; +} + +/** + * Aggregate change counts parsed from an agent's unified diff. + */ +export interface ArenaDiffSummary { + /** Files changed by this agent */ + files: ArenaFileChangeSummary[]; + /** Total added lines */ + additions: number; + /** Total removed lines */ + deletions: number; +} + /** * Result from a single Arena agent. */ @@ -112,8 +136,12 @@ export interface ArenaAgentResult { stats: ArenaAgentStats; /** Git diff of changes made */ diff?: string; + /** Parsed summary of the git diff */ + diffSummary?: ArenaDiffSummary; /** Files modified by this agent */ modifiedFiles?: string[]; + /** High-level implementation approach summary */ + approachSummary?: string; /** Start timestamp */ startedAt: number; /** End timestamp */ diff --git a/packages/core/src/agents/backends/InProcessBackend.test.ts b/packages/core/src/agents/backends/InProcessBackend.test.ts index ba96f8313..fb515a5d5 100644 --- a/packages/core/src/agents/backends/InProcessBackend.test.ts +++ b/packages/core/src/agents/backends/InProcessBackend.test.ts @@ -518,9 +518,10 @@ describe('InProcessBackend', () => { expect(agentContext.getContentGenerator()).toBe(agentGenerator); expect(agentContext.getAuthType()).toBe('anthropic'); + expect(backend.getAgentContentGenerator('agent-1')).toBe(agentGenerator); }); - it('should not create per-agent ContentGenerator without authOverrides', async () => { + it('should expose inherited ContentGenerator without authOverrides', async () => { const mockCreate = createContentGenerator as ReturnType<typeof vi.fn>; mockCreate.mockClear(); @@ -528,6 +529,9 @@ describe('InProcessBackend', () => { await backend.spawnAgent(createSpawnConfig('agent-1')); expect(mockCreate).not.toHaveBeenCalled(); + expect(backend.getAgentContentGenerator('agent-1')).toBe( + mockContentGenerator, + ); }); it('should fall back to parent ContentGenerator if per-agent creation fails', async () => { @@ -553,6 +557,7 @@ describe('InProcessBackend', () => { // Falls back to parent's content generator expect(agentContext.getContentGenerator()).toBe(mockContentGenerator); + expect(backend.getAgentContentGenerator('agent-1')).toBeUndefined(); }); it('should give different agents different ContentGenerators', async () => { diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 0305ed59d..df434cf8c 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -51,6 +51,7 @@ export class InProcessBackend implements Backend { private readonly runtimeContext: Config; private readonly agents = new Map<string, AgentInteractive>(); + private readonly agentContentGenerators = new Map<string, ContentGenerator>(); private readonly agentRegistries: ToolRegistry[] = []; private readonly agentOrder: string[] = []; private activeAgentId: string | null = null; @@ -88,12 +89,19 @@ export class InProcessBackend implements Backend { // Build a per-agent runtime context with isolated working directory, // target directory, workspace context, tool registry, and (optionally) // a dedicated ContentGenerator for per-agent auth isolation. - const agentContext = await createPerAgentConfig( + const perAgent = await createPerAgentConfig( this.runtimeContext, config.cwd, inProcessConfig.runtimeConfig.modelConfig.model, inProcessConfig.authOverrides, ); + const agentContext = perAgent.config; + if (perAgent.contentGenerator) { + this.agentContentGenerators.set( + config.agentId, + perAgent.contentGenerator, + ); + } this.agentRegistries.push(agentContext.getToolRegistry()); @@ -200,6 +208,7 @@ export class InProcessBackend implements Backend { this.agentRegistries.length = 0; this.agents.clear(); + this.agentContentGenerators.clear(); this.agentOrder.length = 0; this.activeAgentId = null; debugLogger.info('InProcessBackend cleaned up'); @@ -309,6 +318,18 @@ export class InProcessBackend implements Backend { return this.agents.get(agentId); } + /** + * Get the ContentGenerator this agent can use for summary generation. + * If auth overrides created an isolated generator, this returns that + * generator. If no override was requested, this returns the inherited + * generator the agent already runs with. If override creation failed, this is + * undefined so callers can avoid sending agent data through a fallback + * provider. + */ + getAgentContentGenerator(agentId: string): ContentGenerator | undefined { + return this.agentContentGenerators.get(agentId); + } + // ─── Private ─────────────────────────────────────────────── private navigate(direction: 1 | -1): string | null { @@ -336,15 +357,17 @@ export class InProcessBackend implements Backend { * the agent Config * - `getContentGenerator()` / `getContentGeneratorConfig()` / `getAuthType()` * → per-agent ContentGenerator when `authOverrides` is provided + * - returned `contentGenerator` → the generator safe to use for summaries */ async function createPerAgentConfig( base: Config, cwd: string, modelId?: string, authOverrides?: InProcessSpawnConfig['authOverrides'], -): Promise<Config> { +): Promise<{ config: Config; contentGenerator?: ContentGenerator }> { // eslint-disable-next-line @typescript-eslint/no-explicit-any const override = Object.create(base) as any; + let dedicatedContentGenerator: ContentGenerator | undefined; override.getWorkingDir = () => cwd; override.getTargetDir = () => cwd; @@ -374,6 +397,7 @@ async function createPerAgentConfig( agentGeneratorConfig, override as Config, ); + dedicatedContentGenerator = agentGenerator; override.getContentGenerator = (): ContentGenerator => agentGenerator; override.getContentGeneratorConfig = (): ContentGeneratorConfig => agentGeneratorConfig; @@ -392,5 +416,10 @@ async function createPerAgentConfig( } } - return override as Config; + return { + config: override as Config, + contentGenerator: + dedicatedContentGenerator ?? + (authOverrides?.authType ? undefined : base.getContentGenerator()), + }; } diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index a709e844a..1287de97c 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -17,6 +17,7 @@ */ import { reportError } from '../../utils/errorReporting.js'; +import { subagentNameContext } from '../../utils/subagentNameContext.js'; import type { Config } from '../../config/config.js'; import { type ToolCallRequestInfo } from '../../core/turn.js'; import { @@ -31,6 +32,7 @@ import type { ToolResultDisplay, } from '../../tools/tools.js'; import { getInitialChatHistory } from '../../utils/environmentContext.js'; +import { FinishReason } from '@google/genai'; import type { Content, Part, @@ -418,6 +420,28 @@ export class AgentCore { toolsList: FunctionDeclaration[], abortController: AbortController, options?: ReasoningLoopOptions, + ): Promise<ReasoningLoopResult> { + // Tag every API call emitted from this loop with the owning subagent's + // name so the `/stats` panel can attribute tokens/requests to the + // originating subagent. The store is read inside + // `LoggingContentGenerator` via `subagentNameContext.getStore()`. + return subagentNameContext.run(this.name, () => + this._runReasoningLoopInner( + chat, + initialMessages, + toolsList, + abortController, + options, + ), + ); + } + + private async _runReasoningLoopInner( + chat: GeminiChat, + initialMessages: Content[], + toolsList: FunctionDeclaration[], + abortController: AbortController, + options?: ReasoningLoopOptions, ): Promise<ReasoningLoopResult> { const startTime = options?.startTimeMs ?? Date.now(); let currentMessages = initialMessages; @@ -485,6 +509,7 @@ export class AgentCore { let lastUsage: GenerateContentResponseUsageMetadata | undefined = undefined; let currentResponseId: string | undefined = undefined; + let wasOutputTruncated = false; for await (const streamEvent of responseStream) { if (roundAbortController.signal.aborted) { @@ -496,8 +521,16 @@ export class AgentCore { }; } - // Handle retry events + // Handle retry events — reset all per-attempt state so a successful + // retry does not inherit stale data (e.g. wasOutputTruncated) from a + // previous attempt that may have hit MAX_TOKENS. if (streamEvent.type === 'retry') { + functionCalls.length = 0; + roundText = ''; + roundThoughtText = ''; + lastUsage = undefined; + currentResponseId = undefined; + wasOutputTruncated = false; continue; } @@ -509,6 +542,9 @@ export class AgentCore { currentResponseId = resp.responseId; } if (resp.functionCalls) functionCalls.push(...resp.functionCalls); + if (resp.candidates?.[0]?.finishReason === FinishReason.MAX_TOKENS) { + wasOutputTruncated = true; + } const content = resp.candidates?.[0]?.content; const parts = content?.parts || []; for (const p of parts) { @@ -562,6 +598,7 @@ export class AgentCore { turnCounter, toolsList, currentResponseId, + wasOutputTruncated, ); const externalMsgs = options?.getExternalMessages?.() ?? []; @@ -653,6 +690,7 @@ export class AgentCore { currentRound: number, toolsList: FunctionDeclaration[], responseId?: string, + wasOutputTruncated = false, ): Promise<Content[]> { const toolResponseParts: Part[] = []; @@ -882,6 +920,7 @@ export class AgentCore { isClientInitiated: true, prompt_id: promptId, response_id: responseId, + wasOutputTruncated, }; const description = this.getToolDescription(toolName, args); diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index f377a12a3..16e806c9a 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -48,6 +48,7 @@ import type { ToolConfig, } from './agent-types.js'; import { AgentTerminateMode } from './agent-types.js'; +import { WriteFileTool } from '../../tools/write-file.js'; vi.mock('../../core/geminiChat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { @@ -1227,6 +1228,219 @@ describe('subagent.ts', () => { expect(readResult).toBeDefined(); expect(readResult!.success).toBe(true); }); + + it('should mark truncated subagent write_file calls as output-truncated errors', async () => { + const writeFileToolDef: FunctionDeclaration = { + name: WriteFileTool.Name, + description: 'Writes a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([writeFileToolDef]), + getTool: vi.fn().mockImplementation((name: string) => { + if (name === WriteFileTool.Name) { + return new WriteFileTool(config); + } + return undefined; + }), + }); + + const toolConfig: ToolConfig = { tools: [WriteFileTool.Name] }; + const toolResultEvents: AgentToolResultEvent[] = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as AgentToolResultEvent); + }); + + mockSendMessageStream.mockImplementation(async () => + (async function* () { + yield { + type: 'chunk', + value: { + functionCalls: [ + { + id: 'call_write', + name: WriteFileTool.Name, + args: { file_path: '/tmp/truncated.txt' }, + }, + ], + }, + }; + yield { + type: 'chunk', + value: { + candidates: [ + { + finishReason: 'MAX_TOKENS', + content: { parts: [] }, + }, + ], + }, + }; + yield { + type: 'chunk', + value: { + candidates: [ + { + content: { + parts: [{ text: 'done' }], + }, + }, + ], + }, + }; + })(), + ); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + const writeResult = toolResultEvents.find( + (event) => event.name === WriteFileTool.Name, + ); + expect(writeResult).toBeDefined(); + expect(writeResult!.success).toBe(false); + expect(writeResult!.error).toContain( + 'truncated due to max_tokens limit', + ); + expect(writeResult!.error).toContain( + 'rejected to prevent writing truncated content', + ); + expect(writeResult!.error).not.toContain( + "params must have required property 'content'", + ); + }); + + it('should NOT reject write_file when truncated attempt is followed by successful retry', async () => { + const writeFileToolDef: FunctionDeclaration = { + name: WriteFileTool.Name, + description: 'Writes a file', + parameters: { type: Type.OBJECT, properties: {} }, + }; + + const { config } = await createMockConfig({ + getFunctionDeclarationsFiltered: vi + .fn() + .mockReturnValue([writeFileToolDef]), + getTool: vi.fn().mockImplementation((name: string) => { + if (name === WriteFileTool.Name) { + return new WriteFileTool(config); + } + return undefined; + }), + }); + + const toolConfig: ToolConfig = { tools: [WriteFileTool.Name] }; + const toolResultEvents: AgentToolResultEvent[] = []; + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as AgentToolResultEvent); + }); + + // First call: truncated (MAX_TOKENS). Retry resets state, second call: + // complete write_file. The scheduler should see wasOutputTruncated=false + // for the retried response and allow the tool to proceed. + let callCount = 0; + mockSendMessageStream.mockImplementation(async () => { + callCount++; + if (callCount === 1) { + // First round: truncated response with incomplete write_file args + return (async function* () { + yield { + type: 'chunk', + value: { + functionCalls: [ + { + id: 'call_write_truncated', + name: WriteFileTool.Name, + args: { file_path: '/tmp/retry-test.txt' }, + }, + ], + }, + }; + yield { + type: 'retry', + }; + // After retry, complete response with all required args + yield { + type: 'chunk', + value: { + functionCalls: [ + { + id: 'call_write_complete', + name: WriteFileTool.Name, + args: { + file_path: '/tmp/retry-test.txt', + content: 'hello', + }, + }, + ], + }, + }; + yield { + type: 'chunk', + value: { + candidates: [ + { finishReason: 'STOP', content: { parts: [] } }, + ], + }, + }; + })(); + } + // Second round: plain text response to end the agent loop + return (async function* () { + yield { + type: 'chunk', + value: { + candidates: [ + { + finishReason: 'STOP', + content: { parts: [{ text: 'done' }] }, + }, + ], + }, + }; + })(); + }); + + const scope = await AgentHeadless.create( + 'test-agent', + config, + promptConfig, + defaultModelConfig, + defaultRunConfig, + toolConfig, + eventEmitter, + ); + + await scope.execute(new ContextState()); + + const writeResult = toolResultEvents.find( + (event) => event.name === WriteFileTool.Name, + ); + expect(writeResult).toBeDefined(); + // After retry the wasOutputTruncated flag must have been cleared, so + // the call should NOT be rejected with a truncation error — even if + // execution fails for unrelated reasons (e.g. mock filesystem). + expect(writeResult!.error).not.toContain( + 'truncated due to max_tokens limit', + ); + expect(writeResult!.error).not.toContain( + 'rejected to prevent writing truncated content', + ); + }); }); }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7fa1ccabd..360393f3a 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -558,6 +558,12 @@ export class Config { private extensionManager!: ExtensionManager; private skillManager: SkillManager | null = null; private permissionManager: PermissionManager | null = null; + private modelInvocableCommandsProvider: + | (() => ReadonlyArray<{ name: string; description: string }>) + | null = null; + private modelInvocableCommandsExecutor: + | ((name: string, args?: string) => Promise<string | null>) + | null = null; private fileSystemService: FileSystemService; private contentGeneratorConfig!: ContentGeneratorConfig; private contentGeneratorConfigSources: ContentGeneratorConfigSources = {}; @@ -1331,6 +1337,13 @@ export class Config { sessionId?: string, sessionData?: ResumedSessionData, ): string { + // Finalize the outgoing session before switching. + try { + this.chatRecordingService?.finalize(); + } catch { + // Best-effort — don't block session switch + } + this.sessionId = sessionId ?? randomUUID(); this.sessionData = sessionData; setDebugLogSession(this); @@ -1597,6 +1610,13 @@ export class Config { return; } try { + // Finalize the current session's metadata before cleanup. + try { + this.chatRecordingService?.finalize(); + } catch { + // Best-effort — don't block shutdown + } + this.skillManager?.stopWatching(); if (this.toolRegistry) { @@ -1691,22 +1711,19 @@ export class Config { return merged; } + getToolDiscoveryCommand(): string | undefined { + return this.toolDiscoveryCommand; + } + /** * Returns the pre-merged list of slash command names that should be hidden * from the CLI surface. Callers should treat this as a case-insensitive * denylist; `CommandService.create` handles the normalization. - * - * CLI callers (loadCliConfig) populate this from settings, the - * `--disabled-slash-commands` flag, and `QWEN_DISABLED_SLASH_COMMANDS`. */ getDisabledSlashCommands(): readonly string[] { return this.disabledSlashCommands; } - getToolDiscoveryCommand(): string | undefined { - return this.toolDiscoveryCommand; - } - getToolCallCommand(): string | undefined { return this.toolCallCommand; } @@ -2488,6 +2505,49 @@ export class Config { return this.skillManager; } + /** + * Registers a provider that returns model-invocable commands (e.g., bundled + * skills, user/project file commands, MCP prompts). Called by the CLI's + * CommandService after initialisation so that SkillTool can merge these into + * its tool description. + */ + setModelInvocableCommandsProvider( + provider: () => ReadonlyArray<{ name: string; description: string }>, + ): void { + this.modelInvocableCommandsProvider = provider; + } + + /** + * Returns the registered model-invocable commands provider, or null if none + * has been registered (e.g., in SDK mode). + */ + getModelInvocableCommandsProvider(): + | (() => ReadonlyArray<{ name: string; description: string }>) + | null { + return this.modelInvocableCommandsProvider; + } + + /** + * Registers an executor that can invoke a model-invocable command by name + * (e.g., MCP prompts). Returns the prompt content as a string, or null if + * the command cannot be found or executed. Called by the CLI layer. + */ + setModelInvocableCommandsExecutor( + executor: (name: string, args?: string) => Promise<string | null>, + ): void { + this.modelInvocableCommandsExecutor = executor; + } + + /** + * Returns the registered model-invocable commands executor, or null if none + * has been registered (e.g., in SDK mode). + */ + getModelInvocableCommandsExecutor(): + | ((name: string, args?: string) => Promise<string | null>) + | null { + return this.modelInvocableCommandsExecutor; + } + getPermissionManager(): PermissionManager | null { return this.permissionManager; } diff --git a/packages/core/src/constants/codingPlan.ts b/packages/core/src/constants/codingPlan.ts new file mode 100644 index 000000000..3593a5780 --- /dev/null +++ b/packages/core/src/constants/codingPlan.ts @@ -0,0 +1,309 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Coding Plan constants — shared between CLI and VSCode extension. + * Single source of truth for model templates, regions, and env keys. + */ + +import { createHash } from 'node:crypto'; +import type { ModelConfig } from '../models/types.js'; + +/** + * Coding plan regions + */ +export enum CodingPlanRegion { + CHINA = 'china', + GLOBAL = 'global', +} + +/** + * Coding plan template - array of model configurations + * When user provides an api-key, these configs will be cloned with envKey pointing to the stored api-key + */ +export type CodingPlanTemplate = ModelConfig[]; + +/** + * Environment variable key for storing the coding plan API key. + * Unified key for both regions since they are mutually exclusive. + */ +export const CODING_PLAN_ENV_KEY = 'BAILIAN_CODING_PLAN_API_KEY'; + +/** + * Computes the version hash for the coding plan template. + * Uses SHA256 of the JSON-serialized template for deterministic versioning. + * @param template - The template to compute version for + * @returns Hexadecimal string representing the template version + */ +export function computeCodingPlanVersion(template: CodingPlanTemplate): string { + const templateString = JSON.stringify(template); + return createHash('sha256').update(templateString).digest('hex'); +} + +/** + * Generate the complete coding plan template for a specific region. + * China region uses legacy description to maintain backward compatibility. + * Global region uses new description with region indicator. + * @param region - The region to generate template for + * @returns Complete model configuration array for the region + */ +export function generateCodingPlanTemplate( + region: CodingPlanRegion, +): CodingPlanTemplate { + if (region === CodingPlanRegion.CHINA) { + return [ + { + id: 'qwen3.5-plus', + name: '[ModelStudio Coding Plan] qwen3.5-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3.6-plus', + name: '[ModelStudio Coding Plan] qwen3.6-plus', + description: 'Currently available to Pro subscribers only.', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'glm-5', + name: '[ModelStudio Coding Plan] glm-5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + { + id: 'kimi-k2.5', + name: '[ModelStudio Coding Plan] kimi-k2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[ModelStudio Coding Plan] MiniMax-M2.5', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 196608, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[ModelStudio Coding Plan] qwen3-coder-plus', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[ModelStudio Coding Plan] qwen3-coder-next', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[ModelStudio Coding Plan] qwen3-max-2026-01-23', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[ModelStudio Coding Plan] glm-4.7', + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + ]; + } + + // Global region + return [ + { + id: 'qwen3.5-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3.5-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3.6-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3.6-plus', + description: 'Currently available to Pro subscribers only.', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-plus', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-plus', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 1000000, + }, + }, + { + id: 'qwen3-coder-next', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-coder-next', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + contextWindowSize: 262144, + }, + }, + { + id: 'qwen3-max-2026-01-23', + name: '[ModelStudio Coding Plan for Global/Intl] qwen3-max-2026-01-23', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + { + id: 'glm-4.7', + name: '[ModelStudio Coding Plan for Global/Intl] glm-4.7', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + { + id: 'glm-5', + name: '[ModelStudio Coding Plan for Global/Intl] glm-5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 202752, + }, + }, + { + id: 'MiniMax-M2.5', + name: '[ModelStudio Coding Plan for Global/Intl] MiniMax-M2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 196608, + }, + }, + { + id: 'kimi-k2.5', + name: '[ModelStudio Coding Plan for Global/Intl] kimi-k2.5', + baseUrl: 'https://coding-intl.dashscope.aliyuncs.com/v1', + envKey: CODING_PLAN_ENV_KEY, + generationConfig: { + extra_body: { enable_thinking: true }, + contextWindowSize: 262144, + }, + }, + ]; +} + +/** + * Get the complete configuration for a specific region. + * @param region - The region to use + * @returns Object containing template, baseUrl, and version + */ +export function getCodingPlanConfig(region: CodingPlanRegion) { + const template = generateCodingPlanTemplate(region); + const baseUrl = + region === CodingPlanRegion.CHINA + ? 'https://coding.dashscope.aliyuncs.com/v1' + : 'https://coding-intl.dashscope.aliyuncs.com/v1'; + return { + template, + baseUrl, + version: computeCodingPlanVersion(template), + }; +} + +/** + * Get all unique base URLs for coding plan (used for filtering/config detection). + * @returns Array of base URLs + */ +export function getCodingPlanBaseUrls(): string[] { + return [ + 'https://coding.dashscope.aliyuncs.com/v1', + 'https://coding-intl.dashscope.aliyuncs.com/v1', + ]; +} + +/** + * Check if a config belongs to Coding Plan (any region). + * Returns the region if matched, or false if not a Coding Plan config. + * @param baseUrl - The baseUrl to check + * @param envKey - The envKey to check + * @returns The region if matched, false otherwise + */ +export function isCodingPlanConfig( + baseUrl: string | undefined, + envKey: string | undefined, +): CodingPlanRegion | false { + if (!baseUrl || !envKey) return false; + if (envKey !== CODING_PLAN_ENV_KEY) return false; + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + return false; +} + +/** + * Get region from baseUrl. + * @param baseUrl - The baseUrl to check + * @returns The region if matched, null otherwise + */ +export function getRegionFromBaseUrl( + baseUrl: string | undefined, +): CodingPlanRegion | null { + if (!baseUrl) return null; + if (baseUrl === 'https://coding.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.CHINA; + } + if (baseUrl === 'https://coding-intl.dashscope.aliyuncs.com/v1') { + return CodingPlanRegion.GLOBAL; + } + return null; +} diff --git a/packages/core/src/core/baseLlmClient.test.ts b/packages/core/src/core/baseLlmClient.test.ts index df8d82cf9..a760a33ca 100644 --- a/packages/core/src/core/baseLlmClient.test.ts +++ b/packages/core/src/core/baseLlmClient.test.ts @@ -39,6 +39,7 @@ vi.mock('../utils/generateContentResponseUtilities.js', () => ({ vi.mock('../utils/retry.js', () => ({ retryWithBackoff: vi.fn(async (fn) => await fn()), + isUnattendedMode: vi.fn(() => false), })); const mockGenerateContent = vi.fn(); diff --git a/packages/core/src/core/baseLlmClient.ts b/packages/core/src/core/baseLlmClient.ts index 53df44fa5..a7ac0402d 100644 --- a/packages/core/src/core/baseLlmClient.ts +++ b/packages/core/src/core/baseLlmClient.ts @@ -17,7 +17,7 @@ import type { Config } from '../config/config.js'; import type { ContentGenerator } from './contentGenerator.js'; import { reportError } from '../utils/errorReporting.js'; import { getErrorMessage } from '../utils/errors.js'; -import { retryWithBackoff } from '../utils/retry.js'; +import { retryWithBackoff, isUnattendedMode } from '../utils/retry.js'; import { getFunctionCalls } from '../utils/generateContentResponseUtilities.js'; const DEFAULT_MAX_ATTEMPTS = 7; @@ -117,6 +117,13 @@ export class BaseLlmClient { const result = await retryWithBackoff(apiCall, { maxAttempts: maxAttempts ?? DEFAULT_MAX_ATTEMPTS, + persistentMode: isUnattendedMode(), + signal: abortSignal, + heartbeatFn: (info) => { + process.stderr.write( + `[qwen-code] Waiting for API capacity... attempt ${info.attempt}, retry in ${Math.ceil(info.remainingMs / 1000)}s\n`, + ); + }, }); const functionCalls = getFunctionCalls(result); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index c58b3db73..8c648f59d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -77,7 +77,7 @@ import { getErrorMessage } from '../utils/errors.js'; import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js'; import { flatMapTextParts } from '../utils/partUtils.js'; import { promptIdContext } from '../utils/promptIdContext.js'; -import { retryWithBackoff } from '../utils/retry.js'; +import { retryWithBackoff, isUnattendedMode } from '../utils/retry.js'; // Hook types and utilities import { @@ -1137,6 +1137,13 @@ export class GeminiClient { }; const result = await retryWithBackoff(apiCall, { authType: this.config.getContentGeneratorConfig()?.authType, + persistentMode: isUnattendedMode(), + signal: abortSignal, + heartbeatFn: (info) => { + process.stderr.write( + `[qwen-code] Waiting for API capacity... attempt ${info.attempt}, retry in ${Math.ceil(info.remainingMs / 1000)}s\n`, + ); + }, }); return result; } catch (error: unknown) { diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index d809193d7..7b1614236 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -92,6 +92,9 @@ export type ContentGeneratorConfig = { frequency_penalty?: number; temperature?: number; max_tokens?: number; + // Additional provider-specific keys pass through verbatim + // (e.g. `max_completion_tokens` for GPT-5 / o-series, `reasoning_effort`). + [key: string]: unknown; }; reasoning?: | false diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 1c2f9fa2a..1c917df12 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Mock } from 'vitest'; import type { + AnyDeclarativeTool, Config, ToolCallConfirmationDetails, ToolConfirmationPayload, @@ -43,6 +44,7 @@ import type { HookExecutionResponse } from '../confirmation-bus/types.js'; import { type NotificationType } from '../hooks/types.js'; import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { IdeClient } from '../ide/ide-client.js'; +import { WriteFileTool } from '../tools/write-file.js'; vi.mock('fs/promises', () => ({ writeFile: vi.fn(), @@ -1822,7 +1824,7 @@ describe('CoreToolScheduler request queueing', () => { describe('CoreToolScheduler truncated output protection', () => { function createTruncationTestScheduler( - tool: TestApprovalTool | MockTool, + tool: AnyDeclarativeTool, toolNames: string[], ) { const onAllToolCallsComplete = vi.fn(); @@ -1990,6 +1992,59 @@ describe('CoreToolScheduler truncated output protection', () => { // Non-Edit tools should still execute even when output was truncated expect(completedCalls[0].status).toBe('success'); }); + + it('should prefer truncation rejection over validation errors for truncated write_file calls', async () => { + const writeFileConfig = { + getProjectRoot: () => '/tmp', + getTargetDir: () => '/tmp', + getFileSystemService: () => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + }), + getDefaultFileEncoding: () => undefined, + setApprovalMode: vi.fn(), + } as unknown as Config; + const writeFileTool = new WriteFileTool(writeFileConfig); + const { scheduler, onAllToolCallsComplete } = createTruncationTestScheduler( + writeFileTool, + [WriteFileTool.Name], + ); + + await scheduler.schedule( + [ + { + callId: '1', + name: WriteFileTool.Name, + args: { file_path: '/tmp/test.txt' }, + isClientInitiated: false, + prompt_id: 'prompt-id-write-file-truncated', + wasOutputTruncated: true, + }, + ], + new AbortController().signal, + ); + + await vi.waitFor(() => { + expect(onAllToolCallsComplete).toHaveBeenCalled(); + }); + + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(1); + const completedCall = completedCalls[0]; + expect(completedCall.status).toBe('error'); + + if (completedCall.status === 'error') { + const errorMessage = completedCall.response.error?.message; + expect(errorMessage).toContain('truncated due to max_tokens limit'); + expect(errorMessage).toContain( + 'rejected to prevent writing truncated content', + ); + expect(errorMessage).not.toContain( + "params must have required property 'content'", + ); + } + }); }); describe('CoreToolScheduler Sequential Execution', () => { diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 4abe34970..09a6c4ccf 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -71,20 +71,22 @@ import { IdeClient } from '../ide/ide-client.js'; const TRUNCATION_PARAM_GUIDANCE = 'Note: Your previous response was truncated due to max_tokens limit, ' + - 'which likely caused incomplete tool call parameters. ' + + 'which caused incomplete tool call parameters. ' + 'Please retry the tool call with complete parameters. ' + 'If the content is too large for a single response, ' + - 'consider splitting it into smaller parts.'; + 'you MUST split it into smaller parts: ' + + 'first write_file with a skeleton/partial content, ' + + 'then use edit to add the remaining sections incrementally.'; const TRUNCATION_EDIT_REJECTION = 'Your previous response was truncated due to max_tokens limit, ' + - 'which likely produced incomplete file content. ' + + 'which produced incomplete file content. ' + 'The tool call has been rejected to prevent writing ' + 'truncated content to the file. ' + - 'Please retry the tool call with complete content. ' + - 'If the content is too large for a single response, ' + - 'consider splitting it into smaller parts ' + - '(e.g., write_file for initial content, then edit for additions).'; + 'You MUST split the content into smaller parts: ' + + 'first write_file with a skeleton/partial content, ' + + 'then use edit to add the remaining sections incrementally. ' + + 'Do NOT retry with the same large content.'; export type ValidatingToolCall = { status: 'validating'; @@ -888,6 +890,24 @@ export class CoreToolScheduler { continue; } + // Reject file-modifying calls when truncated to prevent + // writing incomplete content, even if params failed schema validation. + if (reqInfo.wasOutputTruncated && toolInstance.kind === Kind.Edit) { + const truncationError = new Error(TRUNCATION_EDIT_REJECTION); + newToolCalls.push({ + status: 'error', + request: reqInfo, + tool: toolInstance, + response: createErrorResponse( + reqInfo, + truncationError, + ToolErrorType.OUTPUT_TRUNCATED, + ), + durationMs: 0, + }); + continue; + } + const invocationOrError = this.buildInvocation( toolInstance, reqInfo.args, @@ -934,24 +954,6 @@ export class CoreToolScheduler { // Reset all validation retry counters for this tool since it passed validation this.clearRetryCountsForTool(reqInfo.name); - // Reject file-modifying calls when truncated to prevent - // writing incomplete content. - if (reqInfo.wasOutputTruncated && toolInstance.kind === Kind.Edit) { - const truncationError = new Error(TRUNCATION_EDIT_REJECTION); - newToolCalls.push({ - status: 'error', - request: reqInfo, - tool: toolInstance, - response: createErrorResponse( - reqInfo, - truncationError, - ToolErrorType.OUTPUT_TRUNCATED, - ), - durationMs: 0, - }); - continue; - } - newToolCalls.push({ status: 'validating', request: reqInfo, diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 03f63d167..39cfed3d5 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -2290,4 +2290,303 @@ describe('GeminiChat', async () => { expect(chat.getHistory()).toEqual([]); }); }); + + describe('output token recovery', () => { + function makeChunk( + parts: Array<{ text?: string; functionCall?: unknown }>, + finishReason?: string, + ): GenerateContentResponse { + return { + candidates: [ + { + content: { role: 'model', parts }, + ...(finishReason ? { finishReason } : {}), + }, + ], + } as unknown as GenerateContentResponse; + } + + function makeStream(chunks: GenerateContentResponse[]) { + return (async function* () { + for (const c of chunks) { + yield c; + } + })(); + } + + it('should enter recovery loop when escalated response is also truncated', async () => { + // Three streams: initial (MAX_TOKENS) → escalated (MAX_TOKENS) → + // recovery (STOP). + const streams = [ + makeStream([makeChunk([{ text: 'Hello' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: ' world' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: ' ending.' }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a long essay' }, + 'prompt-recovery', + ); + + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + const retries = events.filter((e) => e.type === StreamEventType.RETRY); + // One RETRY for escalation (isContinuation undefined/false), + // one for recovery (isContinuation true). + expect(retries.length).toBe(2); + expect(retries[0]!.type).toBe(StreamEventType.RETRY); + expect((retries[0] as { isContinuation?: boolean }).isContinuation).toBe( + undefined, + ); + expect((retries[1] as { isContinuation?: boolean }).isContinuation).toBe( + true, + ); + // API called 3 times: initial + escalation + recovery. + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 3, + ); + }); + + it('should skip recovery when truncated turn has a functionCall', async () => { + // Initial stream returns a functionCall + MAX_TOKENS. Escalated stream + // returns the same (functionCall + MAX_TOKENS). Recovery must NOT run + // because appending a user turn after functionCall is invalid. + const streams = [ + makeStream([ + makeChunk( + [ + { + functionCall: { name: 'write_file', args: { file_path: '/x' } }, + }, + ], + 'MAX_TOKENS', + ), + ]), + makeStream([ + makeChunk( + [ + { + functionCall: { name: 'write_file', args: { file_path: '/x' } }, + }, + ], + 'MAX_TOKENS', + ), + ]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'write a file' }, + 'prompt-recovery-skip', + ); + + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + // Only the escalation RETRY should fire; no continuation RETRY. + const continuations = events.filter( + (e) => + e.type === StreamEventType.RETRY && + (e as { isContinuation?: boolean }).isContinuation === true, + ); + expect(continuations.length).toBe(0); + + // API called twice: initial + escalation. No recovery calls. + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 2, + ); + + // History should end with the truncated model turn that has the + // functionCall. No dangling user recovery message. + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + expect(lastEntry.role).toBe('model'); + expect( + lastEntry.parts?.some((p) => 'functionCall' in p && p.functionCall), + ).toBe(true); + }); + + it('should cap recovery attempts at MAX_OUTPUT_RECOVERY_ATTEMPTS (3)', async () => { + // Every stream returns MAX_TOKENS with text (no functionCall). + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => makeStream([makeChunk([{ text: 'x' }], 'MAX_TOKENS')]), + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'infinite loop test' }, + 'prompt-recovery-cap', + ); + + // Consume + for await (const _ of stream) { + /* consume */ + } + + // 1 initial + 1 escalation + 3 recovery = 5 total. + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 5, + ); + }); + + it('should pop dangling recovery message and emit STOP chunk when recovery throws', async () => { + const streams = [ + makeStream([makeChunk([{ text: 'partial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: 'still partial' }], 'MAX_TOKENS')]), + // Recovery stream throws (simulate by yielding no chunks; this makes + // processStreamResponse reject with NO_FINISH_REASON). + (async function* () { + /* empty stream */ + })(), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'recovery fails' }, + 'prompt-recovery-fail', + ); + + const events: StreamEvent[] = []; + for await (const event of stream) { + events.push(event); + } + + // The last chunk should be the synthetic STOP chunk from the catch. + const chunkEvents = events.filter( + (e) => e.type === StreamEventType.CHUNK, + ); + const lastChunk = chunkEvents[chunkEvents.length - 1]!; + expect( + (lastChunk as { value: GenerateContentResponse }).value.candidates?.[0] + ?.finishReason, + ).toBe('STOP'); + + // History should NOT end with a dangling user recovery message, + // and roles must strictly alternate so providers don't reject the + // next turn with "consecutive same-role content" errors. + const history = chat.getHistory(); + for (let i = 1; i < history.length; i++) { + expect(history[i]!.role).not.toBe(history[i - 1]!.role); + } + const lastEntry = history[history.length - 1]!; + // Last entry should be the escalated model response, not a user + // recovery message, and must carry actual parts so the turn is + // not an empty placeholder. + expect(lastEntry.role).toBe('model'); + expect(lastEntry.parts!.length).toBeGreaterThan(0); + }); + + it('should stop recovery mid-loop when a later iteration emits functionCall', async () => { + // Covers the cross-iteration guard: iter 1 returns plain text (recovery + // proceeds), iter 2 returns a functionCall (recovery must break before + // iter 3 pushes another user turn after the functionCall). + const streams = [ + makeStream([makeChunk([{ text: 'initial' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: 'escalated' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: 'recovery 1 text' }], 'MAX_TOKENS')]), + makeStream([ + makeChunk( + [ + { + functionCall: { name: 'write_file', args: { file_path: '/x' } }, + }, + ], + 'MAX_TOKENS', + ), + ]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'mixed recovery' }, + 'prompt-recovery-mixed', + ); + + for await (const _ of stream) { + /* consume */ + } + + // Should call: 1 initial + 1 escalation + 2 recovery (iter 1 text, + // iter 2 functionCall) = 4 total. The guard fires at the start of + // iter 3 before any further API call. + expect(mockContentGenerator.generateContentStream).toHaveBeenCalledTimes( + 4, + ); + + // History must end on the functionCall model turn (not a dangling + // recovery user turn). + const history = chat.getHistory(); + const lastEntry = history[history.length - 1]!; + expect(lastEntry.role).toBe('model'); + expect( + lastEntry.parts?.some((p) => 'functionCall' in p && p.functionCall), + ).toBe(true); + }); + + it('should coalesce successful recovery iterations into the preceding model turn', async () => { + // Two recovery iterations then a clean STOP. Without coalescing, the + // internal OUTPUT_RECOVERY_MESSAGE would persist as a real user turn + // and bias every later model call. + const streams = [ + makeStream([makeChunk([{ text: 'A' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: 'B' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: 'C' }], 'MAX_TOKENS')]), + makeStream([makeChunk([{ text: 'D' }], 'STOP')]), + ]; + let callIndex = 0; + vi.mocked(mockContentGenerator.generateContentStream).mockImplementation( + async () => streams[callIndex++]!, + ); + + const stream = await chat.sendMessageStream( + 'gemini-3-pro', + { message: 'essay' }, + 'prompt-recovery-coalesce', + ); + for await (const _ of stream) { + /* consume */ + } + + const history = chat.getHistory(); + // Exactly one user turn + one model turn — the recovery pairs should + // be folded back into the preceding model entry. + expect(history.length).toBe(2); + expect(history[0]!.role).toBe('user'); + expect(history[1]!.role).toBe('model'); + + // The control prompt must NOT appear anywhere in durable history. + const flattened = JSON.stringify(history); + expect(flattened).not.toContain('Resume directly'); + expect(flattened).not.toContain('Output token limit hit'); + + // All escalation + recovery content must be preserved in the merged + // model turn, in order (B escalation → C recovery-1 → D recovery-2). + const mergedText = (history[1]!.parts ?? []) + .map((p) => ('text' in p ? ((p as { text?: string }).text ?? '') : '')) + .join(''); + expect(mergedText).toBe('BCD'); + }); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 070acf9f2..ff76eb5c6 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -17,13 +17,13 @@ import type { GenerateContentResponseUsageMetadata, } from '@google/genai'; import { createUserContent, FinishReason } from '@google/genai'; -import { retryWithBackoff } from '../utils/retry.js'; +import { retryWithBackoff, isUnattendedMode } from '../utils/retry.js'; import { getErrorStatus } from '../utils/errors.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { parseAndFormatApiError } from '../utils/errorParsing.js'; import { isRateLimitError, type RetryInfo } from '../utils/rateLimit.js'; import type { Config } from '../config/config.js'; -import { ESCALATED_MAX_TOKENS } from './tokenLimits.js'; +import { ESCALATED_MAX_TOKENS, tokenLimit } from './tokenLimits.js'; import { hasCycleInSchema } from '../tools/tools.js'; import type { StructuredError } from './turn.js'; import { @@ -49,7 +49,14 @@ export enum StreamEventType { export type StreamEvent = | { type: StreamEventType.CHUNK; value: GenerateContentResponse } - | { type: StreamEventType.RETRY; retryInfo?: RetryInfo }; + | { + type: StreamEventType.RETRY; + retryInfo?: RetryInfo; + /** When true, the retry is a continuation (recovery) rather than a + * fresh restart (escalation). The UI should keep the accumulated text + * buffer so the continuation appends to it. */ + isContinuation?: boolean; + }; /** * Options for retrying due to invalid content from the model. @@ -76,6 +83,23 @@ const INVALID_STREAM_RETRY_CONFIG = { initialDelayMs: 2000, }; +/** + * Max recovery attempts when the escalated response is also truncated. + * Each attempt keeps the partial response in history and injects a recovery + * message so the model can continue from where it left off. + */ +const MAX_OUTPUT_RECOVERY_ATTEMPTS = 3; + +/** + * Recovery message injected as a user turn when the model's output is + * truncated even after token escalation. Instructs the model to resume + * without repeating itself and to break remaining work into smaller steps. + */ +const OUTPUT_RECOVERY_MESSAGE = + 'Output token limit hit. Resume directly — no apology, no recap of what ' + + 'you were doing. Pick up mid-thought if that is where the cut happened. ' + + 'Break remaining work into smaller pieces.'; + /** * Options for retrying on rate-limit throttling errors returned as stream content. * Fixed 60s delay matches the DashScope per-minute quota window. @@ -497,7 +521,11 @@ export class GeminiChat { } // Max output tokens escalation: if the retry loop succeeded with - // the capped default (8K) but hit MAX_TOKENS, retry once at 64K. + // the capped default (8K) but hit MAX_TOKENS, retry once at the + // model's full output limit. This ensures models with large output + // limits (e.g., 128K for Claude Opus, GPT-5) are fully utilized, + // while using ESCALATED_MAX_TOKENS (64K) as a floor for unknown + // models. // Placed outside the retry loop so that any errors from the // escalated stream propagate directly (not caught by retry logic). if ( @@ -507,8 +535,12 @@ export class GeminiChat { !hasUserMaxTokensOverride ) { maxTokensEscalated = true; + const escalatedLimit = Math.max( + ESCALATED_MAX_TOKENS, + tokenLimit(model, 'output'), + ); debugLogger.info( - `Output truncated at capped default. Escalating to ${ESCALATED_MAX_TOKENS} tokens.`, + `Output truncated at capped default. Escalating to ${escalatedLimit} tokens.`, ); // Remove partial model response from history // (processStreamResponse already pushed it) @@ -525,9 +557,10 @@ export class GeminiChat { ...params, config: { ...params.config, - maxOutputTokens: ESCALATED_MAX_TOKENS, + maxOutputTokens: escalatedLimit, }, }; + let escalatedFinishReason: string | undefined; const escalatedStream = await self.makeApiCallAndProcessStream( model, requestContents, @@ -535,8 +568,112 @@ export class GeminiChat { prompt_id, ); for await (const chunk of escalatedStream) { + const fr = chunk.candidates?.[0]?.finishReason; + if (fr) escalatedFinishReason = fr; yield { type: StreamEventType.CHUNK, value: chunk }; } + + // Recovery: if the escalated response is also truncated, keep the + // partial response in history and inject a recovery message so the + // model can continue from where it left off. + let recoveryCount = 0; + let successfulRecoveries = 0; + while ( + escalatedFinishReason === FinishReason.MAX_TOKENS && + recoveryCount < MAX_OUTPUT_RECOVERY_ATTEMPTS + ) { + // Skip recovery when the truncated turn already contains a + // functionCall. Injecting a plain user message between a + // functionCall and its functionResponse produces an invalid API + // sequence that providers commonly reject. The existing layer-3 + // tool scheduler fallback handles these cases correctly. + const lastEntry = self.history[self.history.length - 1]; + const hasFunctionCall = + lastEntry?.role === 'model' && + lastEntry.parts?.some((p) => p.functionCall) === true; + if (hasFunctionCall) { + debugLogger.info( + 'Skipping recovery: truncated turn contains functionCall; ' + + 'deferring to tool scheduler fallback.', + ); + break; + } + + recoveryCount++; + debugLogger.info( + `Output still truncated after escalation. ` + + `Recovery attempt ${recoveryCount}/${MAX_OUTPUT_RECOVERY_ATTEMPTS}.`, + ); + // The partial model response is already in history + // (pushed by processStreamResponse). Push a recovery user + // message so the model sees its partial output and continues. + self.history.push( + createUserContent([{ text: OUTPUT_RECOVERY_MESSAGE }]), + ); + // Signal UI/turn to clear pending (incomplete) tool calls. + // isContinuation tells the UI to keep the text buffer so the + // model's continuation appends to the previous partial output. + yield { type: StreamEventType.RETRY, isContinuation: true }; + // Re-send with the updated history (includes partial + recovery) + const recoveryContents = self.getHistory(true); + escalatedFinishReason = undefined; + try { + const recoveryStream = await self.makeApiCallAndProcessStream( + model, + recoveryContents, + escalatedParams, + prompt_id, + ); + for await (const chunk of recoveryStream) { + const fr = chunk.candidates?.[0]?.finishReason; + if (fr) escalatedFinishReason = fr; + yield { type: StreamEventType.CHUNK, value: chunk }; + } + // Iteration fully succeeded: both the user recovery turn and + // the model continuation turn are now in history and can be + // coalesced back into the preceding model entry after the loop. + successfulRecoveries++; + } catch (recoveryError) { + // If a recovery attempt fails (e.g., empty response, network + // error), stop recovering and let the partial output stand. + // Pop the dangling recovery message to keep history valid. + if ( + self.history.length > 0 && + self.history[self.history.length - 1].role === 'user' + ) { + self.history.pop(); + } + debugLogger.warn( + `Recovery attempt ${recoveryCount} failed: ${recoveryError}`, + ); + // Emit a synthetic finish-reason chunk so the UI gets a + // terminal signal (Finished event) instead of a partial + // response with no end marker. Uses STOP because partial + // chunks from prior successful iterations are already in + // the transcript and represent the user-visible response. + yield { + type: StreamEventType.CHUNK, + value: { + candidates: [ + { + content: { role: 'model', parts: [] }, + finishReason: FinishReason.STOP, + }, + ], + } as unknown as GenerateContentResponse, + }; + break; + } + } + + // Coalesce completed recovery pairs back into the preceding model + // turn so the OUTPUT_RECOVERY_MESSAGE control prompt does not + // persist as a synthetic user turn in durable history. The user + // never sent that message, and leaving it in history would bias + // later turns and pollute compression / replay / export. + if (successfulRecoveries > 0) { + self.coalesceRecoveryPairs(successfulRecoveries); + } } if (lastError) { @@ -589,6 +726,13 @@ export class GeminiChat { return false; }, authType: this.config.getContentGeneratorConfig()?.authType, + persistentMode: isUnattendedMode(), + signal: params.config?.abortSignal, + heartbeatFn: (info) => { + process.stderr.write( + `[qwen-code] Waiting for API capacity... attempt ${info.attempt}, retry in ${Math.ceil(info.remainingMs / 1000)}s\n`, + ); + }, }); return this.processStreamResponse(model, streamResponse); @@ -964,6 +1108,44 @@ export class GeminiChat { ], }); } + + /** + * Merge `pairCount` trailing (user_recovery, model_continuation) pairs back + * into the model turn that precedes them. Used after the output-token + * recovery loop so the internal OUTPUT_RECOVERY_MESSAGE control prompt + * does not persist in durable history as if the user sent it. + * + * Expected tail shape per iteration (walking from the back): + * [..., precedingModel, userRecovery, modelContinuation] + * + * If any pair doesn't match that shape the method bails defensively + * rather than corrupting history. + */ + private coalesceRecoveryPairs(pairCount: number): void { + for (let i = 0; i < pairCount; i++) { + const len = this.history.length; + if (len < 3) return; + + const modelContinuation = this.history[len - 1]!; + const userRecovery = this.history[len - 2]!; + const precedingModel = this.history[len - 3]!; + + if ( + modelContinuation.role !== 'model' || + userRecovery.role !== 'user' || + precedingModel.role !== 'model' + ) { + return; + } + + precedingModel.parts = [ + ...(precedingModel.parts ?? []), + ...(modelContinuation.parts ?? []), + ]; + // Drop the (userRecovery, modelContinuation) pair. + this.history.splice(len - 2, 2); + } + } } /** Visible for Testing */ diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index 4464f5a7e..d6e2db4b9 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -32,6 +32,7 @@ import { logApiResponse, } from '../../telemetry/loggers.js'; import { isInternalPromptId } from '../../utils/internalPromptIds.js'; +import { subagentNameContext } from '../../utils/subagentNameContext.js'; import type { ContentGenerator, ContentGeneratorConfig, @@ -83,7 +84,12 @@ export class LoggingContentGenerator implements ContentGenerator { const requestText = JSON.stringify(contents); logApiRequest( this.config, - new ApiRequestEvent(model, promptId, requestText), + new ApiRequestEvent( + model, + promptId, + requestText, + subagentNameContext.getStore(), + ), ); } @@ -105,6 +111,7 @@ export class LoggingContentGenerator implements ContentGenerator { this.config.getAuthType(), usageMetadata, responseText, + subagentNameContext.getStore(), ), ); } @@ -135,6 +142,7 @@ export class LoggingContentGenerator implements ContentGenerator { errorMessage, errorType, statusCode: errorStatus, + subagentName: subagentNameContext.getStore(), }), ); } diff --git a/packages/core/src/core/openaiContentGenerator/converter.test.ts b/packages/core/src/core/openaiContentGenerator/converter.test.ts index 46e84e672..09b49e412 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.test.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { OpenAIContentConverter } from './converter.js'; -import type { StreamingToolCallParser } from './streamingToolCallParser.js'; +import { StreamingToolCallParser } from './streamingToolCallParser.js'; import { Type, FinishReason, @@ -31,57 +31,170 @@ describe('OpenAIContentConverter', () => { }); }); - describe('resetStreamingToolCalls', () => { - it('should clear streaming tool calls accumulator', () => { - // Access private field for testing - const parser = ( - converter as unknown as { - streamingToolCallParser: StreamingToolCallParser; - } - ).streamingToolCallParser; + describe('createStreamContext', () => { + it('returns a fresh context with its own StreamingToolCallParser', () => { + const ctx1 = converter.createStreamContext(); + const ctx2 = converter.createStreamContext(); - // Add some test data to the parser - parser.addChunk(0, '{"arg": "value"}', 'test-id', 'test-function'); - parser.addChunk(1, '{"arg2": "value2"}', 'test-id-2', 'test-function-2'); - - // Verify data is present - expect(parser.getBuffer(0)).toBe('{"arg": "value"}'); - expect(parser.getBuffer(1)).toBe('{"arg2": "value2"}'); - - // Call reset method - converter.resetStreamingToolCalls(); - - // Verify data is cleared - expect(parser.getBuffer(0)).toBe(''); - expect(parser.getBuffer(1)).toBe(''); + expect(ctx1.toolCallParser).toBeInstanceOf(StreamingToolCallParser); + expect(ctx2.toolCallParser).toBeInstanceOf(StreamingToolCallParser); + expect(ctx1.toolCallParser).not.toBe(ctx2.toolCallParser); }); - it('should be safe to call multiple times', () => { - // Call reset multiple times - converter.resetStreamingToolCalls(); - converter.resetStreamingToolCalls(); - converter.resetStreamingToolCalls(); + it('isolates two contexts so writes to one do not leak into the other', () => { + // Regression for issue #3516: previously the parser lived on the + // Converter as an instance field, so two concurrent streams sharing + // the same Config.contentGenerator would overwrite each other's + // tool-call buffers. Per-stream contexts eliminate that contention. + const ctx1 = converter.createStreamContext(); + const ctx2 = converter.createStreamContext(); - // Should not throw any errors - const parser = ( - converter as unknown as { - streamingToolCallParser: StreamingToolCallParser; - } - ).streamingToolCallParser; - expect(parser.getBuffer(0)).toBe(''); + ctx1.toolCallParser.addChunk(0, '{"a":1}', 'call_A', 'fn_A'); + ctx2.toolCallParser.addChunk(0, '{"b":2}', 'call_B', 'fn_B'); + + expect(ctx1.toolCallParser.getBuffer(0)).toBe('{"a":1}'); + expect(ctx2.toolCallParser.getBuffer(0)).toBe('{"b":2}'); + expect(ctx1.toolCallParser.getToolCallMeta(0).id).toBe('call_A'); + expect(ctx2.toolCallParser.getToolCallMeta(0).id).toBe('call_B'); }); - it('should be safe to call on empty accumulator', () => { - // Call reset on empty accumulator - converter.resetStreamingToolCalls(); + it('demuxes interleaved chunks from two concurrent streams correctly (#3516)', () => { + // Real-world shape: two subagents share one Config (hence one + // Converter). Their OpenAI streams run concurrently; chunks arrive + // interleaved at the event loop. Under the pre-fix architecture + // this corrupted both tool calls; under per-stream contexts each + // stream's chunks stay in their own parser and close cleanly. + const streamA = converter.createStreamContext(); + const streamB = converter.createStreamContext(); - // Should not throw any errors - const parser = ( - converter as unknown as { - streamingToolCallParser: StreamingToolCallParser; - } - ).streamingToolCallParser; - expect(parser.getBuffer(0)).toBe(''); + const openerA = { + object: 'chat.completion.chunk', + id: 'A-open', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_A', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '{"file_path":"/a', + }, + }, + ], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + const openerB = { + ...openerA, + id: 'B-open', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_B', + type: 'function' as const, + function: { + name: 'read_file', + arguments: '{"file_path":"/b', + }, + }, + ], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + const contA = { + ...openerA, + id: 'A-cont', + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: 0, function: { arguments: '/x.ts"}' } }], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + const contB = { + ...openerB, + id: 'B-cont', + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: 0, function: { arguments: '/y.ts"}' } }], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; + + const finisher = (id: string) => + ({ + object: 'chat.completion.chunk', + id, + created: 2, + model: 'test', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + }) as unknown as OpenAI.Chat.ChatCompletionChunk; + + // Interleave the two streams. Pre-fix this produced corrupt JSON + // because every chunk fed the same shared parser. + converter.convertOpenAIChunkToGemini(openerA, streamA); + converter.convertOpenAIChunkToGemini(openerB, streamB); + converter.convertOpenAIChunkToGemini(contA, streamA); + converter.convertOpenAIChunkToGemini(contB, streamB); + + const resultA = converter.convertOpenAIChunkToGemini( + finisher('A-finish'), + streamA, + ); + const resultB = converter.convertOpenAIChunkToGemini( + finisher('B-finish'), + streamB, + ); + + const fnA = resultA.candidates?.[0]?.content?.parts?.find( + (p: Part) => p.functionCall, + )?.functionCall; + const fnB = resultB.candidates?.[0]?.content?.parts?.find( + (p: Part) => p.functionCall, + )?.functionCall; + + expect(fnA?.name).toBe('read_file'); + expect(fnA?.args).toEqual({ file_path: '/a/x.ts' }); + expect(fnA?.id).toBe('call_A'); + + expect(fnB?.name).toBe('read_file'); + expect(fnB?.args).toEqual({ file_path: '/b/y.ts' }); + expect(fnB?.id).toBe('call_B'); }); }); @@ -867,6 +980,111 @@ describe('OpenAIContentConverter', () => { content: '', }); }); + + describe('assistant message with reasoning-only content (issue #3421)', () => { + /** + * Regression tests for https://github.com/QwenLM/qwen-code/issues/3421 + * + * When a model (e.g. Ollama qwen3.5:9b) returns a response that contains + * reasoning content but an empty text body, the converted assistant message + * must use content: "" instead of content: null. + * Some OpenAI-compatible providers reject content: null with HTTP 400 when + * reasoning_content is also present. + */ + it('should use empty string instead of null for content when assistant has only reasoning parts', () => { + const request: GenerateContentParameters = { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Think about this.' }] }, + { + // Assistant turn that only produced a thought, no visible text + role: 'model', + parts: [{ text: 'I reasoned about it.', thought: true }], + }, + { role: 'user', parts: [{ text: 'What did you conclude?' }] }, + ], + }; + + const messages = converter.convertGeminiRequestToOpenAI(request); + + const assistantMsg = messages.find((m) => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + // Must NOT be null – Ollama and other providers reject null content + // when reasoning_content is present (HTTP 400). + expect((assistantMsg as { content: unknown }).content).toBe(''); + // reasoning_content should still be preserved + expect( + (assistantMsg as { reasoning_content?: string }).reasoning_content, + ).toBe('I reasoned about it.'); + }); + + it('should keep content null when assistant has only tool_calls and no reasoning', () => { + const request: GenerateContentParameters = { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Call the tool.' }] }, + { + role: 'model', + parts: [ + { + functionCall: { + id: 'call_1', + name: 'some_tool', + args: {}, + }, + }, + ], + }, + { + role: 'user', + parts: [ + { + functionResponse: { + id: 'call_1', + name: 'some_tool', + response: { output: 'done' }, + }, + }, + ], + }, + ], + }; + + const messages = converter.convertGeminiRequestToOpenAI(request); + + const assistantMsg = messages.find((m) => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + // Tool-call-only messages follow the OpenAI spec: content should be null + expect((assistantMsg as { content: unknown }).content).toBeNull(); + }); + + it('should use actual text content when assistant has both reasoning and text', () => { + const request: GenerateContentParameters = { + model: 'models/test', + contents: [ + { role: 'user', parts: [{ text: 'Explain.' }] }, + { + role: 'model', + parts: [ + { text: 'My hidden reasoning.', thought: true }, + { text: 'Here is my answer.' }, + ], + }, + ], + }; + + const messages = converter.convertGeminiRequestToOpenAI(request); + + const assistantMsg = messages.find((m) => m.role === 'assistant'); + expect(assistantMsg).toBeDefined(); + expect((assistantMsg as { content: unknown }).content).toBe( + 'Here is my answer.', + ); + expect( + (assistantMsg as { reasoning_content?: string }).reasoning_content, + ).toBe('My hidden reasoning.'); + }); + }); }); describe('MCP multi-part tool results (issue #1520)', () => { @@ -1088,23 +1306,26 @@ describe('OpenAIContentConverter', () => { }); it('should convert streaming reasoning_content delta to a thought part', () => { - const chunk = converter.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'chunk-1', - created: 456, - choices: [ - { - index: 0, - delta: { - content: 'visible text', - reasoning_content: 'thinking...', + const chunk = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'chunk-1', + created: 456, + choices: [ + { + index: 0, + delta: { + content: 'visible text', + reasoning_content: 'thinking...', + }, + finish_reason: 'stop', + logprobs: null, }, - finish_reason: 'stop', - logprobs: null, - }, - ], - model: 'gpt-test', - } as unknown as OpenAI.Chat.ChatCompletionChunk); + ], + model: 'gpt-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk, + converter.createStreamContext(), + ); const parts = chunk.candidates?.[0]?.content?.parts; expect(parts?.[0]).toEqual( @@ -1116,23 +1337,26 @@ describe('OpenAIContentConverter', () => { }); it('should convert streaming reasoning delta to a thought part', () => { - const chunk = converter.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'chunk-1b', - created: 456, - choices: [ - { - index: 0, - delta: { - content: 'visible text', - reasoning: 'thinking...', + const chunk = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'chunk-1b', + created: 456, + choices: [ + { + index: 0, + delta: { + content: 'visible text', + reasoning: 'thinking...', + }, + finish_reason: 'stop', + logprobs: null, }, - finish_reason: 'stop', - logprobs: null, - }, - ], - model: 'gpt-test', - } as unknown as OpenAI.Chat.ChatCompletionChunk); + ], + model: 'gpt-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk, + converter.createStreamContext(), + ); const parts = chunk.candidates?.[0]?.content?.parts; expect(parts?.[0]).toEqual( @@ -1144,21 +1368,24 @@ describe('OpenAIContentConverter', () => { }); it('should not throw when streaming chunk has no delta', () => { - const chunk = converter.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'chunk-2', - created: 456, - choices: [ - { - index: 0, - // Some OpenAI-compatible providers may omit delta entirely. - delta: undefined, - finish_reason: null, - logprobs: null, - }, - ], - model: 'gpt-test', - } as unknown as OpenAI.Chat.ChatCompletionChunk); + const chunk = converter.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'chunk-2', + created: 456, + choices: [ + { + index: 0, + // Some OpenAI-compatible providers may omit delta entirely. + delta: undefined, + finish_reason: null, + logprobs: null, + }, + ], + model: 'gpt-test', + } as unknown as OpenAI.Chat.ChatCompletionChunk, + converter.createStreamContext(), + ); const parts = chunk.candidates?.[0]?.content?.parts; expect(parts).toEqual([]); @@ -2004,51 +2231,60 @@ describe('Truncated tool call detection in streaming', () => { }>, finishReason: string, ) { + // One stream-local context covers every chunk of this simulated stream. + const ctx = conv.createStreamContext(); + // Feed argument chunks (no finish_reason yet) for (const tc of toolCallChunks) { - conv.convertOpenAIChunkToGemini({ + conv.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'chunk-stream', + created: 100, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: tc.index, + id: tc.id, + type: 'function' as const, + function: { + name: tc.name, + arguments: tc.arguments, + }, + }, + ], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); + } + + // Final chunk with finish_reason + return conv.convertOpenAIChunkToGemini( + { object: 'chat.completion.chunk', - id: 'chunk-stream', - created: 100, + id: 'chunk-final', + created: 101, model: 'test-model', choices: [ { index: 0, - delta: { - tool_calls: [ - { - index: tc.index, - id: tc.id, - type: 'function' as const, - function: { - name: tc.name, - arguments: tc.arguments, - }, - }, - ], - }, - finish_reason: null, + delta: {}, + finish_reason: finishReason, logprobs: null, }, ], - } as unknown as OpenAI.Chat.ChatCompletionChunk); - } - - // Final chunk with finish_reason - return conv.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'chunk-final', - created: 101, - model: 'test-model', - choices: [ - { - index: 0, - delta: {}, - finish_reason: finishReason, - logprobs: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk); + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); } it('should override finishReason to MAX_TOKENS when tool call JSON is truncated and provider reports "stop"', () => { @@ -2149,70 +2385,80 @@ describe('Truncated tool call detection in streaming', () => { it('should detect truncation with multi-chunk streaming arguments', () => { // Feed arguments in multiple small chunks like real streaming const conv = new OpenAIContentConverter('test-model'); + const ctx = conv.createStreamContext(); // Chunk 1: start of JSON with tool metadata - conv.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'c1', - created: 100, - model: 'test-model', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - id: 'call_1', - type: 'function' as const, - function: { name: 'write_file', arguments: '{"file_' }, - }, - ], + conv.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'c1', + created: 100, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + type: 'function' as const, + function: { name: 'write_file', arguments: '{"file_' }, + }, + ], + }, + finish_reason: null, + logprobs: null, }, - finish_reason: null, - logprobs: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk); + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); // Chunk 2: more arguments - conv.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'c2', - created: 100, - model: 'test-model', - choices: [ - { - index: 0, - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: 'path": "/tmp/f.txt", "conten' }, - }, - ], + conv.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'c2', + created: 100, + model: 'test-model', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: 'path": "/tmp/f.txt", "conten' }, + }, + ], + }, + finish_reason: null, + logprobs: null, }, - finish_reason: null, - logprobs: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk); + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); // Final chunk: finish_reason "stop" but JSON is still incomplete - const result = conv.convertOpenAIChunkToGemini({ - object: 'chat.completion.chunk', - id: 'c3', - created: 101, - model: 'test-model', - choices: [ - { - index: 0, - delta: {}, - finish_reason: 'stop', - logprobs: null, - }, - ], - } as unknown as OpenAI.Chat.ChatCompletionChunk); + const result = conv.convertOpenAIChunkToGemini( + { + object: 'chat.completion.chunk', + id: 'c3', + created: 101, + model: 'test-model', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk, + ctx, + ); expect(result.candidates?.[0]?.finishReason).toBe(FinishReason.MAX_TOKENS); }); diff --git a/packages/core/src/core/openaiContentGenerator/converter.ts b/packages/core/src/core/openaiContentGenerator/converter.ts index 91d0b31fb..dcab2c449 100644 --- a/packages/core/src/core/openaiContentGenerator/converter.ts +++ b/packages/core/src/core/openaiContentGenerator/converter.ts @@ -90,6 +90,17 @@ type OpenAIContentPart = | OpenAIContentPartVideoUrl | OpenAIContentPartFile; +/** + * Per-stream state for tool-call parsing. Created by + * `OpenAIContentConverter.createStreamContext()` at the start of each + * streaming response and passed into every `convertOpenAIChunkToGemini` + * call on that stream, so concurrent streams (parallel subagents, fork + * children, …) never share parser state. + */ +export interface ConverterStreamContext { + toolCallParser: StreamingToolCallParser; +} + /** * Converter class for transforming data between Gemini and OpenAI formats */ @@ -97,8 +108,6 @@ export class OpenAIContentConverter { private model: string; private schemaCompliance: SchemaComplianceMode; private modalities: InputModalities; - private streamingToolCallParser: StreamingToolCallParser = - new StreamingToolCallParser(); constructor( model: string, @@ -126,12 +135,21 @@ export class OpenAIContentConverter { } /** - * Reset streaming tool calls parser for new stream processing - * This should be called at the beginning of each stream to prevent - * data pollution from previous incomplete streams + * Create fresh per-stream state for processing one OpenAI streaming + * response. The returned context is passed into every + * `convertOpenAIChunkToGemini` call for that stream, then discarded. + * + * Previously the tool-call parser lived on the Converter instance and + * was shared by every caller of the singleton `Config.contentGenerator`. + * Concurrent streams (e.g. two subagents running in parallel after + * PR #3463) raced on that shared state: each stream's stream-start + * `reset()` wiped the other's partial tool-call buffers, chunks from + * different streams landed at the same `index=0` bucket, and + * `getCompletedToolCalls()` returned interleaved corrupt JSON that + * surfaced upstream as `NO_RESPONSE_TEXT` (issue #3516). */ - resetStreamingToolCalls(): void { - this.streamingToolCallParser.reset(); + createStreamContext(): ConverterStreamContext { + return { toolCallParser: new StreamingToolCallParser() }; } /** @@ -502,7 +520,12 @@ export class OpenAIContentConverter { .join(''); const assistantMessage: ExtendedChatCompletionAssistantMessageParam = { role: 'assistant', - content: assistantTextContent || null, + // When there is reasoning content but no text, use "" instead of null. + // Some OpenAI-compatible providers (e.g. Ollama) reject content: null + // when reasoning_content is present, returning HTTP 400. + // For tool-call-only messages we keep null to stay spec-compliant. + content: + assistantTextContent || (reasoningParts.length > 0 ? '' : null), }; if (toolCalls.length > 0) { @@ -926,10 +949,17 @@ export class OpenAIContentConverter { } /** - * Convert OpenAI stream chunk to Gemini format + * Convert OpenAI stream chunk to Gemini format. + * + * `ctx` carries the tool-call parser for this stream. Callers MUST + * obtain it from `createStreamContext()` at the start of the stream + * and pass the same instance for every chunk of that stream. Concurrent + * streams MUST use distinct contexts or their tool-call buffers will + * interleave (issue #3516). */ convertOpenAIChunkToGemini( chunk: OpenAI.Chat.ChatCompletionChunk, + ctx: ConverterStreamContext, ): GenerateContentResponse { const choice = chunk.choices?.[0]; const response = new GenerateContentResponse(); @@ -951,14 +981,14 @@ export class OpenAIContentConverter { } } - // Handle tool calls using the streaming parser + // Handle tool calls using the stream-local parser if (choice.delta?.tool_calls) { for (const toolCall of choice.delta.tool_calls) { const index = toolCall.index ?? 0; // Process the tool call chunk through the streaming parser if (toolCall.function?.arguments) { - this.streamingToolCallParser.addChunk( + ctx.toolCallParser.addChunk( index, toolCall.function.arguments, toolCall.id, @@ -966,7 +996,7 @@ export class OpenAIContentConverter { ); } else { // Handle metadata-only chunks (id and/or name without arguments) - this.streamingToolCallParser.addChunk( + ctx.toolCallParser.addChunk( index, '', // Empty chunk for metadata-only updates toolCall.id, @@ -982,11 +1012,9 @@ export class OpenAIContentConverter { // Detect truncation the provider may not report correctly. // Some providers (e.g. DashScope/Qwen) send "stop" or "tool_calls" // even when output was cut off mid-JSON due to max_tokens. - toolCallsTruncated = - this.streamingToolCallParser.hasIncompleteToolCalls(); + toolCallsTruncated = ctx.toolCallParser.hasIncompleteToolCalls(); - const completedToolCalls = - this.streamingToolCallParser.getCompletedToolCalls(); + const completedToolCalls = ctx.toolCallParser.getCompletedToolCalls(); for (const toolCall of completedToolCalls) { if (toolCall.name) { @@ -1002,8 +1030,9 @@ export class OpenAIContentConverter { } } - // Clear the parser for the next stream - this.streamingToolCallParser.reset(); + // Parser is stream-local; it will be discarded with the + // ConverterStreamContext when the stream finishes. No manual + // reset needed. } // If tool call JSON was truncated, override to "length" so downstream diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.concurrent.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.concurrent.test.ts new file mode 100644 index 000000000..0c83ab47c --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/pipeline.concurrent.test.ts @@ -0,0 +1,350 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Integration test — deliberately does NOT mock `./converter.js`. Unlike + * `pipeline.test.ts` which stubs the converter, this suite drives the real + * `ContentGenerationPipeline` + real `OpenAIContentConverter` through two + * streams that interleave on the event loop, and asserts that tool-call + * arguments from one stream never bleed into the other's output. + * + * This is the regression test for issue #3516: before the per-stream + * parser scoping fix, the Converter singleton held a single + * `StreamingToolCallParser` instance. Two concurrent streams would share + * it; each stream's entry-time reset wiped the other's partial buffers, + * and chunks routed by `index: 0` interleaved into corrupt JSON. + * + * With the fix, `processStreamWithLogging` creates a fresh + * `ConverterStreamContext` at stream entry, so each concurrent generator + * has its own parser. This test would fail deterministically on pre-fix + * code because stream B's entry would wipe stream A's accumulator + * mid-flight, and A's finish chunk would emit zero function calls + * (`wasOutputTruncated`-style behavior). + */ + +import { describe, it, expect, vi } from 'vitest'; +import type OpenAI from 'openai'; +import type { GenerateContentParameters } from '@google/genai'; +import type { Part } from '@google/genai'; +import type { PipelineConfig } from './pipeline.js'; +import { ContentGenerationPipeline } from './pipeline.js'; +import type { Config } from '../../config/config.js'; +import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; +import type { OpenAICompatibleProvider } from './provider/index.js'; +import type { ErrorHandler } from './errorHandler.js'; + +type ChunkFactory = () => OpenAI.Chat.ChatCompletionChunk; + +/** + * Build a slow stream that yields to the event loop between chunks. + * Without the `setImmediate` await, a `for await` loop on one stream + * drains synchronously and `Promise.all` degenerates to serial execution, + * which hides the cross-stream bug. + */ +async function* interleavingStream( + chunks: ChunkFactory[], +): AsyncGenerator<OpenAI.Chat.ChatCompletionChunk> { + for (const make of chunks) { + // Yield control so the sibling stream can advance one step before we do. + await new Promise((r) => setImmediate(r)); + yield make(); + } +} + +function openerChunk( + id: string, + name: string, + firstArgs: string, +): OpenAI.Chat.ChatCompletionChunk { + return { + id: `${id}-opener`, + object: 'chat.completion.chunk', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id, + type: 'function', + function: { name, arguments: firstArgs }, + }, + ], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; +} + +function continuationChunk( + argsFragment: string, +): OpenAI.Chat.ChatCompletionChunk { + return { + id: 'cont', + object: 'chat.completion.chunk', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: { + tool_calls: [{ index: 0, function: { arguments: argsFragment } }], + }, + finish_reason: null, + logprobs: null, + }, + ], + } as unknown as OpenAI.Chat.ChatCompletionChunk; +} + +function finisherChunk(): OpenAI.Chat.ChatCompletionChunk { + return { + id: 'finish', + object: 'chat.completion.chunk', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } as unknown as OpenAI.Chat.ChatCompletionChunk; +} + +describe('ContentGenerationPipeline — concurrent streams (issue #3516)', () => { + function buildPipeline( + createStreamImpl: () => AsyncIterable<OpenAI.Chat.ChatCompletionChunk>, + ) { + const mockClient = { + chat: { + completions: { + // Each call returns a fresh stream. The real Pipeline will + // invoke this twice — once per concurrent executeStream call. + create: vi.fn().mockImplementation(() => createStreamImpl()), + }, + }, + } as unknown as OpenAI; + + const mockProvider: OpenAICompatibleProvider = { + buildClient: vi.fn().mockReturnValue(mockClient), + buildRequest: vi.fn().mockImplementation((req) => req), + buildHeaders: vi.fn().mockReturnValue({}), + getDefaultGenerationConfig: vi.fn().mockReturnValue({}), + } as unknown as OpenAICompatibleProvider; + + const mockErrorHandler: ErrorHandler = { + handle: vi.fn().mockImplementation((error: unknown) => { + throw error; + }), + shouldSuppressErrorLogging: vi.fn().mockReturnValue(false), + } as unknown as ErrorHandler; + + const contentGeneratorConfig: ContentGeneratorConfig = { + model: 'test-model', + authType: 'openai' as AuthType, + } as ContentGeneratorConfig; + + const config: PipelineConfig = { + cliConfig: {} as Config, + provider: mockProvider, + contentGeneratorConfig, + errorHandler: mockErrorHandler, + }; + + return { pipeline: new ContentGenerationPipeline(config), mockClient }; + } + + it('two concurrent streams keep their tool-call buffers isolated', async () => { + // Queue of pending stream factories — each call to the mocked + // chat.completions.create consumes one. + const streamQueue: Array< + () => AsyncIterable<OpenAI.Chat.ChatCompletionChunk> + > = []; + + streamQueue.push(() => + interleavingStream([ + () => openerChunk('call_A', 'read_file', '{"file_path":"/a'), + () => continuationChunk('/one.ts"}'), + () => finisherChunk(), + ]), + ); + streamQueue.push(() => + interleavingStream([ + () => openerChunk('call_B', 'read_file', '{"file_path":"/b'), + () => continuationChunk('/two.ts"}'), + () => finisherChunk(), + ]), + ); + + const { pipeline } = buildPipeline(() => { + const next = streamQueue.shift(); + if (!next) throw new Error('unexpected extra stream request'); + return next(); + }); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'read the files' }] }], + }; + + // Kick off both streams *before* consuming either, so the two generators + // are actually alive on the event loop at the same time. + const [streamA, streamB] = await Promise.all([ + pipeline.executeStream(request, 'prompt-a'), + pipeline.executeStream(request, 'prompt-b'), + ]); + + // Interleaved consumption: alternate one chunk from each to maximize + // parser state overlap. + const collectedA: unknown[] = []; + const collectedB: unknown[] = []; + + const aIter = streamA[Symbol.asyncIterator](); + const bIter = streamB[Symbol.asyncIterator](); + + while (true) { + const [aNext, bNext] = await Promise.all([aIter.next(), bIter.next()]); + if (!aNext.done) collectedA.push(aNext.value); + if (!bNext.done) collectedB.push(bNext.value); + if (aNext.done && bNext.done) break; + } + + const extractFunctionCall = (responses: unknown[]) => { + for (const resp of responses) { + const candidates = ( + resp as { candidates?: Array<{ content?: { parts?: Part[] } }> } + ).candidates; + const parts = candidates?.[0]?.content?.parts ?? []; + const fc = parts.find((p) => p.functionCall)?.functionCall; + if (fc) return fc; + } + return undefined; + }; + + const fnA = extractFunctionCall(collectedA); + const fnB = extractFunctionCall(collectedB); + + // Pre-fix behaviour: at least one of these would either be undefined + // (buffer wiped by the other stream's reset) or carry the wrong args + // (other stream's chunks merged into this bucket). + expect(fnA?.name).toBe('read_file'); + expect(fnA?.id).toBe('call_A'); + expect(fnA?.args).toEqual({ file_path: '/a/one.ts' }); + + expect(fnB?.name).toBe('read_file'); + expect(fnB?.id).toBe('call_B'); + expect(fnB?.args).toEqual({ file_path: '/b/two.ts' }); + }); + + it('an error in one stream does not poison a concurrent stream (no shared reset on error)', async () => { + // Stream A: normal tool call. + // Stream B: yields an `error_finish` chunk mid-flight, which the + // Pipeline wraps as StreamContentError. + // Pre-fix: the error path ran `resetStreamingToolCalls()` on the shared + // converter, wiping A's partial buffers. Post-fix: streamCtx is local + // to each generator, so A is untouched. + const streamQueue: Array< + () => AsyncIterable<OpenAI.Chat.ChatCompletionChunk> + > = []; + + streamQueue.push(() => + interleavingStream([ + () => openerChunk('call_A', 'read_file', '{"file_path":"/x'), + () => continuationChunk('.ts"}'), + () => finisherChunk(), + ]), + ); + + streamQueue.push(() => + interleavingStream([ + () => openerChunk('call_B', 'read_file', '{"file_path":"/y'), + // Inject an error_finish chunk — this triggers StreamContentError + // inside processStreamWithLogging's catch block. + () => + ({ + id: 'err', + object: 'chat.completion.chunk', + created: 1, + model: 'test', + choices: [ + { + index: 0, + delta: { content: 'rate limit' }, + finish_reason: 'error_finish', + logprobs: null, + }, + ], + }) as unknown as OpenAI.Chat.ChatCompletionChunk, + ]), + ); + + const { pipeline } = buildPipeline(() => { + const next = streamQueue.shift(); + if (!next) throw new Error('unexpected extra stream request'); + return next(); + }); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ role: 'user', parts: [{ text: 'read the files' }] }], + }; + + const [streamA, streamB] = await Promise.all([ + pipeline.executeStream(request, 'prompt-a'), + pipeline.executeStream(request, 'prompt-b'), + ]); + + const consumeA = (async () => { + const out: unknown[] = []; + for await (const r of streamA) out.push(r); + return out; + })(); + const consumeB = (async () => { + try { + for await (const _ of streamB) { + /* drain */ + } + return 'completed'; + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + })(); + + const [aResults, bOutcome] = await Promise.all([consumeA, consumeB]); + + // Stream B blew up as expected. + expect(typeof bOutcome).toBe('string'); + expect(bOutcome).toContain('rate limit'); + + // Stream A still emitted its function call cleanly, despite B's error + // path running concurrently. On pre-fix code the error path would have + // called converter.resetStreamingToolCalls(), wiping A's in-flight + // buffer and causing A to emit zero function calls. + const fnA = (() => { + for (const resp of aResults) { + const parts = + (resp as { candidates?: Array<{ content?: { parts?: Part[] } }> }) + .candidates?.[0]?.content?.parts ?? []; + const fc = parts.find((p) => p.functionCall)?.functionCall; + if (fc) return fc; + } + return undefined; + })(); + + expect(fnA?.name).toBe('read_file'); + expect(fnA?.id).toBe('call_A'); + expect(fnA?.args).toEqual({ file_path: '/x.ts' }); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts index 549d39f2a..235d23c51 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.test.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.test.ts @@ -12,6 +12,7 @@ import { GenerateContentResponse, Type, FinishReason } from '@google/genai'; import type { PipelineConfig } from './pipeline.js'; import { ContentGenerationPipeline, StreamContentError } from './pipeline.js'; import { OpenAIContentConverter } from './converter.js'; +import { StreamingToolCallParser } from './streamingToolCallParser.js'; import type { Config } from '../../config/config.js'; import type { ContentGeneratorConfig, AuthType } from '../contentGenerator.js'; import type { OpenAICompatibleProvider } from './provider/index.js'; @@ -44,7 +45,8 @@ describe('ContentGenerationPipeline', () => { }, } as unknown as OpenAI; - // Mock converter + // Mock converter. `createStreamContext` returns a fresh parser each + // stream; tests that don't care about tool-call buffers just ignore it. mockConverter = { setModel: vi.fn(), setModalities: vi.fn(), @@ -52,7 +54,9 @@ describe('ContentGenerationPipeline', () => { convertOpenAIResponseToGemini: vi.fn(), convertOpenAIChunkToGemini: vi.fn(), convertGeminiToolsToOpenAI: vi.fn(), - resetStreamingToolCalls: vi.fn(), + createStreamContext: vi.fn(() => ({ + toolCallParser: new StreamingToolCallParser(), + })), } as unknown as OpenAIContentConverter; // Mock provider @@ -607,7 +611,9 @@ describe('ContentGenerationPipeline', () => { expect(results).toHaveLength(2); expect(results[0]).toBe(mockGeminiResponse1); expect(results[1]).toBe(mockGeminiResponse2); - expect(mockConverter.resetStreamingToolCalls).toHaveBeenCalled(); + // Parser is now created per-stream via createStreamContext — assert + // that the pipeline asked for a fresh one at stream entry. + expect(mockConverter.createStreamContext).toHaveBeenCalled(); expect(mockClient.chat.completions.create).toHaveBeenCalledWith( expect.objectContaining({ stream: true, @@ -719,7 +725,10 @@ describe('ContentGenerationPipeline', () => { } expect(results).toHaveLength(0); // No results due to error - expect(mockConverter.resetStreamingToolCalls).toHaveBeenCalledTimes(2); // Once at start, once on error + // createStreamContext is called exactly once at stream entry; the + // error path no longer needs an explicit parser reset because the + // stream-local context is discarded when the generator unwinds. + expect(mockConverter.createStreamContext).toHaveBeenCalledTimes(1); expect(mockErrorHandler.handle).toHaveBeenCalledWith( testError, expect.any(Object), @@ -1475,6 +1484,80 @@ describe('ContentGenerationPipeline', () => { }), ); }); + + it('should pass arbitrary samplingParams keys through verbatim (e.g. max_completion_tokens for GPT-5)', async () => { + // Arrange: user sets a GPT-5 / o-series shape in samplingParams. + // None of these are typed fields; all must appear on the wire because + // samplingParams is the source of truth. + mockContentGeneratorConfig.samplingParams = { + max_completion_tokens: 4096, + reasoning_effort: 'medium', + verbosity: 'low', + } as ContentGeneratorConfig['samplingParams']; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + config: { maxOutputTokens: 999 }, + }; + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'test', + choices: [{ message: { content: 'r' } }], + }); + + // Act + await pipeline.execute(request, 'prompt-id'); + + // Assert: the exact samplingParams keys reach the wire; max_tokens is NOT + // synthesized from request.config.maxOutputTokens. + const call = (mockClient.chat.completions.create as Mock).mock + .calls[0][0]; + expect(call).toMatchObject({ + max_completion_tokens: 4096, + reasoning_effort: 'medium', + verbosity: 'low', + }); + expect(call).not.toHaveProperty('max_tokens'); + }); + + it('should preserve historical default behavior when samplingParams is absent', async () => { + // Arrange: no samplingParams — request.config.maxOutputTokens must still + // fall through to max_tokens on the wire (original behavior unchanged). + mockContentGeneratorConfig.samplingParams = undefined; + pipeline = new ContentGenerationPipeline(mockConfig); + + const request: GenerateContentParameters = { + model: 'test-model', + contents: [{ parts: [{ text: 'Hello' }], role: 'user' }], + config: { temperature: 0.5, topP: 0.6, maxOutputTokens: 2048 }, + }; + (mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue([]); + (mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue( + new GenerateContentResponse(), + ); + (mockClient.chat.completions.create as Mock).mockResolvedValue({ + id: 'test', + choices: [{ message: { content: 'r' } }], + }); + + // Act + await pipeline.execute(request, 'prompt-id'); + + // Assert: identical to upstream behavior for existing users + expect(mockClient.chat.completions.create).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + top_p: 0.6, + max_tokens: 2048, + }), + expect.objectContaining({ signal: undefined }), + ); + }); }); describe('createRequestContext', () => { diff --git a/packages/core/src/core/openaiContentGenerator/pipeline.ts b/packages/core/src/core/openaiContentGenerator/pipeline.ts index 505880c07..77ff96f2f 100644 --- a/packages/core/src/core/openaiContentGenerator/pipeline.ts +++ b/packages/core/src/core/openaiContentGenerator/pipeline.ts @@ -143,8 +143,12 @@ export class ContentGenerationPipeline { ): AsyncGenerator<GenerateContentResponse> { const collectedGeminiResponses: GenerateContentResponse[] = []; - // Reset streaming tool calls to prevent data pollution from previous streams - this.converter.resetStreamingToolCalls(); + // Stream-local parser state. Previously the tool-call parser lived on + // the Converter singleton and was reset at stream start — but that + // wiped concurrent streams' in-flight buffers (e.g. parallel subagents + // sharing the same Config.contentGenerator). Scoping it per-stream + // fixes issue #3516. + const streamCtx = this.converter.createStreamContext(); // State for handling chunk merging. // pendingFinishResponse holds a finish chunk waiting to be merged with @@ -170,7 +174,10 @@ export class ContentGenerationPipeline { throw new StreamContentError(errorContent); } - const response = this.converter.convertOpenAIChunkToGemini(chunk); + const response = this.converter.convertOpenAIChunkToGemini( + chunk, + streamCtx, + ); // Stage 2b: Filter empty responses to avoid downstream issues if ( @@ -234,8 +241,8 @@ export class ContentGenerationPipeline { // Stage 2e: Stream completed successfully context.duration = Date.now() - context.startTime; } catch (error) { - // Clear streaming tool calls on error to prevent data pollution - this.converter.resetStreamingToolCalls(); + // No manual parser cleanup needed — streamCtx is stream-local and + // becomes eligible for garbage collection once this generator unwinds. // Re-throw StreamContentError directly so it can be handled by // the caller's retry logic (e.g., TPM throttling retry in sendMessageStream) @@ -419,6 +426,14 @@ export class ContentGenerationPipeline { return value !== undefined ? { [key]: value } : {}; }; + // When samplingParams is set, its keys pass through to the wire verbatim. + // This lets users target provider-specific parameter names + // (e.g. `max_completion_tokens` for GPT-5 / o-series) without a client release. + // When absent, the historical default behavior applies. + if (configSamplingParams !== undefined) { + return { ...configSamplingParams }; + } + const params: Record<string, unknown> = { // Parameters with request fallback but no defaults ...addParameterIfDefined('temperature', 'temperature', 'temperature'), diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts index 031219d87..12c04c115 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.test.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.test.ts @@ -306,6 +306,46 @@ describe('DefaultOpenAICompatibleProvider', () => { expect(result.max_tokens).toBe(8000); // GPT-4 has 16K limit, min(16K, 8K) = 8K }); + it('should not inject max_tokens when samplingParams is set without it (e.g. GPT-5 / o-series)', () => { + // GPT-5 / o-series on Azure reject max_tokens entirely. + // When the user sets samplingParams without max_tokens, honor the opt-out. + const cfg = { + ...mockContentGeneratorConfig, + samplingParams: { max_completion_tokens: 4096 }, + } as ContentGeneratorConfig; + const p = new DefaultOpenAICompatibleProvider(cfg, mockCliConfig); + + const request: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'gpt-4', + messages: [{ role: 'user', content: 'Hello' }], + }; + + const result = p.buildRequest(request, 'prompt-id'); + + expect(result.max_tokens).toBeUndefined(); + }); + + it('should pass samplingParams.max_tokens through verbatim, bypassing the model cap', () => { + // When samplingParams is the source of truth, even max_tokens values that + // exceed the known model output limit pass through unchanged — + // no automatic capping. + const cfg = { + ...mockContentGeneratorConfig, + samplingParams: { max_tokens: 100000 }, + } as ContentGeneratorConfig; + const p = new DefaultOpenAICompatibleProvider(cfg, mockCliConfig); + + const request: OpenAI.Chat.ChatCompletionCreateParams = { + model: 'gpt-4', // known model, 16K output limit — would normally cap. + messages: [{ role: 'user', content: 'Hello' }], + max_tokens: 100000, + }; + + const result = p.buildRequest(request, 'prompt-id'); + + expect(result.max_tokens).toBe(100000); + }); + it('should handle streaming requests', () => { const streamingRequest: OpenAI.Chat.ChatCompletionCreateParams = { model: 'gpt-4', diff --git a/packages/core/src/core/openaiContentGenerator/provider/default.ts b/packages/core/src/core/openaiContentGenerator/provider/default.ts index 0243c9e9e..3066a372c 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/default.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/default.ts @@ -121,6 +121,12 @@ export class DefaultOpenAICompatibleProvider protected applyOutputTokenLimit< T extends { max_tokens?: number | null; model: string }, >(request: T): T { + // When samplingParams is set, it is the source of truth for the wire shape. + // Don't inject a max_tokens default — honor the user's explicit choice. + if (this.contentGeneratorConfig.samplingParams !== undefined) { + return request; + } + const userMaxTokens = request.max_tokens; // Get model-specific output limit and check if model is known diff --git a/packages/core/src/core/turn.ts b/packages/core/src/core/turn.ts index 78e8dac24..b0971d0b6 100644 --- a/packages/core/src/core/turn.ts +++ b/packages/core/src/core/turn.ts @@ -69,6 +69,9 @@ export enum GeminiEventType { export type ServerGeminiRetryEvent = { type: GeminiEventType.Retry; retryInfo?: RetryInfo; + /** When true, the retry is a continuation (recovery) rather than a fresh + * restart. The UI should keep accumulated text so the continuation appends. */ + isContinuation?: boolean; }; export interface StructuredError { @@ -298,6 +301,7 @@ export class Turn { yield { type: GeminiEventType.Retry, retryInfo: streamEvent.retryInfo, + isContinuation: streamEvent.isContinuation, }; continue; // Skip to the next event in the stream } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e672e4adf..a318e4730 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -44,6 +44,19 @@ export { validateModelConfig, } from './models/index.js'; +// Coding Plan constants +export { + CodingPlanRegion, + type CodingPlanTemplate, + CODING_PLAN_ENV_KEY, + computeCodingPlanVersion, + generateCodingPlanTemplate, + getCodingPlanConfig, + getCodingPlanBaseUrls, + isCodingPlanConfig, + getRegionFromBaseUrl, +} from './constants/codingPlan.js'; + // Output formatting export * from './output/json-formatter.js'; export * from './output/types.js'; @@ -277,6 +290,7 @@ export { ConditionalRulesRegistry } from './utils/rulesDiscovery.js'; export type { RuleFile } from './utils/rulesDiscovery.js'; export { OpenAILogger, openaiLogger } from './utils/openaiLogger.js'; export * from './utils/partUtils.js'; +export * from './utils/sessionStorageUtils.js'; export * from './utils/pathReader.js'; export * from './utils/paths.js'; export * from './utils/projectSummary.js'; diff --git a/packages/core/src/mcp/oauth-provider.ts b/packages/core/src/mcp/oauth-provider.ts index 6d01f65ef..ce92e97da 100644 --- a/packages/core/src/mcp/oauth-provider.ts +++ b/packages/core/src/mcp/oauth-provider.ts @@ -815,12 +815,16 @@ export class MCPOAuthProvider { displayMessage({ key: 'If the browser does not open, copy and paste this URL into your browser:', }); - displayMessage(`\n${authUrl.toString()}\n`); displayMessage({ key: 'Make sure to copy the COMPLETE URL - it may wrap across multiple lines.', }); if (events) { + // UI consumers render the URL from this event (as a clickable OSC 8 + // hyperlink). Avoid also pushing the raw URL through displayMessage — + // hard-wrapping it inside the message list breaks link detection. events.emit(OAUTH_AUTH_URL_EVENT, authUrl.toString()); + } else { + displayMessage(`\n${authUrl.toString()}\n`); } // Start callback server diff --git a/packages/core/src/output/json-formatter.test.ts b/packages/core/src/output/json-formatter.test.ts index 587030a98..071ff3f93 100644 --- a/packages/core/src/output/json-formatter.test.ts +++ b/packages/core/src/output/json-formatter.test.ts @@ -66,6 +66,7 @@ describe('JsonFormatter', () => { thoughts: 103, tool: 0, }, + bySource: {}, }, 'gemini-2.5-flash': { api: { @@ -81,6 +82,7 @@ describe('JsonFormatter', () => { thoughts: 138, tool: 0, }, + bySource: {}, }, }, tools: { diff --git a/packages/core/src/services/chatRecordingService.customTitle.test.ts b/packages/core/src/services/chatRecordingService.customTitle.test.ts new file mode 100644 index 000000000..479aa58c7 --- /dev/null +++ b/packages/core/src/services/chatRecordingService.customTitle.test.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import path from 'node:path'; +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Config } from '../config/config.js'; +import { + ChatRecordingService, + type ChatRecord, +} from './chatRecordingService.js'; +import * as jsonl from '../utils/jsonl-utils.js'; + +vi.mock('node:path'); +vi.mock('node:child_process'); +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(), + createHash: vi.fn(() => ({ + update: vi.fn(() => ({ + digest: vi.fn(() => 'mocked-hash'), + })), + })), +})); +vi.mock('../utils/jsonl-utils.js'); + +describe('ChatRecordingService - recordCustomTitle', () => { + let chatRecordingService: ChatRecordingService; + let mockConfig: Config; + + let uuidCounter = 0; + + beforeEach(() => { + uuidCounter = 0; + + mockConfig = { + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getProjectRoot: vi.fn().mockReturnValue('/test/project/root'), + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + storage: { + getProjectTempDir: vi + .fn() + .mockReturnValue('/test/project/root/.qwen/tmp/hash'), + getProjectDir: vi + .fn() + .mockReturnValue('/test/project/root/.qwen/projects/test-project'), + }, + getModel: vi.fn().mockReturnValue('qwen-plus'), + getDebugMode: vi.fn().mockReturnValue(false), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn().mockReturnValue({ + displayName: 'Test Tool', + description: 'A test tool', + isOutputMarkdown: false, + }), + }), + getResumedSessionData: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + vi.mocked(randomUUID).mockImplementation( + () => + `00000000-0000-0000-0000-00000000000${++uuidCounter}` as `${string}-${string}-${string}-${string}-${string}`, + ); + vi.mocked(path.join).mockImplementation((...args) => args.join('/')); + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/'); + }); + vi.mocked(execSync).mockReturnValue('main\n'); + vi.spyOn(fs, 'mkdirSync').mockImplementation(() => undefined); + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => undefined); + vi.spyOn(fs, 'existsSync').mockReturnValue(false); + + chatRecordingService = new ChatRecordingService(mockConfig); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should record a custom title as a system record', () => { + chatRecordingService.recordCustomTitle('my-feature'); + + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + + const writtenRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(writtenRecord.type).toBe('system'); + expect(writtenRecord.subtype).toBe('custom_title'); + expect(writtenRecord.systemPayload).toEqual({ + customTitle: 'my-feature', + }); + expect(writtenRecord.sessionId).toBe('test-session-id'); + }); + + it('should maintain parent chain when recording title after other records', () => { + chatRecordingService.recordUserMessage([{ text: 'hello' }]); + chatRecordingService.recordCustomTitle('my-feature'); + + expect(jsonl.writeLineSync).toHaveBeenCalledTimes(2); + + const userRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + const titleRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[1][1] as ChatRecord; + + expect(titleRecord.parentUuid).toBe(userRecord.uuid); + }); + + it('should include correct metadata in the record', () => { + chatRecordingService.recordCustomTitle('test-title'); + + const writtenRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + + expect(writtenRecord.cwd).toBe('/test/project/root'); + expect(writtenRecord.version).toBe('1.0.0'); + expect(writtenRecord.gitBranch).toBe('main'); + expect(writtenRecord.uuid).toBeDefined(); + expect(writtenRecord.timestamp).toBeDefined(); + }); + + describe('finalize', () => { + it('should re-append cached custom title to EOF', () => { + chatRecordingService.recordCustomTitle('my-feature'); + vi.mocked(jsonl.writeLineSync).mockClear(); + + chatRecordingService.finalize(); + + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(record.type).toBe('system'); + expect(record.subtype).toBe('custom_title'); + expect(record.systemPayload).toEqual({ customTitle: 'my-feature' }); + }); + + it('should not write anything when no custom title was set', () => { + chatRecordingService.finalize(); + + expect(jsonl.writeLineSync).not.toHaveBeenCalled(); + }); + + it('should re-append the latest title after multiple renames', () => { + chatRecordingService.recordCustomTitle('first-name'); + chatRecordingService.recordCustomTitle('second-name'); + vi.mocked(jsonl.writeLineSync).mockClear(); + + chatRecordingService.finalize(); + + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + const record = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(record.systemPayload).toEqual({ customTitle: 'second-name' }); + }); + }); +}); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 13d3d1566..5a821b4fc 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -59,7 +59,8 @@ export interface ChatRecord { | 'ui_telemetry' | 'at_command' | 'notification' - | 'cron'; + | 'cron' + | 'custom_title'; /** Working directory at time of message */ cwd: string; /** CLI version for compatibility tracking */ @@ -100,6 +101,7 @@ export interface ChatRecord { | SlashCommandRecordPayload | UiTelemetryRecordPayload | AtCommandRecordPayload + | CustomTitleRecordPayload | NotificationRecordPayload; /** Background subagent that produced this record (e.g. "explore-7f3c"). */ @@ -157,6 +159,14 @@ export interface AtCommandRecordPayload { userText?: string; } +/** + * Stored payload for custom title set via /rename. + */ +export interface CustomTitleRecordPayload { + /** The custom title for the session */ + customTitle: string; +} + /** * Stored payload for UI telemetry replay. */ @@ -192,11 +202,30 @@ export class ChatRecordingService { /** UUID of the last written record in the chain */ private lastRecordUuid: string | null = null; private readonly config: Config; + /** In-memory cache of the current session's custom title (for re-append on exit) */ + private currentCustomTitle: string | undefined; constructor(config: Config) { this.config = config; this.lastRecordUuid = config.getResumedSessionData()?.lastCompletedUuid ?? null; + + // On resume, load the cached custom title from the session file and + // immediately re-append it to EOF. This keeps the title within the + // 64KB tail window even as new messages push it deeper into the file. + // Without this, a crash mid-session could lose the title if the exit + // re-append never runs. + if (config.getResumedSessionData()) { + try { + const sessionService = config.getSessionService(); + this.currentCustomTitle = sessionService.getSessionTitle( + config.getSessionId(), + ); + this.finalize(); + } catch { + // Best-effort — don't block construction + } + } } /** @@ -490,6 +519,59 @@ export class ChatRecordingService { } } + /** + * Records a custom title for the session (set via /rename). + * Appended as a system record so it persists with the session data. + * Also caches the title in memory for re-append on shutdown. + * + * @returns true if the record was written successfully, false on I/O error. + */ + recordCustomTitle(customTitle: string): boolean { + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle }, + }; + + this.appendRecord(record); + this.currentCustomTitle = customTitle; + return true; + } catch (error) { + debugLogger.error('Error saving custom title record:', error); + return false; + } + } + + /** + * Finalizes the current session by re-appending cached metadata to EOF. + * + * Call this whenever leaving the current session — whether switching to + * another session, shutting down the process, or any other transition. + * This single entry point replaces scattered re-append calls and ensures + * the custom_title record stays within the last 64KB tail window that + * readSessionTitleFromFile() scans. + * + * Best-effort: errors are logged but never thrown. + */ + finalize(): void { + if (!this.currentCustomTitle) { + return; + } + try { + const record: ChatRecord = { + ...this.createBaseRecord('system'), + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: this.currentCustomTitle }, + }; + this.appendRecord(record); + } catch (error) { + debugLogger.error('Error finalizing session metadata:', error); + } + } + /** * Records @-command metadata as a system record for UI reconstruction. */ diff --git a/packages/core/src/services/sessionRecap.ts b/packages/core/src/services/sessionRecap.ts index be1554749..077a5fa95 100644 --- a/packages/core/src/services/sessionRecap.ts +++ b/packages/core/src/services/sessionRecap.ts @@ -14,22 +14,16 @@ const RECENT_MESSAGE_WINDOW = 30; const RECAP_SYSTEM_PROMPT = `You generate session recaps for a programming assistant CLI. -You receive the most recent turns of a conversation between a user and an -assistant. The user has stepped away and is now returning. Your sole job is -to remind them where they left off so they can resume quickly. +The user stepped away and is coming back. Recap in under 40 words, 1-2 plain sentences, no markdown. Lead with the overall goal and current task, then the one next action. Skip root-cause narrative, fix internals, secondary to-dos, and em-dash tangents. -Content rules: -- Exactly ONE sentence. Hard cap: 80 characters. Plain prose, no bullets, no headings, no markdown. -- Combine the high-level task and the concrete next step into a single sentence. -- Do NOT list what was done, recite tool calls, or include status reports. -- Match the dominant language of the conversation (English or Chinese). +Match the dominant language of the conversation (English or Chinese). For Chinese, treat the budget as roughly 80 characters total. Output format — strict: - Wrap your recap in <recap>...</recap> tags. - Put NOTHING outside the tags. No preamble, no reasoning, no closing remarks. Example: -<recap>Debugging the auth retry race condition; next, add deterministic timing to the test.</recap>`; +<recap>Debugging the auth retry race condition. Next: add deterministic timing to the integration test.</recap>`; const RECAP_USER_PROMPT = 'Generate the recap now. Wrap it in <recap>...</recap>. Nothing outside the tags.'; @@ -43,9 +37,10 @@ export interface SessionRecapResult { } /** - * Generate a one-sentence "where did I leave off" summary of the current + * Generate a 1-2 sentence "where did I leave off" summary of the current * session. Uses the configured fast model (falls back to main model) with - * tools disabled and a very small generation budget. + * tools disabled and a very small generation budget. Prompt mirrors + * Claude Code's away-summary prompt for behavioral parity. * * Returns null on any failure — recap is best-effort and must never break * the main flow or surface errors to the user. diff --git a/packages/core/src/services/sessionService.rename.test.ts b/packages/core/src/services/sessionService.rename.test.ts new file mode 100644 index 000000000..5ba227cee --- /dev/null +++ b/packages/core/src/services/sessionService.rename.test.ts @@ -0,0 +1,506 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; +import { getProjectHash } from '../utils/paths.js'; +import { SessionService } from './sessionService.js'; +import type { ChatRecord } from './chatRecordingService.js'; +import * as jsonl from '../utils/jsonl-utils.js'; + +vi.mock('node:path'); +vi.mock('../utils/paths.js'); +vi.mock('../utils/jsonl-utils.js'); + +describe('SessionService - rename and custom title', () => { + let sessionService: SessionService; + + let readdirSyncSpy: MockInstance<typeof fs.readdirSync>; + let statSyncSpy: MockInstance<typeof fs.statSync>; + + let readSyncSpy: MockInstance<typeof fs.readSync>; + + const sessionIdA = '550e8400-e29b-41d4-a716-446655440000'; + const sessionIdB = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + const recordA1: ChatRecord = { + uuid: 'a1', + parentUuid: null, + sessionId: sessionIdA, + timestamp: '2024-01-01T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hello session a' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + }; + + const recordB1: ChatRecord = { + uuid: 'b1', + parentUuid: null, + sessionId: sessionIdB, + timestamp: '2024-01-02T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hi session b' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'feature', + }; + + beforeEach(() => { + vi.mocked(getProjectHash).mockReturnValue('test-project-hash'); + vi.mocked(path.join).mockImplementation((...args) => args.join('/')); + vi.mocked(path.dirname).mockImplementation((p) => { + const parts = p.split('/'); + parts.pop(); + return parts.join('/'); + }); + + sessionService = new SessionService('/test/project/root'); + + readdirSyncSpy = vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + statSyncSpy = vi.spyOn(fs, 'statSync').mockImplementation( + () => + ({ + mtimeMs: Date.now(), + size: 100, + isFile: () => true, + }) as unknown as fs.Stats, + ); + vi.spyOn(fs, 'openSync').mockReturnValue(42); + readSyncSpy = vi.spyOn(fs, 'readSync').mockReturnValue(0); + vi.spyOn(fs, 'closeSync').mockImplementation(() => undefined); + + vi.mocked(jsonl.read).mockResolvedValue([]); + vi.mocked(jsonl.readLines).mockResolvedValue([]); + vi.mocked(jsonl.writeLineSync).mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('renameSession', () => { + it('should append a custom_title record to the session file', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + const result = await sessionService.renameSession( + sessionIdA, + 'my-feature', + ); + + expect(result).toBe(true); + expect(jsonl.writeLineSync).toHaveBeenCalledOnce(); + + const writtenRecord = vi.mocked(jsonl.writeLineSync).mock + .calls[0][1] as ChatRecord; + expect(writtenRecord.type).toBe('system'); + expect(writtenRecord.subtype).toBe('custom_title'); + expect(writtenRecord.systemPayload).toEqual({ + customTitle: 'my-feature', + }); + expect(writtenRecord.sessionId).toBe(sessionIdA); + }); + + it('should return false when session does not exist', async () => { + vi.mocked(jsonl.readLines).mockResolvedValue([]); + + const result = await sessionService.renameSession( + '00000000-0000-0000-0000-000000000000', + 'test', + ); + + expect(result).toBe(false); + expect(jsonl.writeLineSync).not.toHaveBeenCalled(); + }); + + it('should return false for session from different project', async () => { + const differentProjectRecord: ChatRecord = { + ...recordA1, + cwd: '/different/project', + }; + vi.mocked(jsonl.readLines).mockResolvedValue([differentProjectRecord]); + vi.mocked(getProjectHash).mockImplementation((cwd: string) => + cwd === '/test/project/root' + ? 'test-project-hash' + : 'other-project-hash', + ); + + const result = await sessionService.renameSession( + sessionIdA, + 'my-feature', + ); + + expect(result).toBe(false); + expect(jsonl.writeLineSync).not.toHaveBeenCalled(); + }); + + it('should handle file not found error', async () => { + const error = new Error('ENOENT') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + vi.mocked(jsonl.readLines).mockRejectedValue(error); + + const result = await sessionService.renameSession( + '00000000-0000-0000-0000-000000000000', + 'test', + ); + + expect(result).toBe(false); + }); + }); + + describe('getSessionTitle', () => { + it('should return custom title from session file tail', () => { + const titleRecord = JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'my-feature' }, + }); + + statSyncSpy.mockReturnValue({ + size: titleRecord.length + 1, + mtimeMs: Date.now(), + } as unknown as fs.Stats); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleRecord + '\n'); + data.copy(buffer); + return data.length; + }, + ); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBe('my-feature'); + }); + + it('should return last custom title when multiple exist', () => { + const line1 = JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'old-name' }, + }); + const line2 = JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'new-name' }, + }); + const content = line1 + '\n' + line2 + '\n'; + + statSyncSpy.mockReturnValue({ + size: content.length, + mtimeMs: Date.now(), + } as unknown as fs.Stats); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(content); + data.copy(buffer); + return data.length; + }, + ); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBe('new-name'); + }); + + it('should return undefined when no custom title exists', () => { + const userRecord = JSON.stringify({ + type: 'user', + message: { role: 'user', parts: [{ text: 'hello' }] }, + }); + + statSyncSpy.mockReturnValue({ + size: userRecord.length + 1, + mtimeMs: Date.now(), + } as unknown as fs.Stats); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(userRecord + '\n'); + data.copy(buffer); + return data.length; + }, + ); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBeUndefined(); + }); + + it('should return undefined when file does not exist', () => { + statSyncSpy.mockImplementation(() => { + throw new Error('ENOENT'); + }); + + const title = sessionService.getSessionTitle(sessionIdA); + expect(title).toBeUndefined(); + }); + }); + + describe('findSessionsByTitle', () => { + const now = Date.now(); + + function setupSessionFiles( + sessions: Array<{ + id: string; + record: ChatRecord; + mtime: number; + titleContent?: string; + }>, + ) { + readdirSyncSpy.mockReturnValue( + sessions.map((s) => `${s.id}.jsonl`) as unknown as Array< + fs.Dirent<Buffer> + >, + ); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const p = filePath.toString(); + const session = sessions.find((s) => p.includes(s.id)); + return { + mtimeMs: session?.mtime ?? now, + size: session?.titleContent?.length ?? 100, + isFile: () => true, + } as unknown as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + const session = sessions.find((s) => filePath.includes(s.id)); + return session ? [session.record] : []; + }, + ); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, _buffer: any) => + // For simplicity, return empty content (no title by default) + // Individual tests can override this + 0, + ); + } + + it('should find session by exact custom title (case-insensitive)', async () => { + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'My-Feature' }, + }) + '\n'; + + setupSessionFiles([ + { id: sessionIdA, record: recordA1, mtime: now, titleContent }, + ]); + + // Override readSync to return title for session A + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const matches = await sessionService.findSessionsByTitle('my-feature'); + + expect(matches).toHaveLength(1); + expect(matches[0].sessionId).toBe(sessionIdA); + }); + + it('should return empty array when no session matches', async () => { + setupSessionFiles([{ id: sessionIdA, record: recordA1, mtime: now }]); + + const matches = await sessionService.findSessionsByTitle('nonexistent'); + + expect(matches).toHaveLength(0); + }); + + it('should not skip matches when multiple sessions share the same mtime (regression for PR #3093 review)', async () => { + // Three sessions sharing identical mtimes would fall on the page + // boundary of a paginated listSessions() and the third would be + // dropped by the strict `mtime < cursor` filter. Verify the exhaustive + // scan path returns all three. + const sessionIdC = '7ba7b810-9dad-11d1-80b4-00c04fd430c9'; + const recordC1: ChatRecord = { + uuid: 'c1', + parentUuid: null, + sessionId: sessionIdC, + timestamp: '2024-01-03T00:00:00Z', + type: 'user', + message: { role: 'user', parts: [{ text: 'hi session c' }] }, + cwd: '/test/project/root', + version: '1.0.0', + gitBranch: 'main', + }; + + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'shared-name' }, + }) + '\n'; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + `${sessionIdC}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + const sharedMtime = now; + statSyncSpy.mockImplementation( + () => + ({ + mtimeMs: sharedMtime, + size: titleContent.length, + isFile: () => true, + }) as unknown as fs.Stats, + ); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdA)) return [recordA1]; + if (filePath.includes(sessionIdB)) return [recordB1]; + if (filePath.includes(sessionIdC)) return [recordC1]; + return []; + }, + ); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const matches = await sessionService.findSessionsByTitle('shared-name'); + + expect(matches).toHaveLength(3); + const matchedIds = matches.map((m) => m.sessionId).sort(); + expect(matchedIds).toEqual([sessionIdA, sessionIdB, sessionIdC].sort()); + }); + + it('should return multiple matches for duplicate titles', async () => { + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'shared-name' }, + }) + '\n'; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + `${sessionIdB}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + statSyncSpy.mockImplementation((filePath: fs.PathLike) => { + const p = filePath.toString(); + return { + mtimeMs: p.includes(sessionIdB) ? now : now - 1000, + size: titleContent.length, + isFile: () => true, + } as unknown as fs.Stats; + }); + + vi.mocked(jsonl.readLines).mockImplementation( + async (filePath: string) => { + if (filePath.includes(sessionIdA)) return [recordA1]; + return [recordB1]; + }, + ); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const matches = await sessionService.findSessionsByTitle('shared-name'); + + expect(matches).toHaveLength(2); + }); + }); + + describe('listSessions with customTitle', () => { + it('should include customTitle in session list items', async () => { + const now = Date.now(); + const titleContent = + JSON.stringify({ + type: 'system', + subtype: 'custom_title', + systemPayload: { customTitle: 'my-feature' }, + }) + '\n'; + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + statSyncSpy.mockReturnValue({ + mtimeMs: now, + size: titleContent.length, + isFile: () => true, + } as unknown as fs.Stats); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, buffer: any) => { + const data = Buffer.from(titleContent); + data.copy(buffer); + return data.length; + }, + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(1); + expect(result.items[0].customTitle).toBe('my-feature'); + }); + + it('should return undefined customTitle when none set', async () => { + const now = Date.now(); + + readdirSyncSpy.mockReturnValue([ + `${sessionIdA}.jsonl`, + ] as unknown as Array<fs.Dirent<Buffer>>); + + statSyncSpy.mockReturnValue({ + mtimeMs: now, + size: 100, + isFile: () => true, + } as unknown as fs.Stats); + + vi.mocked(jsonl.readLines).mockResolvedValue([recordA1]); + + readSyncSpy.mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_fd: number, _buffer: any) => 0, + ); + + const result = await sessionService.listSessions(); + + expect(result.items).toHaveLength(1); + expect(result.items[0].customTitle).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/src/services/sessionService.ts b/packages/core/src/services/sessionService.ts index 33a516319..19b7af1f5 100644 --- a/packages/core/src/services/sessionService.ts +++ b/packages/core/src/services/sessionService.ts @@ -8,6 +8,7 @@ import { Storage } from '../config/storage.js'; import { getProjectHash } from '../utils/paths.js'; import path from 'node:path'; import fs from 'node:fs'; +import { randomUUID } from 'node:crypto'; import readline from 'node:readline'; import type { Content, Part } from '@google/genai'; import * as jsonl from '../utils/jsonl-utils.js'; @@ -18,6 +19,7 @@ import type { } from './chatRecordingService.js'; import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; import { createDebugLogger } from '../utils/debugLogger.js'; +import { readLastJsonStringFieldSync } from '../utils/sessionStorageUtils.js'; const debugLogger = createDebugLogger('SESSION'); @@ -42,6 +44,8 @@ export interface SessionListItem { filePath: string; /** Number of messages in the session (unique message UUIDs) */ messageCount: number; + /** Custom title set via /rename, if any */ + customTitle?: string; } /** @@ -105,6 +109,12 @@ export interface ResumedSessionData { */ const MAX_FILES_TO_PROCESS = 10000; +/** + * Maximum character length for a session custom title. + * Shared across CLI, WebUI, VSCode, and ACP. + */ +export const SESSION_TITLE_MAX_LENGTH = 200; + /** * Pattern for validating session file names. * Session files are named as `${sessionId}.jsonl` where sessionId is a UUID-like identifier @@ -113,6 +123,11 @@ const MAX_FILES_TO_PROCESS = 10000; const SESSION_FILE_PATTERN = /^[0-9a-fA-F-]{32,36}\.jsonl$/; /** Maximum number of lines to scan when looking for the first prompt text. */ const MAX_PROMPT_SCAN_LINES = 10; +/** + * Maximum bytes to read from head/tail of a session file. + * Used by readLastRecordUuid which still does its own tail read. + */ +const TAIL_READ_SIZE = 64 * 1024; /** * Service for managing chat sessions. @@ -138,6 +153,63 @@ export class SessionService { return path.join(this.storage.getProjectDir(), 'chats'); } + /** + * Reads the session title from a JSONL file. + * + * Delegates to {@link readLastJsonStringFieldSync}, which scans the tail + * window first (fast path; almost always hits because finalize() re-appends + * the title on every lifecycle event) and falls back to a full-file scan + * when the tail has no match. The `custom_title` line-marker guards against + * false matches from user content that happens to include a `customTitle` + * field. + */ + private readSessionTitleFromFile(filePath: string): string | undefined { + return readLastJsonStringFieldSync(filePath, 'customTitle', 'custom_title'); + } + + /** + * Reads the UUID of the last record in a session JSONL file. + * Uses a tail-read strategy for efficiency. + */ + private readLastRecordUuid(filePath: string): string | null { + try { + const stats = fs.statSync(filePath); + const fileSize = stats.size; + const readStart = Math.max(0, fileSize - TAIL_READ_SIZE); + const readLength = Math.min(fileSize, TAIL_READ_SIZE); + + const fd = fs.openSync(filePath, 'r'); + let buffer: Buffer; + try { + buffer = Buffer.alloc(readLength); + fs.readSync(fd, buffer, 0, readLength, readStart); + } finally { + fs.closeSync(fd); + } + + const tail = buffer.toString('utf-8'); + const lines = tail.split('\n'); + + // Walk backwards to find the last valid record + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (!trimmed) continue; + try { + const record = JSON.parse(trimmed) as ChatRecord; + if (record.uuid) { + return record.uuid; + } + } catch { + continue; + } + } + + return null; + } catch { + return null; + } + } + /** * Extracts the first user prompt text from a Content object. */ @@ -302,6 +374,7 @@ export class SessionService { gitBranch: firstRecord.gitBranch, filePath, messageCount, + customTitle: this.readSessionTitleFromFile(filePath), }); } @@ -483,6 +556,9 @@ export class SessionService { * @returns true if removed, false if not found */ async removeSession(sessionId: string): Promise<boolean> { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return false; + } const chatsDir = this.getChatsDir(); const filePath = path.join(chatsDir, `${sessionId}.jsonl`); @@ -508,6 +584,159 @@ export class SessionService { } } + /** + * Renames a session by appending a custom_title system record to its JSONL file. + * + * @param sessionId The session ID to rename + * @param title The new custom title + * @returns true if renamed successfully, false if session not found + */ + async renameSession(sessionId: string, title: string): Promise<boolean> { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return false; + } + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + + try { + // Verify the file exists and belongs to this project + const records = await jsonl.readLines<ChatRecord>(filePath, 1); + if (records.length === 0) { + return false; + } + + const recordProjectHash = getProjectHash(records[0].cwd); + if (recordProjectHash !== this.projectHash) { + return false; + } + + // Read the last record's UUID so the custom_title record is properly + // chained into the parent history. reconstructHistory() walks from the + // tail record upward via parentUuid; a null parentUuid would sever the + // chain and cause the session to appear empty on next load. + const lastUuid = this.readLastRecordUuid(filePath); + + // Append a custom_title system record + const titleRecord: ChatRecord = { + uuid: randomUUID(), + parentUuid: lastUuid, + sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'custom_title', + cwd: records[0].cwd, + version: records[0].version, + systemPayload: { customTitle: title }, + }; + jsonl.writeLineSync(filePath, titleRecord); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return false; + } + throw error; + } + } + + /** + * Gets the custom title for a session by reading from its JSONL file. + * + * @param sessionId The session ID to look up + * @returns The custom title, or undefined if none set + */ + getSessionTitle(sessionId: string): string | undefined { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return undefined; + } + const chatsDir = this.getChatsDir(); + const filePath = path.join(chatsDir, `${sessionId}.jsonl`); + return this.readSessionTitleFromFile(filePath); + } + + /** + * Finds sessions by custom title. + * Returns all matching sessions ordered by most recent first. + * + * @param title The custom title to search for (case-insensitive exact match) + * @returns Array of matching session list items + */ + async findSessionsByTitle(title: string): Promise<SessionListItem[]> { + const normalizedTitle = title.toLowerCase().trim(); + const matches: SessionListItem[] = []; + const chatsDir = this.getChatsDir(); + + // Scan all session files directly rather than paging through + // listSessions(): the mtime-only cursor there uses a strict `<` boundary, + // so sessions that share an mtime with the page's last entry are skipped, + // which would silently drop valid title matches. + let fileNames: string[]; + try { + fileNames = fs.readdirSync(chatsDir); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return matches; + } + throw error; + } + + const files: Array<{ name: string; mtime: number }> = []; + for (const name of fileNames) { + if (!SESSION_FILE_PATTERN.test(name)) continue; + const filePath = path.join(chatsDir, name); + try { + const stats = fs.statSync(filePath); + files.push({ name, mtime: stats.mtimeMs }); + } catch { + continue; + } + } + + // Sort most-recent first, with filename as a stable tie-breaker so runs + // are deterministic even when multiple files share an mtime. + files.sort((a, b) => b.mtime - a.mtime || a.name.localeCompare(b.name)); + + let filesProcessed = 0; + for (const file of files) { + if (filesProcessed >= MAX_FILES_TO_PROCESS) break; + filesProcessed++; + + const filePath = path.join(chatsDir, file.name); + + // Cheap check first: tail-read the title and skip non-matches before + // doing the full hydration work (first-record read, project filter, + // message count, prompt extraction). + const customTitle = this.readSessionTitleFromFile(filePath); + if (customTitle?.toLowerCase().trim() !== normalizedTitle) continue; + + const records = await jsonl.readLines<ChatRecord>( + filePath, + MAX_PROMPT_SCAN_LINES, + ); + if (records.length === 0) continue; + const firstRecord = records[0]; + + const recordProjectHash = getProjectHash(firstRecord.cwd); + if (recordProjectHash !== this.projectHash) continue; + + const messageCount = await this.countSessionMessages(filePath); + const prompt = this.extractFirstPromptFromRecords(records); + + matches.push({ + sessionId: firstRecord.sessionId, + cwd: firstRecord.cwd, + startTime: firstRecord.timestamp, + mtime: file.mtime, + prompt, + gitBranch: firstRecord.gitBranch, + filePath, + messageCount, + customTitle, + }); + } + + return matches; + } + /** * Loads the most recent session for the current project. * Combines listSessions and loadSession for convenience. @@ -529,6 +758,9 @@ export class SessionService { * @returns true if session exists and belongs to current project */ async sessionExists(sessionId: string): Promise<boolean> { + if (!SESSION_FILE_PATTERN.test(`${sessionId}.jsonl`)) { + return false; + } const chatsDir = this.getChatsDir(); const filePath = path.join(chatsDir, `${sessionId}.jsonl`); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index f0838fa45..0c92e0761 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -450,6 +450,18 @@ export class SkillManager { // Extract optional model field const model = parseModelField(frontmatter); + // Extract when_to_use and disable-model-invocation + const whenToUse = + typeof frontmatter['when_to_use'] === 'string' + ? frontmatter['when_to_use'] + : undefined; + const disableModelInvocationRaw = frontmatter['disable-model-invocation']; + const disableModelInvocation = + disableModelInvocationRaw === true || + disableModelInvocationRaw === 'true' + ? true + : undefined; + const config: SkillConfig = { name, description, @@ -460,6 +472,8 @@ export class SkillManager { level, filePath, body: body.trim(), + whenToUse, + disableModelInvocation, }; // Validate the parsed configuration @@ -649,7 +663,7 @@ export class SkillManager { const skills: SkillConfig[] = []; for (const extension of extensions) { extension.skills?.forEach((skill) => { - skills.push(skill); + skills.push({ ...skill, extensionName: extension.name }); }); } debugLogger.debug( diff --git a/packages/core/src/skills/types.ts b/packages/core/src/skills/types.ts index c7afcf3ff..fe7332466 100644 --- a/packages/core/src/skills/types.ts +++ b/packages/core/src/skills/types.ts @@ -80,6 +80,21 @@ export interface SkillConfig { * For extension-level skills: the name of the providing extension */ extensionName?: string; + + /** + * Describes when to invoke this skill — shown to the model in the SkillTool + * description so it can decide whether to use it. Parsed from the + * `when_to_use` frontmatter field in SKILL.md. + */ + whenToUse?: string; + + /** + * When true, the skill is hidden from the model's SkillTool listing and + * cannot be invoked by the model. Only the user can trigger it via + * `/<skill-name>`. Parsed from the `disable-model-invocation` frontmatter + * field in SKILL.md. + */ + disableModelInvocation?: boolean; } /** diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index 5869b3974..186222ede 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -914,8 +914,12 @@ export class SubagentManager { try { const config = await this.parseSubagentFile(filePath, level); subagents.push(config); - } catch (_error) { - // Ignore invalid files + } catch (error) { + // Skip invalid files but surface the reason. Before this warning + // was added, invalid subagent files failed silently — a user who + // mistyped frontmatter or used a reserved name had no way to see + // why their agent wasn't loading. + warnInvalidSubagentFile(filePath, error); continue; } } @@ -994,8 +998,8 @@ export async function loadSubagentFromDir( new SubagentValidator(), ); subagents.push(config); - } catch (_error) { - // Ignore invalid files + } catch (error) { + warnInvalidSubagentFile(filePath, error); continue; } } @@ -1138,3 +1142,14 @@ function parseSubagentContent( ); } } + +/** + * Log an invalid-subagent-file error via the debug logger. Before this was + * added, the loader swallowed these errors entirely — users running with + * debug logging enabled had no way to tell why their subagent wasn't loading. + * Kept on the debug channel so the TUI stays quiet during normal startup. + */ +function warnInvalidSubagentFile(filePath: string, error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + debugLogger.debug(`Skipped invalid file ${filePath}: ${message}`); +} diff --git a/packages/core/src/subagents/validation.test.ts b/packages/core/src/subagents/validation.test.ts index 420483b11..9a4cc6279 100644 --- a/packages/core/src/subagents/validation.test.ts +++ b/packages/core/src/subagents/validation.test.ts @@ -106,6 +106,7 @@ describe('SubagentValidator', () => { 'tool', 'config', 'default', + 'main', ]; for (const name of reservedNames) { diff --git a/packages/core/src/subagents/validation.ts b/packages/core/src/subagents/validation.ts index a885b14c4..9f05b0d60 100644 --- a/packages/core/src/subagents/validation.ts +++ b/packages/core/src/subagents/validation.ts @@ -133,7 +133,10 @@ export class SubagentValidator { errors.push('Name cannot end with a hyphen or underscore'); } - // Check for reserved names + // Check for reserved names. `main` is the sentinel used by the /stats + // attribution pipeline to label the main (non-subagent) conversation; + // a subagent named `main` would collide with that sentinel and be + // silently merged into the main bucket. const reservedNames = [ 'self', 'system', @@ -142,6 +145,7 @@ export class SubagentValidator { 'tool', 'config', 'default', + 'main', ]; if (reservedNames.includes(trimmedName.toLowerCase())) { errors.push(`"${trimmedName}" is a reserved name and cannot be used`); diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index f22582f05..a6a6c9ace 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -598,6 +598,7 @@ export class QwenLogger { properties: { model: event.model, prompt_id: event.prompt_id, + subagent_name: event.subagent_name, }, }); @@ -615,6 +616,7 @@ export class QwenLogger { auth_type: event.auth_type, model: event.model, prompt_id: event.prompt_id, + subagent_name: event.subagent_name, }, snapshots: JSON.stringify({ input_token_count: event.input_token_count, @@ -653,6 +655,7 @@ export class QwenLogger { auth_type: event.auth_type, model: event.model, prompt_id: event.prompt_id, + subagent_name: event.subagent_name, error_message: event.error_message, error_type: event.error_type, }, diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index c3666ae9a..4a9335434 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -240,13 +240,24 @@ export class ApiRequestEvent implements BaseTelemetryEvent { model: string; prompt_id: string; request_text?: string; + /** + * Name of the subagent that issued this request, or undefined when the + * request originates from the main conversation. + */ + subagent_name?: string; - constructor(model: string, prompt_id: string, request_text?: string) { + constructor( + model: string, + prompt_id: string, + request_text?: string, + subagent_name?: string, + ) { this['event.name'] = 'api_request'; this['event.timestamp'] = new Date().toISOString(); this.model = model; this.prompt_id = prompt_id; this.request_text = request_text; + this.subagent_name = subagent_name; } } @@ -264,6 +275,11 @@ export class ApiErrorEvent implements BaseTelemetryEvent { error_type?: string; // HTTP status code from the API response (e.g. 429, 500) status_code?: number | string; + /** + * Name of the subagent that issued this request, or undefined when the + * request originates from the main conversation. + */ + subagent_name?: string; constructor(opts: { responseId?: string; @@ -274,6 +290,7 @@ export class ApiErrorEvent implements BaseTelemetryEvent { errorMessage: string; errorType?: string; statusCode?: number | string; + subagentName?: string; }) { this['event.name'] = 'api_error'; this['event.timestamp'] = new Date().toISOString(); @@ -285,6 +302,7 @@ export class ApiErrorEvent implements BaseTelemetryEvent { this.error_message = opts.errorMessage; this.error_type = opts.errorType; this.status_code = opts.statusCode; + this.subagent_name = opts.subagentName; } } @@ -320,6 +338,11 @@ export class ApiResponseEvent implements BaseTelemetryEvent { response_text?: string; prompt_id: string; auth_type?: string; + /** + * Name of the subagent that issued this request, or undefined when the + * request originates from the main conversation. + */ + subagent_name?: string; constructor( response_id: string, @@ -329,6 +352,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent { auth_type?: string, usage_data?: GenerateContentResponseUsageMetadata, response_text?: string, + subagent_name?: string, ) { this['event.name'] = 'api_response'; this['event.timestamp'] = new Date().toISOString(); @@ -345,6 +369,7 @@ export class ApiResponseEvent implements BaseTelemetryEvent { this.response_text = response_text; this.prompt_id = prompt_id; this.auth_type = auth_type; + this.subagent_name = subagent_name; } } diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index 37542273a..311aac4f9 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { UiTelemetryService } from './uiTelemetry.js'; +import { UiTelemetryService, MAIN_SOURCE } from './uiTelemetry.js'; import { ToolCallDecision } from './tool-call-decision.js'; import type { ApiErrorEvent, ApiResponseEvent } from './types.js'; import { ToolCallEvent } from './types.js'; @@ -187,7 +187,7 @@ describe('UiTelemetryService', () => { service.addEvent(event); const metrics = service.getMetrics(); - expect(metrics.models['gemini-2.5-pro']).toEqual({ + const modelAggregate = { api: { totalRequests: 1, totalErrors: 0, @@ -201,6 +201,12 @@ describe('UiTelemetryService', () => { thoughts: 2, tool: 3, }, + }; + expect(metrics.models['gemini-2.5-pro']).toEqual({ + ...modelAggregate, + bySource: { + [MAIN_SOURCE]: modelAggregate, + }, }); expect(service.getLastPromptTokenCount()).toBe(0); }); @@ -237,7 +243,7 @@ describe('UiTelemetryService', () => { service.addEvent(event2); const metrics = service.getMetrics(); - expect(metrics.models['gemini-2.5-pro']).toEqual({ + const modelAggregate = { api: { totalRequests: 2, totalErrors: 0, @@ -251,6 +257,12 @@ describe('UiTelemetryService', () => { thoughts: 6, tool: 9, }, + }; + expect(metrics.models['gemini-2.5-pro']).toEqual({ + ...modelAggregate, + bySource: { + [MAIN_SOURCE]: modelAggregate, + }, }); expect(service.getLastPromptTokenCount()).toBe(0); }); @@ -307,7 +319,7 @@ describe('UiTelemetryService', () => { service.addEvent(event); const metrics = service.getMetrics(); - expect(metrics.models['gemini-2.5-pro']).toEqual({ + const modelAggregate = { api: { totalRequests: 1, totalErrors: 1, @@ -321,6 +333,12 @@ describe('UiTelemetryService', () => { thoughts: 0, tool: 0, }, + }; + expect(metrics.models['gemini-2.5-pro']).toEqual({ + ...modelAggregate, + bySource: { + [MAIN_SOURCE]: modelAggregate, + }, }); }); @@ -349,7 +367,7 @@ describe('UiTelemetryService', () => { service.addEvent(errorEvent); const metrics = service.getMetrics(); - expect(metrics.models['gemini-2.5-pro']).toEqual({ + const modelAggregate = { api: { totalRequests: 2, totalErrors: 1, @@ -363,10 +381,161 @@ describe('UiTelemetryService', () => { thoughts: 2, tool: 3, }, + }; + expect(metrics.models['gemini-2.5-pro']).toEqual({ + ...modelAggregate, + bySource: { + [MAIN_SOURCE]: modelAggregate, + }, }); }); }); + describe('Subagent Source Attribution', () => { + it('attributes API calls without subagent_name to MAIN_SOURCE', () => { + const event = { + 'event.name': EVENT_API_RESPONSE, + model: 'glm-5', + duration_ms: 100, + input_token_count: 10, + output_token_count: 5, + total_token_count: 15, + cached_content_token_count: 0, + thoughts_token_count: 0, + tool_token_count: 0, + } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; + + service.addEvent(event); + + const modelMetrics = service.getMetrics().models['glm-5']; + expect(Object.keys(modelMetrics.bySource)).toEqual([MAIN_SOURCE]); + expect(modelMetrics.bySource[MAIN_SOURCE].api.totalRequests).toBe(1); + expect(modelMetrics.api.totalRequests).toBe(1); + }); + + it('splits a single model between main and a subagent', () => { + const mainEvent = { + 'event.name': EVENT_API_RESPONSE, + model: 'glm-5', + duration_ms: 200, + input_token_count: 100, + output_token_count: 50, + total_token_count: 150, + cached_content_token_count: 20, + thoughts_token_count: 0, + tool_token_count: 0, + } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; + const subagentEvent = { + 'event.name': EVENT_API_RESPONSE, + model: 'glm-5', + duration_ms: 80, + input_token_count: 40, + output_token_count: 10, + total_token_count: 50, + cached_content_token_count: 0, + thoughts_token_count: 0, + tool_token_count: 0, + subagent_name: 'echoer', + } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; + + service.addEvent(mainEvent); + service.addEvent(subagentEvent); + + const modelMetrics = service.getMetrics().models['glm-5']; + // Aggregate spans both main and subagent calls + expect(modelMetrics.api.totalRequests).toBe(2); + expect(modelMetrics.api.totalLatencyMs).toBe(280); + expect(modelMetrics.tokens.prompt).toBe(140); + expect(modelMetrics.tokens.total).toBe(200); + // Per-source breakdown isolates each contributor + expect(new Set(Object.keys(modelMetrics.bySource))).toEqual( + new Set([MAIN_SOURCE, 'echoer']), + ); + expect(modelMetrics.bySource[MAIN_SOURCE].api.totalRequests).toBe(1); + expect(modelMetrics.bySource[MAIN_SOURCE].tokens.prompt).toBe(100); + expect(modelMetrics.bySource['echoer'].api.totalRequests).toBe(1); + expect(modelMetrics.bySource['echoer'].tokens.prompt).toBe(40); + }); + + it('splits two subagents sharing a model into distinct source buckets', () => { + const makeEvent = ( + subagentName: string, + duration: number, + ): ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE } => + ({ + 'event.name': EVENT_API_RESPONSE, + model: 'glm-5', + duration_ms: duration, + input_token_count: 10, + output_token_count: 5, + total_token_count: 15, + cached_content_token_count: 0, + thoughts_token_count: 0, + tool_token_count: 0, + subagent_name: subagentName, + }) as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; + + service.addEvent(makeEvent('alpha', 50)); + service.addEvent(makeEvent('bravo', 70)); + + const modelMetrics = service.getMetrics().models['glm-5']; + expect(modelMetrics.api.totalRequests).toBe(2); + expect(Object.keys(modelMetrics.bySource).sort()).toEqual([ + 'alpha', + 'bravo', + ]); + expect(modelMetrics.bySource['alpha'].api.totalRequests).toBe(1); + expect(modelMetrics.bySource['bravo'].api.totalRequests).toBe(1); + // Main bucket should NOT be created when no main-origin event arrived + expect(modelMetrics.bySource[MAIN_SOURCE]).toBeUndefined(); + }); + + it('handles a subagent named after an Object.prototype member without crashing', () => { + // `constructor` is a valid subagent name per the naming regex. A + // plain-object `bySource` would return `Object.prototype.constructor` + // from a truthiness check, short-circuiting the bucket creation and + // crashing the aggregation path. The prototype-free map prevents this. + const event = { + 'event.name': EVENT_API_RESPONSE, + model: 'glm-5', + duration_ms: 100, + input_token_count: 10, + output_token_count: 5, + total_token_count: 15, + cached_content_token_count: 0, + thoughts_token_count: 0, + tool_token_count: 0, + subagent_name: 'constructor', + } as ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }; + + expect(() => service.addEvent(event)).not.toThrow(); + + const modelMetrics = service.getMetrics().models['glm-5']; + expect(modelMetrics.bySource['constructor']).toBeDefined(); + expect(modelMetrics.bySource['constructor'].api.totalRequests).toBe(1); + expect(modelMetrics.bySource['constructor'].tokens.prompt).toBe(10); + // Sanity: the Object prototype member was not actually mutated. + expect(typeof modelMetrics.bySource['constructor']).toBe('object'); + }); + + it('attributes API errors to the subagent source bucket', () => { + const errorEvent = { + 'event.name': EVENT_API_ERROR, + model: 'glm-5', + duration_ms: 150, + error_message: 'boom', + subagent_name: 'alpha', + } as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }; + + service.addEvent(errorEvent); + + const modelMetrics = service.getMetrics().models['glm-5']; + expect(modelMetrics.api.totalErrors).toBe(1); + expect(modelMetrics.bySource['alpha'].api.totalErrors).toBe(1); + expect(modelMetrics.bySource[MAIN_SOURCE]).toBeUndefined(); + }); + }); + describe('Tool Call Event Processing', () => { it('should process a single successful ToolCallEvent', () => { const toolCall = createFakeCompletedToolCall( diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index a7361f038..d4639bec3 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -17,6 +17,9 @@ import type { ApiResponseEvent, ToolCallEvent, } from './types.js'; +import { MAIN_SOURCE } from '../utils/subagentNameContext.js'; + +export { MAIN_SOURCE } from '../utils/subagentNameContext.js'; export type UiEvent = | (ApiResponseEvent & { 'event.name': typeof EVENT_API_RESPONSE }) @@ -42,7 +45,12 @@ export interface ToolCallStats { }; } -export interface ModelMetrics { +/** + * Per-model counters without the nested source breakdown. Used both as the + * aggregate `ModelMetrics` shape (via extension) and as the value type of the + * `bySource` map — keeping the type non-recursive. + */ +export interface ModelMetricsCore { api: { totalRequests: number; totalErrors: number; @@ -58,6 +66,16 @@ export interface ModelMetrics { }; } +export interface ModelMetrics extends ModelMetricsCore { + /** + * Per-source breakdown. Keys are subagent names, or `MAIN_SOURCE` ("main") + * for calls originating from the main conversation. Every API call that + * increments an aggregate counter also increments the matching per-source + * record so the two views stay consistent. + */ + bySource: Record<string, ModelMetricsCore>; +} + export interface SessionMetrics { models: Record<string, ModelMetrics>; tools: { @@ -79,7 +97,7 @@ export interface SessionMetrics { }; } -const createInitialModelMetrics = (): ModelMetrics => ({ +const createInitialModelMetricsCore = (): ModelMetricsCore => ({ api: { totalRequests: 0, totalErrors: 0, @@ -95,6 +113,16 @@ const createInitialModelMetrics = (): ModelMetrics => ({ }, }); +// `bySource` keys are user-controlled subagent names. Using a prototype-free +// map avoids crashes when a subagent is named after an inherited Object +// member (e.g. `constructor`, `toString`, `hasOwnProperty`), which would +// otherwise short-circuit `!bySource[name]` checks and return the inherited +// prototype member as the "bucket". +const createInitialModelMetrics = (): ModelMetrics => ({ + ...createInitialModelMetricsCore(), + bySource: Object.create(null) as Record<string, ModelMetricsCore>, +}); + const createInitialMetrics = (): SessionMetrics => ({ models: {}, tools: { @@ -187,25 +215,48 @@ export class UiTelemetryService extends EventEmitter { return this.#metrics.models[modelName]; } + private getOrCreateSourceMetrics( + modelMetrics: ModelMetrics, + source: string, + ): ModelMetricsCore { + if (!modelMetrics.bySource[source]) { + modelMetrics.bySource[source] = createInitialModelMetricsCore(); + } + return modelMetrics.bySource[source]; + } + private processApiResponse(event: ApiResponseEvent) { const modelMetrics = this.getOrCreateModelMetrics(event.model); + const sourceMetrics = this.getOrCreateSourceMetrics( + modelMetrics, + event.subagent_name ?? MAIN_SOURCE, + ); - modelMetrics.api.totalRequests++; - modelMetrics.api.totalLatencyMs += event.duration_ms; + for (const bucket of [modelMetrics, sourceMetrics]) { + bucket.api.totalRequests++; + bucket.api.totalLatencyMs += event.duration_ms; - modelMetrics.tokens.prompt += event.input_token_count; - modelMetrics.tokens.candidates += event.output_token_count; - modelMetrics.tokens.total += event.total_token_count; - modelMetrics.tokens.cached += event.cached_content_token_count; - modelMetrics.tokens.thoughts += event.thoughts_token_count; - modelMetrics.tokens.tool += event.tool_token_count; + bucket.tokens.prompt += event.input_token_count; + bucket.tokens.candidates += event.output_token_count; + bucket.tokens.total += event.total_token_count; + bucket.tokens.cached += event.cached_content_token_count; + bucket.tokens.thoughts += event.thoughts_token_count; + bucket.tokens.tool += event.tool_token_count; + } } private processApiError(event: ApiErrorEvent) { const modelMetrics = this.getOrCreateModelMetrics(event.model); - modelMetrics.api.totalRequests++; - modelMetrics.api.totalErrors++; - modelMetrics.api.totalLatencyMs += event.duration_ms; + const sourceMetrics = this.getOrCreateSourceMetrics( + modelMetrics, + event.subagent_name ?? MAIN_SOURCE, + ); + + for (const bucket of [modelMetrics, sourceMetrics]) { + bucket.api.totalRequests++; + bucket.api.totalErrors++; + bucket.api.totalLatencyMs += event.duration_ms; + } } private processToolCall(event: ToolCallEvent) { diff --git a/packages/core/src/tools/agent/agent.ts b/packages/core/src/tools/agent/agent.ts index c89f5e876..4de325718 100644 --- a/packages/core/src/tools/agent/agent.ts +++ b/packages/core/src/tools/agent/agent.ts @@ -49,6 +49,7 @@ import type { AgentFinishEvent, AgentErrorEvent, AgentApprovalRequestEvent, + AgentUsageEvent, } from '../../agents/runtime/agent-events.js'; import { BuiltinAgentRegistry } from '../../subagents/builtin-agents.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; @@ -521,6 +522,26 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> { ); }); + // Track real-time token consumption from subagent API calls. + // Each USAGE_METADATA event carries per-round usage, so we accumulate + // output tokens across rounds. We use candidatesTokenCount (output-only) + // to stay consistent with the main stream's chars/4 output-token estimate. + let accumulatedOutputTokens = 0; + this.eventEmitter.on( + AgentEventType.USAGE_METADATA, + (...args: unknown[]) => { + const event = args[0] as AgentUsageEvent; + const outputTokens = event.usage?.candidatesTokenCount ?? 0; + if (outputTokens > 0) { + accumulatedOutputTokens += outputTokens; + this.updateDisplay( + { tokenCount: accumulatedOutputTokens }, + updateOutput, + ); + } + }, + ); + // Indicate when a tool call is waiting for approval this.eventEmitter.on( AgentEventType.TOOL_WAITING_APPROVAL, diff --git a/packages/core/src/tools/skill.test.ts b/packages/core/src/tools/skill.test.ts index 821d407b9..a6bdfb0d4 100644 --- a/packages/core/src/tools/skill.test.ts +++ b/packages/core/src/tools/skill.test.ts @@ -76,6 +76,8 @@ describe('SkillTool', () => { getSessionId: vi.fn().mockReturnValue('test-session-id'), getSkillManager: vi.fn(), getGeminiClient: vi.fn().mockReturnValue(undefined), + getModelInvocableCommandsProvider: vi.fn().mockReturnValue(null), + getModelInvocableCommandsExecutor: vi.fn().mockReturnValue(null), } as unknown as Config; changeListeners = []; @@ -434,6 +436,162 @@ describe('SkillTool', () => { }); }); + describe('modelInvocableCommands integration', () => { + const mockCommands = [ + { name: 'review', description: 'Bundled code review skill' }, + { name: 'mcp-prompt-a', description: 'An MCP prompt' }, + ]; + + it('should show non-skill commands in <available_skills> section', async () => { + // 'review' and 'mcp-prompt-a' don't overlap with file skills + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => mockCommands, + ); + + const tool = new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(tool.description).not.toContain('<available_commands>'); + expect(tool.description).toContain('<available_skills>'); + expect(tool.description).toContain('review'); + expect(tool.description).toContain('mcp-prompt-a'); + }); + + it('should not duplicate commands already present as file-based skills', async () => { + // 'code-review' matches a skill in mockSkills → should be filtered out + const commandsIncludingSkill = [ + { name: 'code-review', description: 'Bundled version of code-review' }, + { name: 'mcp-prompt-a', description: 'An MCP prompt' }, + ]; + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => commandsIncludingSkill, + ); + + const tool = new SkillTool(config); + await vi.runAllTimersAsync(); + + // 'code-review' is already in <available_skills> as a file skill, must NOT appear twice + const codeReviewMatches = (tool.description.match(/code-review/g) || []) + .length; + expect(codeReviewMatches).toBe(1); + // 'mcp-prompt-a' is not a file-based skill, must appear in the unified list + expect(tool.description).toContain('mcp-prompt-a'); + }); + + it('should hide <available_commands> when all commands are already covered by skills', async () => { + // Both command names match existing skills + const commandsAllOverlapping = [ + { name: 'code-review', description: 'Bundled code-review' }, + { name: 'testing', description: 'Bundled testing' }, + ]; + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => commandsAllOverlapping, + ); + + const tool = new SkillTool(config); + await vi.runAllTimersAsync(); + + expect(tool.description).not.toContain('<available_commands>'); + // All commands overlapped with file skills, so no extra entries added + expect(tool.description).toContain('<available_skills>'); + }); + }); + + describe('validateToolParams with modelInvocableCommands', () => { + beforeEach(async () => { + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => [{ name: 'mcp-prompt-a', description: 'An MCP prompt' }], + ); + await skillTool.refreshSkills(); + }); + + it('should accept a model-invocable command name that is not a file skill', () => { + const result = skillTool.validateToolParams({ skill: 'mcp-prompt-a' }); + expect(result).toBeNull(); + }); + + it('should reject a name not in skills or commands, listing both in error', () => { + const result = skillTool.validateToolParams({ skill: 'unknown' }); + expect(result).toContain('"unknown" not found'); + expect(result).toContain('code-review'); + expect(result).toContain('mcp-prompt-a'); + }); + }); + + describe('commandExecutor fallback in execute()', () => { + beforeEach(async () => { + // Expose an MCP-only command that has no file-based skill + vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue( + () => [{ name: 'mcp-prompt-a', description: 'An MCP prompt' }], + ); + await skillTool.refreshSkills(); + }); + + it('should invoke commandExecutor when loadSkillForRuntime returns null', async () => { + const executor = vi.fn().mockResolvedValue('Prompt content from MCP'); + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue( + executor, + ); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'mcp-prompt-a' }); + const result = await invocation.execute(); + + expect(executor).toHaveBeenCalledWith('mcp-prompt-a'); + const llmText = partToString(result.llmContent); + expect(llmText).toBe('Prompt content from MCP'); + expect(result.returnDisplay).toBe('Executed command: mcp-prompt-a'); + }); + + it('should fall through to not-found error when executor returns null', async () => { + const executor = vi.fn().mockResolvedValue(null); + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue( + executor, + ); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'mcp-prompt-a' }); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('"mcp-prompt-a" not found'); + }); + + it('should skip commandExecutor when no executor is registered', async () => { + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(null); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'mcp-prompt-a' }); + const result = await invocation.execute(); + + const llmText = partToString(result.llmContent); + expect(llmText).toContain('"mcp-prompt-a" not found'); + }); + + it('should use loadSkillForRuntime first and skip executor when skill is found', async () => { + const executor = vi.fn().mockResolvedValue('Should not be called'); + vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue( + executor, + ); + vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue( + mockSkills[0], + ); + + const invocation = ( + skillTool as SkillToolWithProtectedMethods + ).createInvocation({ skill: 'code-review' }); + await invocation.execute(); + + expect(executor).not.toHaveBeenCalled(); + }); + }); + describe('modelOverride propagation', () => { it('should propagate model from skill config to ToolResult', async () => { const skillWithModel: SkillConfig = { diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index c4e90ea76..f2a7a0c7f 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -35,6 +35,10 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> { private skillManager: SkillManager; private availableSkills: SkillConfig[] = []; + private modelInvocableCommands: ReadonlyArray<{ + name: string; + description: string; + }> = []; private loadedSkillNames: Set<string> = new Set(); constructor(private readonly config: Config) { @@ -81,11 +85,23 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> { */ async refreshSkills(): Promise<void> { try { - this.availableSkills = await this.skillManager.listSkills(); + this.availableSkills = (await this.skillManager.listSkills()).filter( + (s) => !s.disableModelInvocation, + ); + // Merge in model-invocable commands from CommandService (injected via Config), + // but exclude any whose names already appear as file-based skills to avoid + // showing the same skill in both <available_skills> and <available_commands>. + const provider = this.config.getModelInvocableCommandsProvider(); + const allCommands = provider ? provider() : []; + const skillNames = new Set(this.availableSkills.map((s) => s.name)); + this.modelInvocableCommands = allCommands.filter( + (cmd) => !skillNames.has(cmd.name), + ); this.updateDescriptionAndSchema(); } catch (error) { debugLogger.warn('Failed to load skills for Skills tool:', error); this.availableSkills = []; + this.modelInvocableCommands = []; this.updateDescriptionAndSchema(); } finally { // Update the client with the new tools @@ -97,29 +113,45 @@ export class SkillTool extends BaseDeclarativeTool<SkillParams, ToolResult> { } /** - * Updates the tool's description and schema based on available skills. + * Updates the tool's description and schema based on available skills and + * model-invocable commands (e.g. bundled skills, file commands, MCP prompts). */ private updateDescriptionAndSchema(): void { - let skillDescriptions = ''; - if (this.availableSkills.length === 0) { - skillDescriptions = - 'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.'; - } else { - skillDescriptions = this.availableSkills - .map( - (skill) => `<skill> + // Merge file-based skills and prompt commands into a single unified list, + // matching Claude Code's design where all invocable commands are listed together. + const allSkillEntries: string[] = []; + + for (const skill of this.availableSkills) { + allSkillEntries.push(`<skill> <name> ${skill.name} </name> <description> -${skill.description} (${skill.level}) +${skill.description}${skill.whenToUse ? ` — ${skill.whenToUse}` : ''} (${skill.level}) </description> <location> ${skill.level} </location> -</skill>`, - ) - .join('\n'); +</skill>`); + } + + for (const cmd of this.modelInvocableCommands) { + allSkillEntries.push(`<skill> +<name> +${cmd.name} +</name> +<description> +${cmd.description} +</description> +</skill>`); + } + + let skillDescriptions = ''; + if (allSkillEntries.length === 0) { + skillDescriptions = + 'No skills are currently configured. Skills can be created by adding directories with SKILL.md files to .qwen/skills/ or ~/.qwen/skills/.'; + } else { + skillDescriptions = allSkillEntries.join('\n'); } const baseDescription = `Execute a skill within the main conversation @@ -149,8 +181,7 @@ Important: <available_skills> ${skillDescriptions} -</available_skills> -`; +</available_skills>`; // Update description using object property assignment (this as { description: string }).description = baseDescription; } @@ -165,20 +196,26 @@ ${skillDescriptions} return 'Parameter "skill" must be a non-empty string.'; } - // Validate that the skill exists + // Check file-based skills const skillExists = this.availableSkills.some( (skill) => skill.name === params.skill, ); + if (skillExists) return null; - if (!skillExists) { - const availableNames = this.availableSkills.map((s) => s.name); - if (availableNames.length === 0) { - return `Skill "${params.skill}" not found. No skills are currently available.`; - } - return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`; + // Check model-invocable commands (e.g. MCP prompts) listed in the description + const commandExists = this.modelInvocableCommands.some( + (cmd) => cmd.name === params.skill, + ); + if (commandExists) return null; + + const availableNames = [ + ...this.availableSkills.map((s) => s.name), + ...this.modelInvocableCommands.map((c) => c.name), + ]; + if (availableNames.length === 0) { + return `Skill "${params.skill}" not found. No skills are currently available.`; } - - return null; + return `Skill "${params.skill}" not found. Available skills: ${availableNames.join(', ')}`; } protected createInvocation(params: SkillParams) { @@ -187,6 +224,7 @@ ${skillDescriptions} this.skillManager, params, (name: string) => this.loadedSkillNames.add(name), + this.config.getModelInvocableCommandsExecutor(), ); } @@ -218,6 +256,9 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> { private readonly skillManager: SkillManager, params: SkillParams, private readonly onSkillLoaded: (name: string) => void, + private readonly commandExecutor: + | ((name: string, args?: string) => Promise<string | null>) + | null = null, ) { super(params); } @@ -237,6 +278,22 @@ class SkillToolInvocation extends BaseToolInvocation<SkillParams, ToolResult> { ); if (!skill) { + // Try model-invocable command executor (e.g. MCP prompts) + if (this.commandExecutor) { + const content = await this.commandExecutor(this.params.skill); + if (content !== null) { + logSkillLaunch( + this.config, + new SkillLaunchEvent(this.params.skill, true), + ); + this.onSkillLoaded(this.params.skill); + return { + llmContent: [{ text: content }], + returnDisplay: `Executed command: ${this.params.skill}`, + }; + } + } + // Log failed skill launch logSkillLaunch( this.config, diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index dd8d68e9b..c807cf05c 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -499,6 +499,8 @@ export interface AgentResultDisplay { terminateReason?: string; result?: string; executionSummary?: AgentStatsSummary; + /** Real-time output-token count during execution, accumulated across subagent rounds. */ + tokenCount?: number; // If the subagent is awaiting approval for a tool call, // this contains the confirmation details for inline UI rendering. diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts index a2ac74680..14a88686f 100644 --- a/packages/core/src/utils/editor.test.ts +++ b/packages/core/src/utils/editor.test.ts @@ -22,6 +22,7 @@ import { type EditorType, } from './editor.js'; import { execSync, spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; vi.mock('child_process', () => ({ execSync: vi.fn(), @@ -29,6 +30,10 @@ vi.mock('child_process', () => ({ spawnSync: vi.fn(() => ({ error: null, status: 0 })), })); +vi.mock('fs', () => ({ + existsSync: vi.fn(), +})); + const originalPlatform = process.platform; describe('editor utils', () => { @@ -171,7 +176,6 @@ describe('editor utils', () => { win32Commands: ['windsurf'], }, { editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] }, - { editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] }, { editor: 'trae', commands: ['trae'], win32Commands: ['trae'] }, ]; @@ -314,6 +318,57 @@ describe('editor utils', () => { const command = getDiffCommand('old.txt', 'new.txt', 'foobar'); expect(command).toBeNull(); }); + + // Zed-specific tests (Zed is handled specially for macOS app detection) + describe('Zed', () => { + it('should use CLI command "zed" when it exists on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + (execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/zed')); + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).toEqual({ + command: 'zed', + args: ['--wait', '--diff', 'old.txt', 'new.txt'], + }); + }); + + it('should use CLI command "zeditor" when "zed" does not exist on Linux', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + (execSync as Mock) + .mockImplementationOnce(() => { + throw new Error(); // zed not found + }) + .mockReturnValueOnce(Buffer.from('/usr/bin/zeditor')); // zeditor found + + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).toEqual({ + command: 'zeditor', + args: ['--wait', '--diff', 'old.txt', 'new.txt'], + }); + }); + + it('should return null on Linux when no CLI commands exist', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // all commands not found + }); + (existsSync as Mock).mockReturnValue(false); + + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).toBeNull(); + }); + + it('should use CLI command "zed" on Windows when it exists', () => { + Object.defineProperty(process, 'platform', { value: 'win32' }); + (execSync as Mock).mockReturnValue( + Buffer.from('C:\\Program Files\\Zed\\zed.exe'), + ); + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).toEqual({ + command: 'zed', + args: ['--wait', '--diff', 'old.txt', 'new.txt'], + }); + }); + }); }); describe('openDiff', () => { @@ -322,7 +377,6 @@ describe('editor utils', () => { 'vscodium', 'windsurf', 'cursor', - 'zed', 'trae', ]; @@ -377,6 +431,67 @@ describe('editor utils', () => { }); } + // Zed-specific openDiff tests + describe('Zed', () => { + it('should call spawn for zed on macOS with CLI', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed')); + (existsSync as Mock).mockReturnValue(false); + + const mockSpawnOn = vi.fn((event, cb) => { + if (event === 'close') { + cb(0); + } + }); + (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); + + await openDiff('old.txt', 'new.txt', 'zed', () => {}); + expect(spawn).toHaveBeenCalledWith( + 'zed', + ['--wait', '--diff', 'old.txt', 'new.txt'], + { + stdio: 'inherit', + shell: false, + }, + ); + }); + + it('should call spawn for zed on macOS with app bundle CLI', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + // Accept any path containing Zed.app + (existsSync as Mock).mockImplementation((path: string) => + path.includes('Zed.app'), + ); + + const mockSpawnOn = vi.fn((event, cb) => { + if (event === 'close') { + cb(0); + } + }); + (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); + + await openDiff('old.txt', 'new.txt', 'zed', () => {}); + expect(spawn).toHaveBeenCalled(); + // Verify the command uses the CLI tool (not GUI binary) + const call = (spawn as Mock).mock.calls[0]; + expect(call[0]).toMatch(/MacOS[/\\]cli$/); + }); + + it('should reject if zed is not installed', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + (existsSync as Mock).mockReturnValue(false); // App not found + + await openDiff('old.txt', 'new.txt', 'zed', () => {}); + // Should complete without throwing (logs error to debugLogger) + }); + }); + const terminalEditors: EditorType[] = ['vim', 'neovim', 'emacs']; for (const editor of terminalEditors) { @@ -427,7 +542,6 @@ describe('editor utils', () => { 'vscodium', 'windsurf', 'cursor', - 'zed', 'trae', ]; for (const editor of guiEditors) { @@ -443,6 +557,23 @@ describe('editor utils', () => { expect(onEditorClose).not.toHaveBeenCalled(); }); } + + // Zed-specific onEditorClose tests + it('should not call onEditorClose for zed', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed')); + (existsSync as Mock).mockReturnValue(false); + + const onEditorClose = vi.fn(); + const mockSpawnOn = vi.fn((event, cb) => { + if (event === 'close') { + cb(0); + } + }); + (spawn as Mock).mockReturnValue({ on: mockSpawnOn }); + await openDiff('old.txt', 'new.txt', 'zed', onEditorClose); + expect(onEditorClose).not.toHaveBeenCalled(); + }); }); }); @@ -543,4 +674,103 @@ describe('editor utils', () => { expect(isEditorAvailable('neovim')).toBe(true); }); }); + + describe('Zed macOS app detection', () => { + describe('checkHasEditorType for Zed', () => { + it('should return true on macOS when Zed.app exists even if CLI is not in PATH', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + (existsSync as Mock).mockReturnValue(true); // Zed.app exists + expect(checkHasEditorType('zed')).toBe(true); + }); + + it('should return false on macOS when Zed.app does not exist and CLI is not in PATH', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + (existsSync as Mock).mockReturnValue(false); // Zed.app does not exist + expect(checkHasEditorType('zed')).toBe(false); + }); + + it('should return true on macOS when Zed CLI is in PATH', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed')); + expect(checkHasEditorType('zed')).toBe(true); + }); + + it('should not check for Zed.app on non-macOS platforms', () => { + Object.defineProperty(process, 'platform', { value: 'linux' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + (existsSync as Mock).mockReturnValue(true); // This should be ignored on Linux + expect(checkHasEditorType('zed')).toBe(false); + }); + }); + + describe('getDiffCommand for Zed on macOS', () => { + it('should use app bundle CLI path when CLI is not in PATH', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + // Accept any path containing Zed.app (the CLI check will be for Contents/MacOS/cli) + (existsSync as Mock).mockImplementation((path: string) => + path.includes('Zed.app'), + ); + + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).not.toBeNull(); + // Verify the command ends with cli (the CLI tool, not GUI binary zed) + expect(diffCommand!.command).toMatch(/MacOS[/\\]cli$/); + expect(diffCommand!.args).toEqual([ + '--wait', + '--diff', + 'old.txt', + 'new.txt', + ]); + }); + + it('should prefer CLI in PATH over app bundle', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed')); + (existsSync as Mock).mockReturnValue(true); // App also exists + + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).toEqual({ + command: 'zed', + args: ['--wait', '--diff', 'old.txt', 'new.txt'], + }); + }); + + it('should return null when Zed is not installed at all', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + (existsSync as Mock).mockReturnValue(false); // App not found + + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).toBeNull(); + }); + + it('should check user Applications folder as fallback', () => { + Object.defineProperty(process, 'platform', { value: 'darwin' }); + (execSync as Mock).mockImplementation(() => { + throw new Error(); // CLI not found + }); + // Accept any path containing Zed.app + (existsSync as Mock).mockImplementation((path: string) => + path.includes('Zed.app'), + ); + + const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed'); + expect(diffCommand).not.toBeNull(); + expect(diffCommand!.command).toMatch(/MacOS[/\\]cli$/); + }); + }); + }); }); diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts index 70f574ab4..c19963169 100644 --- a/packages/core/src/utils/editor.ts +++ b/packages/core/src/utils/editor.ts @@ -5,6 +5,9 @@ */ import { execSync, spawn, spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; import { createDebugLogger } from './debugLogger.js'; const debugLogger = createDebugLogger('EDITOR'); @@ -51,6 +54,15 @@ export function commandExists(cmd: string): boolean { } } +/** + * Get possible paths for Zed.app on macOS. + * Returns paths lazily to avoid calling os.homedir() at module initialization time, + * which would break tests that mock node:os without providing a homedir mock. + */ +function getZedAppPaths(): string[] { + return ['/Applications/Zed.app', join(homedir(), 'Applications/Zed.app')]; +} + /** * Editor command configurations for different platforms. * Each editor can have multiple possible command names, listed in order of preference. @@ -70,11 +82,67 @@ export const editorCommands: Record< trae: { win32: ['trae'], default: ['trae'] }, }; -export function checkHasEditorType(editor: EditorType): boolean { - const commandConfig = editorCommands[editor]; +/** + * Get the Zed command to use for opening files/diffs. + * On macOS, if the CLI is not in PATH, fall back to using the app bundle's CLI. + */ +function getZedCommand(): string | null { + // Check CLI commands first + const commands = editorCommands.zed.default; + for (const cmd of commands) { + if (commandExists(cmd)) { + return cmd; + } + } + + // On macOS, check for app bundle CLI + if (process.platform === 'darwin') { + for (const appPath of getZedAppPaths()) { + const cliPath = join(appPath, 'Contents/MacOS/cli'); + if (existsSync(cliPath)) { + return cliPath; + } + } + } + + return null; +} + +/** + * Get the executable command for a given editor type. + * Resolves both CLI commands and platform-specific fallbacks (e.g., macOS app bundles). + * This is the shared function used by both getDiffCommand and useLaunchEditor. + * Returns null if no editor command is found. + */ +export function getEditorExecutable(editorType: EditorType): string | null { + const commandConfig = editorCommands[editorType]; const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; - return commands.some((cmd) => commandExists(cmd)); + + // Check if any of the CLI commands exist + const found = commands.find((cmd) => commandExists(cmd)); + if (found) { + return found; + } + + // Special handling for Zed on macOS: check app bundle CLI as fallback + if (editorType === 'zed' && process.platform === 'darwin') { + for (const appPath of getZedAppPaths()) { + const cliPath = join(appPath, 'Contents/MacOS/cli'); + if (existsSync(cliPath)) { + return cliPath; + } + } + } + + // No command found + return null; +} + +export function checkHasEditorType(editor: EditorType): boolean { + // Use the same resolution logic as getEditorExecutable to keep + // availability detection and execution in sync. + return getEditorExecutable(editor) !== null; } export function allowEditorTypeInSandbox(editor: EditorType): boolean { @@ -110,6 +178,16 @@ export function getDiffCommand( if (!isValidEditorType(editor)) { return null; } + + // Special handling for Zed on macOS + if (editor === 'zed') { + const zedCmd = getZedCommand(); + if (!zedCmd) { + return null; + } + return { command: zedCmd, args: ['--wait', '--diff', oldPath, newPath] }; + } + const commandConfig = editorCommands[editor]; const commands = process.platform === 'win32' ? commandConfig.win32 : commandConfig.default; @@ -122,7 +200,6 @@ export function getDiffCommand( case 'vscodium': case 'windsurf': case 'cursor': - case 'zed': case 'trae': return { command, args: ['--wait', '--diff', oldPath, newPath] }; case 'vim': diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index d9aaa5ba1..0fd247860 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -5,9 +5,21 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + afterAll, +} from 'vitest'; import type { HttpError } from './retry.js'; -import { retryWithBackoff } from './retry.js'; +import { + retryWithBackoff, + isTransientCapacityError, + isUnattendedMode, +} from './retry.js'; import { getErrorStatus } from './errors.js'; import { setSimulate429 } from './testUtils.js'; import { AuthType } from '../core/contentGenerator.js'; @@ -462,6 +474,447 @@ describe('retryWithBackoff', () => { }); }); +describe('isTransientCapacityError', () => { + it('should return true for 429 errors', () => { + const error = { status: 429 }; + expect(isTransientCapacityError(error)).toBe(true); + }); + + it('should return true for 529 errors', () => { + const error = { status: 529 }; + expect(isTransientCapacityError(error)).toBe(true); + }); + + it('should return false for 500 errors', () => { + const error = { status: 500 }; + expect(isTransientCapacityError(error)).toBe(false); + }); + + it('should return false for 400 errors', () => { + const error = { status: 400 }; + expect(isTransientCapacityError(error)).toBe(false); + }); + + it('should return false for errors without status', () => { + expect(isTransientCapacityError(new Error('generic'))).toBe(false); + expect(isTransientCapacityError(null)).toBe(false); + }); +}); + +describe('isUnattendedMode', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env['QWEN_CODE_UNATTENDED_RETRY']; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + it('should return true when QWEN_CODE_UNATTENDED_RETRY=1', () => { + process.env['QWEN_CODE_UNATTENDED_RETRY'] = '1'; + expect(isUnattendedMode()).toBe(true); + }); + + it('should return true when QWEN_CODE_UNATTENDED_RETRY=true', () => { + process.env['QWEN_CODE_UNATTENDED_RETRY'] = 'true'; + expect(isUnattendedMode()).toBe(true); + }); + + it('should return false when no env vars are set', () => { + expect(isUnattendedMode()).toBe(false); + }); + + it('should NOT activate on CI=true alone', () => { + process.env['CI'] = 'true'; + expect(isUnattendedMode()).toBe(false); + }); + + it('should return false for non-matching values', () => { + process.env['QWEN_CODE_UNATTENDED_RETRY'] = '0'; + expect(isUnattendedMode()).toBe(false); + process.env['QWEN_CODE_UNATTENDED_RETRY'] = 'false'; + expect(isUnattendedMode()).toBe(false); + process.env['QWEN_CODE_UNATTENDED_RETRY'] = ''; + expect(isUnattendedMode()).toBe(false); + }); + + it('should use strict matching consistent with parseBooleanEnvFlag', () => { + // Only 'true' and '1' are accepted — matches project convention + process.env['QWEN_CODE_UNATTENDED_RETRY'] = 'TRUE'; + expect(isUnattendedMode()).toBe(false); // strict: not 'true' + process.env['QWEN_CODE_UNATTENDED_RETRY'] = ' 1 '; + expect(isUnattendedMode()).toBe(false); // strict: not '1' + process.env['QWEN_CODE_UNATTENDED_RETRY'] = 'yes'; + expect(isUnattendedMode()).toBe(false); + }); +}); + +describe('retryWithBackoff - persistent mode', () => { + beforeEach(() => { + vi.useFakeTimers(); + setSimulate429(false); + console.warn = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('should retry indefinitely for 429 errors in persistent mode', async () => { + // Fail 10 times with 429, then succeed + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 10) { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, // Would normally fail after 3 + initialDelayMs: 10, + persistentMode: true, + }); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(11); // 10 failures + 1 success + }); + + it('should retry indefinitely for 529 errors in persistent mode', async () => { + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 8) { + const error: HttpError = new Error('Overloaded'); + error.status = 529; + throw error; + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 10, + persistentMode: true, + }); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(9); + }); + + it('should NOT retry indefinitely for 500 errors in persistent mode', async () => { + const fn = vi.fn(async () => { + const error: HttpError = new Error('Internal Server Error'); + error.status = 500; + throw error; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 10, + persistentMode: true, + }); + + // eslint-disable-next-line vitest/valid-expect + const assertionPromise = expect(promise).rejects.toThrow( + 'Internal Server Error', + ); + await vi.runAllTimersAsync(); + await assertionPromise; + + // Should stop at maxAttempts for non-transient errors + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should cap single retry backoff at persistentMaxBackoffMs', async () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 20) { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + persistentMode: true, + persistentMaxBackoffMs: 5000, // 5 seconds cap for test + }); + + await vi.runAllTimersAsync(); + await promise; + + // Jitter is re-capped, so no delay should exceed the cap itself + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + for (const d of delays) { + expect(d).toBeLessThanOrEqual(5000 + 1); // cap + rounding tolerance + } + }); + + it('should call heartbeatFn during persistent retry waits', async () => { + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 2) { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + } + return 'success'; + }); + + const heartbeatFn = vi.fn(); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + persistentMode: true, + heartbeatIntervalMs: 30, // Short interval for test + heartbeatFn, + }); + + await vi.runAllTimersAsync(); + await promise; + + // Heartbeat should have been called at least once during waits > heartbeatInterval + expect(heartbeatFn).toHaveBeenCalled(); + // Verify heartbeat info structure + const call = heartbeatFn.mock.calls[0][0]; + expect(call).toHaveProperty('attempt'); + expect(call).toHaveProperty('remainingMs'); + expect(call).toHaveProperty('error'); + }); + + it('should abort persistent retry when signal is aborted', async () => { + const controller = new AbortController(); + const fn = vi.fn(async () => { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 10000, // Long delay so abort happens during sleep + persistentMode: true, + heartbeatIntervalMs: 50, + signal: controller.signal, + }); + + // Abort after the first retry starts waiting + setTimeout(() => controller.abort(), 100); + + // eslint-disable-next-line vitest/valid-expect + const assertionPromise = expect(promise).rejects.toThrow( + 'Retry aborted by signal', + ); + await vi.runAllTimersAsync(); + await assertionPromise; + }); + + it('should respect shouldRetryOnError even in persistent mode', async () => { + // Caller explicitly says "don't retry 429" — persistent mode must obey + const fn = vi.fn(async () => { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 10, + persistentMode: true, + shouldRetryOnError: () => false, // force fast-fail + }); + + // eslint-disable-next-line vitest/valid-expect + const assertionPromise = expect(promise).rejects.toThrow('Rate limited'); + await vi.runAllTimersAsync(); + await assertionPromise; + + // Should fail on first attempt — shouldRetryOnError trumps persistent mode + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should not infinite-loop when heartbeatIntervalMs is 0', async () => { + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 2) { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 10, + persistentMode: true, + heartbeatIntervalMs: 0, // Would cause infinite loop without Math.max(1, ...) + }); + + await vi.runAllTimersAsync(); + const result = await promise; + + expect(result).toBe('success'); + expect(fn).toHaveBeenCalledTimes(3); + }); + + it('should not affect normal mode behavior when persistentMode is false', async () => { + const fn = vi.fn(async () => { + const error: HttpError = new Error('Rate limited'); + error.status = 429; + throw error; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 10, + persistentMode: false, + }); + + // eslint-disable-next-line vitest/valid-expect + const assertionPromise = expect(promise).rejects.toThrow('Rate limited'); + await vi.runAllTimersAsync(); + await assertionPromise; + + expect(fn).toHaveBeenCalledTimes(3); + }); +}); + +describe('retryWithBackoff - Retry-After handling in persistent mode', () => { + beforeEach(() => { + vi.useFakeTimers(); + setSimulate429(false); + console.warn = vi.fn(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + // Helper: create a 429 error with Retry-After header + function make429WithRetryAfter(seconds: number): HttpError { + const error: HttpError & { response: { headers: Record<string, string> } } = + Object.assign(new Error('Rate limited'), { + status: 429, + response: { headers: { 'retry-after': String(seconds) } }, + }); + return error; + } + + it('should respect Retry-After and NOT cap at maxBackoff', async () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 1) { + throw make429WithRetryAfter(600); // server says wait 10 minutes + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + persistentMode: true, + persistentMaxBackoffMs: 5000, // 5 seconds — Retry-After must NOT be capped to this + }); + + await vi.runAllTimersAsync(); + await promise; + + // The first retry delay should be ~600s (600000ms), not 5s (5000ms) + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + const firstRetryDelay = delays[0]; + expect(firstRetryDelay).toBeGreaterThan(5000); // NOT capped at maxBackoff + expect(firstRetryDelay).toBeLessThanOrEqual(600 * 1000); // respects server value + }); + + it('should cap Retry-After at persistentCapMs', async () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 1) { + throw make429WithRetryAfter(100); // server says wait 100s + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + persistentMode: true, + persistentCapMs: 50_000, // absolute cap 50s — less than Retry-After + }); + + await vi.runAllTimersAsync(); + await promise; + + // Delay should be capped at persistentCapMs (50s), not the full 100s + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + const firstRetryDelay = delays[0]; + expect(firstRetryDelay).toBeLessThanOrEqual(50_000 + 1); + }); + + it('should NOT add jitter to Retry-After delays', async () => { + const setTimeoutSpy = vi.spyOn(global, 'setTimeout'); + // Run multiple times to check for jitter variance + const observedDelays: number[] = []; + + for (let run = 0; run < 5; run++) { + setTimeoutSpy.mockClear(); + let attempts = 0; + const fn = vi.fn(async () => { + attempts++; + if (attempts <= 1) { + throw make429WithRetryAfter(10); // 10 seconds + } + return 'success'; + }); + + const promise = retryWithBackoff(fn, { + maxAttempts: 3, + initialDelayMs: 100, + persistentMode: true, + }); + + await vi.runAllTimersAsync(); + await promise; + + const delays = setTimeoutSpy.mock.calls.map((call) => call[1] as number); + observedDelays.push(delays[0]); + } + + // All delays should be exactly 10000ms — no jitter + for (const d of observedDelays) { + expect(d).toBe(10_000); + } + }); +}); + describe('getErrorStatus', () => { it('should extract status from error.status (OpenAI/Anthropic/Gemini style)', () => { expect(getErrorStatus({ status: 429 })).toBe(429); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index d1c3e7ba6..54b0b6db9 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -12,10 +12,21 @@ import { getErrorStatus } from './errors.js'; const debugLogger = createDebugLogger('RETRY'); +// Persistent retry mode constants +const PERSISTENT_MAX_BACKOFF_MS = 5 * 60 * 1000; // 5 minutes — single retry backoff cap +const PERSISTENT_CAP_MS = 6 * 60 * 60 * 1000; // 6 hours — absolute single wait cap +const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds + export interface HttpError extends Error { status?: number; } +export interface HeartbeatInfo { + attempt: number; + remainingMs: number; + error: unknown; +} + export interface RetryOptions { maxAttempts: number; initialDelayMs: number; @@ -23,6 +34,13 @@ export interface RetryOptions { shouldRetryOnError: (error: Error) => boolean; shouldRetryOnContent?: (content: GenerateContentResponse) => boolean; authType?: string; + // Persistent retry mode options + persistentMode?: boolean; + persistentMaxBackoffMs?: number; + persistentCapMs?: number; + heartbeatIntervalMs?: number; + heartbeatFn?: (info: HeartbeatInfo) => void; + signal?: AbortSignal; } const DEFAULT_RETRY_OPTIONS: RetryOptions = { @@ -45,6 +63,27 @@ function defaultShouldRetry(error: Error | unknown): boolean { ); } +/** + * Determines if an error is a transient capacity error eligible for persistent retry. + * Only 429 (Rate Limit) and 529 (Overloaded) qualify — HTTP 500 is excluded + * because it may indicate a permanent server bug. + */ +export function isTransientCapacityError(error: unknown): boolean { + const status = getErrorStatus(error); + return status === 429 || status === 529; +} + +/** + * Detects whether persistent retry mode is explicitly enabled. + * Requires the user to opt in via QWEN_CODE_UNATTENDED_RETRY — we intentionally + * do NOT auto-activate on CI=true, because silently turning a fast-fail CI job + * into an infinite-wait job would be surprising and dangerous. + */ +export function isUnattendedMode(): boolean { + const val = process.env['QWEN_CODE_UNATTENDED_RETRY']; + return val === 'true' || val === '1'; +} + /** * Delays execution for a specified number of milliseconds. * @param ms The number of milliseconds to delay. @@ -54,8 +93,45 @@ function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Sleeps in chunks, emitting heartbeat callbacks at regular intervals. + * Supports AbortSignal for graceful cancellation. + */ +async function sleepWithHeartbeat( + totalMs: number, + ctx: { + attempt: number; + error: unknown; + heartbeatInterval: number; + heartbeatFn?: (info: HeartbeatInfo) => void; + signal?: AbortSignal; + }, +): Promise<void> { + let remaining = totalMs; + + while (remaining > 0) { + if (ctx.signal?.aborted) { + throw new Error('Retry aborted by signal'); + } + + const chunk = Math.max(1, Math.min(remaining, ctx.heartbeatInterval)); + await delay(chunk); + remaining -= chunk; + + if (remaining > 0 && ctx.heartbeatFn) { + ctx.heartbeatFn({ + attempt: ctx.attempt, + remainingMs: remaining, + error: ctx.error, + }); + } + } +} + /** * Retries a function with exponential backoff and jitter. + * Supports persistent retry mode for unattended/CI environments where transient + * capacity errors (429/529) should be retried indefinitely rather than failing. * @param fn The asynchronous function to retry. * @param options Optional retry configuration. * @returns A promise that resolves with the result of the function if successful. @@ -80,12 +156,24 @@ export async function retryWithBackoff<T>( authType, shouldRetryOnError, shouldRetryOnContent, + persistentMode, + persistentMaxBackoffMs, + persistentCapMs, + heartbeatIntervalMs, + heartbeatFn, + signal, } = { ...DEFAULT_RETRY_OPTIONS, ...cleanOptions, }; + const persistent = persistentMode ?? false; + const maxBackoff = persistentMaxBackoffMs ?? PERSISTENT_MAX_BACKOFF_MS; + const capMs = persistentCapMs ?? PERSISTENT_CAP_MS; + const heartbeatInterval = heartbeatIntervalMs ?? HEARTBEAT_INTERVAL_MS; + let attempt = 0; + let persistentAttempt = 0; let currentDelay = initialDelayMs; while (attempt < maxAttempts) { @@ -120,31 +208,86 @@ export async function retryWithBackoff<T>( ); } + // Determine if this error qualifies for persistent retry. + // Persistent mode still respects shouldRetryOnError — callers can force + // fast-fail even for transient errors if they explicitly return false. + const isTransient = isTransientCapacityError(error); + const callerAllowsRetry = shouldRetryOnError(error as Error); + const shouldPersist = persistent && isTransient && callerAllowsRetry; + // Check if we've exhausted retries or shouldn't retry - if (attempt >= maxAttempts || !shouldRetryOnError(error as Error)) { - throw error; + if (!shouldPersist) { + if (attempt >= maxAttempts || !callerAllowsRetry) { + throw error; + } } - const retryAfterMs = - errorStatus === 429 ? getRetryAfterDelayMs(error) : 0; + // === Calculate delay === + let delayMs: number; - if (retryAfterMs > 0) { - // Respect Retry-After header if present and parsed + if (shouldPersist) { + persistentAttempt++; + + // Prefer Retry-After header for 429 errors + const retryAfterMs = + errorStatus === 429 ? getRetryAfterDelayMs(error) : 0; + + if (retryAfterMs > 0) { + // Retry-After is a server-specified wait — respect it, only cap at + // the absolute limit (capMs/6h), NOT at maxBackoff (5min). + delayMs = Math.min(retryAfterMs, capMs); + } else { + // Exponential backoff — cap at maxBackoff (5min) then absolute cap + delayMs = Math.min( + initialDelayMs * Math.pow(2, persistentAttempt - 1), + maxBackoff, + ); + delayMs = Math.min(delayMs, capMs); + + // Add jitter (±25%), then re-apply caps so delay never exceeds limits + delayMs += delayMs * 0.25 * (Math.random() * 2 - 1); + delayMs = Math.min(Math.max(0, delayMs), maxBackoff, capMs); + } + + const reportedAttempt = persistentAttempt; debugLogger.warn( - `Attempt ${attempt} failed with status ${errorStatus ?? 'unknown'}. Retrying after explicit delay of ${retryAfterMs}ms...`, + `[Persistent] Attempt ${reportedAttempt} failed with status ${errorStatus ?? 'unknown'}. ` + + `Retrying in ${Math.ceil(delayMs / 1000)}s...`, error, ); - await delay(retryAfterMs); - // Reset currentDelay for next potential non-429 error, or if Retry-After is not present next time - currentDelay = initialDelayMs; + + // Heartbeat sleep — chunked to keep CI alive + await sleepWithHeartbeat(delayMs, { + attempt: reportedAttempt, + error, + heartbeatInterval, + heartbeatFn, + signal, + }); + + // Clamp attempt so the while-loop never exits + if (attempt >= maxAttempts) { + attempt = maxAttempts - 1; + } } else { - // Fallback to exponential backoff with jitter - logRetryAttempt(attempt, error, errorStatus); - // Add jitter: +/- 30% of currentDelay - const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1); - const delayWithJitter = Math.max(0, currentDelay + jitter); - await delay(delayWithJitter); - currentDelay = Math.min(maxDelayMs, currentDelay * 2); + // Normal retry path (unchanged behavior) + const retryAfterMs = + errorStatus === 429 ? getRetryAfterDelayMs(error) : 0; + + if (retryAfterMs > 0) { + debugLogger.warn( + `Attempt ${attempt} failed with status ${errorStatus ?? 'unknown'}. Retrying after explicit delay of ${retryAfterMs}ms...`, + error, + ); + await delay(retryAfterMs); + currentDelay = initialDelayMs; + } else { + logRetryAttempt(attempt, error, errorStatus); + const jitter = currentDelay * 0.3 * (Math.random() * 2 - 1); + const delayWithJitter = Math.max(0, currentDelay + jitter); + await delay(delayWithJitter); + currentDelay = Math.min(maxDelayMs, currentDelay * 2); + } } } } diff --git a/packages/core/src/utils/secure-browser-launcher.test.ts b/packages/core/src/utils/secure-browser-launcher.test.ts index de27ce6ff..cd6490330 100644 --- a/packages/core/src/utils/secure-browser-launcher.test.ts +++ b/packages/core/src/utils/secure-browser-launcher.test.ts @@ -95,13 +95,11 @@ describe('secure-browser-launcher', () => { it('should prevent PowerShell command injection on Windows', async () => { setPlatform('win32'); - // The POC from the vulnerability report const maliciousUrl = "http://127.0.0.1:8080/?param=example#$(Invoke-Expression([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('Y2FsYy5leGU='))))"; await openBrowserSecurely(maliciousUrl); - // Verify that execFile was called (not exec) and the URL is passed safely expect(mockExecFile).toHaveBeenCalledWith( 'powershell.exe', [ @@ -130,7 +128,6 @@ describe('secure-browser-launcher', () => { for (const url of urlsWithSpecialChars) { await openBrowserSecurely(url); - // Verify the URL is passed as an argument, not interpreted by shell expect(mockExecFile).toHaveBeenCalledWith( 'open', [url], @@ -146,7 +143,6 @@ describe('secure-browser-launcher', () => { "http://example.com/path?name=O'Brien&test='value'"; await openBrowserSecurely(urlWithSingleQuotes); - // Verify that single quotes are escaped by doubling them expect(mockExecFile).toHaveBeenCalledWith( 'powershell.exe', [ @@ -205,21 +201,29 @@ describe('secure-browser-launcher', () => { }); describe('Error handling', () => { - it('should handle browser launch failures gracefully', async () => { + it('should handle browser launch failures gracefully by logging instead of throwing', async () => { setPlatform('darwin'); mockExecFile.mockRejectedValueOnce(new Error('Command not found')); - await expect(openBrowserSecurely('https://example.com')).rejects.toThrow( - 'Failed to open browser', - ); - }); + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect( + openBrowserSecurely('https://example.com'), + ).resolves.toBeUndefined(); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('Failed to open browser automatically'), + ); + + consoleSpy.mockRestore(); + }); + }); + + describe('Linux Fallback', () => { it('should try fallback browsers on Linux', async () => { setPlatform('linux'); - // First call to xdg-open fails mockExecFile.mockRejectedValueOnce(new Error('Command not found')); - // Second call to gnome-open succeeds mockExecFile.mockResolvedValueOnce({ stdout: '', stderr: '' }); await openBrowserSecurely('https://example.com'); diff --git a/packages/core/src/utils/secure-browser-launcher.ts b/packages/core/src/utils/secure-browser-launcher.ts index c60a646d1..3a6e3219a 100644 --- a/packages/core/src/utils/secure-browser-launcher.ts +++ b/packages/core/src/utils/secure-browser-launcher.ts @@ -42,14 +42,14 @@ function validateUrl(url: string): void { } /** - * Opens a URL in the default browser using platform-specific commands. - * This implementation avoids shell injection vulnerabilities by: - * 1. Validating the URL to ensure it's HTTP/HTTPS only - * 2. Using execFile instead of exec to avoid shell interpretation - * 3. Passing the URL as an argument rather than constructing a command string + * Opens a URL in the user's default browser securely. * - * @param url The URL to open - * @throws Error if the URL is invalid or if opening the browser fails + * On failure (e.g., missing browser binary or command), this function does NOT throw an error. + * Instead, it logs the URL to the console error stream so the user can open it manually, + * and resolves successfully to prevent application crashes. + * + * @param url - The URL to open. + * @returns A promise that resolves when the attempt is made (whether successful or logged). */ export async function openBrowserSecurely(url: string): Promise<void> { // Validate the URL first @@ -107,7 +107,7 @@ export async function openBrowserSecurely(url: string): Promise<void> { try { await execFileAsync(command, args, options); - } catch (error) { + } catch (_error) { // For Linux, try fallback commands if xdg-open fails if ( (platformName === 'linux' || @@ -121,6 +121,7 @@ export async function openBrowserSecurely(url: string): Promise<void> { 'firefox', 'chromium', 'google-chrome', + 'microsoft-edge', ]; for (const fallbackCommand of fallbackCommands) { @@ -134,10 +135,13 @@ export async function openBrowserSecurely(url: string): Promise<void> { } } - // Re-throw the error if all attempts failed - throw new Error( - `Failed to open browser: ${error instanceof Error ? error.message : 'Unknown error'}`, + // Log the URL so the user can open it manually instead of crashing. + /* eslint-disable no-console */ + console.warn( + `Failed to open browser automatically. Please open this URL manually: ${url}`, ); + /* eslint-enable no-console */ + return; } } diff --git a/packages/core/src/utils/sessionStorageUtils.test.ts b/packages/core/src/utils/sessionStorageUtils.test.ts new file mode 100644 index 000000000..1e8cf9eb7 --- /dev/null +++ b/packages/core/src/utils/sessionStorageUtils.test.ts @@ -0,0 +1,283 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, it, expect } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + extractJsonStringField, + extractLastJsonStringField, + LITE_READ_BUF_SIZE, + readLastJsonStringFieldSync, + unescapeJsonString, +} from './sessionStorageUtils.js'; + +describe('sessionStorageUtils', () => { + describe('unescapeJsonString', () => { + it('should return string as-is when no escapes', () => { + expect(unescapeJsonString('hello world')).toBe('hello world'); + }); + + it('should unescape JSON escape sequences', () => { + expect(unescapeJsonString('hello\\nworld')).toBe('hello\nworld'); + expect(unescapeJsonString('tab\\there')).toBe('tab\there'); + expect(unescapeJsonString('quote\\"here')).toBe('quote"here'); + }); + + it('should handle backslash', () => { + expect(unescapeJsonString('path\\\\to\\\\file')).toBe('path\\to\\file'); + }); + }); + + describe('extractJsonStringField', () => { + it('should extract field without space after colon', () => { + const text = '{"customTitle":"my-feature"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('my-feature'); + }); + + it('should extract field with space after colon', () => { + const text = '{"customTitle": "my-feature"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('my-feature'); + }); + + it('should return first match', () => { + const text = '{"customTitle":"first"}\n{"customTitle":"second"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('first'); + }); + + it('should return undefined when field not found', () => { + const text = '{"type":"user","message":"hello"}'; + expect(extractJsonStringField(text, 'customTitle')).toBeUndefined(); + }); + + it('should handle escaped characters in value', () => { + const text = '{"customTitle":"hello\\nworld"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('hello\nworld'); + }); + + it('should handle escaped quotes in value', () => { + const text = '{"customTitle":"say \\"hi\\""}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('say "hi"'); + }); + + it('should work on truncated/partial lines', () => { + // Simulates reading from middle of a file where first line is cut + const text = 'tle":"partial"}\n{"customTitle":"complete"}'; + expect(extractJsonStringField(text, 'customTitle')).toBe('complete'); + }); + }); + + describe('extractLastJsonStringField', () => { + it('should return last occurrence', () => { + const text = '{"customTitle":"old-name"}\n{"customTitle":"new-name"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('new-name'); + }); + + it('should handle single occurrence', () => { + const text = '{"customTitle":"only-one"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('only-one'); + }); + + it('should return undefined when not found', () => { + const text = '{"type":"user"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBeUndefined(); + }); + + it('should handle mixed spacing styles', () => { + const text = '{"customTitle":"no-space"}\n{"customTitle": "with-space"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe( + 'with-space', + ); + }); + + it('should return globally last match when mixed patterns interleave', () => { + // Bug fix: previously returned "middle" because the second pattern + // ("key": "value") scan overwrote the result from the first pattern. + const text = + '{"customTitle":"old"}\n{"customTitle": "middle"}\n{"customTitle":"newest"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('newest'); + }); + + it('should filter by lineContains when provided', () => { + const text = [ + '{"type":"user","content":"I set customTitle to \\"customTitle\\":\\"fake\\""}', + '{"subtype":"custom_title","customTitle":"real-title"}', + ].join('\n'); + expect( + extractLastJsonStringField(text, 'customTitle', 'custom_title'), + ).toBe('real-title'); + }); + + it('should ignore matches on lines without lineContains marker', () => { + const text = + '{"role":"assistant","customTitle":"spoofed"}\n{"subtype":"custom_title","customTitle":"legit"}'; + expect( + extractLastJsonStringField(text, 'customTitle', 'custom_title'), + ).toBe('legit'); + }); + + it('should return undefined when lineContains excludes all matches', () => { + const text = '{"customTitle":"no-subtype-here"}'; + expect( + extractLastJsonStringField(text, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('should not confuse different field names', () => { + const text = '{"otherField":"other-value"}\n{"customTitle":"user-name"}'; + expect(extractLastJsonStringField(text, 'customTitle')).toBe('user-name'); + expect(extractLastJsonStringField(text, 'otherField')).toBe( + 'other-value', + ); + }); + + it('should handle many occurrences', () => { + const lines = Array.from( + { length: 10 }, + (_, i) => `{"customTitle":"title-${i}"}`, + ).join('\n'); + expect(extractLastJsonStringField(lines, 'customTitle')).toBe('title-9'); + }); + }); + + describe('readLastJsonStringFieldSync', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sst-readlast-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function writeFile(name: string, content: string): string { + const p = path.join(tmpDir, name); + fs.writeFileSync(p, content); + return p; + } + + it('returns undefined for a missing file', () => { + const p = path.join(tmpDir, 'does-not-exist.jsonl'); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('returns undefined for an empty file', () => { + const p = writeFile('empty.jsonl', ''); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('returns the only match for a small file', () => { + const p = writeFile( + 'small.jsonl', + '{"type":"user"}\n{"subtype":"custom_title","customTitle":"only"}\n', + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('only'); + }); + + it('returns the last match when the tail contains the field', () => { + const p = writeFile( + 'tail-hit.jsonl', + [ + '{"subtype":"custom_title","customTitle":"old"}', + '{"subtype":"custom_title","customTitle":"new"}', + '', + ].join('\n'), + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('new'); + }); + + it('falls back to full-file scan when tail has no match (Phase 2)', () => { + // Build a file whose custom_title record is near the start, followed by + // enough filler bytes (> LITE_READ_BUF_SIZE) that the tail window is + // entirely filler. The old head+tail reader would have hit this via the + // head window; this test verifies the new tail-first + full-scan + // strategy still resolves it. + const titleLine = + '{"subtype":"custom_title","customTitle":"buried-in-middle"}'; + const filler = '{"type":"user","message":"' + 'x'.repeat(256) + '"}'; + // ~4x the tail window, guaranteed to push the title line out of tail. + const fillerCount = Math.ceil((LITE_READ_BUF_SIZE * 4) / filler.length); + const content = + titleLine + + '\n' + + Array.from({ length: fillerCount }, () => filler).join('\n') + + '\n'; + + const p = writeFile('phase2.jsonl', content); + expect(fs.statSync(p).size).toBeGreaterThan(LITE_READ_BUF_SIZE * 3); + + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('buried-in-middle'); + }); + + it('returns the last occurrence even when multiple land in the full-scan region', () => { + const early = '{"subtype":"custom_title","customTitle":"first-rename"}'; + const middle = '{"subtype":"custom_title","customTitle":"second-rename"}'; + const filler = '{"type":"user","message":"' + 'x'.repeat(256) + '"}'; + const fillerCount = Math.ceil((LITE_READ_BUF_SIZE * 3) / filler.length); + + const content = + early + + '\n' + + middle + + '\n' + + Array.from({ length: fillerCount }, () => filler).join('\n') + + '\n'; + + const p = writeFile('phase2-multi.jsonl', content); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('second-rename'); + }); + + it('respects the lineContains filter when scanning', () => { + const p = writeFile( + 'filter.jsonl', + [ + '{"type":"user","customTitle":"spoofed-in-user-content"}', + '{"subtype":"custom_title","customTitle":"legit"}', + '', + ].join('\n'), + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('legit'); + }); + + it('returns undefined when neither phase finds the field', () => { + const line = '{"type":"user","message":"' + 'x'.repeat(512) + '"}'; + const lineCount = Math.ceil((LITE_READ_BUF_SIZE * 3) / line.length); + const content = + Array.from({ length: lineCount }, () => line).join('\n') + '\n'; + const p = writeFile('no-title.jsonl', content); + + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBeUndefined(); + }); + + it('handles a final line without a trailing newline', () => { + const p = writeFile( + 'no-trailing-newline.jsonl', + '{"type":"user"}\n{"subtype":"custom_title","customTitle":"last"}', + ); + expect( + readLastJsonStringFieldSync(p, 'customTitle', 'custom_title'), + ).toBe('last'); + }); + }); +}); diff --git a/packages/core/src/utils/sessionStorageUtils.ts b/packages/core/src/utils/sessionStorageUtils.ts new file mode 100644 index 000000000..c91c8615d --- /dev/null +++ b/packages/core/src/utils/sessionStorageUtils.ts @@ -0,0 +1,227 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Portable session storage utilities for efficient session metadata reading. + * + * Provides string-level JSON field extraction (no full parse) and head/tail + * file reading for fast session metadata access on large JSONL files. + */ + +import fs from 'node:fs'; + +/** Size of the head/tail buffer for lite metadata reads (64KB). */ +export const LITE_READ_BUF_SIZE = 64 * 1024; + +// --------------------------------------------------------------------------- +// JSON string field extraction — no full parse, works on truncated lines +// --------------------------------------------------------------------------- + +/** + * Unescape a JSON string value extracted as raw text. + * Only allocates a new string when escape sequences are present. + */ +export function unescapeJsonString(raw: string): string { + if (!raw.includes('\\')) return raw; + try { + return JSON.parse(`"${raw}"`); + } catch { + return raw; + } +} + +/** + * Extracts a simple JSON string field value from raw text without full parsing. + * Looks for `"key":"value"` or `"key": "value"` patterns. + * Returns the first match, or undefined if not found. + */ +export function extractJsonStringField( + text: string, + key: string, +): string | undefined { + const patterns = [`"${key}":"`, `"${key}": "`]; + for (const pattern of patterns) { + const idx = text.indexOf(pattern); + if (idx < 0) continue; + + const valueStart = idx + pattern.length; + let i = valueStart; + while (i < text.length) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (text[i] === '"') { + return unescapeJsonString(text.slice(valueStart, i)); + } + i++; + } + } + return undefined; +} + +/** + * Like extractJsonStringField but finds the LAST occurrence. + * Useful for fields that are appended (customTitle, aiTitle, etc.) + * where the most recent entry should win. + * + * When `lineContains` is provided, only matches on lines that also contain + * the given substring are considered. This prevents false matches from user + * content that happens to contain the same key pattern. + */ +export function extractLastJsonStringField( + text: string, + key: string, + lineContains?: string, +): string | undefined { + const patterns = [`"${key}":"`, `"${key}": "`]; + let lastValue: string | undefined; + let lastOffset = -1; + for (const pattern of patterns) { + let searchFrom = 0; + while (true) { + const idx = text.indexOf(pattern, searchFrom); + if (idx < 0) break; + + // If lineContains is specified, verify the current line contains it + if (lineContains) { + const lineStart = text.lastIndexOf('\n', idx) + 1; + const lineEnd = text.indexOf('\n', idx); + const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd); + if (!line.includes(lineContains)) { + searchFrom = idx + pattern.length; + continue; + } + } + + const valueStart = idx + pattern.length; + let i = valueStart; + while (i < text.length) { + if (text[i] === '\\') { + i += 2; + continue; + } + if (text[i] === '"') { + if (idx > lastOffset) { + lastValue = unescapeJsonString(text.slice(valueStart, i)); + lastOffset = idx; + } + break; + } + i++; + } + searchFrom = i + 1; + } + } + return lastValue; +} + +// --------------------------------------------------------------------------- +// File I/O — tail-first scan with full-file fallback +// --------------------------------------------------------------------------- + +/** + * Reads a JSON string field value from a JSONL file, returning the latest + * occurrence (last in file order). + * + * Two-phase strategy: + * 1. Scan the last LITE_READ_BUF_SIZE bytes of the file; if the field is + * present, return it immediately. This is the common path because + * ChatRecordingService.finalize() re-appends metadata records to EOF + * on every session lifecycle event, keeping the latest title near the + * end of the file. + * 2. If the tail window has no match, stream the entire file in chunks + * and return the last hit. This guarantees we never miss a record that + * landed between the head and tail windows in a large file — a blind + * spot the previous head+tail approach had. + * + * Phase 2 is a full-file scan and is intentionally slower; it is only paid + * when Phase 1 misses. + * + * Returns `undefined` on any I/O error or when the field is not found. + * + * @param lineContains Optional substring that must appear on the same line + * as the matched field. See {@link extractLastJsonStringField}. + */ +export function readLastJsonStringFieldSync( + filePath: string, + key: string, + lineContains?: string, +): string | undefined { + let fd: number | undefined; + try { + const stats = fs.statSync(filePath); + const fileSize = stats.size; + if (fileSize === 0) return undefined; + + fd = fs.openSync(filePath, 'r'); + + // Phase 1: tail window — fast path. + const tailLength = Math.min(fileSize, LITE_READ_BUF_SIZE); + const tailOffset = fileSize - tailLength; + const tailBuffer = Buffer.alloc(tailLength); + const tailBytes = fs.readSync(fd, tailBuffer, 0, tailLength, tailOffset); + if (tailBytes > 0) { + const tailText = tailBuffer.toString('utf-8', 0, tailBytes); + const tailHit = extractLastJsonStringField(tailText, key, lineContains); + if (tailHit !== undefined) { + return tailHit; + } + } + + // If the whole file already fit in the tail window, there is nothing left + // to scan. + if (tailOffset === 0) return undefined; + + // Phase 2: stream the whole file and return the last hit. Scanning from + // offset 0 (rather than [0, tailOffset)) avoids the edge case where a + // single record straddles the Phase 1/Phase 2 boundary — duplicate work + // on the tail bytes is harmless because we only care about the final + // match. + let lastHit: string | undefined; + let readOffset = 0; + let carry = ''; + while (readOffset < fileSize) { + const toRead = Math.min(LITE_READ_BUF_SIZE, fileSize - readOffset); + const buf = Buffer.alloc(toRead); + const bytesRead = fs.readSync(fd, buf, 0, toRead, readOffset); + if (bytesRead === 0) break; + readOffset += bytesRead; + + const chunk = carry + buf.toString('utf-8', 0, bytesRead); + const lastNewline = chunk.lastIndexOf('\n'); + if (lastNewline < 0) { + // No newline yet — the entire chunk is a partial line; keep carrying. + carry = chunk; + continue; + } + + const complete = chunk.slice(0, lastNewline + 1); + carry = chunk.slice(lastNewline + 1); + + const hit = extractLastJsonStringField(complete, key, lineContains); + if (hit !== undefined) lastHit = hit; + } + + // Final trailing line without a newline terminator. + if (carry) { + const hit = extractLastJsonStringField(carry, key, lineContains); + if (hit !== undefined) lastHit = hit; + } + + return lastHit; + } catch { + return undefined; + } finally { + if (fd !== undefined) { + try { + fs.closeSync(fd); + } catch { + // best-effort: we already have our result (or decided there is none) + } + } + } +} diff --git a/packages/core/src/utils/subagentNameContext.ts b/packages/core/src/utils/subagentNameContext.ts new file mode 100644 index 000000000..0571dd4bf --- /dev/null +++ b/packages/core/src/utils/subagentNameContext.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; + +/** + * Label used for API calls that do not originate from a subagent + * (i.e. calls made by the main conversation). + */ +export const MAIN_SOURCE = 'main'; + +/** + * AsyncLocalStorage carrying the name of the subagent that owns the current + * execution context. When set, `LoggingContentGenerator` annotates emitted + * telemetry events with this name so the `/stats` panel can attribute API + * calls to the originating subagent. When unset, API calls are attributed + * to `MAIN_SOURCE` ("main"). + * + * AgentCore wraps its reasoning loop in `subagentNameContext.run(this.name, + * ...)`; the content generator reads the store inside its per-call logging + * helpers. + */ +export const subagentNameContext = new AsyncLocalStorage<string>(); diff --git a/packages/vscode-ide-companion/esbuild.js b/packages/vscode-ide-companion/esbuild.js index 2a344109c..6abe3e6a5 100644 --- a/packages/vscode-ide-companion/esbuild.js +++ b/packages/vscode-ide-companion/esbuild.js @@ -218,6 +218,9 @@ async function main() { logLevel: 'silent', plugins: [reactDedupPlugin, cssInjectPlugin, esbuildProblemMatcherPlugin], jsx: 'automatic', // Use new JSX transform (React 17+) + loader: { + '.png': 'dataurl', + }, define: { 'process.env.NODE_ENV': production ? '"production"' : '"development"', }, diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index c426c9d12..4fc4fbf81 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.14.5", + "version": "0.15.0", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { @@ -111,8 +111,8 @@ "icon": "./assets/icon.png" }, { - "command": "qwen-code.login", - "title": "Qwen Code: Login" + "command": "qwen-code.auth", + "title": "Qwen Code: Auth" }, { "command": "qwen-code.focusChat", @@ -138,8 +138,7 @@ "when": "qwen.diff.isVisible" }, { - "command": "qwen-code.login", - "when": "false" + "command": "qwen-code.auth" } ], "editor/title": [ @@ -159,6 +158,45 @@ } ] }, + "configuration": { + "title": "Qwen Code", + "properties": { + "qwen-code.provider": { + "order": 0, + "type": "string", + "enum": [ + "coding-plan", + "api-key" + ], + "enumDescriptions": [ + "Alibaba Cloud Coding Plan — configurable from VS Code Settings", + "Configured via Qwen Code: Auth or the onboarding button" + ], + "default": "coding-plan", + "markdownDescription": "**Coding Plan**: enter API Key + Region here to sync `~/.qwen/settings.json`.\n\n**API Key**: use **Qwen Code: Auth** or the onboarding button to configure ModelStudio or custom OpenAI-compatible providers." + }, + "qwen-code.apiKey": { + "order": 1, + "type": "string", + "default": "", + "markdownDescription": "API key used for **Coding Plan** settings sync. For **API Key** providers, configure the full provider details through **Qwen Code: Auth**." + }, + "qwen-code.codingPlanRegion": { + "order": 2, + "type": "string", + "enum": [ + "china", + "global" + ], + "enumDescriptions": [ + "China — 阿里云百炼 (aliyun.com)", + "Global — Alibaba Cloud (alibabacloud.com)" + ], + "default": "china", + "markdownDescription": "Region for Coding Plan. _(Only used when Provider is `coding-plan`)_" + } + } + }, "keybindings": [ { "command": "qwen.diff.accept", diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 53ebea1ff..a54ddd1a4 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -52,10 +52,15 @@ "default": true }, "showSessionRecap": { - "description": "Auto-show a one-line \"where you left off\" recap when returning to the terminal after being away for 5+ minutes. Off by default. Use /recap to trigger manually regardless of this setting.", + "description": "Auto-show a one-line \"where you left off\" recap when returning to the terminal after being away. Off by default. Use /recap to trigger manually regardless of this setting.", "type": "boolean", "default": false }, + "sessionRecapAwayThresholdMinutes": { + "description": "How many minutes the terminal must be blurred before an auto-recap fires on the next focus-in. Matches Claude Code's default of 5 minutes; raise if you briefly alt-tab and do not want recaps to pile up.", + "type": "number", + "default": 5 + }, "gitCoAuthor": { "description": "Automatically add a Co-authored-by trailer to git commit messages when commits are made through Qwen Code.", "type": "boolean", @@ -244,6 +249,11 @@ "description": "Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).", "type": "boolean", "default": false + }, + "shellOutputMaxLines": { + "description": "Max number of shell output lines shown inline. Set to 0 to disable the cap and show full output. The hidden line count is still surfaced via the `+N lines` indicator.", + "type": "number", + "default": 5 } } }, @@ -453,6 +463,19 @@ } } }, + "slashCommands": { + "description": "Configuration for slash commands exposed by the CLI. Useful for locking down the command surface in multi-tenant or enterprise deployments.", + "type": "object", + "properties": { + "disabled": { + "description": "Slash command names to hide and refuse to execute. Matched case-insensitively against the final command name (for extension commands this is the disambiguated form, e.g. \"myext.deploy\"). Merged as a union across settings scopes, so workspace settings can add to but not remove entries defined in system/user settings.", + "type": "array", + "items": { + "type": "string" + } + } + } + }, "permissions": { "description": "Permission rules controlling tool usage. Rules are evaluated in priority order: deny > ask > allow.", "type": "object", @@ -480,19 +503,6 @@ } } }, - "slashCommands": { - "description": "Configuration for slash commands exposed by the CLI. Useful for locking down the command surface in multi-tenant or enterprise deployments.", - "type": "object", - "properties": { - "disabled": { - "description": "Slash command names to hide and refuse to execute. Matched case-insensitively against the final command name (for extension commands this is the disambiguated form, e.g. \"myext.deploy\"). Merged as a union across settings scopes, so workspace settings can add to but not remove entries defined in system/user settings.", - "type": "array", - "items": { - "type": "string" - } - } - } - }, "tools": { "description": "Settings for built-in and custom tools.", "type": "object", diff --git a/packages/vscode-ide-companion/src/assets.d.ts b/packages/vscode-ide-companion/src/assets.d.ts new file mode 100644 index 000000000..1c5923252 --- /dev/null +++ b/packages/vscode-ide-companion/src/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.png' { + const value: string; + export default value; +} diff --git a/packages/vscode-ide-companion/src/commands/index.test.ts b/packages/vscode-ide-companion/src/commands/index.test.ts index 526b7ffea..e2ffe203e 100644 --- a/packages/vscode-ide-companion/src/commands/index.test.ts +++ b/packages/vscode-ide-companion/src/commands/index.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { + authCommand, focusChatCommand, openNewChatTabCommand, registerNewCommands, @@ -70,6 +71,7 @@ describe('registerNewCommands', () => { const provider = { show: vi.fn().mockResolvedValue(undefined), createNewSession: vi.fn().mockResolvedValue(undefined), + startInteractiveAuth: vi.fn().mockResolvedValue(undefined), setInitialModelId: vi.fn(), }; @@ -90,6 +92,27 @@ describe('registerNewCommands', () => { expect(provider.setInitialModelId).toHaveBeenCalledWith('glm-5'); }); + it('auth opens the interactive provider setup flow instead of VS Code settings', async () => { + const provider = { + show: vi.fn().mockResolvedValue(undefined), + startInteractiveAuth: vi.fn().mockResolvedValue(undefined), + }; + + registerNewCommands( + context as never, + log, + diffManager as never, + () => [provider as never], + vi.fn(() => provider as never), + ); + + await getRegisteredHandler(authCommand)(); + + expect(provider.show).toHaveBeenCalledTimes(1); + expect(provider.startInteractiveAuth).toHaveBeenCalledTimes(1); + expect(executeCommand).not.toHaveBeenCalled(); + }); + it('focusChat focuses the secondary sidebar when it is supported', async () => { registerNewCommands( context as never, diff --git a/packages/vscode-ide-companion/src/commands/index.ts b/packages/vscode-ide-companion/src/commands/index.ts index 0fbe2a654..959946b77 100644 --- a/packages/vscode-ide-companion/src/commands/index.ts +++ b/packages/vscode-ide-companion/src/commands/index.ts @@ -19,7 +19,7 @@ export const runQwenCodeCommand = 'qwen-code.runQwenCode'; export const showDiffCommand = 'qwenCode.showDiff'; export const openChatCommand = 'qwen-code.openChat'; export const openNewChatTabCommand = 'qwenCode.openNewChatTab'; -export const loginCommand = 'qwen-code.login'; +export const authCommand = 'qwen-code.auth'; export const focusChatCommand = 'qwen-code.focusChat'; export const newConversationCommand = 'qwen-code.newConversation'; export const showLogsCommand = 'qwen-code.showLogs'; @@ -101,15 +101,15 @@ export function registerNewCommands( ); disposables.push( - vscode.commands.registerCommand(loginCommand, async () => { + vscode.commands.registerCommand(authCommand, async () => { const providers = getWebViewProviders(); - if (providers.length > 0) { - await providers[providers.length - 1].forceReLogin(); - } else { - vscode.window.showInformationMessage( - 'Please open Qwen Code chat first before logging in.', - ); - } + const provider = + providers.length > 0 + ? providers[providers.length - 1] + : createWebViewProvider(); + + await provider.show(); + await provider.startInteractiveAuth(); }), ); diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index 9e2d6f6a9..3550d1fa1 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -523,7 +523,12 @@ export class AcpConnection { params['cursor'] = String(options.cursor); } if (options?.size !== undefined) { - params['size'] = options.size; + // ACP ListSessionsRequest schema has no `size` field; the SDK's zod + // validator strips unknown top-level keys, so the agent would never + // see it. Carry it via `_meta` instead, matching the pattern used for + // other Qwen Code ACP extensions. + const existingMeta = (params['_meta'] ?? {}) as Record<string, unknown>; + params['_meta'] = { ...existingMeta, size: options.size }; } const response = await conn.unstable_listSessions( params as Parameters<typeof conn.unstable_listSessions>[0], @@ -539,6 +544,38 @@ export class AcpConnection { } } + async deleteSession(sessionId: string): Promise<{ success: boolean }> { + const conn = this.ensureConnection(); + try { + const result = await conn.extMethod('deleteSession', { + sessionId, + cwd: this.workingDir, + }); + return result as { success: boolean }; + } catch (error) { + console.error('[ACP] Failed to delete session:', error); + throw error; + } + } + + async renameSession( + sessionId: string, + title: string, + ): Promise<{ success: boolean }> { + const conn = this.ensureConnection(); + try { + const result = await conn.extMethod('renameSession', { + sessionId, + title, + cwd: this.workingDir, + }); + return result as { success: boolean }; + } catch (error) { + console.error('[ACP] Failed to rename session:', error); + throw error; + } + } + async switchSession(sessionId: string): Promise<void> { console.log('[ACP] Switching to session:', sessionId); this.sessionId = sessionId; diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 72d2b4b03..426558ece 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -716,6 +716,32 @@ export class QwenAgentManager { } } + /** + * Delete a session by ID via ACP. + */ + async deleteSession(sessionId: string): Promise<boolean> { + try { + const res = await this.connection.deleteSession(sessionId); + return res.success; + } catch (error) { + console.error('[QwenAgentManager] Failed to delete session:', error); + return false; + } + } + + /** + * Rename a session via ACP. + */ + async renameSession(sessionId: string, title: string): Promise<boolean> { + try { + const res = await this.connection.renameSession(sessionId, title); + return res.success; + } catch (error) { + console.error('[QwenAgentManager] Failed to rename session:', error); + return false; + } + } + // Read CLI JSONL session file and convert to ChatMessage[] for UI private async readJsonlMessages(filePath: string): Promise<ChatMessage[]> { const fs = await import('fs'); diff --git a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts index 478da7c7b..93303c6d8 100644 --- a/packages/vscode-ide-companion/src/services/qwenSessionReader.ts +++ b/packages/vscode-ide-companion/src/services/qwenSessionReader.ts @@ -8,8 +8,10 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import * as readline from 'readline'; +import * as crypto from 'crypto'; import { getProjectHash } from '@qwen-code/qwen-code-core/src/utils/paths.js'; import { truncatePanelTitle } from '../webview/utils/panelTitleUtils.js'; +import { getGitBranch } from '@qwen-code/qwen-code-core/src/utils/gitUtils.js'; export interface QwenMessage { id: string; @@ -37,6 +39,7 @@ export interface QwenSession { filePath?: string; messageCount?: number; firstUserText?: string; + customTitle?: string; cwd?: string; } @@ -182,6 +185,10 @@ export class QwenSessionReader { * Get session title (based on first user message) */ getSessionTitle(session: QwenSession): string { + // Prefer custom title set via /rename + if (session.customTitle) { + return session.customTitle; + } // Prefer cached prompt text to avoid loading messages for JSONL sessions const text = session.firstUserText ? session.firstUserText @@ -219,6 +226,7 @@ export class QwenSessionReader { let sessionId: string | undefined; let startTime: string | undefined; let firstUserText: string | undefined; + let customTitle: string | undefined; let cwd: string | undefined; for await (const line of rl) { @@ -265,6 +273,19 @@ export class QwenSessionReader { firstUserText = text; } } + + // Extract custom title from system records (last one wins) + if ( + type === 'system' && + obj.subtype === 'custom_title' && + typeof obj.systemPayload === 'object' && + obj.systemPayload !== null + ) { + const payload = obj.systemPayload as Record<string, unknown>; + if (typeof payload.customTitle === 'string') { + customTitle = payload.customTitle; + } + } } // Ensure stream is closed @@ -287,6 +308,7 @@ export class QwenSessionReader { filePath, messageCount: seenUuids.size, firstUserText, + customTitle, cwd, }; } catch (error) { @@ -325,23 +347,110 @@ export class QwenSessionReader { } } + /** + * Reads the UUID of the last record in a JSONL file via tail-read. + */ + private readLastRecordUuid(filePath: string): string | null { + try { + const TAIL_SIZE = 64 * 1024; + const stats = fs.statSync(filePath); + const readStart = Math.max(0, stats.size - TAIL_SIZE); + const readLength = Math.min(stats.size, TAIL_SIZE); + + const fd = fs.openSync(filePath, 'r'); + let buffer: Buffer; + try { + buffer = Buffer.alloc(readLength); + fs.readSync(fd, buffer, 0, readLength, readStart); + } finally { + fs.closeSync(fd); + } + + const lines = buffer.toString('utf-8').split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (!trimmed) { + continue; + } + try { + const record = JSON.parse(trimmed); + if (record.uuid) { + return record.uuid; + } + } catch { + continue; + } + } + return null; + } catch { + return null; + } + } + /** * Delete session file */ - async deleteSession( - sessionId: string, - _workingDir: string, - ): Promise<boolean> { + async deleteSession(sessionId: string, workingDir: string): Promise<boolean> { try { - const session = await this.getSession(sessionId, _workingDir); - if (session && session.filePath) { - fs.unlinkSync(session.filePath); - return true; + const session = await this.getSession(sessionId, workingDir); + if (!session || !session.filePath) { + return false; } - return false; + // Verify the session belongs to the current project + const expectedHash = getProjectHash(workingDir); + if (session.projectHash && session.projectHash !== expectedHash) { + return false; + } + fs.unlinkSync(session.filePath); + return true; } catch (error) { console.error('[QwenSessionReader] Failed to delete session:', error); return false; } } + + /** + * Rename session by appending a custom_title system record to the JSONL file. + */ + async renameSession( + sessionId: string, + title: string, + workingDir: string, + ): Promise<boolean> { + try { + const session = await this.getSession(sessionId, workingDir); + if (!session || !session.filePath) { + return false; + } + // Verify the session belongs to the current project + const expectedHash = getProjectHash(workingDir); + if (session.projectHash && session.projectHash !== expectedHash) { + return false; + } + + // Read the last record's UUID so the custom_title record is properly + // chained into the parent history (reconstructHistory walks from tail). + const lastUuid = this.readLastRecordUuid(session.filePath); + + const cwd = session.cwd || workingDir; + const record = JSON.stringify({ + uuid: crypto.randomUUID(), + parentUuid: lastUuid, + sessionId, + timestamp: new Date().toISOString(), + type: 'system', + subtype: 'custom_title', + cwd, + version: 'vscode', + gitBranch: getGitBranch(cwd), + systemPayload: { customTitle: title }, + }); + + fs.appendFileSync(session.filePath, record + '\n'); + return true; + } catch (error) { + console.error('[QwenSessionReader] Failed to rename session:', error); + return false; + } + } } diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.test.ts b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts new file mode 100644 index 000000000..306e78cf0 --- /dev/null +++ b/packages/vscode-ide-companion/src/services/settingsWriter.test.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetGlobalSettingsPath } = vi.hoisted(() => ({ + mockGetGlobalSettingsPath: vi.fn(), +})); + +vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { + const actual = + await importOriginal<typeof import('@qwen-code/qwen-code-core')>(); + return { + ...actual, + Storage: { + ...actual.Storage, + getGlobalSettingsPath: mockGetGlobalSettingsPath, + }, + }; +}); + +import { CODING_PLAN_ENV_KEY, AuthType } from '@qwen-code/qwen-code-core'; +import { + readQwenSettingsForVSCode, + writeCodingPlanConfig, + writeModelProvidersConfig, +} from './settingsWriter.js'; + +describe('settingsWriter', () => { + let tempDir: string; + let settingsPath: string; + + beforeEach(() => { + vi.clearAllMocks(); + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-vscode-settings-')); + settingsPath = path.join(tempDir, '.qwen', 'settings.json'); + mockGetGlobalSettingsPath.mockReturnValue(settingsPath); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('clears stale coding plan metadata when writing api-key providers', () => { + writeCodingPlanConfig('china', 'coding-plan-key'); + + writeModelProvidersConfig({ + apiKey: 'manual-key', + modelProviders: { + 'gpt-4o': 'https://api.openai.com/v1', + }, + activeModel: 'gpt-4o', + }); + + const settings = JSON.parse( + fs.readFileSync(settingsPath, 'utf-8'), + ) as Record<string, unknown>; + const env = settings.env as Record<string, string>; + const modelProviders = settings.modelProviders as Record<string, unknown>; + const openaiModels = modelProviders[AuthType.USE_OPENAI] as Array< + Record<string, string> + >; + + expect(env.OPENAI_API_KEY).toBe('manual-key'); + expect(env[CODING_PLAN_ENV_KEY]).toBeUndefined(); + expect(settings.codingPlan).toBeUndefined(); + expect(settings.model).toEqual({ name: 'gpt-4o' }); + // The new entry must be present + expect(openaiModels[0]).toEqual({ + id: 'gpt-4o', + name: 'gpt-4o', + baseUrl: 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }); + // Non-target entries (Coding Plan) are preserved, not silently deleted + const preserved = openaiModels.filter( + (m) => m.envKey === CODING_PLAN_ENV_KEY, + ); + expect(preserved.length).toBeGreaterThan(0); + }); + + it('reads an api-key configuration after switching away from coding plan', () => { + writeCodingPlanConfig('china', 'coding-plan-key'); + + writeModelProvidersConfig({ + apiKey: 'manual-key', + modelProviders: { + 'gpt-4o': 'https://api.openai.com/v1', + }, + activeModel: 'gpt-4o', + }); + + expect(readQwenSettingsForVSCode()).toEqual({ + provider: 'api-key', + apiKey: 'manual-key', + codingPlanRegion: 'china', + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/services/settingsWriter.ts b/packages/vscode-ide-companion/src/services/settingsWriter.ts new file mode 100644 index 000000000..43d83b8aa --- /dev/null +++ b/packages/vscode-ide-companion/src/services/settingsWriter.ts @@ -0,0 +1,294 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Settings writer for VSCode extension. + * Handles bidirectional sync between VSCode Settings and ~/.qwen/settings.json. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { + AuthType, + Storage, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, + getCodingPlanConfig, +} from '@qwen-code/qwen-code-core'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Model providers as key-value map: modelId → baseUrl. + * This is the format VSCode Settings UI can render as an editable table. + */ +export type VSCodeModelProviders = Record<string, string>; + +/** + * Values extracted from ~/.qwen/settings.json for populating VSCode Settings. + */ +export interface QwenSettingsForVSCode { + provider: 'coding-plan' | 'api-key'; + apiKey: string; + codingPlanRegion: 'china' | 'global'; +} + +// --------------------------------------------------------------------------- +// Low-level read/write helpers +// --------------------------------------------------------------------------- + +/** + * Read ~/.qwen/settings.json. Returns {} if missing or invalid. + */ +function readSettings(): Record<string, unknown> { + try { + const content = fs.readFileSync(Storage.getGlobalSettingsPath(), 'utf-8'); + return JSON.parse(content) as Record<string, unknown>; + } catch { + return {}; + } +} + +/** + * Write ~/.qwen/settings.json (creates dir if needed). + */ +function writeSettings(settings: Record<string, unknown>): void { + const settingsPath = Storage.getGlobalSettingsPath(); + const dir = path.dirname(settingsPath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); +} + +/** + * Ensure nested objects exist at the given key path. + */ +function ensureNestedObject( + obj: Record<string, unknown>, + ...keys: string[] +): Record<string, unknown> { + let current = obj; + for (const key of keys) { + if (!current[key] || typeof current[key] !== 'object') { + current[key] = {}; + } + current = current[key] as Record<string, unknown>; + } + return current; +} + +/** + * Find OpenAI-compatible model entries from modelProviders. + * CLI uses AuthType.USE_OPENAI ('openai') as the key, but some legacy + * configs may use other keys. Check both. + */ +function findOpenaiModels( + modelProviders: Record<string, unknown> | undefined, +): Array<Record<string, unknown>> { + if (!modelProviders) { + return []; + } + for (const key of [AuthType.USE_OPENAI, 'use_openai']) { + const arr = modelProviders[key]; + if (Array.isArray(arr) && arr.length > 0) { + return arr as Array<Record<string, unknown>>; + } + } + return []; +} + +// --------------------------------------------------------------------------- +// Write: VSCode Settings → ~/.qwen/settings.json +// --------------------------------------------------------------------------- + +/** + * Write Coding Plan configuration to ~/.qwen/settings.json. + * Auto-injects model providers from the regional template, + * preserving any existing non-Coding-Plan entries. + * + * @returns The injected models as a VSCode key-value map (modelId → baseUrl) + */ +export function writeCodingPlanConfig( + region: 'china' | 'global', + apiKey: string, +): VSCodeModelProviders { + const settings = readSettings(); + const codingRegion = + region === 'global' ? CodingPlanRegion.GLOBAL : CodingPlanRegion.CHINA; + const planConfig = getCodingPlanConfig(codingRegion); + + // Auth + const auth = ensureNestedObject(settings, 'security', 'auth'); + auth.selectedType = AuthType.USE_OPENAI; + + // API key + const env = ensureNestedObject(settings, 'env'); + env[CODING_PLAN_ENV_KEY] = apiKey; + + // Model providers — merge Coding Plan templates with existing non-CP entries + const providers = ensureNestedObject(settings, 'modelProviders'); + const existing = findOpenaiModels( + settings.modelProviders as Record<string, unknown>, + ); + const nonCodingPlan = existing.filter( + (e) => e.envKey !== CODING_PLAN_ENV_KEY, + ); + providers[AuthType.USE_OPENAI] = [...planConfig.template, ...nonCodingPlan]; + + // Coding Plan metadata + settings.codingPlan = { region: codingRegion, version: planConfig.version }; + + // Default model + const defaultModelId = planConfig.template[0]?.id ?? 'qwen3.5-plus'; + settings.model = { name: defaultModelId }; + + writeSettings(settings); + + // Return key-value map for VSCode settings + const result: VSCodeModelProviders = {}; + for (const m of planConfig.template) { + result[m.id] = m.baseUrl || ''; + } + return result; +} + +/** + * Write model providers from VSCode Settings (key-value map) to ~/.qwen/settings.json. + * Used when provider = "api-key" and user edits the modelProviders map. + * + * @param params.apiKey - The API key + * @param params.modelProviders - Map of modelId → baseUrl + * @param params.activeModel - Currently selected model ID + */ +export function writeModelProvidersConfig(params: { + apiKey: string; + modelProviders: VSCodeModelProviders; + activeModel: string; +}): void { + const settings = readSettings(); + + // Auth + const auth = ensureNestedObject(settings, 'security', 'auth'); + auth.selectedType = AuthType.USE_OPENAI; + + // API key + const env = ensureNestedObject(settings, 'env'); + env['OPENAI_API_KEY'] = params.apiKey; + delete env[CODING_PLAN_ENV_KEY]; + + // Convert key-value map to CLI's array format and merge with existing + // non-target entries so reconfiguring one provider doesn't silently + // delete others (e.g. Coding Plan entries with a different envKey). + const providers = ensureNestedObject(settings, 'modelProviders'); + const modelArray = Object.entries(params.modelProviders).map( + ([id, baseUrl]) => ({ + id, + name: id, + baseUrl: baseUrl || 'https://api.openai.com/v1', + envKey: 'OPENAI_API_KEY', + }), + ); + const existing = findOpenaiModels( + settings.modelProviders as Record<string, unknown>, + ); + const nonTarget = existing.filter((e) => e.envKey !== 'OPENAI_API_KEY'); + providers[AuthType.USE_OPENAI] = [...modelArray, ...nonTarget]; + + // Active model + if (params.activeModel) { + settings.model = { name: params.activeModel }; + } + + delete settings.codingPlan; + + writeSettings(settings); +} + +// --------------------------------------------------------------------------- +// Read: ~/.qwen/settings.json → VSCode Settings +// --------------------------------------------------------------------------- + +/** + * Read ~/.qwen/settings.json and extract values for VSCode Settings UI. + * Returns null if no valid configuration found. + */ +export function readQwenSettingsForVSCode(): QwenSettingsForVSCode | null { + const settings = readSettings(); + + const security = settings.security as Record<string, unknown> | undefined; + const auth = security?.auth as Record<string, unknown> | undefined; + if (!auth?.selectedType) { + return null; + } + + const env = (settings.env ?? {}) as Record<string, string>; + const codingPlan = settings.codingPlan as Record<string, unknown> | undefined; + + // Determine if this is a Coding Plan setup + const hasCodingPlanKey = !!env[CODING_PLAN_ENV_KEY]; + const hasCodingPlanRegion = !!codingPlan?.region; + + if (hasCodingPlanKey && hasCodingPlanRegion) { + return { + provider: 'coding-plan', + apiKey: env[CODING_PLAN_ENV_KEY] || '', + codingPlanRegion: (codingPlan?.region as 'china' | 'global') || 'china', + }; + } + + // Non-Coding-Plan — find API key from model providers + const modelProviders = settings.modelProviders as + | Record<string, unknown> + | undefined; + const openaiModels = findOpenaiModels(modelProviders); + const firstEnvKey = (openaiModels[0]?.envKey as string) || 'OPENAI_API_KEY'; + const apiKey = env[firstEnvKey] || ''; + + if (!apiKey) { + return null; + } + + return { + provider: 'api-key', + apiKey, + codingPlanRegion: 'china', + }; +} + +/** + * Clear persisted auth credentials from ~/.qwen/settings.json. + * Removes API keys, auth type selection, and coding plan metadata + * so runtime state matches the cleared VS Code settings. + */ +export function clearPersistedAuth(): void { + try { + const settings = readSettings(); + + // Remove auth type selection + const security = settings.security as Record<string, unknown> | undefined; + if (security?.auth) { + delete (security.auth as Record<string, unknown>).selectedType; + } + + // Remove API keys + const env = settings.env as Record<string, unknown> | undefined; + if (env) { + delete env[CODING_PLAN_ENV_KEY]; + delete env['OPENAI_API_KEY']; + } + + // Remove coding plan metadata + delete settings.codingPlan; + + writeSettings(settings); + } catch (error) { + console.error( + '[settingsWriter] Failed to clear persisted auth credentials:', + error, + ); + } +} diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index a2c685e18..8e3a32263 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -12,7 +12,11 @@ import type { ApprovalModeValue } from './approvalModeValueTypes.js'; // Private / Qwen-specific types (not part of ACP spec) // --------------------------------------------------------------------------- -export const authMethod = 'qwen-oauth'; +// Default auth method for ACP authenticate requests. +// Value matches AuthType.USE_OPENAI from @qwen-code/qwen-code-core. +// Cannot import directly because this file is used in the webview bundle +// where core (Node.js-only) is excluded as external. +export const authMethod = 'openai'; /** * Authenticate update notification (Qwen extension, not ACP spec). diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts index 601848c6a..9f277dde1 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.bundle.test.ts @@ -39,6 +39,9 @@ describe('imageSupport browser bundling', () => { write: false, logLevel: 'silent', external: ['@qwen-code/qwen-code-core'], + loader: { + '.png': 'dataurl', + }, }); const output = result.outputFiles[0]?.text ?? ''; diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 8a95b8644..8e6963c07 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -281,9 +281,9 @@ export const App: React.FC = () => { // Account group const accountGroupItems: CompletionItem[] = [ { - id: 'login', - label: 'Login', - description: 'Login to Qwen Code', + id: 'auth', + label: '/auth', + description: 'Configure Coding Plan or API Key', type: 'command', group: 'Account', }, @@ -697,9 +697,9 @@ export const App: React.FC = () => { } }; - if (itemId === 'login') { + if (itemId === 'auth') { clearTriggerText(); - vscode.postMessage({ type: 'login', data: {} }); + vscode.postMessage({ type: 'auth', data: {} }); completion.closeCompletion(); return; } @@ -970,12 +970,14 @@ export const App: React.FC = () => { return ( <div className="chat-container relative"> {/* Top-level loading overlay */} - {isLoading && ( + {(isLoading || sessionManagement.isSwitchingSession) && ( <div className="bg-background/80 absolute inset-0 z-50 flex items-center justify-center backdrop-blur-sm"> <div className="text-center"> <div className="border-primary mx-auto mb-2 h-8 w-8 animate-spin rounded-full border-b-2"></div> <p className="text-muted-foreground text-sm"> - Preparing Qwen Code... + {sessionManagement.isSwitchingSession + ? 'Loading conversation...' + : 'Preparing Qwen Code...'} </p> </div> </div> @@ -991,6 +993,8 @@ export const App: React.FC = () => { sessionManagement.handleSwitchSession(sessionId); sessionManagement.setSessionSearchQuery(''); }} + onRenameSession={sessionManagement.handleRenameSession} + onDeleteSession={sessionManagement.handleDeleteSession} onClose={() => sessionManagement.setShowSessionSelector(false)} hasMore={sessionManagement.hasMore} isLoading={sessionManagement.isLoading} @@ -1009,18 +1013,25 @@ export const App: React.FC = () => { ref={messagesContainerRef} className="chat-messages messages-container flex-1 overflow-y-auto overflow-x-hidden pt-5 pr-5 pl-5 pb-[140px] flex flex-col relative min-w-0 focus:outline-none [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-white/20 [&::-webkit-scrollbar-thumb]:rounded-sm [&::-webkit-scrollbar-thumb]:hover:bg-white/30 [&>*]:flex [&>*]:gap-0 [&>*]:items-start [&>*]:text-left [&>*]:py-2 [&>*:not(:last-child)]:pb-[8px] [&>*]:flex-col [&>*]:relative [&>*]:animate-[fadeIn_0.2s_ease-in]" > - {!hasContent && !isLoading ? ( + {!hasContent && !isLoading && !sessionManagement.isSwitchingSession ? ( isAuthenticated === false ? ( - <Onboarding - onLogin={() => { - vscode.postMessage({ type: 'login', data: {} }); - messageHandling.setWaitingForResponse( - 'Logging in to Qwen Code...', - ); - }} - /> + <Onboarding /> ) : isAuthenticated === null ? ( - <EmptyState loadingMessage="Checking login status…" /> + <div className="flex flex-col items-center justify-center h-full gap-3"> + <span + className="inline-block w-6 h-6 animate-spin rounded-full border-2" + style={{ + borderColor: 'var(--app-secondary-foreground)', + borderTopColor: 'transparent', + }} + /> + <span + className="text-sm" + style={{ color: 'var(--app-secondary-foreground)' }} + > + Preparing Qwen Code... + </span> + </div> ) : ( <EmptyState isAuthenticated /> ) diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx new file mode 100644 index 000000000..31a20804a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.test.tsx @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; + +vi.mock('./ProviderSetupForm.js', () => ({ + ProviderSetupForm: () => <button type="button">Get Started</button>, +})); + +import { Onboarding } from './Onboarding.js'; + +describe('Onboarding', () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + document.body.removeAttribute('data-extension-uri'); + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + }); + + it('renders the logo without requiring an extension URI on the body', () => { + act(() => { + root?.render(<Onboarding />); + }); + + const logo = container?.querySelector('img[alt="Qwen Code"]'); + + expect(logo).toBeTruthy(); + expect(logo?.getAttribute('src')).toBeTruthy(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx index b67893097..bd4691dff 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/Onboarding.tsx @@ -3,24 +3,70 @@ * Copyright 2025 Qwen Team * SPDX-License-Identifier: Apache-2.0 * - * VSCode-specific Onboarding adapter - * Uses webui Onboarding component with platform-specific icon URL + * VSCode-specific Onboarding page. + * Vertically centered welcome card with provider setup trigger. */ import type { FC } from 'react'; -import { Onboarding as BaseOnboarding } from '@qwen-code/webui'; -import { generateIconUrl } from '../../utils/resourceUrl.js'; - -interface OnboardingPageProps { - onLogin: () => void; -} +// eslint-disable-next-line import/no-internal-modules -- bundle the webview logo as a data URL +import iconUrl from '../../../../assets/icon.png'; +import { ProviderSetupForm } from './ProviderSetupForm.js'; /** - * VSCode Onboarding wrapper - * Provides platform-specific icon URL to the webui Onboarding component + * VSCode Onboarding page. */ -export const Onboarding: FC<OnboardingPageProps> = ({ onLogin }) => { - const iconUri = generateIconUrl('icon.png'); +export const Onboarding: FC = () => ( + <div + className="flex flex-col flex-1 min-h-0 px-6" + style={{ + alignItems: 'center', + justifyContent: 'center', + textAlign: 'center', + }} + > + {/* Logo + title block — sits above the card for visual breathing room */} + <div className="flex flex-col items-center gap-3 mb-6"> + <img src={iconUrl} alt="Qwen Code" className="w-12 h-12 object-contain" /> + <div className="text-center"> + <h1 + className="text-base font-semibold" + style={{ color: 'var(--app-primary-foreground)' }} + > + Qwen Code + </h1> + <p + className="text-xs mt-1" + style={{ color: 'var(--app-secondary-foreground)' }} + > + AI-powered coding assistant for your editor + </p> + </div> + </div> - return <BaseOnboarding iconUrl={iconUri} onGetStarted={onLogin} />; -}; + {/* Setup card */} + <div + className="w-full max-w-[300px] rounded-lg border p-4" + style={{ + backgroundColor: 'var(--app-input-secondary-background)', + borderColor: 'var(--app-input-border)', + }} + > + <p + className="text-xs mb-3 text-center" + style={{ color: 'var(--app-secondary-foreground)' }} + > + Connect a model provider to get started + </p> + <ProviderSetupForm /> + </div> + + {/* Subtle hint below the card */} + <p + className="text-[10px] mt-4 text-center max-w-[260px]" + style={{ color: 'var(--app-secondary-foreground)', opacity: 0.6 }} + > + Supports Alibaba Cloud Coding Plan, ModelStudio API Key, and + OpenAI-compatible endpoints + </p> + </div> +); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx new file mode 100644 index 000000000..970f0ec1a --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.test.tsx @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; + +const { mockPostMessage } = vi.hoisted(() => ({ + mockPostMessage: vi.fn(), +})); + +vi.mock('../../hooks/useVSCode.js', () => ({ + useVSCode: () => ({ + postMessage: mockPostMessage, + getState: vi.fn(), + setState: vi.fn(), + }), +})); + +import { ProviderSetupForm } from './ProviderSetupForm.js'; + +describe('ProviderSetupForm', () => { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => { + root?.unmount(); + }); + container?.remove(); + root = null; + container = null; + }); + + it('leaves connecting state when auth flow is cancelled', () => { + act(() => { + root?.render(<ProviderSetupForm />); + }); + + const button = container?.querySelector('button'); + expect(button).toBeTruthy(); + + act(() => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(mockPostMessage).toHaveBeenCalledWith({ type: 'auth' }); + expect(container?.textContent).toContain('Connecting...'); + expect(button?.hasAttribute('disabled')).toBe(true); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { type: 'authCancelled' }, + }), + ); + }); + + expect(container?.textContent).toContain('Get Started'); + expect(button?.hasAttribute('disabled')).toBe(false); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx new file mode 100644 index 000000000..63d284e22 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/ProviderSetupForm.tsx @@ -0,0 +1,118 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Provider Setup — triggers the auth interactive flow (QuickPick + InputBox). + */ + +import { useState, useEffect, type FC } from 'react'; +import { useVSCode } from '../../hooks/useVSCode.js'; + +/** + * Small rotating spinner for loading states. + */ +const Spinner: FC<{ size?: number }> = ({ size = 14 }) => ( + <span + className="inline-block animate-spin rounded-full border-2 border-current" + style={{ + width: size, + height: size, + borderTopColor: 'transparent', + }} + /> +); + +/** + * ProviderSetupForm — Single button that launches the interactive auth flow. + */ +export const ProviderSetupForm: FC = () => { + const vscode = useVSCode(); + const [isConnecting, setIsConnecting] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => { + const handler = (event: MessageEvent) => { + const msg = event.data; + if (msg?.type === 'authError' || msg?.type === 'agentConnectionError') { + setIsConnecting(false); + setError( + msg.data?.message || 'Connection failed. Check your settings.', + ); + } + if (msg?.type === 'authCancelled') { + setIsConnecting(false); + setError(null); + } + if (msg?.type === 'authSuccess' || msg?.type === 'agentConnected') { + setIsConnecting(false); + setError(null); + } + }; + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, []); + + const handleGetStarted = () => { + setError(null); + setIsConnecting(true); + vscode.postMessage({ type: 'auth' }); + }; + + return ( + <div className="flex flex-col gap-2.5"> + <button + onClick={handleGetStarted} + disabled={isConnecting} + className="w-full py-2 rounded-md text-[13px] font-medium flex items-center justify-center gap-2 transition-all" + style={{ + backgroundColor: isConnecting + ? 'var(--app-input-secondary-background)' + : 'var(--app-primary, var(--app-button-background))', + color: isConnecting + ? 'var(--app-secondary-foreground)' + : 'var(--app-button-foreground, #fff)', + cursor: isConnecting ? 'not-allowed' : 'pointer', + border: isConnecting + ? '1px solid var(--app-input-border)' + : '1px solid transparent', + }} + > + {isConnecting ? ( + <> + <Spinner /> + Connecting... + </> + ) : ( + <> + Get Started + <svg + width="12" + height="12" + viewBox="0 0 12 12" + fill="none" + stroke="currentColor" + strokeWidth="1.5" + strokeLinecap="round" + strokeLinejoin="round" + > + <path d="M4.5 2.5L8 6L4.5 9.5" /> + </svg> + </> + )} + </button> + + {error && ( + <div + className="text-[11px] leading-snug px-2.5 py-2 rounded" + style={{ + backgroundColor: 'color-mix(in srgb, #ef4444 10%, transparent)', + color: '#f87171', + }} + > + {error} + </div> + )} + </div> + ); +}; diff --git a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx index bc912e367..b9b6e855e 100644 --- a/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx +++ b/packages/vscode-ide-companion/src/webview/context/VSCodePlatformProvider.tsx @@ -97,10 +97,10 @@ export const VSCodePlatformProvider: FC<VSCodePlatformProviderProps> = ({ }); }, [vscode]); - // Login handler + // Auth handler const login = useCallback(() => { vscode.postMessage({ - type: 'login', + type: 'auth', data: {}, }); }, [vscode]); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts new file mode 100644 index 000000000..85a7512a6 --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.test.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockShowInputBox, mockShowQuickPick } = vi.hoisted(() => ({ + mockShowInputBox: vi.fn(), + mockShowQuickPick: vi.fn(), +})); + +vi.mock('vscode', () => ({ + window: { + showQuickPick: mockShowQuickPick, + showInputBox: mockShowInputBox, + }, +})); + +import { AuthMessageHandler } from './AuthMessageHandler.js'; + +describe('AuthMessageHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('sends authCancelled when the provider picker is dismissed', async () => { + mockShowQuickPick.mockResolvedValue(undefined); + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ type: 'auth' }); + + expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); + }); + + it('sends authCancelled when the api key input is dismissed mid-flow', async () => { + mockShowQuickPick + .mockResolvedValueOnce({ value: 'coding-plan' }) + .mockResolvedValueOnce({ value: 'china' }); + mockShowInputBox.mockResolvedValue(undefined); + + const sendToWebView = vi.fn(); + const handler = new AuthMessageHandler( + {} as never, + {} as never, + null, + sendToWebView, + ); + + await handler.handle({ type: 'auth' }); + + expect(sendToWebView).toHaveBeenCalledWith({ type: 'authCancelled' }); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts index 65aae6d00..c55513660 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/AuthMessageHandler.ts @@ -13,22 +13,30 @@ import { getErrorMessage } from '../../utils/errorMessage.js'; * Handles all authentication-related messages */ export class AuthMessageHandler extends BaseMessageHandler { - private loginHandler: (() => Promise<void>) | null = null; + private authInteractiveHandler: + | (( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise<void>) + | null = null; canHandle(messageType: string): boolean { - return ['login', 'getAccountInfo'].includes(messageType); + return ['auth', 'getAccountInfo'].includes(messageType); } async handle(message: { type: string; data?: unknown }): Promise<void> { switch (message.type) { - case 'login': - await this.handleLogin(); + case 'auth': + await this.handleAuthInteractive(); break; - case 'getAccountInfo': { + case 'getAccountInfo': await this.handleGetAccountInfo(); break; - } default: console.warn( @@ -40,14 +48,23 @@ export class AuthMessageHandler extends BaseMessageHandler { } /** - * Set login handler + * Set auth interactive handler — interactive auth flow. */ - setLoginHandler(handler: () => Promise<void>): void { - this.loginHandler = handler; + setAuthInteractiveHandler( + handler: ( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise<void>, + ): void { + this.authInteractiveHandler = handler; } /** - * Handle getAccountInfo request - queries ACP for live account info + * Handle getAccountInfo request */ private async handleGetAccountInfo(): Promise<void> { try { @@ -71,45 +88,300 @@ export class AuthMessageHandler extends BaseMessageHandler { } } - /** - * Handle login request - */ - private async handleLogin(): Promise<void> { - try { - console.log('[AuthMessageHandler] Login requested'); - console.log( - '[AuthMessageHandler] Login handler available:', - !!this.loginHandler, - ); + // --------------------------------------------------------------------------- + // auth: Interactive auth flow (mirrors CLI's /auth) + // --------------------------------------------------------------------------- - // Direct login without additional confirmation - if (this.loginHandler) { - console.log('[AuthMessageHandler] Calling login handler'); - await this.loginHandler(); - console.log( - '[AuthMessageHandler] Login handler completed successfully', - ); + // Alibaba Standard API Key region endpoints + private static readonly ALIBABA_STANDARD_ENDPOINTS: Record<string, string> = { + 'cn-beijing': 'https://dashscope.aliyuncs.com/compatible-mode/v1', + 'sg-singapore': 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1', + 'us-virginia': 'https://dashscope-us.aliyuncs.com/compatible-mode/v1', + 'cn-hongkong': + 'https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1', + }; + + /** + * Notify the webview that the interactive auth flow was dismissed. + */ + private notifyAuthCancelled(): void { + this.sendToWebView({ type: 'authCancelled' }); + } + + /** + * Helper: show a QuickPick and return the selected item's `value`. + * Returns undefined if the user cancels. + */ + private async pick<T extends string>( + items: Array<{ label: string; description?: string; value: T }>, + title: string, + placeHolder: string, + ): Promise<T | undefined> { + const choice = await vscode.window.showQuickPick(items, { + title, + placeHolder, + }); + if (!choice) { + this.notifyAuthCancelled(); + return undefined; + } + return (choice as { value: T }).value; + } + + /** + * Helper: show an InputBox. Returns undefined if the user cancels. + */ + private async input(opts: { + title: string; + prompt: string; + placeHolder?: string; + value?: string; + password?: boolean; + required?: boolean; + }): Promise<string | undefined> { + const value = await vscode.window.showInputBox({ + title: opts.title, + prompt: opts.prompt, + placeHolder: opts.placeHolder, + value: opts.value, + password: opts.password ?? false, + validateInput: opts.required + ? (v) => (!v?.trim() ? 'This field is required' : null) + : undefined, + }); + if (value === undefined) { + this.notifyAuthCancelled(); + return undefined; + } + return value; + } + + /** + * Handle auth — full interactive auth flow. + * + * Tree (mirrors CLI AuthDialog): + * |- Coding Plan -> Region (China/Global) -> API Key -> done + * \- API Key + * |- Alibaba Standard -> Region (4 regions) -> API Key -> Model IDs -> done + * \- Custom -> Base URL -> API Key -> Model -> done + */ + private async handleAuthInteractive(): Promise<void> { + try { + // Main menu + const provider = await this.pick( + [ + { + label: 'Alibaba Cloud Coding Plan', + description: + 'Paid · Up to 6,000 requests/5 hrs · All Coding Plan Models', + value: 'coding-plan' as const, + }, + { + label: 'API Key', + description: 'Bring your own API key', + value: 'api-key' as const, + }, + ], + 'Qwen Code: Auth', + 'Select authentication method', + ); + if (!provider) { + return; + } + + if (provider === 'coding-plan') { + await this.authCodingPlan(); } else { - console.log('[AuthMessageHandler] Using fallback login method'); - // Fallback: show message and use command - vscode.window.showInformationMessage( - 'Please wait while we connect to Qwen Code...', - ); - await vscode.commands.executeCommand('qwen-code.login'); + await this.authApiKey(); } } catch (error) { const errorMsg = getErrorMessage(error); - console.error('[AuthMessageHandler] Login failed:', error); - console.error( - '[AuthMessageHandler] Error stack:', - error instanceof Error ? error.stack : 'N/A', - ); + console.error('[AuthMessageHandler] auth failed:', error); this.sendToWebView({ - type: 'loginError', - data: { - message: `Login failed: ${errorMsg}`, - }, + type: 'authError', + data: { message: `Auth failed: ${errorMsg}` }, }); } } + + /** + * Coding Plan: region -> API key -> connect. + */ + private async authCodingPlan(): Promise<void> { + const region = await this.pick( + [ + { + label: '中国 (China)', + description: '阿里云百炼 — aliyun.com', + value: 'china' as const, + }, + { + label: 'Global', + description: 'Alibaba Cloud — alibabacloud.com', + value: 'global' as const, + }, + ], + 'Qwen Code: Coding Plan Region', + 'Select region', + ); + if (!region) { + return; + } + + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your Coding Plan API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler('coding-plan', region, apiKey); + } + } + + /** + * API Key: select type -> Alibaba Standard or Custom. + */ + private async authApiKey(): Promise<void> { + const keyType = await this.pick( + [ + { + label: 'Alibaba Cloud ModelStudio Standard API Key', + description: 'Quick setup for Model Studio (China/International)', + value: 'alibaba-standard' as const, + }, + { + label: 'Custom API Key', + description: + 'For other OpenAI / Anthropic / Gemini-compatible providers', + value: 'custom' as const, + }, + ], + 'Qwen Code: Select API Key Type', + 'Select API key type', + ); + if (!keyType) { + return; + } + + if (keyType === 'alibaba-standard') { + await this.authAlibabaStandard(); + } else { + await this.authCustom(); + } + } + + /** + * Alibaba Standard: region -> API key -> model IDs -> connect. + */ + private async authAlibabaStandard(): Promise<void> { + const endpoints = AuthMessageHandler.ALIBABA_STANDARD_ENDPOINTS; + + const region = await this.pick( + Object.entries(endpoints).map(([key, endpoint]) => ({ + label: + key === 'cn-beijing' + ? 'China (Beijing)' + : key === 'sg-singapore' + ? 'Singapore' + : key === 'us-virginia' + ? 'US (Virginia)' + : 'China (Hong Kong)', + description: `Endpoint: ${endpoint}`, + value: key, + })), + 'Qwen Code: Select Region', + 'Select region for Alibaba Cloud ModelStudio', + ); + if (!region) { + return; + } + + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your Alibaba Cloud ModelStudio API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + const modelIds = await this.input({ + title: 'Qwen Code: Model IDs', + prompt: 'Enter model IDs (comma-separated)', + placeHolder: 'qwen3.5-plus,glm-5,kimi-k2.5', + value: 'qwen3.5-plus', + required: true, + }); + if (!modelIds) { + return; + } + + const baseUrl = endpoints[region] || endpoints['cn-beijing']; + const firstModel = modelIds.split(',')[0]?.trim() || 'qwen3.5-plus'; + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler( + 'alibaba-standard', + region, + apiKey, + baseUrl, + firstModel, + modelIds, + ); + } + } + + /** + * Custom: base URL -> API key -> model -> connect. + */ + private async authCustom(): Promise<void> { + const baseUrl = await this.input({ + title: 'Qwen Code: Base URL', + prompt: 'Enter API base URL', + placeHolder: 'https://api.openai.com/v1', + value: 'https://api.openai.com/v1', + }); + if (baseUrl === undefined) { + return; + } + + const apiKey = await this.input({ + title: 'Qwen Code: API Key', + prompt: 'Enter your API key', + placeHolder: 'sk-...', + password: true, + required: true, + }); + if (!apiKey) { + return; + } + + const model = await this.input({ + title: 'Qwen Code: Model', + prompt: 'Enter model name', + placeHolder: 'gpt-4o', + required: true, + }); + if (!model) { + return; + } + + if (this.authInteractiveHandler) { + await this.authInteractiveHandler( + 'api-key', + undefined, + apiKey, + baseUrl, + model, + ); + } + } } diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index 2f1b862cc..e7d40fd47 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -165,11 +165,26 @@ export class MessageRouter { } /** - * Set login handler + * Set auth interactive handler — interactive auth flow. + * Also registers the handler on the session handler so + * "Configure" prompts in session flows trigger the interactive flow. */ - setLoginHandler(handler: () => Promise<void>): void { - this.authHandler.setLoginHandler(handler); - this.sessionHandler?.setLoginHandler?.(handler); + setAuthInteractiveHandler( + handler: ( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise<void>, + ): void { + this.authHandler.setAuthInteractiveHandler(handler); + // SessionMessageHandler's authHandler is a simple () => Promise<void>. + // Wrap so "Configure" prompts trigger the full interactive auth QuickPick. + this.sessionHandler?.setAuthHandler?.(() => + this.authHandler.handle({ type: 'auth' }), + ); } /** diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts index 591a69493..6dc7bea52 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.test.ts @@ -186,6 +186,52 @@ describe('SessionMessageHandler', () => { ]); }); + it('keeps currentConversationId aligned with the archived sessionId when session/load falls back to a new ACP session', async () => { + const archivedSessionId = 'archived-session'; + const agentManager = { + isConnected: true, + currentSessionId: 'old-acp-session', + getSessionList: vi + .fn() + .mockResolvedValue([{ id: archivedSessionId, cwd: '/workspace' }]), + loadSessionViaAcp: vi + .fn() + .mockRejectedValue(new Error('session not found on server')), + getSessionMessages: vi.fn().mockResolvedValue([]), + createNewSession: vi.fn().mockResolvedValue('new-acp-session'), + }; + const conversationStore = { + createConversation: vi.fn(), + getConversation: vi.fn(), + addMessage: vi.fn(), + }; + const sendToWebView = vi.fn(); + + const handler = new SessionMessageHandler( + agentManager as never, + conversationStore as never, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'switchQwenSession', + data: { sessionId: archivedSessionId }, + }); + + // Backend-tracked current session must match the sessionId the webview sees, + // otherwise rename/delete/title-update flows will target the wrong session + // during the fallback window (see PR #3093 review). + expect(handler.getCurrentConversationId()).toBe(archivedSessionId); + expect(agentManager.createNewSession).toHaveBeenCalled(); + expect(sendToWebView).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'qwenSessionSwitched', + data: expect.objectContaining({ sessionId: archivedSessionId }), + }), + ); + }); + it('forces a fresh ACP session when the webview requests a new session', async () => { const agentManager = { isConnected: true, diff --git a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts index ea94c10c4..7f5e72ae2 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/SessionMessageHandler.ts @@ -22,7 +22,7 @@ import { getErrorMessage } from '../../utils/errorMessage.js'; */ export class SessionMessageHandler extends BaseMessageHandler { private currentStreamContent = ''; - private loginHandler: (() => Promise<void>) | null = null; + private authHandler: (() => Promise<void>) | null = null; private isTitleSet = false; // Flag to track if title has been set canHandle(messageType: string): boolean { @@ -32,6 +32,8 @@ export class SessionMessageHandler extends BaseMessageHandler { 'switchQwenSession', 'getQwenSessions', 'resumeSession', + 'deleteQwenSession', + 'renameQwenSession', 'cancelStreaming', // UI action: open a new chat tab (new WebviewPanel) 'openNewChatTab', @@ -42,10 +44,10 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Set login handler + * Set auth handler */ - setLoginHandler(handler: () => Promise<void>): void { - this.loginHandler = handler; + setAuthHandler(handler: () => Promise<void>): void { + this.authHandler = handler; } async handle(message: { type: string; data?: unknown }): Promise<void> { @@ -95,6 +97,17 @@ export class SessionMessageHandler extends BaseMessageHandler { await this.handleResumeSession((data?.sessionId as string) || ''); break; + case 'deleteQwenSession': + await this.handleDeleteQwenSession((data?.sessionId as string) || ''); + break; + + case 'renameQwenSession': + await this.handleRenameQwenSession( + (data?.sessionId as string) || '', + (data?.title as string) || '', + ); + break; + case 'openNewChatTab': // Open a brand new chat tab (WebviewPanel) via the extension command // This does not alter the current conversation in this tab; the new tab @@ -223,16 +236,16 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Prompt user to login and invoke the registered login handler/command. - * Returns true if a login was initiated. + * Prompt user to authenticate and invoke the registered auth handler/command. + * Returns true if authentication was initiated. */ - private async promptLogin(message: string): Promise<boolean> { - const result = await vscode.window.showWarningMessage(message, 'Login Now'); - if (result === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); + private async promptAuth(message: string): Promise<boolean> { + const result = await vscode.window.showWarningMessage(message, 'Configure'); + if (result === 'Configure') { + if (this.authHandler) { + await this.authHandler(); } else { - await vscode.commands.executeCommand('qwen-code.login'); + await vscode.commands.executeCommand('qwen-code.auth'); } return true; } @@ -240,25 +253,25 @@ export class SessionMessageHandler extends BaseMessageHandler { } /** - * Prompt user to login or view offline. Returns 'login', 'offline', or 'dismiss'. - * When login is chosen, it triggers the login handler/command. + * Prompt user to authenticate or view offline. Returns 'auth', 'offline', or 'dismiss'. + * When configure is chosen, it triggers the auth handler/command. */ - private async promptLoginOrOffline( + private async promptAuthOrOffline( message: string, - ): Promise<'login' | 'offline' | 'dismiss'> { + ): Promise<'auth' | 'offline' | 'dismiss'> { const selection = await vscode.window.showWarningMessage( message, - 'Login Now', + 'Configure', 'View Offline', ); - if (selection === 'Login Now') { - if (this.loginHandler) { - await this.loginHandler(); + if (selection === 'Configure') { + if (this.authHandler) { + await this.authHandler(); } else { - await vscode.commands.executeCommand('qwen-code.login'); + await vscode.commands.executeCommand('qwen-code.auth'); } - return 'login'; + return 'auth'; } if (selection === 'View Offline') { return 'offline'; @@ -270,7 +283,7 @@ export class SessionMessageHandler extends BaseMessageHandler { return getErrorMessage(error); } - private shouldPromptLogin(error: unknown): boolean { + private shouldPromptAuth(error: unknown): boolean { return isAuthenticationRequiredError(error); } @@ -424,8 +437,10 @@ export class SessionMessageHandler extends BaseMessageHandler { if (!this.agentManager.isConnected) { console.warn('[SessionMessageHandler] Agent not connected'); - // Show non-modal notification with Login button - await this.promptLogin('You need to login first to use Qwen Code.'); + // Show non-modal notification with Configure button + await this.promptAuth( + 'You need to configure your provider to use Qwen Code.', + ); return; } @@ -441,9 +456,9 @@ export class SessionMessageHandler extends BaseMessageHandler { createErr, ); const errorMsg = this.getErrorMessage(createErr); - if (this.shouldPromptLogin(createErr)) { - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + if (this.shouldPromptAuth(createErr)) { + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to continue using Qwen Code.', ); return; } @@ -495,6 +510,21 @@ export class SessionMessageHandler extends BaseMessageHandler { } this.sendStreamEnd(undefined, myRequestId); + + // After first message, sync ACP session ID to webview for session list highlighting + const acpSessionId = this.agentManager.currentSessionId; + if (acpSessionId && acpSessionId !== this.currentConversationId) { + this.currentConversationId = acpSessionId; + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { + sessionId: acpSessionId, + title: + displayText.substring(0, 50) + + (displayText.length > 50 ? '...' : ''), + }, + }); + } } catch (error) { console.error('[SessionMessageHandler] Error sending message:', error); @@ -522,17 +552,17 @@ export class SessionMessageHandler extends BaseMessageHandler { // Check for session not found error and handle it appropriately if ( errorMsg.includes('Session not found') || - this.shouldPromptLogin(error) + this.shouldPromptAuth(error) ) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to continue using Qwen Code.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to continue using Qwen Code.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); this.sendStreamEnd('session_expired', myRequestId); } else { @@ -578,10 +608,10 @@ export class SessionMessageHandler extends BaseMessageHandler { try { console.log('[SessionMessageHandler] Creating new Qwen session...'); - // Ensure connection (login) before creating a new session + // Ensure connection (auth) before creating a new session if (!this.agentManager.isConnected) { - const proceeded = await this.promptLogin( - 'You need to login before creating a new session.', + const proceeded = await this.promptAuth( + 'You need to configure your provider before creating a new session.', ); if (!proceeded) { return; @@ -610,16 +640,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to create a new session.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to create a new session.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -637,10 +667,10 @@ export class SessionMessageHandler extends BaseMessageHandler { try { console.log('[SessionMessageHandler] Switching to session:', sessionId); - // If not connected yet, offer to login or view offline + // If not connected yet, offer to authenticate or view offline if (!this.agentManager.isConnected) { - const choice = await this.promptLoginOrOffline( - 'You are not logged in. Login now to fully restore this session, or view it offline.', + const choice = await this.promptAuthOrOffline( + 'You are not authenticated. Configure your provider to fully restore this session, or view it offline.', ); if (choice === 'offline') { @@ -652,12 +682,20 @@ export class SessionMessageHandler extends BaseMessageHandler { type: 'qwenSessionSwitched', data: { sessionId, messages }, }); + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); vscode.window.showInformationMessage( - 'Showing cached session content. Login to interact with the AI.', + 'Showing cached session content. Configure your provider to interact with the AI.', ); return; - } else if (choice !== 'login') { - // User dismissed; do nothing + } else if (choice !== 'auth') { + // User dismissed; clear loading state + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); return; } } @@ -702,6 +740,12 @@ export class SessionMessageHandler extends BaseMessageHandler { // Reset title flag when switching sessions this.isTitleSet = false; + // Notify webview that session history has finished loading + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); + // Successfully loaded session, return early to avoid fallback logic return; } catch (loadError) { @@ -711,16 +755,16 @@ export class SessionMessageHandler extends BaseMessageHandler { ); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(loadError)) { + if (this.shouldPromptAuth(loadError)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to switch sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to switch sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); return; } @@ -731,19 +775,28 @@ export class SessionMessageHandler extends BaseMessageHandler { // If we are connected, try to create a fresh ACP session so user can interact if (this.agentManager.isConnected) { try { - const newAcpSessionId = await this.agentManager.createNewSession( - workingDir, - { - forceNew: true, - }, - ); + await this.agentManager.createNewSession(workingDir, { + forceNew: true, + }); - this.currentConversationId = newAcpSessionId; + // Keep the viewed session identity aligned with what the webview sees + // (the archived sessionId). The live ACP session lives on + // agentManager.currentSessionId; the sync-on-first-message path + // (see streamEnd handler) will flip both sides to the ACP id once + // the user actually sends a message. Setting currentConversationId + // to the new ACP id here would desync the backend from the webview + // and cause rename/delete/title-update flows to target the wrong + // session during the fallback window. + this.currentConversationId = sessionId; this.sendToWebView({ type: 'qwenSessionSwitched', data: { sessionId, messages, session: sessionDetails }, }); + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); // Only show the cache warning if we actually fell back to local cache // and didn't successfully load via ACP @@ -765,16 +818,18 @@ export class SessionMessageHandler extends BaseMessageHandler { ); // Check for authentication/session expiration errors in session creation - if (this.shouldPromptLogin(createError)) { + if (this.shouldPromptAuth(createError)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to switch sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to switch sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { + message: 'Session expired. Please authenticate again.', + }, }); return; } @@ -788,8 +843,12 @@ export class SessionMessageHandler extends BaseMessageHandler { type: 'qwenSessionSwitched', data: { sessionId, messages, session: sessionDetails }, }); + this.sendToWebView({ + type: 'sessionLoadComplete', + data: { sessionId }, + }); vscode.window.showWarningMessage( - 'Showing cached session content. Login to interact with the AI.', + 'Showing cached session content. Configure your provider to interact with the AI.', ); } } @@ -799,16 +858,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to switch sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to switch sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -848,16 +907,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to view sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to view sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -895,10 +954,10 @@ export class SessionMessageHandler extends BaseMessageHandler { */ private async handleResumeSession(sessionId: string): Promise<void> { try { - // If not connected, offer to login or view offline + // If not connected, offer to authenticate or view offline if (!this.agentManager.isConnected) { - const choice = await this.promptLoginOrOffline( - 'You are not logged in. Login now to fully restore this session, or view it offline.', + const choice = await this.promptAuthOrOffline( + 'You are not authenticated. Configure your provider to fully restore this session, or view it offline.', ); if (choice === 'offline') { @@ -910,10 +969,10 @@ export class SessionMessageHandler extends BaseMessageHandler { data: { sessionId, messages }, }); vscode.window.showInformationMessage( - 'Showing cached session content. Login to interact with the AI.', + 'Showing cached session content. Configure your provider to interact with the AI.', ); return; - } else if (choice !== 'login') { + } else if (choice !== 'auth') { return; } } @@ -937,16 +996,16 @@ export class SessionMessageHandler extends BaseMessageHandler { return; } catch (acpError) { // Check for authentication/session expiration errors - if (this.shouldPromptLogin(acpError)) { + if (this.shouldPromptAuth(acpError)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to resume sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to resume sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); return; } @@ -959,16 +1018,16 @@ export class SessionMessageHandler extends BaseMessageHandler { // Safely convert error to string const errorMsg = this.getErrorMessage(error); // Check for authentication/session expiration errors - if (this.shouldPromptLogin(error)) { + if (this.shouldPromptAuth(error)) { // Show a more user-friendly error message for expired sessions - await this.promptLogin( - 'Your login session has expired or is invalid. Please login again to resume sessions.', + await this.promptAuth( + 'Your session has expired or is invalid. Please configure your provider to resume sessions.', ); // Send a specific error to the webview for better UI handling this.sendToWebView({ type: 'sessionExpired', - data: { message: 'Session expired. Please login again.' }, + data: { message: 'Session expired. Please authenticate again.' }, }); } else { this.sendToWebView({ @@ -979,6 +1038,98 @@ export class SessionMessageHandler extends BaseMessageHandler { } } + /** + * Handle delete session request + */ + private async handleDeleteQwenSession(sessionId: string): Promise<void> { + try { + if ( + sessionId === this.currentConversationId || + sessionId === this.agentManager.currentSessionId + ) { + this.sendToWebView({ + type: 'error', + data: { message: 'Cannot delete the current active session.' }, + }); + return; + } + + const success = await this.agentManager.deleteSession(sessionId); + if (success) { + this.sendToWebView({ + type: 'sessionDeleted', + data: { sessionId }, + }); + } else { + this.sendToWebView({ + type: 'error', + data: { message: 'Failed to delete session.' }, + }); + } + } catch (error) { + const errorMsg = this.getErrorMessage(error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to delete session: ${errorMsg}` }, + }); + } + } + + /** + * Handle rename session request + */ + private async handleRenameQwenSession( + sessionId: string, + title: string, + ): Promise<void> { + try { + const trimmedTitle = title.trim().replace(/[\r\n]+/g, ' '); + if (!trimmedTitle) { + this.sendToWebView({ + type: 'error', + data: { message: 'Please provide a name.' }, + }); + return; + } + // Matches SESSION_TITLE_MAX_LENGTH from @qwen-code/qwen-code-core/sessionService + if (trimmedTitle.length > 200) { + this.sendToWebView({ + type: 'error', + data: { message: 'Name is too long. Maximum 200 characters.' }, + }); + return; + } + + const success = await this.agentManager.renameSession( + sessionId, + trimmedTitle, + ); + if (success) { + this.sendToWebView({ + type: 'sessionRenamed', + data: { sessionId, title: trimmedTitle }, + }); + if (sessionId === this.currentConversationId) { + this.sendToWebView({ + type: 'sessionTitleUpdated', + data: { sessionId, title: trimmedTitle }, + }); + } + } else { + this.sendToWebView({ + type: 'error', + data: { message: 'Failed to rename session.' }, + }); + } + } catch (error) { + const errorMsg = this.getErrorMessage(error); + this.sendToWebView({ + type: 'error', + data: { message: `Failed to rename session: ${errorMsg}` }, + }); + } + } + /** * Set approval mode via agent (ACP session/set_mode) */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts index 2f69772d9..2b863cd45 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/session/useSessionManagement.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useState, useCallback, useMemo } from 'react'; +import { useState, useCallback, useEffect, useMemo, useRef } from 'react'; import type { VSCodeAPI } from '../../hooks/useVSCode.js'; /** @@ -23,9 +23,40 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { const [nextCursor, setNextCursor] = useState<number | undefined>(undefined); const [hasMore, setHasMore] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(false); + const [isSwitchingSession, setIsSwitchingSessionRaw] = + useState<boolean>(false); + const switchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); + const SWITCH_TIMEOUT_MS = 15000; const PAGE_SIZE = 20; + const setIsSwitchingSession = useCallback((value: boolean) => { + setIsSwitchingSessionRaw(value); + if (switchTimeoutRef.current) { + clearTimeout(switchTimeoutRef.current); + switchTimeoutRef.current = null; + } + if (value) { + switchTimeoutRef.current = setTimeout(() => { + console.warn( + '[useSessionManagement] Switch session timed out, clearing loading state', + ); + setIsSwitchingSessionRaw(false); + switchTimeoutRef.current = null; + }, SWITCH_TIMEOUT_MS); + } + }, []); + + useEffect( + () => () => { + if (switchTimeoutRef.current) { + clearTimeout(switchTimeoutRef.current); + switchTimeoutRef.current = null; + } + }, + [], + ); + /** * Filter session list */ @@ -98,12 +129,39 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { } console.log('[useSessionManagement] Switching to session:', sessionId); + setIsSwitchingSession(true); vscode.postMessage({ type: 'switchQwenSession', data: { sessionId }, }); }, - [currentSessionId, vscode], + [currentSessionId, vscode, setIsSwitchingSession], + ); + + /** + * Delete session + */ + const handleDeleteSession = useCallback( + (sessionId: string) => { + vscode.postMessage({ + type: 'deleteQwenSession', + data: { sessionId }, + }); + }, + [vscode], + ); + + /** + * Rename session + */ + const handleRenameSession = useCallback( + (sessionId: string, title: string) => { + vscode.postMessage({ + type: 'renameQwenSession', + data: { sessionId, title }, + }); + }, + [vscode], ); return { @@ -117,6 +175,7 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { nextCursor, hasMore, isLoading, + isSwitchingSession, // State setters setQwenSessions, @@ -127,11 +186,14 @@ export const useSessionManagement = (vscode: VSCodeAPI) => { setNextCursor, setHasMore, setIsLoading, + setIsSwitchingSession, // Operations handleLoadQwenSessions, handleNewQwenSession, handleSwitchSession, handleLoadMoreSessions, + handleDeleteSession, + handleRenameSession, }; }; diff --git a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts index 2344f7caa..517d2db4e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useMessageSubmit.ts @@ -110,12 +110,12 @@ export const useMessageSubmit = ({ inputFieldRef.current.setAttribute('data-empty', 'true'); } vscode.postMessage({ - type: 'login', + type: 'auth', data: {}, }); - // Show a friendly loading message in the chat while logging in + // Show a friendly loading message in the chat while authenticating try { - messageHandling.setWaitingForResponse('Logging in to Qwen Code...'); + messageHandling.setWaitingForResponse('Authenticating Qwen Code...'); } catch (_err) { // Best-effort UI hint; ignore if hook not available } diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 0eca0d8eb..8ad6cf865 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -47,6 +47,7 @@ interface UseWebViewMessagesProps { setNextCursor: (cursor: number | undefined) => void; setHasMore: (hasMore: boolean) => void; setIsLoading: (loading: boolean) => void; + setIsSwitchingSession: (switching: boolean) => void; }; // File context @@ -456,15 +457,8 @@ export const useWebViewMessages = ({ break; } - case 'loginSuccess': { - // Clear loading state and show a short assistant notice + case 'authSuccess': { handlers.messageHandling.clearWaitingForResponse(); - handlers.messageHandling.addMessage({ - role: 'assistant', - content: 'Successfully logged in. You can continue chatting.', - timestamp: Date.now(), - }); - // Set authentication state to true handlers.setIsAuthenticated?.(true); break; } @@ -494,12 +488,12 @@ export const useWebViewMessages = ({ break; } - case 'loginError': { + case 'authError': { // Clear loading state and show error notice handlers.messageHandling.clearWaitingForResponse(); const errorMsg = (message?.data?.message as string) || - 'Login failed. Please try again.'; + 'Auth failed. Please try again.'; handlers.messageHandling.addMessage({ role: 'assistant', content: errorMsg, @@ -679,6 +673,7 @@ export const useWebViewMessages = ({ clearInsightState(); } handlers.messageHandling.clearWaitingForResponse(); + handlers.sessionManagement.setIsSwitchingSession(false); // Display error message to user so they know what went wrong const errorMessage = (message?.data?.message as string) || @@ -1033,6 +1028,11 @@ export const useWebViewMessages = ({ lastPlanSnapshotRef.current = null; break; + case 'sessionLoadComplete': + case 'sessionExpired': + handlers.sessionManagement.setIsSwitchingSession(false); + break; + case 'conversationCleared': clearInsightState(); resetConversationState({ @@ -1060,6 +1060,35 @@ export const useWebViewMessages = ({ break; } + case 'sessionDeleted': { + const deletedId = message.data?.sessionId as string; + if (deletedId) { + handlers.sessionManagement.setQwenSessions( + (prev: Array<Record<string, unknown>>) => + prev.filter( + (s) => s.sessionId !== deletedId && s.id !== deletedId, + ), + ); + } + break; + } + + case 'sessionRenamed': { + const renamedId = message.data?.sessionId as string; + const newTitle = message.data?.title as string; + if (renamedId && newTitle) { + handlers.sessionManagement.setQwenSessions( + (prev: Array<Record<string, unknown>>) => + prev.map((s) => + s.sessionId === renamedId || s.id === renamedId + ? { ...s, title: newTitle, name: newTitle } + : s, + ), + ); + } + break; + } + case 'activeEditorChanged': { const fileName = message.data?.fileName as string | null; const filePath = message.data?.filePath as string | null; diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index d400fa727..6c5460cfc 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -75,10 +75,19 @@ export class MessageHandler { } /** - * Set login handler + * Set auth interactive handler — interactive auth flow. */ - setLoginHandler(handler: () => Promise<void>): void { - this.router.setLoginHandler(handler); + setAuthInteractiveHandler( + handler: ( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ) => Promise<void>, + ): void { + this.router.setAuthInteractiveHandler(handler); } /** diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts index b9d52e4ad..4412cdb6e 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.test.ts @@ -7,23 +7,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { + mockConfigChangeHandlers, availableCommandsCallbackRef, mockCreateImagePathResolver, + mockConfigGet, + mockConfigUpdate, mockGetGlobalTempDir, mockGetPanel, mockMessageHandlerInstances, + mockOnDidChangeConfiguration, mockOnDidChangeActiveTextEditor, mockOnDidChangeTextEditorSelection, mockOpenExternal, + mockReadQwenSettingsForVSCode, + mockWriteCodingPlanConfig, + mockWriteModelProvidersConfig, + mockClearPersistedAuth, slashCommandNotificationCallbackRef, mockQwenAgentManagerInstances, } = vi.hoisted(() => ({ + mockConfigChangeHandlers: [] as Array< + (event: { affectsConfiguration: (section: string) => boolean }) => unknown + >, availableCommandsCallbackRef: { current: undefined as | ((commands: Array<{ name: string; description?: string }>) => void) | undefined, }, mockCreateImagePathResolver: vi.fn(), + mockConfigGet: vi.fn(), + mockConfigUpdate: vi.fn(), mockGetGlobalTempDir: vi.fn(() => '/global-temp'), mockGetPanel: vi.fn<() => { webview: { postMessage: unknown } } | null>( () => null, @@ -34,9 +47,29 @@ const { data: { optionId?: string }; }) => void; }>, + mockOnDidChangeConfiguration: vi.fn( + ( + handler: (event: { + affectsConfiguration: (section: string) => boolean; + }) => unknown, + ) => { + mockConfigChangeHandlers.push(handler); + return { dispose: vi.fn() }; + }, + ), mockOnDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), mockOnDidChangeTextEditorSelection: vi.fn(() => ({ dispose: vi.fn() })), mockOpenExternal: vi.fn(), + mockReadQwenSettingsForVSCode: vi.fn< + () => { + provider: 'coding-plan' | 'api-key'; + apiKey: string; + codingPlanRegion: 'china' | 'global'; + } | null + >(() => null), + mockWriteCodingPlanConfig: vi.fn(() => ({})), + mockWriteModelProvidersConfig: vi.fn(), + mockClearPersistedAuth: vi.fn(), slashCommandNotificationCallbackRef: { current: undefined as | ((event: { @@ -50,6 +83,7 @@ const { mockQwenAgentManagerInstances: [] as Array<{ permissionRequestCallback?: (request: unknown) => Promise<string>; cancelCurrentPrompt: ReturnType<typeof vi.fn>; + disconnect: ReturnType<typeof vi.fn>; }>, })); @@ -66,6 +100,9 @@ vi.mock('@qwen-code/qwen-code-core', async () => { }); vi.mock('vscode', () => ({ + ConfigurationTarget: { + Global: 'global', + }, Uri: { joinPath: vi.fn((base: { fsPath?: string }, ...parts: string[]) => ({ fsPath: `${base.fsPath ?? ''}/${parts.join('/')}`.replace(/\/+/g, '/'), @@ -82,12 +119,24 @@ vi.mock('vscode', () => ({ }, workspace: { workspaceFolders: [{ uri: { fsPath: '/workspace-root' } }], + onDidChangeConfiguration: mockOnDidChangeConfiguration, + getConfiguration: vi.fn(() => ({ + get: mockConfigGet, + update: mockConfigUpdate, + })), }, commands: { executeCommand: vi.fn(), }, })); +vi.mock('../../services/settingsWriter.js', () => ({ + writeCodingPlanConfig: mockWriteCodingPlanConfig, + writeModelProvidersConfig: mockWriteModelProvidersConfig, + readQwenSettingsForVSCode: mockReadQwenSettingsForVSCode, + clearPersistedAuth: mockClearPersistedAuth, +})); + vi.mock('../../services/qwenAgentManager.js', () => ({ QwenAgentManager: class { isConnected = false; @@ -179,7 +228,7 @@ vi.mock('./MessageHandler.js', () => ({ ) { mockMessageHandlerInstances.push(this); } - setLoginHandler = vi.fn(); + setAuthInteractiveHandler = vi.fn(); permissionHandler?: (message: { type: string; data: { optionId?: string }; @@ -227,6 +276,10 @@ import { MAX_PANEL_TITLE_LENGTH, } from '../utils/panelTitleUtils.js'; +const createConfigChangeEvent = (...affectedSections: string[]) => ({ + affectsConfiguration: (section: string) => affectedSections.includes(section), +}); + type WebViewMessageHandler = (message: { type: string; data?: unknown; @@ -278,12 +331,19 @@ async function setupAttachedProvider(options?: { return { webview, postMessage, provider, messageHandler }; } +beforeEach(() => { + mockConfigChangeHandlers.length = 0; +}); + describe('WebViewProvider.attachToView', () => { beforeEach(() => { vi.clearAllMocks(); mockMessageHandlerInstances.length = 0; mockQwenAgentManagerInstances.length = 0; mockGetPanel.mockReturnValue(null); + mockConfigGet.mockImplementation( + (_key: string, defaultValue: unknown) => defaultValue, + ); availableCommandsCallbackRef.current = undefined; slashCommandNotificationCallbackRef.current = undefined; mockCreateImagePathResolver.mockReturnValue((paths: string[]) => @@ -666,6 +726,231 @@ describe('WebViewProvider.attachToView', () => { }); }); +describe('WebViewProvider settings sync', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockConfigChangeHandlers.length = 0; + mockConfigGet.mockImplementation( + (_key: string, defaultValue: unknown) => defaultValue, + ); + }); + + it('does not report success for api-key settings without interactive auth data', async () => { + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'apiKey') { + return 'sk-test'; + } + if (key === 'provider') { + return 'api-key'; + } + return defaultValue; + }); + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + const synced = await ( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise<boolean>; + } + ).syncVSCodeSettingsToQwenConfig(); + + expect(synced).toBe(false); + expect(mockWriteCodingPlanConfig).not.toHaveBeenCalled(); + expect(mockWriteModelProvidersConfig).not.toHaveBeenCalled(); + }); + + it('only syncs non-secret VS Code settings from ~/.qwen/settings.json', async () => { + mockReadQwenSettingsForVSCode.mockReturnValue({ + provider: 'coding-plan', + apiKey: 'sk-updated', + codingPlanRegion: 'global', + }); + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'provider') { + return 'api-key'; + } + if (key === 'apiKey') { + return 'sk-current'; + } + if (key === 'codingPlanRegion') { + return 'china'; + } + return defaultValue; + }); + + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + await ( + provider as unknown as { + syncQwenConfigToVSCodeSettings: () => Promise<void>; + } + ).syncQwenConfigToVSCodeSettings(); + + expect(mockConfigUpdate).toHaveBeenCalledTimes(2); + expect(mockConfigUpdate).toHaveBeenCalledWith( + 'provider', + 'coding-plan', + expect.anything(), + ); + expect(mockConfigUpdate).toHaveBeenCalledWith( + 'codingPlanRegion', + 'global', + expect.anything(), + ); + expect(mockConfigUpdate).not.toHaveBeenCalledWith( + 'apiKey', + 'sk-updated', + expect.anything(), + ); + }); + + it('ignores non-auth qwen-code setting changes', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + const syncSpy = vi + .spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise<boolean>; + }, + 'syncVSCodeSettingsToQwenConfig', + ) + .mockResolvedValue(true); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + await configChangeHandler?.(createConfigChangeEvent('qwen-code')); + + expect(syncSpy).not.toHaveBeenCalled(); + }); + + it('reacts to auth-related qwen-code setting changes', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + const syncSpy = vi + .spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise<boolean>; + }, + 'syncVSCodeSettingsToQwenConfig', + ) + .mockResolvedValue(false); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + await configChangeHandler?.( + createConfigChangeEvent('qwen-code', 'qwen-code.apiKey'), + ); + + expect(syncSpy).toHaveBeenCalledTimes(1); + }); + + it('clears persisted credentials and disconnects when apiKey is emptied', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + // Simulate an already-initialized agent connection + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + + // syncVSCodeSettingsToQwenConfig returns false because apiKey is empty + vi.spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise<boolean>; + }, + 'syncVSCodeSettingsToQwenConfig', + ).mockResolvedValue(false); + + // apiKey is empty (user cleared it in Settings) + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'apiKey') { + return ''; + } + return defaultValue; + }); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + await configChangeHandler?.( + createConfigChangeEvent('qwen-code', 'qwen-code.apiKey'), + ); + + // Should clear persisted auth + expect(mockClearPersistedAuth).toHaveBeenCalledTimes(1); + + // Should disconnect the agent + const agentManager = mockQwenAgentManagerInstances.at(-1); + expect(agentManager?.disconnect).toHaveBeenCalledTimes(1); + + // agentInitialized should be reset + expect( + (provider as unknown as { agentInitialized: boolean }).agentInitialized, + ).toBe(false); + }); + + it('does not de-auth when non-apiKey auth settings change on an api-key provider', async () => { + const provider = new WebViewProvider( + { subscriptions: [] } as never, + { fsPath: '/extension-root' } as never, + ); + + // Simulate an already-initialized agent with api-key provider + (provider as unknown as { agentInitialized: boolean }).agentInitialized = + true; + + // syncVSCodeSettingsToQwenConfig returns false — normal for api-key providers + vi.spyOn( + provider as unknown as { + syncVSCodeSettingsToQwenConfig: () => Promise<boolean>; + }, + 'syncVSCodeSettingsToQwenConfig', + ).mockResolvedValue(false); + + // apiKey is empty because api-key providers don't use this VS Code setting + mockConfigGet.mockImplementation((key: string, defaultValue: unknown) => { + if (key === 'apiKey') { + return ''; + } + if (key === 'provider') { + return 'api-key'; + } + return defaultValue; + }); + + const configChangeHandler = mockConfigChangeHandlers.at(-1); + expect(configChangeHandler).toBeDefined(); + + // Changing codingPlanRegion should NOT trigger de-auth + await configChangeHandler?.( + createConfigChangeEvent('qwen-code', 'qwen-code.codingPlanRegion'), + ); + + expect(mockClearPersistedAuth).not.toHaveBeenCalled(); + + const agentManager = mockQwenAgentManagerInstances.at(-1); + expect(agentManager?.disconnect).not.toHaveBeenCalled(); + + // agentInitialized should remain true + expect( + (provider as unknown as { agentInitialized: boolean }).agentInitialized, + ).toBe(true); + }); +}); + describe('WebViewProvider.createNewSession', () => { it('forces a fresh ACP session for the sidebar new-session action', async () => { const provider = new WebViewProvider( diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index 2a2c20071..bcae4dd88 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -26,8 +26,20 @@ import { createImagePathResolver } from '../utils/imageHandler.js'; import { type ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import { isAuthenticationRequiredError } from '../../utils/authErrors.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; +import { + writeCodingPlanConfig, + writeModelProvidersConfig, + readQwenSettingsForVSCode, + clearPersistedAuth, +} from '../../services/settingsWriter.js'; import { parseInsightMessage } from '@qwen-code/qwen-code-core'; +const AUTH_RELATED_QWEN_SETTINGS = [ + 'qwen-code.provider', + 'qwen-code.apiKey', + 'qwen-code.codingPlanRegion', +] as const; + function isInsightCommand(command: string): boolean { const [firstToken = ''] = command.trim().split(/\s+/, 1); return firstToken.replace(/^\/+/, '') === 'insight'; @@ -40,6 +52,7 @@ export class WebViewProvider { private conversationStore: ConversationStore; private disposables: vscode.Disposable[] = []; private agentInitialized = false; // Track if agent has been initialized + private isSyncingToVSCode = false; // Guard to prevent config change loop // Track a pending permission request and its resolver so extension commands // can "simulate" user choice from the command palette (e.g. after accepting // a diff, auto-allow read/execute, or auto-reject on cancel). @@ -70,6 +83,10 @@ export class WebViewProvider { /** Guards against concurrent auth-restore / connection init */ private initializationPromise: Promise<void> | null = null; private isReconnecting = false; + /** Timer for the deferred auto-auth launch inside doInitializeAgentConnection */ + private autoAuthTimer: ReturnType<typeof setTimeout> | null = null; + /** Whether an explicit interactive auth flow is currently active */ + private authFlowActive = false; constructor( private context: vscode.ExtensionContext, @@ -100,10 +117,78 @@ export class WebViewProvider { (message) => this.sendMessageToWebView(message), ); - // Set login handler for /login command - direct force re-login - this.messageHandler.setLoginHandler(async () => { - await this.forceReLogin(); - }); + // Set auth interactive handler — interactive auth flow (QuickPick → InputBox → write settings → reconnect) + this.messageHandler.setAuthInteractiveHandler( + async (provider, region, apiKey, baseUrl, model, modelIds) => { + await this.handleAuthInteractive( + provider, + region, + apiKey, + baseUrl, + model, + modelIds, + ); + }, + ); + + // Watch for auth-related VSCode settings changes — auto-sync and reconnect. + // The isSyncingToVSCode guard prevents a loop when we programmatically populate VSCode settings. + const configChangeDisposable = vscode.workspace.onDidChangeConfiguration( + async (e) => { + const authSettingsChanged = AUTH_RELATED_QWEN_SETTINGS.some((setting) => + e.affectsConfiguration(setting), + ); + + if (authSettingsChanged && !this.isSyncingToVSCode) { + console.log( + '[WebViewProvider] Auth-related qwen-code settings changed by user, syncing...', + ); + const synced = await this.syncVSCodeSettingsToQwenConfig(); + if (synced && this.agentInitialized) { + // Settings changed and we have an active connection — reconnect + try { + this.agentManager.disconnect(); + this.agentInitialized = false; + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.doInitializeAgentConnection({ + autoAuthenticate: false, + }); + } catch (e) { + console.error( + '[WebViewProvider] Reconnect after settings change failed:', + e, + ); + } + } else if ( + !synced && + this.agentInitialized && + e.affectsConfiguration('qwen-code.apiKey') + ) { + // Only de-auth when qwen-code.apiKey itself was cleared. + // Other auth-related settings (provider, codingPlanRegion) returning + // synced=false is normal for api-key providers — those are managed by + // the interactive auth flow, not VS Code Settings sync. + const apiKey = vscode.workspace + .getConfiguration('qwen-code') + .get<string>('apiKey', ''); + if (!apiKey) { + console.log( + '[WebViewProvider] apiKey cleared — de-authenticating and clearing persisted credentials', + ); + clearPersistedAuth(); + this.agentManager.disconnect(); + this.agentInitialized = false; + this.authState = false; + this.sendMessageToWebView({ + type: 'authState', + data: { authenticated: false }, + }); + } + } + } + }, + ); + this.disposables.push(configChangeDisposable); // Setup file watchers for cache invalidation const fileWatcherDisposable = this.messageHandler.setupFileWatchers(); @@ -866,6 +951,29 @@ export class WebViewProvider { await this.attemptAuthStateRestoration(); } + /** + * Launch the interactive auth flow (QuickPick → InputBox → write settings → reconnect). + * Guards against concurrent launches: if auto-auth was scheduled by + * doInitializeAgentConnection's deferred timeout, it is cancelled first. + */ + async startInteractiveAuth(): Promise<void> { + // Cancel any pending auto-auth from doInitializeAgentConnection so we + // don't end up with two overlapping auth flows. + if (this.autoAuthTimer) { + clearTimeout(this.autoAuthTimer); + this.autoAuthTimer = null; + } + if (this.authFlowActive) { + return; + } + this.authFlowActive = true; + try { + await this.messageHandler.route({ type: 'auth' }); + } finally { + this.authFlowActive = false; + } + } + setInitialModelId(modelId: string | null | undefined): void { this.initialModelId = typeof modelId === 'string' && modelId.trim().length > 0 @@ -874,8 +982,113 @@ export class WebViewProvider { } /** - * Attempt to restore authentication state and initialize connection - * This is called when the webview is first shown + * Sync VSCode extension settings (qwen-code.*) to ~/.qwen/settings.json + * if an API key is configured. This enables auto-connect on startup + * without requiring the user to click "Connect" each time. + * + * @returns true if settings were synced (apiKey is configured), false otherwise + */ + private async syncVSCodeSettingsToQwenConfig(): Promise<boolean> { + const config = vscode.workspace.getConfiguration('qwen-code'); + const apiKey = config.get<string>('apiKey', ''); + + if (!apiKey) { + return false; + } + + try { + const provider = config.get<string>('provider', 'coding-plan'); + + if (provider !== 'coding-plan') { + console.log( + '[WebViewProvider] Skipping VSCode settings sync for api-key provider; interactive auth owns provider details', + ); + return false; + } + + const region = config.get<'china' | 'global'>( + 'codingPlanRegion', + 'china', + ); + writeCodingPlanConfig(region, apiKey); + + console.log( + `[WebViewProvider] Synced VSCode settings → ~/.qwen/settings.json (provider=${provider})`, + ); + return true; + } catch (error) { + console.error('[WebViewProvider] Failed to sync VSCode settings:', error); + return false; + } + } + + /** + * Sync ~/.qwen/settings.json values back to VSCode Settings UI. + * This makes existing CLI-configured non-secret metadata visible in the + * VSCode Settings page without mirroring credentials into settings.json. + */ + private async syncQwenConfigToVSCodeSettings(): Promise<void> { + try { + const qwenSettings = readQwenSettingsForVSCode(); + if (!qwenSettings) { + return; + } + + console.log( + '[WebViewProvider] Syncing ~/.qwen/settings.json → VSCode settings', + ); + + // Set guard to prevent onDidChangeConfiguration from triggering a write-back + const config = vscode.workspace.getConfiguration('qwen-code'); + const target = vscode.ConfigurationTarget.Global; + const updates: Array<Thenable<void>> = []; + + if ( + config.get<string>('provider', 'coding-plan') !== qwenSettings.provider + ) { + updates.push(config.update('provider', qwenSettings.provider, target)); + } + if ( + config.get<'china' | 'global'>('codingPlanRegion', 'china') !== + qwenSettings.codingPlanRegion + ) { + updates.push( + config.update( + 'codingPlanRegion', + qwenSettings.codingPlanRegion, + target, + ), + ); + } + + if (updates.length === 0) { + console.log( + '[WebViewProvider] VSCode settings already match ~/.qwen/settings.json', + ); + return; + } + + this.isSyncingToVSCode = true; + + try { + await Promise.all(updates); + } finally { + this.isSyncingToVSCode = false; + } + } catch (error) { + console.error( + '[WebViewProvider] Failed to sync qwen config to VSCode settings:', + error, + ); + } + } + + /** + * Attempt to restore authentication state and initialize connection. + * On startup, sync ~/.qwen/settings.json → VSCode settings so the Settings UI + * reflects existing non-secret CLI config, then attempt a connection. + * Writing back to ~/.qwen/settings.json happens through the auth flow and + * auth-related VSCode setting changes. */ private async attemptAuthStateRestoration(): Promise<void> { // Prevent concurrent initialization attempts (e.g. visibility toggle + webviewReady race) @@ -885,6 +1098,8 @@ export class WebViewProvider { this.initializationPromise = (async () => { try { + await this.syncQwenConfigToVSCodeSettings(); + console.log('[WebViewProvider] Attempting connection...'); // Attempt a connection to detect prior auth without forcing login await this.initializeAgentConnection({ autoAuthenticate: false }); @@ -954,7 +1169,7 @@ export class WebViewProvider { // send authState message and return without creating session if (connectResult.requiresAuth && !autoAuthenticate) { console.log( - '[WebViewProvider] Authentication required but auto-auth disabled, sending authState and returning', + '[WebViewProvider] Authentication required, launching auth flow...', ); this.sendMessageToWebView({ type: 'authState', @@ -962,6 +1177,22 @@ export class WebViewProvider { }); // Initialize empty conversation to allow browsing history await this.initializeEmptyConversation(); + + // Auto-launch the interactive auth flow (QuickPick → InputBox) + // so the user is immediately guided to configure their provider, + // mirroring CLI's behavior of showing AuthDialog on first run. + // Deferred to avoid conflicting with the current connection init. + // The timer is stored so startInteractiveAuth() can cancel it + // to prevent two overlapping auth flows. + this.autoAuthTimer = setTimeout(() => { + this.autoAuthTimer = null; + if (!this.authFlowActive) { + this.authFlowActive = true; + void this.messageHandler.route({ type: 'auth' }).finally(() => { + this.authFlowActive = false; + }); + } + }, 100); return; } @@ -1009,70 +1240,100 @@ export class WebViewProvider { } /** - * Force re-login by clearing auth cache and reconnecting - * Called when user explicitly uses /login command + * Handle auth interactive — interactive auth flow result. + * Writes provider config to ~/.qwen/settings.json and reconnects. + * Mirrors the CLI's `qwen auth coding-plan` / `qwen auth` flow. */ - async forceReLogin(): Promise<void> { - console.log('[WebViewProvider] Force re-login requested'); + private async handleAuthInteractive( + provider: string, + region?: string, + apiKey?: string, + baseUrl?: string, + model?: string, + modelIds?: string, + ): Promise<void> { + if (!apiKey) { + this.sendMessageToWebView({ + type: 'authError', + data: { message: 'API key is required.' }, + }); + return; + } - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - cancellable: false, - }, - async (progress) => { - try { - progress.report({ message: 'Preparing sign-in...' }); - - // Disconnect existing connection if any - if (this.agentInitialized) { - try { - this.agentManager.disconnect(); - console.log('[WebViewProvider] Existing connection disconnected'); - } catch (_error) { - console.log('[WebViewProvider] Error disconnecting:', _error); - } - this.agentInitialized = false; - } - - // Wait a moment for cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 300)); - - progress.report({ - message: 'Connecting to CLI and starting sign-in...', - }); - - // Reinitialize connection (will trigger fresh authentication) - await this.doInitializeAgentConnection({ autoAuthenticate: true }); - console.log( - '[WebViewProvider] Force re-login completed successfully', - ); - - // Send success notification to WebView - this.sendMessageToWebView({ - type: 'loginSuccess', - data: { message: 'Successfully logged in!' }, - }); - } catch (_error) { - const errorMsg = getErrorMessage(_error); - console.error('[WebViewProvider] Force re-login failed:', _error); - console.error( - '[WebViewProvider] Error stack:', - _error instanceof Error ? _error.stack : 'N/A', - ); - - // Send error notification to WebView - this.sendMessageToWebView({ - type: 'loginError', - data: { - message: `Login failed: ${errorMsg}`, - }, - }); - - throw _error; - } - }, + console.log( + `[WebViewProvider] authInteractive: provider=${provider}, region=${region}, model=${model}`, ); + + try { + if (provider === 'coding-plan') { + writeCodingPlanConfig(region === 'global' ? 'global' : 'china', apiKey); + } else if (provider === 'alibaba-standard') { + // Alibaba Standard — multiple models sharing the same base URL + const modelBaseUrl = + baseUrl || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + const ids = (modelIds || model || 'qwen3.5-plus') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + const providers: Record<string, string> = {}; + for (const id of ids) { + providers[id] = modelBaseUrl; + } + writeModelProvidersConfig({ + apiKey, + modelProviders: providers, + activeModel: ids[0] || 'qwen3.5-plus', + }); + } else { + // Custom API Key — single model entry + const modelId = model || 'default'; + const modelBaseUrl = baseUrl || 'https://api.openai.com/v1'; + writeModelProvidersConfig({ + apiKey, + modelProviders: { [modelId]: modelBaseUrl }, + activeModel: modelId, + }); + } + + // Disconnect + reconnect + if (this.agentInitialized) { + try { + this.agentManager.disconnect(); + } catch (e) { + console.log('[WebViewProvider] Error disconnecting:', e); + } + this.agentInitialized = false; + } + + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.doInitializeAgentConnection({ autoAuthenticate: false }); + + // Only emit authSuccess when the reconnection actually authenticated. + // doInitializeAgentConnection updates this.authState via sendMessageToWebView; + // if credentials were rejected, authState will be false and we should not + // claim success (which would briefly show a success toast then re-open auth). + if (this.authState === true) { + this.sendMessageToWebView({ + type: 'authSuccess', + data: { message: 'Provider configured successfully!' }, + }); + } else { + this.sendMessageToWebView({ + type: 'authError', + data: { + message: + 'Connection established but authentication failed. Please check your credentials.', + }, + }); + } + } catch (error) { + const errorMsg = getErrorMessage(error); + console.error('[WebViewProvider] authInteractive failed:', error); + this.sendMessageToWebView({ + type: 'authError', + data: { message: `Configuration failed: ${errorMsg}` }, + }); + } } /** @@ -1324,11 +1585,11 @@ export class WebViewProvider { } break; case 'agentConnected': - case 'loginSuccess': + case 'authSuccess': this.authState = true; break; case 'agentConnectionError': - case 'loginError': + case 'authError': this.authState = false; break; default: diff --git a/packages/web-templates/package.json b/packages/web-templates/package.json index aa327b463..95ca955e2 100644 --- a/packages/web-templates/package.json +++ b/packages/web-templates/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/web-templates", - "version": "0.14.5", + "version": "0.15.0", "description": "Web templates bundled as embeddable JS/CSS strings", "repository": { "type": "git", diff --git a/packages/webui/package.json b/packages/webui/package.json index 815188058..5a83e7356 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.14.5", + "version": "0.15.0", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/webui/src/components/PermissionDrawer.tsx b/packages/webui/src/components/PermissionDrawer.tsx index d2eee02d6..02163fa5f 100644 --- a/packages/webui/src/components/PermissionDrawer.tsx +++ b/packages/webui/src/components/PermissionDrawer.tsx @@ -187,14 +187,17 @@ export const PermissionDrawer: FC<PermissionDrawerProps> = ({ return null; } for (const item of toolCall.content) { + const itemType = item['type']; + const itemContent = item['content']; + if ( - item.type === 'content' && - typeof item.content === 'object' && - item.content !== null + itemType === 'content' && + typeof itemContent === 'object' && + itemContent !== null ) { - const inner = item.content as { type?: string; text?: string }; - if (inner.type === 'text' && typeof inner.text === 'string') { - return inner.text; + const inner = itemContent as Record<string, unknown>; + if (inner['type'] === 'text' && typeof inner['text'] === 'string') { + return inner['text']; } } } diff --git a/packages/webui/src/components/layout/SessionSelector.tsx b/packages/webui/src/components/layout/SessionSelector.tsx index 7012770ff..0586a87fe 100644 --- a/packages/webui/src/components/layout/SessionSelector.tsx +++ b/packages/webui/src/components/layout/SessionSelector.tsx @@ -8,7 +8,7 @@ */ import type { FC } from 'react'; -import { Fragment } from 'react'; +import { Fragment, useState, useRef, useEffect } from 'react'; import { getTimeAgo, groupSessionsByDate, @@ -31,6 +31,10 @@ export interface SessionSelectorProps { onSearchChange: (query: string) => void; /** Callback when a session is selected */ onSelectSession: (sessionId: string) => void; + /** Callback when a session is renamed */ + onRenameSession?: (sessionId: string, newTitle: string) => void; + /** Callback when a session is deleted */ + onDeleteSession?: (sessionId: string) => void; /** Callback when selector should close */ onClose: () => void; /** Whether there are more sessions to load */ @@ -71,11 +75,38 @@ export const SessionSelector: FC<SessionSelectorProps> = ({ searchQuery, onSearchChange, onSelectSession, + onRenameSession, + onDeleteSession, onClose, hasMore = false, isLoading = false, onLoadMore, }) => { + const [renamingSessionId, setRenamingSessionId] = useState<string | null>( + null, + ); + const [renameValue, setRenameValue] = useState(''); + const [originalRenameValue, setOriginalRenameValue] = useState(''); + const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null); + const renameInputRef = useRef<HTMLInputElement>(null); + const isCancelingRenameRef = useRef(false); + + useEffect(() => { + if (renamingSessionId && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [renamingSessionId]); + + const handleRenameSubmit = (sessionId: string) => { + const trimmed = renameValue.trim(); + if (trimmed && trimmed !== originalRenameValue && onRenameSession) { + onRenameSession(sessionId, trimmed); + } + setRenamingSessionId(null); + setRenameValue(''); + setOriginalRenameValue(''); + }; if (!visible) { return null; } @@ -155,27 +186,127 @@ export const SessionSelector: FC<SessionSelectorProps> = ({ ''; const isActive = sessionId === currentSessionId; + if (renamingSessionId === sessionId) { + return ( + <div + key={sessionId} + className="session-item flex items-center py-1.5 px-2 rounded-md" + > + <input + ref={renameInputRef} + type="text" + maxLength={200} // SESSION_TITLE_MAX_LENGTH + className="flex-1 bg-[var(--vscode-input-background,var(--app-input-background))] text-[var(--vscode-input-foreground,var(--app-primary-foreground))] border-2 border-[var(--vscode-focusBorder)] rounded px-2 py-1 text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] outline-none min-w-0 shadow-[0_0_0_1px_var(--vscode-focusBorder)]" + value={renameValue} + onChange={(e) => setRenameValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleRenameSubmit(sessionId); + } else if (e.key === 'Escape') { + isCancelingRenameRef.current = true; + setRenamingSessionId(null); + setRenameValue(''); + setOriginalRenameValue(''); + } + }} + onBlur={() => { + if (isCancelingRenameRef.current) { + isCancelingRenameRef.current = false; + return; + } + handleRenameSubmit(sessionId); + }} + /> + </div> + ); + } + return ( - <button + <div key={sessionId} - type="button" - className={`session-item flex items-center justify-between py-1.5 px-2 bg-transparent border-none rounded-md cursor-pointer text-left w-full text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)] text-[var(--app-primary-foreground)] transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${ + className={`session-item group flex items-center justify-between py-1.5 px-2 rounded-md cursor-pointer transition-colors duration-100 hover:bg-[var(--app-list-hover-background)] ${ isActive ? 'active bg-[var(--app-list-active-background)] text-[var(--app-list-active-foreground)] font-[600]' - : '' + : 'text-[var(--app-primary-foreground)]' }`} onClick={() => { onSelectSession(sessionId); onClose(); }} > - <span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0"> + <span className="session-item-title flex-1 overflow-hidden text-ellipsis whitespace-nowrap min-w-0 text-[var(--vscode-chat-font-size,13px)] font-[var(--vscode-chat-font-family)]"> {title} </span> - <span className="session-item-time opacity-60 text-[0.9em] flex-shrink-0 ml-3"> - {getTimeAgo(lastUpdated)} + <span className="flex items-center gap-1 flex-shrink-0 ml-2"> + {(onRenameSession || onDeleteSession) && ( + <span + className={`items-center gap-0.5 ${confirmDeleteId === sessionId ? 'flex' : 'hidden group-hover:flex'}`} + > + {onRenameSession && ( + <button + type="button" + className="p-0.5 bg-transparent border-none cursor-pointer opacity-50 hover:opacity-100 text-[var(--app-primary-foreground)] rounded" + title="Rename" + onClick={(e) => { + e.stopPropagation(); + setRenamingSessionId(sessionId); + setRenameValue(title); + setOriginalRenameValue(title); + }} + > + <svg + width="14" + height="14" + viewBox="0 0 16 16" + fill="currentColor" + > + <path d="M13.23 1h-1.46L3.52 9.25l-.16.22L1 13.59 2.41 15l4.12-2.36.22-.16L15 4.23V2.77L13.23 1zM2.41 13.59l1.51-3 1.45 1.45-2.96 1.55zm3.83-2.06L4.47 9.76l8-8 1.77 1.77-8 8z" /> + </svg> + </button> + )} + {onDeleteSession && + !isActive && + (confirmDeleteId === sessionId ? ( + <button + type="button" + className="px-1.5 py-0.5 bg-[var(--vscode-inputValidation-errorBackground,#5a1d1d)] border border-[var(--vscode-inputValidation-errorBorder,#be1100)] cursor-pointer text-[var(--vscode-errorForeground,#f48771)] rounded text-[11px] leading-tight" + title="Click to confirm delete" + onClick={(e) => { + e.stopPropagation(); + setConfirmDeleteId(null); + onDeleteSession(sessionId); + }} + onBlur={() => setConfirmDeleteId(null)} + > + Delete? + </button> + ) : ( + <button + type="button" + className="p-0.5 bg-transparent border-none cursor-pointer opacity-50 hover:opacity-100 text-[var(--app-primary-foreground)] rounded" + title="Delete" + onClick={(e) => { + e.stopPropagation(); + setConfirmDeleteId(sessionId); + }} + > + <svg + width="14" + height="14" + viewBox="0 0 16 16" + fill="currentColor" + > + <path d="M10 3h3v1h-1v9l-1 1H5l-1-1V4H3V3h3V2a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1zM9 2H7v1h2V2zM5 4v9h6V4H5zm2 2h1v5H7V6zm3 0h-1v5h1V6z" /> + </svg> + </button> + ))} + </span> + )} + <span className="session-item-time opacity-60 text-[0.9em]"> + {getTimeAgo(lastUpdated)} + </span> </span> - </button> + </div> ); })} </div> diff --git a/packages/webui/src/components/toolcalls/GenericToolCall.tsx b/packages/webui/src/components/toolcalls/GenericToolCall.tsx index 4dc00fc9c..5e92ee865 100644 --- a/packages/webui/src/components/toolcalls/GenericToolCall.tsx +++ b/packages/webui/src/components/toolcalls/GenericToolCall.tsx @@ -6,7 +6,7 @@ * Generic tool call component - handles all tool call types as fallback */ -import type { FC } from 'react'; +import { useState, type FC } from 'react'; import { ToolCallContainer, ToolCallCard, @@ -17,6 +17,45 @@ import { } from './shared/index.js'; import type { BaseToolCallProps } from './shared/index.js'; import { getToolDisplayLabel } from './labelUtils.js'; +import { MarkdownRenderer } from '../messages/MarkdownRenderer/MarkdownRenderer.js'; + +const COLLAPSED_HEIGHT = 200; +const EXPAND_THRESHOLD = 400; + +const CollapsibleOutput: FC<{ content: string }> = ({ content }) => { + const [isExpanded, setIsExpanded] = useState(false); + const isLongContent = content.length > EXPAND_THRESHOLD; + + return ( + <div className="flex flex-col gap-[3px]"> + <div + className="text-[13px] opacity-90 overflow-hidden" + style={ + !isExpanded && isLongContent + ? { + maxHeight: `${COLLAPSED_HEIGHT}px`, + maskImage: `linear-gradient(to bottom, var(--app-primary-background) 140px, transparent ${COLLAPSED_HEIGHT}px)`, + WebkitMaskImage: `linear-gradient(to bottom, var(--app-primary-background) 140px, transparent ${COLLAPSED_HEIGHT}px)`, + } + : undefined + } + > + <MarkdownRenderer content={content} enableFileLinks={false} /> + </div> + {isLongContent && ( + <div className="flex justify-center border-t border-[var(--app-input-border)] pt-1"> + <button + type="button" + onClick={() => setIsExpanded(!isExpanded)} + className="text-[var(--app-secondary-foreground)] text-[0.8em] hover:text-[var(--app-primary-foreground)] cursor-pointer bg-transparent border-none px-2 py-1 rounded hover:bg-[var(--app-input-background)] transition-colors" + > + {isExpanded ? '▲ Collapse' : '▼ Show more'} + </button> + </div> + )} + </div> + ); +}; /** * Generic tool call component that can display any tool call type @@ -55,18 +94,13 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({ const isLong = output.length > 150; if (isLong) { - const truncatedOutput = - output.length > 300 ? output.substring(0, 300) + '...' : output; - return ( <ToolCallCard icon="🔧"> <ToolCallRow label={displayLabel}> <div>{operationText}</div> </ToolCallRow> <ToolCallRow label="Output"> - <div className="whitespace-pre-wrap font-mono text-[13px] opacity-90"> - {truncatedOutput} - </div> + <CollapsibleOutput content={output} /> </ToolCallRow> </ToolCallCard> ); diff --git a/packages/webui/src/components/toolcalls/WebFetchToolCall.tsx b/packages/webui/src/components/toolcalls/WebFetchToolCall.tsx index 7af3da775..d6ad482cf 100644 --- a/packages/webui/src/components/toolcalls/WebFetchToolCall.tsx +++ b/packages/webui/src/components/toolcalls/WebFetchToolCall.tsx @@ -16,6 +16,7 @@ import { } from './shared/index.js'; import type { BaseToolCallProps } from './shared/index.js'; import { getToolDisplayLabel } from './labelUtils.js'; +import { MarkdownRenderer } from '../messages/MarkdownRenderer/MarkdownRenderer.js'; type WebVariant = 'fetch' | 'search'; @@ -70,24 +71,28 @@ const OutputCard: FC<{ OUT </div> <div - className={`whitespace-pre-wrap break-words m-0 p-1 overflow-hidden ${ - !isExpanded && isLongContent - ? `max-h-[${COLLAPSED_HEIGHT}px] [mask-image:linear-gradient(to_bottom,var(--app-primary-background)_80px,transparent_${COLLAPSED_HEIGHT}px)]` - : '' + className={`break-words m-0 p-1 overflow-hidden ${ + isError ? 'whitespace-pre-wrap' : '' }`} style={ !isExpanded && isLongContent - ? { maxHeight: `${COLLAPSED_HEIGHT}px` } + ? { + maxHeight: `${COLLAPSED_HEIGHT}px`, + maskImage: `linear-gradient(to bottom, var(--app-primary-background) 80px, transparent ${COLLAPSED_HEIGHT}px)`, + WebkitMaskImage: `linear-gradient(to bottom, var(--app-primary-background) 80px, transparent ${COLLAPSED_HEIGHT}px)`, + } : undefined } > - <pre - className={`m-0 overflow-hidden font-mono text-[0.85em] ${ - isError ? 'text-[#c74e39]' : '' - }`} - > - {content} - </pre> + {isError ? ( + <pre className="m-0 overflow-hidden font-mono text-[0.85em] text-[#c74e39]"> + {content} + </pre> + ) : ( + <div className="text-[0.85em]"> + <MarkdownRenderer content={content} enableFileLinks={false} /> + </div> + )} </div> </div>