From 3fd2de05511ee17c92e89c187c2f2027d5c30afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 10 Apr 2026 18:02:58 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20optimize=20compact=20mode=20UX=20?= =?UTF-8?q?=E2=80=94=20shortcuts,=20settings=20sync,=20and=20safety=20impr?= =?UTF-8?q?ovements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Ctrl+O to keyboard shortcuts list (?) and /help command - Sync compact mode toggle from Settings dialog with CompactModeContext - Protect tool approval prompts from being hidden in compact mode (MainContent forces live rendering during WaitingForConfirmation) - Remove snapshot freezing on toggle — treat as persistent preference, not temporary peek (differs from Claude Code's session-scoped model) - Add compact mode tip to startup Tips rotation for non-intrusive discovery - Remove compact mode indicator from footer to reduce UI clutter - Add competitive analysis design doc (EN + ZH) comparing with Claude Code - Update user docs (settings.md) and i18n translations (en/zh/ru/pt) Relates to #3047, #2767, #2770 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../compact-mode/compact-mode-design-zh.md | 296 ++++++++++++++++++ .../compact-mode/compact-mode-design.md | 295 +++++++++++++++++ docs/users/configuration/settings.md | 2 +- packages/cli/src/i18n/locales/en.js | 4 + packages/cli/src/i18n/locales/pt.js | 4 + packages/cli/src/i18n/locales/ru.js | 4 + packages/cli/src/i18n/locales/zh.js | 4 + packages/cli/src/ui/AppContainer.tsx | 17 +- packages/cli/src/ui/components/Footer.tsx | 8 - packages/cli/src/ui/components/Help.tsx | 6 + .../src/ui/components/KeyboardShortcuts.tsx | 7 +- .../cli/src/ui/components/MainContent.tsx | 13 +- .../cli/src/ui/components/SettingsDialog.tsx | 8 + packages/cli/src/ui/components/Tips.tsx | 1 + .../src/ui/contexts/CompactModeContext.tsx | 1 + 15 files changed, 646 insertions(+), 24 deletions(-) create mode 100644 docs/design/compact-mode/compact-mode-design-zh.md create mode 100644 docs/design/compact-mode/compact-mode-design.md diff --git a/docs/design/compact-mode/compact-mode-design-zh.md b/docs/design/compact-mode/compact-mode-design-zh.md new file mode 100644 index 00000000000..ec392da9573 --- /dev/null +++ b/docs/design/compact-mode/compact-mode-design-zh.md @@ -0,0 +1,296 @@ +# 紧凑模式设计:竞品分析与优化方案 + +> Ctrl+O 紧凑/详细模式切换 —— 与 Claude Code 的竞品分析、当前实现评审及优化建议。 +> +> 用户文档:[设置 — ui.compactMode](../../users/configuration/settings.md)。 + +## 1. 概要 + +Qwen Code 和 Claude Code 都提供了 Ctrl+O 快捷键来切换紧凑和详细的工具输出视图,但**设计理念、默认状态和交互模型存在根本性差异**。本文档提供源码级深度对比,识别用户体验差距,并提出 Qwen Code 的优化方案。 + +| 维度 | Claude Code | Qwen Code | +| -------------- | ------------------------------------ | -------------------------------------- | +| 默认模式 | 紧凑(verbose=false) | 详细(compactMode=false) | +| 切换语义 | 临时查看详情 | 持久偏好切换 | +| 持久化 | 仅当前会话,重启后重置 | 持久化到 settings.json | +| 作用范围 | 全局屏幕切换(prompt ↔ transcript) | 组件级渲染切换 | +| 冻结快照 | 无此概念 | 基础设施存在,当前未使用 | +| 单工具展开提示 | 有("ctrl+o to expand") | 有("按 Ctrl+O 查看详细工具调用结果") | + +## 2. Claude Code 实现分析 + +### 2.1 架构 + +Claude Code 采用**屏幕级**切换方式,而非组件级渲染开关: + +``` +┌──────────────────────────────────┐ +│ AppState (Zustand) │ +│ verbose: boolean(默认 false) │ +│ screen: 'prompt' | 'transcript' │ +└──────────┬───────────────────────┘ + │ + ┌─────┴──────┐ + │ Ctrl+O │ 切换屏幕模式 + │ 处理器 │ 不是渲染标志 + └─────┬──────┘ + │ + ┌─────▼──────────────┐ + │ REPL.tsx │ + │ screen='prompt' → 紧凑视图(默认) + │ screen='transcript'→ 详细视图 + └────────────────────┘ +``` + +### 2.2 关键源文件 + +| 组件 | 文件 | 关键逻辑 | +| ---------- | -------------------------------------------------- | ------------------------------------------------- | +| 切换处理器 | `src/hooks/useGlobalKeybindings.tsx:90-132` | 在 `'prompt'` 和 `'transcript'` 之间切换 `screen` | +| 快捷键绑定 | `src/keybindings/defaultBindings.ts:44` | `app:toggleTranscript` | +| 状态定义 | `src/state/AppStateStore.ts:472` | `verbose: false`(仅会话级) | +| 展开提示 | `src/components/CtrlOToExpand.tsx:29-46` | 单工具 "(ctrl+o to expand)" 提示文本 | +| 消息过滤 | `src/components/Messages.tsx:93-151` | `filterForBriefTool()` 用于紧凑视图 | +| 权限弹窗 | `src/components/permissions/PermissionRequest.tsx` | 在 overlay 层渲染,永远不会被隐藏 | + +### 2.3 设计决策 + +1. **紧凑是默认模式。** 用户开箱即看到简洁界面,详情需主动查看。 +2. **会话级作用域。** `verbose` 在每次新会话时重置为 `false` —— Claude Code 假设用户通常偏好紧凑视图,只是临时需要详情。 +3. **屏幕级切换。** Ctrl+O 不改变组件的渲染方式,而是在"prompt"屏幕(紧凑)和"transcript"屏幕(详细)之间切换整个显示。 +4. **无冻结快照。** 不存在快照冻结的概念。切换时显示立即更新为当前状态。 +5. **权限对话框独立。** 工具授权在专用 overlay 层渲染,永远不受 verbose/compact 切换影响。 +6. **单工具提示。** `CtrlOToExpand` 组件在产生大量输出的工具上显示上下文提示,在子代理中被抑制。 + +### 2.4 用户流程 + +``` +会话开始 → 紧凑模式(默认) + │ + ├─ 工具输出被汇总为单行 + ├─ 大量输出的工具显示 "(ctrl+o to expand)" 提示 + │ + ├─ 用户按 Ctrl+O + │ └─→ 屏幕切换到 transcript(详细视图) + │ └─ 用户看到所有工具输出、思考过程等 + │ + ├─ 用户再按 Ctrl+O + │ └─→ 屏幕切回 prompt(紧凑模式) + │ + └─ 会话结束 → verbose 重置为 false +``` + +## 3. Qwen Code 实现分析 + +### 3.1 架构 + +Qwen Code 采用**组件级渲染标志**,每个 UI 组件从 context 中读取: + +``` +┌─────────────────────────────────────┐ +│ CompactModeContext │ +│ compactMode: boolean(默认 false) │ +│ frozenSnapshot: items[] | null │ +│ setCompactMode: (v) => void │ +└──────────┬──────────────────────────┘ + │ + ┌─────┴──────┐ + │ Ctrl+O │ 切换 compactMode + │ 处理器 │ 持久化到设置 + └─────┬──────┘ + │ + ┌─────▼──────────────────┐ + │ 每个组件读取 │ + │ compactMode 并 │ + │ 决定如何渲染 │ + └────────────────────────┘ + │ + ┌─────▼──────────────────────────────┐ + │ ToolGroupMessage │ + │ showCompact = compactMode │ + │ && !hasConfirmingTool │ + │ && !hasErrorTool │ + │ && !isEmbeddedShellFocused │ + │ && !isUserInitiated │ + └────────────────────────────────────┘ +``` + +### 3.2 关键源文件 + +| 组件 | 文件 | 关键逻辑 | +| ---------- | ------------------------------------- | ------------------------------------------------- | +| 切换处理器 | `AppContainer.tsx:1684-1694` | 切换 `compactMode`,持久化,清除快照 | +| Context | `CompactModeContext.tsx` | `compactMode`、`frozenSnapshot`、`setCompactMode` | +| 工具组 | `ToolGroupMessage.tsx:105-110` | `showCompact` 含 4 个强制展开条件 | +| 工具消息 | `ToolMessage.tsx:346-350` | 紧凑模式下隐藏 `displayRenderer` | +| 紧凑显示 | `CompactToolGroupDisplay.tsx:49-108` | 带状态和提示的单行摘要 | +| 确认消息 | `ToolConfirmationMessage.tsx:113-147` | 简化的 3 选项紧凑确认 UI | +| 启动提示 | `Tips.tsx:14-29` | 启动提示轮播中包含紧凑模式提示 | +| 设置同步 | `SettingsDialog.tsx:189-192` | 与 CompactModeContext 同步 | +| 主内容区 | `MainContent.tsx:63-72` | frozenSnapshot 含 WaitingForConfirmation 保护 | +| 思考内容 | `HistoryItemDisplay.tsx:123-133` | 紧凑模式下隐藏 `gemini_thought` | + +### 3.3 设计决策 + +1. **详细是默认模式。** 用户默认看到所有工具输出和思考过程。 +2. **持久化偏好。** `compactMode` 保存到 `settings.json`,跨会话保持。 +3. **组件级渲染。** 每个组件从 context 读取 `compactMode` 并调整自身渲染。 +4. **强制展开保护。** 四个条件覆盖紧凑模式,确保关键 UI 元素始终可见(确认、错误、Shell、用户发起的操作)。 +5. **无快照冻结。** 虽然 `frozenSnapshot` 基础设施存在,但从未激活 —— 切换始终显示实时输出。 +6. **设置对话框同步。** 从设置中切换紧凑模式时,通过 `setCompactMode` 立即更新 React 状态。 +7. **非侵入式可发现性。** 紧凑模式通过启动提示轮播引导用户发现,而非在底部栏持续显示,避免 UI 杂乱。 + +### 3.4 用户流程 + +``` +会话开始 → 详细模式(默认) + │ + ├─ 所有工具输出、思考过程、详情可见 + ├─ 启动提示可能随机显示"按 Ctrl+O 切换紧凑模式" + │ + ├─ 用户按 Ctrl+O(或在设置中切换) + │ └─→ compactMode = true,持久化 + │ ├─ 工具组显示单行摘要 + │ ├─ 思考内容被隐藏 + │ └─ 确认、错误、Shell 仍然展开 + │ + ├─ 用户再按 Ctrl+O + │ └─→ compactMode = false,持久化 + │ └─ 所有详情重新可见 + │ + └─ 下次会话 → 保持上次使用的模式 +``` + +## 4. 关键差异深度对比 + +### 4.1 默认模式理念 + +| 方面 | Claude Code(默认紧凑) | Qwen Code(默认详细) | +| -------- | -------------------------------- | ------------------------------- | +| 第一印象 | 简洁、精练 —— 专业感 | 信息丰富 —— 完全透明 | +| 学习曲线 | 用户需学会 Ctrl+O 才能看详情 | 用户立即看到一切 | +| 目标用户 | 信任工具的资深用户 | 需要了解发生了什么的用户 | +| 信息过载 | 默认避免 | 新用户可能感到信息过载 | +| 可发现性 | 单工具 "(ctrl+o to expand)" 提示 | 启动提示轮播 + ? 快捷键 + /help | + +**分析:** Claude Code 的紧凑默认之所以有效,是因为其用户群体主要是信任工具、不需要查看每次工具调用的资深开发者。Qwen Code 的详细默认适合当前阶段 —— 通过透明度建立用户信任更为重要。 + +### 4.2 持久化模型 + +| 方面 | Claude Code | Qwen Code | +| ------------ | -------------------- | -------------------------- | +| 是否持久化? | 否 —— 仅当前会话 | 是 —— 保存到 settings.json | +| 设计理由 | 详细模式只是临时查看 | 模式是用户偏好 | +| 重启行为 | 始终从紧凑模式开始 | 从上次使用的模式开始 | + +**分析:** Claude Code 将详情查看视为临时需求 —— 看完就回去。Qwen Code 将其视为稳定偏好 —— 有些用户始终需要详情,有些始终需要紧凑。两者都合理;Qwen Code 的方式更灵活。 + +### 4.3 确认保护机制 + +| 方面 | Claude Code | Qwen Code | +| ----------- | ------------------------------ | -------------------------------- | +| 机制 | Overlay/模态层(结构性分离) | `showCompact` 中的强制展开条件 | +| 覆盖范围 | 完全 —— 授权永远不会被隐藏 | 完全 —— 4 个条件覆盖所有交互状态 | +| 紧凑确认 UI | 不适用(overlay 始终完整显示) | 简化的 3 选项 RadioButtonSelect | + +**分析:** Claude Code 的架构分离(overlay 层)更加健壮。Qwen Code 的强制展开方式有效,但需要每个新的交互状态都显式添加到条件列表中。 + +### 4.4 渲染方式 + +| 方面 | Claude Code | Qwen Code | +| -------- | ------------------------------ | -------------------------- | +| 切换范围 | 屏幕级(prompt ↔ transcript) | 组件级(每个组件自行决定) | +| 粒度 | 全有或全无 | 细粒度,按组件 | +| 灵活性 | 低 —— 全局开关 | 高 —— 组件可覆盖 | +| 一致性 | 保证一致 | 取决于每个组件的实现 | + +**分析:** Qwen Code 的组件级方式更灵活(如针对特定条件的强制展开),但需要更多纪律来维护一致性。Claude Code 的屏幕级方式更简单,保证行为一致。 + +## 5. 优化建议 + +### 5.1 [P0] 保持详细模式为默认 —— 无需改动 + +Qwen Code 当前阶段以详细模式为默认是正确的选择。新用户需要透明度来建立对工具的信任。随着产品成熟,可考虑将紧凑作为默认(与 Claude Code 一致)。 + +### 5.2 [P1] 简化 frozenSnapshot 基础设施 + +当前 `frozenSnapshot` 状态和基础设施存在但从未使用(始终为 null)。两个选项: + +- **方案 A —— 完全移除。** 清理死代码。如果未来需要单工具展开,重新实现。 +- **方案 B —— 保留基础设施。** 如果计划添加 Claude Code 风格的单工具 "(ctrl+o to expand)" 临时展开功能,可直接复用。 + +**建议:** 方案 B —— 保留基础设施,但添加代码注释标记其为未来单工具展开预留。 + +### 5.3 [P1] 大输出工具的单工具展开 + +Claude Code 在产生大量输出的工具上显示 "(ctrl+o to expand)"。Qwen Code 当前只有全局切换。建议: + +- 当单个工具输出超过 N 行时,在紧凑模式下显示单工具"展开"提示。 +- 可复用现有的 `frozenSnapshot` 基础设施。 +- 范围:未来增强,非当前优先级。 + +### 5.4 [P2] 考虑会话级覆盖 + +部分用户可能希望紧凑模式作为默认,但偶尔需要在某个会话中使用详细模式。建议同时支持: + +- `settings.json` → 持久默认值(当前行为) +- 会话中按 Ctrl+O → 仅覆盖当前会话(Claude Code 行为) +- 会话重启 → 恢复 settings.json 中的值 + +这让用户两全其美。实现需要将"设置默认值"与"会话覆盖"状态分离。 + +### 5.5 [P2] 确认弹窗的结构性分离 + +当前确认保护依赖 `ToolGroupMessage` 中的 `showCompact` 条件。建议更健壮的方式: + +- 将确认弹窗渲染在独立层中(类似 Claude Code 的 overlay 方式)。 +- 这将使紧凑模式在架构上不可能影响确认弹窗。 +- 优先级较低,因为当前强制展开方式工作正常。 + +## 6. 当前实现状态 + +`feat/compact-mode-optimization` 分支变更后: + +| 功能 | 状态 | 说明 | +| -------------------------- | ------ | -------------------------------------- | +| 启动提示引导 | 已完成 | 紧凑模式提示加入 Tips 轮播(非侵入式) | +| Ctrl+O 加入快捷键列表(?) | 已完成 | 添加到 KeyboardShortcuts 组件 | +| Ctrl+O 加入 /help | 已完成 | 添加到 Help 组件 | +| 设置对话框同步 | 已完成 | compactMode 与 CompactModeContext 同步 | +| 无快照冻结 | 已完成 | 切换始终显示实时输出 | +| 确认保护 | 已完成 | 强制展开 + WaitingForConfirmation 防护 | +| Shell 保护 | 已完成 | `!isEmbeddedShellFocused` 强制展开 | +| 错误保护 | 已完成 | `!hasErrorTool` 强制展开 | +| 用户文档已更新 | 已完成 | settings.md、keyboard-shortcuts.md | + +## 7. 文件索引 + +### Qwen Code + +| 文件 | 用途 | +| --------------------------------------------------------------------- | ---------------------------------------- | +| `packages/cli/src/ui/AppContainer.tsx` | 切换处理器、状态初始化、context provider | +| `packages/cli/src/ui/contexts/CompactModeContext.tsx` | Context 定义 | +| `packages/cli/src/ui/components/messages/ToolGroupMessage.tsx` | 强制展开逻辑 | +| `packages/cli/src/ui/components/messages/ToolMessage.tsx` | 单工具输出隐藏 | +| `packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx` | 紧凑视图渲染 | +| `packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx` | 紧凑确认 UI | +| `packages/cli/src/ui/components/MainContent.tsx` | frozenSnapshot 渲染 | +| `packages/cli/src/ui/components/Tips.tsx` | 启动提示含紧凑模式引导 | +| `packages/cli/src/ui/components/Help.tsx` | /help 快捷键条目 | +| `packages/cli/src/ui/components/KeyboardShortcuts.tsx` | ? 快捷键条目 | +| `packages/cli/src/ui/components/SettingsDialog.tsx` | 设置同步 | +| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | 思考内容隐藏 | +| `packages/cli/src/config/settingsSchema.ts` | 设置定义 | +| `packages/cli/src/config/keyBindings.ts` | Ctrl+O 绑定 | + +### Claude Code(参考) + +| 文件 | 用途 | +| -------------------------------------------------- | -------------------------- | +| `src/hooks/useGlobalKeybindings.tsx` | 切换处理器 | +| `src/state/AppStateStore.ts` | 状态定义(verbose: false) | +| `src/components/CtrlOToExpand.tsx` | 单工具展开提示 | +| `src/components/Messages.tsx` | 简要消息过滤 | +| `src/screens/REPL.tsx` | 屏幕级模式切换 | +| `src/components/permissions/PermissionRequest.tsx` | 基于 overlay 的确认弹窗 | diff --git a/docs/design/compact-mode/compact-mode-design.md b/docs/design/compact-mode/compact-mode-design.md new file mode 100644 index 00000000000..7458c7eda41 --- /dev/null +++ b/docs/design/compact-mode/compact-mode-design.md @@ -0,0 +1,295 @@ +# Compact Mode Design: Competitive Analysis & Optimization + +> Ctrl+O compact/verbose mode toggle — competitive analysis with Claude Code, current implementation review, and optimization recommendations. +> +> User documentation: [Settings — ui.compactMode](../../users/configuration/settings.md). + +## 1. Executive Summary + +Qwen Code and Claude Code both provide a Ctrl+O shortcut for toggling between compact and detailed tool output views, but the **design philosophy, default state, and interaction model differ fundamentally**. This document provides a deep source-level comparison, identifies UX gaps, and proposes optimizations for Qwen Code. + +| Dimension | Claude Code | Qwen Code | +| -------------------- | ------------------------------------------- | --------------------------------------------- | +| Default mode | Compact (verbose=false) | Verbose (compactMode=false) | +| Toggle semantics | Temporary peek at details | Persistent preference switch | +| Persistence | Session-only, resets on restart | Persisted to settings.json | +| Scope | Global screen switch (prompt ↔ transcript) | Per-component rendering toggle | +| Frozen snapshot | None (no concept) | Infrastructure exists, currently unused | +| Per-tool expand hint | Yes ("ctrl+o to expand") | Yes ("Press Ctrl+O to show full tool output") | + +## 2. Claude Code Implementation Analysis + +### 2.1 Architecture + +Claude Code uses a **screen-based** approach rather than a component-level rendering toggle: + +``` +┌──────────────────────────────────┐ +│ AppState (Zustand) │ +│ verbose: boolean (default: false)│ +│ screen: 'prompt' | 'transcript' │ +└──────────┬───────────────────────┘ + │ + ┌─────┴──────┐ + │ Ctrl+O │ toggles screen mode + │ Handler │ NOT a rendering flag + └─────┬──────┘ + │ + ┌─────▼──────────────┐ + │ REPL.tsx │ + │ screen='prompt' → compact view (default) + │ screen='transcript'→ detailed view + └────────────────────┘ +``` + +### 2.2 Key Source Files + +| Component | File | Key Logic | +| ---------------- | -------------------------------------------------- | ------------------------------------------------------- | +| Toggle handler | `src/hooks/useGlobalKeybindings.tsx:90-132` | Switches `screen` between `'prompt'` and `'transcript'` | +| Keybinding | `src/keybindings/defaultBindings.ts:44` | `app:toggleTranscript` | +| State definition | `src/state/AppStateStore.ts:472` | `verbose: false` (session-only) | +| Expand hint | `src/components/CtrlOToExpand.tsx:29-46` | Per-tool "(ctrl+o to expand)" text | +| Message filter | `src/components/Messages.tsx:93-151` | `filterForBriefTool()` for compact view | +| Permission | `src/components/permissions/PermissionRequest.tsx` | Rendered in overlay layer, never hidden | + +### 2.3 Design Decisions + +1. **Compact is the default.** Users see a clean interface out of the box; detail is opt-in. +2. **Session-scoped.** `verbose` resets to `false` on every new session — Claude Code assumes users generally prefer the compact view and only need details temporarily. +3. **Screen-level toggle.** Ctrl+O doesn't change how components render; it switches the entire display between a "prompt" screen (compact) and a "transcript" screen (detailed). +4. **No frozen snapshot.** There is no snapshot freezing concept. When toggling, the display updates immediately with current state. +5. **Permission dialogs are separate.** Tool approvals are rendered in a dedicated overlay layer that is never affected by the verbose/compact toggle. +6. **Per-tool hint.** `CtrlOToExpand` component shows a contextual hint on individual tools when they produce large output, suppressed in sub-agents. + +### 2.4 User Flow + +``` +Session start → compact mode (default) + │ + ├─ Tool outputs are summarized in a single line + ├─ Large tool output shows "(ctrl+o to expand)" hint + │ + ├─ User presses Ctrl+O + │ └─→ Screen switches to transcript (detailed view) + │ └─ User sees all tool output, thinking, etc. + │ + ├─ User presses Ctrl+O again + │ └─→ Screen switches back to prompt (compact) + │ + └─ Session ends → verbose resets to false +``` + +## 3. Qwen Code Implementation Analysis + +### 3.1 Architecture + +Qwen Code uses a **component-level rendering flag** that each UI component reads from context: + +``` +┌─────────────────────────────────────┐ +│ CompactModeContext │ +│ compactMode: boolean (default: false)│ +│ frozenSnapshot: items[] | null │ +│ setCompactMode: (v) => void │ +└──────────┬──────────────────────────┘ + │ + ┌─────┴──────┐ + │ Ctrl+O │ toggles compactMode + │ Handler │ persists to settings + └─────┬──────┘ + │ + ┌─────▼──────────────────┐ + │ Each component reads │ + │ compactMode and │ + │ decides how to render │ + └────────────────────────┘ + │ + ┌─────▼──────────────────────────────┐ + │ ToolGroupMessage │ + │ showCompact = compactMode │ + │ && !hasConfirmingTool │ + │ && !hasErrorTool │ + │ && !isEmbeddedShellFocused │ + │ && !isUserInitiated │ + └────────────────────────────────────┘ +``` + +### 3.2 Key Source Files + +| Component | File | Key Logic | +| --------------- | ------------------------------------- | ------------------------------------------------- | +| Toggle handler | `AppContainer.tsx:1684-1694` | Toggles `compactMode`, persists, clears snapshot | +| Context | `CompactModeContext.tsx` | `compactMode`, `frozenSnapshot`, `setCompactMode` | +| Tool group | `ToolGroupMessage.tsx:105-110` | `showCompact` with 4 force-expand conditions | +| Tool message | `ToolMessage.tsx:346-350` | Hides `displayRenderer` in compact mode | +| Compact display | `CompactToolGroupDisplay.tsx:49-108` | Single-line summary with status + hint | +| Confirmation | `ToolConfirmationMessage.tsx:113-147` | Simplified 3-option compact approval | +| Tips | `Tips.tsx:14-29` | Startup tip rotation includes compact mode hint | +| Settings sync | `SettingsDialog.tsx:189-192` | Syncs with CompactModeContext | +| MainContent | `MainContent.tsx:63-72` | frozenSnapshot with WaitingForConfirmation guard | +| Thinking | `HistoryItemDisplay.tsx:123-133` | Hides `gemini_thought` in compact mode | + +### 3.3 Design Decisions + +1. **Verbose is the default.** Users see all tool output and thinking by default. +2. **Persistent preference.** `compactMode` is saved to `settings.json` and survives across sessions. +3. **Component-level rendering.** Each component reads `compactMode` from context and adjusts its own rendering. +4. **Force-expand protection.** Four conditions override compact mode to ensure critical UI elements are always visible (confirmations, errors, shell, user-initiated). +5. **No snapshot freezing.** Although the `frozenSnapshot` infrastructure exists, it is never activated — toggle always shows live output. +6. **Settings dialog sync.** Toggling compact mode from Settings updates React state immediately via `setCompactMode`. +7. **Non-intrusive discoverability.** Compact mode is introduced via the startup Tips rotation rather than a persistent footer indicator, avoiding UI clutter. + +### 3.4 User Flow + +``` +Session start → verbose mode (default) + │ + ├─ All tool outputs, thinking, details visible + │ + ├─ User presses Ctrl+O (or toggles in Settings) + │ └─→ compactMode = true, persisted + │ ├─ Tool groups show single-line summary + │ ├─ Thinking/thought content hidden + │ └─ Confirmations, errors, shell still expanded + │ + ├─ User presses Ctrl+O again + │ └─→ compactMode = false, persisted + │ └─ All details visible again + │ + └─ Next session → same mode as last session +``` + +## 4. Key Differences Deep Dive + +### 4.1 Default Mode Philosophy + +| Aspect | Claude Code (compact default) | Qwen Code (verbose default) | +| -------------------- | ------------------------------------- | --------------------------------------------- | +| First impression | Clean, minimal — professional feel | Information-rich — full transparency | +| Learning curve | User must learn Ctrl+O to see details | User can immediately see everything | +| Target audience | Experienced users who trust the tool | Users who want to understand what's happening | +| Information overload | Avoided by default | Possible for new users | +| Discoverability | Per-tool "(ctrl+o to expand)" hints | Startup Tips rotation + ? shortcuts + /help | + +**Analysis:** Claude Code's compact default works because its user base is generally experienced developers who trust the tool and don't need to see every tool invocation. Qwen Code's verbose default is appropriate for its earlier stage where building user trust through transparency is important. + +### 4.2 Persistence Model + +| Aspect | Claude Code | Qwen Code | +| ---------------- | ------------------------- | -------------------------- | +| Persisted? | No — session-only | Yes — to settings.json | +| Rationale | Verbose is temporary peek | Mode is user preference | +| Restart behavior | Always starts compact | Starts with last-used mode | + +**Analysis:** Claude Code treats detail viewing as a momentary need — you look, then go back. Qwen Code treats it as a stable preference — some users always want details, others always want compact. Both are valid; Qwen Code's approach is more flexible. + +### 4.3 Confirmation Protection + +| Aspect | Claude Code | Qwen Code | +| ----------------------- | ------------------------------------------- | ---------------------------------------------------- | +| Mechanism | Overlay/modal layer (structurally separate) | Force-expand conditions in `showCompact` | +| Coverage | Complete — approvals can never be hidden | Complete — 4 conditions cover all interactive states | +| Compact confirmation UI | N/A (overlay is always full) | Simplified 3-option RadioButtonSelect | + +**Analysis:** Claude Code's architectural separation (overlay layer) is more robust. Qwen Code's force-expand approach is effective but requires each new interactive state to be explicitly added to the condition list. + +### 4.4 Rendering Approach + +| Aspect | Claude Code | Qwen Code | +| ------------ | ----------------------------------- | ------------------------------------------ | +| Toggle scope | Screen-level (prompt ↔ transcript) | Component-level (each component decides) | +| Granularity | All-or-nothing | Fine-grained per component | +| Flexibility | Low — global switch | High — components can override | +| Consistency | Guaranteed | Depends on each component's implementation | + +**Analysis:** Qwen Code's component-level approach is more flexible (e.g., force-expand for specific conditions) but requires more discipline to maintain consistency. Claude Code's screen-level approach is simpler and guarantees consistent behavior. + +## 5. Optimization Recommendations + +### 5.1 [P0] Keep Verbose as Default — No Change Needed + +Qwen Code's verbose default is the right choice for its current stage. Users who are new to the tool need transparency to build trust. As the product matures, consider making compact the default (like Claude Code). + +### 5.2 [P1] Simplify frozenSnapshot Infrastructure + +Currently `frozenSnapshot` state and infrastructure exist but are never used (always null). Two options: + +- **Option A — Remove entirely.** Clean up dead code. If per-tool expansion is needed later, re-implement. +- **Option B — Keep as infrastructure.** Useful if we plan to add Claude Code-style per-tool "(ctrl+o to expand)" temporary expansion for individual large outputs. + +**Recommendation:** Option B — keep the infrastructure but add a code comment marking it as reserved for future per-tool expansion. + +### 5.3 [P1] Per-Tool Expansion for Large Outputs + +Claude Code shows "(ctrl+o to expand)" on individual tools that produce large output. Qwen Code currently only has a global toggle. Consider: + +- When a single tool produces output exceeding N lines, show a per-tool "expand" hint in compact mode. +- This would use the existing `frozenSnapshot` infrastructure. +- Scope: future enhancement, not current priority. + +### 5.4 [P2] Consider Session-Scoped Override + +Some users may want compact mode as their default but occasionally need verbose for a specific session. Consider supporting both: + +- `settings.json` → persistent default (current behavior) +- Ctrl+O during session → temporary override for current session only (Claude Code behavior) +- On session restart → revert to settings.json value + +This gives users the best of both worlds. Implementation would require separating "settings default" from "session override" state. + +### 5.5 [P2] Structural Separation for Confirmations + +Currently, confirmation protection relies on `showCompact` conditions in `ToolGroupMessage`. Consider a more robust approach: + +- Render confirmations in a separate layer (like Claude Code's overlay approach). +- This would make it architecturally impossible for compact mode to affect confirmations. +- Lower priority since the current force-expand approach works correctly. + +## 6. Current Implementation Status + +After the `feat/compact-mode-optimization` branch changes: + +| Feature | Status | Notes | +| -------------------------------- | ------ | ------------------------------------------------- | +| Startup Tips hint | Done | Compact mode tip in Tips rotation (non-intrusive) | +| Ctrl+O in keyboard shortcuts (?) | Done | Added to KeyboardShortcuts component | +| Ctrl+O in /help | Done | Added to Help component | +| Settings dialog sync | Done | Syncs compactMode with CompactModeContext | +| No snapshot freezing | Done | Toggle always shows live output | +| Confirmation protection | Done | Force-expand + WaitingForConfirmation guard | +| Shell protection | Done | `!isEmbeddedShellFocused` force-expand | +| Error protection | Done | `!hasErrorTool` force-expand | +| User docs updated | Done | settings.md, keyboard-shortcuts.md | + +## 7. File Reference + +### Qwen Code + +| File | Purpose | +| --------------------------------------------------------------------- | ------------------------------------------------------ | +| `packages/cli/src/ui/AppContainer.tsx` | Toggle handler, state initialization, context provider | +| `packages/cli/src/ui/contexts/CompactModeContext.tsx` | Context definition | +| `packages/cli/src/ui/components/messages/ToolGroupMessage.tsx` | Force-expand logic | +| `packages/cli/src/ui/components/messages/ToolMessage.tsx` | Per-tool output hiding | +| `packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx` | Compact view rendering | +| `packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx` | Compact confirmation UI | +| `packages/cli/src/ui/components/MainContent.tsx` | frozenSnapshot rendering | +| `packages/cli/src/ui/components/Tips.tsx` | Startup tip with compact mode hint | +| `packages/cli/src/ui/components/Help.tsx` | /help shortcut entry | +| `packages/cli/src/ui/components/KeyboardShortcuts.tsx` | ? shortcut entry | +| `packages/cli/src/ui/components/SettingsDialog.tsx` | Settings sync | +| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | Thinking content hiding | +| `packages/cli/src/config/settingsSchema.ts` | Setting definition | +| `packages/cli/src/config/keyBindings.ts` | Ctrl+O binding | + +### Claude Code (Reference) + +| File | Purpose | +| -------------------------------------------------- | --------------------------------- | +| `src/hooks/useGlobalKeybindings.tsx` | Toggle handler | +| `src/state/AppStateStore.ts` | State definition (verbose: false) | +| `src/components/CtrlOToExpand.tsx` | Per-tool expand hint | +| `src/components/Messages.tsx` | Brief message filter | +| `src/screens/REPL.tsx` | Screen-level mode switching | +| `src/components/permissions/PermissionRequest.tsx` | Overlay-based confirmation | diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 4b7a96fd8f1..4106e3d42b0 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -106,7 +106,7 @@ Settings are organized into categories. All settings should be placed within the | `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` | | `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. When enabled, a `compact` indicator appears in the footer. The setting persists across sessions. | `false` | +| `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` | | `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. 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` | diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 7ef40def257..1ba4e920f48 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -59,6 +59,7 @@ export default { 'to search history': 'to search history', 'to paste images': 'to paste images', 'for external editor': 'for external editor', + 'to toggle compact mode': 'to toggle compact mode', 'Jump through words in the input': 'Jump through words in the input', 'Close dialogs, cancel requests, or quit application': 'Close dialogs, cancel requests, or quit application', @@ -1556,6 +1557,8 @@ export default { 'You can switch permission mode quickly with Tab or /approval-mode.', 'Try /insight to generate personalized insights from your chat history.': 'Try /insight to generate personalized insights from your chat history.', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.', // ============================================================================ // Exit Screen / Stats @@ -2013,6 +2016,7 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n', compact: 'compact', + 'compact mode: on (Ctrl+O off)': 'compact mode: on (Ctrl+O off)', 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).', 'Press Ctrl+O to show full tool output': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c1a622a9e71..24c6c951151 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -51,6 +51,7 @@ export default { 'to search history': 'para pesquisar no histórico', 'to paste images': 'para colar imagens', 'for external editor': 'para editor externo', + 'to toggle compact mode': 'alternar modo compacto', 'Jump through words in the input': 'Pular palavras na entrada', 'Close dialogs, cancel requests, or quit application': 'Fechar diálogos, cancelar solicitações ou sair do aplicativo', @@ -1512,6 +1513,8 @@ export default { 'Você pode alternar o modo de permissão rapidamente com Shift+Tab ou /approval-mode.', 'Try /insight to generate personalized insights from your chat history.': 'Experimente /insight para gerar insights personalizados do seu histórico de conversas.', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + 'Pressione Ctrl+O para alternar o modo compacto — ocultar saída de ferramentas e raciocínio.', // ============================================================================ // Exit Screen / Stats @@ -1962,6 +1965,7 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n', compact: 'compacto', + 'compact mode: on (Ctrl+O off)': 'modo compacto: ligado (Ctrl+O desligar)', 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': 'Ocultar saída da ferramenta e raciocínio para uma visualização mais limpa (alternar com Ctrl+O).', 'Press Ctrl+O to show full tool output': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index ae83eb6bfbf..c2963c42a68 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -88,6 +88,7 @@ export default { 'to search history': 'поиск в истории', 'to paste images': 'вставить изображения', 'for external editor': 'внешний редактор', + 'to toggle compact mode': 'переключить компактный режим', // ============================================================================ // Поля системной информации @@ -1706,6 +1707,8 @@ export default { 'Вы можете быстро переключать режим разрешений с помощью Tab или /approval-mode.', 'Try /insight to generate personalized insights from your chat history.': 'Попробуйте /insight, чтобы получить персонализированные выводы из истории чатов.', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + 'Нажмите Ctrl+O для переключения компактного режима — скрыть вывод инструментов и рассуждения.', // ============================================================================ // Custom API Key Configuration @@ -1969,6 +1972,7 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', compact: 'компактный', + 'compact mode: on (Ctrl+O off)': 'компактный режим: вкл (Ctrl+O выкл)', 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': 'Скрывать вывод инструментов и процесс рассуждений для более чистого вида (переключить с помощью Ctrl+O).', 'Press Ctrl+O to show full tool output': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 50014ec5d4a..10c6242e1b1 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -57,6 +57,7 @@ export default { 'to search history': '搜索历史', 'to paste images': '粘贴图片', 'for external editor': '外部编辑器', + 'to toggle compact mode': '切换紧凑模式', 'Jump through words in the input': '在输入中按单词跳转', 'Close dialogs, cancel requests, or quit application': '关闭对话框、取消请求或退出应用程序', @@ -1473,6 +1474,8 @@ export default { '按 Tab 或输入 /approval-mode 可快速切换权限模式。', 'Try /insight to generate personalized insights from your chat history.': '试试 /insight,从聊天记录中生成个性化洞察。', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + '按 Ctrl+O 切换紧凑模式 ── 隐藏工具输出和思考过程,界面更简洁。', // ============================================================================ // Exit Screen / Stats @@ -1819,6 +1822,7 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', compact: '紧凑', + 'compact mode: on (Ctrl+O off)': '紧凑模式:开(Ctrl+O 关闭)', 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': '紧凑模式下隐藏工具输出和思考过程,界面更简洁(Ctrl+O 切换)。', 'Press Ctrl+O to show full tool output': '按 Ctrl+O 查看详细工具调用结果', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c564cc5ba93..f970847ba9f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1686,13 +1686,11 @@ export const AppContainer = (props: AppContainerProps) => { setCompactMode(newValue); void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); - // Only freeze during the actual responding phase. WaitingForConfirmation - // must keep focus so the user can approve/cancel tool confirmation UI. - if (streamingState === StreamingState.Responding) { - setFrozenSnapshot([...pendingHistoryItems]); - } else { - setFrozenSnapshot(null); - } + // Unlike Claude Code (where compact is default and Ctrl+O temporarily + // expands a single tool), qwen-code treats the toggle as a persistent + // preference change. No snapshot freezing in either direction — the + // user always sees live output after switching. + setFrozenSnapshot(null); } }, [ @@ -1729,7 +1727,6 @@ export const AppContainer = (props: AppContainerProps) => { compactMode, setCompactMode, setFrozenSnapshot, - pendingHistoryItems, refreshStatic, ], ); @@ -2155,8 +2152,8 @@ export const AppContainer = (props: AppContainerProps) => { ); const compactModeValue = useMemo( - () => ({ compactMode, frozenSnapshot }), - [compactMode, frozenSnapshot], + () => ({ compactMode, frozenSnapshot, setCompactMode }), + [compactMode, frozenSnapshot, setCompactMode], ); return ( diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 54956faf6d7..652c485daa3 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -17,7 +17,6 @@ import { useStatusLine } from '../hooks/useStatusLine.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { useVimMode } from '../contexts/VimModeContext.js'; -import { useCompactMode } from '../contexts/CompactModeContext.js'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -26,7 +25,6 @@ export const Footer: React.FC = () => { const config = useConfig(); const { vimEnabled, vimMode } = useVimMode(); const { text: statusLineText } = useStatusLine(); - const { compactMode } = useCompactMode(); const { promptTokenCount, showAutoAcceptIndicator } = { promptTokenCount: uiState.sessionStats.lastPromptTokenCount, @@ -101,12 +99,6 @@ export const Footer: React.FC = () => { ), }); } - if (compactMode) { - rightItems.push({ - key: 'compact', - node: {t('compact')}, - }); - } // Layout matches upstream: left column has status line (top) + hints/mode // (bottom), right section has indicators. Status line and hints coexist. diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx index 64c2f76885f..48866b598ab 100644 --- a/packages/cli/src/ui/components/Help.tsx +++ b/packages/cli/src/ui/components/Help.tsx @@ -134,6 +134,12 @@ export const Help: React.FC = ({ commands, width }) => ( {' '} - {t('Clear the screen')} + + + Ctrl+O + {' '} + - {t('to toggle compact mode')} + {process.platform === 'darwin' ? 'Ctrl+X / Meta+Enter' : 'Ctrl+X'} diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index df84d0c2720..06a8089ddc9 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -42,6 +42,7 @@ const getShortcuts = (): Shortcut[] => [ { key: 'ctrl+y', description: t('to retry last request') }, { key: getPasteKey(), description: t('to paste images') }, { key: getExternalEditorKey(), description: t('for external editor') }, + { key: 'ctrl+o', description: t('to toggle compact mode') }, ]; const ShortcutItem: React.FC<{ shortcut: Shortcut }> = ({ shortcut }) => ( @@ -57,9 +58,9 @@ const MARGIN_RIGHT = 2; // Column distribution for different layouts (4+4+4 for 3 cols, 6+6 for 2 cols) const COLUMN_SPLITS: Record = { - 3: [4, 4, 4], - 2: [6, 6], - 1: [12], + 3: [5, 4, 4], + 2: [7, 6], + 1: [13], }; export const KeyboardShortcuts: React.FC = () => { diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 422532ff776..b29fb61ba6c 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -14,6 +14,7 @@ import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; +import { StreamingState } from '../types.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -59,8 +60,16 @@ export const MainContent = () => { - {(frozenSnapshot ?? pendingHistoryItems).map((item, i) => { - const isFrozen = frozenSnapshot !== null; + {/* Never use frozenSnapshot when waiting for user confirmation — + tool approvals must always remain visible and interactive. */} + {(frozenSnapshot && + uiState.streamingState !== StreamingState.WaitingForConfirmation + ? frozenSnapshot + : pendingHistoryItems + ).map((item, i) => { + const isFrozen = + frozenSnapshot !== null && + uiState.streamingState !== StreamingState.WaitingForConfirmation; return ( ('settings'); @@ -183,6 +186,11 @@ export function SettingsDialog({ }); } + // Special handling for compact mode to sync with CompactModeContext + if (key === 'ui.compactMode' && newValue !== compactMode) { + setCompactMode?.(newValue as boolean); + } + // Special handling for approval mode to apply to current session if ( key === 'tools.approvalMode' && diff --git a/packages/cli/src/ui/components/Tips.tsx b/packages/cli/src/ui/components/Tips.tsx index f85184a194e..eb7daf55f01 100644 --- a/packages/cli/src/ui/components/Tips.tsx +++ b/packages/cli/src/ui/components/Tips.tsx @@ -26,6 +26,7 @@ const startupTips: Tip[] = [ text: 'Try /insight to generate personalized insights from your chat history.', weight: 3, }, + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.', ]; function tipText(tip: Tip): string { diff --git a/packages/cli/src/ui/contexts/CompactModeContext.tsx b/packages/cli/src/ui/contexts/CompactModeContext.tsx index 54cb1bbb64d..0081c0cd518 100644 --- a/packages/cli/src/ui/contexts/CompactModeContext.tsx +++ b/packages/cli/src/ui/contexts/CompactModeContext.tsx @@ -10,6 +10,7 @@ import type { HistoryItemWithoutId } from '../types.js'; interface CompactModeContextType { compactMode: boolean; frozenSnapshot: HistoryItemWithoutId[] | null; + setCompactMode?: (value: boolean) => void; } const CompactModeContext = createContext({ From a7acd1e523df2d37c87d13866d10966fd18a064a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 10 Apr 2026 18:49:42 +0800 Subject: [PATCH 2/6] refactor: remove frozenSnapshot dead code and Chinese design doc - Remove frozenSnapshot state, useEffect, and all related logic from AppContainer, MainContent, CompactModeContext, and test files - Simplify MainContent to always render live pendingHistoryItems - Delete compact-mode-design-zh.md (redundant Chinese translation) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../compact-mode/compact-mode-design-zh.md | 296 ------------------ packages/cli/src/ui/AppContainer.tsx | 26 +- .../cli/src/ui/components/MainContent.tsx | 46 +-- .../components/messages/ToolMessage.test.tsx | 2 +- .../src/ui/contexts/CompactModeContext.tsx | 3 - 5 files changed, 18 insertions(+), 355 deletions(-) delete mode 100644 docs/design/compact-mode/compact-mode-design-zh.md diff --git a/docs/design/compact-mode/compact-mode-design-zh.md b/docs/design/compact-mode/compact-mode-design-zh.md deleted file mode 100644 index ec392da9573..00000000000 --- a/docs/design/compact-mode/compact-mode-design-zh.md +++ /dev/null @@ -1,296 +0,0 @@ -# 紧凑模式设计:竞品分析与优化方案 - -> Ctrl+O 紧凑/详细模式切换 —— 与 Claude Code 的竞品分析、当前实现评审及优化建议。 -> -> 用户文档:[设置 — ui.compactMode](../../users/configuration/settings.md)。 - -## 1. 概要 - -Qwen Code 和 Claude Code 都提供了 Ctrl+O 快捷键来切换紧凑和详细的工具输出视图,但**设计理念、默认状态和交互模型存在根本性差异**。本文档提供源码级深度对比,识别用户体验差距,并提出 Qwen Code 的优化方案。 - -| 维度 | Claude Code | Qwen Code | -| -------------- | ------------------------------------ | -------------------------------------- | -| 默认模式 | 紧凑(verbose=false) | 详细(compactMode=false) | -| 切换语义 | 临时查看详情 | 持久偏好切换 | -| 持久化 | 仅当前会话,重启后重置 | 持久化到 settings.json | -| 作用范围 | 全局屏幕切换(prompt ↔ transcript) | 组件级渲染切换 | -| 冻结快照 | 无此概念 | 基础设施存在,当前未使用 | -| 单工具展开提示 | 有("ctrl+o to expand") | 有("按 Ctrl+O 查看详细工具调用结果") | - -## 2. Claude Code 实现分析 - -### 2.1 架构 - -Claude Code 采用**屏幕级**切换方式,而非组件级渲染开关: - -``` -┌──────────────────────────────────┐ -│ AppState (Zustand) │ -│ verbose: boolean(默认 false) │ -│ screen: 'prompt' | 'transcript' │ -└──────────┬───────────────────────┘ - │ - ┌─────┴──────┐ - │ Ctrl+O │ 切换屏幕模式 - │ 处理器 │ 不是渲染标志 - └─────┬──────┘ - │ - ┌─────▼──────────────┐ - │ REPL.tsx │ - │ screen='prompt' → 紧凑视图(默认) - │ screen='transcript'→ 详细视图 - └────────────────────┘ -``` - -### 2.2 关键源文件 - -| 组件 | 文件 | 关键逻辑 | -| ---------- | -------------------------------------------------- | ------------------------------------------------- | -| 切换处理器 | `src/hooks/useGlobalKeybindings.tsx:90-132` | 在 `'prompt'` 和 `'transcript'` 之间切换 `screen` | -| 快捷键绑定 | `src/keybindings/defaultBindings.ts:44` | `app:toggleTranscript` | -| 状态定义 | `src/state/AppStateStore.ts:472` | `verbose: false`(仅会话级) | -| 展开提示 | `src/components/CtrlOToExpand.tsx:29-46` | 单工具 "(ctrl+o to expand)" 提示文本 | -| 消息过滤 | `src/components/Messages.tsx:93-151` | `filterForBriefTool()` 用于紧凑视图 | -| 权限弹窗 | `src/components/permissions/PermissionRequest.tsx` | 在 overlay 层渲染,永远不会被隐藏 | - -### 2.3 设计决策 - -1. **紧凑是默认模式。** 用户开箱即看到简洁界面,详情需主动查看。 -2. **会话级作用域。** `verbose` 在每次新会话时重置为 `false` —— Claude Code 假设用户通常偏好紧凑视图,只是临时需要详情。 -3. **屏幕级切换。** Ctrl+O 不改变组件的渲染方式,而是在"prompt"屏幕(紧凑)和"transcript"屏幕(详细)之间切换整个显示。 -4. **无冻结快照。** 不存在快照冻结的概念。切换时显示立即更新为当前状态。 -5. **权限对话框独立。** 工具授权在专用 overlay 层渲染,永远不受 verbose/compact 切换影响。 -6. **单工具提示。** `CtrlOToExpand` 组件在产生大量输出的工具上显示上下文提示,在子代理中被抑制。 - -### 2.4 用户流程 - -``` -会话开始 → 紧凑模式(默认) - │ - ├─ 工具输出被汇总为单行 - ├─ 大量输出的工具显示 "(ctrl+o to expand)" 提示 - │ - ├─ 用户按 Ctrl+O - │ └─→ 屏幕切换到 transcript(详细视图) - │ └─ 用户看到所有工具输出、思考过程等 - │ - ├─ 用户再按 Ctrl+O - │ └─→ 屏幕切回 prompt(紧凑模式) - │ - └─ 会话结束 → verbose 重置为 false -``` - -## 3. Qwen Code 实现分析 - -### 3.1 架构 - -Qwen Code 采用**组件级渲染标志**,每个 UI 组件从 context 中读取: - -``` -┌─────────────────────────────────────┐ -│ CompactModeContext │ -│ compactMode: boolean(默认 false) │ -│ frozenSnapshot: items[] | null │ -│ setCompactMode: (v) => void │ -└──────────┬──────────────────────────┘ - │ - ┌─────┴──────┐ - │ Ctrl+O │ 切换 compactMode - │ 处理器 │ 持久化到设置 - └─────┬──────┘ - │ - ┌─────▼──────────────────┐ - │ 每个组件读取 │ - │ compactMode 并 │ - │ 决定如何渲染 │ - └────────────────────────┘ - │ - ┌─────▼──────────────────────────────┐ - │ ToolGroupMessage │ - │ showCompact = compactMode │ - │ && !hasConfirmingTool │ - │ && !hasErrorTool │ - │ && !isEmbeddedShellFocused │ - │ && !isUserInitiated │ - └────────────────────────────────────┘ -``` - -### 3.2 关键源文件 - -| 组件 | 文件 | 关键逻辑 | -| ---------- | ------------------------------------- | ------------------------------------------------- | -| 切换处理器 | `AppContainer.tsx:1684-1694` | 切换 `compactMode`,持久化,清除快照 | -| Context | `CompactModeContext.tsx` | `compactMode`、`frozenSnapshot`、`setCompactMode` | -| 工具组 | `ToolGroupMessage.tsx:105-110` | `showCompact` 含 4 个强制展开条件 | -| 工具消息 | `ToolMessage.tsx:346-350` | 紧凑模式下隐藏 `displayRenderer` | -| 紧凑显示 | `CompactToolGroupDisplay.tsx:49-108` | 带状态和提示的单行摘要 | -| 确认消息 | `ToolConfirmationMessage.tsx:113-147` | 简化的 3 选项紧凑确认 UI | -| 启动提示 | `Tips.tsx:14-29` | 启动提示轮播中包含紧凑模式提示 | -| 设置同步 | `SettingsDialog.tsx:189-192` | 与 CompactModeContext 同步 | -| 主内容区 | `MainContent.tsx:63-72` | frozenSnapshot 含 WaitingForConfirmation 保护 | -| 思考内容 | `HistoryItemDisplay.tsx:123-133` | 紧凑模式下隐藏 `gemini_thought` | - -### 3.3 设计决策 - -1. **详细是默认模式。** 用户默认看到所有工具输出和思考过程。 -2. **持久化偏好。** `compactMode` 保存到 `settings.json`,跨会话保持。 -3. **组件级渲染。** 每个组件从 context 读取 `compactMode` 并调整自身渲染。 -4. **强制展开保护。** 四个条件覆盖紧凑模式,确保关键 UI 元素始终可见(确认、错误、Shell、用户发起的操作)。 -5. **无快照冻结。** 虽然 `frozenSnapshot` 基础设施存在,但从未激活 —— 切换始终显示实时输出。 -6. **设置对话框同步。** 从设置中切换紧凑模式时,通过 `setCompactMode` 立即更新 React 状态。 -7. **非侵入式可发现性。** 紧凑模式通过启动提示轮播引导用户发现,而非在底部栏持续显示,避免 UI 杂乱。 - -### 3.4 用户流程 - -``` -会话开始 → 详细模式(默认) - │ - ├─ 所有工具输出、思考过程、详情可见 - ├─ 启动提示可能随机显示"按 Ctrl+O 切换紧凑模式" - │ - ├─ 用户按 Ctrl+O(或在设置中切换) - │ └─→ compactMode = true,持久化 - │ ├─ 工具组显示单行摘要 - │ ├─ 思考内容被隐藏 - │ └─ 确认、错误、Shell 仍然展开 - │ - ├─ 用户再按 Ctrl+O - │ └─→ compactMode = false,持久化 - │ └─ 所有详情重新可见 - │ - └─ 下次会话 → 保持上次使用的模式 -``` - -## 4. 关键差异深度对比 - -### 4.1 默认模式理念 - -| 方面 | Claude Code(默认紧凑) | Qwen Code(默认详细) | -| -------- | -------------------------------- | ------------------------------- | -| 第一印象 | 简洁、精练 —— 专业感 | 信息丰富 —— 完全透明 | -| 学习曲线 | 用户需学会 Ctrl+O 才能看详情 | 用户立即看到一切 | -| 目标用户 | 信任工具的资深用户 | 需要了解发生了什么的用户 | -| 信息过载 | 默认避免 | 新用户可能感到信息过载 | -| 可发现性 | 单工具 "(ctrl+o to expand)" 提示 | 启动提示轮播 + ? 快捷键 + /help | - -**分析:** Claude Code 的紧凑默认之所以有效,是因为其用户群体主要是信任工具、不需要查看每次工具调用的资深开发者。Qwen Code 的详细默认适合当前阶段 —— 通过透明度建立用户信任更为重要。 - -### 4.2 持久化模型 - -| 方面 | Claude Code | Qwen Code | -| ------------ | -------------------- | -------------------------- | -| 是否持久化? | 否 —— 仅当前会话 | 是 —— 保存到 settings.json | -| 设计理由 | 详细模式只是临时查看 | 模式是用户偏好 | -| 重启行为 | 始终从紧凑模式开始 | 从上次使用的模式开始 | - -**分析:** Claude Code 将详情查看视为临时需求 —— 看完就回去。Qwen Code 将其视为稳定偏好 —— 有些用户始终需要详情,有些始终需要紧凑。两者都合理;Qwen Code 的方式更灵活。 - -### 4.3 确认保护机制 - -| 方面 | Claude Code | Qwen Code | -| ----------- | ------------------------------ | -------------------------------- | -| 机制 | Overlay/模态层(结构性分离) | `showCompact` 中的强制展开条件 | -| 覆盖范围 | 完全 —— 授权永远不会被隐藏 | 完全 —— 4 个条件覆盖所有交互状态 | -| 紧凑确认 UI | 不适用(overlay 始终完整显示) | 简化的 3 选项 RadioButtonSelect | - -**分析:** Claude Code 的架构分离(overlay 层)更加健壮。Qwen Code 的强制展开方式有效,但需要每个新的交互状态都显式添加到条件列表中。 - -### 4.4 渲染方式 - -| 方面 | Claude Code | Qwen Code | -| -------- | ------------------------------ | -------------------------- | -| 切换范围 | 屏幕级(prompt ↔ transcript) | 组件级(每个组件自行决定) | -| 粒度 | 全有或全无 | 细粒度,按组件 | -| 灵活性 | 低 —— 全局开关 | 高 —— 组件可覆盖 | -| 一致性 | 保证一致 | 取决于每个组件的实现 | - -**分析:** Qwen Code 的组件级方式更灵活(如针对特定条件的强制展开),但需要更多纪律来维护一致性。Claude Code 的屏幕级方式更简单,保证行为一致。 - -## 5. 优化建议 - -### 5.1 [P0] 保持详细模式为默认 —— 无需改动 - -Qwen Code 当前阶段以详细模式为默认是正确的选择。新用户需要透明度来建立对工具的信任。随着产品成熟,可考虑将紧凑作为默认(与 Claude Code 一致)。 - -### 5.2 [P1] 简化 frozenSnapshot 基础设施 - -当前 `frozenSnapshot` 状态和基础设施存在但从未使用(始终为 null)。两个选项: - -- **方案 A —— 完全移除。** 清理死代码。如果未来需要单工具展开,重新实现。 -- **方案 B —— 保留基础设施。** 如果计划添加 Claude Code 风格的单工具 "(ctrl+o to expand)" 临时展开功能,可直接复用。 - -**建议:** 方案 B —— 保留基础设施,但添加代码注释标记其为未来单工具展开预留。 - -### 5.3 [P1] 大输出工具的单工具展开 - -Claude Code 在产生大量输出的工具上显示 "(ctrl+o to expand)"。Qwen Code 当前只有全局切换。建议: - -- 当单个工具输出超过 N 行时,在紧凑模式下显示单工具"展开"提示。 -- 可复用现有的 `frozenSnapshot` 基础设施。 -- 范围:未来增强,非当前优先级。 - -### 5.4 [P2] 考虑会话级覆盖 - -部分用户可能希望紧凑模式作为默认,但偶尔需要在某个会话中使用详细模式。建议同时支持: - -- `settings.json` → 持久默认值(当前行为) -- 会话中按 Ctrl+O → 仅覆盖当前会话(Claude Code 行为) -- 会话重启 → 恢复 settings.json 中的值 - -这让用户两全其美。实现需要将"设置默认值"与"会话覆盖"状态分离。 - -### 5.5 [P2] 确认弹窗的结构性分离 - -当前确认保护依赖 `ToolGroupMessage` 中的 `showCompact` 条件。建议更健壮的方式: - -- 将确认弹窗渲染在独立层中(类似 Claude Code 的 overlay 方式)。 -- 这将使紧凑模式在架构上不可能影响确认弹窗。 -- 优先级较低,因为当前强制展开方式工作正常。 - -## 6. 当前实现状态 - -`feat/compact-mode-optimization` 分支变更后: - -| 功能 | 状态 | 说明 | -| -------------------------- | ------ | -------------------------------------- | -| 启动提示引导 | 已完成 | 紧凑模式提示加入 Tips 轮播(非侵入式) | -| Ctrl+O 加入快捷键列表(?) | 已完成 | 添加到 KeyboardShortcuts 组件 | -| Ctrl+O 加入 /help | 已完成 | 添加到 Help 组件 | -| 设置对话框同步 | 已完成 | compactMode 与 CompactModeContext 同步 | -| 无快照冻结 | 已完成 | 切换始终显示实时输出 | -| 确认保护 | 已完成 | 强制展开 + WaitingForConfirmation 防护 | -| Shell 保护 | 已完成 | `!isEmbeddedShellFocused` 强制展开 | -| 错误保护 | 已完成 | `!hasErrorTool` 强制展开 | -| 用户文档已更新 | 已完成 | settings.md、keyboard-shortcuts.md | - -## 7. 文件索引 - -### Qwen Code - -| 文件 | 用途 | -| --------------------------------------------------------------------- | ---------------------------------------- | -| `packages/cli/src/ui/AppContainer.tsx` | 切换处理器、状态初始化、context provider | -| `packages/cli/src/ui/contexts/CompactModeContext.tsx` | Context 定义 | -| `packages/cli/src/ui/components/messages/ToolGroupMessage.tsx` | 强制展开逻辑 | -| `packages/cli/src/ui/components/messages/ToolMessage.tsx` | 单工具输出隐藏 | -| `packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx` | 紧凑视图渲染 | -| `packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx` | 紧凑确认 UI | -| `packages/cli/src/ui/components/MainContent.tsx` | frozenSnapshot 渲染 | -| `packages/cli/src/ui/components/Tips.tsx` | 启动提示含紧凑模式引导 | -| `packages/cli/src/ui/components/Help.tsx` | /help 快捷键条目 | -| `packages/cli/src/ui/components/KeyboardShortcuts.tsx` | ? 快捷键条目 | -| `packages/cli/src/ui/components/SettingsDialog.tsx` | 设置同步 | -| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | 思考内容隐藏 | -| `packages/cli/src/config/settingsSchema.ts` | 设置定义 | -| `packages/cli/src/config/keyBindings.ts` | Ctrl+O 绑定 | - -### Claude Code(参考) - -| 文件 | 用途 | -| -------------------------------------------------- | -------------------------- | -| `src/hooks/useGlobalKeybindings.tsx` | 切换处理器 | -| `src/state/AppStateStore.ts` | 状态定义(verbose: false) | -| `src/components/CtrlOToExpand.tsx` | 单工具展开提示 | -| `src/components/Messages.tsx` | 简要消息过滤 | -| `src/screens/REPL.tsx` | 屏幕级模式切换 | -| `src/components/permissions/PermissionRequest.tsx` | 基于 overlay 的确认弹窗 | diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index f970847ba9f..1ad5c8f8449 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1265,10 +1265,6 @@ export const AppContainer = (props: AppContainerProps) => { const [compactMode, setCompactMode] = useState( settings.merged.ui?.compactMode ?? false, ); - const [frozenSnapshot, setFrozenSnapshot] = useState< - HistoryItemWithoutId[] | null - >(null); - const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -1283,18 +1279,6 @@ export const AppContainer = (props: AppContainerProps) => { const [showEscapePrompt, setShowEscapePrompt] = useState(false); const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false); - useEffect(() => { - // Clear frozen snapshot when streaming ends OR when entering confirmation - // state. During WaitingForConfirmation, the user needs to see the latest - // pending items (including the confirmation message) rather than a stale snapshot. - if ( - streamingState === StreamingState.Idle || - streamingState === StreamingState.WaitingForConfirmation - ) { - setFrozenSnapshot(null); - } - }, [streamingState]); - const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } = useFolderTrust(settings, setIsTrustedFolder); const { @@ -1686,11 +1670,6 @@ export const AppContainer = (props: AppContainerProps) => { setCompactMode(newValue); void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); - // Unlike Claude Code (where compact is default and Ctrl+O temporarily - // expands a single tool), qwen-code treats the toggle as a persistent - // preference change. No snapshot freezing in either direction — the - // user always sees live output after switching. - setFrozenSnapshot(null); } }, [ @@ -1726,7 +1705,6 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, - setFrozenSnapshot, refreshStatic, ], ); @@ -2152,8 +2130,8 @@ export const AppContainer = (props: AppContainerProps) => { ); const compactModeValue = useMemo( - () => ({ compactMode, frozenSnapshot, setCompactMode }), - [compactMode, frozenSnapshot, setCompactMode], + () => ({ compactMode, setCompactMode }), + [compactMode, setCompactMode], ); return ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index b29fb61ba6c..53551c547c6 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -13,8 +13,6 @@ import { useUIState } from '../contexts/UIStateContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; -import { useCompactMode } from '../contexts/CompactModeContext.js'; -import { StreamingState } from '../types.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -25,7 +23,6 @@ const MAX_GEMINI_MESSAGE_LINES = 65536; export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); - const { frozenSnapshot } = useCompactMode(); const { pendingHistoryItems, terminalWidth, @@ -60,34 +57,21 @@ export const MainContent = () => { - {/* Never use frozenSnapshot when waiting for user confirmation — - tool approvals must always remain visible and interactive. */} - {(frozenSnapshot && - uiState.streamingState !== StreamingState.WaitingForConfirmation - ? frozenSnapshot - : pendingHistoryItems - ).map((item, i) => { - const isFrozen = - frozenSnapshot !== null && - uiState.streamingState !== StreamingState.WaitingForConfirmation; - return ( - - ); - })} + {pendingHistoryItems.map((item, i) => ( + + ))} diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index aab5a3e30a2..a0bb30f7aba 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -110,7 +110,7 @@ const renderWithContext = ( ) => { const contextValue: StreamingState = streamingState; return render( - + {ui} diff --git a/packages/cli/src/ui/contexts/CompactModeContext.tsx b/packages/cli/src/ui/contexts/CompactModeContext.tsx index 0081c0cd518..55c7f5f1263 100644 --- a/packages/cli/src/ui/contexts/CompactModeContext.tsx +++ b/packages/cli/src/ui/contexts/CompactModeContext.tsx @@ -5,17 +5,14 @@ */ import { createContext, useContext } from 'react'; -import type { HistoryItemWithoutId } from '../types.js'; interface CompactModeContextType { compactMode: boolean; - frozenSnapshot: HistoryItemWithoutId[] | null; setCompactMode?: (value: boolean) => void; } const CompactModeContext = createContext({ compactMode: false, - frozenSnapshot: null, }); export const useCompactMode = (): CompactModeContextType => From e45c9974a4638530521c5cd0d2230a4ec55a0673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 10 Apr 2026 20:33:45 +0800 Subject: [PATCH 3/6] fix: address PR review feedback for compact mode optimization - Add refreshStatic() call after setCompactMode in SettingsDialog so already-rendered Static history updates immediately - Fix outdated column split comment in KeyboardShortcuts (5+4+4) - Update design doc: remove all frozenSnapshot references, renumber optimization recommendations, fix file reference descriptions - Add missing i18n keys for de.js and ja.js locales - Add test for SettingsDialog compact mode sync with CompactModeContext Co-Authored-By: Claude Opus 4.6 (1M context) --- .../compact-mode/compact-mode-design.md | 47 +++++------ packages/cli/src/i18n/locales/de.js | 4 + packages/cli/src/i18n/locales/ja.js | 4 + .../src/ui/components/KeyboardShortcuts.tsx | 2 +- .../src/ui/components/SettingsDialog.test.tsx | 79 +++++++++++++++++++ .../cli/src/ui/components/SettingsDialog.tsx | 4 + 6 files changed, 110 insertions(+), 30 deletions(-) diff --git a/docs/design/compact-mode/compact-mode-design.md b/docs/design/compact-mode/compact-mode-design.md index 7458c7eda41..178fefd0745 100644 --- a/docs/design/compact-mode/compact-mode-design.md +++ b/docs/design/compact-mode/compact-mode-design.md @@ -14,7 +14,7 @@ Qwen Code and Claude Code both provide a Ctrl+O shortcut for toggling between co | Toggle semantics | Temporary peek at details | Persistent preference switch | | Persistence | Session-only, resets on restart | Persisted to settings.json | | Scope | Global screen switch (prompt ↔ transcript) | Per-component rendering toggle | -| Frozen snapshot | None (no concept) | Infrastructure exists, currently unused | +| Frozen snapshot | None (no concept) | None (removed) | | Per-tool expand hint | Yes ("ctrl+o to expand") | Yes ("Press Ctrl+O to show full tool output") | ## 2. Claude Code Implementation Analysis @@ -90,7 +90,6 @@ Qwen Code uses a **component-level rendering flag** that each UI component reads ┌─────────────────────────────────────┐ │ CompactModeContext │ │ compactMode: boolean (default: false)│ -│ frozenSnapshot: items[] | null │ │ setCompactMode: (v) => void │ └──────────┬──────────────────────────┘ │ @@ -117,18 +116,18 @@ Qwen Code uses a **component-level rendering flag** that each UI component reads ### 3.2 Key Source Files -| Component | File | Key Logic | -| --------------- | ------------------------------------- | ------------------------------------------------- | -| Toggle handler | `AppContainer.tsx:1684-1694` | Toggles `compactMode`, persists, clears snapshot | -| Context | `CompactModeContext.tsx` | `compactMode`, `frozenSnapshot`, `setCompactMode` | -| Tool group | `ToolGroupMessage.tsx:105-110` | `showCompact` with 4 force-expand conditions | -| Tool message | `ToolMessage.tsx:346-350` | Hides `displayRenderer` in compact mode | -| Compact display | `CompactToolGroupDisplay.tsx:49-108` | Single-line summary with status + hint | -| Confirmation | `ToolConfirmationMessage.tsx:113-147` | Simplified 3-option compact approval | -| Tips | `Tips.tsx:14-29` | Startup tip rotation includes compact mode hint | -| Settings sync | `SettingsDialog.tsx:189-192` | Syncs with CompactModeContext | -| MainContent | `MainContent.tsx:63-72` | frozenSnapshot with WaitingForConfirmation guard | -| Thinking | `HistoryItemDisplay.tsx:123-133` | Hides `gemini_thought` in compact mode | +| Component | File | Key Logic | +| --------------- | ------------------------------------- | ----------------------------------------------- | +| Toggle handler | `AppContainer.tsx:1684-1690` | Toggles `compactMode`, persists to settings | +| Context | `CompactModeContext.tsx` | `compactMode`, `setCompactMode` | +| Tool group | `ToolGroupMessage.tsx:105-110` | `showCompact` with 4 force-expand conditions | +| Tool message | `ToolMessage.tsx:346-350` | Hides `displayRenderer` in compact mode | +| Compact display | `CompactToolGroupDisplay.tsx:49-108` | Single-line summary with status + hint | +| Confirmation | `ToolConfirmationMessage.tsx:113-147` | Simplified 3-option compact approval | +| Tips | `Tips.tsx:14-29` | Startup tip rotation includes compact mode hint | +| Settings sync | `SettingsDialog.tsx:189-193` | Syncs with CompactModeContext + refreshStatic | +| MainContent | `MainContent.tsx:60-76` | Renders live pendingHistoryItems | +| Thinking | `HistoryItemDisplay.tsx:123-133` | Hides `gemini_thought` in compact mode | ### 3.3 Design Decisions @@ -136,7 +135,7 @@ Qwen Code uses a **component-level rendering flag** that each UI component reads 2. **Persistent preference.** `compactMode` is saved to `settings.json` and survives across sessions. 3. **Component-level rendering.** Each component reads `compactMode` from context and adjusts its own rendering. 4. **Force-expand protection.** Four conditions override compact mode to ensure critical UI elements are always visible (confirmations, errors, shell, user-initiated). -5. **No snapshot freezing.** Although the `frozenSnapshot` infrastructure exists, it is never activated — toggle always shows live output. +5. **No snapshot freezing.** The toggle always shows live output — no frozen snapshots. 6. **Settings dialog sync.** Toggling compact mode from Settings updates React state immediately via `setCompactMode`. 7. **Non-intrusive discoverability.** Compact mode is introduced via the startup Tips rotation rather than a persistent footer indicator, avoiding UI clutter. @@ -211,24 +210,14 @@ Session start → verbose mode (default) Qwen Code's verbose default is the right choice for its current stage. Users who are new to the tool need transparency to build trust. As the product matures, consider making compact the default (like Claude Code). -### 5.2 [P1] Simplify frozenSnapshot Infrastructure - -Currently `frozenSnapshot` state and infrastructure exist but are never used (always null). Two options: - -- **Option A — Remove entirely.** Clean up dead code. If per-tool expansion is needed later, re-implement. -- **Option B — Keep as infrastructure.** Useful if we plan to add Claude Code-style per-tool "(ctrl+o to expand)" temporary expansion for individual large outputs. - -**Recommendation:** Option B — keep the infrastructure but add a code comment marking it as reserved for future per-tool expansion. - -### 5.3 [P1] Per-Tool Expansion for Large Outputs +### 5.2 [P1] Per-Tool Expansion for Large Outputs Claude Code shows "(ctrl+o to expand)" on individual tools that produce large output. Qwen Code currently only has a global toggle. Consider: - When a single tool produces output exceeding N lines, show a per-tool "expand" hint in compact mode. -- This would use the existing `frozenSnapshot` infrastructure. - Scope: future enhancement, not current priority. -### 5.4 [P2] Consider Session-Scoped Override +### 5.3 [P2] Consider Session-Scoped Override Some users may want compact mode as their default but occasionally need verbose for a specific session. Consider supporting both: @@ -238,7 +227,7 @@ Some users may want compact mode as their default but occasionally need verbose This gives users the best of both worlds. Implementation would require separating "settings default" from "session override" state. -### 5.5 [P2] Structural Separation for Confirmations +### 5.4 [P2] Structural Separation for Confirmations Currently, confirmation protection relies on `showCompact` conditions in `ToolGroupMessage`. Consider a more robust approach: @@ -274,7 +263,7 @@ After the `feat/compact-mode-optimization` branch changes: | `packages/cli/src/ui/components/messages/ToolMessage.tsx` | Per-tool output hiding | | `packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx` | Compact view rendering | | `packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx` | Compact confirmation UI | -| `packages/cli/src/ui/components/MainContent.tsx` | frozenSnapshot rendering | +| `packages/cli/src/ui/components/MainContent.tsx` | Pending history items rendering | | `packages/cli/src/ui/components/Tips.tsx` | Startup tip with compact mode hint | | `packages/cli/src/ui/components/Help.tsx` | /help shortcut entry | | `packages/cli/src/ui/components/KeyboardShortcuts.tsx` | ? shortcut entry | diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 4177edb3c6b..ba5a9f82ea8 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1779,6 +1779,8 @@ export default { 'Sie können den Berechtigungsmodus schnell mit Tab oder /approval-mode wechseln.', 'Try /insight to generate personalized insights from your chat history.': 'Probieren Sie /insight, um personalisierte Erkenntnisse aus Ihrem Chatverlauf zu erstellen.', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + 'Strg+O drücken, um den Kompaktmodus umzuschalten — Tool-Ausgabe und Denkprozess ausblenden.', // ============================================================================ // Custom API Key Configuration @@ -1972,6 +1974,8 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n', compact: 'kompakt', + 'compact mode: on (Ctrl+O off)': 'Kompaktmodus: ein (Strg+O aus)', + 'to toggle compact mode': 'Kompaktmodus umschalten', 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': 'Tool-Ausgabe und Denkprozess ausblenden für eine übersichtlichere Ansicht (mit Strg+O umschalten).', 'Press Ctrl+O to show full tool output': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 2789affd1e4..6b53fa6a8bf 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1175,6 +1175,8 @@ export default { 'Tab または /approval-mode で権限モードをすばやく切り替えられます。', 'Try /insight to generate personalized insights from your chat history.': '/insight でチャット履歴からパーソナライズされたインサイトを生成できます。', + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.': + 'Ctrl+O でコンパクトモードを切り替え — ツール出力と思考を非表示にしてすっきり表示。', 'Tips for getting started:': '始めるためのヒント:', '1. Ask questions, edit files, or run commands.': '1. 質問したり、ファイルを編集したり、コマンドを実行したりできます', @@ -1464,6 +1466,8 @@ export default { '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', compact: 'コンパクト', + 'compact mode: on (Ctrl+O off)': 'コンパクトモード: オン (Ctrl+O でオフ)', + 'to toggle compact mode': 'コンパクトモードの切り替え', 'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).': 'コンパクトモードでツール出力と思考を非表示にします(Ctrl+O で切り替え)。', 'Press Ctrl+O to show full tool output': 'Ctrl+O で完全なツール出力を表示', diff --git a/packages/cli/src/ui/components/KeyboardShortcuts.tsx b/packages/cli/src/ui/components/KeyboardShortcuts.tsx index 06a8089ddc9..860342c481b 100644 --- a/packages/cli/src/ui/components/KeyboardShortcuts.tsx +++ b/packages/cli/src/ui/components/KeyboardShortcuts.tsx @@ -56,7 +56,7 @@ const COLUMN_GAP = 4; const MARGIN_LEFT = 2; const MARGIN_RIGHT = 2; -// Column distribution for different layouts (4+4+4 for 3 cols, 6+6 for 2 cols) +// Column distribution for different layouts (5+4+4 for 3 cols, 7+6 for 2 cols) const COLUMN_SPLITS: Record = { 3: [5, 4, 4], 2: [7, 6], diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index e640effa6b7..c727507377e 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -40,6 +40,12 @@ import { OUTPUT_LANGUAGE_AUTO } from '../../utils/languageUtils.js'; const mockToggleVimEnabled = vi.fn(); const mockSetVimMode = vi.fn(); +// Mock the CompactModeContext +const mockSetCompactMode = vi.fn(); + +// Mock the UIActionsContext +const mockRefreshStatic = vi.fn(); + enum TerminalKeys { ENTER = '\u000D', TAB = '\t', @@ -122,6 +128,27 @@ vi.mock('../contexts/VimModeContext.js', async () => { }; }); +vi.mock('../contexts/CompactModeContext.js', async () => { + const actual = await vi.importActual('../contexts/CompactModeContext.js'); + return { + ...actual, + useCompactMode: () => ({ + compactMode: false, + setCompactMode: mockSetCompactMode, + }), + }; +}); + +vi.mock('../contexts/UIActionsContext.js', async () => { + const actual = await vi.importActual('../contexts/UIActionsContext.js'); + return { + ...actual, + useUIActions: () => ({ + refreshStatic: mockRefreshStatic, + }), + }; +}); + vi.mock('../../utils/settingsUtils.js', async () => { const actual = await vi.importActual('../../utils/settingsUtils.js'); return { @@ -434,6 +461,58 @@ describe('SettingsDialog', () => { unmount(); }); + it('should sync compact mode with CompactModeContext when toggled', async () => { + vi.mocked(saveModifiedSettings).mockClear(); + mockSetCompactMode.mockClear(); + mockRefreshStatic.mockClear(); + + const settings = createMockSettings(); + const onSelect = vi.fn(); + const component = ( + + + + ); + + const { stdin, unmount, lastFrame } = render(component); + + await waitFor(() => { + expect(lastFrame()).toContain('● Tool Approval Mode'); + }); + + const dialogKeys = getDialogSettingKeys(); + const targetIndex = dialogKeys.indexOf('ui.compactMode'); + expect(targetIndex).toBeGreaterThan(0); + + // Navigate to Compact Mode setting + for (let i = 0; i < targetIndex; i++) { + act(() => { + stdin.write(TerminalKeys.DOWN_ARROW as string); + }); + await wait(); + } + await waitFor(() => { + expect(lastFrame()).toContain('● Compact Mode'); + }); + + // Toggle the setting + act(() => { + stdin.write(TerminalKeys.ENTER as string); + }); + await waitFor(() => { + expect( + vi.mocked(saveModifiedSettings).mock.calls.length, + ).toBeGreaterThan(0); + }); + + // Verify compact mode context was synced + expect(mockSetCompactMode).toHaveBeenCalledWith(true); + // Verify refreshStatic was called to update rendered history + expect(mockRefreshStatic).toHaveBeenCalled(); + + unmount(); + }); + describe('enum values', () => { it('toggles enum values with the enter key', async () => { vi.mocked(saveModifiedSettings).mockClear(); diff --git a/packages/cli/src/ui/components/SettingsDialog.tsx b/packages/cli/src/ui/components/SettingsDialog.tsx index 07221732b40..24d9e1619ca 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -30,6 +30,7 @@ import { import { updateOutputLanguageFile } from '../../utils/languageUtils.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; import { createDebugLogger, type Config } from '@qwen-code/qwen-code-core'; import { useKeypress } from '../hooks/useKeypress.js'; import chalk from 'chalk'; @@ -62,6 +63,7 @@ export function SettingsDialog({ const { vimEnabled, toggleVimEnabled } = useVimMode(); // Get compact mode context to sync compact mode changes const { compactMode, setCompactMode } = useCompactMode(); + const uiActions = useUIActions(); // Mode state: 'settings' or 'scope' (view switching like ThemeDialog) const [mode, setMode] = useState<'settings' | 'scope'>('settings'); @@ -187,8 +189,10 @@ export function SettingsDialog({ } // Special handling for compact mode to sync with CompactModeContext + // and refresh static content so already-rendered history updates. if (key === 'ui.compactMode' && newValue !== compactMode) { setCompactMode?.(newValue as boolean); + uiActions.refreshStatic(); } // Special handling for approval mode to apply to current session From 47ee03c41a51817f9d553b751bab75f23ebdf33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Fri, 10 Apr 2026 21:54:14 +0800 Subject: [PATCH 4/6] fix: prevent subagent confirmation from being hidden in compact mode hasConfirmingTool only checks ToolCallStatus.Confirming, but subagent approvals arrive via resultDisplay.pendingConfirmation while the tool status remains Executing. Add hasSubagentPendingConfirmation to the showCompact guard so tool groups with pending subagent confirmations are always force-expanded. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/components/messages/ToolGroupMessage.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 45eb50796bf..fb9d915fa7b 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -100,11 +100,13 @@ export const ToolGroupMessage: React.FC = ({ const focusedSubagentCallId = focusedSubagentRef.current; // Compact mode: entire group → single line summary - // Force-expand when: user must interact (Confirming), tool errored, - // shell is focused, or user-initiated + // Force-expand when: user must interact (Confirming or subagent pending + // confirmation), tool errored, shell is focused, or user-initiated + const hasSubagentPendingConfirmation = subagentsAwaitingApproval.length > 0; const showCompact = compactMode && !hasConfirmingTool && + !hasSubagentPendingConfirmation && !hasErrorTool && !isEmbeddedShellFocused && !isUserInitiated; From 2ae59206d6077b46542c1a997b7df9480ccd1884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 11 Apr 2026 12:09:04 +0800 Subject: [PATCH 5/6] fix: force show subagent confirmation result in compact mode The previous fix (47ee03c) correctly force-expanded the tool group wrapper when a subagent had pending confirmation, but each inner ToolMessage still hid its resultDisplay due to compactMode check, which hid the AgentExecutionDisplay containing the inline confirmation UI. Add isAgentWithPendingConfirmation to forceShowResult conditions so the inner AgentExecutionDisplay is rendered even in compact mode. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/ui/components/messages/ToolGroupMessage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index fb9d915fa7b..445d19c8337 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -207,7 +207,8 @@ export const ToolGroupMessage: React.FC = ({ forceShowResult={ isUserInitiated || tool.status === ToolCallStatus.Confirming || - tool.status === ToolCallStatus.Error + tool.status === ToolCallStatus.Error || + isAgentWithPendingConfirmation(tool.resultDisplay) } isFocused={isSubagentFocused} isWaitingForOtherApproval={isWaitingForOtherApproval} From 23cc45e8e065ca851351d146155f7cf967d652f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 11 Apr 2026 18:29:00 +0800 Subject: [PATCH 6/6] feat(compact-mode): merge consecutive tool groups across hidden items MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In compact mode, sequential tool calls across multiple LLM turns each produced a separate bordered box, defeating the "compact" intent. The model typically emits a `gemini_thought` between consecutive tool calls, which is hidden in compact mode — so visually the boxes look adjacent, but in `history` they are separated by hidden items. This commit adds render-time merging of consecutive tool_group history items, where "consecutive" allows hidden-in-compact items (`gemini_thought`, `gemini_thought_content`) between them. Key pieces: - New `mergeCompactToolGroups` utility that merges adjacent mergeable tool_groups, skipping hidden items between them. Force-expand conditions (Confirming/Error tools, subagent pending confirmation, user-initiated, focused embedded shell) preserve group boundaries so authorization prompts, errors, and shell focus stay visible. - `MainContent.tsx` applies the merger only when `compactMode === true` (verbose mode is unchanged) and calls `refreshStatic()` when a merge consolidates items, because Ink's `` is append-only and cannot replace already-committed terminal content. - `CompactToolGroupDisplay.tsx` shows a `× N` count when a merged group contains more than one tool, matching the existing single-turn multi-tool display style. - 19 unit tests covering empty/single/multiple groups, hidden-item skipping (the 8-tool real-world scenario), force-expand boundaries, mixed tool types, and complex sequences. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../cli/src/ui/components/MainContent.tsx | 55 ++- .../messages/CompactToolGroupDisplay.tsx | 8 +- .../ui/utils/mergeCompactToolGroups.test.ts | 401 ++++++++++++++++++ .../src/ui/utils/mergeCompactToolGroups.ts | 185 ++++++++ 4 files changed, 647 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts create mode 100644 packages/cli/src/ui/utils/mergeCompactToolGroups.ts diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 53551c547c6..89c79909745 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -5,14 +5,18 @@ */ import { Box, Static } from 'ink'; +import { useEffect, useMemo, useRef } from 'react'; import { HistoryItemDisplay } from './HistoryItemDisplay.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { Notifications } from './Notifications.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; +import { useCompactMode } from '../contexts/CompactModeContext.js'; +import { mergeCompactToolGroups } from '../utils/mergeCompactToolGroups.js'; // Limit Gemini messages to a very high number of lines to mitigate performance // issues in the worst case if we somehow get an enormous response from Gemini. @@ -23,6 +27,8 @@ const MAX_GEMINI_MESSAGE_LINES = 65536; export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); + const uiActions = useUIActions(); + const { compactMode } = useCompactMode(); const { pendingHistoryItems, terminalWidth, @@ -31,6 +37,53 @@ export const MainContent = () => { availableTerminalHeight, } = uiState; + // Merge consecutive tool_groups for compact mode display + const mergedHistory = useMemo( + () => + compactMode + ? mergeCompactToolGroups( + uiState.history, + uiState.embeddedShellFocused, + uiState.activePtyId, + ) + : uiState.history, + [ + compactMode, + uiState.history, + uiState.embeddedShellFocused, + uiState.activePtyId, + ], + ); + + // Ink's is append-only: once an item is rendered to the terminal + // buffer, it cannot be replaced. In compact mode, when a new tool_group is + // merged into a previous one, the merged result has FEWER items than the + // raw history. Static would not re-render the older items even though their + // content changed, so we explicitly call refreshStatic() to clear the + // terminal and re-render the merged view. + // + // Detection: if history length grew but mergedHistory length did NOT grow + // proportionally (i.e., a merge consolidated items), trigger a refresh. + const prevHistoryLengthRef = useRef(uiState.history.length); + const prevMergedLengthRef = useRef(mergedHistory.length); + useEffect(() => { + if (!compactMode) { + prevHistoryLengthRef.current = uiState.history.length; + prevMergedLengthRef.current = mergedHistory.length; + return; + } + const prevHLen = prevHistoryLengthRef.current; + const currHLen = uiState.history.length; + const prevMLen = prevMergedLengthRef.current; + const currMLen = mergedHistory.length; + // History grew, but merged length stayed same or shrank → a merge happened. + if (currHLen > prevHLen && currMLen <= prevMLen) { + uiActions.refreshStatic(); + } + prevHistoryLengthRef.current = currHLen; + prevMergedLengthRef.current = currMLen; + }, [compactMode, uiState.history, mergedHistory, uiActions]); + return ( <> { , , , - ...uiState.history.map((h) => ( + ...mergedHistory.map((h) => ( - {/* Status line: icon + tool name + description */} + {/* Status line: icon + tool name + count + description */} {activeTool.name} + {toolCalls.length > 1 ? ( + + {' × '} + {toolCalls.length} + + ) : null} {activeToolDescription ? ( {' '} diff --git a/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts b/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts new file mode 100644 index 00000000000..2a8f69afe2d --- /dev/null +++ b/packages/cli/src/ui/utils/mergeCompactToolGroups.test.ts @@ -0,0 +1,401 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { mergeCompactToolGroups } from './mergeCompactToolGroups.js'; +import type { + HistoryItem, + HistoryItemToolGroup, + IndividualToolCallDisplay, +} from '../types.js'; +import { ToolCallStatus } from '../types.js'; + +// Helper to create tool_group history items +function createToolGroup( + id: number, + tools: IndividualToolCallDisplay[], + isUserInitiated?: boolean, +): HistoryItem { + return { + type: 'tool_group', + id, + tools, + isUserInitiated, + }; +} + +function createTool( + callId: string, + name: string, + status: ToolCallStatus, + resultDisplay?: unknown, + ptyId?: number, +): IndividualToolCallDisplay { + return { + callId, + name, + description: `${name} description`, + status, + resultDisplay: resultDisplay as IndividualToolCallDisplay['resultDisplay'], + confirmationDetails: undefined, + ptyId, + }; +} + +// Type guard for tool_group +function isToolGroup( + item: HistoryItem, +): item is HistoryItemToolGroup & { id: number } { + return item.type === 'tool_group'; +} + +describe('mergeCompactToolGroups', () => { + it('returns empty array unchanged', () => { + expect(mergeCompactToolGroups([])).toEqual([]); + }); + + it('returns single tool_group unchanged', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [ + createTool('c1', 'Shell', ToolCallStatus.Success, 'output'), + ]), + ]; + expect(mergeCompactToolGroups(items)).toEqual(items); + }); + + it('returns single non-tool-group unchanged', () => { + const items: HistoryItem[] = [{ type: 'user', id: 1, text: 'hello' }]; + expect(mergeCompactToolGroups(items)).toEqual(items); + }); + + it('merges two consecutive mergeable tool_groups', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [ + createTool('c1', 'Shell', ToolCallStatus.Success, 'output1'), + ]), + createToolGroup(2, [ + createTool('c2', 'Shell', ToolCallStatus.Success, 'output2'), + ]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(1); + expect(merged[0].type).toBe('tool_group'); + expect(merged[0].id).toBe(1); // First group's id preserved + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(2); + expect(merged[0].tools[0].callId).toBe('c1'); + expect(merged[0].tools[1].callId).toBe('c2'); + } + }); + + it('merges multiple consecutive mergeable groups', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [createTool('c2', 'Shell', ToolCallStatus.Success)]), + createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(1); + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(3); + } + expect(merged[0].id).toBe(1); + }); + + it('does NOT merge across non-tool-group item', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + { type: 'gemini', id: 2, text: 'response' }, + createToolGroup(3, [createTool('c2', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(3); + expect(merged[0].type).toBe('tool_group'); + expect(merged[1].type).toBe('gemini'); + expect(merged[2].type).toBe('tool_group'); + }); + + it('does NOT merge user-initiated tool_group', () => { + const items: HistoryItem[] = [ + createToolGroup( + 1, + [createTool('c1', 'Shell', ToolCallStatus.Success)], + false, + ), + createToolGroup( + 2, + [createTool('c2', 'Shell', ToolCallStatus.Success)], + true, + ), // user-initiated + createToolGroup( + 3, + [createTool('c3', 'Shell', ToolCallStatus.Success)], + false, + ), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(3); + // Groups 1 and 2 stay separate because 2 is user-initiated + // Group 3 stays separate because streak was broken + }); + + it('does NOT merge tool_group with error tool', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [ + createTool('c2', 'Shell', ToolCallStatus.Error, 'error output'), + ]), + createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(3); + // Group 2 with error stays separate + }); + + it('does NOT merge tool_group with confirming tool', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [ + createTool('c2', 'Shell', ToolCallStatus.Confirming), + ]), + createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(3); + // Group 2 with confirmation stays separate + }); + + it('does NOT merge tool_group with subagent pending confirmation', () => { + const subagentResult = { + type: 'task_execution', + subagentName: 'test-agent', + taskDescription: 'test task', + status: 'running', + pendingConfirmation: { someData: true }, + }; + + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [ + createTool('c2', 'Agent', ToolCallStatus.Executing, subagentResult), + ]), + createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(3); + // Group 2 with subagent pending confirmation stays separate + }); + + it('does NOT merge focused executing shell', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [ + createTool('c2', 'Shell', ToolCallStatus.Executing, 'output', 123), + ]), // active shell + createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items, true, 123); // shell focused, ptyId=123 + + expect(merged.length).toBe(3); + // Group 2 with active shell stays separate + }); + + it('merges mixed tool types (Shell + Read)', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + createToolGroup(2, [ + createTool('c2', 'Read', ToolCallStatus.Success, 'file content'), + ]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(1); + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(2); + expect(merged[0].tools[0].name).toBe('Shell'); + expect(merged[0].tools[1].name).toBe('Read'); + } + }); + + it('preserves all tool properties after merge', () => { + const tool1 = createTool('c1', 'Shell', ToolCallStatus.Success, 'output1'); + tool1.renderOutputAsMarkdown = true; + + const tool2 = createTool('c2', 'Read', ToolCallStatus.Success, 'output2'); + tool2.renderOutputAsMarkdown = false; + + const items: HistoryItem[] = [ + createToolGroup(1, [tool1]), + createToolGroup(2, [tool2]), + ]; + + const merged = mergeCompactToolGroups(items); + if (isToolGroup(merged[0])) { + expect(merged[0].tools[0].renderOutputAsMarkdown).toBe(true); + expect(merged[0].tools[1].renderOutputAsMarkdown).toBe(false); + } + }); + + it('merges tool_groups with multiple tools each', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [ + createTool('c1', 'Shell', ToolCallStatus.Success), + createTool('c2', 'Read', ToolCallStatus.Success), + ]), + createToolGroup(2, [ + createTool('c3', 'Shell', ToolCallStatus.Success), + createTool('c4', 'Write', ToolCallStatus.Success), + ]), + ]; + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(1); + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(4); + expect(merged[0].tools.map((t) => t.callId)).toEqual([ + 'c1', + 'c2', + 'c3', + 'c4', + ]); + } + }); + + it('merges tool_groups separated by gemini_thought (hidden in compact)', () => { + // This is the real-world case: model emits a thought between consecutive + // tool calls. Since gemini_thought is hidden in compact mode, the user + // visually sees adjacent boxes — so we merge them. + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + { type: 'gemini_thought', id: 2, text: 'thinking...' }, + createToolGroup(3, [createTool('c2', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + // The hidden gemini_thought between merged groups is dropped + expect(merged.length).toBe(1); + expect(merged[0].id).toBe(1); + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(2); + expect(merged[0].tools.map((t) => t.callId)).toEqual(['c1', 'c2']); + } + }); + + it('merges 8 tool_groups each separated by a gemini_thought', () => { + // Real scenario: 8 sequential shell commands, model thinks between each. + const items: HistoryItem[] = []; + for (let n = 0; n < 8; n++) { + items.push( + createToolGroup(n * 2 + 1, [ + createTool(`c${n}`, 'Shell', ToolCallStatus.Success), + ]), + ); + if (n < 7) { + items.push({ type: 'gemini_thought', id: n * 2 + 2, text: 'thinking' }); + } + } + + const merged = mergeCompactToolGroups(items); + + expect(merged.length).toBe(1); + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(8); + } + }); + + it('does NOT merge across visible non-tool-group items (gemini text)', () => { + // gemini text IS visible in compact mode → it breaks the streak + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + { type: 'gemini_thought', id: 2, text: 'thinking...' }, + { type: 'gemini', id: 3, text: 'visible response' }, // visible in compact + createToolGroup(4, [createTool('c2', 'Shell', ToolCallStatus.Success)]), + ]; + + const merged = mergeCompactToolGroups(items); + + // Should not merge because of the visible 'gemini' item + expect(merged.length).toBe(4); + expect(merged[0].type).toBe('tool_group'); + expect(merged[1].type).toBe('gemini_thought'); + expect(merged[2].type).toBe('gemini'); + expect(merged[3].type).toBe('tool_group'); + }); + + it('preserves trailing gemini_thought after merged group', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), + { type: 'gemini_thought', id: 2, text: 'thinking' }, + createToolGroup(3, [createTool('c2', 'Shell', ToolCallStatus.Success)]), + { type: 'gemini_thought', id: 4, text: 'more thinking' }, + ]; + + const merged = mergeCompactToolGroups(items); + + // Merged group + trailing gemini_thought + expect(merged.length).toBe(2); + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(2); + } + expect(merged[1].type).toBe('gemini_thought'); + }); + + it('handles complex sequence with mixed force-expand and mergeable', () => { + const items: HistoryItem[] = [ + createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]), // mergeable + createToolGroup(2, [createTool('c2', 'Shell', ToolCallStatus.Success)]), // mergeable + createToolGroup(3, [ + createTool('c3', 'Shell', ToolCallStatus.Error, 'error'), + ]), // force-expand + createToolGroup(4, [createTool('c4', 'Shell', ToolCallStatus.Success)]), // mergeable (streak broken) + createToolGroup( + 5, + [createTool('c5', 'Shell', ToolCallStatus.Success)], + true, + ), // user-initiated + createToolGroup(6, [createTool('c6', 'Shell', ToolCallStatus.Success)]), // mergeable (streak broken) + ]; + + const merged = mergeCompactToolGroups(items); + + // Expected: 1+2 merged, 3 separate, 4 separate, 5 separate, 6 separate + expect(merged.length).toBe(5); + + // First merged group (1+2) + if (isToolGroup(merged[0])) { + expect(merged[0].tools.length).toBe(2); + } + expect(merged[0].id).toBe(1); + + // Error group (3) + if (isToolGroup(merged[1])) { + expect(merged[1].tools[0].status).toBe(ToolCallStatus.Error); + } + + // Groups 4, 5, 6 stay separate + expect(merged[2].type).toBe('tool_group'); + expect(merged[3].type).toBe('tool_group'); + expect(merged[4].type).toBe('tool_group'); + }); +}); diff --git a/packages/cli/src/ui/utils/mergeCompactToolGroups.ts b/packages/cli/src/ui/utils/mergeCompactToolGroups.ts new file mode 100644 index 00000000000..087cdb82f11 --- /dev/null +++ b/packages/cli/src/ui/utils/mergeCompactToolGroups.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Merge consecutive tool_group history items for compact mode display. + * + * In compact mode, consecutive tool calls across multiple LLM turns each produce + * separate HistoryItemToolGroup items. This utility merges them into single groups + * for display, preserving force-expand conditions for authorization/error/shell focus. + */ + +import type { HistoryItem, IndividualToolCallDisplay } from '../types.js'; +import { ToolCallStatus } from '../types.js'; +import type { AgentResultDisplay } from '@qwen-code/qwen-code-core'; + +/** + * Check if a tool's resultDisplay indicates a subagent with pending confirmation. + * Matches the logic in ToolGroupMessage.tsx:21-31. + */ +function isAgentWithPendingConfirmation( + rd: IndividualToolCallDisplay['resultDisplay'], +): boolean { + return ( + typeof rd === 'object' && + rd !== null && + (rd as AgentResultDisplay).type === 'task_execution' && + (rd as AgentResultDisplay).pendingConfirmation !== undefined + ); +} + +/** + * Check if a tool_group history item should be excluded from merging due to force-expand conditions. + * These conditions match ToolGroupMessage.tsx:105-112 showCompact logic. + */ +function isForceExpandGroup( + item: HistoryItem, + embeddedShellFocused: boolean, + activeShellPtyId: number | undefined, +): boolean { + if (item.type !== 'tool_group') { + return false; + } + + // User-initiated groups stay distinct as visual boundaries + if (item.isUserInitiated) { + return true; + } + + const tools = item.tools; + + // Authorization prompts must show + if (tools.some((t) => t.status === ToolCallStatus.Confirming)) { + return true; + } + + // Errors must be visible + if (tools.some((t) => t.status === ToolCallStatus.Error)) { + return true; + } + + // Subagent pending confirmations must show + if (tools.some((t) => isAgentWithPendingConfirmation(t.resultDisplay))) { + return true; + } + + // Active focused shell must be visible + if ( + embeddedShellFocused && + activeShellPtyId !== undefined && + tools.some( + (t) => + t.ptyId === activeShellPtyId && t.status === ToolCallStatus.Executing, + ) + ) { + return true; + } + + return false; +} + +/** + * Check if an item is hidden in compact mode (so it shouldn't break tool_group adjacency). + * This mirrors HistoryItemDisplay.tsx:123-142 which hides gemini_thought / gemini_thought_content + * when compactMode is true. + */ +function isHiddenInCompactMode(item: HistoryItem): boolean { + return ( + item.type === 'gemini_thought' || item.type === 'gemini_thought_content' + ); +} + +/** + * Merge consecutive tool_group history items for compact mode display. + * + * Tool_groups separated only by items hidden in compact mode (`gemini_thought`, + * `gemini_thought_content`) are considered "consecutive" because the user + * doesn't see anything between them visually. Hidden items between merged + * tool_groups are dropped from the result (they would render as nothing + * anyway in compact mode). + * + * @param items - History items array + * @param embeddedShellFocused - Whether embedded shell is focused + * @param activeShellPtyId - PTY ID of the active shell (if any) + * @returns New array with merged tool_groups (does not mutate input) + */ +export function mergeCompactToolGroups( + items: HistoryItem[], + embeddedShellFocused: boolean = false, + activeShellPtyId: number | undefined = undefined, +): HistoryItem[] { + const result: HistoryItem[] = []; + let i = 0; + + while (i < items.length) { + const item = items[i]; + + // Pass through non-mergeable items unchanged + if ( + item.type !== 'tool_group' || + isForceExpandGroup(item, embeddedShellFocused, activeShellPtyId) + ) { + result.push(item); + i++; + continue; + } + + // item is a mergeable tool_group. Look ahead for more mergeable + // tool_groups, allowing hidden-in-compact-mode items between them. + const mergeableGroups: HistoryItem[] = [item]; + let lastMergedIdx = i; + let j = i + 1; + + while (j < items.length) { + const next = items[j]; + + if (isHiddenInCompactMode(next)) { + // Skip past hidden item, keep looking for next tool_group + j++; + continue; + } + + if ( + next.type === 'tool_group' && + !isForceExpandGroup(next, embeddedShellFocused, activeShellPtyId) + ) { + mergeableGroups.push(next); + lastMergedIdx = j; + j++; + continue; + } + + // Visible non-mergeable item — streak broken + break; + } + + // If only one group found, no merge needed + if (mergeableGroups.length === 1) { + result.push(item); + i++; + continue; + } + + // Merge: concatenate tools, reuse first group's id for React key stability + const mergedTools = mergeableGroups.flatMap((g) => + g.type === 'tool_group' ? g.tools : [], + ); + const mergedGroup: HistoryItem = { + type: 'tool_group', + tools: mergedTools, + id: mergeableGroups[0].id, + }; + + result.push(mergedGroup); + // Continue right after the last merged tool_group. Hidden items between + // merged groups are dropped (they'd render as nothing in compact mode); + // hidden items AFTER the last merged group will be picked up by the next + // iteration since we resume at lastMergedIdx + 1. + i = lastMergedIdx + 1; + } + + return result; +}