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..178fefd0745 --- /dev/null +++ b/docs/design/compact-mode/compact-mode-design.md @@ -0,0 +1,284 @@ +# 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) | 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 + +### 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)│ +│ 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-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 + +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.** 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. + +### 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] 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. +- Scope: future enhancement, not current priority. + +### 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: + +- `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.4 [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` | 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 | +| `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 6d753dfbb98..aca191cdd7e 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` | @@ -527,7 +527,7 @@ For authentication-related variables (like `OPENAI_*`) and the recommended `.qwe | `CODE_ASSIST_ENDPOINT` | Specifies the endpoint for the code assist server. | This is useful for development and testing. | | `QWEN_CODE_MAX_OUTPUT_TOKENS` | Overrides the default maximum output tokens per response. When not set, Qwen Code uses an adaptive strategy: starts with 8K tokens and automatically retries with 64K if the response is truncated. Set this to a specific value (e.g., `16000`) to use a fixed limit instead. | Takes precedence over the capped default (8K) but is overridden by `samplingParams.max_tokens` in settings. Disables automatic escalation when set. Example: `export QWEN_CODE_MAX_OUTPUT_TOKENS=16000` | | `TAVILY_API_KEY` | Your API key for the Tavily web search service. | Used to enable the `web_search` tool functionality. Example: `export TAVILY_API_KEY="tvly-your-api-key-here"` | -| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | +| `QWEN_CODE_PROFILE_STARTUP` | Set to `1` to enable startup performance profiling. Writes a JSON timing report to `~/.qwen/startup-perf/` with per-phase durations. | Only active inside the sandbox child process. Zero overhead when not set. Example: `export QWEN_CODE_PROFILE_STARTUP=1` | ## Command-Line Arguments diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index b2444fa7b22..ca5333e0d04 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1787,6 +1787,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.', 'Add a QWEN.md file to give Qwen Code persistent project context.': 'Fügen Sie eine QWEN.md-Datei hinzu, um Qwen Code dauerhaften Projektkontext zu geben.', 'Use /btw to ask a quick side question without disrupting the conversation.': @@ -1993,6 +1995,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/en.js b/packages/cli/src/i18n/locales/en.js index 1c99dcf7dc3..b96bfe783b6 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', @@ -1563,6 +1564,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.', 'Add a QWEN.md file to give Qwen Code persistent project context.': 'Add a QWEN.md file to give Qwen Code persistent project context.', 'Use /btw to ask a quick side question without disrupting the conversation.': @@ -2033,6 +2036,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/ja.js b/packages/cli/src/i18n/locales/ja.js index f0a1e9846de..960408d04c1 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1182,6 +1182,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 でコンパクトモードを切り替え — ツール出力と思考を非表示にしてすっきり表示。', 'Add a QWEN.md file to give Qwen Code persistent project context.': 'QWEN.md ファイルを追加すると、Qwen Code に永続的なプロジェクトコンテキストを与えられます。', 'Use /btw to ask a quick side question without disrupting the conversation.': @@ -1484,6 +1486,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/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 935db9f4078..24ae5571f68 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', @@ -1520,6 +1521,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.', 'Add a QWEN.md file to give Qwen Code persistent project context.': 'Adicione um arquivo QWEN.md para dar ao Qwen Code um contexto persistente do projeto.', 'Use /btw to ask a quick side question without disrupting the conversation.': @@ -1983,6 +1986,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 5ee2bb6dc7b..b5e673cec0d 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': 'переключить компактный режим', // ============================================================================ // Поля системной информации @@ -1714,6 +1715,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 для переключения компактного режима — скрыть вывод инструментов и рассуждения.', 'Add a QWEN.md file to give Qwen Code persistent project context.': 'Добавьте файл QWEN.md, чтобы предоставить Qwen Code постоянный контекст проекта.', 'Use /btw to ask a quick side question without disrupting the conversation.': @@ -1990,6 +1993,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 59c05bf1af5..476f5bfc514 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': '关闭对话框、取消请求或退出应用程序', @@ -1480,6 +1481,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 切换紧凑模式 ── 隐藏工具输出和思考过程,界面更简洁。', 'Add a QWEN.md file to give Qwen Code persistent project context.': '添加 QWEN.md 文件,为 Qwen Code 提供持久的项目上下文。', 'Use /btw to ask a quick side question without disrupting the conversation.': @@ -1839,6 +1842,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/services/tips/tipRegistry.ts b/packages/cli/src/services/tips/tipRegistry.ts index 8db0dabdbb6..cb655783b2f 100644 --- a/packages/cli/src/services/tips/tipRegistry.ts +++ b/packages/cli/src/services/tips/tipRegistry.ts @@ -175,4 +175,13 @@ export const tipRegistry: ContextualTip[] = [ cooldownPrompts: 0, priority: 50, }, + { + id: 'compact-mode', + content: + 'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.', + trigger: 'startup', + isRelevant: () => true, + cooldownPrompts: 0, + priority: 50, + }, ]; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 128fb76d9d2..aebf8bf2665 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1313,10 +1313,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); @@ -1331,18 +1327,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 { @@ -1734,13 +1718,6 @@ 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); - } } }, [ @@ -1776,8 +1753,6 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, - setFrozenSnapshot, - pendingHistoryItems, refreshStatic, ], ); @@ -2207,8 +2182,8 @@ export const AppContainer = (props: AppContainerProps) => { ); const compactModeValue = useMemo( - () => ({ compactMode, frozenSnapshot }), - [compactMode, frozenSnapshot], + () => ({ compactMode, setCompactMode }), + [compactMode, 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..860342c481b 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 }) => ( @@ -55,11 +56,11 @@ 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: [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..89c79909745 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -5,15 +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. @@ -24,7 +27,8 @@ const MAX_GEMINI_MESSAGE_LINES = 65536; export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); - const { frozenSnapshot } = useCompactMode(); + const uiActions = useUIActions(); + const { compactMode } = useCompactMode(); const { pendingHistoryItems, terminalWidth, @@ -33,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) => ( { - {(frozenSnapshot ?? pendingHistoryItems).map((item, i) => { - const isFrozen = frozenSnapshot !== null; - return ( - - ); - })} + {pendingHistoryItems.map((item, i) => ( + + ))} 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 2be88b613fb..6fe1dd0a0fe 100644 --- a/packages/cli/src/ui/components/SettingsDialog.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.tsx @@ -29,6 +29,8 @@ import { } from '../../utils/settingsUtils.js'; 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'; @@ -59,6 +61,9 @@ export function SettingsDialog({ }: SettingsDialogProps): React.JSX.Element { // Get vim mode context to sync vim mode changes 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'); @@ -186,6 +191,13 @@ 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 if ( key === 'tools.approvalMode' && diff --git a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx index cf870f0bebb..45b757a4b33 100644 --- a/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx +++ b/packages/cli/src/ui/components/messages/CompactToolGroupDisplay.tsx @@ -83,12 +83,18 @@ export const CompactToolGroupDisplay: React.FC< borderColor={borderColor} gap={0} > - {/* 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/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 45eb50796bf..445d19c8337 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; @@ -205,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} 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 54cb1bbb64d..55c7f5f1263 100644 --- a/packages/cli/src/ui/contexts/CompactModeContext.tsx +++ b/packages/cli/src/ui/contexts/CompactModeContext.tsx @@ -5,16 +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 => 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; +} diff --git a/packages/cli/src/utils/startupProfiler.test.ts b/packages/cli/src/utils/startupProfiler.test.ts index 0fb4bebfeb2..adfb4bf99a2 100644 --- a/packages/cli/src/utils/startupProfiler.test.ts +++ b/packages/cli/src/utils/startupProfiler.test.ts @@ -112,9 +112,7 @@ describe('startupProfiler', () => { it('should write JSON file on finalize and print path to stderr', () => { vi.mocked(fs.mkdirSync).mockReturnValue(undefined); vi.mocked(fs.writeFileSync).mockReturnValue(undefined); - const stderrSpy = vi - .spyOn(process.stderr, 'write') - .mockReturnValue(true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); initStartupProfiler(); profileCheckpoint('main_entry'); @@ -200,9 +198,7 @@ describe('startupProfiler', () => { vi.mocked(fs.mkdirSync).mockImplementation(() => { throw new Error('Permission denied'); }); - const stderrSpy = vi - .spyOn(process.stderr, 'write') - .mockReturnValue(true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockReturnValue(true); initStartupProfiler(); profileCheckpoint('test'); diff --git a/packages/cli/src/utils/startupProfiler.ts b/packages/cli/src/utils/startupProfiler.ts index 3c3cb42f536..d9884bf8423 100644 --- a/packages/cli/src/utils/startupProfiler.ts +++ b/packages/cli/src/utils/startupProfiler.ts @@ -122,9 +122,7 @@ export function finalizeStartupProfile(sessionId?: string): void { fs.writeFileSync(filepath, JSON.stringify(report, null, 2), 'utf-8'); process.stderr.write(`Startup profile written to: ${filepath}\n`); } catch { - process.stderr.write( - 'Warning: Failed to write startup profile report\n', - ); + process.stderr.write('Warning: Failed to write startup profile report\n'); } }