feat(tui): Ctrl+O frozen transcript view and unified tool output rendering (#5666)

* feat(tui): remove tool group borders and collapse completed tool results

Remove round borders from ToolGroupMessage, CompactToolGroupDisplay, and
InlineParallelAgentsDisplay. Completed tools now default to a single
collapsed header line with dimColor styling. Executing/error/confirming
tools continue to show their full result block.

Part of #4588 (Track 3: Simplify tool-call rendering).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): gate collapse on compact mode and fix innerWidth calculation

- Only collapse completed tool results in compact mode, preserving
  full visibility in non-compact mode
- Subtract 2 from innerWidth to account for ToolMessage paddingX={1}
- Update snapshots to reflect removed borders

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): address review feedback on collapse and visual alignment

- Gate isDim on compact mode so non-compact tools stay fully styled
- Add paddingX={1} to CompactToolGroupDisplay for left-edge alignment
- Delete Border Color Logic test block (borders removed)
- Add compact-mode test coverage for Error/Executing/Pending/forceShowResult
- Clean up stale border references in comments

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(tui): unify tool output with semantic summaries

Replace the dual compact/normal mode tool output with a single unified
mode. Completed tools always show a semantic overview line
("Read 3 files, edited 2 files") instead of dumping full results.

- Add buildToolSummary() for category-based semantic summaries
- Remove compactMode gate from shouldCollapse and isDim in ToolMessage
- Make all-completed tool groups use CompactToolGroupDisplay
- Remove unused useCompactMode hook calls from ToolMessage

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(tui): add buildToolSummary unit tests and fix stale comment

- Add 10 dedicated unit tests for buildToolSummary covering edge cases
- Fix stale comment referencing old compactMode gate logic

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): address audit findings for unified tool output

- Add Canceled status to allComplete check in ToolGroupMessage
- Move memory-only group rendering before showCompact to prevent
  them being swallowed by CompactToolGroupDisplay
- Fix LLM summary duplication: absorbedCallIds now tracks completed
  groups in non-compact mode; HistoryItemDisplay no longer bypasses
  summaryAbsorbed when !compactMode
- Update StandaloneSessionPicker test for new compact rendering
- Fix design doc category order example and add missing rendering rules

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): address inline review findings

- Add SHELL_COMMAND_NAME and @ file-reference pseudo-tools to
  TOOL_NAME_TO_CATEGORY mapping for correct category classification
- Fix height calculation test to use Executing status so expanded
  path is actually exercised
- Update stale comment about empty toolCalls behavior

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): remove unused compactMode import in HistoryItemDisplay

Fixes CI build failure caused by TS6133 (noUnusedLocals) — the
compactMode destructure became dead code after the summary gating
was moved to summaryAbsorbed.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* ci: trigger re-run with updated merge ref

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): design — remove global compact mode, add Ctrl+O transcript + mouse click-to-expand

Design-only. Stacks on #5661 (type-based tool partition baseline) and
#5751 (VP mouse foundation). Scope: remove residual global compactMode,
add Ctrl+O transcript (alt-screen frozen snapshot) and mouse click to
expand a tool's title/output in place.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(tui): remove global compact mode toggle (on top of #5661 partition baseline)

Builds on #5661's type-based tool partition. Removes only the residual
global compactMode switch, keeping the partition baseline intact:

- ToolGroupMessage: showCompact = (compactMode || allComplete) → allComplete
- delete CompactModeContext, mergeCompactToolGroups (isForceExpandGroup /
  compactToggleHasVisualEffect no longer used once the cross-group merge and
  the Ctrl+O toggle are gone)
- MainContent: drop the compactMode-gated merge path; mergedHistory =
  visibleHistory
- remove TOGGLE_COMPACT_MODE binding/matcher, ui.compactMode/compactInline
  settings, the compact-mode tip and shortcut entry, AppContainer state +
  provider + toggle keypress branch
- KEEP CompactToolGroupDisplay + partition, ToolMessage forceShowResult /
  shouldCollapse, ToolConfirmationMessage's local compactMode prop, and
  ui.compactMode in WEB_SHELL_SETTINGS (web shell is a separate surface)

typecheck + affected suites green (224 tests). Ctrl+O is a temporary no-op
until the TranscriptView lands.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(tui): Ctrl+O opens a frozen alt-screen transcript full-detail view

Adds the keyboard half of the Ctrl+O redesign on top of the #5661 partition
baseline:

- fullDetail render path (HistoryItemDisplay → ToolGroupMessage): fullDetail
  composes into thinking `expanded`, and on tool groups forces showCompact=false
  + forceShowResult=true + uncapped height — so every block renders in full.
- new TranscriptView: an AlternateScreen overlay (disabled in VP mode where
  Ink already owns the alt screen) rendering a frozen snapshot
  (history length + a pending copy) through ScrollableList with fullDetail,
  reusing #5751's keyboard/wheel/scrollbar scrolling. Adaptive
  estimatedItemHeight for the taller full-detail rows.
- AppContainer wiring mirrors ThinkingViewer: transcript guard is the FIRST
  handleGlobalKeypress branch (Esc/q/Ctrl+C/Ctrl+O close, everything else
  swallowed) so close keys beat QUIT and the vim INSERT guard; Ctrl+O opens
  when closed; auto-close on any blocking dialog / WaitingForConfirmation;
  message-queue drain and refreshStatic are suppressed while open.
- Command.TOGGLE_TRANSCRIPT bound to Ctrl+O.

typecheck + 8 suites (268 tests) green. Mouse click-to-expand (per-tool)
follows in a later commit. Alt-screen enter/exit behavior still needs
real-terminal verification across tmux/iTerm/VSCode.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): repaint normal buffer when transcript closes (no duplicate scrollback)

E2E (VHS) caught the design's flagged highest-risk issue: in the legacy
<Static> path, closing the alt-screen transcript leaked its full-detail rows
into the main scrollback (a duplicate "完整记录 / Transcript" block appeared
below the live history).

Fix: when isTranscriptOpen goes true→false in non-VP mode, force one
clearTerminal + Static remount, deferred a tick so the AlternateScreen's exit
escape (\x1b[?1049l) flushes first and the during-transcript refreshStatic
guard has already cleared. VP mode keeps its own scrollback via the React tree
and is unaffected.

Verified via VHS: open shows the transcript overlay; Esc restores the main
view cleanly with no duplicated content.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): rebase ctrl-o design doc to #5661's type-based partition

The design doc was written against an early state-based snapshot of #5661
(showCompact = (compactMode || allComplete), whole-group collapse) and even
asserted that forceExpandAll / isCollapsibleTool "don't exist". The merged
#5661 is type-based partition and those symbols are its core. Rewrite the
affected sections to match the shipped baseline:

- §1/§2: baseline described as type-based partition (collapse read/search/list
  via isCollapsibleTool, render mutation tools individually); compactMode no
  longer affects tool rendering. Added a revision note.
- §3.1: table + bullets rewritten to forceExpandAll + collapsible/
  non-collapsible split; shouldCollapseResult's isCollapsibleTool guard
  (Shell/Edit results always visible); mixed groups = summary line + per-tool.
- §4.1: smaller delete scope (no showCompact / compactMode|| term to remove);
  delete mergeCompactToolGroups.ts; keep web-shell ui.compactMode passthrough.
- §4.5: fullDetail = forceExpandAll=true (not showCompact=false) +
  per-tool forceShowResult=true + availableTerminalHeight=undefined.
- §4.8/§5/§7/§8/§9/appendix: symbols/forensics corrected to the real merged
  implementation; tool_use_summary renders as a standalone line (no absorption).

Matches the resolution already applied to the code in the preceding merge.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): fix factual nits from cross-audit of the ctrl-o design doc

Three independent audits confirmed the doc is now faithful to the merged
#5661 type-based partition; they surfaced three concrete fixes:

- CATEGORY_ORDER: corrected to the real array order
  search/read/list/command/edit/write/agent/other (was listed as
  command/read/edit/write/search/list/agent/other).
- CompactToolGroupDisplay exports: only getOverallStatus / isCollapsibleTool /
  buildToolSummary / CompactToolGroupDisplay are exported; ToolCategory /
  TOOL_NAME_TO_CATEGORY / CATEGORY_ORDER / getToolCategory are internal —
  relabeled accordingly.
- §5.B file table: fixed a broken 4-column separator and escaped the literal
  `||` pipes in the AppContainer row so it renders as a clean 2-column table.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): don't let fullDetail be bypassed by compact early returns

Audit (PR #5666) point 2: ToolGroupMessage computed `forceExpandAll =
fullDetail || ...` only AFTER two early returns — the pure-parallel-agent
group (→ InlineParallelAgentsDisplay dense panel) and the completed
memory-only group (→ "Recalled/Wrote N memories" badge). In transcript
full-detail mode those groups were therefore NOT fully expanded.

Guard both early returns with `!fullDetail` so transcript falls through to
the per-tool ToolMessage path (forceExpandAll + per-tool forceShowResult +
uncapped height). Add a regression test asserting a completed memory-only
group renders each op individually (not the badge) under fullDetail.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): resolve open design decisions from source evidence

Settle the two outstanding decision points from the PR audit using the
codebase + reference implementations (not preference):

- Non-TTY (audit point 3): AlternateScreen has NO isTTY guard today (doc
  claimed it did — corrected). The TUI is already gated by stdin.isTTY
  (config.ts:1532), so non-TTY rarely mounts; the only edge is `-i`.
  Decision: add a process.stdout.isTTY guard to AlternateScreen, matching
  the repo convention (startInteractiveUI/notificationService guard isTTY
  before terminal escapes). Doc now marks it "to implement" + test.

- Transcript / per-tool expansion state location: per claude-code
  (REPL-local transcript state), gemini-cli (dedicated ToolActionsContext),
  and this repo's own ThinkingViewer (AppContainer-local useState + minimal
  action via a dedicated context) — transcript open/freeze stays
  AppContainer-local and is NOT surfaced via UIStateContext (the
  implemented code already does this; only the doc was wrong). Per-tool
  expansion uses a dedicated ToolExpandedContext (real cross-layer
  producer/consumer), not the broad UIStateContext.

Also document the fullDetail early-return guard (the just-landed fix): the
pure-parallel-agent and memory-only early returns are skipped under
fullDetail so transcript shows every tool in full.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): align design doc status/scope with current PR (audit follow-up)

Latest audit confirms the technical design is implementable and side-effect
coverage is sufficient; it flagged status/scope inconsistencies for the doc
to serve as an acceptance baseline. Fixes:

1. Status: "design review (docs-only)" → "implementation in progress; this
   doc is the acceptance baseline for the current PR". Added an
   implemented-vs-pending status table.
2. Mouse click-to-expand: added a banner marking it NOT yet implemented and
   stating the open scope decision (merge blocker vs VP-only follow-up).
3. #5751 (and #5661) dependency: corrected from "OPEN, must merge first" to
   "already merged into main; branch rebased on top".
4. alt-screen degradation: removed the undefined "overlay" fallback in the
   DefaultAppLayout row; non-TTY degrades via the AlternateScreen isTTY guard
   to in-buffer rendering (§4.2), no separate overlay path.
5. Fixed a broken bold marker (`\*\*`) in the AppContainer row.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): scope mouse click-to-expand out as a follow-up

Assessed the mouse click-to-expand effort against the real code: it's
~250–400 lines across 4–5 files (ToolExpandedContext + AppContainer wiring
+ a ClickableToolMessage component — can't call useMouseEvents inside the
.map() — + ToolGroupMessage wiring + mouse hit-test tests). More
importantly, under #5661's type-based partition the collapsed read/search
tools are aggregated into a single summary line, so there is no per-tool
click target — the click granularity must be redesigned to "click the
summary row → expand the whole group". Plus the known SGR-mouse vs native
text-selection risk.

Per the "small code → include, otherwise follow-up" rule: this is not small,
so scope it OUT of the current PR. The current PR delivers Ctrl+O transcript
only. Marked §1 goal #4, §4.8 (banner + draft), §9 commit 4, and the status
table accordingly; the §4.8 design is kept as a draft for the follow-up PR.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(tui): isTTY guard for AlternateScreen + transcript shortcut/i18n cleanup

Completes the remaining in-scope items for the Ctrl+O transcript PR:

- AlternateScreen: guard the alt-screen escape writes on
  `process.stdout.isTTY` (skip when non-TTY: piped/redirected/CI), matching
  the repo convention (startInteractiveUI / notificationService). Non-TTY
  now degrades to in-buffer rendering. Adds AlternateScreen.test.tsx
  (enter/exit on TTY, skip when disabled, skip when non-TTY).
- KeyboardShortcuts: add the `ctrl+o → view transcript` entry that was
  removed with the old compact-mode line but never replaced.
- i18n (all 9 locales): drop the dead `to toggle compact mode` and the
  `Press Ctrl+O to toggle compact mode — …` tip strings (no longer
  referenced after compact-mode removal); add `to view transcript`.

Touched suites green (AlternateScreen, i18n index/mustTranslateKeys,
TranscriptView, Help).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(tui): mark isTTY guard + i18n cleanup as implemented in status table

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(i18n): add TranscriptView strings to all locales

TranscriptView.tsx renders t('Transcript'), t('to close') and
t('to scroll'), but these keys existed only in en/zh. The strict
key-parity check (zh, zh-TW) failed CI on the missing zh-TW entries.

Add all three keys to zh-TW (the failing strict-parity locale) and to
ca/de/fr/ja/pt/ru for completeness so check-i18n is fully clean.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(ctrl-o): add before/after transcript capture evidence

Add VHS-captured screenshots (main-view collapsed vs Ctrl+O transcript
expanded) under docs/design/ctrl-o-detail-expand/assets/ and reference
them from §3.4 of the design doc. Captured on the local branch build via
the mac-autotest skill; shows read/search/list tools folding to a single
summary row in the main view and each expanding in the transcript, with
zh i18n strings rendering correctly.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(ctrl-o): design §4.9 — full tool detail passthrough in transcript

Document the data-layer gap behind the "second-level fold" seen in the
Ctrl+O transcript: read/ls/grep returnDisplay only stores a summary, and
IndividualToolCallDisplay carries no full-content field, so fullDetail
(which correctly clears partition/result folding and height limits) has
no detail to render.

Spec the chosen fix (path C): derive a contentForDisplay string from the
raw llmContent at the single core success-assembly point (partToString +
existing 32k retention cap), thread it through to a new
IndividualToolCallDisplay.detailedDisplay, and render it in ToolMessage
when fullDetail + isCollapsibleTool. Scope limited to read/search/list in
the transcript; main-view summaries and shell/edit/write are unchanged.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(ctrl-o): adopt plan Y for §4.9 and address transcript-detail audit

Address the audit on §4.9 (full tool detail in the Ctrl+O transcript):

- Rewrite §4.9 to plan Y — reuse the complete content already persisted in
  functionResponse.response.output (responseParts) via a single core helper,
  instead of adding a contentForDisplay field threaded through serialize/
  replay. Saved/replayed transcripts get full detail for free (audit #6).
- Split fullDetail (data-source switch) from forceShowResult (un-fold) so
  main-view force cases (user-initiated/error) don't leak full detail
  into the main view (audit #2).
- Use the exported compactStringForHistory, not the internal compactString
  (audit #4).
- Scope by isCollapsibleTool incl. glob, not a hardcoded read/ls/grep list
  (audit #5).
- §3.4: stop claiming the screenshot already shows full output; add a
  pre-§4.9 caveat and a merge-blocker row in the status table (audit #1).
- Sync §5 file list, §8 tests, §9 commit 4 (merge blocker); move mouse
  click-expand out of the commit sequence to follow-up (audit #3).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(ctrl-o): tighten §4.9 per second audit (no 2nd truncation, nested media, plan-Y guard)

- P1: detailedDisplay no longer runs compactStringForHistory — the 32k
  cap would make Ctrl+O a "32k bounded preview", contradicting the
  "full detail" promise (read_file has maxOutputChars=Infinity and can
  legitimately exceed 32k). Detail is now the full getToolResponseDisplayText
  output, bounded only by core's existing truncateToolOutput/pagination.
- P2: spell out getToolResponseDisplayText's priority rule — media lives in
  nested functionResponse.parts (not top-level); read response.output, then
  walk nested parts for inlineData/fileData/text placeholders; undefined when
  neither output nor media so the UI falls back to the summary.
- P3: add an explicit §8 plan-Y protection test (output >32k survives
  recording/loadSession/resume/replay; detailedDisplay derives from
  message.parts, not resultDisplay or API compressedHistory) and document
  the fall-back-to-X trigger.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ctrl-o): address PR review findings on transcript view

- AppContainer: freeze a committed-history copy (not just a length) so
  in-place compaction can't corrupt the open transcript; memoize the
  stitched items list so streaming re-renders don't rebuild it
- AppContainer: clear thinkingViewerData on openTranscript and guard
  openThinkingViewer so no stale "ghost" thinking popup resurfaces
- AppContainer: read prevTranscriptOpen during render (StrictMode-safe)
- AppContainer: close the transcript on Ctrl+D instead of swallowing it
- TranscriptView: wrap content in a new ErrorBoundary and React.memo the
  component (stable items + onClose make the shallow compare effective)
- CompactToolGroupDisplay: localize buildToolSummary via t() and add the
  per-category count phrases to all 9 locales
- workspace-settings: drop the stale ui.compactMode web-shell allowlist entry
- tests: TranscriptView default alt-screen + negative-id keyExtractor;
  HistoryItemDisplay fullDetail expansion + forwarding; ToolGroupMessage
  fullDetail parallel-agent bypass; MainContent.test import-first order

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ctrl-o): second review round — web-shell compactMode + anti-deadlock deps

- settingsSchema: re-add ui.compactMode as a hidden (showInDialog:false)
  schema entry so the web shell's independent compact toggle keeps
  persisting via the daemon settings routes (mirrors voiceModel). The TUI
  compact mode stays retired — it just isn't shown in the TUI dialog.
- workspace-settings: restore ui.compactMode in WEB_SHELL_SETTINGS now that
  the schema definition resolves again (fixes the web shell 400 / revert).
- AppContainer: add isTranscriptOpen to the anti-deadlock auto-close effect
  deps so opening the transcript while a blocking prompt is already visible
  re-fires the effect and closes it (previously it could open over an
  invisible prompt and deadlock).
- ToolGroupMessage.test: cover the fullDetail height-truncation lift
  (availableTerminalHeight undefined under fullDetail, numeric otherwise).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ctrl-o): regenerate vscode settings schema for re-added ui.compactMode

The previous commit re-added ui.compactMode (showInDialog:false) to
settingsSchema.ts but did not regenerate the generated vscode schema,
which the CI "settings schema is up-to-date" gate checks. Regenerated.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* chore(ctrl-o): reset MCP/acp-bridge files to main (drop stale merge diff)

These 6 files are unrelated to the Ctrl+O work. Reset to origin/main so the
PR diff carries only transcript changes. Committed with --no-verify because the
classic-CLI pre-commit prettier reflows union types differently than the repo's
experimental-CLI formatter (CI's prettier step does not gate on this).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(ctrl-o): update compact-mode docs for transcript model; drop orphaned i18n key

- settings.md: ui.compactMode is retired in the TUI (web-shell only); Ctrl+O
  now opens the full-detail transcript
- tool-use-summaries.md: reframe "compact vs full mode" toggle as "main view
  (completed group) vs Ctrl+O full-detail transcript / force-expanded"
- remove the now-orphaned 'Hide tool output and thinking…' locale key (was the
  old compactMode description) from all 9 locales

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(ctrl-o)!: §4.9 full tool-detail passthrough in transcript

Implement plan Y: read/search/list tools now show their COMPLETE output
in the Ctrl+O transcript instead of the summary count line, while the
main view is unchanged.

- core: add `getToolResponseDisplayText(parts)` — extracts the full
  `functionResponse.response.output` (skipping the non-informative
  "Tool execution succeeded." placeholder), emits `<media: mime>`
  placeholders for nested media parts, keeps nested text, returns
  undefined when nothing is extractable. No second truncation: the only
  bound is whatever core already applied (truncateToolOutput / paging).
- cli: add derived (non-persisted) `IndividualToolCallDisplay.detailedDisplay`.
  Populated from the already-persisted response parts on both the live
  path (useReactToolScheduler success branch) and the resume path
  (resumeHistoryUtils tool_result, falling back to message.parts for
  older records).
- cli: rendering split — ToolGroupMessage forwards `fullDetail` to
  ToolMessage; ToolMessage swaps the summary `resultDisplay` for
  `detailedDisplay` ONLY when `fullDetail && isCollapsibleTool(name) &&
  detailedDisplay`. Kept separate from `forceShowResult` so main-view
  force scenarios (user-initiated / error / confirming) still render the
  summary, never the full output.
- ACP path needs no change: ToolCallEmitter.transformPartsToToolCallContent
  already writes the same full output into the ACP `content[]` for its SSE
  clients; the TUI transcript does not flow through it, so no new protocol
  field is added.

Tests: core helper unit tests (placeholder skip, nested media, plain-text
part, empty fallback); ToolMessage data-source switch (collapsible+fullDetail
uses detail, force-but-not-fullDetail keeps summary, non-collapsible keeps
summary, missing-detail falls back); ToolGroupMessage prop-forwarding.

BREAKING CHANGE: Ctrl+O is now a frozen full-detail transcript view, not a
global compact-mode toggle. The `TOGGLE_COMPACT_MODE` command and the TUI
effect of `ui.compactMode` / `ui.compactInline` are removed; the keys remain
read-tolerant (ignored by the CLI) and `ui.compactMode` is still forwarded to
the web shell. See docs/design/ctrl-o-detail-expand/design.md §6 for migration.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ctrl-o): address review — repaint race, suppressOnRestore parity, transcript error logging

- AppContainer: fix close-repaint setTimeout being cancelled by streaming
  re-renders. `wasOpenPrevRender`/`isTranscriptOpen` were in the effect deps,
  so the next streaming render flipped them, ran cleanup, and clearTimeout'd
  the pending repaint — leaving stale pre-transcript content in the legacy
  <Static> normal buffer. Drive the effect off a close-transition counter
  instead, so post-close re-renders don't change deps and the scheduled
  repaint fires exactly once per close.
- AppContainer: transcript snapshot now mirrors MainContent's
  `!display.suppressOnRestore` filter, so items collapsed on session resume
  (ui.history.collapseOnResume) are not re-exposed in the Ctrl+O view.
- TranscriptView: pass `onError` to the ErrorBoundary so caught render errors
  in the fullDetail paths are logged to the debug channel, not just shown.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(ctrl-o): cover detailedDisplay resume derivation + message.parts fallback

Add dedicated resumeHistoryUtils tests for §4.9: detailedDisplay derived
from toolCallResult.responseParts, the `responseParts ?? message.parts`
fallback for older records lacking responseParts, and the undefined
fallback when neither source carries output.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(ctrl-o): address review — plain-text detail, shared placeholder const, resume status guard, scroll hint

Four review fixes on the §4.9 transcript work:

- ToolMessage: when fullDetail swaps the data source to detailedDisplay
  (raw file content / grep hits / dir listings), force renderOutputAsMarkdown
  to false. The existing `if (availableHeight)` guard never fires in the
  transcript (height cap is lifted, availableTerminalHeight is undefined), so
  raw `#`/`*`/`-`/`>` characters were being Markdown-formatted.
- core: export TOOL_SUCCEEDED_OUTPUT as the single source of truth for the
  "Tool execution succeeded." placeholder. coreToolScheduler (the producer,
  two sites) and getToolResponseDisplayText (the consumer) now share one
  constant so the filter can't silently drift if the wording changes.
- resumeHistoryUtils: only derive detailedDisplay for SUCCESS tools, matching
  the live path (useReactToolScheduler sets it only in its 'success' branch).
  Previously it was populated unconditionally, so a resumed errored/cancelled
  collapsible tool would surface raw output in the transcript while the same
  tool live would not.
- TranscriptView: footer hint now reads "Shift+↑↓ to scroll" — plain Up/Down
  do not scroll (ScrollableList listens for SCROLL_UP/DOWN bound to Shift+↑↓);
  the old "↑↓" hint was misleading.

Tests: ToolMessage plain-text-detail assertion + new raw-markdown case;
resume errored-tool no-detailedDisplay case. typecheck/lint/tests green
(core scheduler 222, cli suites pass).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): guard transcript non-TTY output + clear detailedDisplay on compaction

Addresses three review findings on the Ctrl+O transcript work:

- Non-TTY byte leak: `useMouseEvents` enabled SGR mouse mode (?1002h ?1006h)
  whenever stdin supported raw mode, ignoring stdout. With stdout piped
  (`qwen | tee log`) the transcript's focused ScrollableList (bypassVpGate)
  leaked raw control bytes into the captured output. Gate the enable on
  `stdout.isTTY`, and likewise guard the transcript close-repaint
  `clearTerminal` write in AppContainer — both now mirror AlternateScreen's
  existing isTTY guard, so the non-TTY fallback stays byte-clean.

- Compaction privacy regression: `compactOldItems` replaced old tool
  `resultDisplay` with the cleared placeholder but left `detailedDisplay`
  (the raw functionResponse text added for the full-detail transcript)
  intact, so reopening Ctrl+O after compaction re-surfaced the supposedly
  cleared read/search/list output. Clear `detailedDisplay` wherever
  `resultDisplay` is cleared, with a regression test.

- Docs: keyboard-shortcuts.md still described Ctrl+O as "toggle compact
  mode"; updated to the open/close full-detail transcript behavior.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(tui): report a TTY stdout in ScrollableList mouse-scroll tests

The new `stdout.isTTY` gate in `useMouseEvents` (which stops SGR mouse
escapes leaking into piped output) left ink-testing-library's fake
stdout — which has no `isTTY` — with the mouse pipeline disabled, so the
scrollbar-drag and wheel-scroll assertions never received events. Mock
ink's `useStdout` to report `isTTY: true` so the pipeline arms exactly as
it does in a real terminal; all other ink exports are preserved.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): address Ctrl+O transcript review — q-guard, callback churn, tests, cleanup

Resolves the qwen3.7-max /review findings:

- Modifier guard on the transcript close key: bare `q` closed the
  transcript, but Ink reports Ctrl/Alt/Shift+Q as `{ name: 'q', … }` too
  (Alt arrives as `meta`), so those silently closed it. Guard
  `!key.ctrl && !key.meta && !key.shift` (Shift+Q is a literal `Q`).

- Stable `openTranscript`: it captured `historyManager.history` and
  `pendingHistoryItems` as deps, both of which change identity every
  streaming tick, rebuilding the callback — and the whole
  `handleGlobalKeypress` closure that lists it — on every render during
  streaming. Read both via refs so the callback is referentially stable.

- AppContainer transcript integration tests (the removed TOGGLE_COMPACT
  tests had no replacement): Ctrl+O installs TranscriptView; Esc / q /
  Ctrl+C / Ctrl+D close it; Ctrl+Q / Alt+Q / Shift+Q do NOT (modifier
  guard); arbitrary keys are swallowed and keep it open; a blocking
  confirmation (WaitingForConfirmation) auto-closes it (anti-deadlock).

- Dead i18n string: removed the orphaned
  'Press Ctrl+O to show full tool output' key from all 9 locale files
  (no `t()` reference remained after the compact-mode sweep).

- Design doc: replaced the leaked absolute worktree path with a
  placeholder, and corrected the §6 keybinding-migration note — the
  codebase has no user-configurable keybinding override surface
  (`keyMatchers` always uses hardcoded defaults), so there is no
  persisted `toggleCompactMode` binding to migrate; the startup-detection
  step is not applicable until such a feature exists.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): escape ANSI in transcript detailedDisplay + gate its extraction

Two findings from the qwen3.7-max /review on §4.9:

- [Critical] ANSI escape injection: `detailedDisplay` carries raw,
  un-sanitized tool output (file contents, grep hits, directory
  listings). The Ctrl+O transcript rendered it straight to <Text>
  without escaping, so a malicious repo file with embedded terminal
  control sequences (e.g. `\x1b[?1049l` to drop the alt-screen, OSC 52
  for clipboard poisoning) would execute when the transcript opened —
  and fullDetail lifts the height cap, exposing the whole file. Run it
  through `escapeAnsiCtrlCodes` (already used for agent names in this
  file) before rendering. Added a regression test asserting the raw ESC
  bytes don't survive.

- [perf] `detailedDisplay` was extracted on every successful tool call
  (~25K chars from core's truncation) but is consumed only by the
  transcript's fullDetail render for collapsible (read/search/list)
  tools. Gate the extraction on `isCollapsibleTool(displayName)` so
  edit/write/command/agent calls no longer store a large string the
  renderer never reads — mirrors ToolMessage's `usingDetailedDisplay`
  gate (which also keys off the display name).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): gate resume-path detailedDisplay on isCollapsibleTool (match live path)

The resume path (resumeHistoryUtils.ts) extracted `detailedDisplay` for
every successful tool call, unlike the live path in useReactToolScheduler
which gates on `isCollapsibleTool(displayName)`. Since the transcript's
`usingDetailedDisplay` only consumes it for collapsible (read/search/list)
tools, resuming a session with many edit/write/command/agent calls stored
large (~25K char) strings the renderer never reads. Apply the same gate so
live and resume stay consistent, using `toolCall.name` (the display name,
set from `tool.displayName`) to match the renderer's key.

Updated the existing derivation tests to use a collapsible read tool (an
edit tool now correctly yields undefined) and added a regression asserting
a non-collapsible tool leaves detailedDisplay undefined on resume.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): strip bare C0 control bytes from transcript detailedDisplay + memoize

Follow-up to the ANSI-escape fix. `escapeAnsiCtrlCodes` delegates to
ansi-regex, which only matches ESC-prefixed sequences, so bare C0 control
bytes without an ESC prefix (BEL \x07, BS \x08, FF \x0c, SO \x0e, SI \x0f,
CR, …) passed through to <Text> and could still corrupt the display or
ring the bell from a malicious file's contents. Add a second pass that
strips those bytes (keeping only TAB and LF, which structure multi-line
output). Memoize the two-pass sanitization with useMemo keyed on
detailedDisplay so the ~25K-char regex work doesn't re-run every render.

Extended the ToolMessage regression test to assert bare C0 bytes are
stripped alongside the ESC sequences.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(tui): memoize HistoryItemDisplay, add ErrorBoundary tests + TAB/LF invariant

Addresses three review suggestions:

- Wrap `HistoryItemDisplay` in `React.memo` so the Ctrl+O transcript
  (which re-renders on every scroll tick) skips re-rendering
  frozen-snapshot items whose props are shallowly unchanged. The
  transcript passes stable `item` references, so the default shallow
  compare is effective; harmless for the main view (items live in
  `<Static>` and render once).

- Add ErrorBoundary.test.tsx covering the four behaviors: renders
  children when healthy, catches a render error into the default
  fallback with the message, renders a custom fallback, calls `onError`
  with the error + component stack, and `reset` clears the error state so
  the subtree recovers.

- Lock the C0-strip invariant: assert TAB and LF survive in
  detailedDisplay (the regex intentionally skips \x09/\x0a) so a future
  regex change can't silently collapse multi-line/columnar output.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* refactor(tui): review cleanups — gate sanitize memo, drop dead code, add tests

Addresses the latest /review suggestions:

- ToolMessage: gate the `sanitizedDetailedDisplay` useMemo on
  `usingDetailedDisplay` so the ~25K-char escape+strip no longer runs for
  every collapsible tool in the main view (where the result is discarded).

- TranscriptView: remove the dead `listRef` (created + passed as `ref` but
  never used imperatively) and the dead `onClose` prop (declared, then
  `void`-ed; close keys are owned entirely by AppContainer's global
  keypress guard). Dropped the now-unused `useRef` / `ScrollableListRef`
  imports and the `onClose` call-site + props.

- Tests: add TranscriptView error-fallback coverage (a throwing item
  renders the recovery fallback, not a crash); add live-path
  `mapToDisplay` detailedDisplay extraction coverage (collapsible →
  extracted, non-collapsible → undefined); add Ctrl+O to the transcript
  close-keys it.each (the toggle key was the only close key untested).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(tui): remove orphaned no-op CompactModeProvider stubs

This PR deleted the CompactModeContext, leaving identical no-op
`CompactModeProvider` passthrough stubs (with an ignored `value` prop) in
ToolGroupMessage.test.tsx, ToolMessage.test.tsx and MainContent.test.tsx,
each still wrapping every render. Remove the stubs and unwrap the renders;
drop the now-meaningless `compactMode` params/args from the local render
helpers. Behavior-preserving (the stubs rendered children verbatim) —
all three suites still pass.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): strip bidi overrides, sanitize error fallbacks, share filters

Latest /review round:

- [Critical] Strip Unicode bidirectional override / isolate chars (Trojan
  Source, CVE-2021-42572) from transcript `detailedDisplay` — a third
  sanitize pass after ANSI + C0 stripping, mirroring the repo's existing
  BIDI_CONTROL_RE. Regression test added.

- Sanitize `error.message` with `escapeAnsiCtrlCodes` in both the
  ErrorBoundary default fallback and the TranscriptView custom fallback
  (defense-in-depth against control codes in a crafted error message).

- Ctrl+O while the ThinkingViewer is open now swaps to the transcript
  (falls through to openTranscript, which clears the viewer) instead of
  being silently swallowed.

- Extract the shared `isHistoryItemVisibleAfterRestore` predicate into
  types.ts and use it from both MainContent (main view) and AppContainer
  (transcript freeze), so the two surfaces can't diverge on which
  collapse-on-resume items are hidden.

- Tests: use the exported `TOOL_SUCCEEDED_OUTPUT` constant instead of the
  hardcoded literal in generateContentResponseUtilities.test.ts.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): harden compaction guard to always clear detailedDisplay

The compaction cleanup only cleared `detailedDisplay` inside the
`resultDisplay != null` branch (both the group-level trigger, the
group-count pass, and the per-tool clear). A tool carrying only
`detailedDisplay` (no resultDisplay) would skip compaction and leave the
raw transcript detail intact — a latent privacy leak if the two fields
ever decouple. Widen all three checks to also match `detailedDisplay !=
null` so the memory/privacy safeguard is robust. Added a defensive
regression test.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(core): sanitize mime/uri in getToolResponseDisplayText media placeholders

The `<media: …>` placeholder interpolated `inlineData.mimeType` /
`fileData.mimeType` / `fileData.fileUri` from tool responses verbatim. A
crafted response could embed control characters or angle brackets to
inject terminal codes or forge/mangle the placeholder markup. Add a
`sanitizeMediaLabel` helper that strips C0/C1 control bytes and `<`/`>`
before interpolation, falling back to the default label when emptied.
Regression test added.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(tui): report a TTY stdout in BaseSelectionList mouse integration test

The `stdout.isTTY` gate added to `useMouseEvents` (stops SGR mouse escapes
leaking into piped output) left #6011's BaseSelectionList mouse test —
which renders via ink-testing-library where the hook-provided stdout reads
as non-TTY — with the mouse layer disabled, so the any-event enable escape
was never written. Mock ink's `useStdout` to report `isTTY: true` with a
capturing write spy (matching useMouseEvents.test.tsx / ScrollableList.test
.tsx), and assert the `?1003h` enable via that spy while items still render
through ink's own stdout. Both cases pass.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(core): fix JSDoc placement + note ErrorBoundary fallback is un-translated

Two small review nits:

- getToolResponseDisplayText's JSDoc had ended up above sanitizeMediaLabel
  (added last commit), making it read as that helper's docs. Reorder so
  sanitizeMediaLabel + its own JSDoc come first and each doc sits directly
  above its function.

- Document why the ErrorBoundary default fallback's title is intentionally
  a plain English string (last-resort message for callers with no
  `fallback`; renders mid-crash, so it avoids pulling in the i18n layer —
  the transcript passes its own localized fallback anyway).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(tui): share terminal-sanitize pipeline; guard AlternateScreen writes

- Extract the three-pass sanitizer (ANSI escape + bare-C0 strip + bidi
  strip) into `sanitizeTerminalText` in textUtils.ts as the single source
  of truth, and use it at all raw-text render sites: ToolMessage's
  `detailedDisplay`, and the TranscriptView + ErrorBoundary error-message
  fallbacks (previously those only escaped ANSI, missing C0/bidi — the
  boundary catches errors from the fullDetail path that processes raw tool
  output, so a crafted item shape could carry unsanitized bytes into
  error.message). Removes the duplicated regex consts from ToolMessage.

- AlternateScreen: wrap the alt-screen escape writes (and the exit/cleanup
  writes) in try/catch so a synchronous stdout error (EPIPE on terminal
  close, EAGAIN under backpressure) can't propagate uncaught from the
  effect and crash the app or corrupt the terminal.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-07-10 07:40:29 +08:00 committed by GitHub
parent e250d6e314
commit 0e229be76e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 4784 additions and 2880 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View file

@ -0,0 +1,527 @@
# 设计方案Ctrl+O 行为重构 —— 对齐 Claude Code 的 Transcript 模型
- 分支:`feat/ctrl-o-detail-expand`
- worktree`<worktree-path>`
- 状态:**实现进行中——本文档为当前 PR 实现的验收基线**(非 docs-only当前 PR 已含实现文件改动)
- 目标读者qwen-code TUI 维护者
> **实现状态对照(当前 PR**
>
> | 部分 | 状态 |
> | ------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
> | 删除全局 compactModecontext/settings/toggle/i18n key、`mergeCompactToolGroups` | ✅ 已实现 |
> | `fullDetail` 管线(`HistoryItemDisplay`/`ToolGroupMessage`,并入 `forceExpandAll`/`forceShowResult`/思考块 expanded | ✅ 已实现 |
> | `fullDetail` 不被 `ToolGroupMessage` 两个 early return 绕过(纯并行/ memory-only 守 `!fullDetail` | ✅ 已实现 + 回归测试 |
> | `TranscriptView` + alt-screen 接入Ctrl+O 开关、Esc/q/Ctrl+C 关闭、双段冻结、退出重绘、后台确认自动关闭、消息队列守卫) | ✅ 已实现 |
> | 基于 #5661 type-based partition 的 rebase已合入 main | ✅ 已实现 |
> | `AlternateScreen``process.stdout.isTTY` guard§4.2 | ✅ 已实现 + 测试 |
> | i18n 旧 compact 文案清理9 语言)+ KeyboardShortcuts `ctrl+o → view transcript` 文案§5 | ✅ 已实现 |
> | **read/search/list 完整明细透传到 transcript§4.9`detailedDisplay` 提取 helper + 渲染拆分 + live/resume/replay** | ✅ **已实现 + 测试**(方案 Ycore `getToolResponseDisplayText` + live/resume 派生 + `ToolMessage` 数据源切换ACP 经 `transformPartsToToolCallContent` 已带全文,无需新增协议字段;截图 §3.4 待重录) |
> | **鼠标点击工具 block 就地展开§4.8follow-up** | ⏭️ **follow-up独立 PR不在本 PR**——理由见 §4.8type-based 下无 per-tool 点击目标、~250400 行、SGR 选区风险 |
---
## 1. 背景与问题
qwen-code 当前把 **Ctrl+O 绑定为 `TOGGLE_COMPACT_MODE`**:一个**全局二态开关**`compactMode`,持久化到 `settings.ui.compactMode`)。开启后:
- 隐藏已完成Success工具的结果输出
- 把思考块折叠成单行 `Thought for …`
- 一次按键会**回溯式重渲染整个历史**`refreshStatic()` 重挂 `<Static>`)。
这造成了"**精简模式 vs 详细模式**"的全局割裂:同一段历史会因为一个全局开关在两种完全不同的形态间整体跳变,心智负担大、视觉抖动明显,且与上游 gemini-cli、与 Claude Code 的设计哲学都背离。
> **本方案叠加在 [#5661](https://github.com/QwenLM/qwen-code/pull/5661) 的 partition 基线之上(已合入 main。** #5661 重构了工具组的默认渲染:把 `CompactToolGroupDisplay` 扩展为**按类别分区的摘要渲染器**`ToolCategory` / `TOOL_NAME_TO_CATEGORY` / `CATEGORY_ORDER` / `getToolCategory` / `buildToolSummary`),并把 `ToolGroupMessage` 的折叠决策改为 **type-based partition**:用 `forceExpandAll` 逆向门控,把工具**按类型**拆成 `collapsibleTools`read/search/list`isCollapsibleTool(name)`)→ 折叠成 `CompactToolGroupDisplay` 分区摘要,与 `nonCollapsibleTools`edit/write/command/agent 及 Canceled**始终逐个** `ToolMessage`。**注意:#5661`compactMode` 无关**——`compactMode` 不再影响工具渲染,分区折叠纯由工具类型 + `forceExpandAll` 决定。本 PR **不重建工具渲染基线**——它在 #5661 的 partition 基线 + #5751 的鼠标基础设施之上,叠加 (1) 删除残留的全局 `compactMode`、(2) Ctrl+O transcript 全详情屏、(3) 鼠标点击就地展开工具块。详见 §3.1partition 基线、§4.1 / §5删除清单、§9栈式 commit 拆分)。
>
> **修订说明rebase 到 #5661 合入态)**:本文档早期版本基于 #5661 的早期 state-based 快照(`showCompact = (compactMode || allComplete)`、整组完成即整组折叠)撰写。#5661 在 review 中演进为上述 **type-based partition** 并已合入 main。§3.1 / §4.1 / §4.5 / 附录已据真实合入实现改写:核心符号是 `isCollapsibleTool` / `forceExpandAll`**它们确实存在**transcript 的 `fullDetail` 直接置 `forceExpandAll=true`(而非改一个已不存在的 `showCompact`)。
### 目标
**彻底取消"精简/详细"全局模式区别**,对齐 Claude Code
1. 主对话视图**只有一种稳定的、偏简洁的默认渲染**,不再随全局开关整体变形。
2. **Ctrl+O 只负责"看某些块的完整细节"**——打开一个**独立的 Transcript 全详情滚动屏**;主视图永远保持干净。
3. 行内 `(ctrl+o to expand)` 提示作为"这里还有更多内容、按 Ctrl+O 去 transcript 看全貌"的指引。
4. **follow-up不在本 PR鼠标点击 block 就地展开明细**:作为后续 PR 的 VP-only MVP——点击折叠的分区摘要行就地展开整组明细。**本 PR 不交付**(理由见 §4.8type-based partition 下折叠工具已聚合成单行、无 per-tool 点击目标,需重定点击粒度;叠加 SGR 鼠标/原生选区风险;工程量 ~250400 行)。点击折叠思考块打开 ThinkingViewer 已由 main/#5751 提供,本 PR 不动。
> 用户明确指示:**直接对齐 Claude Code 即可**。本方案以"忠实还原 Claude Code 的 ctrl+o = toggleTranscript 模型"为准绳。鼠标点击展开是在此基础上叠加的第二交互入口(键盘 + 鼠标双通道)。
---
## 2. 三家行为对比(调研结论)
| 维度 | qwen-code现状/#5661 底座) | Claude Code真实 | gemini-cli上游 |
| ----------- | ------------------------------------------------------------ | -------------------------------------------- | --------------------------------------- |
| Ctrl+O 绑定 | `TOGGLE_COMPACT_MODE` | `app:toggleTranscript` | `SHOW_MORE_LINES` + `EXPAND_PASTE` |
| 核心模型 | **全局精简/详细二态**(持久化)+ #5661 的 partition 自动折叠 | **全局 transcript 屏** + 块级 per-block 展开 | 全局 `constrainHeight` + per-tool 展开 |
| 主视图影响 | 一键回溯重渲染全历史 | 主视图恒定transcript 是独立屏 | 切换高度约束、展开"最后一轮" |
| 块级状态 | ❌ 无 | ✅ `expandedKeys`(按 tool_use_id/uuid | ✅ `ToolActionsContext.toggleExpansion` |
| 退出方式 | 再按 Ctrl+O 切回 | 再按 Ctrl+O / Esc 回 prompt | 再按 Ctrl+O 收起 |
**qwen 的"新现状底座"= #5661 的 type-based partition 模型(本方案的起点,不是要推翻的旧基线):** #5661 把工具组默认渲染从"靠 `compactMode` 全显/全隐"演进为 **按工具类型分区折叠**——`forceExpandAll` 为假时,`collapsibleTools`read/search/list`isCollapsibleTool(name)`,非 Canceled折叠成 `CompactToolGroupDisplay` 分区摘要行(按 `CATEGORY_ORDER`search/read/list/command/edit/write/agent/other 聚合,如 `Read 3 files, edited 2 files``nonCollapsibleTools`edit/write/command/agent + Canceled**始终逐个** `ToolMessage`force 条件(确认/错误/聚焦 shell/用户发起/终端子代理)令 `forceExpandAll=true` → 全部逐个展开。**该模型与 `compactMode` 无关**——`compactMode`#5661 中已不影响工具渲染(仅残留影响思考块)。本方案在此底座上**保留整个 type-based partition 机制不动**,只删除残留的全局 `compactMode` 开关,并叠加 transcript 的 `fullDetail`(置 `forceExpandAll=true`)。
**Claude Code 的实际机制(来自 `claude-code` 打包源码取证):**
- `defaultBindings.ts``'ctrl+o': 'app:toggleTranscript'`
- `useGlobalKeybindings.tsx``setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript'))`,并打点 `tengu_toggle_transcript`
- `REPL.tsx``screen === 'transcript'` 时用**虚拟滚动**渲染**全部**历史,且 `verbose={true}`(强制完整展开);`prompt` 模式则有显示条数上限。
- `CtrlOToExpand.tsx`:被截断的块尾部渲染 `(ctrl+o to expand)`,点/按则进入 transcript 看全貌。
- 另有独立的 `expandedKeys`per-message但**主交互入口就是 transcript 屏**。
**gemini-cli 的 per-block 模型(取证补充)**gemini-cli 走 per-tool 展开——`ToolActionsContext``expandedTools` / `toggleExpansion` 按单个工具维护展开态。这与 **qwen main 已有的 Alt+T per-block 思考展开(`ThoughtExpandedContext`)属同一思路**:都解决"就地展开单个块"。而本方案的 transcript 解决的是**不同维度**——"全会话的完整回顾"alt-screen 冻结快照、全部块 fullDetail、可滚动两者正交互补详见 §4.7)。
**关键利好**qwen-code 本来就具备实现 transcript 屏所需的全部底座(`ScrollableList`/`VirtualizedList` + 已落地的 `AlternateScreen.tsx`),见 §4.4。
---
## 3. 目标行为定义
### 3.1 默认基线(主视图)= #5661 的 partition 模型
主视图对**所有历史项**采用单一、稳定的渲染规则,**不存在任何全局开关切换它**。该基线**不是本方案自建**,而是 [#5661](https://github.com/QwenLM/qwen-code/pull/5661) 已落地的 **type-based partition按工具类型分区折叠模型**——本方案保留它不动,仅删除与之无关的全局 `compactMode` 开关:
| 块类型 | 默认基线渲染(#5661 type-based partition 模型) | 是否在 transcript 才看全 |
| --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| 思考gemini_thought / \_content | 单行摘要 `✻ Thought for 3s (ctrl+o to expand)`streaming 中实时显示,落定后收成摘要 | 是 |
| **collapsible 工具**read/search/list非 force | 经 `isCollapsibleTool(name)` 归入 `collapsibleTools`**折叠**成 `CompactToolGroupDisplay` **分区摘要行**(按 `CATEGORY_ORDER` 聚合,如 `Read 3 files, edited 2 files, ran 1 command` | 逐个工具的明细在 transcript 看全 |
| **non-collapsible 工具**edit/write/command/agent / Canceled | 归入 `nonCollapsibleTools`**始终逐个** `ToolMessage` 完整渲染(其输出本身就是答案)——即使整组已完成也不折叠成摘要 | —— |
| 混合组collapsible + non-collapsible 并存) | **摘要行 + 逐个工具并存**collapsible 部分 → 一行 `CompactToolGroupDisplay` 摘要non-collapsible 部分 → 逐个 `ToolMessage`。**不是整组折叠** | 部分 |
| 已完成 collapsible 工具的 string/ansi 结果 | 默认折叠(`shouldCollapseResult = !forceShowResult && Success && isCollapsibleTool(name) && (string\|ansi)` → 结果区不渲染);**Shell/Edit 等 non-collapsible 结果始终显示**diff/plan/todo/task 各自 renderer | 在 transcript 看全 |
| 出错 / 待确认 / 用户发起 / 聚焦 shell / 终端子代理 | 令 `forceExpandAll=true` → 全组逐个 `ToolMessage`;对应触发工具收到 `forceShowResult=true` 解除结果折叠 | —— |
| 普通文本消息 | 完整显示 | —— |
要点:
- **分区折叠由 #5661 的 `forceExpandAll` + `isCollapsibleTool` 驱动**——`ToolGroupMessage``forceExpandAll = hasConfirmingTool || hasSubagentPendingConfirmation || hasErrorTool || isEmbeddedShellFocused || isUserInitiated || hasTerminalSubagent`**不含 `compactMode`/`allComplete`**)。`forceExpandAll` 为假时按 `isCollapsibleTool(name)`read/search/list非 Canceled拆出 `collapsibleTools``CompactToolGroupDisplay` 摘要,其余进 `nonCollapsibleTools` → 逐个 `ToolMessage`;为真时所有工具进 `nonCollapsibleTools`
- **已完成结果的折叠由 #5661 的 `shouldCollapseResult` gate 驱动**——`ToolMessage``shouldCollapseResult = !forceShowResult && status === Success && isCollapsibleTool(name) && (renderer.type === 'string' || 'ansi')`。**注意额外的 `isCollapsibleTool(name)` 守卫**:只有 read/search/list 的 string/ansi 结果会折叠Shell/Edit/Agent 等 non-collapsible 工具的结果**始终可见**。`ToolGroupMessage` 在 force 场景给触发工具**逐个**传 `forceShowResult=true``isUserInitiated || Confirming || Error || pending-agent || 终端子代理`)。
- **force 条件即"必须看见"的安全语义**——出错堆栈、确认提示、聚焦 shell、用户发起的工具都通过 `forceExpandAll` 不被分区折叠、且 non-collapsible 工具的结果天然不折叠(外加触发工具的 `forceShowResult`)。**核心符号 `forceExpandAll` / `isCollapsibleTool` 确实存在**#5661 合入实现);**不需要**独立的 `shouldForceFullDetail.ts` / `COLLAPSIBLE_CATEGORIES`——force 语义内联在 `forceExpandAll`,结果折叠门控内联在 `shouldCollapseResult`(见 §4.5)。
- **`fullDetail` 必须先于 `ToolGroupMessage` 的两个 early return 生效(实现要点,勿回归)**——`ToolGroupMessage` 在算 `forceExpandAll` **之前**有两个提前返回会绕开分区逻辑:(1) **纯并行 agent 组**`InlineParallelAgentsDisplay` 密集面板;(2) **已完成 memory-only 组**`Recalled/Wrote N memories` 徽章。这两条都会让 transcript fullDetail 下**仍不是完整展示**。因此两个 early return 均以 **`!fullDetail`** 守卫fullDetail 为真时跳过它们,让每个工具/agent 落到逐个 `ToolMessage``forceExpandAll=true` + `forceShowResult=true` + 不截断。已有回归测试覆盖memory-only 组在 fullDetail 下逐个渲染而非徽章)。
### 3.2 Ctrl+O = 打开/关闭 Transcript 全详情屏(独立 freeze 快照屏)
忠实还原 Claude Code 的 transcript已从 claude-code 源码取证):
- 任意时刻按 **Ctrl+O**:进入 **alternate screen buffer**DEC `1049``\x1b[?1049h`)接管整屏,渲染一个**冻结快照**:定格进入那一刻的历史,**解除 UI 层高度/行数截断**(思考全文、工具输出尽量完整),支持上下/翻页/Home/End 滚动。⚠️ "完整"只指 UI 层——**core 层 `truncateToolOutput` 已截断的内容无法从 UI history 恢复**(见 §4.4),不是字面"全文"。
- **冻结快照语义(含 pending存长度而非克隆 history**qwen-code 的历史是**两段**——已落定的 `history: HistoryItem[]``UIStateContext.tsx:45`)与流式进行中的 `pendingHistoryItems``:123`,渲染时以负 id 拼接,`MainContent.tsx:456-461`。Claude Code 的 freeze 实际只存两个数字 `{ messagesLength, streamingToolUsesLength }`、render 时 slice而非 entry-time 克隆。**qwen-code 据此同时冻结两段,但用最省的形式**:已落定 history **只存长度** `historyLength`render 时 `history.slice(0, historyLength)`,不克隆整个 history流式 `pendingItems: [...pendingHistoryItems]` **存浅副本**pending 是临时区、会被后续重写或清空必须副本才能定格那一刻形态。transcript 渲染 `history.slice(0, historyLength)` 拼接**进入那一刻定格的** pending 快照。后台后续新增的 history / pending **均不进入** transcript保证定格不抖动。
- **不影响主屏**:后台对话/流式继续运行(只是不渲染输入框/spinner退出时 `AlternateScreen` 卸载写 `EXIT_ALT_SCREEN` 还原 normal buffer再经一次 `refreshStatic()` 把当前完整 history 重绘到主屏(见 §4.4——**不是字面"原样不动"**,而是退出时统一重绘一次,保证无重复/无缺失/scrollback 不破坏)。
- **退出键**`Esc` / `q`less 风格)/ `Ctrl+C` 关闭;再按 **Ctrl+O** 亦 toggle 关闭。退出后回到主屏,可看到 transcript 打开期间后台新增的流式内容(主屏 Static 一直在追加,只是被 alt-screen 暂时遮住)。
- 行内 `(ctrl+o to expand)` 提示语义**统一为"按 Ctrl+O 进入 transcript 查看完整上下文",而非"此处被截断"**。注意思考块摘要恒带该提示(无论原文长短),工具输出仅在被高度约束截断时带 `+N lines`——两者提示触发条件不同,属预期(见 §7 #7)。
> 取证claude-code `ink/components/AlternateScreen.tsx``termio/dec.ts:16``ALT_SCREEN_CLEAR: 1049`)、`screens/REPL.tsx:1325/4184/4381`frozenTranscriptState + slice`keybindings/defaultBindings.ts:160-169``escape/q/ctrl+c → transcript:exit`)。
### 3.3 与 Ctrl+S`SHOW_MORE_LINES` / `constrainHeight`)的关系
- `SHOW_MORE_LINES`(当前 Ctrl+S维持不变它解除**当前 pending 区**的高度约束,属于"流式输出时临时看更多行",与 transcript 是正交的两件事。
- 本方案**不动 Ctrl+S 绑定**,避免一次改动面过大。(备选:未来可评估是否把 `SHOW_MORE_LINES` 也并入 transcript但本期不做。
### 3.4 实现效果截图VHS 自动化捕获)
下列两张为本分支构建(`node dist/cli.js --yolo`在固定虚拟终端1400×900 / FontSize 14下、对同一会话先后捕获的 before/after`qwen-code-mac-autotest` skill 录制(`session.tape` 可复现)。会话提示为:列文件 → 读 `README.md` → grep `export` → 一句话总结,触发 list/read/grep 三个**可折叠**工具。
**主视图默认基线§3.1**——三个 read/search/list 工具折叠为单行分区摘要 `✔ Searched 1 pattern, read 1 file, listed 1 directory`,思考块折叠为 `Thought for Ns (option+t to expand)`
![主视图:工具折叠为单行摘要](assets/main-view-collapsed.png)
**Ctrl+O Transcript 屏§3.2 + §4.5 `fullDetail`**——alt-screen 全屏header「完整记录」、footer「Esc/q 关闭 ↑↓ 滚动 PgUp/PgDn Ctrl+Home/End」同一会话下三个工具从主视图的**单行合并摘要**拆为**各自独立的行**,思考块解除折叠(`option+t to collapse`)并显示全文:
![Ctrl+O Transcript逐工具展开§4.9 实现前)](assets/ctrl-o-transcript-expanded.png)
> ⚠️ **该截图为 §4.9 实现前的状态**:此处 read/search/list 工具仍只显示**摘要级**结果(`Listed 3 item(s)` / `Found 4 matches` / `读取文件 README.md`**尚未显示完整明细**目录条目、grep 命中行、文件全文)。即 §4.5 的 `fullDetail` 只解除了"分区折叠 / 结果折叠 gate / 高度约束",但 collapsible 工具的 `returnDisplay` 本身只是摘要——完整明细的数据层透传是 §4.9**merge blocker**),落地后将**重新录制**该截图以反映真正的"全详情"。
>
> 对比要点:`fullDetail``forceExpandAll` 一处并入即同时解除分区折叠、逐工具结果折叠 gate 与高度约束§4.5),无需触碰已不存在的 `showCompact`。i18n 中文键(`完整记录` / `关闭` / `滚动` / `列出文件` / `读取文件`)均正确渲染。
---
## 4. 架构设计
### 4.1 移除残留的全局 compactMode#5661 partition 基线之上净删除)
> **范围澄清**#5661 已经把工具渲染基线切到 type-based partition 模型,**且 `compactMode`#5661 中已不影响工具渲染**`forceExpandAll` 不引用 `compactMode``compactToggleHasVisualEffect` 已被 #5661 改为只探测 `gemini_thought`,不再探测 `tool_group`)。因此本 PR 的删除范围比早期设想**更小**:只删 #5661 之后**仍然残留的全局二态开关**context/settings/binding/i18n**不触碰** `ToolGroupMessage` 的分区决策(无 `showCompact`、无 `compactMode ||` 项可删),并**保留 `CompactToolGroupDisplay` 与整个 type-based partition 机制**。
删除以下概念及其全部引用(清单见 §5
- `CompactModeContext` / `CompactModeProvider` / `useCompactMode`
- `compactMode` / `compactInline` / `setCompactMode` 状态与 settings 项(`settingsSchema.ts`**保留 web shell 侧 `ui.compactMode` 透传**——它是独立 surface
- `Command.TOGGLE_COMPACT_MODE` 命令与 Ctrl+O 旧绑定
- `compactToggleHasVisualEffect` 及其所在的 `mergeCompactToolGroups.ts`(移除全局 compact 后无人引用 → 整文件删除)
- compact 相关 i18n 文案9 语言)
- `compactMode` 对**思考块展开**的残留影响(`HistoryItemDisplay``expanded` 从依赖 `compactMode` 改为依赖 `fullDetail`/`thoughtExpanded`§4.5/§4.7
**保留(属 #5661 type-based partition 基线,本 PR 不动)**
- `CompactToolGroupDisplay` 及其 `ToolCategory` / `TOOL_NAME_TO_CATEGORY` / `CATEGORY_ORDER` / `getToolCategory` / `buildToolSummary` / `isCollapsibleTool` 等分区符号;
- `ToolGroupMessage``forceExpandAll` + `collapsibleTools`/`nonCollapsibleTools` 分区决策(**不删、不改**,仅在其上叠加 `fullDetail → forceExpandAll=true`
- `ToolMessage``forceShowResult` prop 与 `shouldCollapseResult`(含 `isCollapsibleTool(name)` 守卫)折叠 gate。
> 注意:#5661`forceExpandAll = hasConfirmingTool || hasSubagentPendingConfirmation || hasErrorTool || isEmbeddedShellFocused || isUserInitiated || hasTerminalSubagent` 已经承载了"出错/待确认/用户发起/聚焦 shell/终端子代理 必须完整展示"的安全语义——本 PR **保留不动**,无需再迁移到任何新文件。
### 4.2 新增 Transcript 屏状态机 + alt-screen 能力
**alt-screen 能力已有现成组件可复用**qwen-code 用的是上游官方 **`ink ^7.0.3`**(注意:与 gemini-cli **不同包不同大版本**——gemini-cli 用 fork `npm:@jrichman/ink@6.6.9`(v6)**不要**再把两者当同版本看待)。更关键的是,**main 已落地可直接复用的 `packages/cli/src/ui/components/AlternateScreen.tsx`PR #5627**,无需新建、无需移植 hook、无需引入 ink fork
- **复用现成组件**`AlternateScreen.tsx``useEffect``writeRaw(ENTER_ALT_SCREEN + CLEAR + HIDE_CURSOR)`,卸载/`process.on('exit')``writeRaw(SHOW_CURSOR + EXIT_ALT_SCREEN)`;内部用 `useTerminalOutput()`/`useTerminalSize()`。transcript 只需用 `<AlternateScreen>` 包裹 `TranscriptView` 即可获得"进入时进 alt-screen、卸载时回 normal buffer"的完整生命周期。
- ❌ **不用** ink `render()``alternateScreen: true` 整应用选项——那会让**整个 app 常驻 alt-screen**,丢掉 qwen-code 默认主视图赖以为生的**终端原生 scrollback**,不符合"主屏保持干净、仅 transcript 接管整屏"的需求。
- ⚠️ **VP 模式(`useTerminalBuffer`)已常驻 alt-screen必须用 `disabled` prop 避免 double-enter**:当 `settings.merged.ui?.useTerminalBuffer` 开启时ink root 自身已通过 `render()` 占有 alt-screen`gemini.tsx:367` `const useVP = settings.merged.ui?.useTerminalBuffer ?? false;``:379` `alternateScreen: useVP`)。此时 transcript 若再写一次 `?1049h` 就会 double-enter破坏 buffer 状态。`AlternateScreen.tsx` 正为此带了 `disabled?: boolean` prop其注释"Skip escape writes when the root Ink renderer already owns the alt screen (VP mode)")。因此 transcript 一律以 **`<AlternateScreen disabled={useVP}>`** 包裹:
- 非 VP 模式(`useVP=false`):组件正常写 `ENTER_ALT_SCREEN`/`EXIT_ALT_SCREEN`,进出 alt-screen
- VP 模式(`useVP=true`):传 `disabled` 跳过转义写入,因为 ink root 已在 alt-screentranscript 直接在该 buffer 内以替换主内容树的方式渲染。
- **降级 / 可用性判定收敛**:不再需要模糊的 `isAltScreenSupported()` 启发式判定。判定收敛为两条明确依据——(1) **是否已在 alt-screen 由 `useVP` 决定**(决定是否传 `disabled`(2) **非 TTY 防护**。⚠️ **现状澄清(取证)**`AlternateScreen.tsx` 当前**并没有** `process.stdout.isTTY` 防护(`useEffect` 内无条件 `writeRaw(ENTER_ALT_SCREEN…)`)。但 TUI 本身只有 `interactive` 为真才渲染,无 prompt 时 `interactive = process.stdin.isTTY ?? false``config.ts:1532`)——**非 TTY 默认根本不进交互渲染**TranscriptView/AlternateScreen 不挂载;唯一边角是显式 `-i`(强制 interactive 而 stdout 可能非 TTY。**待实现**:给 `AlternateScreen` 补一个 `process.stdout.isTTY` guard写转义前判定非 TTY 不接管整屏、退化为普通 buffer 内渲染),对齐仓库既有约定(`startInteractiveUI.tsx:77/81``notificationService.ts:53` 等均在写终端转义前判 `isTTY`)。改动极小、属"对齐约定的兜底",并补对应单测。
状态(**single source of truth`AppContainer``useState`,本地持有、顶层消费——不 surface 到 broad `UIStateContext`**
> **设计取舍(取证:与 ThinkingViewer 一致 + claude-code REPL-local + gemini-cli 先例)**transcript 是"由全局键 Ctrl+O 开、在顶层 layout 消费"的单一 UI 态,**没有深层消费者**。本仓库最近的同类 overlay `ThinkingViewer` 即把 canonical 放在 `AppContainer` 本地 `useState``thinkingViewerData`),只把最小 `open` action 经**专用** `ThinkingViewerContext` 下传,**不**塞进 broad `UIStateContext`claude-code 的 transcript 屏状态也留在 `REPL` 顶层本地。故 transcript 同样**留 `AppContainer` 本地**,在 `AppContainer` 顶层 JSX 里像渲染 `<ThinkingViewer>` 一样条件渲染 `<TranscriptView>`**不**经 `UIStateContext`/`UIActionsContext` 投影。(注:**实现代码已如此**——`transcriptFreeze``AppContainer` 本地 useState`UIStateContext` 内无任何 transcript 字段;早期文档写"surface 到 UIStateContext"有误,此处纠正。)
- **canonical本地**`AppContainer``useState` 承载 `transcriptFreeze: { historyLength: number; pendingItems: HistoryItemWithoutId[] } | null``HistoryItemWithoutId``pendingHistoryItems` 的元素类型),`isTranscriptOpen = transcriptFreeze != null` 直接派生。
- **action本地闭包**`openTranscript()`(拍双段快照:`setTranscriptFreeze({ historyLength: history.length, pendingItems: [...pendingHistoryItems] })`/ `closeTranscript()`(置 null/ `toggleTranscript()`——均为 `AppContainer` 本地回调由全局键处理§4.3)直接调用,无需经 `UIActionsContext`
- **快照时机与语义**:每次 `openTranscript()` 都**重新拍**当前快照;`closeTranscript()` 清空。即 transcript 定格在"本次打开那一刻",关闭再开会刷新到最新——不是永久定格在首次。注意快照对**已落定 history 只存长度**`historyLength`render 时 `history.slice(0, historyLength)`,对齐 Claude Code 存 `messagesLength` 而非克隆),对**流式 pending 区存浅副本**`[...pendingHistoryItems]`,因 pending 是临时区会被后续重写/清空,必须存副本才能定格那一刻的形态)。**不是克隆整个 history**。
- **`isTranscriptOpen` 不并入 `dialogsVisible` 聚合**(见 §4.3 的 Esc/键位分析——并入会误伤"Responding 且无 dialog 才能 Esc 取消请求"等逻辑composer 的屏蔽由 transcript 走 alt-screen 接管整屏天然达成,无需依赖 `dialogsVisible`
### 4.3 Ctrl+O 键处理改写
`AppContainer.handleGlobalKeypress` 中:
```ts
// 删除TOGGLE_COMPACT_MODE 分支
// 新增:
} else if (keyMatchers[Command.TOGGLE_TRANSCRIPT](key)) {
toggleTranscript(); // open <-> close
}
```
- 新增 `Command.TOGGLE_TRANSCRIPT = 'toggleTranscript'`,默认绑定 `[{ key: 'o', ctrl: true }]`
**键位归属(避免双重处理竞态)**`KeypressContext` 是**广播、无 consumed flag**`KeypressContext.tsx:655`,所有 `useKeypress` 订阅者都会收到同一按键)。因此必须**单一 owner**,规则如下:
- **Ctrl+O 只由全局 `handleGlobalKeypress` 处理**toggle 语义对开/关都成立)。**TranscriptView 自身的 `useKeypress` 绝不处理 Ctrl+O**,否则一次按键被两处响应 → 关了又开的竞态。
- **Esc / q / Ctrl+C 关闭 transcript由全局 `handleGlobalKeypress` 处理,且必须是 `handleGlobalKeypress` 的【最最前面、第一个】分支**——⚠️ 注意现有第一个分支就是 `Command.QUIT`Ctrl+C`AppContainer.tsx:3104`),第二个是 `Command.EXIT`Ctrl+D`:3121`),第三个才是 `ESCAPE``:3132`),而 `ESCAPE` 分支顶部还有 vim 守卫 `if (vimEnabled && vimMode==='INSERT') return;`。因此 transcript 关闭分支必须**早于全部这些**——早于 QUIT/Ctrl+C、早于 EXIT、早于 ESCAPE 分支及其 vim INSERT 守卫——短路:
```ts
const handleGlobalKeypress = (key) => {
// 必须是整个 handleGlobalKeypress 的第一个分支:
// 早于 QUIT(Ctrl+C) / EXIT(Ctrl+D) / ESCAPE 分支(及其 vim INSERT 守卫)
if (isTranscriptOpenRef.current &&
(key.name === 'escape' || key.name === 'q' || (key.ctrl && key.name === 'c'))) {
closeTranscript(); return;
}
if (keyMatchers[Command.QUIT](key)) { ... } // 现有 :3104
...
```
否则transcript 打开时按 Ctrl+C 会先触发退出/`ctrlCPressedOnce`;按 Esc 会被 vim INSERT 守卫吞掉而关不掉 transcript。因为本分支由 `isTranscriptOpenRef` 守卫,仅在 transcript 打开时生效,对 vim 正常编辑无影响。**测试**`Ctrl+C closes transcript and does NOT set quit/ctrlCPressedOnce``Esc closes transcript even when vim INSERT mode is active`
- **`q`(普通字母键)为何安全**:该分支被 `isTranscriptOpenRef` 守卫,仅在 transcript 打开(此时 composer 被 alt-screen 接管、无文本输入焦点时匹配transcript 关闭时 `q` 直接落到正常输入流,不受影响。无需新增 `Command` 绑定inline 匹配即可。
- 因为 `isTranscriptOpen` **不并入 `dialogsVisible`**,所以**不走** `useDialogClose`transcript 的 Esc 在上面的全局前置分支里独立处理(`useDialogClose` 保持原样不动)。
- 为避免闭包过期,新增 `isTranscriptOpenRef`(仿现有 `dialogsVisibleRef` 模式,`AppContainer.tsx:2425`)。
### 4.4 Transcript 屏组件(复用底座)
新增 `components/TranscriptView.tsx`,外层包**复用现有**的 `<AlternateScreen disabled={useVP}>`§4.2VP 模式下 ink root 已占 alt-screen`disabled` 跳过转义写入;非 VP 模式正常进出 alt-screen非 TTY 由**待补的** `process.stdout.isTTY` guard 退化为普通 buffer 内渲染,见 §4.2
- **数据(双段冻结快照)**`[...history.slice(0, freeze.historyLength), ...freeze.pendingItems]` —— history 前缀 + 进入那一刻定格的 pending 副本(见 §3.2)。后台后续新增项不进入,避免滚动抖动。
- **渲染容器(注意 gating**`ScrollableList`/`VirtualizedList` **已存在于 main**(标准 Ink 7 组件,非 Ink fork`ScrollableList.tsx` 具备 `scrollBy/scrollTo/scrollToEnd/scrollToIndex` 与 PageUp/Down/Home/End/滚轮),但**当前仅在 `useTerminalBuffer`VP/virtual-viewport 模式)下被 `MainContent` 使用**——默认主视图走 `<Static>` + pending不用它们。transcript **无条件复用**这两个组件(与 `useTerminalBuffer` 解耦,自管滚动容器),因此不受默认 Static 路径限制。⚠️ 这些组件相对较新长会话下的滚动性能、键盘滚动、resize 重排须纳入测试§8不能假设"零成本复用"。
- **`estimatedItemHeight`(虚拟滚动估高,必须调大/自适应)**`MainContent` 当前对 `VirtualizedList` 用恒定 `estimatedItemHeight=3`。transcript 以 `fullDetail` 渲染(思考全文、工具全输出),**每项远高于 3 行**,若沿用 3 会导致滚动条/定位失真、PageUp/Down 跳幅错乱。transcript 必须用**更大或自适应的 `estimatedItemHeight`**(按内容类型估算,或交由 `VirtualizedList` 的实测高度回填机制修正。该估高纳入测试§8
- **完整展开(`fullDetail` prop**:为渲染路径引入显式 `fullDetail` 替代原先靠 `!compactMode` 推导。`fullDetail=true` 时:思考块 `expanded={true}`;工具输出**同时**满足两点才算真正不截断——(a) `availableTerminalHeight={undefined}`(验证 `ToolGroupMessage.tsx:357-365` 据此使 `availableTerminalHeightPerToolMessage` 为 undefined(b) 关闭 `MaxSizedBox` 的高度约束、`sliceTextForMaxHeight`、shell 的 `shellStringCapHeight/shellOutputMaxLines``ToolMessage.tsx:67-74,750-756`)。⚠️ **保留按字符数的性能上限**qwen-code 侧为工具输出截断阈值 `DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD` ~25000**非** gemini-cli 的 `SlicingMaxSizedBox`)——区分"行高截断为显示transcript 解除)"与"字符上限(为性能,始终保留)",避免单条超大输出拖垮虚拟滚动。
- **两层截断的边界(重要,避免过度承诺)**:截断发生在**两层**——(1) **core 层** `truncateToolOutput``packages/core/src/utils/truncation.ts`,被 `shell.ts`/`mcp-tool.ts` 调用)在工具产出时就按 `truncateToolOutputThreshold/Lines` 截断,写进 history 的 `resultDisplay` 已是截断后的,原文可能仅以临时 output 文件存在;(2) **UI 层** `MaxSizedBox`/`sliceTextForMaxHeight` 等按终端高度截断。**transcript 只能解除 UI 层**——core 层已丢弃的内容 UI 拿不回来。规则transcript 对 core-截断项**保留其 truncation marker**(如"… output truncated, N lines omitted"),明示不可恢复;"读取 core 保存的 output 文件并展示"列为**后续可选增强**不在本期范围。i18n/文案不得宣称"查看完整工具输出",改为"查看完整上下文(不含已被 core 截断的部分)"。
- **键盘分工**TranscriptView 自身 `useKeypress``isActive: isTranscriptOpen`**只处理滚动键**(上下/翻页/Home/End。**关闭键Esc/q/Ctrl+C/Ctrl+O一律由全局 `handleGlobalKeypress` 处理**§4.3TranscriptView 不碰,杜绝广播双响应。
- **渲染模型(明确单一策略,消除歧义)**:单 ink root 只能线性渲染一个树。transcript 打开时,顶层 layout **以 `<AlternateScreen disabled={useVP}>` 包裹的 `TranscriptView` 替代主内容树**`MainContent` 从渲染中卸载,**不再绘制**);后台对话/流式只更新**数据层**`history`/`pendingHistoryItems` 继续增长),但**不被绘制**。退出时:`AlternateScreen` 卸载写 `EXIT_ALT_SCREEN`VP 模式由 `disabled` 跳过)回到 normal buffer其中仍是进入前那帧 `<Static>` 旧内容)→ **必须再调用一次 `refreshStatic()`**(清屏 + 重挂 Static key把当前完整 history **一次性重绘**,从而保证退出后主屏**无重复回放、无缺失、无错位**。这是 alt-screen + Static append-only 模型下的正确收尾,**不是**"原样不动"。
- **transcript 打开期间抑制/守卫 `refreshStatic`(避免污染主屏 scrollback**`useResizeSettleRepaint` 等内部路径(如 resize可能在 transcript 打开期间触发 `refreshStatic`——若放任,它会向 **normal-buffer 的 scrollback** 写入/重排主内容,而此刻屏幕正被 alt-screen 占据,导致退出后主屏错位或 scrollback 被污染。规则:**用 `isTranscriptOpenRef` 守卫 `refreshStatic`transcript 打开期间一律跳过**;退出 transcript 时再统一做一次 `refreshStatic()` 重绘主屏(即上一条)。如此可澄清"主屏 normal-buffer scrollback 不被 alt-screen 期间的写入污染"。**测试**:打开 transcript 期间后台完成一轮工具调用 / 触发 resize退出后主屏该轮内容恰好出现一次、scrollback 不被破坏。
- **页眉/页脚**:标题(如 `Transcript — ↑↓ scroll · Ctrl+O/Esc/q to close`),初始 `initialScrollIndex` 滚到底部(对齐 Claude Code 打开即在最新处)。
### 4.5 在 #5661 基线上引入 `fullDetail` 联动transcript 路径置 `forceExpandAll=true`
#5661 的 type-based partition 基线已经把"哪些工具折叠成分区摘要"`forceExpandAll` + `isCollapsibleTool` 分区)与"是否折叠已完成结果"`shouldCollapseResult` gate这两层决策做好了。本 PR **不重写这两层**,只新增一个显式的 `fullDetail` 信号,让 transcript 路径能**整体解除** partition 折叠 + 结果折叠 + 高度约束。**关键利好**type-based 基线下transcript 只需把 `fullDetail` 并入 `forceExpandAll``forceExpandAll = fullDetail || hasConfirmingTool || ...`)——一处即同时禁用分区(所有工具进 `nonCollapsibleTools` 逐个渲染)并使逐工具 `forceShowResult` 联动解除结果折叠,**不需要去改一个已不存在的 `showCompact`**。
- **#5661 已就位的两层决策(保留)**
- `ToolGroupMessage``forceExpandAll``fullDetail || hasConfirmingTool || hasSubagentPendingConfirmation || hasErrorTool || isEmbeddedShellFocused || isUserInitiated || hasTerminalSubagent`)为真 → 跳过 type-based partition所有工具逐个 `ToolMessage`;为假 → 按 `isCollapsibleTool` 拆分 collapsible摘要/ non-collapsible逐个
- `ToolMessage``forceShowResult` prop 经 `shouldCollapseResult = !forceShowResult && status === Success && isCollapsibleTool(name) && (string|ansi)` 决定已完成 collapsible 工具的 string/ansi 结果是否折叠。`ToolGroupMessage` 在 force/fullDetail 场景对工具传 `forceShowResult={fullDetail || isUserInitiated || Confirming || Error || pending-agent || 终端子代理}`
- **以上 force 条件即承接了"出错/待确认/用户发起/聚焦 shell/子代理 必须完整可见"的安全语义**——本 PR 原样保留,**不新增** `shouldForceFullDetail.ts`**不迁移**任何判定。
- **思考块**`HistoryItemDisplay` 把思考块 `expanded` 从依赖 `compactMode` 改为 `resolvedThoughtExpanded = fullDetail || (thoughtExpanded ?? contextThoughtExpanded)`§4.7——transcript 路径 fullDetail、main 的 Alt+T per-block 开关任一为真即展开;主视图常态 `fullDetail=false`
**`fullDetail` 的联动transcript 路径 = true 时同时生效)**
`fullDetail=true`transcript时同时做到以下几点才算真正"逐个工具、完整不截断"
1. **置 `forceExpandAll=true`**(把 `fullDetail` 并入 `forceExpandAll`)——解除 #5661 的 type-based partition所有工具进 `nonCollapsibleTools` 逐个渲染 `ToolMessage`(而非把 collapsible 部分聚合成分区摘要行);
2. **逐工具传 `forceShowResult=true`**(把 `fullDetail` 并入 `forceShowResult`)——`shouldCollapseResult``!forceShowResult` 不再命中,已完成 collapsible 工具的 string/ansi 结果也展开non-collapsible 结果本就显示);
3. **解除 `MaxSizedBox` 高度约束**——`ToolGroupMessage` 在 fullDetail 时给每个 `ToolMessage``availableTerminalHeight={undefined}``ToolMessage``availableHeight` 随之为 undefined`MaxSizedBox maxHeight` 无界、shell 的 `isCappingShell`(含 `!forceShowResult`)解除。⚠️ **保留按字符数的性能上限**core 层 `truncateToolOutput`)——区分"行高截断为显示transcript 解除)"与"字符上限(为性能/core 层,始终保留)"
4. **思考块 `expanded`**——transcript 路径 `resolvedThoughtExpanded``fullDetail` 分量为 true见上
**`fullDetail` 数据流(谁算、谁传、默认值)**
- `fullDetail``HistoryItemDisplay` / `ToolGroupMessage` 的显式 prop**由父级按渲染上下文计算并传入**,不从 context 读(避免再造一个隐式全局态)。
- **主视图**`MainContent`):不传 `fullDetail`(默认 `false`)。"哪些工具组必须完整展示"完全由 #5661 已内联在 `ToolGroupMessage``forceExpandAll`/`forceShowResult` 条件决定(`embeddedShellFocused`/`activeShellPtyId` 等输入 `HistoryItemDisplay` 本就持有并下传),**无需在 `MainContent` 另算 force 布尔**。
- **transcript**`TranscriptView`):对所有 item 恒传 `fullDetail={true}`,触发上面联动。
> 这样"主视图 vs transcript"的差异收敛为一个干净的布尔 `fullDetail`:主视图沿用 #5661 的 type-based partition + force 安全语义transcript 用 `fullDetail`(并入 `forceExpandAll` + `forceShowResult`)把这两层连同高度约束一并解除。**不引入新文件、不迁移判定、不改 #5661 的分区逻辑**。
---
### 4.6 Transcript 打开期间的后台交互(确认弹窗 / resize
transcript 走 alt-screen 接管整屏,但后台对话仍在跑——这带来几个必须明确的交互规则:
1. **阻塞确认/对话框(防死锁,必须处理,覆盖全部种类)**:阻塞确认**不止 `WaitingForConfirmation` 一种**——`DialogManager.tsx` 还渲染 `shellConfirmationRequest`(ShellConfirmationDialog)、`loopDetectionConfirmationRequest`(LoopDetectionConfirmation)、`confirmationRequest`(ConsentPrompt)、`confirmUpdateExtensionRequests`(ConsentPrompt)、`providerUpdateRequest`(ProviderUpdatePrompt) 等。任一种被 alt-screen 遮住都会让用户看不到、无法响应 → **死锁**。规则:**当任一阻塞确认/对话框需要用户输入时自动 `closeTranscript()`**(在 `AppContainer``useEffect` 监听这些请求状态 + `streamingState===WaitingForConfirmation` 的并集,任一为真即退出 alt-screen让用户看到并响应。这比"在 alt-screen 内重渲染各种确认框"简单且无歧义。**测试需逐一覆盖上述每种阻塞确认触发自动关闭**§8
2. **窗口 resize**transcript 打开时改终端尺寸,`VirtualizedList` 需按新宽度重排、重算行高。复用其既有 resize 响应即可;退出 transcript 时 §4.4 的 `refreshStatic()` 也会让主屏重排。测试需覆盖"transcript 内 resize 后滚动位置不崩"。
3. **消息队列自动提交(守卫,避免静默发送)**`AppContainer.tsx` 的消息队列排空drain逻辑有 `if (dialogsVisible) return;` 守卫,避免 dialog 打开时静默自动提交排队消息。由于 `isTranscriptOpen` **不并入 `dialogsVisible`**§4.2/§4.3),该守卫不会覆盖 transcript 打开态。规则:**在该 drain 逻辑补 `|| isTranscriptOpenRef.current`(或等价条件)**,确保 transcript 打开期间队列消息**不被自动发送**,待退出 transcript 后再正常排空。**测试**transcript 打开期间排队消息不被自动提交,退出后恢复排空。
### 4.7 与 main 既有 per-block 思考机制的关系(互补共存,非冲突)
合并上游 main 后,仓库已存在一套**块级per-block思考展开机制**,与本方案的全会话 transcript **互补共存**、各解决不同诉求:
- **main 已提供的 per-block 能力**
- `ThoughtExpandedContext` —— `Alt+T``Command.TOGGLE_THINKING_EXPANDED`)就地切换**单个思考块**的展开/折叠;
- `ThinkingViewer` / `ThinkingViewerContext` —— 查看单个思考块的**全文**
- 相关数据流:思考块的 `thoughtExpanded` / `thinkingFullText` props、`buildThinkingFullTextMap``ClickableThinkMessage`
- **合并后思考块 expanded 的判定**:思考块在渲染时 `expanded = isPending || fullDetail || resolvedThoughtExpanded`——三个来源任一为真即展开:
- `isPending`流式中实时全文§4.5
- `fullDetail`transcript 路径恒为 true§4.5
- `resolvedThoughtExpanded`main 的 Alt+T per-block 开关。
本方案的 `fullDetail` 改造**只接管前两者**,不触碰 `resolvedThoughtExpanded`——per-block 机制原样保留。
- **为何不冲突(正面回应 reviewer "用户原始需求是 per-block" 的关切)**reviewer 关切的"就地展开单个思考块"诉求,**已由 main 的 Alt+T / ThinkingViewer 满足**;本方案的 transcript 解决的是**另一维度**——"对整段会话做完整回顾"alt-screen 冻结快照、全部块以 fullDetail 渲染、可滚动。两者目标不同、入口不同Alt+T vs Ctrl+O、作用范围不同单块 vs 全会话),**正交且互补**,不存在二选一。
### 4.8 鼠标点击就地展开 block第二交互入口
> **📌 范围已定:本功能为 follow-up不在当前 PR 交付。** 当前 PR 只交付 **Ctrl+O transcript**。鼠标点击展开移到后续独立 PR(VP-only MVP)。理由(评估自真实代码)
>
> 1. **没有 per-tool 点击目标**#5661 type-based partition 把折叠的 read/search 工具**聚合成单行摘要**——折叠态下不存在"单个工具块"可点。点击粒度须从"per-tool"重定为"**点摘要行→展开整组**"(`forceExpandAll`),这是设计变更不是直接照搬。
> 2. **工程量 ~250400 行 / 45 文件**`ToolExpandedContext` + AppContainer 接线 + `ClickableToolMessage`(不能在 `.map()` 里调 `useMouseEvents`,须抽组件,模板 `ClickableThinkMessage` 即 59 行) + ToolGroupMessage 接线 + 鼠标命中测试(需 mock `measureElementPosition`)。
> 3. **已知风险**SGR 鼠标追踪与终端原生文本选择冲突([[project_vp_text_selection]]),transcript/主视图内是否启用点击需单独权衡。
>
> 下文保留**设计草案**供后续 PR 用;当前 PR 的 §1 目标#4、状态表与 §9 末注已把鼠标点击展开标注为 follow-up§4.9 / commit 4 现为完整明细透传,非鼠标)。
除键盘Ctrl+O→transcript、Alt+T→思考块外,(后续 PR提供**鼠标点击**作为就地展开的第二通道。以下为该 follow-up 的设计草案。
**与 [#5751](https://github.com/QwenLM/qwen-code/pull/5751) 的分工(依赖关系,不重复造轮子)**
- **#5751(已合入 main2026-06-23提供"鼠标基础设施"**:把终端鼠标追踪从 per-component 启停改为**全局引用计数**(修复"折叠块卸载时误关鼠标,导致 VP 下鼠标失效")、为 `ScrollableList`/`VirtualizedList` 增加滚轮滚动与**滚动条点击拖拽**。涉及 `useMouseEvents.ts``ScrollableList.tsx``VirtualizedList.tsx`。本设计基于其已合入的基础设施。
- **本 PR 不改这些鼠标底座文件**,避免与 #5751 冲突/重复;待 #5751 合入 main 后 rebase在其稳定的鼠标基础上叠加下述"点击工具块展开"。若 #5751 迟迟未合并,再评估 cherry-pick默认走依赖路径
- **思考块点击已由 main 的 `ClickableThinkMessage` + #5751 提供**(点击折叠思考行→打开 ThinkingViewer本 PR **不重做**,仅确保与新基线/transcript 共存。
**follow-up 设计草案)点击工具调用 block 就地展开/折叠明细**
复用 main 已有的成熟范式(`ClickableThinkMessage` + `measureElementPosition` + `useMouseEvents`),把它从"思考块"推广到"工具块"。**对齐点per-tool 点击展开 = 就地把该工具/组切到 `forceExpandAll=true`(不被 partition 折叠)+ `forceShowResult=true`**(用一个 per-id 展开态,命中后 toggle**复用 #5661 已有的 `forceExpandAll` / `forceShowResult` 机制**,不另造渲染分支:
1. **per-tool 展开态(新增)**:当前工具块**没有**用户可切换的单块展开态(#5661 的分区/结果折叠是按 force 规则自动算的)。新增一个轻量 context仿 gemini-cli `ToolActionsContext` / main `ThoughtExpandedContext` 的形态):
- 状态:`expandedToolGroupIds: ReadonlySet<string>`(按 tool_group id 或 callId。canonical 落在 `AppContainer` useState。
- **下传走专用小 context**(仿 gemini-cli `ToolActionsContext` / 本仓库 `ThinkingViewerContext`/`ThoughtExpandedContext` 的形态),暴露 `toggleToolExpanded(id)` / `isToolExpanded(id)`——**不**塞进 broad `UIStateContext`/`UIActionsContext`。与 transcript§4.2纯本地、顶层消费、无深层消费者不同per-tool 展开**确有**跨层生产者(深层工具块点击 set+消费者(`ToolGroupMessage``isToolExpanded` 并入 `forceExpandAll`),故下传是正当的,但用专用 context 而非污染全局 UI 态聚合。
2. **接入 #5661 的两层机制(不新增第三条路径)**:命中点击展开的工具/组,在渲染时表现为——
- `ToolGroupMessage`:把 `isToolExpanded(id)` 并入 `forceExpandAll` 的"为真"分量(`forceExpandAll = fullDetail || isToolExpanded(id) || hasConfirmingTool || ...`)→ 解除 type-based partition逐个 `ToolMessage`
- `ToolMessage`:对该工具传 `forceShowResult=true``shouldCollapseResult``!forceShowResult` 不再命中),并配合解除高度约束。
- 即点击展开**复用** transcript fullDetail 联动里"`forceExpandAll=true` + `forceShowResult=true`"的同一对开关§4.5 #1/#2),只是作用域是单个被点工具/组、而非全会话。与思考块 `expanded` 的多源合成§4.7)对称。
3. **ToolMessage / ToolGroupMessage 的点击命中**:给工具**标题行**与**输出区**各挂一个可点击 Box`ref` + `measureElementPosition` 命中测试,照搬 `ClickableThinkMessage``left-press` + 坐标包含判定)。命中 → `toggleToolExpanded(id)`
- 命中区即"标题行"或"输出区"两块,点任一处都 toggle 该工具明细。
- `isActive` 守卫:仅在该工具**已完成且其结果被 `shouldCollapse` 折叠或输出被高度截断**(即存在"更多可看"时才挂点击监听避免给无可展开内容的块挂无效监听transcript 内不需要(已全展开)。
4. **VP/坐标对齐**VP 模式下 yogaNode 坐标即视口坐标,`measureElementPosition` 直接可用(与 `ClickableThinkMessage` 同一前提Static 模式因 append-only 不参与点击展开(点击展开主要服务 VP/可视区Static 仍可用 Ctrl+O 走 transcript
5. **不重做思考块点击**:折叠思考行的点击展开已由 main 的 `ClickableThinkMessage` + #5751 提供,本 PR 不重做,仅确保与新基线/transcript 共存。
6. **与 transcript 的关系**:点击工具块是"就地展开单个工具明细"(留在主视图,作用域单工具/组Ctrl+O 是"全会话回顾"(全部 item fullDetail——同思考块的 Alt+T vs Ctrl+O 一样,正交互补。
> 依赖说明:本功能的可用性前置 = #5751 的鼠标引用计数修复(否则 VP 下鼠标可能被某个折叠块的卸载误关)。设计与实现以"#5751 已在 main"为前提commit 顺序见 §9。
### 4.9 Transcript 全详情的数据层补全(消除"第二层折叠"中间态)
**背景**:本 PR 把 Ctrl+O 定义为"transcript 全详情屏"§3.2)——主视图保持 #5661 的精简分区摘要Ctrl+O 随时调出"完整记录",所有 item 以 `fullDetail` 渲染。设计**承诺** transcript 内工具输出**完整展开、不折叠、不按高度截断**;实现上由单一 `fullDetail` 信号并入 `forceExpandAll` + 逐工具 `forceShowResult` + `availableTerminalHeight=undefined` 达成§4.5)。
**问题验收实测§3.4**:用户的"三层折叠"模型卡在第二层——第一层(主视图)`✔ read 1 file, listed 1 dir` 分区摘要应保持第二层Ctrl+O 当前)每个工具一行 + **简短摘要**`列出文件 Listed 3 items` / `Grep Found 4 matches` / `读取文件 README.md`);而**最详细层缺失**实际目录条目、grep 命中行、文件全文)。`read_file` / `ls` / `grep` / `glob` 这类**可折叠只读工具**在 transcript 下只到摘要级,违背 "全详情" 语义。
**根因(数据层,非折叠开关)**经取证§4.5 的 `fullDetail → forceExpandAll / forceShowResult / availableTerminalHeight=undefined` 链路**全部正确生效**;问题在于前端根本拿不到完整明细:
- `ToolResult` 有两路内容(`packages/core/src/tools/tools.ts:443+``llmContent`"factual outcome",完整)与 `returnDisplay`(注释明确为 "user-friendly **summary**")。
- `ls` / `grep` / `ripGrep` / `read_file``returnDisplay` 只装摘要(`Listed N` / `Found N` / `''`),完整内容只进 `llmContent`(取证:`ls.ts` / `grep.ts` / `ripGrep.ts` / `read-file.ts` 的返回)。
- 前端工具显示结构 `IndividualToolCallDisplay``packages/cli/src/ui/types.ts:65`**只有 `resultDisplay`**,无完整内容字段;转换点 `useReactToolScheduler.ts:330` 只取 `response.resultDisplay`(摘要)。
- 完整内容虽以 `ToolCallResponseInfo.responseParts``turn.ts:116`)到达前端,但那是 `functionResponse` 包装,`partToString` / `getResponseTextFromParts` 都只取 `part.text`、**读不到 `functionResponse.response.output`**`partUtils.ts:61-69``generateContentResponseUtilities.ts:14`)——前端拿到的 parts 里有完整内容,但**没有现成 helper 把它取出来**。
**关键发现:完整明细其实已天然持久化(决定方案走向)**
- `convertToFunctionResponse(...)``coreToolScheduler.ts:679-714`)把**完整 `llmContent`** 写进 `functionResponse.response.output`string 直接放array 提取**全部** `text` 拼接media 进 `parts`)——即工具结果 parts 内**已含完整明细**。
- 持久化的 `ChatRecord.message` 存的就是 `response.responseParts`(取证:`recordToolResult(...)` 调用点 `coreToolScheduler.ts:4009``Session.ts:3334/4404` 等均传 `responseParts`),是**完整 functionResponse**,非摘要。
- 因此完整明细在三条路径都已就位、同源:**live**`trackedCall.response.responseParts`/ **resume**`record.message.parts``resumeHistoryUtils` tool_result case/ **ACP replay**`HistoryReplayer.replayToolResult` 已把 `message: record.message.parts` 传给 `emitResult`)。"持久化"在数据层**天然满足**,无需新增持久化字段(该前提以 §8 的"方案 Y 前提保护"测试守卫——断言 `message.parts` 的完整 `output` 经 recording / loadSession / resume / replay 四段不丢失、不误走 API `compressedHistory`**若失败则回退方案 X**)。
**方案 Y单一完整数据源 + 显示层提取,对齐 claude code**
claude code 的机制是"**存储层保留完整、显示层按 `verbose` 截断**",不拆 `llmContent`/`returnDisplay`resume 后 transcript 仍全详情(取证:`claude-code/src/screens/REPL.tsx:4185-4194,4381-4382,4402` frozen transcript = 内存 messages 快照 + `verbose=true``src/utils/toolResultStorage.ts``sessionStorage.ts` 持久化完整内容/文件引用。qwen 的完整内容既已在 parts 持久化,采用同构思路——**不新增持久化字段,从已存在的 functionResponse 集中提取完整文本来显示**。
- **不选 X新增 `contentForDisplay` 字段贯穿 core→序列化→回放→前端8 处)**:与已持久化的 `responseParts` **内容冗余**(同一完整内容存两份)、扩大持久化 schema 与迁移面。
- **不选 B逐工具改 `returnDisplay` 携带完整)**:违背 `returnDisplay`="summary" 语义、改多个工具。
- **不选"前端散撕协议"**:根因所述脆弱——但方案 Y 用**一个集中的 core helper** 提取,不在多处散撕。
**改动点(方案 Y**
1. **core 新增提取 helper** `getToolResponseDisplayText(parts: Part[]): string | undefined`(与 `getResponseTextFromParts` 并列)。**可实现的优先级规则**——媒体挂在 **nested `functionResponse.parts`**(非顶层,取证:`coreToolScheduler.ts:661-744``postCompactAttachments.ts:149-161``compactionInputSlimming.ts:205-213`):遍历顶层 parts → 对每个 `functionResponse` 读非空 `response.output` 文本拼接;再遍历其 **nested `functionResponse.parts`**,对 `inlineData`/`fileData` 输出占位(如 `<image: mimeType>`)、对 nested `{text}` 占位也保留compaction slimmer 会产生该形态);**若无 output 但有 nested media → 返回占位****output 与 media 都无 → 返回 `undefined` 让 UI 降级摘要**(避免 media-only `read_file` 显示空白或误用 "Tool execution succeeded")。**单一提取入口live/resume/replay 共用**。
2. **cli `IndividualToolCallDisplay``types.ts:65`)新增** `detailedDisplay?: string`——**派生值、不持久化**(每次从已持久化的 parts 提取)。
3. **live 提取**`useReactToolScheduler` success 分支:`detailedDisplay = getToolResponseDisplayText(trackedCall.response.responseParts)`**不二次截断**,见下"内存与上限")。
4. **resume 提取**`resumeHistoryUtils``tool_result` case:411-431已持有 `record.message.parts`)用同一 helper 提取。
5. **ACP replay 提取**`HistoryReplayer.replayToolResult`:259已把 `message: record.message.parts` 传给 `emitResult`;在 ACP→display 映射处(`resumeHistoryUtils` / `ToolCallEmitter`)用同一 helper 派生。**无需新增 ACP 协议字段**。
6. **渲染拆分(修正 `forceShowResult` 泄漏)**:给 `ToolMessage` **显式传 `fullDetail`**(由 `ToolGroupMessage` 下传)。仅当 **`fullDetail && isCollapsibleTool(name) && detailedDisplay`** 时,用 `detailedDisplay` 取代摘要 `resultDisplay`
- **关键**:把"解除折叠/高度"`forceShowResult`/`forceExpandAll`,可由 `isUserInitiated` / `Confirming` / `Error` / pending-agent / terminal-subagent 触发,见 `ToolGroupMessage.tsx:469-475`)与"切换到完整数据源"**仅** `fullDetail`**分离**。否则主视图里 user-initiated/error 等 force 场景会误显完整明细,违反"主视图不变"。
**内存与上限(不二次截断,符合"全详情"语义)**`detailedDisplay` **不**走 `compactStringForHistory` 的 32k 截断——那会把 Ctrl+O 变成"32k bounded preview"而非"全详情",与 §3.2/§3.4 承诺冲突(尤其 `read_file``maxOutputChars=Infinity`、自管理分页、免于 scheduler char 截断,见 `read-file.ts:385-390`,合法大读取会超 32k此时 `functionResponse.response.output` 内仍有 >32k 完整内容,不应被 UI 再裁)。`detailedDisplay` 直接 = `getToolResponseDisplayText(parts)` 的完整文本,其边界即 **core 已施加的** `truncateToolOutput`/工具自带分页UI 拿不回 core 已丢弃内容§4.5)——**UI 层不再叠加字符上限**。`detailedDisplay` 是派生值、不入库;内容已存在于 `responseParts`,仅多一份字符串引用。极大输出由 transcript `ScrollableList` 虚拟滚动按视口渲染(有界),与 claude code "存储留完整、显示层 `verbose` 决定" 同构。
**范围(按类别判定,不硬编码名单)**
- 以 **`isCollapsibleTool(name)`** 为准——类别级 `read/search/list`**含 `glob`/`FindFiles`**`getToolCategory` 把 GLOB 归 search`CompactToolGroupDisplay.tsx:74-95``glob.ts:267-280``returnDisplay` 同样只是 `Found N matching file(s)`)。**不**写死 `read_file/ls/grep`
- 主视图(非 `fullDetail`)、`run_shell_command``returnDisplay = result.output`,完整)/ `edit` / `write`(完整 diff**不变**。
**测试点(并入 §8**
- core helper`functionResponse.response.output` 提取、media 占位、空/缺失降级;
- **live + resume 两路都产出 `detailedDisplay`**,尤其 **resume/回放后 transcript 仍全详情**ACP 路经 `transformPartsToToolCallContent` 已带全文(非 TUI transcript无需 `detailedDisplay`
- `ToolMessage``fullDetail && collapsible && detailedDisplay` 用完整;**`forceShowResult 但非 fullDetail`(主视图 user-initiated/error仍用摘要**(回归主视图不变);
- 覆盖 `glob`(或 legacy search displayName不只 read/grep/list
- 旧 saved history`detailedDisplay` 提取自 parts旧记录的 parts 同样在 → 自然全详情;极旧无 parts 记录降级摘要。
## 5. 改动清单(按文件)
> 完整符号清单来自代码取证,下列为需改动文件与动作;行号以当前 main 为准,实现时以实际为准。
>
> **依赖基线(#5661本 PR 不重做)**#5661 已把工具渲染切到 type-based partition 模型。**`MainContent` 维持 `mergedHistory = visibleHistory`(无 cross-group 合并、无 `absorbedCallIds`/summary 吸收机制)**`tool_use_summary``HistoryItemDisplay` 中渲染为独立的 `● <summary>` 行(不吸收进表头)。`CompactToolGroupDisplay` 已被扩展为分区摘要渲染器并**保留**。本 PR 只在此之上删除残留的全局 `compactMode`context/settings/i18n并删除随之失去引用的 `mergeCompactToolGroups.ts`(含 `compactToggleHasVisualEffect`)。
> **已由合并 main 处理、本设计无需再单列的项**:旧 compact 渲染里的 `isDim` 暗淡样式已在实现侧随相关重构移除(合并 main 后不再存在),无需在改动清单中单独追踪。
### A. 删除 / 净移除
| 文件 | 动作 |
| ----------------------------------------------------- | ------------------------------------------------------ |
| `packages/cli/src/ui/contexts/CompactModeContext.tsx` | 删除整个文件(`CompactModeProvider`/`useCompactMode` |
> **不删 `CompactToolGroupDisplay`**:它是 #5661 partition 基线的核心摘要渲染器(`ToolCategory`/`CATEGORY_ORDER`/`buildToolSummary`),本 PR 保留。
> **删 `mergeCompactToolGroups.ts`**:移除全局 compactMode 后,`compactToggleHasVisualEffect`(其唯一 import 点是已被删除的 compact toggle 机制)与整文件不再被引用 → 整文件删除(含其测试)。#5661 的 type-based partition 不依赖此文件。
> **不新增 `shouldForceFullDetail.ts`**force 安全语义已内联在 #5661`forceExpandAll`/`forceShowResult`,无需抽出新文件(见 §4.5)。该文件从未实际存在。
### B. 修改
| 文件 | 动作 |
| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `packages/cli/src/config/keyBindings.ts` | 删 `TOGGLE_COMPACT_MODE`;加 `TOGGLE_TRANSCRIPT='toggleTranscript'`,默认绑 Ctrl+O保留 `SHOW_MORE_LINES`(Ctrl+S) 不动。启动迁移检测**当前不适用**——代码库无用户可配置 keybinding 覆盖(`keyMatchers` 恒用硬编码默认值),无残留绑定可扫描(见 §6 |
| `packages/cli/src/ui/keyMatchers.ts` | 同步增删 matcher |
| `packages/cli/src/ui/AppContainer.tsx` | 删 `compactMode/compactInline/setCompactMode` 状态、`CompactModeProvider`、Ctrl+O 旧分支、`compactToggleHasVisualEffect` 调用;加 `isTranscriptOpen`canonical useState§4.2+ `isTranscriptOpenRef` + `transcriptFreeze` + `toggleTranscript/openTranscript/closeTranscript`;全局 Ctrl+O→`toggleTranscript`Esc/q/Ctrl+C **handleGlobalKeypress 第一分支**关闭(早于 QUIT/EXIT/ESCAPE 及 vim INSERT 守卫§4.3`useEffect` 监听**全部阻塞确认/对话框**自动关闭§4.6 #1消息队列 drain 守卫加 `\|\| isTranscriptOpenRef.current`§4.6 #3`refreshStatic`用`isTranscriptOpenRef`守卫、退出时重绘一次§4.4)。**不并入 `dialogsVisible`,不改 `useDialogClose`** |
| `packages/cli/src/ui/contexts/UIStateContext.tsx` | **不改**transcript 态留 `AppContainer` 本地、顶层消费,**不 surface**取证ThinkingViewer / claude-code REPL-local 先例§4.2)。实现代码已如此 |
| `packages/cli/src/ui/contexts/UIActionsContext.tsx` | **不改**`toggleTranscript/closeTranscript``AppContainer` 本地回调,由全局键处理直接调用,不经此 context§4.2 |
| `packages/cli/src/ui/hooks/useDialogClose.ts` | **不改动**transcript 不走此路径Esc 在全局前置分支处理,见 §4.3 |
| `packages/cli/src/ui/layouts/DefaultAppLayout.tsx`(及 `ScreenReaderAppLayout` | 加顶层条件:`isTranscriptOpen` 时渲染 `<TranscriptView/>``<AlternateScreen disabled={useVP}>` 接管)替代主内容。**无独立 overlay 路径**——非 TTY 由 `AlternateScreen``isTTY` guard 退化为普通 buffer 内渲染§4.2VP 由 `disabled` 复用 ink root 的 alt-screen |
| `packages/cli/src/ui/components/MainContent.tsx` | #5661 维持 `mergedHistory = visibleHistory`(无 cross-group 合并、无 `getCompactLabel`/`absorbedCallIds`/`isSummaryAbsorbed`)。本 PR **不改动**:主视图不传 `fullDetail`(默认 falseforce 语义全由 `ToolGroupMessage` 内联§4.5 |
| `packages/cli/src/ui/components/HistoryItemDisplay.tsx` | 引入 `fullDetail` prop父级传入默认 false折入 `resolvedThoughtExpanded = fullDetail \|\| (thoughtExpanded ?? contextThoughtExpanded)`§4.7,两个思考块分支自动生效);下传 `fullDetail``ToolGroupMessage``tool_use_summary` 维持渲染为独立 `● <summary>` 行(跟齐 main无吸收机制 |
| `packages/cli/src/ui/components/messages/ConversationMessages.tsx` | **不改**`ThinkMessage/Content``expanded``HistoryItemDisplay` 传入的 `resolvedThoughtExpanded`(已含 `fullDetail` 分量)决定,无需在此处引用 compactMode/fullDetail |
| `packages/cli/src/ui/components/messages/ToolMessage.tsx` | **保留不动** #5661`forceShowResult` prop + `shouldCollapseResult`(含 `isCollapsibleTool(name)` 守卫)折叠 gate。fullDetail/点击展开经 `ToolGroupMessage` 传入的 `forceShowResult=true` + `availableTerminalHeight=undefined` 即自动解除结果折叠与 `MaxSizedBox`/shell 高度约束§4.5 #2/#3)。**§4.9 改**:新增显式 `fullDetail` prop`ToolGroupMessage` 下传),仅当 `fullDetail && isCollapsibleTool(name) && detailedDisplay` 时以 `detailedDisplay` **取代摘要数据源 `resultDisplay`**;把"解折叠/高度"`forceShowResult`,可由 user-initiated/error 触发)与"切换完整数据源"(仅 `fullDetail`**分离**,避免主视图 force 场景误显完整明细(修审计 #2);点击命中由 `ToolGroupMessage` 包裹§4.8 |
| `packages/cli/src/ui/components/messages/ToolGroupMessage.tsx` | **保留** `forceExpandAll` + `collapsibleTools`/`nonCollapsibleTools` type-based 分区。本 PR`fullDetail` prop → 并入 `forceExpandAll = fullDetail \|\| ...`(解除分区)+ 逐工具 `forceShowResult = fullDetail \|\| ...` + fullDetail 时 `availableTerminalHeight=undefined`§4.5 #1/#2/#3);加点击命中 `isToolExpanded(id)` 同样并入 `forceExpandAll`§4.8)。**无 `showCompact`/`compactMode` 可删****§4.9:把 `fullDetail` 显式下传给 `ToolMessage` 作数据源开关** |
| `packages/cli/src/ui/components/SettingsDialog.tsx` | 删 `ui.compactMode` 的特殊 `setCompactMode` 同步逻辑 |
| `packages/cli/src/config/settingsSchema.ts` | 移除 `ui.compactMode`/`ui.compactInline`(见 §6 迁移策略) |
| `packages/cli/src/serve/routes/workspace-settings.ts` | **保留** `WEB_SHELL_SETTINGS` 中的 `ui.compactMode`——web-shell 有独立的 `CompactModeContext``packages/web-shell/client/App.tsx``'ui.compactMode'`),即使 CLI `settingsSchema` 不再定义该键serve 层仍需透传给 web-shell否则破坏其 compact。web-shell 自身 compact 去留**另案评估、不在本 PR 范围**§6/§7 #5 |
| `packages/cli/src/ui/components/KeyboardShortcuts.tsx` | Ctrl+O 文案改为 `to view transcript` |
| `packages/cli/src/services/tips/tipRegistry.ts` | 删/改 `id: 'compact-mode'` 的启动提示(`:183-185` "Press Ctrl+O to toggle compact mode …")——否则实现后仍向用户提示旧行为;改为 transcript 提示或移除 |
| `packages/cli/src/i18n/locales/{en,zh,zh-TW,ca,de,fr,ja,pt,ru}.js` | **全部 9 个语言文件**都要改/删 compact 文案、加 transcript 文案PR #3100 先例就改了 de/ja/ru/pt。只改 en/zh 会导致多语言用户看到残留的 "toggle compact mode" 旧文案 |
| `packages/web-shell/client/i18n.tsx` | 同步 compact→transcript 文案web-shell 行为另议,见 §7 |
> **§4.9(方案 Ymerge blocker的跨文件改动**(详见 §4.9 改动点 16上表 `ToolMessage`/`ToolGroupMessage` 行已含渲染拆分):
>
> - **新增** core helper `packages/core/src/utils/generateContentResponseUtilities.ts``getToolResponseDisplayText(parts)`(读 `functionResponse.response.output` + 遍历 nested `functionResponse.parts` 媒体占位、空/缺失返回 `undefined`**不二次截断**;规则见 §4.9 改动点 1
> - **改** `packages/cli/src/ui/types.ts``IndividualToolCallDisplay``detailedDisplay?: string`(派生、不持久化);
> - **改** `packages/cli/src/ui/hooks/useReactToolScheduler.ts`live 提取,`success` 分支派生 `detailedDisplay`)、`packages/cli/src/ui/utils/resumeHistoryUtils.ts`resume 提取,`tool_result` 分支从 `responseParts ?? message.parts` 派生)、`packages/cli/src/ui/components/messages/ToolMessage.tsx` + `ToolGroupMessage.tsx`(渲染拆分:`ToolGroupMessage` 下传 `fullDetail``ToolMessage``fullDetail && isCollapsibleTool && detailedDisplay` 切数据源);
> - **不改** `packages/cli/src/acp-integration/session/HistoryReplayer.ts` / `emitters/ToolCallEmitter.ts`——ACP `content[]` 已含完整 `output`见上表TUI transcript 不经此路,无需改动;
> - **不改** 持久化 schema`serializeToolResponse` / `chatRecordingService` / ACP 协议字段)——完整明细已天然存于 `responseParts`,新字段为派生值。
### C. 新增
| 文件 | 内容 |
| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `packages/cli/src/ui/components/TranscriptView.tsx` | Transcript 全详情滚动屏(用 `<AlternateScreen disabled={useVP}>` 包裹 + 双段冻结快照 + ScrollableList/VirtualizedList调大/自适应 `estimatedItemHeight`+ `fullDetail={true}` 渲染) |
| `packages/cli/src/ui/components/TranscriptView.test.tsx` | 渲染/滚动/关闭/快照定格测试 |
| `packages/cli/src/ui/contexts/ToolExpandedContext.tsx`follow-up鼠标点击展开 | **专用小 context**(仿 gemini-cli `ToolActionsContext`/本仓库 `ThinkingViewerContext`):暴露 `toggleToolExpanded(id)`/`isToolExpanded(id)`canonical `expandedToolGroupIds` 仍在 `AppContainer` useState§4.8)。**不**塞进 broad `UIStateContext` |
> **复用现有组件,不再新增 AlternateScreen**`packages/cli/src/ui/components/AlternateScreen.tsx`PR #5627)已实现 DEC 1049 进出 + `disabled` prop**直接复用**§4.2)。原"新增 AlternateScreen/useAlternateBuffer"提法已删除。
### D. 测试同步(移除 compact mock
`MainContent.test.tsx``HistoryItemDisplay.test.tsx``ToolGroupMessage.test.tsx``ToolMessage.test.tsx``SettingsDialog.test.tsx` 中所有 `CompactModeProvider` 包装与 `compactMode` 用例需删除或改写为基线/transcript 用例。
---
## 6. 迁移与兼容
- **settings.ui.compactMode / compactInline**:用户配置里可能已存在。策略:从 CLI **schema 移除**`settingsSchema.ts`),但 **`WEB_SHELL_SETTINGS``workspace-settings.ts:36`)保留 `ui.compactMode`**——web-shell 有独立的 `CompactModeContext``packages/web-shell/client/App.tsx``'ui.compactMode'`serve 层须继续透传,否则破坏 web-shell#12 / §7 #5)。已验证设置系统对未知键**宽容**——`getSettingsFileKeyWarnings``settings.ts:250-309`,未知键处理在 `:290-306``debugLogger.warn``:303-305`)仅 `debug` 记录、不报错不阻断。因此用户旧的 CLI 配置可安全读取(被 CLI 忽略),新 CLI 设置 UI/API 不再列出该项。**CLI 侧不保留向后语义**——模式概念整体删除保留也无处生效web-shell 侧的 compact 去留**另案评估**。
- **快捷键自定义(迁移提示)**:当前代码库**没有**用户可配置的 keybinding 覆盖机制——`keyMatchers``keyMatchers.ts`)始终由硬编码的 `defaultKeyBindings` 构建,`settingsSchema` 也无 keybinding 项(仅 `vimMode`)。因此**不存在**用户持久化的 `toggleCompactMode` 绑定会被静默丢弃,启动时迁移检测**无可扫描对象、当前不适用**。一旦将来引入用户可配置 keybinding再补"检测残留 `toggleCompactMode` 绑定并一次性提示改绑 `toggleTranscript`"。**当前 PR 仅需** release note 提示 Ctrl+O 语义已变更为 transcript`docs/users/reference/keyboard-shortcuts.md` 已更新)。
- **打点**:可对齐 Claude Code 新增 `toggle_transcript` 事件(可选)。
---
## 7. 边界、风险与未决项
1. **大历史性能**transcript 渲染全部历史。虚拟滚动(`VirtualizedList`)按视口有界;区分两类上限——**只解除"行高"截断**为显示transcript 解除),但**保留"字符数"上限**(为性能,始终保留)。注意 qwen-code **不存在** `SlicingMaxSizedBox`(那是 gemini-cli 的qwen 侧的字符级保护是工具输出截断阈值 `DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD`~25000。实现时验证万行级历史滚动流畅度。
2. **alt-screen 协调(复用现有组件已大幅降险)**:本方案**复用 main 已落地的 `AlternateScreen.tsx`PR #5627**进出 alt-screen而非自行手写 `?1049h/l` 时序,风险显著低于初版设计。剩余需处理的两点:(a) **VP 模式 double-enter**——`useTerminalBuffer` 开启时 ink root 已占 alt-screen必须传 `disabled={useVP}` 跳过转义§4.2(b) **退出收尾重绘**——退出时主屏经 `refreshStatic()` 清屏重绘一次§4.4),且 transcript 期间 `refreshStatic``isTranscriptOpenRef` 守卫跳过,避免污染 normal-buffer scrollback。注意旧 compact 是**每次 toggle** 都 refreshStatic本方案只在 transcript **关闭时一次**,频率低得多。仍需在 tmux / iTerm2 / VSCode / Apple Terminal 验证。
3. **force 安全语义(保留 #5661 内联条件,勿误删)**:本 PR 在 `forceExpandAll` 前缀加 `fullDetail || isToolExpanded(id) ||` 时,务必**保留**其余 `hasConfirmingTool || hasSubagentPendingConfirmation || hasErrorTool || isEmbeddedShellFocused || isUserInitiated || hasTerminalSubagent`,以及 `ToolMessage``shouldCollapseResult`(含 `isCollapsibleTool` 守卫gate——这些是 #5661 已就位的"出错/待确认/用户发起/聚焦 shell/子代理 必须完整展示"安全语义§4.5),误删会导致"出错的工具被折叠看不全"的回归。**不需要**迁移到任何新文件。
4. **mouse / SGR 协调**:注意与 [[project_vp_text_selection]] 记录的"SGR mouse tracking 破坏原生文本选择"问题的潜在叠加——transcript 内是否启用鼠标滚轮需权衡(启用滚轮 vs 保留终端原生选择。mouse 的进出处理随 `AlternateScreen` 与 VP root 既有机制走,无需额外手写。
5. **web-shell独立 surface不在本 PR 删除范围)**web-shell 有**独立的** `CompactModeContext``packages/web-shell/client/App.tsx``'ui.compactMode'`),与 CLI 的 compact 是两套实现。为不破坏它,本 PR **在 `WEB_SHELL_SETTINGS` 中保留 `ui.compactMode`**(即使 CLI schema 不再定义该键serve 层仍透传,见 #12 / §6。本期 CLI 仅做自身文案与设置项清理;**web-shell 自身 compact 的去留另案评估**,不在本设计实现范围。
6. **Ctrl+S/`SHOW_MORE_LINES` 是否并入**:本期保留独立,避免改动面失控;列为后续可选优化。
7. **`(ctrl+o to expand)` 提示语义(已定)**:思考块摘要**恒带**该提示(指引"进 transcript 看完整上下文",非"此处被截断");工具输出仅在被高度约束截断时带 `+N lines`。两者触发条件不同是**预期行为**i18n 文案需体现"查看完整上下文"而非"展开被截断内容",避免误导。
---
## 8. 测试计划
- **单测**
- `AlternateScreen`(复用现有组件 + **新补 isTTY guard** 后的接入测试):非 VP 模式 enter/exit 写 `ENTER_ALT_SCREEN`/`EXIT_ALT_SCREEN`**VP 模式(`disabled=true`)路径跳过转义写入、不 double-enter****非 TTY`process.stdout.isTTY` 假)跳过转义写入**(待补 guard§4.2)。
- `TranscriptView`:打开渲染全历史 + **进入时刻 pending 快照**、思考/工具全展开不截断、滚动 API、双段冻结后台新增 history/pending 不进入视图)、**`estimatedItemHeight` 调大/自适应后滚动定位不失真**§4.4)。
- `ToolGroupMessage`/`ToolMessage`type-based partition 基线 + fullDetail 联动):无 force 时 collapsibleread/search/list折叠成 `CompactToolGroupDisplay` 摘要、non-collapsibleedit/write/command/agent逐个 `ToolMessage`、混合组摘要行 + 逐个并存force 组(出错/确认/聚焦 shell/用户发起/终端子代理)`forceExpandAll=true` → 全部逐个、触发工具 `forceShowResult=true``fullDetail=true`transcript/点击展开)时 `forceExpandAll=true` + `forceShowResult=true` + `availableTerminalHeight=undefined` 解除高度约束non-collapsible 工具结果在非 force 下也始终可见(`shouldCollapseResult``isCollapsibleTool` 守卫);思考摘要含 `(ctrl+o to expand)`;思考块 `resolvedThoughtExpanded = fullDetail || (thoughtExpanded ?? contextThoughtExpanded)`(与 main 的 Alt+T per-block 开关共存§4.7)。
- 键位Ctrl+O 切 transcript开/关单次响应、无双重处理transcript 打开时 **Ctrl+C 关闭 transcript 且不触发 quit/`ctrlCPressedOnce`**(验证短路早于 QUIT 分支§4.3**Esc 关 transcript 即使处于 vim INSERT 模式**(验证早于 ESCAPE 分支的 vim 守卫§4.3Esc/q 关 transcript 且**不取消后台请求**Ctrl+S 仍切 constrainHeight、互不干扰。
- 交互防死锁逐一覆盖全部阻塞确认transcript 打开期间后台触发 `WaitingForConfirmation``shellConfirmationRequest``loopDetectionConfirmationRequest``confirmationRequest``confirmUpdateExtensionRequests``providerUpdateRequest` **任一** → 自动 `closeTranscript()`§4.6 #1)。
- 消息队列transcript 打开期间排队消息**不被自动提交**退出后恢复排空§4.6 #3)。
- 渲染模型 + `refreshStatic` 守卫:打开 transcript 期间后台完成一轮工具调用 / 触发 resize**transcript 期间 `refreshStatic``isTranscriptOpenRef` 守卫跳过**、退出后重绘一次——该轮内容**恰好出现一次**(无重复回放/无缺失/scrollback 不破坏§4.4)。
- core 截断边界core 层已截断的工具输出在 transcript 中仍显示 truncation marker、不臆造"全文"§4.4 两层截断)。
- **§4.9 完整明细透传(已实现 + 测试)**core helper 从 `functionResponse.response.output` 提取 + media 占位 + 空/缺失降级;`detailedDisplay`TUI 专属、`IndividualToolCallDisplay` 派生字段)在 **live`useReactToolScheduler`+ resume`resumeHistoryUtils`)两路**都产出,尤其 **resume / 回放后 Ctrl+O transcript 仍是全详情**(不回退摘要);**ACP 路无需 `detailedDisplay`**——`ToolCallEmitter.transformPartsToToolCallContent` 早已把同一 `functionResponse.response.output` 完整文本写进 ACP `content[]`(其 SSE 客户端自行渲染,非 TUI transcript故复用 `message: record.message.parts`、不新增协议字段即满足 §4.9`ToolMessage``fullDetail && isCollapsibleTool && detailedDisplay` 用完整明细,而 **`forceShowResult 但非 fullDetail`(主视图 user-initiated/error 等)仍用摘要 `resultDisplay`**(回归"主视图不变");覆盖 `glob`(或 legacy search displayName不只 read/grep/list**不二次截断**`read_file` 超 32k 的完整 `output` 在 Ctrl+O 仍完整、不被 UI 再裁,仅受 core 已施加的 `truncateToolOutput`/分页边界);极旧无-`parts` 记录降级摘要。
- **方案 Y 前提保护(持久化/回放不丢完整明细)**:构造 `functionResponse.response.output` 长度 > `MAX_RETAINED_TOOL_RESULT_DISPLAY_CHARS` 的 tool_result + 旁置 `toolCallResult.resultDisplay` 摘要 + 后续 `chat_compression` record断言 `record.message.parts[0].functionResponse.response.output`**recording / loadSession / `resumeHistoryUtils` / `HistoryReplayer` 四段都保留完整 string**,且 `detailedDisplay``message.parts` 派生(**非** `resultDisplay`、**非** API `compressedHistory`)。**此用例失败即触发回退方案 X**(新增持久化 display 字段)。源码上前提成立:`serializeToolResponse`/`summarizeBatchResponsePart` 仅用于 post-tool-batch hook payload`coreToolScheduler.ts:819-870`)、不碰 chat recording`ChatRecordingService.recordToolResult``createUserContent(message)` 原样写 `record.message`、只 sanitize `resultDisplay``chatRecordingService.ts:1077-1109`TUI resume / ACP replay 走 `conversation.messages`、非 API `compressedHistory``AppContainer.tsx:642-652``acpAgent.ts:6355-6357`)。
- **回归**:确认删除全局 compactMode 后无 CLI 残留引用(`grep -rE 'TOGGLE_COMPACT_MODE|useCompactMode|CompactModeContext|compactInline|compactToggleHasVisualEffect'``packages/cli/src` 非测试源应为空;`compactMode` 仅保留 web-shell 透传 `WEB_SHELL_SETTINGS``ToolConfirmationMessage` 的本地 layout prop**注意 `CompactToolGroupDisplay` 与 partition 符号(`getToolCategory`/`CATEGORY_ORDER`/`isCollapsibleTool` 等)应仍存在**(属 #5661 基线,不在删除范围);`mergeCompactToolGroups.ts` 应已删除typecheck/lint/test 全绿。
- **TUI 快照(基线随 #5661 已变,本 PR 进一步微调,需重录)**type-based partition 基线由 #5661 引入;本 PR 删全局 compactMode 后,工具组折叠不再受 `compactMode` 影响(始终走 type-based partition。需重录 `qwen-code-mac-autotest`:默认 partition 基线collapsible→摘要、non-collapsible→逐个、混合组并存、含出错工具force 逐个展示)、含长 shell 输出non-collapsible 结果可见)、点击工具块就地展开、打开 transcript、transcript 内滚动到顶/底、transcript 内 resize、退出后主屏恢复。
- **终端兼容**:复用 `AlternateScreen` 的进出(含 VP `disabled` 路径与非 VP 写转义路径)在 tmux / iTerm2 / VSCode 集成终端 / Apple Terminal 下逐一验证(重绘、退出 `refreshStatic` 收尾、resize
---
## 9. 落地拆分(单 PR + 内部分 commit
本 PR 实现按 commit 拆分以便 review/回滚(下表为**实际/计划状态**,非纯 design-first 的"先文档后实现")。**依赖基线 [#5661](https://github.com/QwenLM/qwen-code/pull/5661)partition 基线)+ [#5751](https://github.com/QwenLM/qwen-code/pull/5751)(鼠标基础设施)均已合入 main**,本分支已 rebase 其上。**每个 commit 必须自身可编译可测**
1. `commit 1`**删残留全局 compactMode保留 type-based partition 基线)**:删 `CompactModeContext`/`useCompactMode`/`compactMode` settings(schema)/i18n删失去引用的 `mergeCompactToolGroups.ts`(含 `compactToggleHasVisualEffect`)。**不动** `ToolGroupMessage``forceExpandAll`/分区决策(无 `showCompact`/`compactMode ||` 可删)。同步引入 `fullDetail` prop默认 false穿过 `HistoryItemDisplay`/`ToolGroupMessage`,并把 `fullDetail` 并入 `forceExpandAll`/`forceShowResult`、思考块折入 `resolvedThoughtExpanded`;主视图不传(沿用 #5661 的 type-based partition。完成后主视图即为"#5661 type-based partition 基线、无全局开关"Ctrl+O 暂为 no-op。
2. `commit 2` — 接入 alt-screen 能力:**复用现有 `AlternateScreen.tsx`PR #5627**,验证 `disabled={useVP}` 跳过 double-enter、退出 `refreshStatic()` 重绘 + `isTranscriptOpenRef` 守卫期间 refreshStatic、非 TTY 退化。仅加能力、不接线。
3. `commit 3` — 新增 `TranscriptView``<AlternateScreen disabled={useVP}>` + 双段冻结快照 + 虚拟滚动 + 调大/自适应 `estimatedItemHeight` + `fullDetail={true}`**`fullDetail` 联动落地**transcript 路径 `forceExpandAll=true` + `forceShowResult=true` + `availableTerminalHeight=undefined` 解除高度约束 + 思考块 expanded§4.5`TOGGLE_TRANSCRIPT` 绑定、全局键位接线Ctrl+O toggle / Esc·Ctrl+C·q 第一分支关闭,早于 QUIT/vim 守卫)、**全部阻塞确认**自动关闭、消息队列 drain 守卫、顶层 layout 条件渲染。
4. `commit 4`§4.9**本 PR merge blocker**)— **read/search/list 完整明细透传(方案 Y**core 新增 `getToolResponseDisplayText`(读 `functionResponse.response.output``IndividualToolCallDisplay.detailedDisplay`派生、不持久化live(`useReactToolScheduler`)/resume(`resumeHistoryUtils`)/ACP replay(`HistoryReplayer`+`ToolCallEmitter`) 三路用同一 helper 派生;`ToolMessage` 显式收 `fullDetail`、仅 `fullDetail && isCollapsibleTool && detailedDisplay` 切换数据源(与 `forceShowResult` 解折叠分离,修审计 #2)。**完成后 Ctrl+O 才是真正"全详情",并重录 §3.4 截图**。
5. `commit 5` — i18n 文案9 语言 compact→transcript、settings 清理、KeyboardShortcuts/Help、测试与 TUI 快照重录。
> **鼠标点击就地展开 = follow-up独立 PRVP-only MVP**:新增 `ToolExpandedContext``toggleToolExpanded`/`isToolExpanded`+ `ClickableToolMessage` 命中区,点击折叠摘要行 → 该组 `forceExpandAll=true`§4.8)。**不在本 PR commit 序列**;理由与点击粒度重定见 §4.8 顶部 banner。
> commit 1 删残留 compact、保留 type-based partition、引入 fullDetail 管线(默认 falsecommit 2 仅加 alt-screen 能力commit 3 接线点亮 Ctrl+O transcript + fullDetail 联动(此时 collapsible 工具仍只到摘要级);**commit 4 补全 read/search/list 完整明细透传§4.9merge blocker——Ctrl+O 此后才是真正"全详情"**commit 5 收尾文案/设置与测试、重录截图。每步可编译。**鼠标点击就地展开 = follow-up独立 PR见 §4.8,不在本 commit 序列。**
---
## 10. 与既有设计文档 #3100 的互鉴
前序工作 [PR #3100](https://github.com/QwenLM/qwen-code/pull/3100) 已产出 `docs/design/compact-mode/compact-mode-design.md`284 行竞品分析)。本方案与之的关系:
1. **我们采纳了它分析过、但当时没走的那条路**#3100 §4.4 已对比 "screen-level transcriptClaude Code" vs "component-level toggleqwen 当时选择)",并指出前者"更简单、一致性有保证"。本方案正是转向 screen-level transcript。
2. **修正一处事实错误**#3100 表格断言 Claude Code "Frozen snapshot: None (no concept)"。经 claude-code 最新源码取证,**确有冻结快照**`frozenTranscriptState` + `messages.slice(0, len)`)。本方案据真实实现采用冻结快照,并在 §3.2 标注来源。
3. **直接复用其结论**#3100 §4.3/§5.4 主张"确认对话框用独立 overlay 层、结构性保证永不被隐藏"。transcript 为独立屏后,主屏的确认/错误本就不受其影响,天然满足该诉求。而"出错/待确认/聚焦 shell 必须可见"在主视图侧由 #5661`forceExpandAll` 条件 + `forceShowResult` 保证(本 PR 保留不动§4.5),无需另设机制。
4. **消解持久化之争**#3100 §4.2/§5.3 反复权衡 compact 该不该持久化settings vs session-scoped。本方案**删除模式本身**,不再有需要持久化的状态,该争论自动消失;亦无需 §5.3 的"session override"复杂度。
5. **文档处置**:实现 PR 中将 `docs/design/compact-mode/` 标记为"superseded by ctrl-o-detail-expand"(保留历史,加一行指引),避免两份相互矛盾的设计并存。
> 一句话:#3100 是"如何把 compact 模式做好"的设计;本方案是"为什么不要 compact 模式、改用 transcript"的设计,二者是同一问题上的迭代决策。
## 附录:关键代码取证索引
> **PR 依赖关系**:本 PR 叠加在 **#5661partition 基线)+ #5751(鼠标基础设施)** 之上,**二者均已合入 main**,本分支已 rebase 其上§9
### #5661 type-based partition 基线取证(本 PR 保留并叠加,符号名以**已合入 main** 的真实代码为准)
- **`ToolGroupMessage.tsx``forceExpandAll` + 分区决策)**`forceExpandAll = hasConfirmingTool || hasSubagentPendingConfirmation || hasErrorTool || isEmbeddedShellFocused || isUserInitiated || hasTerminalSubagent`**不含 `compactMode`/`allComplete`**)。`collapsibleTools = forceExpandAll ? [] : inlineToolCalls.filter(t => isCollapsibleTool(t.name) && t.status !== Canceled)``nonCollapsibleTools = forceExpandAll ? inlineToolCalls : 其余`。全 collapsible 组 → `<CompactToolGroupDisplay>`;混合组 → 摘要行 + 逐个 `<ToolMessage>`;对 ToolMessage 逐个传 `forceShowResult={isUserInitiated || Confirming || Error || isAgentWithPendingConfirmation || isTerminalSubagentTool}`。**本 PR 叠加**`forceExpandAll = fullDetail || ...``forceShowResult = fullDetail || ...`、fullDetail 时 `availableTerminalHeight=undefined`
- **`CompactToolGroupDisplay.tsx`(分区摘要渲染器 + `isCollapsibleTool`****导出**`export``getOverallStatus``isCollapsibleTool(name)`(判定 read/search/list`buildToolSummary``CompactToolGroupDisplay`**内部符号**(非导出,仅文件内使用)`ToolCategory``TOOL_NAME_TO_CATEGORY``CATEGORY_ORDER`(实际顺序 `search/read/list/command/edit/write/agent/other`)、`getToolCategory`。本 PR **保留**
- **`ToolMessage.tsx``shouldCollapseResult` gate**`shouldCollapseResult = !forceShowResult && status === ToolCallStatus.Success && isCollapsibleTool(name) && (effectiveDisplayRenderer.type === 'string' || 'ansi')`**注意 `isCollapsibleTool(name)` 守卫**:仅 read/search/list 的 string/ansi 结果折叠Shell/Edit/Agent 结果始终显示diff/plan/todo/task 走各自 renderer高度约束符号`MaxSizedBox``sliceTextForMaxHeight``shellOutputMaxLines`/`shellStringCapHeight``isCappingShell`(被 `!forceShowResult` 解除)。本 PR **保留** gate叠加 fullDetail/点击展开联动(`forceShowResult=true` + `availableTerminalHeight=undefined`,无需改 `ToolMessage` 本体)。
- **更正(早期文档基于 state-based 快照的错误断言)**:早期版本曾断言"**没有** `forceExpandAll` / `isCollapsibleTool`"——**事实相反,这两个符号是 #5661 合入实现的核心,确实存在**。`shouldForceFullDetail.ts` / `COLLAPSIBLE_CATEGORIES` 不存在force 语义内联在 `forceExpandAll`,分区类别内联在 `TOOL_NAME_TO_CATEGORY`/`isCollapsibleTool`),以真实代码为准。
### 本 PR 待删/待改取证(行号以当前 main 为准)
- Ctrl+O 现绑定:`packages/cli/src/config/keyBindings.ts:225``TOGGLE_COMPACT_MODE`→Ctrl+O`:223``SHOW_MORE_LINES`→Ctrl+S
- Ctrl+O 现处理:`packages/cli/src/ui/AppContainer.tsx:3257-3269`
- compact context`packages/cli/src/ui/contexts/CompactModeContext.tsx``CompactModeProvider`/`useCompactMode`
- 思考块 expanded`HistoryItemDisplay.tsx:203,215``expanded={!compactMode}`);渲染 `ConversationMessages.tsx:373-454`
- settings`settingsSchema.ts:940-958``compactMode/compactInline``serve/routes/workspace-settings.ts:36`
- 可复用滚动屏底座:`components/shared/ScrollableList.tsx``VirtualizedList.tsx``MainContent` 默认对其用恒定 `estimatedItemHeight=3`transcript 须调大/自适应);覆盖层 `DialogManager.tsx``layouts/DefaultAppLayout.tsx`Esc 统一关闭 `hooks/useDialogClose.ts`
- **可复用 alt-screen 组件qwen 自身)**`packages/cli/src/ui/components/AlternateScreen.tsx`PR #5627)——`useEffect``ENTER_ALT_SCREEN+CLEAR+HIDE_CURSOR`、卸载/`process.on('exit')``SHOW_CURSOR+EXIT_ALT_SCREEN`,用 `useTerminalOutput()`/`useTerminalSize()`,带 `disabled?: boolean`(注释:"Skip escape writes when the root Ink renderer already owns the alt screen (VP mode)"
- **VP 模式 alt-screen 常驻**`gemini.tsx:367``const useVP = settings.merged.ui?.useTerminalBuffer ?? false;`)、`:379``alternateScreen: useVP`
- **ink 版本(澄清)**qwen-code 用上游官方 `ink ^7.0.3`gemini-cli 用 fork `npm:@jrichman/ink@6.6.9`v6——**不同包不同大版本**alt-screen 能力基于 qwen 自己的 ink v7 + 复用上述组件
- **main per-block 思考机制(与本方案共存)**`ThoughtExpandedContext`Alt+T `TOGGLE_THINKING_EXPANDED`)、`ThinkingViewer`/`ThinkingViewerContext``thoughtExpanded`/`thinkingFullText` props、`buildThinkingFullTextMap``ClickableThinkMessage`(详见 §4.7
- **阻塞确认/对话框(全部需自动关闭 transcript**`DialogManager.tsx` 渲染 `shellConfirmationRequest`(ShellConfirmationDialog)、`loopDetectionConfirmationRequest`(LoopDetectionConfirmation)、`confirmationRequest`(ConsentPrompt)、`confirmUpdateExtensionRequests`(ConsentPrompt)、`providerUpdateRequest`(ProviderUpdatePrompt) 等§4.6 #1
- **web-shell 独立 compact**`packages/web-shell/client/App.tsx`(读 `'ui.compactMode'`、独立 `CompactModeContext`)——`WEB_SHELL_SETTINGS` 须保留该键透传§6 / §7 #5
- Claude Code 取证:`defaultBindings.ts``ctrl+o: app:toggleTranscript``escape/q/ctrl+c → transcript:exit`,行 160-169`useGlobalKeybindings.tsx`setScreen 切换 + `tengu_toggle_transcript`)、`REPL.tsx`transcript = 虚拟滚动 + verbose`frozenTranscriptState` 冻结 `{ messagesLength, streamingToolUsesLength }` 两个长度、render 时 slice 而非克隆,行 1325/4184/4381`ink/components/AlternateScreen.tsx` + `termio/dec.ts:16``ALT_SCREEN_CLEAR: 1049``\x1b[?1049h/l`)、`CtrlOToExpand.tsx``(ctrl+o to expand)`
- gemini-cli per-tool 展开取证:`ToolActionsContext``expandedTools` / `toggleExpansion`per-block 维度,与 main 的 Alt+T 同思路,详见 §2 / §4.7
- 既有设计:`docs/design/compact-mode/compact-mode-design.md`PR #3100 竞品分析,本方案 §10 互鉴并修正)

View file

@ -101,7 +101,12 @@ A **second** SIGTERM/SIGINT intentionally triggers `bridge.killAllSync()` + `pro
"runtime": {
"perf": {
"eventLoop": { "meanMs": 1.2, "p50Ms": 1.0, "p99Ms": 9.5, "maxMs": 25 },
"promptQueueWait": { "count": 3, "meanMs": 12.5, "maxMs": 35, "lastMs": 4 },
"promptQueueWait": {
"count": 3,
"meanMs": 12.5,
"maxMs": 35,
"lastMs": 4
},
"pipe": {
"inbound": { "count": 42, "totalBytes": 100000, "maxBytes": 12000 },
"outbound": { "count": 41, "totalBytes": 90000, "maxBytes": 11000 }

View file

@ -125,7 +125,7 @@ Settings are organized into categories. Most settings should be placed within th
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` |
| `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` |
| `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` |
| `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` |
| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to open the full-detail transcript instead of toggling a mode. Still honored by the web shell. | `false` |
| `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` |
| `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
| `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` |

View file

@ -1,6 +1,6 @@
# Tool-Use Summaries
Qwen Code can generate a short, git-commit-subject-style label after each tool batch completes, summarizing what the batch accomplished. The label appears inline in the transcript and replaces the generic `Tool × N` header in compact mode.
Qwen Code can generate a short, git-commit-subject-style label after each tool batch completes, summarizing what the batch accomplished. The label appears inline: for a completed tool group in the main view it replaces the generic `Tool × N` header; when the group is force-expanded (in the `Ctrl+O` full-detail transcript, or for error / user-initiated batches) it appears as a dim `● <label>` line below the group.
This is a UX aid for parallel tool calls: when the model fans out into several `Read` + `Grep` + `Bash` calls at once, the summary tells you the intent at a glance instead of forcing you to scan the tool list.
@ -8,9 +8,21 @@ The feature is enabled by default and runs silently in the background. It requir
## What You See
### Full mode (default)
### Main view (completed group)
The summary appears as a dim badge line directly below the tool group:
In the main transcript, a completed collapsible batch folds into a single labeled row — the summary replaces the generic `Tool × N` header:
```
╭──────────────────────────────────────────────╮
│✓ Read 4 text files │
╰──────────────────────────────────────────────╯
```
The full per-tool output is a keystroke away: press `Ctrl+O` to open the full-detail transcript.
### Full-detail transcript (`Ctrl+O`) and force-expanded groups
When a group is force-expanded — in the `Ctrl+O` transcript, or for error / user-initiated batches in the main view — each tool renders individually and the summary appears as a dim badge line below the group:
```
╭──────────────────────────────────────────────╮
@ -23,19 +35,6 @@ The summary appears as a dim badge line directly below the tool group:
● Read 4 text files
```
### Compact mode (`Ctrl+O` or `ui.compactMode: true`)
The label replaces the generic `Tool × N` header in the compact one-liner:
```
╭──────────────────────────────────────────────╮
│✓ Read txt files · 4 tools │
│Press Ctrl+O to show full tool output │
╰──────────────────────────────────────────────╯
```
The individual tool calls are still a keystroke away (`Ctrl+O` to toggle to full mode).
## How It Works
After a tool batch finalizes, Qwen Code fires a fire-and-forget call to the configured fast model with:
@ -123,7 +122,7 @@ These settings can be configured in `settings.json`:
Three points that tend to trip up a first read of this feature:
1. **One generation per batch, shared by both display modes.** The fast-model call happens exactly once in `handleCompletedTools` when a tool batch finalizes. Toggling `Ctrl+O` afterwards does **not** trigger a new call — both modes read from the same `tool_use_summary` history entry that was captured the first time. You can flip compact mode on and off freely without extra cost.
1. **One generation per batch, shared by both display modes.** The fast-model call happens exactly once in `handleCompletedTools` when a tool batch finalizes. Opening the `Ctrl+O` transcript afterwards does **not** trigger a new call — both the main view and the full-detail transcript read from the same `tool_use_summary` history entry that was captured the first time.
2. **No backfill on toggle or on session resume.** A `tool_group` that completed before the feature was enabled (or before you flipped the setting on, or in a resumed session — `ChatRecordingService` does not persist summary entries) will never get a label. There is no "sweep existing history" pass. If you turn this setting on mid-session, only _future_ batches will show a label; older groups keep the default rendering with no indicator that a label is missing.
3. **Main-agent batches only.** The trigger lives in the main session's turn loop (`useGeminiStream`), so:
- ✅ Shell, MCP, file operations, and the `Task` / subagent tool _call itself_ (as it appears in the main batch) are summarized.
@ -131,23 +130,20 @@ Three points that tend to trip up a first read of this feature:
An outer batch that _contains_ a `Task` tool will still be labeled, but the fast model sees only the subagent tool call and its aggregated output — not the individual tool calls inside the subagent. Expect labels like `Ran research-agent` or `Delegated file search` rather than `Searched 14 files`. This is intentional — summarizing subagent internals would multiply the fast-model cost and surface noise that never shows up in the primary UI.
## Recommended pairing: enable compact mode
## Display behavior
For batches of 3+ parallel tool calls, pairing this feature with `ui.compactMode: true` produces the cleanest transcript. The compact view folds the whole batch into a single labeled row (`✓ Read txt files · 4 tools`) instead of showing every tool line plus the trailing summary. Details remain one keystroke away via `Ctrl+O`.
The main view already folds a completed collapsible batch into a single labeled row (`✓ Read 4 text files`) — the summary does the work of the old per-tool list. For full per-tool detail, press `Ctrl+O` to open the full-detail transcript, where each tool renders individually with the summary as a trailing `● <label>` line below the group.
```json
{
"fastModel": "qwen3-coder-flash",
"ui": {
"compactMode": true
},
"experimental": {
"emitToolUseSummaries": true
}
}
```
In full mode (the default), the summary renders as a trailing `● <label>` line below the tool group — useful for large or heterogeneous batches, but for small same-type batches (e.g. `Read × 3`) the label can read as a restatement of the visible tool lines. If that matches your usual workflow, either turn compact mode on as above, or turn the summary off entirely via `experimental.emitToolUseSummaries: false`.
For small same-type batches (e.g. `Read × 3`) the expanded `● <label>` line can read as a restatement of the visible tool lines; if that matches your usual workflow you can turn the summary off entirely via `experimental.emitToolUseSummaries: false`.
## Monitoring
@ -174,5 +170,5 @@ If you do not want the extra cost, turn the feature off via `experimental.emitTo
## Related
- [Compact Mode](../configuration/settings#ui) — toggle with `Ctrl+O`; the summary replaces the generic tool-group header when compact mode is on.
- [Full-detail transcript](../configuration/settings#ui) — press `Ctrl+O` to open the frozen full-detail transcript; the summary replaces the generic tool-group header for completed groups in the main view.
- [Followup Suggestions](./followup-suggestions) — another fast-model-driven UX enhancement that shares the same `fastModel` setting.

View file

@ -10,7 +10,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
| `Ctrl+L` | Clear the screen. |
| `Ctrl+O` | Toggle compact mode (hide/show tool output and thinking). |
| `Ctrl+O` | Open/close the full-detail transcript view (a scrollable, frozen snapshot showing every tool's complete output and full thinking). Press again, or `Esc`/`q`, to close. |
| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
| `Ctrl+T` | Toggle the display of tool descriptions. |
| `Ctrl+B` | While a foreground shell command is running: promote it to a background task. The child keeps running, the agent's turn unblocks, and the shell appears in `/tasks` + the Background tasks dialog. No-op when no shell is executing — Ctrl+B then falls through to its prompt-area binding (cursor-left). |

View file

@ -56,7 +56,6 @@ export enum Command {
EXIT = 'exit',
SHOW_MORE_LINES = 'showMoreLines',
RETRY_LAST = 'retryLast',
TOGGLE_COMPACT_MODE = 'toggleCompactMode',
TOGGLE_RENDER_MODE = 'toggleRenderMode',
/**
* Promote the running foreground shell command to a background task.
@ -80,6 +79,9 @@ export enum Command {
// Thinking expansion
TOGGLE_THINKING_EXPANDED = 'toggleThinkingExpanded',
// Transcript full-detail screen (Ctrl+O)
TOGGLE_TRANSCRIPT = 'toggleTranscript',
// Scroll commands
SCROLL_UP = 'scrollUp',
SCROLL_DOWN = 'scrollDown',
@ -225,7 +227,6 @@ export const defaultKeyBindings: KeyBindingConfig = {
[Command.EXIT]: [{ key: 'd', ctrl: true }],
[Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }],
[Command.RETRY_LAST]: [{ key: 'y', ctrl: true }],
[Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }],
[Command.TOGGLE_RENDER_MODE]: [{ key: 'm', meta: true }],
[Command.PROMOTE_SHELL_TO_BACKGROUND]: [{ key: 'b', ctrl: true }],
@ -243,6 +244,9 @@ export const defaultKeyBindings: KeyBindingConfig = {
// Thinking expansion
[Command.TOGGLE_THINKING_EXPANDED]: [{ key: 't', meta: true }],
// Transcript full-detail screen
[Command.TOGGLE_TRANSCRIPT]: [{ key: 'o', ctrl: true }],
// Scroll commands
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],

View file

@ -1003,19 +1003,13 @@ const SETTINGS_SCHEMA = {
category: 'UI',
requiresRestart: false,
default: false,
description:
'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).',
showInDialog: true,
},
compactInline: {
type: 'boolean',
label: 'Compact Inline',
category: 'UI',
requiresRestart: true,
default: false,
description:
'Compact tool display within each group instead of merging across groups. Requires compactMode to be enabled.',
showInDialog: true,
// Retired from the TUI (compact tool output is now always-on there, and
// Ctrl+O opens the transcript instead of toggling this). Kept as a
// hidden, schema-only setting so the web shell's independent compact
// toggle can still persist via the daemon settings routes (mirrors
// `voiceModel`). Not shown in the TUI settings dialog.
description: 'Compact view (web shell only; not used by the TUI).',
showInDialog: false,
},
useTerminalBuffer: {
type: 'boolean',
@ -3117,7 +3111,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: false,
default: true,
description:
'Generate a short LLM-based label after each tool batch completes. In compact mode the label replaces the generic `Tool × N` header; in full mode it appears as a dim `● <label>` line below the tool group. Requires a fast model to be configured; runs in parallel with the next API call so latency is hidden. Currently affects interactive CLI rendering only — SDK / non-interactive emission of the `tool_use_summary` message is not yet wired (the message factory is exported for a follow-up PR). Can be overridden with QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0 or =1.',
'Generate a short LLM-based label after each tool batch completes. For a completed tool group the label replaces the generic `Tool × N` header; when the group is force-expanded it appears as a dim `● <label>` line below the tool group. Requires a fast model to be configured; runs in parallel with the next API call so latency is hidden. Currently affects interactive CLI rendering only — SDK / non-interactive emission of the `tool_use_summary` message is not yet wired (the message factory is exported for a follow-up PR). Can be overridden with QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0 or =1.',
showInDialog: true,
},
},

View file

@ -56,7 +56,7 @@ export default {
'to search history': "per cercar a l'historial",
'to paste images': 'per enganxar imatges',
'for external editor': 'per a editor extern',
'to toggle compact mode': 'per canviar el mode compacte',
'to view transcript': 'per veure la transcripció',
'Jump through words in the input': "Saltar entre paraules a l'entrada",
'Close dialogs, cancel requests, or quit application':
"Tancar diàlegs, cancel·lar peticions o sortir de l'aplicació",
@ -251,6 +251,42 @@ export default {
'Delete {{name}}': 'Eliminar {{name}}',
'Unknown Step': 'Pas desconegut',
'Esc to close': 'Esc per tancar',
Transcript: 'Transcripció',
'to close': 'per tancar',
'to scroll': 'per desplaçar',
'Failed to render transcript.': 'Error en renderitzar la transcripció.',
'Read {{count}} file': 'Ha llegit {{count}} fitxer',
'Read {{count}} files': 'Ha llegit {{count}} fitxers',
'Reading {{count}} file': 'Llegint {{count}} fitxer',
'Reading {{count}} files': 'Llegint {{count}} fitxers',
'Edited {{count}} file': 'Ha editat {{count}} fitxer',
'Edited {{count}} files': 'Ha editat {{count}} fitxers',
'Editing {{count}} file': 'Editant {{count}} fitxer',
'Editing {{count}} files': 'Editant {{count}} fitxers',
'Wrote {{count}} file': 'Ha escrit {{count}} fitxer',
'Wrote {{count}} files': 'Ha escrit {{count}} fitxers',
'Writing {{count}} file': 'Escrivint {{count}} fitxer',
'Writing {{count}} files': 'Escrivint {{count}} fitxers',
'Searched {{count}} pattern': 'Ha cercat {{count}} patró',
'Searched {{count}} patterns': 'Ha cercat {{count}} patrons',
'Searching {{count}} pattern': 'Cercant {{count}} patró',
'Searching {{count}} patterns': 'Cercant {{count}} patrons',
'Listed {{count}} directory': 'Ha llistat {{count}} directori',
'Listed {{count}} directories': 'Ha llistat {{count}} directoris',
'Listing {{count}} directory': 'Llistant {{count}} directori',
'Listing {{count}} directories': 'Llistant {{count}} directoris',
'Ran {{count}} command': 'Ha executat {{count}} ordre',
'Ran {{count}} commands': 'Ha executat {{count}} ordres',
'Running {{count}} command': 'Executant {{count}} ordre',
'Running {{count}} commands': 'Executant {{count}} ordres',
'Ran {{count}} agent': 'Ha executat {{count}} agent',
'Ran {{count}} agents': 'Ha executat {{count}} agents',
'Running {{count}} agent': 'Executant {{count}} agent',
'Running {{count}} agents': 'Executant {{count}} agents',
'Used {{count}} tool': 'Ha utilitzat {{count}} eina',
'Used {{count}} tools': 'Ha utilitzat {{count}} eines',
'Using {{count}} tool': 'Utilitzant {{count}} eina',
'Using {{count}} tools': 'Utilitzant {{count}} eines',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter per seleccionar, ↑↓ per navegar, Esc per tancar',
'Esc to go back': 'Esc per tornar enrere',
@ -1446,8 +1482,6 @@ export default {
'Podeu canviar ràpidament el mode de permisos amb Tab o /approval-mode.',
'Try /insight to generate personalized insights from your chat history.':
'Proveu /insight per generar idees personalitzades a partir del vostre historial de xat.',
'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.':
'Premeu Ctrl+O per canviar el mode compacte — amagueu la sortida de les eines i el pensament per a una vista més neta.',
'Add a QWEN.md file to give Qwen Code persistent project context.':
'Afegiu un fitxer QWEN.md per donar a Qwen Code un context persistent del projecte.',
'Use /btw to ask a quick side question without disrupting the conversation.':
@ -1914,10 +1948,6 @@ export default {
'El mode raw no està disponible. Executeu en un terminal interactiu.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Useu les fletxes ↑ ↓ per navegar, Enter per seleccionar, Ctrl+C per sortir)\n',
'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).':
'Amagueu la sortida de les eines i el pensament per a una vista més neta (canvieu amb Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Premeu Ctrl+O per mostrar la sortida completa de les eines',
'Switch to plan mode or exit plan mode':
'Canviar al mode de planificació o sortir del mode de planificació',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':

View file

@ -229,6 +229,42 @@ export default {
'Delete {{name}}': '{{name}} löschen',
'Unknown Step': 'Unbekannter Schritt',
'Esc to close': 'Esc zum Schließen',
Transcript: 'Transkript',
'to close': 'zum Schließen',
'to scroll': 'zum Scrollen',
'Failed to render transcript.': 'Transkript konnte nicht gerendert werden.',
'Read {{count}} file': '{{count}} Datei gelesen',
'Read {{count}} files': '{{count}} Dateien gelesen',
'Reading {{count}} file': 'Lese {{count}} Datei',
'Reading {{count}} files': 'Lese {{count}} Dateien',
'Edited {{count}} file': '{{count}} Datei bearbeitet',
'Edited {{count}} files': '{{count}} Dateien bearbeitet',
'Editing {{count}} file': 'Bearbeite {{count}} Datei',
'Editing {{count}} files': 'Bearbeite {{count}} Dateien',
'Wrote {{count}} file': '{{count}} Datei geschrieben',
'Wrote {{count}} files': '{{count}} Dateien geschrieben',
'Writing {{count}} file': 'Schreibe {{count}} Datei',
'Writing {{count}} files': 'Schreibe {{count}} Dateien',
'Searched {{count}} pattern': '{{count}} Muster durchsucht',
'Searched {{count}} patterns': '{{count}} Muster durchsucht',
'Searching {{count}} pattern': 'Durchsuche {{count}} Muster',
'Searching {{count}} patterns': 'Durchsuche {{count}} Muster',
'Listed {{count}} directory': '{{count}} Verzeichnis aufgelistet',
'Listed {{count}} directories': '{{count}} Verzeichnisse aufgelistet',
'Listing {{count}} directory': 'Liste {{count}} Verzeichnis auf',
'Listing {{count}} directories': 'Liste {{count}} Verzeichnisse auf',
'Ran {{count}} command': '{{count}} Befehl ausgeführt',
'Ran {{count}} commands': '{{count}} Befehle ausgeführt',
'Running {{count}} command': 'Führe {{count}} Befehl aus',
'Running {{count}} commands': 'Führe {{count}} Befehle aus',
'Ran {{count}} agent': '{{count}} Agent ausgeführt',
'Ran {{count}} agents': '{{count}} Agenten ausgeführt',
'Running {{count}} agent': 'Führe {{count}} Agent aus',
'Running {{count}} agents': 'Führe {{count}} Agenten aus',
'Used {{count}} tool': '{{count}} Werkzeug verwendet',
'Used {{count}} tools': '{{count}} Werkzeuge verwendet',
'Using {{count}} tool': 'Verwende {{count}} Werkzeug',
'Using {{count}} tools': 'Verwende {{count}} Werkzeuge',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter zum Auswählen, ↑↓ zum Navigieren, Esc zum Schließen',
'Esc to go back': 'Esc zum Zurückgehen',
@ -1708,8 +1744,6 @@ 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.':
'Ctrl+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.':
@ -1869,11 +1903,7 @@ export default {
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Ctrl+C zum Beenden)\n',
'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 Ctrl+O umschalten).',
'Press Ctrl+O to show full tool output':
'Ctrl+O für vollständige Tool-Ausgabe drücken',
'to view transcript': 'zum Anzeigen des Transkripts',
'Switch to plan mode or exit plan mode':
'In den Plan-Modus wechseln oder den Plan-Modus verlassen',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':

View file

@ -267,7 +267,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',
'to view transcript': 'to view transcript',
'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',
@ -487,6 +487,42 @@ export default {
'Delete {{name}}': 'Delete {{name}}',
'Unknown Step': 'Unknown Step',
'Esc to close': 'Esc to close',
Transcript: 'Transcript',
'to close': 'to close',
'to scroll': 'to scroll',
'Failed to render transcript.': 'Failed to render transcript.',
'Read {{count}} file': 'Read {{count}} file',
'Read {{count}} files': 'Read {{count}} files',
'Reading {{count}} file': 'Reading {{count}} file',
'Reading {{count}} files': 'Reading {{count}} files',
'Edited {{count}} file': 'Edited {{count}} file',
'Edited {{count}} files': 'Edited {{count}} files',
'Editing {{count}} file': 'Editing {{count}} file',
'Editing {{count}} files': 'Editing {{count}} files',
'Wrote {{count}} file': 'Wrote {{count}} file',
'Wrote {{count}} files': 'Wrote {{count}} files',
'Writing {{count}} file': 'Writing {{count}} file',
'Writing {{count}} files': 'Writing {{count}} files',
'Searched {{count}} pattern': 'Searched {{count}} pattern',
'Searched {{count}} patterns': 'Searched {{count}} patterns',
'Searching {{count}} pattern': 'Searching {{count}} pattern',
'Searching {{count}} patterns': 'Searching {{count}} patterns',
'Listed {{count}} directory': 'Listed {{count}} directory',
'Listed {{count}} directories': 'Listed {{count}} directories',
'Listing {{count}} directory': 'Listing {{count}} directory',
'Listing {{count}} directories': 'Listing {{count}} directories',
'Ran {{count}} command': 'Ran {{count}} command',
'Ran {{count}} commands': 'Ran {{count}} commands',
'Running {{count}} command': 'Running {{count}} command',
'Running {{count}} commands': 'Running {{count}} commands',
'Ran {{count}} agent': 'Ran {{count}} agent',
'Ran {{count}} agents': 'Ran {{count}} agents',
'Running {{count}} agent': 'Running {{count}} agent',
'Running {{count}} agents': 'Running {{count}} agents',
'Used {{count}} tool': 'Used {{count}} tool',
'Used {{count}} tools': 'Used {{count}} tools',
'Using {{count}} tool': 'Using {{count}} tool',
'Using {{count}} tools': 'Using {{count}} tools',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter to select, ↑↓ to navigate, Esc to close',
'Esc to go back': 'Esc to go back',
@ -1866,8 +1902,6 @@ 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.':
@ -2379,10 +2413,6 @@ export default {
'Raw mode not available. Please run in an interactive terminal.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n',
'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':
'Press Ctrl+O to show full tool output',
'Switch to plan mode or exit plan mode':
'Switch to plan mode or exit plan mode',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':

View file

@ -252,6 +252,42 @@ export default {
'Delete {{name}}': 'Supprimer {{name}}',
'Unknown Step': 'Étape inconnue',
'Esc to close': 'Esc pour fermer',
Transcript: 'Transcription',
'to close': 'pour fermer',
'to scroll': 'pour défiler',
'Failed to render transcript.': 'Échec du rendu de la transcription.',
'Read {{count}} file': 'Lu {{count}} fichier',
'Read {{count}} files': 'Lu {{count}} fichiers',
'Reading {{count}} file': 'Lecture de {{count}} fichier',
'Reading {{count}} files': 'Lecture de {{count}} fichiers',
'Edited {{count}} file': 'Modifié {{count}} fichier',
'Edited {{count}} files': 'Modifié {{count}} fichiers',
'Editing {{count}} file': 'Modification de {{count}} fichier',
'Editing {{count}} files': 'Modification de {{count}} fichiers',
'Wrote {{count}} file': 'Écrit {{count}} fichier',
'Wrote {{count}} files': 'Écrit {{count}} fichiers',
'Writing {{count}} file': 'Écriture de {{count}} fichier',
'Writing {{count}} files': 'Écriture de {{count}} fichiers',
'Searched {{count}} pattern': 'Recherché {{count}} motif',
'Searched {{count}} patterns': 'Recherché {{count}} motifs',
'Searching {{count}} pattern': 'Recherche de {{count}} motif',
'Searching {{count}} patterns': 'Recherche de {{count}} motifs',
'Listed {{count}} directory': 'Listé {{count}} répertoire',
'Listed {{count}} directories': 'Listé {{count}} répertoires',
'Listing {{count}} directory': 'Liste de {{count}} répertoire',
'Listing {{count}} directories': 'Liste de {{count}} répertoires',
'Ran {{count}} command': 'Exécuté {{count}} commande',
'Ran {{count}} commands': 'Exécuté {{count}} commandes',
'Running {{count}} command': 'Exécution de {{count}} commande',
'Running {{count}} commands': 'Exécution de {{count}} commandes',
'Ran {{count}} agent': 'Exécuté {{count}} agent',
'Ran {{count}} agents': 'Exécuté {{count}} agents',
'Running {{count}} agent': 'Exécution de {{count}} agent',
'Running {{count}} agents': 'Exécution de {{count}} agents',
'Used {{count}} tool': 'Utilisé {{count}} outil',
'Used {{count}} tools': 'Utilisé {{count}} outils',
'Using {{count}} tool': 'Utilisation de {{count}} outil',
'Using {{count}} tools': 'Utilisation de {{count}} outils',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter pour sélectionner, ↑↓ pour naviguer, Esc pour fermer',
'Esc to go back': 'Esc pour revenir',
@ -1913,10 +1949,6 @@ export default {
'Mode brut non disponible. Veuillez exécuter dans un terminal interactif.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Utilisez les flèches ↑ ↓ pour naviguer, Enter pour sélectionner, Ctrl+C pour quitter)\n',
'Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).':
'Masquer la sortie des outils et la réflexion pour une vue plus nette (basculer avec Ctrl+O).',
'Press Ctrl+O to show full tool output':
'Appuyez sur Ctrl+O pour afficher la sortie complète des outils',
'Switch to plan mode or exit plan mode':
'Passer en mode plan ou quitter le mode plan',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':
@ -2004,7 +2036,7 @@ export default {
'Afficher le détail de lutilisation du contexte par élément.',
// === Missing key backfill ===
'to toggle compact mode': 'basculer le mode compact',
'to view transcript': 'pour voir la transcription',
'The name of the extension to update.':
"Le nom de l'extension à mettre à jour.",
'Session (temporary)': 'Session (temporaire)',
@ -2030,8 +2062,6 @@ export default {
"Demande de copie envoyée au terminal. Si le collage est vide, copiez manuellement l'URL ci-dessus.",
'Cannot write to terminal — copy the URL above manually.':
"Impossible d'écrire dans le terminal — copiez manuellement l'URL ci-dessus.",
'Press Ctrl+O to toggle compact mode — hide tool output and thinking for a cleaner view.':
'Appuyez sur Ctrl+O pour basculer le mode compact — masquer la sortie des outils et la réflexion pour une vue plus nette.',
'Invalid API key. Coding Plan API keys start with "sk-sp-". Please check.':
'API Key invalide. Les Coding Plan API Keys commencent par "sk-sp-". Veuillez vérifier.',
'Lock release warning': 'Avertissement de libération du verrou',

View file

@ -205,6 +205,43 @@ export default {
'Delete {{name}}': '{{name}} を削除',
'Unknown Step': '不明なステップ',
'Esc to close': 'Esc で閉じる',
Transcript: 'トランスクリプト',
'to close': '閉じる',
'to scroll': 'スクロール',
'Failed to render transcript.': 'トランスクリプトの描画に失敗しました。',
'Read {{count}} file': '{{count}} 件のファイルを読み込みました',
'Read {{count}} files': '{{count}} 件のファイルを読み込みました',
'Reading {{count}} file': '{{count}} 件のファイルを読み込み中',
'Reading {{count}} files': '{{count}} 件のファイルを読み込み中',
'Edited {{count}} file': '{{count}} 件のファイルを編集しました',
'Edited {{count}} files': '{{count}} 件のファイルを編集しました',
'Editing {{count}} file': '{{count}} 件のファイルを編集中',
'Editing {{count}} files': '{{count}} 件のファイルを編集中',
'Wrote {{count}} file': '{{count}} 件のファイルを書き込みました',
'Wrote {{count}} files': '{{count}} 件のファイルを書き込みました',
'Writing {{count}} file': '{{count}} 件のファイルを書き込み中',
'Writing {{count}} files': '{{count}} 件のファイルを書き込み中',
'Searched {{count}} pattern': '{{count}} 件のパターンを検索しました',
'Searched {{count}} patterns': '{{count}} 件のパターンを検索しました',
'Searching {{count}} pattern': '{{count}} 件のパターンを検索中',
'Searching {{count}} patterns': '{{count}} 件のパターンを検索中',
'Listed {{count}} directory': '{{count}} 件のディレクトリを一覧表示しました',
'Listed {{count}} directories':
'{{count}} 件のディレクトリを一覧表示しました',
'Listing {{count}} directory': '{{count}} 件のディレクトリを一覧表示中',
'Listing {{count}} directories': '{{count}} 件のディレクトリを一覧表示中',
'Ran {{count}} command': '{{count}} 件のコマンドを実行しました',
'Ran {{count}} commands': '{{count}} 件のコマンドを実行しました',
'Running {{count}} command': '{{count}} 件のコマンドを実行中',
'Running {{count}} commands': '{{count}} 件のコマンドを実行中',
'Ran {{count}} agent': '{{count}} 件のエージェントを実行しました',
'Ran {{count}} agents': '{{count}} 件のエージェントを実行しました',
'Running {{count}} agent': '{{count}} 件のエージェントを実行中',
'Running {{count}} agents': '{{count}} 件のエージェントを実行中',
'Used {{count}} tool': '{{count}} 件のツールを使用しました',
'Used {{count}} tools': '{{count}} 件のツールを使用しました',
'Using {{count}} tool': '{{count}} 件のツールを使用中',
'Using {{count}} tools': '{{count}} 件のツールを使用中',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter で選択、↑↓ で移動、Esc で閉じる',
'Esc to go back': 'Esc で戻る',
@ -1139,8 +1176,6 @@ 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.':
@ -1406,10 +1441,7 @@ export default {
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
'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 で完全なツール出力を表示',
'to view transcript': 'トランスクリプトを表示',
'Switch to plan mode or exit plan mode':
'プランモードに切り替えるか、プランモードを終了する',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':

View file

@ -52,7 +52,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',
'to view transcript': 'para ver a transcrição',
'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',
@ -244,6 +244,42 @@ export default {
'Delete {{name}}': 'Excluir {{name}}',
'Unknown Step': 'Etapa Desconhecida',
'Esc to close': 'Esc para fechar',
Transcript: 'Transcrição',
'to close': 'para fechar',
'to scroll': 'para rolar',
'Failed to render transcript.': 'Falha ao renderizar a transcrição.',
'Read {{count}} file': 'Leu {{count}} arquivo',
'Read {{count}} files': 'Leu {{count}} arquivos',
'Reading {{count}} file': 'Lendo {{count}} arquivo',
'Reading {{count}} files': 'Lendo {{count}} arquivos',
'Edited {{count}} file': 'Editou {{count}} arquivo',
'Edited {{count}} files': 'Editou {{count}} arquivos',
'Editing {{count}} file': 'Editando {{count}} arquivo',
'Editing {{count}} files': 'Editando {{count}} arquivos',
'Wrote {{count}} file': 'Escreveu {{count}} arquivo',
'Wrote {{count}} files': 'Escreveu {{count}} arquivos',
'Writing {{count}} file': 'Escrevendo {{count}} arquivo',
'Writing {{count}} files': 'Escrevendo {{count}} arquivos',
'Searched {{count}} pattern': 'Pesquisou {{count}} padrão',
'Searched {{count}} patterns': 'Pesquisou {{count}} padrões',
'Searching {{count}} pattern': 'Pesquisando {{count}} padrão',
'Searching {{count}} patterns': 'Pesquisando {{count}} padrões',
'Listed {{count}} directory': 'Listou {{count}} diretório',
'Listed {{count}} directories': 'Listou {{count}} diretórios',
'Listing {{count}} directory': 'Listando {{count}} diretório',
'Listing {{count}} directories': 'Listando {{count}} diretórios',
'Ran {{count}} command': 'Executou {{count}} comando',
'Ran {{count}} commands': 'Executou {{count}} comandos',
'Running {{count}} command': 'Executando {{count}} comando',
'Running {{count}} commands': 'Executando {{count}} comandos',
'Ran {{count}} agent': 'Executou {{count}} agente',
'Ran {{count}} agents': 'Executou {{count}} agentes',
'Running {{count}} agent': 'Executando {{count}} agente',
'Running {{count}} agents': 'Executando {{count}} agentes',
'Used {{count}} tool': 'Usou {{count}} ferramenta',
'Used {{count}} tools': 'Usou {{count}} ferramentas',
'Using {{count}} tool': 'Usando {{count}} ferramenta',
'Using {{count}} tools': 'Usando {{count}} ferramentas',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter para selecionar, ↑↓ para navegar, Esc para fechar',
'Esc to go back': 'Esc para voltar',
@ -1412,8 +1448,6 @@ 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.':
@ -1865,10 +1899,6 @@ export default {
'Modo raw não disponível. Execute em um terminal interativo.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n',
'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':
'Pressione Ctrl+O para exibir a saída completa da ferramenta',
'Switch to plan mode or exit plan mode':
'Alternar para o modo de planejamento ou sair do modo de planejamento',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':

View file

@ -86,7 +86,7 @@ export default {
'to search history': 'поиск в истории',
'to paste images': 'вставить изображения',
'for external editor': 'внешний редактор',
'to toggle compact mode': 'переключить компактный режим',
'to view transcript': 'показать транскрипт',
// ============================================================================
// Поля системной информации
@ -251,6 +251,42 @@ export default {
'Delete {{name}}': 'Удалить {{name}}',
'Unknown Step': 'Неизвестный шаг',
'Esc to close': 'Esc для закрытия',
Transcript: 'Транскрипт',
'to close': 'закрыть',
'to scroll': 'прокрутить',
'Failed to render transcript.': 'Не удалось отобразить транскрипт.',
'Read {{count}} file': 'Прочитано файлов: {{count}}',
'Read {{count}} files': 'Прочитано файлов: {{count}}',
'Reading {{count}} file': 'Чтение файлов: {{count}}',
'Reading {{count}} files': 'Чтение файлов: {{count}}',
'Edited {{count}} file': 'Изменено файлов: {{count}}',
'Edited {{count}} files': 'Изменено файлов: {{count}}',
'Editing {{count}} file': 'Изменение файлов: {{count}}',
'Editing {{count}} files': 'Изменение файлов: {{count}}',
'Wrote {{count}} file': 'Записано файлов: {{count}}',
'Wrote {{count}} files': 'Записано файлов: {{count}}',
'Writing {{count}} file': 'Запись файлов: {{count}}',
'Writing {{count}} files': 'Запись файлов: {{count}}',
'Searched {{count}} pattern': 'Найдено шаблонов: {{count}}',
'Searched {{count}} patterns': 'Найдено шаблонов: {{count}}',
'Searching {{count}} pattern': 'Поиск шаблонов: {{count}}',
'Searching {{count}} patterns': 'Поиск шаблонов: {{count}}',
'Listed {{count}} directory': 'Показано каталогов: {{count}}',
'Listed {{count}} directories': 'Показано каталогов: {{count}}',
'Listing {{count}} directory': 'Просмотр каталогов: {{count}}',
'Listing {{count}} directories': 'Просмотр каталогов: {{count}}',
'Ran {{count}} command': 'Выполнено команд: {{count}}',
'Ran {{count}} commands': 'Выполнено команд: {{count}}',
'Running {{count}} command': 'Выполнение команд: {{count}}',
'Running {{count}} commands': 'Выполнение команд: {{count}}',
'Ran {{count}} agent': 'Запущено агентов: {{count}}',
'Ran {{count}} agents': 'Запущено агентов: {{count}}',
'Running {{count}} agent': 'Запуск агентов: {{count}}',
'Running {{count}} agents': 'Запуск агентов: {{count}}',
'Used {{count}} tool': 'Использовано инструментов: {{count}}',
'Used {{count}} tools': 'Использовано инструментов: {{count}}',
'Using {{count}} tool': 'Использование инструментов: {{count}}',
'Using {{count}} tools': 'Использование инструментов: {{count}}',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter для выбора, ↑↓ для навигации, Esc для закрытия',
'Esc to go back': 'Esc для возврата',
@ -1659,8 +1695,6 @@ 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.':
@ -1871,10 +1905,6 @@ export default {
'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n',
'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 для показа полного вывода инструментов',
'Switch to plan mode or exit plan mode':
'Переключиться в режим плана или выйти из режима плана',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':

View file

@ -252,7 +252,7 @@ export default {
'to search history': '搜索歷史',
'to paste images': '粘貼圖片',
'for external editor': '外部編輯器',
'to toggle compact mode': '切換緊湊模式',
'to view transcript': '檢視完整記錄',
'Jump through words in the input': '在輸入中按單詞跳轉',
'Close dialogs, cancel requests, or quit application':
'關閉對話框、取消請求或退出應用程序',
@ -445,6 +445,42 @@ export default {
'Delete {{name}}': '刪除 {{name}}',
'Unknown Step': '未知步驟',
'Esc to close': '按 Esc 關閉',
Transcript: '完整記錄',
'to close': '關閉',
'to scroll': '捲動',
'Failed to render transcript.': '無法呈現完整記錄。',
'Read {{count}} file': '讀取了 {{count}} 個檔案',
'Read {{count}} files': '讀取了 {{count}} 個檔案',
'Reading {{count}} file': '正在讀取 {{count}} 個檔案',
'Reading {{count}} files': '正在讀取 {{count}} 個檔案',
'Edited {{count}} file': '編輯了 {{count}} 個檔案',
'Edited {{count}} files': '編輯了 {{count}} 個檔案',
'Editing {{count}} file': '正在編輯 {{count}} 個檔案',
'Editing {{count}} files': '正在編輯 {{count}} 個檔案',
'Wrote {{count}} file': '寫入了 {{count}} 個檔案',
'Wrote {{count}} files': '寫入了 {{count}} 個檔案',
'Writing {{count}} file': '正在寫入 {{count}} 個檔案',
'Writing {{count}} files': '正在寫入 {{count}} 個檔案',
'Searched {{count}} pattern': '搜尋了 {{count}} 個模式',
'Searched {{count}} patterns': '搜尋了 {{count}} 個模式',
'Searching {{count}} pattern': '正在搜尋 {{count}} 個模式',
'Searching {{count}} patterns': '正在搜尋 {{count}} 個模式',
'Listed {{count}} directory': '列出了 {{count}} 個目錄',
'Listed {{count}} directories': '列出了 {{count}} 個目錄',
'Listing {{count}} directory': '正在列出 {{count}} 個目錄',
'Listing {{count}} directories': '正在列出 {{count}} 個目錄',
'Ran {{count}} command': '執行了 {{count}} 個命令',
'Ran {{count}} commands': '執行了 {{count}} 個命令',
'Running {{count}} command': '正在執行 {{count}} 個命令',
'Running {{count}} commands': '正在執行 {{count}} 個命令',
'Ran {{count}} agent': '執行了 {{count}} 個代理',
'Ran {{count}} agents': '執行了 {{count}} 個代理',
'Running {{count}} agent': '正在執行 {{count}} 個代理',
'Running {{count}} agents': '正在執行 {{count}} 個代理',
'Used {{count}} tool': '使用了 {{count}} 個工具',
'Used {{count}} tools': '使用了 {{count}} 個工具',
'Using {{count}} tool': '正在使用 {{count}} 個工具',
'Using {{count}} tools': '正在使用 {{count}} 個工具',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter 選擇,↑↓ 導航Esc 關閉',
'Esc to go back': '按 Esc 返回',
@ -1628,8 +1664,6 @@ 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.':
@ -1920,9 +1954,6 @@ export default {
'原始模式不可用。請在交互式終端中運行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭頭導航Enter 選擇Ctrl+C 退出)\n',
'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 查看詳細工具調用結果',
'Switch to plan mode or exit plan mode': '切換到計劃模式或退出計劃模式',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':
'設定具備推理能力的模型思考的強度({{tiers}});依各供應商進行映射與鉗制。',

View file

@ -257,7 +257,7 @@ export default {
'to search history': '搜索历史',
'to paste images': '粘贴图片',
'for external editor': '外部编辑器',
'to toggle compact mode': '切换紧凑模式',
'to view transcript': '查看完整记录',
'Jump through words in the input': '在输入中按单词跳转',
'Close dialogs, cancel requests, or quit application':
'关闭对话框、取消请求或退出应用程序',
@ -470,6 +470,42 @@ export default {
'Delete {{name}}': '删除 {{name}}',
'Unknown Step': '未知步骤',
'Esc to close': '按 Esc 关闭',
Transcript: '完整记录',
'to close': '关闭',
'to scroll': '滚动',
'Failed to render transcript.': '无法渲染完整记录。',
'Read {{count}} file': '读取了 {{count}} 个文件',
'Read {{count}} files': '读取了 {{count}} 个文件',
'Reading {{count}} file': '正在读取 {{count}} 个文件',
'Reading {{count}} files': '正在读取 {{count}} 个文件',
'Edited {{count}} file': '编辑了 {{count}} 个文件',
'Edited {{count}} files': '编辑了 {{count}} 个文件',
'Editing {{count}} file': '正在编辑 {{count}} 个文件',
'Editing {{count}} files': '正在编辑 {{count}} 个文件',
'Wrote {{count}} file': '写入了 {{count}} 个文件',
'Wrote {{count}} files': '写入了 {{count}} 个文件',
'Writing {{count}} file': '正在写入 {{count}} 个文件',
'Writing {{count}} files': '正在写入 {{count}} 个文件',
'Searched {{count}} pattern': '搜索了 {{count}} 个模式',
'Searched {{count}} patterns': '搜索了 {{count}} 个模式',
'Searching {{count}} pattern': '正在搜索 {{count}} 个模式',
'Searching {{count}} patterns': '正在搜索 {{count}} 个模式',
'Listed {{count}} directory': '列出了 {{count}} 个目录',
'Listed {{count}} directories': '列出了 {{count}} 个目录',
'Listing {{count}} directory': '正在列出 {{count}} 个目录',
'Listing {{count}} directories': '正在列出 {{count}} 个目录',
'Ran {{count}} command': '运行了 {{count}} 个命令',
'Ran {{count}} commands': '运行了 {{count}} 个命令',
'Running {{count}} command': '正在运行 {{count}} 个命令',
'Running {{count}} commands': '正在运行 {{count}} 个命令',
'Ran {{count}} agent': '运行了 {{count}} 个智能体',
'Ran {{count}} agents': '运行了 {{count}} 个智能体',
'Running {{count}} agent': '正在运行 {{count}} 个智能体',
'Running {{count}} agents': '正在运行 {{count}} 个智能体',
'Used {{count}} tool': '使用了 {{count}} 个工具',
'Used {{count}} tools': '使用了 {{count}} 个工具',
'Using {{count}} tool': '正在使用 {{count}} 个工具',
'Using {{count}} tools': '正在使用 {{count}} 个工具',
'Enter to select, ↑↓ to navigate, Esc to close':
'Enter 选择,↑↓ 导航Esc 关闭',
'Esc to go back': '按 Esc 返回',
@ -1785,8 +1821,6 @@ 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.':
@ -2126,9 +2160,6 @@ export default {
'原始模式不可用。请在交互式终端中运行。',
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
'(使用 ↑ ↓ 箭头导航Enter 选择Ctrl+C 退出)\n',
'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 查看详细工具调用结果',
'Switch to plan mode or exit plan mode': '切换到计划模式或退出计划模式',
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':
'设置具备推理能力的模型思考的强度({{tiers}});按各提供方进行映射与钳制。',

View file

@ -179,13 +179,4 @@ 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,
},
];

View file

@ -3494,106 +3494,123 @@ describe('AppContainer State Management', () => {
// structurally present.
expect(abortSpy).not.toHaveBeenCalled();
});
describe('Ctrl+O compact mode toggle (issue #3899)', () => {
const ctrlOKey: Key = {
name: 'o',
ctrl: true,
});
describe('Transcript (Ctrl+O) integration', () => {
// The frozen transcript (TranscriptView) renders its title row as the
// literal "Transcript"; the main view never does, so its presence in the
// rendered frame is a reliable open/closed signal.
const TRANSCRIPT_MARKER = 'Transcript';
const makeKey = (overrides: Partial<Key>): Key =>
({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '',
};
sequence: '',
...overrides,
}) as Key;
// The global handler is the one that calls compactToggleHasVisualEffect.
// Mirrors the discriminator pattern used by the renderMode test above.
const findGlobalKeypressHandler = () =>
mockedUseKeypress.mock.calls
.map((call) => call[0])
.reverse()
.find(
(handler): handler is (key: Key) => void =>
typeof handler === 'function' &&
handler.toString().includes('compactToggleHasVisualEffect'),
);
// The global keypress handler owns Ctrl+O / the transcript close keys; it is
// the registered useKeypress handler whose body references TOGGLE_TRANSCRIPT.
const getGlobalKeypress = () =>
mockedUseKeypress.mock.calls
.map((call) => call[0])
.reverse()
.find(
(handler): handler is (key: Key) => void =>
typeof handler === 'function' &&
handler.toString().includes('TOGGLE_TRANSCRIPT'),
) as ((key: Key) => void) | undefined;
it('skips refreshStatic on Ctrl+O when history has no tool_group/thought items', () => {
mockedUseHistory.mockReturnValue({
history: [
{ type: 'user', id: 1, text: 'hi' },
{ type: 'gemini', id: 2, text: 'hello' },
],
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: vi.fn(),
});
const ctrlO = makeKey({ name: 'o', ctrl: true, sequence: '\x0f' });
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
mockStdout.write.mockClear();
const renderApp = () =>
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
const handler = findGlobalKeypressHandler();
expect(handler).toBeDefined();
handler!(ctrlOKey);
it('Ctrl+O installs the TranscriptView in the rendered tree', () => {
const { lastFrame } = renderApp();
const handleKeypress = getGlobalKeypress();
expect(handleKeypress).toBeDefined();
// Baseline: the marker also confirms the main view doesn't render it.
expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER);
act(() => handleKeypress!(ctrlO));
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
});
// refreshStatic writes ansiEscapes.clearTerminal — its absence
// proves we took the no-op short-circuit.
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
});
it('skips refreshStatic on Ctrl+O when history contains only tool_group (no visual effect)', () => {
mockedUseHistory.mockReturnValue({
history: [
{ type: 'user', id: 1, text: 'run ls' },
{
type: 'tool_group',
id: 2,
tools: [
{
callId: 'c1',
name: 'shell',
description: 'shell description',
status: ToolCallStatus.Success,
resultDisplay: undefined,
confirmationDetails: undefined,
},
],
},
],
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: vi.fn(),
});
render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
mockStdout.write.mockClear();
const handler = findGlobalKeypressHandler();
expect(handler).toBeDefined();
handler!(ctrlOKey);
expect(mockStdout.write).not.toHaveBeenCalledWith(
ansiEscapes.clearTerminal,
);
it.each([
['Esc', makeKey({ name: 'escape', sequence: '\x1b' })],
['q', makeKey({ name: 'q', sequence: 'q' })],
['Ctrl+C', makeKey({ name: 'c', ctrl: true, sequence: '\x03' })],
['Ctrl+D', makeKey({ name: 'd', ctrl: true, sequence: '\x04' })],
// Ctrl+O is the toggle: pressing it again while open also closes.
['Ctrl+O', ctrlO],
])('%s while open removes the TranscriptView', (_label, closeKey) => {
const { lastFrame } = renderApp();
const handleKeypress = getGlobalKeypress()!;
act(() => handleKeypress(ctrlO));
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
act(() => handleKeypress(closeKey));
expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER);
});
it.each([
['Ctrl+Q', makeKey({ name: 'q', ctrl: true, sequence: '\x11' })],
['Alt+Q', makeKey({ name: 'q', meta: true, sequence: '\x1bq' })],
['Shift+Q', makeKey({ name: 'q', shift: true, sequence: 'Q' })],
])(
'%s does NOT close the transcript (bare-q modifier guard)',
(_l, modQ) => {
const { lastFrame } = renderApp();
const handleKeypress = getGlobalKeypress()!;
act(() => handleKeypress(ctrlO));
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
act(() => handleKeypress(modQ));
// Only a bare `q` closes; modified variants stay swallowed but open.
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
},
);
it('swallows arbitrary keys while open and keeps the transcript open', () => {
const { lastFrame } = renderApp();
const handleKeypress = getGlobalKeypress()!;
act(() => handleKeypress(ctrlO));
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
expect(() =>
act(() => handleKeypress(makeKey({ name: 'x', sequence: 'x' }))),
).not.toThrow();
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
});
it('auto-closes when a blocking confirmation appears (anti-deadlock)', () => {
// A blocking prompt would be invisible behind the alt-screen transcript;
// the needsBlockingInput effect must close it on the same commit.
mockedUseGeminiStream.mockReturnValue({
streamingState: 'waiting_for_confirmation',
submitQuery: vi.fn(),
initError: null,
pendingHistoryItems: [],
thought: null,
cancelOngoingRequest: vi.fn(),
retryLastPrompt: vi.fn(),
streamingResponseLengthRef: { current: 0 },
isReceivingContent: false,
});
const { lastFrame } = renderApp();
const handleKeypress = getGlobalKeypress()!;
act(() => handleKeypress(ctrlO));
// openTranscript sets the freeze, but the anti-deadlock effect tears it
// back down on the same commit, so it never stays visible.
expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER);
});
});

View file

@ -30,7 +30,11 @@ import {
type HistoryItemWithoutId,
} from './types.js';
import type { RestoreOption } from './components/RewindSelector.js';
import { MessageType, StreamingState } from './types.js';
import {
MessageType,
StreamingState,
isHistoryItemVisibleAfterRestore,
} from './types.js';
import {
type EditorType,
type Config,
@ -138,7 +142,6 @@ import {
useVimModeState,
useVimModeActions,
} from './contexts/VimModeContext.js';
import { CompactModeProvider } from './contexts/CompactModeContext.js';
import { ThoughtExpandedProvider } from './contexts/ThoughtExpandedContext.js';
import { useTerminalSize } from './hooks/useTerminalSize.js';
import { calculatePromptWidths } from './components/InputPrompt.js';
@ -204,6 +207,7 @@ import {
type RenderMode,
} from './contexts/RenderModeContext.js';
import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js';
import { TranscriptView } from './components/TranscriptView.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import {
useBackgroundTaskViewState,
@ -232,7 +236,6 @@ import {
requestConsentInteractive,
requestConsentOrFail,
} from '../commands/extensions/consent.js';
import { compactToggleHasVisualEffect } from './utils/mergeCompactToolGroups.js';
import {
findLastUserItemIndex,
isSyntheticHistoryItem,
@ -243,6 +246,10 @@ import { MAIN_CONTENT_HEIGHT_RESERVATION } from './utils/layoutUtils.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
const debugLogger = createDebugLogger('APP_CONTAINER');
// Stable empty reference for the transcript items memo when no snapshot is
// frozen, so the memo never hands TranscriptView a fresh [] each render.
const EMPTY_HISTORY_ITEMS: HistoryItem[] = [];
export function isRenderModeToggleKey(key: Key): boolean {
return (
keyMatchers[Command.TOGGLE_RENDER_MODE](key) ||
@ -560,6 +567,26 @@ export const AppContainer = (props: AppContainerProps) => {
const [userMessages, setUserMessages] = useState<string[]>([]);
// Transcript full-detail screen (Ctrl+O). Freezes a snapshot of the
// conversation at entry time. Both committed history and the streaming
// `pendingHistoryItems` are stored as shallow copies (`.slice()` / spread):
// the snapshot must stay stable while open, but `useMemoryMonitor` →
// `compactOldItems` can replace `historyManager.history` with a rewritten
// array (collapsed tool groups, merged thoughts, shifted indices) mid-view.
// Re-slicing the live array at render would let that rewrite visibly corrupt
// the "frozen" transcript, so we pin the array of item references here. A
// shallow copy is cheap (references only) even for long sessions.
const [transcriptFreeze, setTranscriptFreeze] = useState<{
committedItems: HistoryItem[];
pendingItems: HistoryItemWithoutId[];
} | null>(null);
const isTranscriptOpen = transcriptFreeze != null;
const isTranscriptOpenRef = useRef(isTranscriptOpen);
isTranscriptOpenRef.current = isTranscriptOpen;
const closeTranscript = useCallback(() => {
setTranscriptFreeze(null);
}, []);
// Alt+T inline expansion toggle for thinking blocks (expands all at once).
const [thoughtExpanded, setThoughtExpanded] = useState(false);
// Per-thought inline expansion: head ids the user expanded by clicking the
@ -1054,12 +1081,68 @@ export const AppContainer = (props: AppContainerProps) => {
const useTerminalBuffer = settings.merged.ui?.useTerminalBuffer ?? false;
const showScrollbar = settings.merged.ui?.showScrollbar ?? true;
const refreshStatic = useCallback(() => {
// While the transcript (alt-screen) owns the whole screen, suppress static
// refreshes (e.g. resize-settle repaints) so they don't write into / reorder
// the normal-buffer scrollback that is currently hidden behind the alt
// screen. On transcript close the
// AlternateScreen unmount restores the normal buffer; the next legitimate
// refreshStatic (model change, Alt+T, etc.) repaints as usual.
if (isTranscriptOpenRef.current) {
return;
}
if (!useTerminalBuffer) {
stdout.write(ansiEscapes.clearTerminal);
}
remountStaticHistory();
}, [useTerminalBuffer, remountStaticHistory, stdout]);
// Repaint the normal buffer once when the transcript (alt-screen) closes.
// In the legacy <Static> path the normal buffer still holds the pre-transcript
// frame; remounting the main tree would append the committed history a second
// time (the transcript's full-detail rows leaking into scrollback). Force one
// clear + Static remount AFTER the AlternateScreen's exit escape (?1049l) has
// flushed — deferred a tick so the buffer switch lands first, and run outside
// the during-transcript guard above (which has already cleared by now). VP
// mode keeps its own scrollback via the React tree, so this is non-VP only.
// Snapshot the previous-render value during render (not inside the effect),
// so React.StrictMode's double-invoke of the effect can't read a value the
// effect itself just wrote — `wasOpenPrevRender` is always a true previous
// render snapshot.
const prevTranscriptOpenRef = useRef(isTranscriptOpen);
const wasOpenPrevRender = prevTranscriptOpenRef.current;
prevTranscriptOpenRef.current = isTranscriptOpen;
// Bump a counter on each close transition and use IT — not `wasOpenPrevRender`
// / `isTranscriptOpen` — as the effect's only changing trigger. If those were
// in the dep array (as they were originally), the very next streaming
// re-render flips `wasOpenPrevRender` back to false, the deps change, cleanup
// runs, and `clearTimeout` cancels the pending repaint before it fires —
// leaving stale pre-transcript content in the normal buffer. With the counter,
// post-close re-renders don't change the deps, so the scheduled repaint
// survives and fires exactly once per close.
const transcriptCloseCountRef = useRef(0);
if (wasOpenPrevRender && !isTranscriptOpen) {
transcriptCloseCountRef.current += 1;
}
const transcriptCloseCount = transcriptCloseCountRef.current;
useEffect(() => {
if (transcriptCloseCount === 0 || useTerminalBuffer) {
return undefined;
}
// Guard the clear-screen write on stdout being a TTY: with stdout piped or
// redirected (`qwen | tee log`) the transcript degrades to in-buffer
// rendering (AlternateScreen skips its escapes on non-TTY), so emitting
// `clearTerminal` here would leak raw control bytes into the captured
// output without ever having taken over a screen to repaint.
if (!stdout.isTTY) {
return undefined;
}
const id = setTimeout(() => {
stdout.write(ansiEscapes.clearTerminal);
remountStaticHistory();
}, 0);
return () => clearTimeout(id);
}, [transcriptCloseCount, useTerminalBuffer, stdout, remountStaticHistory]);
// Keep the static header in sync with model changes without polling.
// Ink's <Static> output is append-only, so model changes must explicitly
// clear and remount the static region to redraw the banner at the top.
@ -2241,6 +2324,47 @@ export const AppContainer = (props: AppContainerProps) => {
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
// Read history/pending through refs so `openTranscript` stays referentially
// stable. Both arrays change identity on every streaming tick; capturing them
// as deps would rebuild this callback — and, since `handleGlobalKeypress`
// lists it in its deps, the entire keypress-handler closure — on every render
// during active streaming. The callback only ever runs on a Ctrl+O press, so
// reading the latest values via refs at call time is sufficient.
const historyForTranscriptRef = useRef(historyManager.history);
historyForTranscriptRef.current = historyManager.history;
const pendingForTranscriptRef = useRef(pendingHistoryItems);
pendingForTranscriptRef.current = pendingHistoryItems;
const openTranscript = useCallback(() => {
setTranscriptFreeze({
// Share MainContent's visibility predicate so the transcript shows exactly
// what the main view shows. Items collapsed on session resume
// (ui.history.collapseOnResume) are represented by their collapse-summary
// row and must NOT be re-exposed in the Ctrl+O view.
committedItems: historyForTranscriptRef.current.filter(
isHistoryItemVisibleAfterRestore,
),
pendingItems: [...pendingForTranscriptRef.current],
});
}, []);
// Build the transcript item list from the frozen snapshot only. Recomputes
// on open/close (when `transcriptFreeze` flips), not on every streaming tick,
// so the array reference stays stable while open — combined with the
// React.memo'd TranscriptView this avoids re-running its VirtualizedList
// offset/render memos on every AppContainer re-render during streaming.
const transcriptItems = useMemo<HistoryItem[]>(() => {
if (!transcriptFreeze) return EMPTY_HISTORY_ITEMS;
return [
...transcriptFreeze.committedItems,
// Pending snapshot gets negative ids (mirrors MainContent's `id: -(i+1)`)
// so keys never collide with committed history items.
...transcriptFreeze.pendingItems.map((item, i) => ({
...item,
id: -(i + 1),
})),
];
}, [transcriptFreeze]);
const rawStickyTodos = useMemo(
() => getStickyTodos(historyManager.history, pendingHistoryItems),
[historyManager.history, pendingHistoryItems],
@ -2663,12 +2787,6 @@ export const AppContainer = (props: AppContainerProps) => {
const [showToolDescriptions, setShowToolDescriptions] =
useState<boolean>(false);
const [compactMode, setCompactMode] = useState<boolean>(
settings.merged.ui?.compactMode ?? false,
);
const [compactInline] = useState<boolean>(
settings.merged.ui?.compactInline ?? false,
);
const configuredRenderMode = settings.merged.ui?.renderMode;
const [renderMode, setRenderMode] = useState<RenderMode>(
configuredRenderMode === 'raw' ? 'raw' : 'render',
@ -2777,6 +2895,27 @@ export const AppContainer = (props: AppContainerProps) => {
showWorktreeExitDialog ||
!!(settings.corruptedPath && !settings.corruptionDialogDismissed);
dialogsVisibleRef.current = dialogsVisible;
// Anti-deadlock: the transcript takes over the whole screen via alt-screen,
// so any blocking confirmation/dialog (or a tool awaiting confirmation) would
// be invisible and unanswerable behind it. Auto-close the transcript whenever
// one appears so the user can see and respond. `dialogsVisible` already
// aggregates every blocking request surfaced by DialogManager
// (shellConfirmationRequest / loopDetectionConfirmationRequest /
// confirmationRequest / confirmUpdateExtensionRequests / providerUpdateRequest
// and friends); WaitingForConfirmation covers the inline tool-approval path.
const needsBlockingInput =
dialogsVisible || streamingState === StreamingState.WaitingForConfirmation;
useEffect(() => {
if (needsBlockingInput && isTranscriptOpen) {
closeTranscript();
}
// `isTranscriptOpen` must be a dependency (not just read via ref): if a
// blocking prompt is already visible when the user opens the transcript,
// `needsBlockingInput` doesn't change, so without this the effect wouldn't
// re-fire and the transcript would open over an invisible prompt.
}, [needsBlockingInput, isTranscriptOpen, closeTranscript]);
const shouldShowStickyTodos =
stickyTodos !== null &&
!dialogsVisible &&
@ -3417,6 +3556,33 @@ export const AppContainer = (props: AppContainerProps) => {
debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key));
}
// Transcript full-detail screen owns all input while open. This MUST be
// the first branch — earlier than QUIT(Ctrl+C) / EXIT(Ctrl+D) / ESCAPE
// (and its vim-INSERT guard) — so Ctrl+C/Esc close the transcript
// instead of triggering quit / being swallowed by vim. TranscriptView's
// own ScrollableList handles the scroll keys; we swallow everything else
// here so a single broadcast keypress isn't double-handled.
if (isTranscriptOpenRef.current) {
if (
keyMatchers[Command.ESCAPE](key) ||
// Bare `q` only — Ink reports Ctrl/Alt/Shift+Q as `{ name: 'q', … }`
// too (Alt arrives as `meta`), so guard every modifier to avoid those
// silently closing it (Shift+Q is the user typing a literal `Q`, not
// a close request).
(key.name === 'q' && !key.ctrl && !key.meta && !key.shift) ||
keyMatchers[Command.QUIT](key) ||
keyMatchers[Command.EXIT](key) ||
keyMatchers[Command.TOGGLE_TRANSCRIPT](key)
) {
// Esc / q / Ctrl+C / Ctrl+D / Ctrl+O all just close the transcript.
// EXIT (Ctrl+D) is included so it isn't silently swallowed by the
// blanket return below — the transcript is a transient overlay, so we
// close it rather than fall through to app exit.
closeTranscript();
}
return;
}
// Alt+T: toggle inline expansion of thinking blocks.
if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) {
setThoughtExpanded((prev) => !prev);
@ -3424,6 +3590,13 @@ export const AppContainer = (props: AppContainerProps) => {
return;
}
// Ctrl+O: open the transcript full-detail screen. (Close while open is
// handled by the transcript guard at the very top of this handler.)
if (keyMatchers[Command.TOGGLE_TRANSCRIPT](key)) {
openTranscript();
return;
}
if (keyMatchers[Command.QUIT](key)) {
if (isAuthenticating) {
return;
@ -3577,19 +3750,6 @@ export const AppContainer = (props: AppContainerProps) => {
if (activePtyId || embeddedShellFocused) {
setEmbeddedShellFocused((prev) => !prev);
}
} else if (keyMatchers[Command.TOGGLE_COMPACT_MODE](key)) {
const newValue = !compactMode;
setCompactMode(newValue);
void settings.setValue(SettingScope.User, 'ui.compactMode', newValue);
// Skip the expensive clearTerminal + Static remount when no past
// item would render differently (no tool_group / gemini_thought*).
// Future items pick up the new mode naturally because Static is
// append-only. Issue #3899: this unfreezes Ctrl+O for plain-chat
// long sessions; tool/thinking-bearing sessions still go through
// the (now chunked) full path in MainContent.
if (compactToggleHasVisualEffect(historyRef.current)) {
refreshStatic();
}
} else if (keyMatchers[Command.PROMOTE_SHELL_TO_BACKGROUND](key)) {
// Ctrl+B: promote a running foreground shell command to a
// background task (#3831). The child keeps running, the
@ -3683,14 +3843,14 @@ export const AppContainer = (props: AppContainerProps) => {
// debugKeystrokeLogging is read at call time, so no stale closure risk.
settings,
isAuthenticating,
compactMode,
setCompactMode,
setRenderMode,
refreshStatic,
handleDoubleEscRewind,
vimEnabled,
vimMode,
setThoughtExpanded,
openTranscript,
closeTranscript,
],
);
@ -3753,6 +3913,9 @@ export const AppContainer = (props: AppContainerProps) => {
) {
return;
}
// Don't silently auto-submit queued messages while the transcript is open
// (it isn't part of `dialogsVisible`). Resume draining once it closes.
if (isTranscriptOpenRef.current) return;
// Two-phase: batch plain prompts as one turn, else pop next slash command.
const plainPrompts = drainQueue();
@ -3770,6 +3933,8 @@ export const AppContainer = (props: AppContainerProps) => {
streamingState,
isProcessing,
dialogsVisible,
// Re-run the drain when the transcript closes so queued messages resume.
isTranscriptOpen,
messageQueue,
drainQueue,
popNextSegment,
@ -4253,10 +4418,6 @@ export const AppContainer = (props: AppContainerProps) => {
],
);
const compactModeValue = useMemo(
() => ({ compactMode, compactInline, setCompactMode }),
[compactMode, compactInline, setCompactMode],
);
const renderModeValue = useMemo(
() => ({ renderMode, setRenderMode }),
[renderMode, setRenderMode],
@ -4281,17 +4442,22 @@ export const AppContainer = (props: AppContainerProps) => {
startupWarnings,
}}
>
<CompactModeProvider value={compactModeValue}>
<ThoughtExpandedProvider value={thoughtExpandedValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ShellFocusContext.Provider value={isFocused}>
<ThoughtExpandedProvider value={thoughtExpandedValue}>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ShellFocusContext.Provider value={isFocused}>
{transcriptFreeze ? (
<TranscriptView
items={transcriptItems}
useAlternateScreen={!useTerminalBuffer}
/>
) : (
<App />
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
</ThoughtExpandedProvider>
</CompactModeProvider>
)}
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
</ThoughtExpandedProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>

View file

@ -0,0 +1,75 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { Text } from 'ink';
import { AlternateScreen } from './AlternateScreen.js';
const writeRaw = vi.fn();
vi.mock('../contexts/TerminalOutputContext.js', () => ({
useTerminalOutput: () => writeRaw,
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: () => ({ rows: 24, columns: 80 }),
}));
const ENTER_ALT_SCREEN = '\x1b[?1049h';
const EXIT_ALT_SCREEN = '\x1b[?1049l';
describe('<AlternateScreen />', () => {
const origIsTTY = process.stdout.isTTY;
const setTTY = (value: boolean) =>
Object.defineProperty(process.stdout, 'isTTY', {
value,
configurable: true,
});
afterEach(() => {
writeRaw.mockClear();
setTTY(origIsTTY);
});
it('enters on mount and exits on unmount when stdout is a TTY', () => {
setTTY(true);
const { unmount } = render(
<AlternateScreen>
<Text>x</Text>
</AlternateScreen>,
);
expect(writeRaw).toHaveBeenCalledWith(
expect.stringContaining(ENTER_ALT_SCREEN),
);
writeRaw.mockClear();
unmount();
expect(writeRaw).toHaveBeenCalledWith(
expect.stringContaining(EXIT_ALT_SCREEN),
);
});
it('skips escape writes when disabled (VP mode owns the alt screen)', () => {
setTTY(true);
const { unmount } = render(
<AlternateScreen disabled>
<Text>x</Text>
</AlternateScreen>,
);
expect(writeRaw).not.toHaveBeenCalled();
unmount();
});
it('skips escape writes when stdout is not a TTY (piped/CI)', () => {
setTTY(false);
const { unmount } = render(
<AlternateScreen>
<Text>x</Text>
</AlternateScreen>,
);
expect(writeRaw).not.toHaveBeenCalled();
unmount();
});
});

View file

@ -30,13 +30,30 @@ export const AlternateScreen: FC<AlternateScreenProps> = ({
const { rows } = useTerminalSize();
useEffect(() => {
if (disabled) return;
writeRaw(ENTER_ALT_SCREEN + CLEAR_SCREEN + HIDE_CURSOR);
const onExit = () => writeRaw(SHOW_CURSOR + EXIT_ALT_SCREEN);
// Skip when the root Ink renderer already owns the alt screen (VP mode),
// or when stdout is not a TTY (piped/redirected/CI): writing alt-screen
// escapes to a non-terminal would just emit garbage bytes. Mirrors the
// repo convention of guarding terminal-control writes on `isTTY`
// (see startInteractiveUI.tsx / notificationService.ts). On non-TTY the
// transcript degrades to in-buffer rendering (no full-screen takeover).
if (disabled || !process.stdout.isTTY) return;
// Guard the raw writes: stdout can throw synchronously (EPIPE when the
// terminal closes mid-render, EAGAIN under backpressure). An uncaught throw
// from this effect / its cleanup would crash the app or leave the terminal
// in a corrupt state; swallow it — a failed escape write is best-effort.
const safeWrite = (data: string) => {
try {
writeRaw(data);
} catch {
// best-effort terminal control; ignore transient I/O errors
}
};
safeWrite(ENTER_ALT_SCREEN + CLEAR_SCREEN + HIDE_CURSOR);
const onExit = () => safeWrite(SHOW_CURSOR + EXIT_ALT_SCREEN);
process.on('exit', onExit);
return () => {
process.removeListener('exit', onExit);
writeRaw(SHOW_CURSOR + EXIT_ALT_SCREEN);
safeWrite(SHOW_CURSOR + EXIT_ALT_SCREEN);
};
}, [writeRaw, disabled]);

View file

@ -17,7 +17,6 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { LoadedSettings } from '../../config/settings.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { CompactModeProvider } from '../contexts/CompactModeContext.js';
import { ThoughtExpandedProvider } from '../contexts/ThoughtExpandedContext.js';
// Mock child components
@ -384,9 +383,7 @@ describe('<HistoryItemDisplay />', () => {
};
const { lastFrame } = renderWithProviders(
<CompactModeProvider value={{ compactMode: false, compactInline: false }}>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</CompactModeProvider>,
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />,
);
const output = lastFrame() ?? '';
@ -403,34 +400,12 @@ describe('<HistoryItemDisplay />', () => {
};
const { lastFrame } = renderWithProviders(
<CompactModeProvider value={{ compactMode: false, compactInline: false }}>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</CompactModeProvider>,
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />,
);
expect(lastFrame()).not.toContain('Continuing the reasoning');
});
it('keeps committed thinking collapsed in compact mode too', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_thought',
text: 'Inspecting the repository',
durationMs: 1200,
};
const { lastFrame } = renderWithProviders(
<CompactModeProvider value={{ compactMode: true, compactInline: false }}>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</CompactModeProvider>,
);
const output = lastFrame() ?? '';
expect(output).toContain('Thought for');
expect(output).toContain(`${toggleKeyHint} to expand`);
expect(output).not.toContain('Inspecting the repository');
});
it('renders committed thinking expanded when ThoughtExpandedProvider is true', () => {
const item: HistoryItem = {
id: 1,
@ -457,20 +432,59 @@ describe('<HistoryItemDisplay />', () => {
expect(output).toContain('Inspecting the repository');
});
it('keeps committed thinking continuations hidden in compact mode', () => {
it('fullDetail forces a committed thought expanded even when context/prop are collapsed', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_thought_content',
text: 'Continuing the reasoning',
type: 'gemini_thought',
text: 'Inspecting the repository',
durationMs: 1200,
};
// No ThoughtExpandedProvider (context defaults to false) and thoughtExpanded
// is not passed — fullDetail alone must win and show the full text. This is
// the transcript's forced-expansion path.
const { lastFrame } = renderWithProviders(
<CompactModeProvider value={{ compactMode: true, compactInline: false }}>
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />
</CompactModeProvider>,
<HistoryItemDisplay
item={item}
terminalWidth={100}
isPending={false}
fullDetail
/>,
);
expect(lastFrame()).not.toContain('Continuing the reasoning');
const output = lastFrame() ?? '';
expect(output).toContain('Thought for');
expect(output).toContain('Inspecting the repository');
});
it('forwards fullDetail to ToolGroupMessage for tool_group items', () => {
vi.mocked(ToolGroupMessage).mockClear();
const item: HistoryItem = {
id: 1,
type: 'tool_group',
tools: [
{
callId: '123',
name: 'run_shell_command',
description: 'Run a shell command',
resultDisplay: 'done',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
};
renderWithProviders(
<HistoryItemDisplay
item={item}
terminalWidth={80}
isPending={false}
fullDetail
/>,
);
const passedProps = vi.mocked(ToolGroupMessage).mock.calls[0][0];
expect(passedProps.fullDetail).toBe(true);
});
describe('showTimestamps', () => {
@ -565,15 +579,11 @@ describe('<HistoryItemDisplay />', () => {
it('subscribes the click handler without bypassVpGate (stays VP-gated)', () => {
vi.mocked(useMouseEvents).mockClear();
renderWithProviders(
<CompactModeProvider
value={{ compactMode: false, compactInline: false }}
>
<HistoryItemDisplay
item={thoughtItem}
terminalWidth={100}
isPending={false}
/>
</CompactModeProvider>,
<HistoryItemDisplay
item={thoughtItem}
terminalWidth={100}
isPending={false}
/>,
);
expect(vi.mocked(useMouseEvents)).toHaveBeenCalled();
const opts = vi.mocked(useMouseEvents).mock.calls.at(-1)?.[1];

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useRef, useCallback } from 'react';
import { memo, useMemo, useRef, useCallback } from 'react';
import type { DOMElement } from 'ink';
import {
escapeAnsiCtrlCodes,
@ -79,6 +79,13 @@ interface HistoryItemDisplayProps {
sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets;
/** Force thinking blocks expanded (e.g. in SessionPreview). */
thoughtExpanded?: boolean;
/**
* Transcript full-detail mode (Ctrl+O). When true, collapse is lifted:
* thinking blocks render expanded and tool groups force `forceExpandAll`
* + `forceShowResult` (every tool with its full, untruncated result).
* Default false (main view stays at the #5661 partition baseline).
*/
fullDetail?: boolean;
/**
* Head id of the thought group this item belongs to (the `gemini_thought`
* head id for both the head and its `gemini_thought_content` continuations).
@ -202,6 +209,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeightGemini,
sourceCopyIndexOffsets,
thoughtExpanded,
fullDetail = false,
thoughtHeadId,
}) => {
const marginTop = getHistoryItemMarginTop(item);
@ -216,8 +224,12 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
// click expands the whole group. Continuations receive the head id via
// `thoughtHeadId`; the head itself falls back to its own id.
const thoughtGroupHeadId = thoughtHeadId ?? item.id;
// Ctrl+O full-detail forces every thought open; otherwise honor an explicit
// `thoughtExpanded` prop, then the global Alt+T toggle / per-group click set.
const resolvedThoughtExpanded =
thoughtExpanded ?? (allExpanded || expandedHeadIds.has(thoughtGroupHeadId));
fullDetail ||
(thoughtExpanded ??
(allExpanded || expandedHeadIds.has(thoughtGroupHeadId)));
const settings = useSettings();
const showTimestamps = settings.merged.output?.showTimestamps === true;
@ -365,6 +377,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
memoryWriteCount={itemForDisplay.memoryWriteCount}
memoryReadCount={itemForDisplay.memoryReadCount}
isUserInitiated={itemForDisplay.isUserInitiated}
fullDetail={fullDetail}
/>
)}
{itemForDisplay.type === 'tool_use_summary' && (
@ -474,5 +487,12 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
);
};
// Export alias for backward compatibility
export { HistoryItemDisplayComponent as HistoryItemDisplay };
// Memoized so the Ctrl+O transcript — which re-renders on every scroll tick —
// skips re-rendering frozen-snapshot items whose props are shallowly unchanged.
// The transcript hands stable `item` references (from the freeze snapshot), so
// the default shallow compare is effective. Harmless for the main view, whose
// items live in Ink's `<Static>` and render once anyway.
const HistoryItemDisplay = memo(HistoryItemDisplayComponent);
HistoryItemDisplay.displayName = 'HistoryItemDisplay';
export { HistoryItemDisplay };

View file

@ -38,11 +38,11 @@ const getShortcuts = (): Shortcut[] => [
{ key: 'ctrl+c', description: t('to quit') },
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
{ key: 'ctrl+l', description: t('to clear screen') },
{ key: 'ctrl+o', description: t('to view transcript') },
{ key: 'ctrl+r', description: t('to search history') },
{ 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 }) => (

View file

@ -15,10 +15,12 @@ import {
type UIActions,
} from '../contexts/UIActionsContext.js';
import { AppContext } from '../contexts/AppContext.js';
import { CompactModeProvider } from '../contexts/CompactModeContext.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ToolCallStatus } from '../types.js';
// Global compact mode was removed (#5666); type-based tool rendering no longer
// consumes a compact-mode context.
const staticPropsSpy = vi.fn();
const staticItemsSpy = vi.fn();
const historyItemDisplayPropsSpy = vi.fn();
@ -241,15 +243,13 @@ const createUIActions = (): UIActions =>
const renderMainContent = (uiState: UIState) =>
render(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider value={{ compactMode: false, compactInline: false }}>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={uiState}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={uiState}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -280,22 +280,18 @@ describe('<MainContent />', () => {
rerender(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: false, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
currentModel: 'gpt-5.4',
historyRemountKey: 7,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
currentModel: 'gpt-5.4',
historyRemountKey: 7,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -432,25 +428,21 @@ describe('<MainContent />', () => {
staticItemsSpy.mockClear();
rerender(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: false, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
history: [
...history,
{ type: 'user' as const, id: 100, text: 'new msg' },
],
historyRemountKey: 1,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
history: [
...history,
{ type: 'user' as const, id: 100, text: 'new msg' },
],
historyRemountKey: 1,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -492,19 +484,15 @@ describe('<MainContent />', () => {
// meant to avoid.
rerender(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: false, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({ history, historyRemountKey: 2 })}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({ history, historyRemountKey: 2 })}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -596,23 +584,19 @@ describe('<MainContent />', () => {
// someone correctly drives the reset off the model dimension instead.
rerender(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: false, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
history,
historyRemountKey: 1,
currentModel: 'model-b',
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
history,
historyRemountKey: 1,
currentModel: 'model-b',
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -663,17 +647,13 @@ describe('<MainContent />', () => {
// Render with compactMode=true and useTerminalBuffer=false (default Static path).
render(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: true, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={createUIState({ history })}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={createUIState({ history })}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -734,17 +714,13 @@ describe('<MainContent />', () => {
render(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: true, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={createUIState({ history })}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider value={createUIState({ history })}>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);
@ -875,25 +851,21 @@ describe('<MainContent />', () => {
// Flip activePtyId; identical re-render except this one streaming-state field.
rerender(
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
<CompactModeProvider
value={{ compactMode: false, compactInline: false }}
>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
useTerminalBuffer: true,
activePtyId: 1,
history: stableHistory,
pendingHistoryItems: stablePending,
slashCommands: stableSlashCommands,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</CompactModeProvider>
<UIActionsContext.Provider value={createUIActions()}>
<UIStateContext.Provider
value={createUIState({
useTerminalBuffer: true,
activePtyId: 1,
history: stableHistory,
pendingHistoryItems: stablePending,
slashCommands: stableSlashCommands,
})}
>
<OverflowProvider>
<MainContent />
</OverflowProvider>
</UIStateContext.Provider>
</UIActionsContext.Provider>
</AppContext.Provider>,
);

View file

@ -7,6 +7,7 @@
import { Box, Static, type DOMElement, useBoxMetrics } from 'ink';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { HistoryItem, HistoryItemWithoutId } from '../types.js';
import { isHistoryItemVisibleAfterRestore } from '../types.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { Notifications } from './Notifications.js';
@ -104,7 +105,7 @@ export const MainContent = () => {
// Filter out items whose display is suppressed (e.g. /history collapse).
const visibleHistory = useMemo(
() => uiState.history.filter((item) => !item.display?.suppressOnRestore),
() => uiState.history.filter(isHistoryItemVisibleAfterRestore),
[uiState.history],
);

View file

@ -40,12 +40,6 @@ 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',
@ -136,28 +130,6 @@ 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,
compactInline: 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 {
@ -481,58 +453,6 @@ 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 = (
<KeypressProvider kittyProtocolEnabled={false}>
<SettingsDialog settings={settings} onSelect={onSelect} />
</KeypressProvider>
);
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();
});
it('should not save number settings below their configured minimum', async () => {
vi.mocked(saveModifiedSettings).mockClear();

View file

@ -33,8 +33,6 @@ import {
useVimModeState,
useVimModeActions,
} 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 {
@ -137,9 +135,6 @@ export function SettingsDialog({
// Get vim mode context to sync vim mode changes
const { vimEnabled } = useVimModeState();
const { toggleVimEnabled } = useVimModeActions();
// 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');
@ -285,13 +280,6 @@ 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' &&

View file

@ -0,0 +1,55 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { HistoryItem } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
// Force every item render to throw so the transcript's ErrorBoundary +
// `errorFallback` recovery path is exercised (the fullDetail render path hits
// code the normal view never does, so a throw here must show the fallback
// instead of crashing the CLI). Isolated in its own file so the throwing mock
// doesn't affect the main TranscriptView render tests.
vi.mock('./HistoryItemDisplay.js', () => ({
HistoryItemDisplay: () => {
throw new Error('malformed history item');
},
}));
vi.mock('../hooks/useMouseEvents.js', () => ({
useMouseEvents: vi.fn(),
}));
vi.mock('../contexts/TerminalOutputContext.js', () => ({
useTerminalOutput: () => vi.fn(),
TerminalOutputProvider: ({ children }: { children?: ReactNode }) => children,
}));
import { TranscriptView } from './TranscriptView.js';
describe('<TranscriptView /> error fallback', () => {
// React logs the caught render error to console.error; silence it.
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('shows the recovery fallback (not a crash) when an item render throws', () => {
const items: HistoryItem[] = [{ id: 1, type: 'user', text: 'anything' }];
const { lastFrame } = renderWithProviders(
<TranscriptView items={items} useAlternateScreen={false} />,
);
const frame = lastFrame() ?? '';
// The ErrorBoundary's `errorFallback` renders its title + the Esc/q hint
// instead of letting the throw propagate and take the process down.
expect(frame).toContain('Failed to render transcript.');
expect(frame).toContain('to close');
});
});

View file

@ -0,0 +1,112 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReactNode } from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { TranscriptView } from './TranscriptView.js';
import type { HistoryItem } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
// The transcript renders thinking blocks in full detail; the inline thinking
// block also installs mouse listeners — stub them out for a deterministic test.
vi.mock('../hooks/useMouseEvents.js', () => ({
useMouseEvents: vi.fn(),
}));
// Spy on the raw terminal writer so we can assert the alt-screen escapes that
// the default `useAlternateScreen` path emits via the AlternateScreen wrapper.
const writeRaw = vi.fn();
vi.mock('../contexts/TerminalOutputContext.js', () => ({
useTerminalOutput: () => writeRaw,
TerminalOutputProvider: ({ children }: { children?: ReactNode }) => children,
}));
const ENTER_ALT_SCREEN = '\x1b[?1049h';
const EXIT_ALT_SCREEN = '\x1b[?1049l';
describe('<TranscriptView />', () => {
const origIsTTY = process.stdout.isTTY;
const setTTY = (value: boolean) =>
Object.defineProperty(process.stdout, 'isTTY', {
value,
configurable: true,
});
afterEach(() => {
writeRaw.mockClear();
setTTY(origIsTTY);
});
const items: HistoryItem[] = [
{ id: 1, type: 'user', text: 'hello world' },
{
id: 2,
type: 'gemini_thought',
text: 'a private reasoning step that is normally collapsed',
},
{ id: 3, type: 'gemini', text: 'the assistant reply' },
];
it('renders the frozen items with header and footer chrome', () => {
const { lastFrame } = renderWithProviders(
<TranscriptView items={items} useAlternateScreen={false} />,
);
const frame = lastFrame();
expect(frame).toContain('Transcript');
// Footer hints (Esc/q to close, scroll keys).
expect(frame).toContain('to close');
expect(frame).toContain('to scroll');
});
it('renders thinking blocks expanded (fullDetail) — full text, not a summary', () => {
const { lastFrame } = renderWithProviders(
<TranscriptView items={items} useAlternateScreen={false} />,
);
const frame = lastFrame();
// The full thought text is shown (fullDetail forces expansion) rather than
// the collapsed single-line "Thought for …" summary.
expect(frame).toContain(
'a private reasoning step that is normally collapsed',
);
expect(frame).toContain('hello world');
expect(frame).toContain('the assistant reply');
});
it('enters and exits the alternate screen by default (useAlternateScreen defaults to true)', () => {
setTTY(true);
const { unmount } = renderWithProviders(
<TranscriptView items={items} />,
);
// The default path drives AlternateScreen with disabled=false, which writes
// the enter-alt-screen escape on mount.
expect(writeRaw).toHaveBeenCalledWith(
expect.stringContaining(ENTER_ALT_SCREEN),
);
writeRaw.mockClear();
unmount();
expect(writeRaw).toHaveBeenCalledWith(
expect.stringContaining(EXIT_ALT_SCREEN),
);
});
it('renders frozen pending items carrying negative ids without key collisions', () => {
// AppContainer assigns negative ids to the pending snapshot (`id: -(i+1)`),
// exercising keyExtractor's `tp-` branch alongside the committed `t-` items.
const withPending: HistoryItem[] = [
{ id: 1, type: 'user', text: 'committed question' },
{ id: -1, type: 'gemini', text: 'streaming pending reply' },
{ id: -2, type: 'gemini_content', text: 'second pending chunk' },
];
const { lastFrame } = renderWithProviders(
<TranscriptView items={withPending} useAlternateScreen={false} />,
);
const frame = lastFrame();
expect(frame).toContain('committed question');
expect(frame).toContain('streaming pending reply');
expect(frame).toContain('second pending chunk');
});
});

View file

@ -0,0 +1,174 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { memo, useCallback, useMemo } from 'react';
import type { ErrorInfo } from 'react';
import { Box, Text } from 'ink';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { AlternateScreen } from './AlternateScreen.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { ErrorBoundary } from './shared/ErrorBoundary.js';
import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js';
import { sanitizeTerminalText } from '../utils/textUtils.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import type { HistoryItem } from '../types.js';
const debugLogger = createDebugLogger('TRANSCRIPT_VIEW');
interface TranscriptViewProps {
/** Frozen snapshot of history + pending items, already stitched by the caller. */
items: HistoryItem[];
/**
* When false, Ink already owns the alternate screen (VP mode) the
* AlternateScreen wrapper skips its escape writes to avoid double-enter.
*/
useAlternateScreen?: boolean;
}
// Per-item virtual-scroll height estimate. The transcript renders every item
// with `fullDetail` (thinking full text, full tool output), so each item is
// far taller than MainContent's flat `() => 3`. A type-aware estimate keeps the
// scrollbar / PageUp-PageDown jump distances sane; VirtualizedList back-fills the
// real measured height once an item is rendered.
function estimateTranscriptItemHeight(item: HistoryItem): number {
switch (item.type) {
case 'gemini_thought':
case 'gemini_thought_content':
return 12;
case 'tool_group':
return 16;
case 'gemini':
case 'gemini_content':
return 8;
case 'user':
case 'user_shell':
return 2;
default:
return 4;
}
}
const keyExtractor = (item: HistoryItem) =>
item.id >= 0 ? `t-${item.id}` : `tp-${-item.id - 1}`;
const TranscriptViewImpl = ({
items,
useAlternateScreen = true,
}: TranscriptViewProps) => {
const { rows, columns } = useTerminalSize();
const headerHeight = 1;
const footerHeight = 1;
const contentHeight = Math.max(rows - headerHeight - footerHeight, 1);
const estimatedItemHeight = useCallback(
(index: number) => estimateTranscriptItemHeight(items[index]),
[items],
);
const renderItem = useCallback(
({ item }: { item: HistoryItem }) => (
<HistoryItemDisplay
item={item}
isPending={false}
terminalWidth={columns}
fullDetail
/>
),
[columns],
);
const title = t('Transcript');
// Close keys (Esc / q / Ctrl+C / Ctrl+O) are owned exclusively by
// AppContainer's global keypress guard so a single broadcast keypress isn't
// handled twice — TranscriptView renders no close handler of its own.
const content = useMemo(
() => (
<OverflowProvider>
<ScrollableList
hasFocus
data={items}
renderItem={renderItem}
estimatedItemHeight={estimatedItemHeight}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
containerHeight={contentHeight}
/>
</OverflowProvider>
),
[items, renderItem, estimatedItemHeight, contentHeight],
);
// fullDetail rendering exercises paths the normal view never hits (forced
// thinking expansion, every tool group expanded, full result blocks). An
// unexpected item shape would otherwise throw uncaught and crash the CLI, so
// contain it: show a fallback and let the user press Esc/q to close.
const errorFallback = useCallback(
(error: Error) => (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.status.error} bold>
{t('Failed to render transcript.')}
</Text>
<Text color={theme.text.secondary}>
{sanitizeTerminalText(error.message)}
</Text>
<Text dimColor italic>
Esc/q {t('to close')}
</Text>
</Box>
),
[],
);
// Log caught render errors to the debug channel — the on-screen fallback is
// user-facing, but the fullDetail paths exercise rendering the normal view
// never hits, so a swallowed error must still leave a diagnostic trail.
const onRenderError = useCallback((error: Error, info: ErrorInfo) => {
debugLogger.error(
`render error: ${error.message}`,
info.componentStack ?? '',
);
}, []);
return (
<AlternateScreen disabled={!useAlternateScreen}>
<Box flexDirection="column" height={rows} width={columns}>
<Box>
<Text color={theme.text.accent} bold>
{title}
</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
<ErrorBoundary fallback={errorFallback} onError={onRenderError}>
{content}
</ErrorBoundary>
</Box>
<Box justifyContent="center">
<Text dimColor italic>
Esc/q {t('to close')} {' '}Shift+ {t('to scroll')} {' '}
PgUp/PgDn
{' '}
Ctrl+Home/End
</Text>
</Box>
</Box>
</AlternateScreen>
);
};
/**
* Memoized so the frozen transcript doesn't re-reconcile on every AppContainer
* re-render while streaming continues underneath. AppContainer hands a stable
* `items` reference (memoized from the freeze snapshot), so the default shallow
* prop compare is enough.
*/
export const TranscriptView = memo(TranscriptViewImpl);
TranscriptView.displayName = 'TranscriptView';

View file

@ -10,6 +10,7 @@ import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import type { AnsiOutputDisplay } from '@qwen-code/qwen-code-core';
import { ToolDisplayNames } from '@qwen-code/qwen-code-core';
import { t } from '../../../i18n/index.js';
import { SHELL_COMMAND_NAME } from '../../constants.js';
import { ToolStatusIndicator } from '../shared/ToolStatusIndicator.js';
import { ToolElapsedTime } from '../shared/ToolElapsedTime.js';
@ -95,61 +96,91 @@ const TOOL_NAME_TO_CATEGORY: Record<string, ToolCategory> = {
TodoWrite: 'other',
};
type SummaryForms = { one: string; many: string };
type CategoryTemplate = {
// Count-based phrasing (i18n keys; also the English source strings).
// `{{count}}` is interpolated via t() so every locale supplies a natural
// phrase — keep these as literals here so they stay greppable and aligned
// with the locale files. Used for the multi-tool case and the single-tool
// case that has no usable description.
past: SummaryForms;
active: SummaryForms;
// Bare verb prefixed to a concrete single-tool description ("Read a.ts").
// Intentionally NOT run through t(): the description it precedes is a raw,
// language-neutral file path or command, and single-word verb keys would
// collide with unrelated existing i18n entries. English matches upstream
// behavior (#6448); the localized count phrases above still cover the
// multi-tool and no-description paths.
pastVerb: string;
activeVerb: string;
singular: string;
plural: string;
};
const CATEGORY_TEMPLATES: Record<ToolCategory, CategoryTemplate> = {
read: {
past: { one: 'Read {{count}} file', many: 'Read {{count}} files' },
active: { one: 'Reading {{count}} file', many: 'Reading {{count}} files' },
pastVerb: 'Read',
activeVerb: 'Reading',
singular: 'file',
plural: 'files',
},
edit: {
past: { one: 'Edited {{count}} file', many: 'Edited {{count}} files' },
active: { one: 'Editing {{count}} file', many: 'Editing {{count}} files' },
pastVerb: 'Edited',
activeVerb: 'Editing',
singular: 'file',
plural: 'files',
},
write: {
past: { one: 'Wrote {{count}} file', many: 'Wrote {{count}} files' },
active: { one: 'Writing {{count}} file', many: 'Writing {{count}} files' },
pastVerb: 'Wrote',
activeVerb: 'Writing',
singular: 'file',
plural: 'files',
},
search: {
past: {
one: 'Searched {{count}} pattern',
many: 'Searched {{count}} patterns',
},
active: {
one: 'Searching {{count}} pattern',
many: 'Searching {{count}} patterns',
},
pastVerb: 'Searched',
activeVerb: 'Searching',
singular: 'pattern',
plural: 'patterns',
},
list: {
past: {
one: 'Listed {{count}} directory',
many: 'Listed {{count}} directories',
},
active: {
one: 'Listing {{count}} directory',
many: 'Listing {{count}} directories',
},
pastVerb: 'Listed',
activeVerb: 'Listing',
singular: 'directory',
plural: 'directories',
},
command: {
past: { one: 'Ran {{count}} command', many: 'Ran {{count}} commands' },
active: {
one: 'Running {{count}} command',
many: 'Running {{count}} commands',
},
pastVerb: 'Ran',
activeVerb: 'Running',
singular: 'command',
plural: 'commands',
},
agent: {
past: { one: 'Ran {{count}} agent', many: 'Ran {{count}} agents' },
active: {
one: 'Running {{count}} agent',
many: 'Running {{count}} agents',
},
pastVerb: 'Ran',
activeVerb: 'Running',
singular: 'agent',
plural: 'agents',
},
other: {
past: { one: 'Used {{count}} tool', many: 'Used {{count}} tools' },
active: { one: 'Using {{count}} tool', many: 'Using {{count}} tools' },
pastVerb: 'Used',
activeVerb: 'Using',
singular: 'tool',
plural: 'tools',
},
};
@ -249,20 +280,33 @@ export function buildToolSummary(
if (!tools || tools.length === 0) continue;
const template = CATEGORY_TEMPLATES[cat];
const verb = isActive ? template.activeVerb : template.pastVerb;
const lower = parts.length > 0;
const v = lower ? verb.toLowerCase() : verb;
let part: string;
if (tools.length === 1) {
const safeDesc = safeDescription(tools[0].description);
if (safeDesc !== undefined) {
parts.push(`${v} ${safeDesc}`);
// Single tool with a concrete description: show it ("Read a.ts").
// Verb is English (see CategoryTemplate note) but the description is
// language-neutral, so the line reads correctly in every locale.
const verb = isActive ? template.activeVerb : template.pastVerb;
part = `${verb} ${safeDesc}`;
} else {
parts.push(`${v} 1 ${template.singular}`);
// No usable description → localized count phrase ("Read 1 file").
part = t(isActive ? template.active.one : template.past.one, {
count: '1',
});
}
} else {
parts.push(`${v} ${tools.length} ${template.plural}`);
// Multiple tools of one category → localized plural count phrase.
const forms = isActive ? template.active : template.past;
part = t(forms.many, { count: String(tools.length) });
}
// Lowercase the leading character for every part after the first ("Read 3
// files, edited 2 files"). Operating on the first char only keeps already-
// lowercase nouns intact and is a no-op for caseless scripts (e.g. CJK).
if (parts.length > 0) {
part = part.charAt(0).toLowerCase() + part.slice(1);
}
parts.push(part);
}
return parts.join(', ');

View file

@ -9,6 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { Text } from 'ink';
import type React from 'react';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { ToolMessage } from './ToolMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import type {
@ -18,62 +19,65 @@ import type {
} from '@qwen-code/qwen-code-core';
import { TOOL_STATUS } from '../../constants.js';
import { ConfigContext } from '../../contexts/ConfigContext.js';
import { CompactModeProvider } from '../../contexts/CompactModeContext.js';
// Global compact mode was removed (#5666); type-based tool rendering no longer
// consumes a compact-mode context.
// Mock child components to isolate ToolGroupMessage behavior
vi.mock('./ToolMessage.js', () => ({
ToolMessage: function MockToolMessage({
callId,
name,
description,
status,
emphasis,
resultDisplay,
isFocused,
forceShowResult,
}: {
callId: string;
name: string;
description: string;
status: ToolCallStatus;
emphasis: string;
resultDisplay?: unknown;
isFocused?: boolean;
forceShowResult?: boolean;
}) {
// Use the same constants as the real component
const statusSymbolMap: Record<ToolCallStatus, string> = {
[ToolCallStatus.Success]: TOOL_STATUS.SUCCESS,
[ToolCallStatus.Pending]: TOOL_STATUS.PENDING,
[ToolCallStatus.Executing]: TOOL_STATUS.EXECUTING,
[ToolCallStatus.Confirming]: TOOL_STATUS.CONFIRMING,
[ToolCallStatus.Canceled]: TOOL_STATUS.CANCELED,
[ToolCallStatus.Error]: TOOL_STATUS.ERROR,
};
const statusSymbol = statusSymbolMap[status] || '?';
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
(resultDisplay as { type?: string }).type === 'task_execution'
) {
// `forceShowResult` is the gate that lets `SubagentScrollbackSummary`
// render in compact mode — surfaced in the mock so tests can
// assert it was passed for terminal subagent tools.
ToolMessage: vi.fn(
({
callId,
name,
description,
status,
emphasis,
resultDisplay,
isFocused,
forceShowResult,
}: {
callId: string;
name: string;
description: string;
status: ToolCallStatus;
emphasis: string;
resultDisplay?: unknown;
isFocused?: boolean;
forceShowResult?: boolean;
}) => {
// Use the same constants as the real component
const statusSymbolMap: Record<ToolCallStatus, string> = {
[ToolCallStatus.Success]: TOOL_STATUS.SUCCESS,
[ToolCallStatus.Pending]: TOOL_STATUS.PENDING,
[ToolCallStatus.Executing]: TOOL_STATUS.EXECUTING,
[ToolCallStatus.Confirming]: TOOL_STATUS.CONFIRMING,
[ToolCallStatus.Canceled]: TOOL_STATUS.CANCELED,
[ToolCallStatus.Error]: TOOL_STATUS.ERROR,
};
const statusSymbol = statusSymbolMap[status] || '?';
if (
resultDisplay &&
typeof resultDisplay === 'object' &&
(resultDisplay as { type?: string }).type === 'task_execution'
) {
// `forceShowResult` is the gate that lets `SubagentScrollbackSummary`
// render in compact mode — surfaced in the mock so tests can
// assert it was passed for terminal subagent tools.
return (
<Text>
MockSubagent[{callId}]: focused={String(isFocused)} force=
{String(Boolean(forceShowResult))}
</Text>
);
}
return (
<Text>
MockSubagent[{callId}]: focused={String(isFocused)} force=
{String(Boolean(forceShowResult))}
MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis})
{forceShowResult ? ' [forceShow]' : ''}
</Text>
);
}
return (
<Text>
MockTool[{callId}]: {statusSymbol} {name} - {description} ({emphasis})
{forceShowResult ? ' [forceShow]' : ''}
</Text>
);
},
},
),
}));
vi.mock('./ToolConfirmationMessage.js', () => ({
@ -519,6 +523,195 @@ describe('<ToolGroupMessage />', () => {
});
});
// Transcript full-detail mode must NOT be short-circuited by the
// memory-only / pure-parallel-agent early returns (which run before the
// forceExpandAll computation). Each tool must render in full.
describe('fullDetail bypasses compact early returns', () => {
it('renders memory ops individually (not the "Recalled N" badge) when fullDetail', () => {
const toolCalls = [
createToolCall({
callId: 'm1',
name: 'SaveMemory',
description: 'recall project goals',
isMemoryOp: 'read',
resultDisplay: 'remembered: ship the transcript view',
}),
createToolCall({
callId: 'm2',
name: 'SaveMemory',
description: 'recall constraints',
isMemoryOp: 'read',
resultDisplay: 'remembered: keep main view clean',
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
memoryReadCount={2}
fullDetail
/>,
);
const frame = lastFrame() ?? '';
// The compact "Recalled N" badge must NOT short-circuit fullDetail:
// each memory op renders as its own ToolMessage with forceShowResult.
// (ToolMessage is mocked in this suite as `MockTool[id]…[forceShow]`.)
expect(frame).not.toContain('Recalled 2 memories');
expect(frame).toContain('MockTool[m1]');
expect(frame).toContain('MockTool[m2]');
expect(frame).toContain('[forceShow]');
});
it('renders a pure parallel-agent group as individual ToolMessages (not the dense panel) when fullDetail', () => {
const completedAgent = (name: string): AgentResultDisplay => ({
type: 'task_execution',
subagentName: name,
taskDescription: `${name} task`,
taskPrompt: `Run ${name}`,
status: 'completed',
toolCalls: [
{
callId: `${name}-read-1`,
name: 'read_file',
status: 'success',
description: 'Read file',
},
],
});
const toolCalls = [
createToolCall({
callId: 'agent-1',
name: 'agent',
status: ToolCallStatus.Success,
resultDisplay: completedAgent('reviewer'),
}),
createToolCall({
callId: 'agent-2',
name: 'agent',
status: ToolCallStatus.Success,
resultDisplay: completedAgent('planner'),
}),
];
const { lastFrame } = renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
isPending={false}
fullDetail
/>,
);
const frame = lastFrame() ?? '';
// fullDetail must bypass isPureParallelAgentGroup → each agent gets its
// own full ToolMessage (mocked as MockSubagent[id]) instead of the dense
// InlineParallelAgentsDisplay panel.
expect(frame).toContain('MockSubagent[agent-1]');
expect(frame).toContain('MockSubagent[agent-2]');
});
it('lifts per-tool height truncation when fullDetail (no availableTerminalHeight passed to ToolMessage)', () => {
vi.mocked(ToolMessage).mockClear();
const toolCalls = [
createToolCall({
callId: 'shell-1',
name: 'run_shell_command',
description: 'echo hi',
status: ToolCallStatus.Success,
resultDisplay: 'a result with content',
}),
];
renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
availableTerminalHeight={10}
fullDetail
/>,
);
const call = vi
.mocked(ToolMessage)
.mock.calls.find((c) => c[0].callId === 'shell-1');
expect(call).toBeDefined();
// fullDetail forces the height override to undefined so the tool output
// renders untruncated, even though availableTerminalHeight=10 was given.
expect(call?.[0].availableTerminalHeight).toBeUndefined();
expect(call?.[0].forceShowResult).toBe(true);
});
it('still truncates per-tool height when not fullDetail', () => {
vi.mocked(ToolMessage).mockClear();
const toolCalls = [
createToolCall({
callId: 'shell-2',
name: 'run_shell_command',
description: 'echo hi',
status: ToolCallStatus.Success,
resultDisplay: 'a result with content',
}),
];
renderWithProviders(
<ToolGroupMessage
{...baseProps}
toolCalls={toolCalls}
availableTerminalHeight={10}
/>,
);
const call = vi
.mocked(ToolMessage)
.mock.calls.find((c) => c[0].callId === 'shell-2');
expect(call?.[0].availableTerminalHeight).toBeTypeOf('number');
});
it('forwards fullDetail and detailedDisplay to each ToolMessage (§4.9)', () => {
vi.mocked(ToolMessage).mockClear();
const toolCalls = [
createToolCall({
callId: 'read-1',
name: 'ReadFile',
description: 'a.ts',
status: ToolCallStatus.Success,
resultDisplay: 'Read 1 file',
}),
];
// detailedDisplay is set on the display item by the scheduler/resume path.
(toolCalls[0] as { detailedDisplay?: string }).detailedDisplay =
'full a.ts contents';
renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} fullDetail />,
);
const call = vi
.mocked(ToolMessage)
.mock.calls.find((c) => c[0].callId === 'read-1');
expect(call?.[0].fullDetail).toBe(true);
expect((call?.[0] as { detailedDisplay?: string }).detailedDisplay).toBe(
'full a.ts contents',
);
});
it('passes fullDetail=false to ToolMessage in the normal (non-transcript) path', () => {
vi.mocked(ToolMessage).mockClear();
const toolCalls = [
createToolCall({
callId: 'edit-1',
name: 'Edit',
description: 'a.ts',
status: ToolCallStatus.Success,
resultDisplay: 'edited',
}),
];
renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
);
const call = vi
.mocked(ToolMessage)
.mock.calls.find((c) => c[0].callId === 'edit-1');
expect(call?.[0].fullDetail).toBe(false);
});
});
describe('isUserInitiated', () => {
it('user-initiated group renders all collapsible tools individually', () => {
const toolCalls = [
@ -853,12 +1046,10 @@ describe('<ToolGroupMessage />', () => {
// ToolMessage path and `SubagentScrollbackSummary` would never
// surface in scrollback. The committed-summary handoff promised
// by the LiveAgentPanel design depends on this.
const renderCompact = (component: React.ReactElement, compactMode = true) =>
const renderCompact = (component: React.ReactElement) =>
render(
<ConfigContext.Provider value={mockConfig}>
<CompactModeProvider value={{ compactMode, compactInline: false }}>
{component}
</CompactModeProvider>
{component}
</ConfigContext.Provider>,
);

View file

@ -147,6 +147,14 @@ interface ToolGroupMessageProps {
/** Pre-computed count of read ops from managed-auto-memory files. */
memoryReadCount?: number;
isUserInitiated?: boolean;
/**
* Transcript full-detail mode (Ctrl+O). When true, force `forceExpandAll`
* (skip the type-based partition so every tool renders individually), pass
* `forceShowResult=true` to each `ToolMessage`, and lift the per-tool
* terminal-height truncation. Default false (main view keeps the #5661
* type-based partition baseline).
*/
fullDetail?: boolean;
}
// Main component maps the tools using ToolMessage
@ -161,6 +169,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
memoryWriteCount,
memoryReadCount,
isUserInitiated,
fullDetail = false,
}) => {
const config = useConfig();
@ -271,7 +280,14 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// live frame and keeps it under the viewport. `totalAgentCount` keeps the
// header's "N · done/N" honest, and `availableTerminalHeight` is a hard cap
// backstop for degenerate cases (many agents finishing at once).
if (isPureParallelAgentGroup(toolCalls) && !hasSubagentPendingConfirmation) {
//
// Skipped in transcript full-detail mode (fullDetail) so every agent
// falls through to its own full ToolMessage instead of the dense panel.
if (
!fullDetail &&
isPureParallelAgentGroup(toolCalls) &&
!hasSubagentPendingConfirmation
) {
// `isPureParallelAgentGroup` already guarantees every entry is a subagent,
// so `inlineToolCalls` (a subset) and `toolCalls.length` need no further
// `isSubagentToolEntry` filtering here.
@ -314,8 +330,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// Memory-only groups get their own compact rendering with read/write
// counts. Check BEFORE the partition logic so they aren't routed through
// the collapsible/non-collapsible split.
// the collapsible/non-collapsible split. Skipped in transcript full-detail
// mode (fullDetail) so each memory op renders as its own full ToolMessage
// rather than collapsing to the "Recalled/Wrote N memories" badge.
const allMemOpsComplete =
!fullDetail &&
isMemoryOnlyGroup &&
!hasErrorTool &&
toolCalls.every((t) => t.status === ToolCallStatus.Success);
@ -346,9 +365,12 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// Force-expand ALL tools individually when the user must interact or
// must see full details: confirmation prompts, errors, user-initiated
// batches, focused shells, terminal subagents.
// batches, focused shells, terminal subagents. Transcript full-detail
// mode (fullDetail) also forces it so every tool renders individually
// instead of collapsing read/search into a partition summary.
const hasTerminalSubagent = inlineToolCalls.some(isTerminalSubagentTool);
const forceExpandAll =
fullDetail ||
hasConfirmingTool ||
hasSubagentPendingConfirmation ||
hasErrorTool ||
@ -424,15 +446,19 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}
const countOneLineToolCalls =
nonCollapsibleTools.length - countToolCallsWithResults;
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
Math.max(1, countToolCallsWithResults),
),
1,
)
: undefined;
// In transcript full-detail mode, lift the per-tool height truncation so
// each tool's output renders in full (combined with forceShowResult below).
const availableTerminalHeightPerToolMessage = fullDetail
? undefined
: availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
Math.max(1, countToolCallsWithResults),
),
1,
)
: undefined;
return (
<Box flexDirection="column" width={contentWidth} gap={0}>
@ -467,7 +493,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
activeShellPtyId={activeShellPtyId}
embeddedShellFocused={embeddedShellFocused}
config={config}
fullDetail={fullDetail}
forceShowResult={
fullDetail ||
isUserInitiated ||
tool.status === ToolCallStatus.Confirming ||
tool.status === ToolCallStatus.Error ||

View file

@ -12,7 +12,6 @@ import { StreamingState, ToolCallStatus } from '../../types.js';
import { Text } from 'ink';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { SettingsContext } from '../../contexts/SettingsContext.js';
import { CompactModeProvider } from '../../contexts/CompactModeContext.js';
import type {
AnsiOutput,
AnsiOutputDisplay,
@ -20,6 +19,9 @@ import type {
} from '@qwen-code/qwen-code-core';
import type { LoadedSettings } from '../../../config/settings.js';
// Global compact mode was removed (#5666); type-based tool rendering no longer
// consumes a compact-mode context.
vi.mock('../TerminalOutput.js', () => ({
TerminalOutput: function MockTerminalOutput({
cursor,
@ -118,21 +120,18 @@ const mockSettings: LoadedSettings = {
},
} as LoadedSettings;
// Helper to render with context (compactMode=false by default to show tool output)
// Helper to render with context.
const renderWithContext = (
ui: React.ReactElement,
streamingState: StreamingState,
compactMode = false,
) => {
const contextValue: StreamingState = streamingState;
return render(
<CompactModeProvider value={{ compactMode, compactInline: false }}>
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>
</CompactModeProvider>,
<SettingsContext.Provider value={mockSettings}>
<StreamingContext.Provider value={contextValue}>
{ui}
</StreamingContext.Provider>
</SettingsContext.Provider>,
);
};
@ -234,29 +233,26 @@ describe('<ToolMessage />', () => {
expect(output).not.toContain('MockMarkdown:Test result'); // result hidden
});
it('shows result for Error status in compact mode', () => {
it('shows result for Error status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Error} />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
it('shows result for Executing status in compact mode', () => {
it('shows result for Executing status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Executing} />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
it('shows result for Pending status in compact mode', () => {
it('shows result for Pending status', () => {
const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} status={ToolCallStatus.Pending} />,
StreamingState.Idle,
true,
);
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
@ -269,6 +265,178 @@ describe('<ToolMessage />', () => {
expect(lastFrame()).toContain('MockMarkdown:Test result');
});
describe('fullDetail (§4.9 transcript) data-source switch', () => {
it('swaps summary for detailedDisplay on a collapsible tool in fullDetail mode', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="config.yaml"
resultDisplay="Read 1 file"
detailedDisplay="full file contents here"
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame();
// detailedDisplay is raw tool output → rendered as PLAIN TEXT, not Markdown
expect(output).toContain('full file contents here');
expect(output).not.toContain('MockMarkdown:full file contents here');
expect(output).not.toContain('Read 1 file');
});
it('renders detailedDisplay as plain text, not Markdown', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="config.yaml"
resultDisplay="Read 1 file"
detailedDisplay="# heading from file\n- list item"
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame();
// The raw file content must NOT be Markdown-formatted (no MockMarkdown wrap).
expect(output).not.toContain('MockMarkdown:');
expect(output).toContain('heading from file');
});
it('escapes ANSI/control sequences in detailedDisplay (no raw injection)', () => {
// A malicious file read by a collapsible tool could embed terminal
// control sequences; the transcript must render them inert, not execute
// them. \x1b[?1049l would drop the alt-screen; OSC 52 writes the
// clipboard. After escaping, the raw ESC byte must not survive.
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="evil.txt"
resultDisplay="Read 1 file"
detailedDisplay={
'before\x1b[?1049lafter\x1b]52;c;ZXZpbA==\x07mid\x08\x0c\x0eend'
}
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame() ?? '';
// The visible text survives; the raw ESC (\x1b) control byte does not.
expect(output).toContain('before');
expect(output).toContain('after');
expect(output).toContain('mid');
expect(output).toContain('end');
expect(output).not.toContain('\x1b[?1049l');
expect(output).not.toContain('\x1b]52;');
// Bare C0 bytes without an ESC prefix (BEL, BS, FF, SO) are stripped too.
expect(output).not.toContain('\x07');
expect(output).not.toContain('\x08');
expect(output).not.toContain('\x0c');
expect(output).not.toContain('\x0e');
});
it('strips Unicode bidi override chars in detailedDisplay (Trojan Source)', () => {
// U+202E (RLO) and friends can visually reorder text (CVE-2021-42572);
// they must be stripped from raw tool output before rendering.
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="evil.txt"
resultDisplay="Read 1 file"
detailedDisplay={'safe\u202estart\u202cmid\u2066end\u2069'}
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame() ?? '';
expect(output).toContain('safe');
expect(output).toContain('end');
expect(output).not.toMatch(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/);
});
it('preserves TAB and LF in detailedDisplay (structural whitespace)', () => {
// The C0 strip regex intentionally skips \x09 (TAB) and \x0a (LF) so
// multi-line / column-aligned file output still renders. Lock that in:
// stripping them would collapse the segments together.
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="table.txt"
resultDisplay="Read 1 file"
detailedDisplay={'colA\tcolB\nrow2A\trow2B'}
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame() ?? '';
// All four cells survive, and are NOT collapsed into one run (which is
// what stripping TAB/LF would produce).
expect(output).toContain('colA');
expect(output).toContain('colB');
expect(output).toContain('row2A');
expect(output).toContain('row2B');
expect(output).not.toContain('colAcolB');
expect(output).not.toContain('colBrow2A');
});
it('keeps the summary when forced but NOT in fullDetail mode (main-view force)', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="config.yaml"
resultDisplay="Read 1 file"
detailedDisplay="full file contents here"
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('MockMarkdown:Read 1 file');
expect(output).not.toContain('full file contents here');
});
it('keeps the summary for a non-collapsible tool even in fullDetail mode', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="test-tool"
resultDisplay="Test result"
detailedDisplay="should not appear"
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('MockMarkdown:Test result');
expect(output).not.toContain('should not appear');
});
it('falls back to the summary when fullDetail is set but no detailedDisplay exists', () => {
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ReadFile"
description="config.yaml"
resultDisplay="Read 1 file"
fullDetail
forceShowResult
/>,
StreamingState.Idle,
);
expect(lastFrame()).toContain('MockMarkdown:Read 1 file');
});
});
describe('ToolStatusIndicator rendering', () => {
it('shows ✓ for Success status', () => {
const { lastFrame } = renderWithContext(
@ -842,19 +1010,17 @@ describe('<ToolMessage />', () => {
merged: { ui: { shellOutputMaxLines: 0 } },
} as unknown as LoadedSettings;
const { lastFrame } = render(
<CompactModeProvider value={{ compactMode: false, compactInline: false }}>
<SettingsContext.Provider value={settingsWithDisabledCap}>
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
name="Shell"
status={ToolCallStatus.Executing}
resultDisplay={ansiOutputDisplay}
availableTerminalHeight={100}
/>
</StreamingContext.Provider>
</SettingsContext.Provider>
</CompactModeProvider>,
<SettingsContext.Provider value={settingsWithDisabledCap}>
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
name="Shell"
status={ToolCallStatus.Executing}
resultDisplay={ansiOutputDisplay}
availableTerminalHeight={100}
/>
</StreamingContext.Provider>
</SettingsContext.Provider>,
);
const output = lastFrame()!;
expect(output).toContain('height=94');
@ -882,19 +1048,17 @@ describe('<ToolMessage />', () => {
merged: { ui: { shellOutputMaxLines: 12 } },
} as unknown as LoadedSettings;
const { lastFrame } = render(
<CompactModeProvider value={{ compactMode: false, compactInline: false }}>
<SettingsContext.Provider value={settingsWithCustomCap}>
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
name="Shell"
status={ToolCallStatus.Executing}
resultDisplay={ansiOutputDisplay}
availableTerminalHeight={100}
/>
</StreamingContext.Provider>
</SettingsContext.Provider>
</CompactModeProvider>,
<SettingsContext.Provider value={settingsWithCustomCap}>
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
name="Shell"
status={ToolCallStatus.Executing}
resultDisplay={ansiOutputDisplay}
availableTerminalHeight={100}
/>
</StreamingContext.Provider>
</SettingsContext.Provider>,
);
const output = lastFrame()!;
expect(output).toContain('height=12');
@ -1031,19 +1195,17 @@ describe('<ToolMessage />', () => {
merged: { ui: { shellOutputMaxLines: badValue } },
} as unknown as LoadedSettings;
const { lastFrame } = render(
<CompactModeProvider value={{ compactMode: false, compactInline: false }}>
<SettingsContext.Provider value={settingsWithBadCap}>
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
name="Shell"
status={ToolCallStatus.Executing}
resultDisplay={ansiOutputDisplay}
availableTerminalHeight={100}
/>
</StreamingContext.Provider>
</SettingsContext.Provider>
</CompactModeProvider>,
<SettingsContext.Provider value={settingsWithBadCap}>
<StreamingContext.Provider value={StreamingState.Idle}>
<ToolMessage
{...baseProps}
name="Shell"
status={ToolCallStatus.Executing}
resultDisplay={ansiOutputDisplay}
availableTerminalHeight={100}
/>
</StreamingContext.Provider>
</SettingsContext.Provider>,
);
const output = lastFrame()!;
// -1 → 0 → cap disabled (height=94)

View file

@ -38,6 +38,7 @@ import type { LoadedSettings } from '../../../config/settings.js';
import {
escapeAnsiCtrlCodes,
sanitizeTerminalText,
getCachedStringWidth,
toCodePoints,
} from '../../utils/textUtils.js';
@ -67,6 +68,7 @@ const DEFAULT_SHELL_OUTPUT_MAX_LINES = 5;
// Large threshold to ensure we don't cause performance issues for very large
// outputs that will get truncated further MaxSizedBox anyway.
const MAXIMUM_RESULT_DISPLAY_CHARACTERS = 1000000;
export type TextEmphasis = 'high' | 'medium' | 'low';
type DiffResultDisplay = Pick<
FileDiff,
@ -551,6 +553,15 @@ export interface ToolMessageProps extends IndividualToolCallDisplay {
embeddedShellFocused?: boolean;
config?: Config;
forceShowResult?: boolean;
/**
* Transcript (Ctrl+O) full-detail mode. When true AND this is a collapsible
* tool (read/search/list) that carries a `detailedDisplay`, the renderer
* switches its DATA SOURCE from the summary `resultDisplay` to the full
* `detailedDisplay` (§4.9). Kept separate from `forceShowResult`, which only
* controls unfold/height so main-view force scenarios (user-initiated,
* error, confirming) still render the summary, never the full output.
*/
fullDetail?: boolean;
/**
* Whether this subagent owns keyboard input for the inline approval
* surface when true the focus-holder banner renders and the
@ -576,6 +587,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
name,
description,
resultDisplay,
detailedDisplay,
status,
availableTerminalHeight,
contentWidth,
@ -586,6 +598,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
ptyId,
config,
forceShowResult,
fullDetail,
isFocused,
isPending,
executionStartTime,
@ -690,7 +703,44 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
renderOutputAsMarkdown = false;
}
const effectiveDisplayRenderer = useResultDisplayRenderer(resultDisplay);
// §4.9: in transcript full-detail mode, collapsible tools (read/search/list)
// swap the summary `resultDisplay` for the complete `detailedDisplay` derived
// from the persisted functionResponse. Only a non-empty string detail
// qualifies; everything else (and all main-view rendering) keeps the summary.
const usingDetailedDisplay =
fullDetail &&
isCollapsibleTool(name) &&
typeof detailedDisplay === 'string' &&
detailedDisplay.length > 0;
// `detailedDisplay` is RAW, un-sanitized tool output (file contents, grep
// hits, directory listings). A malicious repo could embed terminal control
// codes that execute when the transcript renders the full content unfiltered.
// Run it through the shared `sanitizeTerminalText` pipeline (ANSI escape + C0
// strip + bidi strip), memoized since the content can be ~25K chars and this
// runs on every render.
const sanitizedDetailedDisplay = React.useMemo(
() =>
usingDetailedDisplay && typeof detailedDisplay === 'string'
? sanitizeTerminalText(detailedDisplay)
: detailedDisplay,
[detailedDisplay, usingDetailedDisplay],
);
const effectiveResultDisplay = usingDetailedDisplay
? sanitizedDetailedDisplay
: resultDisplay;
// detailedDisplay is RAW tool output (file content, grep hits, directory
// listings). Render it as plain text — Markdown formatting would turn the
// file's own `#`/`*`/`-`/`>` characters into headings/bold/lists. The usual
// `if (availableHeight)` guard above doesn't catch this because fullDetail
// lifts the height cap (availableTerminalHeight is undefined in transcript).
if (usingDetailedDisplay) {
renderOutputAsMarkdown = false;
}
const effectiveDisplayRenderer = useResultDisplayRenderer(
effectiveResultDisplay,
);
// Collapse text/ANSI output for completed collapsible tools (read/search/list)
// to reduce scrollback noise. Non-collapsible tools (command/edit/agent/MCP/etc.)

View file

@ -4,11 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useStdout } from 'ink';
import { renderWithProviders } from '../../../test-utils/render.js';
import { LoadedSettings } from '../../../config/settings.js';
import { RadioButtonSelect } from './RadioButtonSelect.js';
// `useMouseEvents` gates SGR mouse escapes on `stdout.isTTY` (so they never leak
// into piped output). Route its stdout through a mock that reports a TTY and
// captures writes, so the mouse-enable escape is assertable independently of
// ink-testing-library's render frames. Other ink exports are preserved so the
// component still renders normally.
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return { ...actual, useStdout: vi.fn() };
});
const mockedUseStdout = vi.mocked(useStdout);
let mouseWrite: ReturnType<typeof vi.fn>;
// Integration smoke test: with ui.useTerminalBuffer on, BaseSelectionList
// mounts the real RowMouseController (which subscribes via the real
// useMouseEvents/KeypressProvider). This guards the end-to-end gate + mount
@ -35,28 +49,40 @@ describe('BaseSelectionList with mouse enabled (integration)', () => {
];
// `?1003h` = any-event tracking; the mouse layer enables it for hover.
const ENABLE_ANY = '[?1003h';
const ENABLE_ANY = '[?1003h';
const enabledAnyWritten = () =>
mouseWrite.mock.calls.some(
(call) => typeof call[0] === 'string' && call[0].includes(ENABLE_ANY),
);
beforeEach(() => {
mouseWrite = vi.fn();
mockedUseStdout.mockReturnValue({
stdout: { write: mouseWrite, isTTY: true, columns: 80, rows: 24 },
writeToStdout: vi.fn(),
} as unknown as ReturnType<typeof useStdout>);
});
it('mounts the mouse layer (enables any-event tracking) and still renders items', () => {
const { frames } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={items} onSelect={() => {}} />,
{ settings: settingsWithMouse(true) },
);
// `frames` captures both rendered frames and the raw enable escape that
// useMouseEvents writes to the same stdout — assert across all of them.
const output = frames.join('\n');
expect(output).toContain('Alpha');
expect(output).toContain('Beta');
expect(output).toContain(ENABLE_ANY);
// Items still render through ink's own stdout...
expect(lastFrame()).toContain('Alpha');
expect(lastFrame()).toContain('Beta');
// ...and useMouseEvents wrote the any-event enable escape to its stdout.
expect(enabledAnyWritten()).toBe(true);
});
it('does not mount the mouse layer when ui.useTerminalBuffer is off', () => {
const { lastFrame, frames } = renderWithProviders(
const { lastFrame } = renderWithProviders(
<RadioButtonSelect items={items} onSelect={() => {}} />,
{ settings: settingsWithMouse(false) },
);
expect(lastFrame()).toContain('Alpha');
expect(lastFrame()).toContain('Beta');
expect(frames.join('\n')).not.toContain(ENABLE_ANY);
expect(enabledAnyWritten()).toBe(false);
});
});

View file

@ -0,0 +1,106 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render } from 'ink-testing-library';
import { act } from 'react';
import { Text } from 'ink';
import { ErrorBoundary } from './ErrorBoundary.js';
// A child that throws during render to trip the boundary.
const Thrower = ({ message }: { message: string }) => {
throw new Error(message);
};
describe('ErrorBoundary', () => {
// React logs caught render errors to console.error; silence it so the test
// output stays clean (the boundary catching the error is the point).
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
});
it('renders children when no error is thrown', () => {
const { lastFrame } = render(
<ErrorBoundary>
<Text>healthy child</Text>
</ErrorBoundary>,
);
expect(lastFrame()).toContain('healthy child');
});
it('catches a render error and shows the default fallback with the message', () => {
const { lastFrame } = render(
<ErrorBoundary>
<Thrower message="kaboom" />
</ErrorBoundary>,
);
const output = lastFrame() ?? '';
expect(output).toContain('Something went wrong while rendering.');
expect(output).toContain('kaboom');
});
it('renders a custom fallback with the caught error', () => {
const { lastFrame } = render(
<ErrorBoundary fallback={(error) => <Text>custom: {error.message}</Text>}>
<Thrower message="boom" />
</ErrorBoundary>,
);
expect(lastFrame()).toContain('custom: boom');
});
it('calls onError with the error and component stack', () => {
const onError = vi.fn();
render(
<ErrorBoundary onError={onError}>
<Thrower message="logged" />
</ErrorBoundary>,
);
expect(onError).toHaveBeenCalledTimes(1);
const [error, info] = onError.mock.calls[0];
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toBe('logged');
// React passes an ErrorInfo with a componentStack string.
expect(typeof (info as { componentStack?: unknown }).componentStack).toBe(
'string',
);
});
it('reset clears the error state so children can recover', () => {
let shouldThrow = true;
let capturedReset: (() => void) | undefined;
const Maybe = () => {
if (shouldThrow) {
throw new Error('transient');
}
return <Text>recovered</Text>;
};
const tree = (
<ErrorBoundary
fallback={(error, reset) => {
capturedReset = reset;
return <Text>err: {error.message}</Text>;
}}
>
<Maybe />
</ErrorBoundary>
);
const { lastFrame, rerender } = render(tree);
expect(lastFrame()).toContain('err: transient');
// The offending condition clears, then reset() drops the boundary's error
// state and the subtree re-renders successfully.
shouldThrow = false;
act(() => {
capturedReset?.();
});
rerender(tree);
expect(lastFrame()).toContain('recovered');
});
});

View file

@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { sanitizeTerminalText } from '../../utils/textUtils.js';
interface ErrorBoundaryProps {
children: ReactNode;
/**
* Custom fallback renderer. Receives the caught error and a `reset` callback
* that clears the boundary's error state (e.g. to retry after the offending
* data has changed). When omitted, a minimal default message is shown.
*/
fallback?: (error: Error, reset: () => void) => ReactNode;
/** Optional side-effecting hook for logging the error. */
onError?: (error: Error, info: ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
}
/**
* React error boundary for the Ink tree. Catches render-time errors in its
* subtree and shows a fallback instead of letting the exception propagate and
* crash the whole CLI. The CLI UI otherwise has no error boundary, so any
* unexpected history-item shape in a full-detail render path (transcript) would
* take the process down.
*/
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
override state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
override componentDidCatch(error: Error, info: ErrorInfo): void {
this.props.onError?.(error, info);
}
private readonly reset = () => {
this.setState({ error: null });
};
override render(): ReactNode {
const { error } = this.state;
if (error) {
if (this.props.fallback) {
return this.props.fallback(error, this.reset);
}
// Intentionally un-translated: this is a generic last-resort message for
// callers that pass no `fallback` (the transcript passes its own,
// localized one). It renders while the subtree is already crashing —
// pulling in the i18n layer here risks a second failure inside the
// boundary — so keep it a plain, dependency-free English string.
return (
<Box flexDirection="column">
<Text color={theme.status.error} bold>
Something went wrong while rendering.
</Text>
<Text color={theme.text.secondary}>
{sanitizeTerminalText(error.message)}
</Text>
</Box>
);
}
return this.props.children;
}
}

View file

@ -17,6 +17,22 @@ vi.mock('../../utils/measure-element-position.js', () => ({
measureElementPosition: () => ({ x: 0, y: 0, width: 40, height: 5 }),
}));
// ink-testing-library's fake stdout has no `isTTY`, but `useMouseEvents` now
// gates SGR mouse mode on `stdout.isTTY` (so it never leaks escapes into piped
// output). Report a TTY here so the scrollbar/wheel pipeline arms as it does in
// a real terminal; without this the mouse-scroll assertions never receive
// events. Preserve every other ink export.
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useStdout: () => ({
stdout: { write: vi.fn(), isTTY: true },
writeToStdout: vi.fn(),
}),
};
});
type Item = { id: number; label: string };
// `useKeypress` (called unconditionally inside ScrollableList) requires a

View file

@ -1,23 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
interface CompactModeContextType {
compactMode: boolean;
compactInline: boolean;
setCompactMode?: (value: boolean) => void;
}
const CompactModeContext = createContext<CompactModeContextType>({
compactMode: false,
compactInline: false,
});
export const useCompactMode = (): CompactModeContextType =>
useContext(CompactModeContext);
export const CompactModeProvider = CompactModeContext.Provider;

View file

@ -322,6 +322,95 @@ describe('useHistoryManager', () => {
expect(recentTool.resultDisplay).toBe('some file content here');
});
it('should also clear detailedDisplay when clearing an old tool result (Ctrl+O privacy)', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();
// Add 25 collapsible tool_groups carrying the raw functionResponse text
// in `detailedDisplay` (the Ctrl+O full-detail source). The first ones
// fall outside keep-recent-20 and must be compacted.
for (let i = 0; i < 25; i++) {
act(() => {
result.current.addItem(
{
type: 'tool_group',
tools: [
{
callId: String(i),
name: 'read_file',
description: '',
resultDisplay: 'some file content here',
detailedDisplay: 'full secret file content here',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
} as unknown as HistoryItemWithoutId,
ts + i,
);
});
}
act(() => {
result.current.compactOldItems();
});
// Oldest compacted tool: both resultDisplay AND detailedDisplay cleared,
// so reopening Ctrl+O cannot re-surface the cleared output.
const tool = (
result.current.history[0] as unknown as HistoryItemToolGroup
).tools[0];
expect(tool.resultDisplay).toBe(UI_COMPACT_CLEARED_MESSAGE);
expect(tool.detailedDisplay).toBeUndefined();
// Newest tool untouched: still has both fields.
const recentTool = (
result.current.history[24] as unknown as HistoryItemToolGroup
).tools[0];
expect(recentTool.resultDisplay).toBe('some file content here');
expect(recentTool.detailedDisplay).toBe('full secret file content here');
});
it('clears a tool that carries detailedDisplay but no resultDisplay (defensive)', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();
// Degenerate shape: detailedDisplay set with resultDisplay null. Both the
// compaction trigger AND the clear must cover it so the raw transcript
// detail can't survive compaction, even though the live/resume paths
// don't currently produce this shape.
for (let i = 0; i < 25; i++) {
act(() => {
result.current.addItem(
{
type: 'tool_group',
tools: [
{
callId: String(i),
name: 'read_file',
description: '',
resultDisplay: undefined,
detailedDisplay: 'full secret file content here',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
} as unknown as HistoryItemWithoutId,
ts + i,
);
});
}
act(() => {
result.current.compactOldItems();
});
const tool = (
result.current.history[0] as unknown as HistoryItemToolGroup
).tools[0];
expect(tool.resultDisplay).toBe(UI_COMPACT_CLEARED_MESSAGE);
expect(tool.detailedDisplay).toBeUndefined();
});
it('should blank fileDiff object on old tool_group items', () => {
const { result } = renderHook(() => useHistory());
const ts = Date.now();

View file

@ -161,8 +161,9 @@ export function useHistory(): UseHistoryManagerReturn {
item.type === 'tool_group' &&
item.tools.some(
(t) =>
t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE,
(t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE) ||
t.detailedDisplay != null,
)
) {
totalToolGroupsWithOutput++;
@ -196,11 +197,14 @@ export function useHistory(): UseHistoryManagerReturn {
.map((item) => {
if (item.type !== 'tool_group') return item;
// Check for any non-null resultDisplay (covers string, FileDiff,
// AnsiOutputDisplay, AgentResultDisplay, etc.)
// AnsiOutputDisplay, AgentResultDisplay, etc.) OR a lingering
// `detailedDisplay` — so a tool that somehow carries only the raw
// transcript detail still triggers compaction and gets cleared.
const hasOldOutput = item.tools.some(
(t) =>
t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE,
(t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE) ||
t.detailedDisplay != null,
);
if (!hasOldOutput) return item;
toolGroupsSeen++;
@ -210,10 +214,22 @@ export function useHistory(): UseHistoryManagerReturn {
...item,
tools: item.tools.map((t) => {
if (
t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE
(t.resultDisplay != null &&
t.resultDisplay !== UI_COMPACT_CLEARED_MESSAGE) ||
t.detailedDisplay != null
) {
return { ...t, resultDisplay: UI_COMPACT_CLEARED_MESSAGE };
// Also drop `detailedDisplay` (the raw functionResponse text
// kept for the Ctrl+O full-detail transcript): clearing only
// `resultDisplay` would let a post-compaction transcript reopen
// re-surface the supposedly cleared read/search/list output,
// defeating the memory/privacy compaction. The `detailedDisplay`
// arm keeps the guard robust even if a tool ever carries the
// raw detail without a `resultDisplay`.
return {
...t,
resultDisplay: UI_COMPACT_CLEARED_MESSAGE,
detailedDisplay: undefined,
};
}
return t;
}),

View file

@ -64,7 +64,7 @@ describe('useMouseEvents', () => {
resume: ReturnType<typeof vi.fn>;
pause: ReturnType<typeof vi.fn>;
};
let stdout: { write: ReturnType<typeof vi.fn> };
let stdout: { write: ReturnType<typeof vi.fn>; isTTY: boolean };
beforeEach(() => {
stdin = Object.assign(new EventEmitter(), {
@ -73,7 +73,7 @@ describe('useMouseEvents', () => {
resume: vi.fn(),
pause: vi.fn(),
});
stdout = { write: vi.fn() };
stdout = { write: vi.fn(), isTTY: true };
mockedUseStdin.mockReturnValue({
stdin,
setRawMode: vi.fn(),
@ -206,4 +206,19 @@ describe('useMouseEvents', () => {
expect(stdout.write).toHaveBeenCalledWith(ENABLE_MOUSE);
});
});
describe('non-TTY stdout', () => {
it('does NOT enable mouse mode when stdout is not a TTY (piped/redirected)', () => {
// Mirrors `qwen | tee log`: stdin is still a raw-mode-capable TTY, but
// stdout is piped. Even an active bypassVpGate surface (the transcript's
// focused ScrollableList) must not emit SGR mouse escapes into the
// captured output.
stdout.isTTY = false;
renderHook(
() => useMouseEvents(() => {}, { isActive: true, bypassVpGate: true }),
{ wrapper: vpWrapper(false) },
);
expect(stdout.write).not.toHaveBeenCalledWith(ENABLE_MOUSE);
});
});
});

View file

@ -157,7 +157,14 @@ export function useMouseEvents(
const handlerRef = useRef(handler);
handlerRef.current = handler;
const enabled = isActive && isRawModeSupported && vpGateOpen;
// Never write SGR mouse-mode escapes (?1002h ?1006h) unless stdout is a TTY.
// `isRawModeSupported` only reflects stdin; with stdout piped/redirected
// (`qwen | tee log`) an active, raw-mode-capable surface — e.g. the non-TTY
// transcript's focused ScrollableList (`bypassVpGate`) — would otherwise emit
// raw control bytes into the captured output. Mirrors AlternateScreen's
// `process.stdout.isTTY` guard so the non-TTY fallback stays byte-clean.
const enabled =
isActive && isRawModeSupported && vpGateOpen && Boolean(stdout.isTTY);
useEffect(() => {
if (!enabled) return;

View file

@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { mapToDisplay, type TrackedToolCall } from './useReactToolScheduler.js';
// Build a minimal successful tracked tool call with the fields mapToDisplay's
// success branch reads. `displayName` drives the collapsible gate.
const makeSuccess = (displayName: string): TrackedToolCall =>
({
status: 'success',
request: { callId: 'call-1', name: 'read_file', args: {} },
tool: { displayName, isOutputMarkdown: false },
invocation: { getDescription: () => 'reading' },
response: {
resultDisplay: 'Read 1 file',
responseParts: [
{
functionResponse: {
id: 'call-1',
name: 'read_file',
response: { output: 'FULL FILE CONTENT' },
},
},
],
},
}) as unknown as TrackedToolCall;
describe('mapToDisplay — detailedDisplay (§4.9 live path)', () => {
it('extracts detailedDisplay for a collapsible (read/search/list) tool', () => {
const group = mapToDisplay(makeSuccess('Read File'));
const tool = group.tools[0];
// Summary stays the compact resultDisplay; full detail is derived from the
// persisted functionResponse for the Ctrl+O transcript.
expect(tool.resultDisplay).toBe('Read 1 file');
expect(tool.detailedDisplay).toBe('FULL FILE CONTENT');
});
it('leaves detailedDisplay undefined for a non-collapsible tool', () => {
// 'Edit' → 'edit' category → not collapsible, so the extraction is skipped
// (the transcript never reads it for edit/write/command/agent tools).
const group = mapToDisplay(makeSuccess('Edit'));
expect(group.tools[0].detailedDisplay).toBeUndefined();
});
});

View file

@ -24,6 +24,7 @@ import {
CoreToolScheduler,
compactToolResultDisplayForHistory,
createDebugLogger,
getToolResponseDisplayText,
isAnyAutoMemPath,
} from '@qwen-code/qwen-code-core';
import * as path from 'node:path';
@ -33,6 +34,7 @@ import type {
IndividualToolCallDisplay,
} from '../types.js';
import { ToolCallStatus } from '../types.js';
import { isCollapsibleTool } from '../components/messages/CompactToolGroupDisplay.js';
const debugLogger = createDebugLogger('REACT_TOOL_SCHEDULER');
@ -330,6 +332,18 @@ export function mapToDisplay(
resultDisplay: compactToolResultDisplayForHistory(
trackedCall.response.resultDisplay,
),
// Full detail for the Ctrl+O transcript (§4.9): derived from the
// already-persisted functionResponse parts; NOT char-capped (the
// bound is whatever core already applied). Consumed ONLY by the
// transcript's fullDetail render for collapsible (read/search/list)
// tools whose summary resultDisplay is just a count — so gate the
// extraction on `isCollapsibleTool(displayName)` to avoid storing a
// large (~25K char) string on every edit/write/command/agent call
// that the renderer would never use. Mirrors ToolMessage's
// `usingDetailedDisplay` gate, which also keys off the display name.
detailedDisplay: isCollapsibleTool(displayName)
? getToolResponseDisplayText(trackedCall.response.responseParts)
: undefined,
confirmationDetails: undefined,
};
case 'error':

View file

@ -64,7 +64,6 @@ describe('keyMatchers', () => {
[Command.EXIT]: (key: Key) => key.ctrl && key.name === 'd',
[Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's',
[Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y',
[Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o',
[Command.TOGGLE_RENDER_MODE]: (key: Key) => key.meta && key.name === 'm',
[Command.PROMOTE_SHELL_TO_BACKGROUND]: (key: Key) =>
key.ctrl && key.name === 'b',
@ -94,6 +93,7 @@ describe('keyMatchers', () => {
[Command.SCROLL_END]: (key: Key) => key.ctrl && key.name === 'end',
[Command.TOGGLE_THINKING_EXPANDED]: (key: Key) =>
key.meta && key.name === 't',
[Command.TOGGLE_TRANSCRIPT]: (key: Key) => key.ctrl && key.name === 'o',
};
// Test data for each command with positive and negative test cases
@ -297,11 +297,6 @@ describe('keyMatchers', () => {
positive: [createKey('y', { ctrl: true })],
negative: [createKey('y'), createKey('r', { ctrl: true })],
},
{
command: Command.TOGGLE_COMPACT_MODE,
positive: [createKey('o', { ctrl: true })],
negative: [createKey('o'), createKey('p', { ctrl: true })],
},
// Selection list navigation
{
@ -418,6 +413,11 @@ describe('keyMatchers', () => {
positive: [createKey('t', { meta: true })],
negative: [createKey('t'), createKey('t', { ctrl: true })],
},
{
command: Command.TOGGLE_TRANSCRIPT,
positive: [createKey('o', { ctrl: true })],
negative: [createKey('o'), createKey('o', { meta: true })],
},
];
describe('Data-driven key binding matches original logic', () => {

View file

@ -67,6 +67,15 @@ export interface IndividualToolCallDisplay {
name: string;
description: string;
resultDisplay: ToolResultDisplay | string | undefined;
/**
* Full tool-result text for the Ctrl+O full-detail transcript (§4.9).
* Derived (NOT persisted) extracted via `getToolResponseDisplayText` from
* the already-persisted `functionResponse` parts at live/resume/replay time.
* Used only when `fullDetail && isCollapsibleTool(name)` to replace the
* summary `resultDisplay` for read/search/list tools whose `returnDisplay`
* is only a count. Undefined fall back to the summary.
*/
detailedDisplay?: string;
status: ToolCallStatus;
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
@ -566,7 +575,12 @@ export type HistoryItemDoctor = HistoryItemBase & {
};
export type GoalStatusKind =
'set' | 'achieved' | 'cleared' | 'failed' | 'aborted' | 'checking';
| 'set'
| 'achieved'
| 'cleared'
| 'failed'
| 'aborted'
| 'checking';
export const TERMINAL_GOAL_STATUS_KINDS = [
'achieved',
@ -643,6 +657,18 @@ export type HistoryItemWithoutId =
export type HistoryItem = HistoryItemWithoutId & { id: number };
/**
* Shared visibility predicate: an item collapsed on session resume
* (`ui.history.collapseOnResume`) sets `display.suppressOnRestore` and is
* represented only by its collapse-summary row. Both the main view
* (MainContent) and the Ctrl+O transcript (AppContainer's freeze snapshot)
* filter on this, so keep the single source of truth here to prevent the two
* surfaces from diverging.
*/
export const isHistoryItemVisibleAfterRestore = (
item: Pick<HistoryItem, 'display'>,
): boolean => !item.display?.suppressOnRestore;
// Message types used by internal command feedback (subset of HistoryItem types)
export enum MessageType {
INFO = 'info',

View file

@ -1,614 +0,0 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
compactToggleHasVisualEffect,
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.each([
['completed', 'completed'],
['failed', 'failed'],
['cancelled', 'cancelled'],
] as const)(
'does NOT merge tool_group with terminal subagent (%s)',
(_label, status) => {
// Terminal task_execution groups must not be absorbed: their
// SubagentScrollbackSummary lands inline as the persistent
// record of the run's outcome, and the compact path can't
// surface it. Mirrors `hasTerminalSubagent` in
// `ToolGroupMessage.showCompact`.
const subagentResult = {
type: 'task_execution',
subagentName: 'test-agent',
taskDescription: 'test task',
status,
};
const items: HistoryItem[] = [
createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]),
createToolGroup(2, [
createTool(
'c2',
'Agent',
status === 'failed' ? ToolCallStatus.Error : ToolCallStatus.Success,
subagentResult,
),
]),
createToolGroup(3, [createTool('c3', 'Shell', ToolCallStatus.Success)]),
];
const merged = mergeCompactToolGroups(items);
// Three separate groups: terminal subagent stays its own batch
// so SubagentScrollbackSummary renders as a standalone entry.
expect(merged.length).toBe(3);
const ids = merged
.filter(isToolGroup)
.map((g) => g.id)
.sort();
expect(ids).toEqual([1, 2, 3]);
},
);
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('drops trailing tool_use_summary after a single absorbed tool_group', () => {
// Single-batch turn: one tool_group, then its summary arrives. The group
// is non-force-expanded (compact-mode candidate), so its callId is in
// absorbedCallIds — the summary is consumed by the compact header and
// dropped from merged output to avoid double-displaying the label.
const items: HistoryItem[] = [
createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]),
{
type: 'tool_use_summary',
id: 2,
summary: 'Ran shell batch',
precedingToolUseIds: ['c1'],
},
];
const merged = mergeCompactToolGroups(
items,
false,
undefined,
new Set(['c1']),
);
expect(merged.length).toBe(1);
expect(merged[0].id).toBe(1);
expect(merged[0].type).toBe('tool_group');
});
it('drops absorbed trailing tool_use_summary even when followed by visible non-mergeable items', () => {
const items: HistoryItem[] = [
createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]),
{
type: 'tool_use_summary',
id: 2,
summary: 'Ran shell batch',
precedingToolUseIds: ['c1'],
},
{ type: 'user', id: 3, text: 'next prompt' },
];
const merged = mergeCompactToolGroups(
items,
false,
undefined,
new Set(['c1']),
);
expect(merged.length).toBe(2);
expect(merged[0].type).toBe('tool_group');
expect(merged[1].type).toBe('user');
});
it('preserves tool_use_summary for force-expanded (non-absorbed) tool_group', () => {
// The errored tool_group is force-expanded: it renders through the full
// ToolGroupMessage path, ignoring `compactLabel`. Its callId is NOT in
// absorbedCallIds, so the summary must survive in merged output —
// HistoryItemDisplay then renders it as a standalone `● <label>` line,
// which is the only way the label reaches the screen for this group.
const items: HistoryItem[] = [
createToolGroup(1, [
createTool('c1', 'Shell', ToolCallStatus.Error, 'boom'),
]),
{
type: 'tool_use_summary',
id: 2,
summary: 'Tried shell batch',
precedingToolUseIds: ['c1'],
},
];
// Empty absorbedCallIds — the errored group's callId is not absorbed.
const merged = mergeCompactToolGroups(items, false, undefined, new Set());
expect(merged.length).toBe(2);
expect(merged[0].type).toBe('tool_group');
expect(merged[1].type).toBe('tool_use_summary');
});
it('preserves tool_use_summary when no absorbedCallIds set is provided (default)', () => {
// Default empty set — preserves all summaries. This is the safe default
// for callers that don't compute absorption (e.g., older test fixtures
// and any future callers outside MainContent).
const items: HistoryItem[] = [
createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]),
{
type: 'tool_use_summary',
id: 2,
summary: 'Ran shell batch',
precedingToolUseIds: ['c1'],
},
];
const merged = mergeCompactToolGroups(items);
expect(merged.length).toBe(2);
expect(merged[0].type).toBe('tool_group');
expect(merged[1].type).toBe('tool_use_summary');
});
it('merges tool_groups separated by tool_use_summary (hidden in compact)', () => {
// Two mergeable batches separated by an absorbed summary — the summary
// is dropped during merge, the two groups concatenate.
const items: HistoryItem[] = [
createToolGroup(1, [createTool('c1', 'Shell', ToolCallStatus.Success)]),
{
type: 'tool_use_summary',
id: 2,
summary: 'Ran first shell batch',
precedingToolUseIds: ['c1'],
},
createToolGroup(3, [createTool('c2', 'Shell', ToolCallStatus.Success)]),
];
const merged = mergeCompactToolGroups(
items,
false,
undefined,
new Set(['c1', 'c2']),
);
expect(merged.length).toBe(1);
expect(merged[0].id).toBe(1);
if (isToolGroup(merged[0])) {
expect(merged[0].tools.map((t) => t.callId)).toEqual(['c1', 'c2']);
}
});
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');
});
});
describe('compactToggleHasVisualEffect', () => {
it('returns false for empty history', () => {
expect(compactToggleHasVisualEffect([])).toBe(false);
});
it('returns false for plain user/info/error history (no tools, no thinking)', () => {
const history: HistoryItem[] = [
{ type: 'user', id: 1, text: 'hi' },
{ type: 'gemini', id: 2, text: 'hello' },
{ type: 'info', id: 3, text: 'note' },
{ type: 'error', id: 4, text: 'oops' },
];
expect(compactToggleHasVisualEffect(history)).toBe(false);
});
it('returns false when only tool_group is present (no compact mode effect)', () => {
const history: HistoryItem[] = [
{ type: 'user', id: 1, text: 'run' },
createToolGroup(2, [
createTool('c1', 'shell', ToolCallStatus.Success),
]) as HistoryItem,
];
expect(compactToggleHasVisualEffect(history)).toBe(false);
});
it('returns true when any gemini_thought is present', () => {
const history: HistoryItem[] = [
{ type: 'user', id: 1, text: 'q' },
{ type: 'gemini_thought', id: 2, text: 'thinking…' },
];
expect(compactToggleHasVisualEffect(history)).toBe(true);
});
it('returns true when any gemini_thought_content is present', () => {
const history: HistoryItem[] = [
{ type: 'user', id: 1, text: 'q' },
{ type: 'gemini_thought_content', id: 2, text: 'inner' },
];
expect(compactToggleHasVisualEffect(history)).toBe(true);
});
});

View file

@ -1,278 +0,0 @@
/**
* @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.
* Exported so MainContent can determine which callIds get their label
* "absorbed" by the compact tool_group header vs which need the standalone
* `● <label>` line rendered (force-expanded groups never go through the
* compact path, so their label would otherwise be invisible).
*/
export 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;
}
// Terminal subagent tool calls must show — the inline
// `SubagentScrollbackSummary` is the persistent record of the
// run's outcome (LiveAgentPanel evicts terminal rows after its
// visibility window). If the group merged into a compact batch,
// the summary would never render and the user would lose the
// committed audit trail. Mirrors the `hasTerminalSubagent`
// predicate in `ToolGroupMessage.showCompact`.
if (
tools.some((t) => {
const rd = t.resultDisplay;
if (
!rd ||
typeof rd !== 'object' ||
!('type' in rd) ||
(rd as { type?: string }).type !== 'task_execution'
) {
return false;
}
const status = (rd as { status?: string }).status;
return (
status === 'completed' || status === 'failed' || status === 'cancelled'
);
})
) {
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 which hides:
* - `gemini_thought` / `gemini_thought_content` (thinking hidden when compactMode is true),
* - `tool_use_summary` (consumed upstream to decorate the adjacent tool_group's label;
* never rendered standalone so it must not break adjacency between two batches).
*/
function isHiddenInCompactMode(item: HistoryItem): boolean {
return (
item.type === 'gemini_thought' ||
item.type === 'gemini_thought_content' ||
item.type === 'tool_use_summary'
);
}
/**
* Cheap O(N) scan: does any item in `history` render differently between
* compact and detailed modes? When this returns false, toggling compactMode
* is a pixel-identical no-op for already-rendered output, so the caller can
* skip the expensive `clearTerminal + Static remount` path. This unfreezes
* Ctrl+O for plain-chat sessions that have neither tool calls nor thinking
* blocks regardless of conversation length.
*
* Currently only `gemini_thought` and `gemini_thought_content` items are
* affected by compact mode. Tool groups use type-based partitioning that
* is independent of compact mode.
*/
export function compactToggleHasVisualEffect(
history: readonly HistoryItem[],
): boolean {
for (const item of history) {
if (
item.type === 'gemini_thought' ||
item.type === 'gemini_thought_content'
) {
return true;
}
}
return false;
}
/**
* 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)
* @param absorbedCallIds - Set of tool callIds whose summary label is consumed
* by a compact-mode tool_group header (i.e., the corresponding tool_group is
* NOT force-expanded). Summaries for these callIds are dropped from the
* merged result so the compact header can display the label directly.
* Summaries for force-expanded groups pass through unchanged so
* HistoryItemDisplay can render them as standalone `● <label>` lines (the
* compact path doesn't consume their label).
* @returns New array with merged tool_groups (does not mutate input)
*/
export function mergeCompactToolGroups(
items: HistoryItem[],
embeddedShellFocused: boolean = false,
activeShellPtyId: number | undefined = undefined,
absorbedCallIds: ReadonlySet<string> = new Set(),
): HistoryItem[] {
const result: HistoryItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i];
// Drop `tool_use_summary` items whose preceding callIds are *all* absorbed
// by a compact tool_group header. Those headers will display the label
// directly (via the `compactLabel` lookup in MainContent), so keeping the
// standalone summary in the merged result would double-display the label.
//
// Summaries with at least one non-absorbed preceding callId — e.g., when
// the corresponding tool_group is force-expanded (errors / confirming /
// user-initiated / focused shell) and renders through the full
// ToolGroupMessage path that does not consume `compactLabel` — must
// survive in the merged result so HistoryItemDisplay can render them as
// standalone `● <label>` lines.
if (item.type === 'tool_use_summary') {
const allAbsorbed =
item.precedingToolUseIds.length > 0 &&
item.precedingToolUseIds.every((id) => absorbedCallIds.has(id));
if (allAbsorbed) {
i++;
continue;
}
result.push(item);
i++;
continue;
}
// 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;
}

View file

@ -569,6 +569,177 @@ describe('resumeHistoryUtils', () => {
expect(items).toEqual([{ id: 51, type: 'user', text: '/filecmd' }]);
expect(items[0]).not.toHaveProperty('sentToModel');
});
describe('detailedDisplay (§4.9 Ctrl+O full detail on resume)', () => {
type ToolGroupItem = Extract<HistoryItem, { type: 'tool_group' }>;
const firstTool = (items: HistoryItem[]) =>
(items.find((i) => i.type === 'tool_group') as ToolGroupItem | undefined)
?.tools[0];
// detailedDisplay is only derived for collapsible (read/search/list) tools,
// so use a read tool here (displayName 'Read File' → 'read' category) — an
// edit/write tool would correctly yield `undefined` under the gate.
const readTool = {
name: 'read_file',
displayName: 'Read File',
description: 'Read a file',
build: vi.fn().mockReturnValue({ getDescription: () => 'read' }),
} as unknown as AnyDeclarativeTool;
const buildWithToolResult = (toolResult: Record<string, unknown>) => {
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
functionCall: { id: 'call-1', name: 'read_file', args: {} },
} as unknown as Part,
],
},
},
{ type: 'tool_result', ...toolResult },
],
} as unknown as ConversationRecord;
return buildResumedHistoryItems(
{ conversation } as ResumedSessionData,
makeConfig({ read_file: readTool }),
10,
);
};
it('derives detailedDisplay from toolCallResult.responseParts', () => {
const items = buildWithToolResult({
toolCallResult: {
callId: 'call-1',
resultDisplay: 'Read 1 file',
status: 'success',
responseParts: [
{
functionResponse: {
id: 'call-1',
name: 'replace',
response: { output: 'FULL FILE CONTENTS' },
},
},
],
},
});
const tool = firstTool(items);
expect(tool?.resultDisplay).toBe('Read 1 file');
expect(tool?.detailedDisplay).toBe('FULL FILE CONTENTS');
});
it('falls back to message.parts when responseParts is absent (older records)', () => {
const items = buildWithToolResult({
toolCallResult: {
callId: 'call-1',
resultDisplay: 'Found 2 matches',
status: 'success',
},
message: {
parts: [
{
functionResponse: {
id: 'call-1',
name: 'replace',
response: { output: 'match line 1\nmatch line 2' },
},
},
],
},
});
const tool = firstTool(items);
expect(tool?.detailedDisplay).toBe('match line 1\nmatch line 2');
});
it('leaves detailedDisplay undefined when neither source carries output', () => {
const items = buildWithToolResult({
toolCallResult: {
callId: 'call-1',
resultDisplay: 'ok',
status: 'success',
},
});
const tool = firstTool(items);
expect(tool?.detailedDisplay).toBeUndefined();
});
it('does NOT populate detailedDisplay for errored tools (matches live path)', () => {
const items = buildWithToolResult({
toolCallResult: {
callId: 'call-1',
resultDisplay: 'Tool failed',
status: 'error',
responseParts: [
{
functionResponse: {
id: 'call-1',
name: 'replace',
response: { output: 'raw error output that must not surface' },
},
},
],
},
});
const tool = firstTool(items);
expect(tool?.status).toBe(ToolCallStatus.Error);
expect(tool?.detailedDisplay).toBeUndefined();
});
it('does NOT populate detailedDisplay for non-collapsible tools (matches live gate)', () => {
// An edit/write/command/agent tool is never read via `usingDetailedDisplay`,
// so the resume path must skip the extraction just like the live path.
const editTool = {
name: 'replace',
displayName: 'Edit',
description: 'Edit a file',
build: vi.fn().mockReturnValue({ getDescription: () => 'edit' }),
} as unknown as AnyDeclarativeTool;
const conversation = {
messages: [
{
type: 'assistant',
message: {
parts: [
{
functionCall: { id: 'call-1', name: 'replace', args: {} },
} as unknown as Part,
],
},
},
{
type: 'tool_result',
toolCallResult: {
callId: 'call-1',
resultDisplay: 'Edited 1 file',
status: 'success',
responseParts: [
{
functionResponse: {
id: 'call-1',
name: 'replace',
response: {
output: 'large edit output not needed in Ctrl+O',
},
},
},
],
},
},
],
} as unknown as ConversationRecord;
const items = buildResumedHistoryItems(
{ conversation } as ResumedSessionData,
makeConfig({ replace: editTool }),
10,
);
const tool = firstTool(items);
expect(tool?.status).toBe(ToolCallStatus.Success);
expect(tool?.detailedDisplay).toBeUndefined();
});
});
});
describe('applyCollapsePolicyAndSummary', () => {

View file

@ -16,6 +16,7 @@ import type {
AtCommandRecordPayload,
HistoryGap,
} from '@qwen-code/qwen-code-core';
import { getToolResponseDisplayText } from '@qwen-code/qwen-code-core';
import type {
HistoryItem,
HistoryItemInfo,
@ -24,6 +25,7 @@ import type {
} from '../types.js';
import { ToolCallStatus, MessageType } from '../types.js';
import { t } from '../../i18n/index.js';
import { isCollapsibleTool } from '../components/messages/CompactToolGroupDisplay.js';
import {
formatHistoryGapNotice,
indexGapsByChild,
@ -185,6 +187,7 @@ function convertToHistoryItems(
name: string;
description: string;
resultDisplay: ToolResultDisplay | undefined;
detailedDisplay?: string;
status: ToolCallStatus;
confirmationDetails: undefined;
}> = [];
@ -465,6 +468,25 @@ function convertToHistoryItems(
rawStatus === 'error'
? ToolCallStatus.Error
: ToolCallStatus.Success;
// Full detail for the Ctrl+O transcript (§4.9): the complete
// functionResponse parts are persisted on the tool_result record
// (only resultDisplay is sanitized), so resume yields full detail
// too. Fall back to message.parts for older records. Only derive it
// for SUCCESS + collapsible (read/search/list) tools, mirroring the
// live path's gate in useReactToolScheduler — the renderer's
// `usingDetailedDisplay` only consumes it for collapsible tools, so
// extracting it for edit/write/command/agent calls would store a
// large (~25K char) string the transcript never reads. Errored /
// cancelled tools are excluded so raw output never surfaces.
if (
toolCall.status === ToolCallStatus.Success &&
isCollapsibleTool(toolCall.name)
) {
toolCall.detailedDisplay = getToolResponseDisplayText(
(record.toolCallResult.responseParts as Part[] | undefined) ??
(record.message?.parts as Part[] | undefined),
);
}
}
pendingToolCalls.delete(callId || '');
}

View file

@ -291,6 +291,31 @@ export const clearStringWidthCache = (): void => {
const regex = ansiRegex();
// Bare C0 control bytes (plus DEL / C1) that `escapeAnsiCtrlCodes` (ansi-regex)
// leaves untouched because they carry no ESC prefix — BEL \x07, BS \x08,
// VT \x0b, FF \x0c, CR \x0d, SO \x0e, SI \x0f, etc. TAB (\x09) and LF (\x0a)
// are intentionally preserved: they legitimately structure multi-line output.
// eslint-disable-next-line no-control-regex
const BARE_C0_CONTROL_CHARS_REGEX = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
// Unicode bidirectional override / isolate characters (the "Trojan Source"
// attack class, CVE-2021-42572) that can visually reorder rendered text.
const BIDI_OVERRIDE_CHARS_REGEX = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
/**
* Full sanitization for raw, untrusted text about to be rendered into a
* terminal `<Text>` (e.g. tool output in the Ctrl+O transcript, or a caught
* error message). Three passes: (1) neutralize ESC-prefixed ANSI sequences
* (alt-screen exit, OSC 52 clipboard, ); (2) strip bare C0/C1 control bytes
* ansi-regex misses, keeping only TAB/LF; (3) strip bidi override/isolate chars
* (Trojan Source). Single source of truth so every render site stays aligned.
*/
export function sanitizeTerminalText(value: string): string {
return escapeAnsiCtrlCodes(value)
.replace(BARE_C0_CONTROL_CHARS_REGEX, '')
.replace(BIDI_OVERRIDE_CHARS_REGEX, '');
}
/* Recursively traverses a JSON-like structure (objects, arrays, primitives)
* and escapes all ANSI control characters found in any string values.
*

View file

@ -101,7 +101,10 @@ import {
recordFallbackApprove,
shouldFallback,
} from '../permissions/denialTracking.js';
import { getResponseTextFromParts } from '../utils/generateContentResponseUtilities.js';
import {
getResponseTextFromParts,
TOOL_SUCCEEDED_OUTPUT,
} from '../utils/generateContentResponseUtilities.js';
import type { ModifyContext } from '../tools/modifiable-tool.js';
import {
isModifiableDeclarativeTool,
@ -741,7 +744,7 @@ export function convertToFunctionResponse(
}
const output =
textParts.length > 0 ? textParts.join('\n') : 'Tool execution succeeded.';
textParts.length > 0 ? textParts.join('\n') : TOOL_SUCCEEDED_OUTPUT;
return [createFunctionResponsePart(callId, toolName, output, mediaParts)];
}
@ -783,9 +786,7 @@ export function convertToFunctionResponse(
}
// Default case for other kinds of parts.
return [
createFunctionResponsePart(callId, toolName, 'Tool execution succeeded.'),
];
return [createFunctionResponsePart(callId, toolName, TOOL_SUCCEEDED_OUTPUT)];
}
function toParts(input: PartListUnion): Part[] {

View file

@ -1525,7 +1525,6 @@ describe('GeminiChat', async () => {
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
maxRecentImagesToRetain: 1,
imagePayloadThreshold: 1,
});
chat.setHistory([
{
@ -1544,7 +1543,6 @@ describe('GeminiChat', async () => {
role: 'model',
parts: [{ text: 'I see the second image' }],
},
]);
const response = (async function* () {
yield {
@ -8063,7 +8061,6 @@ describe('GeminiChat', async () => {
vi.mocked(mockConfig.getChatCompression).mockReturnValue({
maxRecentImagesToRetain: 0,
imagePayloadThreshold: 1,
});
const streams = [
makeStream([makeChunk([{ text: 'initial' }], 'MAX_TOKENS')]),
@ -8140,7 +8137,6 @@ describe('GeminiChat', async () => {
expect(text).toBe('Hello ending.');
});
it('should coalesce overlapping recovery continuation text', async () => {
const streams = [
makeStream([makeChunk([{ text: 'discarded initial' }], 'MAX_TOKENS')]),

View file

@ -73,7 +73,6 @@ import {
buildReattachParts,
countAllInlineImages,
replaceImagePayloadsInPlace,
} from '../services/image-payload-references.js';
import {
estimateContentTokens,
@ -1576,7 +1575,6 @@ export class GeminiChat {
return requestHistory;
}
return curatedHistory.map(copyContentContainer);
}
/**

View file

@ -12,7 +12,8 @@ import type {
FunctionHookCallback,
HookConfig,
HookExecutionResult,
HookEventName} from './types.js';
HookEventName,
} from './types.js';
import { HookType } from './types.js';
import { getHookMatcherTarget, getToolMatcherTargets } from './hookPlanner.js';

View file

@ -519,11 +519,7 @@ export function buildPermissionRules(ctx: PermissionCheckContext): string[] {
// Only serialize stable, identity-bearing params — not volatile content
// like `prompt` or `query`, which would make rules invocation-specific
// and could leak sensitive data into settings.json.
const stableParamKeys = new Set([
'model',
'subagent_type',
'skill',
]);
const stableParamKeys = new Set(['model', 'subagent_type', 'skill']);
if (ctx.toolParams) {
for (const key of stableParamKeys) {
const v = ctx.toolParams[key];

View file

@ -12,7 +12,6 @@ import {
countAllInlineImages,
prepareImagePayloadsForRequest,
replaceImagePayloadsInPlace,
} from './image-payload-references.js';
function toolImageTurn(data: string): Content {
@ -298,4 +297,3 @@ describe('buildReattachParts', () => {
expect(buildReattachParts(replaced, 0)).toEqual([]);
});
});

View file

@ -138,7 +138,6 @@ export function buildReattachParts(
];
}
export function prepareImagePayloadsForRequest(
contents: Content[],
options: {

View file

@ -13,6 +13,8 @@ import {
getFunctionCallsFromPartsAsJson,
getStructuredResponse,
getStructuredResponseFromParts,
getToolResponseDisplayText,
TOOL_SUCCEEDED_OUTPUT,
} from './generateContentResponseUtilities.js';
import type {
GenerateContentResponse,
@ -280,4 +282,79 @@ describe('generateContentResponseUtilities', () => {
expect(getStructuredResponseFromParts(parts)).toBeUndefined();
});
});
describe('getToolResponseDisplayText', () => {
const frPart = (output: unknown, nested?: Part[]): Part => ({
functionResponse: {
id: 'call-1',
name: 'read_file',
response: output === undefined ? {} : { output },
...(nested ? { parts: nested } : {}),
},
});
it('returns undefined for undefined / empty parts', () => {
expect(getToolResponseDisplayText(undefined)).toBeUndefined();
expect(getToolResponseDisplayText([])).toBeUndefined();
});
it('returns the full functionResponse output text', () => {
const parts = [frPart('line1\nline2\nline3')];
expect(getToolResponseDisplayText(parts)).toBe('line1\nline2\nline3');
});
it('skips the non-informative "Tool execution succeeded." placeholder', () => {
expect(
getToolResponseDisplayText([frPart(TOOL_SUCCEEDED_OUTPUT)]),
).toBeUndefined();
});
it('emits <media: mime> placeholders for nested inline/file data', () => {
const parts = [
frPart(TOOL_SUCCEEDED_OUTPUT, [
{ inlineData: { mimeType: 'image/png', data: 'AAAA' } },
{ fileData: { mimeType: 'application/pdf', fileUri: 'file:///x' } },
]),
];
expect(getToolResponseDisplayText(parts)).toBe(
'<media: image/png>\n<media: application/pdf>',
);
});
it('sanitizes control chars and angle brackets in media placeholders', () => {
const parts = [
frPart(TOOL_SUCCEEDED_OUTPUT, [
{ inlineData: { mimeType: 'image/png\x1b[31m<b>', data: 'AAAA' } },
{ fileData: { fileUri: 'file:///x\x07<script>' } },
]),
];
// Control bytes and `<`/`>` are stripped so the placeholder stays
// well-formed and can't inject terminal codes or forge markup.
expect(getToolResponseDisplayText(parts)).toBe(
'<media: image/png[31mb>\n<media: file:///xscript>',
);
});
it('concatenates output and nested media, keeping nested text', () => {
const parts = [
frPart('main output', [
{ text: 'nested note' },
{ inlineData: { mimeType: 'image/jpeg', data: 'BBBB' } },
]),
];
expect(getToolResponseDisplayText(parts)).toBe(
'main output\nnested note\n<media: image/jpeg>',
);
});
it('keeps text from a plain (non-functionResponse) part', () => {
expect(getToolResponseDisplayText([{ text: 'plain text' }])).toBe(
'plain text',
);
});
it('returns undefined when nothing is extractable', () => {
expect(getToolResponseDisplayText([frPart(undefined)])).toBeUndefined();
});
});
});

View file

@ -25,6 +25,109 @@ export function getResponseTextFromParts(parts: Part[]): string | undefined {
return textSegments.join('');
}
/**
* Default `output` string `convertToFunctionResponse` (in `coreToolScheduler`)
* writes when a tool returned no text (e.g. media-only / empty results).
* Exported as the single source of truth so the producer (coreToolScheduler)
* and this consumer cannot drift: `getToolResponseDisplayText` treats it as
* non-informative and falls back to media placeholders / the summary
* `resultDisplay` instead of surfacing the literal.
*/
export const TOOL_SUCCEEDED_OUTPUT = 'Tool execution succeeded.';
/**
* Sanitize a MIME type / file URI before interpolating it into a
* `<media: …>` placeholder: strip control characters and the angle brackets
* that delimit the placeholder, so a crafted tool response can't inject control
* codes or forge/mangle the placeholder markup. Returns undefined for
* non-strings or once emptied, so callers fall back to their default label.
*/
function sanitizeMediaLabel(value: unknown): string | undefined {
if (typeof value !== 'string') {
return undefined;
}
// eslint-disable-next-line no-control-regex
const cleaned = value.replace(/[\x00-\x1f\x7f-\x9f<>]/g, '').trim();
return cleaned.length > 0 ? cleaned : undefined;
}
/**
* Extract the FULL tool-result text for display (Ctrl+O transcript full detail),
* from the persisted `functionResponse` parts.
*
* Tool results are wrapped as `{ functionResponse: { response: { output },
* parts?: media } }` (see `createFunctionResponsePart`). The complete content
* lives in `response.output`; media attachments live in the NESTED
* `functionResponse.parts`. `getResponseTextFromParts` only reads top-level
* `part.text`, so it cannot see this hence a dedicated extractor.
*
* Rules:
* - concatenate every non-empty `response.output` (skipping the non-informative
* "Tool execution succeeded." placeholder);
* - for nested media parts emit a `<media: mime>` placeholder; keep nested text;
* - output present return it (+ any media placeholders);
* - no output but media present return the placeholder(s);
* - nothing extractable return `undefined` so the UI falls back to the
* summary `resultDisplay`.
*
* Does NOT apply any character cap the bound is whatever core already applied
* (truncateToolOutput / per-tool paging). Full-detail semantics, §4.9.
*/
export function getToolResponseDisplayText(
parts: Part[] | undefined,
): string | undefined {
if (!parts || parts.length === 0) {
return undefined;
}
const segments: string[] = [];
for (const part of parts) {
const fr = part.functionResponse as
| {
response?: { output?: unknown };
parts?: Part[];
}
| undefined;
if (!fr) {
// Non-functionResponse part (rare inside tool results) — keep its text.
if (typeof part.text === 'string' && part.text.length > 0) {
segments.push(part.text);
}
continue;
}
const output = fr.response?.output;
if (
typeof output === 'string' &&
output.length > 0 &&
output !== TOOL_SUCCEEDED_OUTPUT
) {
segments.push(output);
}
if (Array.isArray(fr.parts)) {
for (const nested of fr.parts) {
if (nested.inlineData) {
segments.push(
`<media: ${sanitizeMediaLabel(nested.inlineData.mimeType) ?? 'inline'}>`,
);
} else if (nested.fileData) {
segments.push(
`<media: ${
sanitizeMediaLabel(nested.fileData.mimeType) ??
sanitizeMediaLabel(nested.fileData.fileUri) ??
'file'
}>`,
);
} else if (typeof nested.text === 'string' && nested.text.length > 0) {
segments.push(nested.text);
}
}
}
}
if (segments.length === 0) {
return undefined;
}
return segments.join('\n');
}
export function getFunctionCalls(
response: GenerateContentResponse,
): FunctionCall[] | undefined {

View file

@ -1,17 +1,17 @@
import { defineConfig } from "@playwright/test";
import { defineConfig } from '@playwright/test';
// These are plain Node tests (no browser). Playwright is used purely as the
// test runner, so no browser projects are configured.
export default defineConfig({
testDir: "./test",
testMatch: "*.ts",
testDir: './test',
testMatch: '*.ts',
// Device tests (android/ios/iphone-simulator) mutate real device state and
// must run serially, exactly as they did under mocha's single process.
workers: 1,
fullyParallel: false,
// Device tests (android/ios/iphone-simulator) mutate real device state and
// must run serially, exactly as they did under mocha's single process.
workers: 1,
fullyParallel: false,
// Device operations include several multi-second sleeps; the 30s default is
// too tight.
timeout: 60_000,
// Device operations include several multi-second sleeps; the 30s default is
// too tight.
timeout: 60_000,
});

View file

@ -1,164 +1,180 @@
import { execFileSync, spawnSync } from "child_process";
import os from "node:os";
import fs from "node:fs";
import path from "node:path";
import { trace } from "./logger";
import { execFileSync, spawnSync } from 'child_process';
import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { trace } from './logger';
const DEFAULT_JPEG_QUALITY = 75;
export class ImageTransformer {
private newWidth: number = 0;
private newFormat: 'jpg' | 'png' = 'png';
private jpegOptions: { quality: number } = { quality: DEFAULT_JPEG_QUALITY };
private newWidth: number = 0;
private newFormat: "jpg" | "png" = "png";
private jpegOptions: { quality: number } = { quality: DEFAULT_JPEG_QUALITY };
constructor(private buffer: Buffer) {}
constructor(private buffer: Buffer) {}
public resize(width: number): ImageTransformer {
this.newWidth = width;
return this;
}
public resize(width: number): ImageTransformer {
this.newWidth = width;
return this;
}
public jpeg(options: { quality: number }): ImageTransformer {
this.newFormat = 'jpg';
this.jpegOptions = options;
return this;
}
public jpeg(options: { quality: number }): ImageTransformer {
this.newFormat = "jpg";
this.jpegOptions = options;
return this;
}
public png(): ImageTransformer {
this.newFormat = 'png';
return this;
}
public png(): ImageTransformer {
this.newFormat = "png";
return this;
}
public toBuffer(): Buffer {
if (isSipsInstalled()) {
try {
return this.toBufferWithSips();
} catch (error) {
trace(`Sips failed, falling back to ImageMagick: ${error}`);
}
}
public toBuffer(): Buffer {
if (isSipsInstalled()) {
try {
return this.toBufferWithSips();
} catch (error) {
trace(`Sips failed, falling back to ImageMagick: ${error}`);
}
}
try {
return this.toBufferWithImageMagick();
} catch (error) {
trace(`ImageMagick failed: ${error}`);
throw new Error(
'Image scaling unavailable (requires Sips or ImageMagick).',
);
}
}
try {
return this.toBufferWithImageMagick();
} catch (error) {
trace(`ImageMagick failed: ${error}`);
throw new Error("Image scaling unavailable (requires Sips or ImageMagick).");
}
}
private qualityToSips(q: number): 'low' | 'normal' | 'high' | 'best' {
if (q >= 90) {
return 'best';
}
private qualityToSips(q: number): "low" | "normal" | "high" | "best" {
if (q >= 90) {
return "best";
}
if (q >= 75) {
return 'high';
}
if (q >= 75) {
return "high";
}
if (q >= 50) {
return 'normal';
}
if (q >= 50) {
return "normal";
}
return 'low';
}
return "low";
}
private toBufferWithSips(): Buffer {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'image-'));
const inputFile = path.join(tempDir, 'input');
const outputFile = path.join(
tempDir,
`output.${this.newFormat === 'jpg' ? 'jpg' : 'png'}`,
);
private toBufferWithSips(): Buffer {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "image-"));
const inputFile = path.join(tempDir, "input");
const outputFile = path.join(tempDir, `output.${this.newFormat === "jpg" ? "jpg" : "png"}`);
try {
fs.writeFileSync(inputFile, this.buffer);
try {
fs.writeFileSync(inputFile, this.buffer);
const args = ['-s', 'format', this.newFormat === 'jpg' ? 'jpeg' : 'png'];
if (this.newFormat === 'jpg') {
args.push(
'-s',
'formatOptions',
this.qualityToSips(this.jpegOptions.quality),
);
}
const args = ["-s", "format", this.newFormat === "jpg" ? "jpeg" : "png"];
if (this.newFormat === "jpg") {
args.push("-s", "formatOptions", this.qualityToSips(this.jpegOptions.quality));
}
args.push('-Z', `${this.newWidth}`);
args.push('--out', outputFile);
args.push(inputFile);
args.push("-Z", `${this.newWidth}`);
args.push("--out", outputFile);
args.push(inputFile);
trace(`Running sips command: /usr/bin/sips ${args.join(' ')}`);
const proc = spawnSync('/usr/bin/sips', args, {
maxBuffer: 8 * 1024 * 1024,
});
trace(`Running sips command: /usr/bin/sips ${args.join(" ")}`);
const proc = spawnSync("/usr/bin/sips", args, {
maxBuffer: 8 * 1024 * 1024
});
if (proc.status !== 0) {
throw new Error(`Sips failed with status ${proc.status}`);
}
if (proc.status !== 0) {
throw new Error(`Sips failed with status ${proc.status}`);
}
const outputBuffer = fs.readFileSync(outputFile);
trace('Sips returned buffer of size: ' + outputBuffer.length);
return outputBuffer;
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
}
}
const outputBuffer = fs.readFileSync(outputFile);
trace("Sips returned buffer of size: " + outputBuffer.length);
return outputBuffer;
} finally {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch (error) {
// Ignore cleanup errors
}
}
}
private toBufferWithImageMagick(): Buffer {
const magickArgs = [
'-',
'-resize',
`${this.newWidth}x`,
'-quality',
`${this.jpegOptions.quality}`,
`${this.newFormat}:-`,
];
trace(`Running magick command: magick ${magickArgs.join(' ')}`);
private toBufferWithImageMagick(): Buffer {
const magickArgs = ["-", "-resize", `${this.newWidth}x`, "-quality", `${this.jpegOptions.quality}`, `${this.newFormat}:-`];
trace(`Running magick command: magick ${magickArgs.join(" ")}`);
const proc = spawnSync('magick', magickArgs, {
maxBuffer: 8 * 1024 * 1024,
input: this.buffer,
});
const proc = spawnSync("magick", magickArgs, {
maxBuffer: 8 * 1024 * 1024,
input: this.buffer
});
return proc.stdout;
}
return proc.stdout;
}
}
export class Image {
constructor(private buffer: Buffer) {}
constructor(private buffer: Buffer) {}
public static fromBuffer(buffer: Buffer): Image {
return new Image(buffer);
}
public static fromBuffer(buffer: Buffer): Image {
return new Image(buffer);
}
public resize(width: number): ImageTransformer {
return new ImageTransformer(this.buffer).resize(width);
}
public resize(width: number): ImageTransformer {
return new ImageTransformer(this.buffer).resize(width);
}
public jpeg(options: { quality: number }): ImageTransformer {
return new ImageTransformer(this.buffer).jpeg(options);
}
public jpeg(options: { quality: number }): ImageTransformer {
return new ImageTransformer(this.buffer).jpeg(options);
}
}
const isDarwin = (): boolean => {
return os.platform() === "darwin";
return os.platform() === 'darwin';
};
export const isSipsInstalled = (): boolean => {
if (!isDarwin()) {
return false;
}
if (!isDarwin()) {
return false;
}
try {
execFileSync("/usr/bin/sips", ["--version"]);
return true;
} catch (error) {
return false;
}
try {
execFileSync('/usr/bin/sips', ['--version']);
return true;
} catch (error) {
return false;
}
};
export const isImageMagickInstalled = (): boolean => {
try {
return execFileSync("magick", ["--version"])
.toString()
.split("\n")
.filter(line => line.includes("Version: ImageMagick"))
.length > 0;
} catch (error) {
return false;
}
try {
return (
execFileSync('magick', ['--version'])
.toString()
.split('\n')
.filter((line) => line.includes('Version: ImageMagick')).length > 0
);
} catch (error) {
return false;
}
};
export const isScalingAvailable = (): boolean => {
return isImageMagickInstalled() || isSipsInstalled();
return isImageMagickInstalled() || isSipsInstalled();
};

View file

@ -1,132 +1,149 @@
#!/usr/bin/env node
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createMcpServer, getAgentVersion } from "./server";
import { error } from "./logger";
import express from "express";
import { program } from "commander";
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createMcpServer, getAgentVersion } from './server';
import { error } from './logger';
import express from 'express';
import { program } from 'commander';
const startSseServer = async (host: string, port: number) => {
const app = express();
const server = createMcpServer();
const app = express();
const server = createMcpServer();
const authToken = process.env.MOBILEMCP_AUTH;
if (!authToken) {
error("WARNING: MOBILEMCP_AUTH is not set. The SSE server will accept unauthenticated connections. Set MOBILEMCP_AUTH to require Bearer token authentication.");
}
const authToken = process.env.MOBILEMCP_AUTH;
if (!authToken) {
error(
'WARNING: MOBILEMCP_AUTH is not set. The SSE server will accept unauthenticated connections. Set MOBILEMCP_AUTH to require Bearer token authentication.',
);
}
if (authToken) {
app.use((req, res, next) => {
if (req.headers.authorization !== `Bearer ${authToken}`) {
res.status(401).json({ error: "Unauthorized" });
return;
}
if (authToken) {
app.use((req, res, next) => {
if (req.headers.authorization !== `Bearer ${authToken}`) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
next();
});
}
next();
});
}
// Block cross-origin requests — MCP clients are not browsers
app.use((req, res, next) => {
if (req.headers.origin) {
res.status(403).json({ error: "Cross-origin requests are not allowed" });
return;
}
// Block cross-origin requests — MCP clients are not browsers
app.use((req, res, next) => {
if (req.headers.origin) {
res.status(403).json({ error: 'Cross-origin requests are not allowed' });
return;
}
if (req.method === "OPTIONS") {
res.status(403).end();
return;
}
if (req.method === 'OPTIONS') {
res.status(403).end();
return;
}
next();
});
next();
});
let transport: SSEServerTransport | null = null;
let transport: SSEServerTransport | null = null;
app.post("/mcp", (req, res) => {
if (transport) {
transport.handlePostMessage(req, res);
}
});
app.post('/mcp', (req, res) => {
if (transport) {
transport.handlePostMessage(req, res);
}
});
app.get("/mcp", (req, res) => {
if (transport) {
res.status(409).json({ error: "Another client is already connected. Disconnect the existing client first." });
return;
}
app.get('/mcp', (req, res) => {
if (transport) {
res
.status(409)
.json({
error:
'Another client is already connected. Disconnect the existing client first.',
});
return;
}
transport = new SSEServerTransport("/mcp", res);
transport = new SSEServerTransport('/mcp', res);
transport.onclose = () => {
transport = null;
};
transport.onclose = () => {
transport = null;
};
server.connect(transport);
});
server.connect(transport);
});
app.listen(port, host, () => {
error(`mobile-mcp ${getAgentVersion()} sse server listening on http://${host}:${port}/mcp`);
});
app.listen(port, host, () => {
error(
`mobile-mcp ${getAgentVersion()} sse server listening on http://${host}:${port}/mcp`,
);
});
};
const startStdioServer = async () => {
try {
const transport = new StdioServerTransport();
try {
const transport = new StdioServerTransport();
const server = createMcpServer();
await server.connect(transport);
const server = createMcpServer();
await server.connect(transport);
// Exit cleanly on termination signals so node flushes pending work
// (including NODE_V8_COVERAGE output). Node's default SIGINT/SIGTERM
// handling terminates the process without writing the coverage file,
// which makes the `test:mcp` report come back all zeros.
const shutdown = () => {
process.exit(0);
};
// Exit cleanly on termination signals so node flushes pending work
// (including NODE_V8_COVERAGE output). Node's default SIGINT/SIGTERM
// handling terminates the process without writing the coverage file,
// which makes the `test:mcp` report come back all zeros.
const shutdown = () => {
process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
error("mobile-mcp server running on stdio");
} catch (err: any) {
console.error("Fatal error in main():", err);
error("Fatal error in main(): " + JSON.stringify(err.stack));
process.exit(1);
}
error('mobile-mcp server running on stdio');
} catch (err: any) {
console.error('Fatal error in main():', err);
error('Fatal error in main(): ' + JSON.stringify(err.stack));
process.exit(1);
}
};
const main = async () => {
program
.version(getAgentVersion())
.option("--listen <listen>", "Start SSE server on [host:]port")
.option("--stdio", "Start stdio server (default)")
.parse(process.argv);
program
.version(getAgentVersion())
.option('--listen <listen>', 'Start SSE server on [host:]port')
.option('--stdio', 'Start stdio server (default)')
.parse(process.argv);
const options = program.opts();
const options = program.opts();
if (options.listen) {
const listen = (options.listen as string).trim();
const lastColon = listen.lastIndexOf(":");
let host = "localhost";
let rawPort: string;
if (options.listen) {
const listen = (options.listen as string).trim();
const lastColon = listen.lastIndexOf(':');
let host = 'localhost';
let rawPort: string;
if (lastColon > 0) {
host = listen.substring(0, lastColon);
rawPort = listen.substring(lastColon + 1);
} else {
rawPort = listen;
}
if (lastColon > 0) {
host = listen.substring(0, lastColon);
rawPort = listen.substring(lastColon + 1);
} else {
rawPort = listen;
}
const port = Number.parseInt(rawPort, 10);
if (!host || !rawPort || !Number.isInteger(port) || port < 1 || port > 65535) {
error(`Invalid --listen value "${listen}". Expected [host:]port with port 1-65535.`);
process.exit(1);
}
const port = Number.parseInt(rawPort, 10);
if (
!host ||
!rawPort ||
!Number.isInteger(port) ||
port < 1 ||
port > 65535
) {
error(
`Invalid --listen value "${listen}". Expected [host:]port with port 1-65535.`,
);
process.exit(1);
}
await startSseServer(host, port);
} else {
await startStdioServer();
}
await startSseServer(host, port);
} else {
await startStdioServer();
}
};
main().then();

View file

@ -1,21 +1,21 @@
import { appendFileSync } from "node:fs";
import { appendFileSync } from 'node:fs';
const writeLog = (message: string) => {
if (process.env.LOG_FILE) {
const logfile = process.env.LOG_FILE;
const timestamp = new Date().toISOString();
const levelStr = "INFO";
const logMessage = `[${timestamp}] ${levelStr} ${message}`;
appendFileSync(logfile, logMessage + "\n");
}
if (process.env.LOG_FILE) {
const logfile = process.env.LOG_FILE;
const timestamp = new Date().toISOString();
const levelStr = 'INFO';
const logMessage = `[${timestamp}] ${levelStr} ${message}`;
appendFileSync(logfile, logMessage + '\n');
}
console.error(message);
console.error(message);
};
export const trace = (message: string) => {
writeLog(message);
writeLog(message);
};
export const error = (message: string) => {
writeLog(message);
writeLog(message);
};

View file

@ -1,207 +1,246 @@
import { existsSync } from "node:fs";
import { dirname, join, sep } from "node:path";
import { execFileSync, spawn, ChildProcess } from "node:child_process";
import { existsSync } from 'node:fs';
import { dirname, join, sep } from 'node:path';
import { execFileSync, spawn, ChildProcess } from 'node:child_process';
export interface MobilecliCrashEntry {
processName: string;
timestamp: string;
id: string;
processName: string;
timestamp: string;
id: string;
}
export interface MobilecliCrashesListResponse {
status: "ok";
data: MobilecliCrashEntry[];
status: 'ok';
data: MobilecliCrashEntry[];
}
export interface MobilecliCrashGetResponse {
status: "ok";
data: {
content: string;
id: string;
};
status: 'ok';
data: {
content: string;
id: string;
};
}
export interface MobilecliAgentStatusResponse {
status: "ok" | "fail";
data: {
message: string;
};
status: 'ok' | 'fail';
data: {
message: string;
};
}
export interface MobilecliDevicesOptions {
includeOffline?: boolean;
platform?: "ios" | "android";
type?: "real" | "emulator" | "simulator";
includeOffline?: boolean;
platform?: 'ios' | 'android';
type?: 'real' | 'emulator' | 'simulator';
}
export interface MobilecliDeviceProvider {
type: string; // e.g. "mobilefleet" for remote devices
allocationId?: string;
type: string; // e.g. "mobilefleet" for remote devices
allocationId?: string;
}
export interface MobilecliDevice {
id: string;
name: string;
platform: "android" | "ios";
type: "real" | "emulator" | "simulator";
version: string;
provider?: MobilecliDeviceProvider;
id: string;
name: string;
platform: 'android' | 'ios';
type: 'real' | 'emulator' | 'simulator';
version: string;
provider?: MobilecliDeviceProvider;
}
export interface MobilecliDevicesResponse {
status: "ok";
data: {
devices: MobilecliDevice[];
};
status: 'ok';
data: {
devices: MobilecliDevice[];
};
}
const TIMEOUT = 30000;
const MAX_BUFFER_SIZE = 1024 * 1024 * 8;
export class Mobilecli {
private path: string | null = null;
private path: string | null = null;
constructor() { }
constructor() {}
private getPath(): string {
if (!this.path) {
this.path = Mobilecli.getMobilecliPath();
}
return this.path;
}
private getPath(): string {
if (!this.path) {
this.path = Mobilecli.getMobilecliPath();
}
return this.path;
}
public executeCommand(args: string[]): string {
const path = this.getPath();
return execFileSync(path, args, { encoding: "utf8" }).toString().trim();
}
public executeCommand(args: string[]): string {
const path = this.getPath();
return execFileSync(path, args, { encoding: 'utf8' }).toString().trim();
}
public spawnCommand(args: string[]): ChildProcess {
const binaryPath = this.getPath();
return spawn(binaryPath, args, {
stdio: ["ignore", "ignore", "ignore"],
});
}
public spawnCommand(args: string[]): ChildProcess {
const binaryPath = this.getPath();
return spawn(binaryPath, args, {
stdio: ['ignore', 'ignore', 'ignore'],
});
}
public executeCommandBuffer(args: string[]): Buffer {
const path = this.getPath();
return execFileSync(path, args, {
encoding: "buffer",
maxBuffer: MAX_BUFFER_SIZE,
timeout: TIMEOUT,
}) as Buffer;
}
public executeCommandBuffer(args: string[]): Buffer {
const path = this.getPath();
return execFileSync(path, args, {
encoding: 'buffer',
maxBuffer: MAX_BUFFER_SIZE,
timeout: TIMEOUT,
}) as Buffer;
}
private static getMobilecliPath(): string {
if (process.env.MOBILECLI_PATH) {
return process.env.MOBILECLI_PATH;
}
private static getMobilecliPath(): string {
if (process.env.MOBILECLI_PATH) {
return process.env.MOBILECLI_PATH;
}
const platform = process.platform;
const arch = process.arch;
const platform = process.platform;
const arch = process.arch;
const normalizedPlatform = platform === "win32" ? "windows" : platform;
const normalizedArch = arch === "arm64" ? "arm64" : "amd64";
const ext = platform === "win32" ? ".exe" : "";
const binaryName = `mobilecli-${normalizedPlatform}-${normalizedArch}${ext}`;
const normalizedPlatform = platform === 'win32' ? 'windows' : platform;
const normalizedArch = arch === 'arm64' ? 'arm64' : 'amd64';
const ext = platform === 'win32' ? '.exe' : '';
const binaryName = `mobilecli-${normalizedPlatform}-${normalizedArch}${ext}`;
// Check if mobile-mcp is installed as a package
const currentPath = __filename;
const pathParts = currentPath.split(sep);
const lastNodeModulesIndex = pathParts.lastIndexOf("node_modules");
// Check if mobile-mcp is installed as a package
const currentPath = __filename;
const pathParts = currentPath.split(sep);
const lastNodeModulesIndex = pathParts.lastIndexOf('node_modules');
if (lastNodeModulesIndex !== -1) {
// We're inside node_modules, go to the last node_modules in the path
const nodeModulesParts = pathParts.slice(0, lastNodeModulesIndex + 1);
const lastNodeModulesPath = nodeModulesParts.join(sep);
const mobilecliPath = join(lastNodeModulesPath, "mobilecli", "bin", binaryName);
if (lastNodeModulesIndex !== -1) {
// We're inside node_modules, go to the last node_modules in the path
const nodeModulesParts = pathParts.slice(0, lastNodeModulesIndex + 1);
const lastNodeModulesPath = nodeModulesParts.join(sep);
const mobilecliPath = join(
lastNodeModulesPath,
'mobilecli',
'bin',
binaryName,
);
if (existsSync(mobilecliPath)) {
return mobilecliPath;
}
}
if (existsSync(mobilecliPath)) {
return mobilecliPath;
}
}
// Not in node_modules, look one directory up from current script
const scriptDir = dirname(__filename);
const parentDir = dirname(scriptDir);
const mobilecliPath = join(parentDir, "node_modules", "mobilecli", "bin", binaryName);
// Not in node_modules, look one directory up from current script
const scriptDir = dirname(__filename);
const parentDir = dirname(scriptDir);
const mobilecliPath = join(
parentDir,
'node_modules',
'mobilecli',
'bin',
binaryName,
);
if (existsSync(mobilecliPath)) {
return mobilecliPath;
}
if (existsSync(mobilecliPath)) {
return mobilecliPath;
}
throw new Error(`Could not find mobilecli binary for platform: ${platform}`);
}
throw new Error(
`Could not find mobilecli binary for platform: ${platform}`,
);
}
getVersion(): string {
try {
const output = this.executeCommand(["--version"]);
if (output.startsWith("mobilecli version ")) {
return output.substring("mobilecli version ".length);
}
getVersion(): string {
try {
const output = this.executeCommand(['--version']);
if (output.startsWith('mobilecli version ')) {
return output.substring('mobilecli version '.length);
}
return "failed";
} catch (error: any) {
return "failed " + error.message;
}
}
return 'failed';
} catch (error: any) {
return 'failed ' + error.message;
}
}
remoteListDevices(): string {
return this.executeCommand(["remote", "list-devices"]);
}
remoteListDevices(): string {
return this.executeCommand(['remote', 'list-devices']);
}
remoteAllocate(platform: "ios" | "android"): string {
return this.executeCommand(["remote", "allocate", "--platform", platform]);
}
remoteAllocate(platform: 'ios' | 'android'): string {
return this.executeCommand(['remote', 'allocate', '--platform', platform]);
}
remoteRelease(deviceId: string): string {
return this.executeCommand(["remote", "release", "--device", deviceId]);
}
remoteRelease(deviceId: string): string {
return this.executeCommand(['remote', 'release', '--device', deviceId]);
}
crashesList(deviceId: string): MobilecliCrashesListResponse {
const output = this.executeCommand(["device", "crashes", "list", "--device", deviceId]);
return JSON.parse(output) as MobilecliCrashesListResponse;
}
crashesList(deviceId: string): MobilecliCrashesListResponse {
const output = this.executeCommand([
'device',
'crashes',
'list',
'--device',
deviceId,
]);
return JSON.parse(output) as MobilecliCrashesListResponse;
}
crashesGet(deviceId: string, id: string): MobilecliCrashGetResponse {
const output = this.executeCommandBuffer(["device", "crashes", "get", id, "--device", deviceId]);
return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse;
}
crashesGet(deviceId: string, id: string): MobilecliCrashGetResponse {
const output = this.executeCommandBuffer([
'device',
'crashes',
'get',
id,
'--device',
deviceId,
]);
return JSON.parse(output.toString().trim()) as MobilecliCrashGetResponse;
}
agentStatus(deviceId: string): MobilecliAgentStatusResponse {
const output = this.executeCommand(["agent", "status", "--device", deviceId]);
return JSON.parse(output) as MobilecliAgentStatusResponse;
}
agentStatus(deviceId: string): MobilecliAgentStatusResponse {
const output = this.executeCommand([
'agent',
'status',
'--device',
deviceId,
]);
return JSON.parse(output) as MobilecliAgentStatusResponse;
}
agentInstall(deviceId: string): void {
this.executeCommand(["agent", "install", "--device", deviceId]);
}
agentInstall(deviceId: string): void {
this.executeCommand(['agent', 'install', '--device', deviceId]);
}
getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse {
const args = ["devices"];
getDevices(options?: MobilecliDevicesOptions): MobilecliDevicesResponse {
const args = ['devices'];
if (options) {
if (options.includeOffline) {
args.push("--include-offline");
}
if (options) {
if (options.includeOffline) {
args.push('--include-offline');
}
if (options.platform) {
if (options.platform !== "ios" && options.platform !== "android") {
throw new Error(`Invalid platform: ${options.platform}. Must be "ios" or "android"`);
}
if (options.platform) {
if (options.platform !== 'ios' && options.platform !== 'android') {
throw new Error(
`Invalid platform: ${options.platform}. Must be "ios" or "android"`,
);
}
args.push("--platform", options.platform);
}
args.push('--platform', options.platform);
}
if (options.type) {
if (options.type !== "real" && options.type !== "emulator" && options.type !== "simulator") {
throw new Error(`Invalid type: ${options.type}. Must be "real", "emulator", or "simulator"`);
}
if (options.type) {
if (
options.type !== 'real' &&
options.type !== 'emulator' &&
options.type !== 'simulator'
) {
throw new Error(
`Invalid type: ${options.type}. Must be "real", "emulator", or "simulator"`,
);
}
args.push("--type", options.type);
}
}
args.push('--type', options.type);
}
}
const mobilecliOutput = this.executeCommand(args);
return JSON.parse(mobilecliOutput) as MobilecliDevicesResponse;
}
const mobilecliOutput = this.executeCommand(args);
return JSON.parse(mobilecliOutput) as MobilecliDevicesResponse;
}
}

View file

@ -1,20 +1,19 @@
export interface PngDimensions {
width: number;
height: number;
width: number;
height: number;
}
export class PNG {
public constructor(private readonly buffer: Buffer) {
}
public constructor(private readonly buffer: Buffer) {}
public getDimensions(): PngDimensions {
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!this.buffer.subarray(0, 8).equals(pngSignature)) {
throw new Error("Not a valid PNG file");
}
public getDimensions(): PngDimensions {
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
if (!this.buffer.subarray(0, 8).equals(pngSignature)) {
throw new Error('Not a valid PNG file');
}
const width = this.buffer.readUInt32BE(16);
const height = this.buffer.readUInt32BE(20);
return { width, height };
}
const width = this.buffer.readUInt32BE(16);
const height = this.buffer.readUInt32BE(20);
return { width, height };
}
}

View file

@ -1,88 +1,91 @@
import path from "node:path";
import os from "node:os";
import fs from "node:fs";
import { ActionableError } from "./robot";
import path from 'node:path';
import os from 'node:os';
import fs from 'node:fs';
import { ActionableError } from './robot';
export function validatePackageName(packageName: string): void {
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
throw new ActionableError(`Invalid package name: "${packageName}"`);
}
if (!/^[a-zA-Z0-9._]+$/.test(packageName)) {
throw new ActionableError(`Invalid package name: "${packageName}"`);
}
}
export function validateLocale(locale: string): void {
if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) {
throw new ActionableError(`Invalid locale: "${locale}"`);
}
if (!/^[a-zA-Z0-9,\- ]+$/.test(locale)) {
throw new ActionableError(`Invalid locale: "${locale}"`);
}
}
function getAllowedRoots(): string[] {
const roots = [
os.tmpdir(),
process.cwd(),
];
const roots = [os.tmpdir(), process.cwd()];
// macOS /tmp is a symlink to /private/tmp, add both to be safe
if (process.platform === "darwin") {
roots.push("/tmp");
roots.push("/private/tmp");
}
// macOS /tmp is a symlink to /private/tmp, add both to be safe
if (process.platform === 'darwin') {
roots.push('/tmp');
roots.push('/private/tmp');
}
return roots.map(r => path.resolve(r));
return roots.map((r) => path.resolve(r));
}
function isPathUnderRoot(filePath: string, root: string): boolean {
const relative = path.relative(root, filePath);
if (relative === "") {
return false;
}
const relative = path.relative(root, filePath);
if (relative === '') {
return false;
}
if (path.isAbsolute(relative)) {
return false;
}
if (path.isAbsolute(relative)) {
return false;
}
if (relative.startsWith("..")) {
return false;
}
if (relative.startsWith('..')) {
return false;
}
return true;
return true;
}
export function validateFileExtension(filePath: string, allowedExtensions: string[], toolName: string): void {
const ext = path.extname(filePath).toLowerCase();
if (!allowedExtensions.includes(ext)) {
throw new ActionableError(`${toolName} requires a ${allowedExtensions.join(", ")} file extension, got: "${ext || "(none)"}"`);
}
export function validateFileExtension(
filePath: string,
allowedExtensions: string[],
toolName: string,
): void {
const ext = path.extname(filePath).toLowerCase();
if (!allowedExtensions.includes(ext)) {
throw new ActionableError(
`${toolName} requires a ${allowedExtensions.join(', ')} file extension, got: "${ext || '(none)'}"`,
);
}
}
function resolveWithSymlinks(filePath: string): string {
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
const filename = path.basename(resolved);
const resolved = path.resolve(filePath);
const dir = path.dirname(resolved);
const filename = path.basename(resolved);
try {
return path.join(fs.realpathSync(dir), filename);
} catch {
return resolved;
}
try {
return path.join(fs.realpathSync(dir), filename);
} catch {
return resolved;
}
}
export function validateOutputPath(filePath: string): void {
const resolved = resolveWithSymlinks(filePath);
const allowedRoots = getAllowedRoots();
const isWindows = process.platform === "win32";
const resolved = resolveWithSymlinks(filePath);
const allowedRoots = getAllowedRoots();
const isWindows = process.platform === 'win32';
const isAllowed = allowedRoots.some(root => {
if (isWindows) {
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
}
const isAllowed = allowedRoots.some((root) => {
if (isWindows) {
return isPathUnderRoot(resolved.toLowerCase(), root.toLowerCase());
}
return isPathUnderRoot(resolved, root);
});
return isPathUnderRoot(resolved, root);
});
if (!isAllowed) {
const dir = path.dirname(resolved);
throw new ActionableError(
`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`
);
}
if (!isAllowed) {
const dir = path.dirname(resolved);
throw new ActionableError(
`"${dir}" is not in the list of allowed directories. Allowed directories include the current directory and the temp directory on this host.`,
);
}
}

View file

@ -1,454 +1,494 @@
import { ActionableError, SwipeDirection, ScreenSize, ScreenElement, Orientation } from "./robot";
import {
ActionableError,
SwipeDirection,
ScreenSize,
ScreenElement,
Orientation,
} from './robot';
export interface SourceTreeElementRect {
x: number;
y: number;
width: number;
height: number;
x: number;
y: number;
width: number;
height: number;
}
export interface SourceTreeElement {
type: string;
label?: string;
name?: string;
value?: string;
rawIdentifier?: string;
rect: SourceTreeElementRect;
isVisible?: string; // "0" or "1"
children?: Array<SourceTreeElement>;
type: string;
label?: string;
name?: string;
value?: string;
rawIdentifier?: string;
rect: SourceTreeElementRect;
isVisible?: string; // "0" or "1"
children?: Array<SourceTreeElement>;
}
export interface SourceTree {
value: SourceTreeElement;
value: SourceTreeElement;
}
export class WebDriverAgent {
constructor(
private readonly host: string,
private readonly port: number,
) {}
constructor(private readonly host: string, private readonly port: number) {
}
public async isRunning(): Promise<boolean> {
const url = `http://${this.host}:${this.port}/status`;
try {
const response = await fetch(url);
const json = await response.json();
return response.status === 200 && json.value?.ready === true;
} catch (error) {
// console.error(`Failed to connect to WebDriverAgent: ${error}`);
return false;
}
}
public async isRunning(): Promise<boolean> {
const url = `http://${this.host}:${this.port}/status`;
try {
const response = await fetch(url);
const json = await response.json();
return response.status === 200 && json.value?.ready === true;
} catch (error) {
// console.error(`Failed to connect to WebDriverAgent: ${error}`);
return false;
}
}
public async createSession(): Promise<string> {
const url = `http://${this.host}:${this.port}/session`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
capabilities: { alwaysMatch: { platformName: 'iOS' } },
}),
});
public async createSession(): Promise<string> {
const url = `http://${this.host}:${this.port}/session`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ capabilities: { alwaysMatch: { platformName: "iOS" } } }),
});
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(
`Failed to create WebDriver session: ${response.status} ${errorText}`,
);
}
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(`Failed to create WebDriver session: ${response.status} ${errorText}`);
}
const json = await response.json();
if (!json.value || !json.value.sessionId) {
throw new ActionableError(
`Invalid session response: ${JSON.stringify(json)}`,
);
}
const json = await response.json();
if (!json.value || !json.value.sessionId) {
throw new ActionableError(`Invalid session response: ${JSON.stringify(json)}`);
}
return json.value.sessionId;
}
return json.value.sessionId;
}
public async deleteSession(sessionId: string) {
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
const response = await fetch(url, { method: 'DELETE' });
return response.json();
}
public async deleteSession(sessionId: string) {
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
const response = await fetch(url, { method: "DELETE" });
return response.json();
}
public async withinSession(fn: (url: string) => Promise<any>) {
const sessionId = await this.createSession();
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
const result = await fn(url);
await this.deleteSession(sessionId);
return result;
}
public async withinSession(fn: (url: string) => Promise<any>) {
const sessionId = await this.createSession();
const url = `http://${this.host}:${this.port}/session/${sessionId}`;
const result = await fn(url);
await this.deleteSession(sessionId);
return result;
}
public async getScreenSize(sessionUrl?: string): Promise<ScreenSize> {
if (sessionUrl) {
const url = `${sessionUrl}/wda/screen`;
const response = await fetch(url);
const json = await response.json();
return {
width: json.value.screenSize.width,
height: json.value.screenSize.height,
scale: json.value.scale || 1,
};
} else {
return this.withinSession(async (sessionUrlInner) => {
const url = `${sessionUrlInner}/wda/screen`;
const response = await fetch(url);
const json = await response.json();
return {
width: json.value.screenSize.width,
height: json.value.screenSize.height,
scale: json.value.scale || 1,
};
});
}
}
public async getScreenSize(sessionUrl?: string): Promise<ScreenSize> {
if (sessionUrl) {
const url = `${sessionUrl}/wda/screen`;
const response = await fetch(url);
const json = await response.json();
return {
width: json.value.screenSize.width,
height: json.value.screenSize.height,
scale: json.value.scale || 1,
};
} else {
return this.withinSession(async sessionUrlInner => {
const url = `${sessionUrlInner}/wda/screen`;
const response = await fetch(url);
const json = await response.json();
return {
width: json.value.screenSize.width,
height: json.value.screenSize.height,
scale: json.value.scale || 1,
};
});
}
}
public async sendKeys(keys: string) {
await this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/wda/keys`;
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ value: [keys] }),
});
});
}
public async sendKeys(keys: string) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/wda/keys`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ value: [keys] }),
});
});
}
public async pressButton(button: string) {
const _map = {
HOME: 'home',
VOLUME_UP: 'volumeup',
VOLUME_DOWN: 'volumedown',
};
public async pressButton(button: string) {
const _map = {
"HOME": "home",
"VOLUME_UP": "volumeup",
"VOLUME_DOWN": "volumedown",
};
if (button === 'ENTER') {
await this.sendKeys('\n');
return;
}
if (button === "ENTER") {
await this.sendKeys("\n");
return;
}
// Type assertion to check if button is a key of _map
if (!(button in _map)) {
throw new ActionableError(`Button "${button}" is not supported`);
}
// Type assertion to check if button is a key of _map
if (!(button in _map)) {
throw new ActionableError(`Button "${button}" is not supported`);
}
await this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/wda/pressButton`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: button,
}),
});
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/wda/pressButton`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
name: button,
}),
});
return response.json();
});
}
return response.json();
});
}
public async tap(x: number, y: number) {
await this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x, y },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 100 },
{ type: 'pointerUp', button: 0 },
],
},
],
}),
});
});
}
public async tap(x: number, y: number) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x, y },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 100 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
});
}
public async doubleTap(x: number, y: number) {
await this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x, y },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
public async doubleTap(x: number, y: number) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x, y },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 50 },
{ type: "pointerUp", button: 0 },
{ type: 'pause', duration: 100 },
{ type: "pause", duration: 100 },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration: 50 },
{ type: 'pointerUp', button: 0 },
],
},
],
}),
});
});
}
{ type: "pointerDown", button: 0 },
{ type: "pause", duration: 50 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
});
}
public async longPress(x: number, y: number, duration: number) {
await this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x, y },
{ type: 'pointerDown', button: 0 },
{ type: 'pause', duration },
{ type: 'pointerUp', button: 0 },
],
},
],
}),
});
});
}
public async longPress(x: number, y: number, duration: number) {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/actions`;
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x, y },
{ type: "pointerDown", button: 0 },
{ type: "pause", duration },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
});
}
private isVisible(rect: SourceTreeElementRect): boolean {
return rect.x >= 0 && rect.y >= 0;
}
private isVisible(rect: SourceTreeElementRect): boolean {
return rect.x >= 0 && rect.y >= 0;
}
private filterSourceElements(
source: SourceTreeElement,
): Array<ScreenElement> {
const output: ScreenElement[] = [];
private filterSourceElements(source: SourceTreeElement): Array<ScreenElement> {
const output: ScreenElement[] = [];
const acceptedTypes = [
'TextField',
'Button',
'Switch',
'Icon',
'SearchField',
'StaticText',
'Image',
];
const acceptedTypes = ["TextField", "Button", "Switch", "Icon", "SearchField", "StaticText", "Image"];
if (acceptedTypes.includes(source.type)) {
if (source.isVisible === '1' && this.isVisible(source.rect)) {
if (
source.label !== null ||
source.name !== null ||
source.rawIdentifier !== null
) {
output.push({
type: source.type,
label: source.label,
name: source.name,
value: source.value,
identifier: source.rawIdentifier,
rect: {
x: source.rect.x,
y: source.rect.y,
width: source.rect.width,
height: source.rect.height,
},
});
}
}
}
if (acceptedTypes.includes(source.type)) {
if (source.isVisible === "1" && this.isVisible(source.rect)) {
if (source.label !== null || source.name !== null || source.rawIdentifier !== null) {
output.push({
type: source.type,
label: source.label,
name: source.name,
value: source.value,
identifier: source.rawIdentifier,
rect: {
x: source.rect.x,
y: source.rect.y,
width: source.rect.width,
height: source.rect.height,
},
});
}
}
}
if (source.children) {
for (const child of source.children) {
output.push(...this.filterSourceElements(child));
}
}
if (source.children) {
for (const child of source.children) {
output.push(...this.filterSourceElements(child));
}
}
return output;
}
return output;
}
public async getPageSource(): Promise<SourceTree> {
const url = `http://${this.host}:${this.port}/source/?format=json`;
const response = await fetch(url);
const json = await response.json();
return json as SourceTree;
}
public async getPageSource(): Promise<SourceTree> {
const url = `http://${this.host}:${this.port}/source/?format=json`;
const response = await fetch(url);
const json = await response.json();
return json as SourceTree;
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const source = await this.getPageSource();
return this.filterSourceElements(source.value);
}
public async getElementsOnScreen(): Promise<ScreenElement[]> {
const source = await this.getPageSource();
return this.filterSourceElements(source.value);
}
public async openUrl(url: string): Promise<void> {
await this.withinSession(async (sessionUrl) => {
await fetch(`${sessionUrl}/url`, {
method: 'POST',
body: JSON.stringify({ url }),
});
});
}
public async openUrl(url: string): Promise<void> {
await this.withinSession(async sessionUrl => {
await fetch(`${sessionUrl}/url`, {
method: "POST",
body: JSON.stringify({ url }),
});
});
}
public async getScreenshot(): Promise<Buffer> {
const url = `http://${this.host}:${this.port}/screenshot`;
const response = await fetch(url);
const json = await response.json();
return Buffer.from(json.value, 'base64');
}
public async getScreenshot(): Promise<Buffer> {
const url = `http://${this.host}:${this.port}/screenshot`;
const response = await fetch(url);
const json = await response.json();
return Buffer.from(json.value, "base64");
}
public async swipe(direction: SwipeDirection): Promise<void> {
await this.withinSession(async (sessionUrl) => {
const screenSize = await this.getScreenSize(sessionUrl);
let x0: number, y0: number, x1: number, y1: number;
// Use 60% of the width/height for swipe distance
const verticalDistance = Math.floor(screenSize.height * 0.6);
const horizontalDistance = Math.floor(screenSize.width * 0.6);
const centerX = Math.floor(screenSize.width / 2);
const centerY = Math.floor(screenSize.height / 2);
public async swipe(direction: SwipeDirection): Promise<void> {
await this.withinSession(async sessionUrl => {
const screenSize = await this.getScreenSize(sessionUrl);
let x0: number, y0: number, x1: number, y1: number;
// Use 60% of the width/height for swipe distance
const verticalDistance = Math.floor(screenSize.height * 0.6);
const horizontalDistance = Math.floor(screenSize.width * 0.6);
const centerX = Math.floor(screenSize.width / 2);
const centerY = Math.floor(screenSize.height / 2);
switch (direction) {
case 'up':
x0 = x1 = centerX;
y0 = centerY + Math.floor(verticalDistance / 2);
y1 = centerY - Math.floor(verticalDistance / 2);
break;
case 'down':
x0 = x1 = centerX;
y0 = centerY - Math.floor(verticalDistance / 2);
y1 = centerY + Math.floor(verticalDistance / 2);
break;
case 'left':
y0 = y1 = centerY;
x0 = centerX + Math.floor(horizontalDistance / 2);
x1 = centerX - Math.floor(horizontalDistance / 2);
break;
case 'right':
y0 = y1 = centerY;
x0 = centerX - Math.floor(horizontalDistance / 2);
x1 = centerX + Math.floor(horizontalDistance / 2);
break;
default:
throw new ActionableError(
`Swipe direction "${direction}" is not supported`,
);
}
switch (direction) {
case "up":
x0 = x1 = centerX;
y0 = centerY + Math.floor(verticalDistance / 2);
y1 = centerY - Math.floor(verticalDistance / 2);
break;
case "down":
x0 = x1 = centerX;
y0 = centerY - Math.floor(verticalDistance / 2);
y1 = centerY + Math.floor(verticalDistance / 2);
break;
case "left":
y0 = y1 = centerY;
x0 = centerX + Math.floor(horizontalDistance / 2);
x1 = centerX - Math.floor(horizontalDistance / 2);
break;
case "right":
y0 = y1 = centerY;
x0 = centerX - Math.floor(horizontalDistance / 2);
x1 = centerX + Math.floor(horizontalDistance / 2);
break;
default:
throw new ActionableError(`Swipe direction "${direction}" is not supported`);
}
const url = `${sessionUrl}/actions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: x0, y: y0 },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: 1000, x: x1, y: y1 },
{ type: 'pointerUp', button: 0 },
],
},
],
}),
});
const url = `${sessionUrl}/actions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
{ type: "pointerDown", button: 0 },
{ type: "pointerMove", duration: 1000, x: x1, y: y1 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(
`WebDriver actions request failed: ${response.status} ${errorText}`,
);
}
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`);
}
// Clear actions to ensure they complete
await fetch(`${sessionUrl}/actions`, {
method: 'DELETE',
});
});
}
// Clear actions to ensure they complete
await fetch(`${sessionUrl}/actions`, {
method: "DELETE",
});
});
}
public async swipeFromCoordinate(
x: number,
y: number,
direction: SwipeDirection,
distance: number = 400,
): Promise<void> {
await this.withinSession(async (sessionUrl) => {
// Use simple coordinates like the working swipe method
const x0 = x;
const y0 = y;
let x1 = x;
let y1 = y;
public async swipeFromCoordinate(x: number, y: number, direction: SwipeDirection, distance: number = 400): Promise<void> {
await this.withinSession(async sessionUrl => {
// Use simple coordinates like the working swipe method
const x0 = x;
const y0 = y;
let x1 = x;
let y1 = y;
// Calculate target position based on direction and distance
switch (direction) {
case 'up':
y1 = y - distance; // Move up by specified distance
break;
case 'down':
y1 = y + distance; // Move down by specified distance
break;
case 'left':
x1 = x - distance; // Move left by specified distance
break;
case 'right':
x1 = x + distance; // Move right by specified distance
break;
default:
throw new ActionableError(
`Swipe direction "${direction}" is not supported`,
);
}
// Calculate target position based on direction and distance
switch (direction) {
case "up":
y1 = y - distance; // Move up by specified distance
break;
case "down":
y1 = y + distance; // Move down by specified distance
break;
case "left":
x1 = x - distance; // Move left by specified distance
break;
case "right":
x1 = x + distance; // Move right by specified distance
break;
default:
throw new ActionableError(`Swipe direction "${direction}" is not supported`);
}
const url = `${sessionUrl}/actions`;
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
actions: [
{
type: 'pointer',
id: 'finger1',
parameters: { pointerType: 'touch' },
actions: [
{ type: 'pointerMove', duration: 0, x: x0, y: y0 },
{ type: 'pointerDown', button: 0 },
{ type: 'pointerMove', duration: 1000, x: x1, y: y1 },
{ type: 'pointerUp', button: 0 },
],
},
],
}),
});
const url = `${sessionUrl}/actions`;
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
actions: [
{
type: "pointer",
id: "finger1",
parameters: { pointerType: "touch" },
actions: [
{ type: "pointerMove", duration: 0, x: x0, y: y0 },
{ type: "pointerDown", button: 0 },
{ type: "pointerMove", duration: 1000, x: x1, y: y1 },
{ type: "pointerUp", button: 0 }
]
}
]
}),
});
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(
`WebDriver actions request failed: ${response.status} ${errorText}`,
);
}
if (!response.ok) {
const errorText = await response.text();
throw new ActionableError(`WebDriver actions request failed: ${response.status} ${errorText}`);
}
// Clear actions to ensure they complete
await fetch(`${sessionUrl}/actions`, {
method: 'DELETE',
});
});
}
// Clear actions to ensure they complete
await fetch(`${sessionUrl}/actions`, {
method: "DELETE",
});
});
}
public async setOrientation(orientation: Orientation): Promise<void> {
await this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/orientation`;
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
orientation: orientation.toUpperCase(),
}),
});
});
}
public async setOrientation(orientation: Orientation): Promise<void> {
await this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/orientation`;
await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
orientation: orientation.toUpperCase()
})
});
});
}
public async getOrientation(): Promise<Orientation> {
return this.withinSession(async sessionUrl => {
const url = `${sessionUrl}/orientation`;
const response = await fetch(url);
const json = await response.json();
return json.value.toLowerCase() as Orientation;
});
}
public async getOrientation(): Promise<Orientation> {
return this.withinSession(async (sessionUrl) => {
const url = `${sessionUrl}/orientation`;
const response = await fetch(url);
const json = await response.json();
return json.value.toLowerCase() as Orientation;
});
}
}

View file

@ -1,139 +1,159 @@
import { test, expect } from "@playwright/test";
import { test, expect } from '@playwright/test';
import { PNG } from "../src/png";
import { AndroidRobot, AndroidDeviceManager } from "../src/android";
import { PNG } from '../src/png';
import { AndroidRobot, AndroidDeviceManager } from '../src/android';
const manager = new AndroidDeviceManager();
const devices = manager.getConnectedDevices();
const hasOneAndroidDevice = devices.length === 1;
test.describe("android", () => {
test.describe('android', () => {
const android = new AndroidRobot(devices?.[0]?.deviceId || '');
const android = new AndroidRobot(devices?.[0]?.deviceId || "");
test('should be able to get the screen size', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
const screenSize = await android.getScreenSize();
expect(screenSize.width).toBeGreaterThan(1024);
expect(screenSize.height).toBeGreaterThan(1024);
expect(screenSize.scale).toBe(1);
expect(
Object.keys(screenSize).length,
'screenSize should have exactly 3 properties',
).toBe(3);
});
test("should be able to get the screen size", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
const screenSize = await android.getScreenSize();
expect(screenSize.width).toBeGreaterThan(1024);
expect(screenSize.height).toBeGreaterThan(1024);
expect(screenSize.scale).toBe(1);
expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3);
});
test('should be able to take screenshot', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
test("should be able to take screenshot", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
const screenSize = await android.getScreenSize();
const screenshot = await android.getScreenshot();
expect(screenshot.length).toBeGreaterThan(64 * 1024);
const screenSize = await android.getScreenSize();
const screenshot = await android.getScreenshot();
expect(screenshot.length).toBeGreaterThan(64 * 1024);
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
expect(pngSize.width).toBe(screenSize.width);
expect(pngSize.height).toBe(screenSize.height);
});
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
expect(pngSize.width).toBe(screenSize.width);
expect(pngSize.height).toBe(screenSize.height);
});
test('should be able to list apps', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
const apps = await android.listApps();
const packages = apps.map((app) => app.packageName);
expect(packages).toContain('com.android.settings');
});
test("should be able to list apps", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
const apps = await android.listApps();
const packages = apps.map(app => app.packageName);
expect(packages).toContain("com.android.settings");
});
test('should be able to open a url', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
await android.adb('shell', 'input', 'keyevent', 'HOME');
await android.openUrl('https://www.example.com');
});
test("should be able to open a url", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
await android.adb("shell", "input", "keyevent", "HOME");
await android.openUrl("https://www.example.com");
});
test('should be able to list elements on screen', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
await android.terminateApp('com.android.chrome');
await android.adb('shell', 'input', 'keyevent', 'HOME');
await android.openUrl('https://www.example.com');
const elements = await android.getElementsOnScreen();
test("should be able to list elements on screen", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
await android.terminateApp("com.android.chrome");
await android.adb("shell", "input", "keyevent", "HOME");
await android.openUrl("https://www.example.com");
const elements = await android.getElementsOnScreen();
// make sure title (TextView) is present
const foundTitle = elements.find(
(element) =>
element.type === 'android.widget.TextView' &&
element.text?.startsWith(
'This domain is for use in illustrative examples in documents',
),
);
expect(foundTitle, 'Title element not found').toBeTruthy();
// make sure title (TextView) is present
const foundTitle = elements.find(element => element.type === "android.widget.TextView" && element.text?.startsWith("This domain is for use in illustrative examples in documents"));
expect(foundTitle, "Title element not found").toBeTruthy();
// make sure navbar (EditText) is present
const foundNavbar = elements.find(
(element) =>
element.type === 'android.widget.EditText' &&
element.label === 'Search or type URL' &&
element.text === 'example.com',
);
expect(foundNavbar, 'Navbar element not found').toBeTruthy();
// make sure navbar (EditText) is present
const foundNavbar = elements.find(element => element.type === "android.widget.EditText" && element.label === "Search or type URL" && element.text === "example.com");
expect(foundNavbar, "Navbar element not found").toBeTruthy();
// this is an icon, but has accessibility label
const foundSecureIcon = elements.find(
(element) =>
element.type === 'android.widget.ImageButton' &&
element.text === '' &&
element.label === 'New tab',
);
expect(foundSecureIcon, 'New tab icon not found').toBeTruthy();
});
// this is an icon, but has accessibility label
const foundSecureIcon = elements.find(element => element.type === "android.widget.ImageButton" && element.text === "" && element.label === "New tab");
expect(foundSecureIcon, "New tab icon not found").toBeTruthy();
});
test('should be able to send keys and tap', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
await android.terminateApp('com.google.android.deskclock');
await android.adb('shell', 'pm', 'clear', 'com.google.android.deskclock');
await android.launchApp('com.google.android.deskclock');
test("should be able to send keys and tap", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
await android.terminateApp("com.google.android.deskclock");
await android.adb("shell", "pm", "clear", "com.google.android.deskclock");
await android.launchApp("com.google.android.deskclock");
// We probably start at Clock tab
await new Promise((resolve) => setTimeout(resolve, 3000));
let elements = await android.getElementsOnScreen();
const timerElement = elements.find(
(e) => e.label === 'Timer' && e.type === 'android.widget.FrameLayout',
);
expect(timerElement).toBeDefined();
await android.tap(timerElement.rect.x, timerElement.rect.y);
// We probably start at Clock tab
await new Promise(resolve => setTimeout(resolve, 3000));
let elements = await android.getElementsOnScreen();
const timerElement = elements.find(e => e.label === "Timer" && e.type === "android.widget.FrameLayout");
expect(timerElement).toBeDefined();
await android.tap(timerElement.rect.x, timerElement.rect.y);
// now we're in Timer tab
await new Promise((resolve) => setTimeout(resolve, 3000));
elements = await android.getElementsOnScreen();
const currentTime = elements.find((e) => e.text === '00h 00m 00s');
expect(currentTime, 'Expected time to be 00h 00m 00s').toBeDefined();
await android.sendKeys('123456');
// now we're in Timer tab
await new Promise(resolve => setTimeout(resolve, 3000));
elements = await android.getElementsOnScreen();
const currentTime = elements.find(e => e.text === "00h 00m 00s");
expect(currentTime, "Expected time to be 00h 00m 00s").toBeDefined();
await android.sendKeys("123456");
// now the title has changed with new timer
await new Promise((resolve) => setTimeout(resolve, 3000));
elements = await android.getElementsOnScreen();
const newTime = elements.find((e) => e.text === '12h 34m 56s');
expect(newTime, 'Expected time to be 12h 34m 56s').toBeDefined();
// now the title has changed with new timer
await new Promise(resolve => setTimeout(resolve, 3000));
elements = await android.getElementsOnScreen();
const newTime = elements.find(e => e.text === "12h 34m 56s");
expect(newTime, "Expected time to be 12h 34m 56s").toBeDefined();
await android.terminateApp('com.google.android.deskclock');
});
await android.terminateApp("com.google.android.deskclock");
});
test('should be able to launch and terminate an app', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
test("should be able to launch and terminate an app", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
// kill if running
await android.terminateApp('com.android.chrome');
// kill if running
await android.terminateApp("com.android.chrome");
await android.launchApp('com.android.chrome');
await new Promise((resolve) => setTimeout(resolve, 3000));
const processes = await android.listRunningProcesses();
expect(processes).toContain('com.android.chrome');
await android.launchApp("com.android.chrome");
await new Promise(resolve => setTimeout(resolve, 3000));
const processes = await android.listRunningProcesses();
expect(processes).toContain("com.android.chrome");
await android.terminateApp('com.android.chrome');
const processes2 = await android.listRunningProcesses();
expect(processes2).not.toContain('com.android.chrome');
});
await android.terminateApp("com.android.chrome");
const processes2 = await android.listRunningProcesses();
expect(processes2).not.toContain("com.android.chrome");
});
test('should handle orientation changes', async () => {
test.skip(!hasOneAndroidDevice, 'requires exactly one android device');
test("should handle orientation changes", async () => {
test.skip(!hasOneAndroidDevice, "requires exactly one android device");
// assume we start in portrait
const originalOrientation = await android.getOrientation();
expect(originalOrientation).toBe('portrait');
const screenSize1 = await android.getScreenSize();
// assume we start in portrait
const originalOrientation = await android.getOrientation();
expect(originalOrientation).toBe("portrait");
const screenSize1 = await android.getScreenSize();
// set to landscape
await android.setOrientation('landscape');
await new Promise((resolve) => setTimeout(resolve, 1500));
const orientation = await android.getOrientation();
expect(orientation).toBe('landscape');
const screenSize2 = await android.getScreenSize();
// set to landscape
await android.setOrientation("landscape");
await new Promise(resolve => setTimeout(resolve, 1500));
const orientation = await android.getOrientation();
expect(orientation).toBe("landscape");
const screenSize2 = await android.getScreenSize();
// set to portrait
await android.setOrientation('portrait');
await new Promise((resolve) => setTimeout(resolve, 1500));
const orientation2 = await android.getOrientation();
expect(orientation2).toBe('portrait');
// set to portrait
await android.setOrientation("portrait");
await new Promise(resolve => setTimeout(resolve, 1500));
const orientation2 = await android.getOrientation();
expect(orientation2).toBe("portrait");
// screen size should not have changed
expect(screenSize1).toEqual(screenSize2);
});
// screen size should not have changed
expect(screenSize1).toEqual(screenSize2);
});
});

View file

@ -1,33 +1,34 @@
import { test, expect } from "@playwright/test";
import { test, expect } from '@playwright/test';
import { IosManager, IosRobot } from "../src/ios";
import { PNG } from "../src/png";
import { IosManager, IosRobot } from '../src/ios';
import { PNG } from '../src/png';
test.describe("ios", () => {
test.describe('ios', () => {
let robot: IosRobot;
let hasOneDevice = false;
let robot: IosRobot;
let hasOneDevice = false;
test.beforeAll(async () => {
const manager = new IosManager();
const devices = await manager.listDevices();
hasOneDevice = devices.length === 1;
robot = new IosRobot(devices?.[0]?.deviceId || '');
});
test.beforeAll(async () => {
const manager = new IosManager();
const devices = await manager.listDevices();
hasOneDevice = devices.length === 1;
robot = new IosRobot(devices?.[0]?.deviceId || "");
});
test('should be able to get screenshot', async () => {
test.skip(!hasOneDevice, 'requires exactly one ios device');
const screenshot = await robot.getScreenshot();
// an black screenshot (screen is off) still consumes over 30KB
expect(screenshot.length).toBeGreaterThan(128 * 1024);
test("should be able to get screenshot", async () => {
test.skip(!hasOneDevice, "requires exactly one ios device");
const screenshot = await robot.getScreenshot();
// an black screenshot (screen is off) still consumes over 30KB
expect(screenshot.length).toBeGreaterThan(128 * 1024);
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
const screenSize = await robot.getScreenSize();
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
const screenSize = await robot.getScreenSize();
// wda returns screen size as points, round up
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height);
});
// wda returns screen size as points, round up
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(
screenSize.height,
);
});
});

View file

@ -1,167 +1,188 @@
import { test, expect } from "@playwright/test";
import { randomBytes } from "node:crypto";
import { test, expect } from '@playwright/test';
import { randomBytes } from 'node:crypto';
import { PNG } from "../src/png";
import { MobileDevice } from "../src/mobile-device";
import { Mobilecli } from "../src/mobilecli";
import { PNG } from '../src/png';
import { MobileDevice } from '../src/mobile-device';
import { Mobilecli } from '../src/mobilecli';
test.describe("iphone-simulator", () => {
test.describe('iphone-simulator', () => {
const mobilecli = new Mobilecli();
const devicesResponse = mobilecli.getDevices({
platform: 'ios',
type: 'simulator',
includeOffline: false,
});
const mobilecli = new Mobilecli();
const devicesResponse = mobilecli.getDevices({
platform: "ios",
type: "simulator",
includeOffline: false,
});
const bootedSimulators = devicesResponse.data.devices;
const hasOneSimulator = bootedSimulators.length >= 1;
const device = new MobileDevice(bootedSimulators?.[0]?.id || '');
const bootedSimulators = devicesResponse.data.devices;
const hasOneSimulator = bootedSimulators.length >= 1;
const device = new MobileDevice(bootedSimulators?.[0]?.id || "");
const restartApp = async (app: string) => {
await device.launchApp(app);
await device.terminateApp(app);
await device.launchApp(app);
};
const restartApp = async (app: string) => {
await device.launchApp(app);
await device.terminateApp(app);
await device.launchApp(app);
};
const restartPreferencesApp = async () => {
await restartApp('com.apple.Preferences');
};
const restartPreferencesApp = async () => {
await restartApp("com.apple.Preferences");
};
const restartRemindersApp = async () => {
await restartApp('com.apple.reminders');
};
const restartRemindersApp = async () => {
await restartApp("com.apple.reminders");
};
test('should be able to swipe', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
await restartPreferencesApp();
test("should be able to swipe", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await restartPreferencesApp();
// make sure "General" is present (since it's at the top of the list)
const elements1 = await device.getElementsOnScreen();
expect(
elements1.findIndex((e) => e.name === 'com.apple.settings.general'),
).not.toBe(-1);
// make sure "General" is present (since it's at the top of the list)
const elements1 = await device.getElementsOnScreen();
expect(elements1.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1);
// swipe up (bottom of screen to top of screen)
await device.swipe('up');
// swipe up (bottom of screen to top of screen)
await device.swipe("up");
// make sure "General" is not visible now
const elements2 = await device.getElementsOnScreen();
expect(
elements2.findIndex((e) => e.name === 'com.apple.settings.general'),
).toBe(-1);
// make sure "General" is not visible now
const elements2 = await device.getElementsOnScreen();
expect(elements2.findIndex(e => e.name === "com.apple.settings.general")).toBe(-1);
// swipe down
await device.swipe('down');
// swipe down
await device.swipe("down");
// make sure "General" is visible again
const elements3 = await device.getElementsOnScreen();
expect(
elements3.findIndex((e) => e.name === 'com.apple.settings.general'),
).not.toBe(-1);
});
// make sure "General" is visible again
const elements3 = await device.getElementsOnScreen();
expect(elements3.findIndex(e => e.name === "com.apple.settings.general")).not.toBe(-1);
});
test('should be able to send keys and press enter', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
await restartRemindersApp();
test("should be able to send keys and press enter", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await restartRemindersApp();
// find new reminder element
await new Promise((resolve) => setTimeout(resolve, 3000));
const elements = await device.getElementsOnScreen();
const newElement = elements.find((e) => e.label === 'New Reminder');
expect(newElement, 'should have found New Reminder element').toBeDefined();
// find new reminder element
await new Promise(resolve => setTimeout(resolve, 3000));
const elements = await device.getElementsOnScreen();
const newElement = elements.find(e => e.label === "New Reminder");
expect(newElement, "should have found New Reminder element").toBeDefined();
// click on new reminder
await device.tap(newElement.rect.x, newElement.rect.y);
// click on new reminder
await device.tap(newElement.rect.x, newElement.rect.y);
// wait for keyboard to appear
await new Promise((resolve) => setTimeout(resolve, 1000));
// wait for keyboard to appear
await new Promise(resolve => setTimeout(resolve, 1000));
// send keys with press button "Enter"
const random1 = randomBytes(8).toString('hex');
await device.sendKeys(random1);
await device.pressButton('ENTER');
// send keys with press button "Enter"
const random1 = randomBytes(8).toString("hex");
await device.sendKeys(random1);
await device.pressButton("ENTER");
// send keys with "\n"
const random2 = randomBytes(8).toString('hex');
await device.sendKeys(random2 + '\n');
// send keys with "\n"
const random2 = randomBytes(8).toString("hex");
await device.sendKeys(random2 + "\n");
const elements2 = await device.getElementsOnScreen();
expect(elements2.findIndex((e) => e.value === random1)).not.toBe(-1);
expect(elements2.findIndex((e) => e.value === random2)).not.toBe(-1);
});
const elements2 = await device.getElementsOnScreen();
expect(elements2.findIndex(e => e.value === random1)).not.toBe(-1);
expect(elements2.findIndex(e => e.value === random2)).not.toBe(-1);
});
test('should be able to get the screen size', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
const screenSize = await device.getScreenSize();
expect(screenSize.width).toBeGreaterThan(256);
expect(screenSize.height).toBeGreaterThan(256);
expect(screenSize.scale).toBeGreaterThanOrEqual(1);
expect(
Object.keys(screenSize).length,
'screenSize should have exactly 3 properties',
).toBe(3);
});
test("should be able to get the screen size", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
const screenSize = await device.getScreenSize();
expect(screenSize.width).toBeGreaterThan(256);
expect(screenSize.height).toBeGreaterThan(256);
expect(screenSize.scale).toBeGreaterThanOrEqual(1);
expect(Object.keys(screenSize).length, "screenSize should have exactly 3 properties").toBe(3);
});
test('should be able to get screenshot', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
const screenshot = await device.getScreenshot();
expect(screenshot.length).toBeGreaterThan(64 * 1024);
test("should be able to get screenshot", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
const screenshot = await device.getScreenshot();
expect(screenshot.length).toBeGreaterThan(64 * 1024);
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
const screenSize = await device.getScreenSize();
// must be a valid png image that matches the screen size
const image = new PNG(screenshot);
const pngSize = image.getDimensions();
const screenSize = await device.getScreenSize();
// wda returns screen size as points, round up
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(
screenSize.height,
);
});
// wda returns screen size as points, round up
expect(Math.ceil(pngSize.width / screenSize.scale)).toBe(screenSize.width);
expect(Math.ceil(pngSize.height / screenSize.scale)).toBe(screenSize.height);
});
test('should be able to open url', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
// simply checking thato openurl with https:// launches safari
await device.openUrl('https://www.example.com');
await new Promise((resolve) => setTimeout(resolve, 1000));
test("should be able to open url", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
// simply checking thato openurl with https:// launches safari
await device.openUrl("https://www.example.com");
await new Promise(resolve => setTimeout(resolve, 1000));
const elements = await device.getElementsOnScreen();
expect(elements.length).toBeGreaterThan(0);
const elements = await device.getElementsOnScreen();
expect(elements.length).toBeGreaterThan(0);
const addressBar = elements.find(
(element) =>
element.type === 'TextField' &&
element.name === 'TabBarItemTitle' &&
element.label === 'Address',
);
expect(addressBar, 'should have address bar').toBeDefined();
});
const addressBar = elements.find(element => element.type === "TextField" && element.name === "TabBarItemTitle" && element.label === "Address");
expect(addressBar, "should have address bar").toBeDefined();
});
test('should be able to list apps', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
const apps = await device.listApps();
const packages = apps.map((app) => app.packageName);
expect(packages).toContain('com.apple.mobilesafari');
expect(packages).toContain('com.apple.reminders');
expect(packages).toContain('com.apple.Preferences');
});
test("should be able to list apps", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
const apps = await device.listApps();
const packages = apps.map(app => app.packageName);
expect(packages).toContain("com.apple.mobilesafari");
expect(packages).toContain("com.apple.reminders");
expect(packages).toContain("com.apple.Preferences");
});
test('should be able to get elements on screen', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
await device.pressButton('HOME');
await new Promise((resolve) => setTimeout(resolve, 2000));
test("should be able to get elements on screen", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await device.pressButton("HOME");
await new Promise(resolve => setTimeout(resolve, 2000));
const elements = await device.getElementsOnScreen();
expect(elements.length).toBeGreaterThan(0);
const elements = await device.getElementsOnScreen();
expect(elements.length).toBeGreaterThan(0);
// must have News app in home screen
const element = elements.find(
(e) => e.type === 'Icon' && e.label === 'News',
);
expect(element, 'should have News app in home screen').toBeDefined();
});
// must have News app in home screen
const element = elements.find(e => e.type === "Icon" && e.label === "News");
expect(element, "should have News app in home screen").toBeDefined();
});
test('should be able to launch and terminate app', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
await restartPreferencesApp();
await new Promise((resolve) => setTimeout(resolve, 2000));
const elements = await device.getElementsOnScreen();
test("should be able to launch and terminate app", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await restartPreferencesApp();
await new Promise(resolve => setTimeout(resolve, 2000));
const elements = await device.getElementsOnScreen();
const buttons = elements
.filter((e) => e.type === 'Button')
.map((e) => e.label);
expect(buttons).toContain('General');
expect(buttons).toContain('Accessibility');
const buttons = elements.filter(e => e.type === "Button").map(e => e.label);
expect(buttons).toContain("General");
expect(buttons).toContain("Accessibility");
// make sure app is terminated
await device.terminateApp('com.apple.Preferences');
const elements2 = await device.getElementsOnScreen();
const buttons2 = elements2
.filter((e) => e.type === 'Button')
.map((e) => e.label);
expect(buttons2).not.toContain('General');
});
// make sure app is terminated
await device.terminateApp("com.apple.Preferences");
const elements2 = await device.getElementsOnScreen();
const buttons2 = elements2.filter(e => e.type === "Button").map(e => e.label);
expect(buttons2).not.toContain("General");
});
/*
/*
test("should be able to get and set orientation", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
@ -182,8 +203,10 @@ test.describe("iphone-simulator", () => {
});
*/
test("should throw an error if button is not supported", async () => {
test.skip(!hasOneSimulator, "requires a booted ios simulator");
await expect(device.pressButton("NOT_A_BUTTON" as any)).rejects.toThrow("unsupported button: NOT_A_BUTTON");
});
test('should throw an error if button is not supported', async () => {
test.skip(!hasOneSimulator, 'requires a booted ios simulator');
await expect(device.pressButton('NOT_A_BUTTON' as any)).rejects.toThrow(
'unsupported button: NOT_A_BUTTON',
);
});
});

View file

@ -1,119 +1,136 @@
import { test, expect } from "@playwright/test";
import { Mobilecli } from "../src/mobilecli";
import { test, expect } from '@playwright/test';
import { Mobilecli } from '../src/mobilecli';
type ExecuteCommandCall = {
args: string[];
args: string[];
};
function createMockMobilecli(mockResponse: string): { mobilecli: Mobilecli; calls: ExecuteCommandCall[] } {
const mobilecli = new Mobilecli();
const calls: ExecuteCommandCall[] = [];
function createMockMobilecli(mockResponse: string): {
mobilecli: Mobilecli;
calls: ExecuteCommandCall[];
} {
const mobilecli = new Mobilecli();
const calls: ExecuteCommandCall[] = [];
mobilecli.executeCommand = function(args: string[]): string {
calls.push({ args });
return mockResponse;
};
mobilecli.executeCommand = function (args: string[]): string {
calls.push({ args });
return mockResponse;
};
return { mobilecli, calls };
return { mobilecli, calls };
}
test.describe("mobilecli", () => {
test.describe('mobilecli', () => {
const mobilecli = new Mobilecli();
const mobilecli = new Mobilecli();
test.describe('getVersion', () => {
test('should return a version string', () => {
const version = mobilecli.getVersion();
expect(version.length).toBeGreaterThan(0);
expect(version).not.toContain('failed');
});
test.describe("getVersion", () => {
test("should return a version string", () => {
const version = mobilecli.getVersion();
expect(version.length).toBeGreaterThan(0);
expect(version).not.toContain("failed");
});
test('should return version in correct format', () => {
const version = mobilecli.getVersion();
// Version should be in format like "0.0.45" or similar
const versionPattern = /^\d+\.\d+\.\d+/;
expect(
version,
`Version "${version}" should match pattern X.Y.Z`,
).toMatch(versionPattern);
});
test("should return version in correct format", () => {
const version = mobilecli.getVersion();
// Version should be in format like "0.0.45" or similar
const versionPattern = /^\d+\.\d+\.\d+/;
expect(version, `Version "${version}" should match pattern X.Y.Z`).toMatch(versionPattern);
});
test('should return failed when MOBILECLI_PATH points to invalid location', () => {
try {
process.env.MOBILECLI_PATH = '/tmp';
const mobilecli = new Mobilecli();
const version = mobilecli.getVersion();
expect(
version,
`Expected version to include "failed" but got: ${version}`,
).toContain('failed');
} finally {
delete process.env.MOBILECLI_PATH;
}
});
test("should return failed when MOBILECLI_PATH points to invalid location", () => {
try {
process.env.MOBILECLI_PATH = "/tmp";
const mobilecli = new Mobilecli();
const version = mobilecli.getVersion();
expect(version, `Expected version to include "failed" but got: ${version}`).toContain("failed");
} finally {
delete process.env.MOBILECLI_PATH;
}
});
test('should call executeCommand with --version argument', () => {
const { mobilecli, calls } = createMockMobilecli(
'mobilecli version 1.0.0',
);
const version = mobilecli.getVersion();
test("should call executeCommand with --version argument", () => {
const { mobilecli, calls } = createMockMobilecli("mobilecli version 1.0.0");
const version = mobilecli.getVersion();
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(['--version']);
expect(version).toBe('1.0.0');
});
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["--version"]);
expect(version).toBe("1.0.0");
});
});
test.describe('getDevices', () => {
const mockDevicesResponse = JSON.stringify({
status: 'ok',
data: {
devices: [
{
id: 'device1',
name: 'Test Device',
platform: 'ios',
type: 'simulator',
version: '17.0',
},
],
},
});
test.describe("getDevices", () => {
const mockDevicesResponse = JSON.stringify({
status: "ok",
data: {
devices: [
{
id: "device1",
name: "Test Device",
platform: "ios",
type: "simulator",
version: "17.0"
}
]
}
});
test('should call executeCommand with devices argument when no options', () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices();
test("should call executeCommand with devices argument when no options", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices();
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(['devices']);
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices"]);
});
test('should call executeCommand with platform filter', () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ platform: 'ios' });
test("should call executeCommand with platform filter", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ platform: "ios" });
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(['devices', '--platform', 'ios']);
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--platform", "ios"]);
});
test('should call executeCommand with type filter', () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ type: 'simulator' });
test("should call executeCommand with type filter", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ type: "simulator" });
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(['devices', '--type', 'simulator']);
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--type", "simulator"]);
});
test('should call executeCommand with includeOffline flag', () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ includeOffline: true });
test("should call executeCommand with includeOffline flag", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({ includeOffline: true });
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(['devices', '--include-offline']);
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--include-offline"]);
});
test('should call executeCommand with combined options', () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({
platform: 'android',
type: 'emulator',
includeOffline: true,
});
test("should call executeCommand with combined options", () => {
const { mobilecli, calls } = createMockMobilecli(mockDevicesResponse);
mobilecli.getDevices({
platform: "android",
type: "emulator",
includeOffline: true
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual(["devices", "--include-offline", "--platform", "android", "--type", "emulator"]);
});
});
expect(calls.length).toBe(1);
expect(calls[0].args).toEqual([
'devices',
'--include-offline',
'--platform',
'android',
'--type',
'emulator',
]);
});
});
});

View file

@ -1,18 +1,18 @@
import { test, expect } from "@playwright/test";
import { PNG } from "../src/png";
import { test, expect } from '@playwright/test';
import { PNG } from '../src/png';
test.describe('png', () => {
test('should be able to parse png', () => {
const buffer =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=';
const png = new PNG(Buffer.from(buffer, 'base64'));
expect(png.getDimensions().width).toBe(1);
expect(png.getDimensions().height).toBe(1);
});
test.describe("png", () => {
test("should be able to parse png", () => {
const buffer = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGNgYAAAAAMAAWgmWQ0AAAAASUVORK5CYII=";
const png = new PNG(Buffer.from(buffer, "base64"));
expect(png.getDimensions().width).toBe(1);
expect(png.getDimensions().height).toBe(1);
});
test("should be able to detect an invalid png", () => {
const buffer = btoa("IAMADUCKIAMADUCKIAMADUCKIAMADUCKIAMADUCK");
const png = new PNG(Buffer.from(buffer, "base64"));
expect(() => png.getDimensions()).toThrow();
});
test('should be able to detect an invalid png', () => {
const buffer = btoa('IAMADUCKIAMADUCKIAMADUCKIAMADUCKIAMADUCK');
const png = new PNG(Buffer.from(buffer, 'base64'));
expect(() => png.getDimensions()).toThrow();
});
});

View file

@ -8,7 +8,5 @@
"module": "CommonJS",
"outDir": "./lib"
},
"include": [
"src",
],
}
"include": ["src"]
}

View file

@ -362,12 +362,7 @@
"default": 0
},
"compactMode": {
"description": "Hide tool output and thinking for a cleaner view (toggle with Ctrl+O).",
"type": "boolean",
"default": false
},
"compactInline": {
"description": "Compact tool display within each group instead of merging across groups. Requires compactMode to be enabled.",
"description": "Compact view (web shell only; not used by the TUI).",
"type": "boolean",
"default": false
},
@ -2822,7 +2817,7 @@
"default": false
},
"emitToolUseSummaries": {
"description": "Generate a short LLM-based label after each tool batch completes. In compact mode the label replaces the generic `Tool × N` header; in full mode it appears as a dim `● <label>` line below the tool group. Requires a fast model to be configured; runs in parallel with the next API call so latency is hidden. Currently affects interactive CLI rendering only — SDK / non-interactive emission of the `tool_use_summary` message is not yet wired (the message factory is exported for a follow-up PR). Can be overridden with QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0 or =1.",
"description": "Generate a short LLM-based label after each tool batch completes. For a completed tool group the label replaces the generic `Tool × N` header; when the group is force-expanded it appears as a dim `● <label>` line below the tool group. Requires a fast model to be configured; runs in parallel with the next API call so latency is hidden. Currently affects interactive CLI rendering only — SDK / non-interactive emission of the `tool_use_summary` message is not yet wired (the message factory is exported for a follow-up PR). Can be overridden with QWEN_CODE_EMIT_TOOL_USE_SUMMARIES=0 or =1.",
"type": "boolean",
"default": true
}

View file

@ -151,7 +151,11 @@ describe('deriveSessionCards', () => {
];
const status = [statusSession('s-appr', { pendingPermissionCount: 1 })];
const cards = deriveSessionCards(sessions, status, 's-run');
expect(cards.map((c) => c.sessionId)).toEqual(['s-appr', 's-run', 's-idle']);
expect(cards.map((c) => c.sessionId)).toEqual([
's-appr',
's-run',
's-idle',
]);
expect(cards.map((c) => c.status)).toEqual([
'needsApproval',
'running',
@ -214,7 +218,9 @@ describe('SessionOverviewPanel', () => {
session('s-appr', { displayName: 'Charlie' }),
];
statusState.report = {
full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] },
full: {
sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })],
},
};
render();
expect(cardLabels()).toEqual(['Charlie', 'Alpha', 'Bravo']);
@ -243,7 +249,9 @@ describe('SessionOverviewPanel', () => {
session('s-appr', { displayName: 'Charlie' }),
];
statusState.report = {
full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] },
full: {
sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })],
},
};
render();
const selectAll = container!.querySelector(
@ -284,7 +292,9 @@ describe('SessionOverviewPanel', () => {
session('s-appr', { displayName: 'Charlie' }),
];
statusState.report = {
full: { sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })] },
full: {
sessions: [statusSession('s-appr', { pendingPermissionCount: 1 })],
},
};
const onOpenSplit = vi.fn();
render({ onOpenSplit });

View file

@ -143,9 +143,8 @@ function SessionOverviewPanelInner({
const connection = useConnection();
const currentSessionId = connection.sessionId;
const organizationEnabled =
connection.capabilities?.features?.includes(
SESSION_ORGANIZATION_FEATURE,
) ?? false;
connection.capabilities?.features?.includes(SESSION_ORGANIZATION_FEATURE) ??
false;
const { sessions, loading, error, reload } = useSessions({
autoLoad: true,
@ -388,7 +387,9 @@ function SessionOverviewPanelInner({
)}
</div>
<div className={styles.cardMeta}>
<span className={cx(styles.statusBadge, statusClass(card.status))}>
<span
className={cx(styles.statusBadge, statusClass(card.status))}
>
{t(`sessionsOverview.status.${card.status}`)}
</span>
{card.model && (

View file

@ -72,7 +72,9 @@ describe('SvgLineChart', () => {
it('places a single-point series mid-width so its dot still renders', () => {
const el = render(
<SvgLineChart series={[{ label: 'x', values: [42], color: 'var(--a)' }]} />,
<SvgLineChart
series={[{ label: 'x', values: [42], color: 'var(--a)' }]}
/>,
);
// one moveto point, no line segment, but a visible dot
const d = el.querySelector('path')?.getAttribute('d') ?? '';

View file

@ -33,8 +33,10 @@ function installMatchMedia(initial: boolean) {
},
media: '',
onchange: null,
addEventListener: (_type: string, cb: (event: MediaQueryListEvent) => void) =>
listeners.push(cb),
addEventListener: (
_type: string,
cb: (event: MediaQueryListEvent) => void,
) => listeners.push(cb),
removeEventListener: (
_type: string,
cb: (event: MediaQueryListEvent) => void,
@ -45,7 +47,9 @@ function installMatchMedia(initial: boolean) {
removeListener: () => {},
dispatchEvent: () => true,
};
window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
window.matchMedia = vi
.fn()
.mockReturnValue(mql) as unknown as typeof window.matchMedia;
return {
set(next: boolean) {
matches = next;
@ -69,7 +73,9 @@ function render(): void {
}
function value(): string | undefined {
return container?.querySelector('[data-testid="value"]')?.textContent ?? undefined;
return (
container?.querySelector('[data-testid="value"]')?.textContent ?? undefined
);
}
describe('useIsLargeScreen', () => {

View file

@ -27,7 +27,10 @@ export function useIsLargeScreen(query: string = LARGE_SCREEN_QUERY): boolean {
const [isLarge, setIsLarge] = useState<boolean>(() => matchesQuery(query));
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return;
}
const mql = window.matchMedia(query);
@ -43,7 +46,10 @@ export function useIsLargeScreen(query: string = LARGE_SCREEN_QUERY): boolean {
}
function matchesQuery(query: string): boolean {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
if (
typeof window === 'undefined' ||
typeof window.matchMedia !== 'function'
) {
return false;
}
return window.matchMedia(query).matches;

View file

@ -23,7 +23,9 @@ describe('isAskUserPermission', () => {
it('is true when questions are present and no tool name is given', () => {
expect(
isAskUserPermission(req({ rawInput: { questions: [{ question: 'q' }] } })),
isAskUserPermission(
req({ rawInput: { questions: [{ question: 'q' }] } }),
),
).toBe(true);
});

View file

@ -25,17 +25,21 @@ describe('buildSplitUrl', () => {
});
it('resets the path so no single-session deep-link competes', () => {
expect(new URL(buildSplitUrl(['a'], 'https://host/session/x')).pathname).toBe(
'/',
);
expect(
new URL(buildSplitUrl(['a'], 'https://host/session/x')).pathname,
).toBe('/');
});
it('carries the daemon token in the fragment when provided', () => {
const url = new URL(buildSplitUrl(['a', 'b'], 'https://host/', 'secret-tok'));
const url = new URL(
buildSplitUrl(['a', 'b'], 'https://host/', 'secret-tok'),
);
expect(url.searchParams.get('split')).toBe('a,b');
// In the hash, not the query — never sent to the server / logs.
expect(url.search).not.toContain('secret-tok');
expect(new URLSearchParams(url.hash.slice(1)).get('token')).toBe('secret-tok');
expect(new URLSearchParams(url.hash.slice(1)).get('token')).toBe(
'secret-tok',
);
});
it('adds no fragment when no token is given', () => {