Skip to content

feat(transcript): collapse consecutive tool runs into expandable summary cells (#2692)#2740

Closed
idling11 wants to merge 2 commits into
Hmbown:mainfrom
idling11:feat/transcript-tool-collapse
Closed

feat(transcript): collapse consecutive tool runs into expandable summary cells (#2692)#2740
idling11 wants to merge 2 commits into
Hmbown:mainfrom
idling11:feat/transcript-tool-collapse

Conversation

@idling11
Copy link
Copy Markdown
Contributor

@idling11 idling11 commented Jun 4, 2026

Add a transcript feature that groups contiguous successful tool calls
(exceeding a configurable threshold) into a single compact summary row.
Users can click the summary to expand it back to individual cells.

Core changes:

  • history.rs:
    • Add is_success, is_failed, is_running, is_collapsible_guard
      helper methods to ToolCell.
    • Add ToolRun, detect_tool_runs(), is_collapsible_tool(),
      tool_display_name(), tool_run_summary() for run grouping.
    • 6 new unit tests.
  • app.rs:
    • Add tool_collapse_threshold: usize (default 3, 0 disables).
    • Add expanded_tool_runs: HashSet<usize> for click-to-toggle state.
    • Clear expanded runs on mark_history_updated().
  • widgets/mod.rs:
    • Pre-process app.history before rendering: detect tool runs,
      replace collapsed runs with a single "▼ N tools collapsed"
      summary cell, skip collapsed members from the output.
  • mouse_ui.rs:
    • Add toggle_tool_run_expand() — click on a collapsed summary
      toggles expansion; click again to re-collapse.
  • settings.rs:
    • Add tool_collapse_threshold setting (serde default 3).
    • Read setting at App initialization.

Non-goals for this PR (can be follow-ups):

  • Calm-mode-only collapse (requires wiring calm mode into the widget).
  • Right-aligned timestamps and token/context meters.
  • Keyboard shortcut to expand/collapse.

Closes #2692

Testing

  • cargo fmt --all -- --check
  • cargo clippy --workspace --all-targets --all-features
  • cargo test --workspace --all-features

Checklist

  • Updated docs or comments as needed
  • Added or updated tests where relevant
  • Verified TUI behavior manually if UI changes

Greptile Summary

This PR adds a transcript feature that groups consecutive successful tool calls (≥ tool_collapse_threshold, default 3) into a single expandable summary row, with click-to-toggle expansion state stored in expanded_tool_runs.

  • history.rs gains ToolRun, detect_tool_runs, and helpers; app.rs adds tool_collapse_threshold + expanded_tool_runs; widgets/mod.rs synthesises summary cells; mouse_ui.rs wires the click handler.
  • The collapse logic is placed entirely in the "slow path" of ChatWidget::new, which is only entered when app.collapsed_cells is non-empty — meaning in a typical session with no folded thinking cells the feature silently never fires.
  • McpToolCell has a status field but is omitted from ToolCell::status(), so successful MCP calls return is_success() = false, they can never be grouped, and they silently split otherwise-continuous runs.

Confidence Score: 3/5

The core collapse feature does not work in the common case and MCP tool calls are excluded from grouping; both defects need fixes before this can be merged.

The slow path that contains all tool-run collapse logic is only entered when app.collapsed_cells is non-empty — a set that tracks folded thinking cells, not tool runs. In a normal session with no folded thinking cells, detect_tool_runs is never called, no summary cells are ever produced, and the feature silently does nothing. Separately, McpToolCell has a status field identical to Exec/Generic/WebSearch but is omitted from the status() dispatch, so successful MCP calls always return is_success() = false, are excluded from every run, and silently break continuity in mixed-tool sequences.

crates/tui/src/tui/widgets/mod.rs (fast-path bypass) and crates/tui/src/tui/history.rs (Mcp omission from status())

Important Files Changed

Filename Overview
crates/tui/src/tui/widgets/mod.rs Adds tool-run collapse pre-processing to ChatWidget::new, but places all collapse logic inside the slow path (guarded by collapsed_cells being non-empty), so the feature never fires in a typical session with no folded thinking cells.
crates/tui/src/tui/history.rs Adds ToolRun, detect_tool_runs, and helper methods; McpToolCell is inadvertently excluded from status() so successful MCP calls never join a collapsible run.
crates/tui/src/tui/mouse_ui.rs Adds toggle_tool_run_expand click handler; expand/collapse logic is correct and mark_history_updated no longer clears expanded_tool_runs.
crates/tui/src/tui/app.rs Adds tool_collapse_threshold and expanded_tool_runs fields; lifecycle management (clear on history clear, retain on truncate) looks correct.
crates/tui/src/settings.rs Adds tool_collapse_threshold with serde default 3 and matches the Default impl; straightforward and correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant mouse_ui as mouse_ui.rs
    participant App as App (state)
    participant Widget as ChatWidget (render)

    User->>mouse_ui: click transcript row
    mouse_ui->>App: transcript_cell_index_from_mouse()
    App-->>mouse_ui: filtered_idx
    mouse_ui->>App: collapsed_cell_map[filtered_idx]
    App-->>mouse_ui: "original_idx (= run.start)"
    alt Already expanded
        mouse_ui->>App: expanded_tool_runs.remove(original_idx)
    else Collapsed → expand
        mouse_ui->>App: detect_tool_runs(history, threshold)
        App-->>mouse_ui: tool_runs
        mouse_ui->>App: expanded_tool_runs.insert(original_idx)
    end
    mouse_ui->>App: "mark_history_updated() → needs_redraw = true"

    User->>Widget: render frame
    alt collapsed_cells is EMPTY (fast path — common case)
        Widget->>Widget: tool-run collapse logic skipped entirely
        Widget->>App: "collapsed_cell_map = identity mapping"
    else collapsed_cells non-empty (slow path)
        Widget->>App: detect_tool_runs(history, threshold)
        App-->>Widget: tool_runs
        Widget->>Widget: build collapsed_skip set
        Widget->>Widget: emit summary cell OR individual cells
        Widget->>App: "collapsed_cell_map = filtered_to_original"
    end
Loading

Fix All in Codex Fix All in Claude Code Fix All in Cursor

Reviews (2): Last reviewed commit: "fix(transcript): move expanded_tool_runs..." | Re-trigger Greptile

Greptile also left 1 inline comment on this PR.

…ary cells (#2692)

Add a transcript feature that groups contiguous successful tool calls
(exceeding a configurable threshold) into a single compact summary row.
Users can click the summary to expand it back to individual cells.

Core changes:
  - `history.rs`:
    - Add `is_success`, `is_failed`, `is_running`, `is_collapsible_guard`
      helper methods to `ToolCell`.
    - Add `ToolRun`, `detect_tool_runs()`, `is_collapsible_tool()`,
      `tool_display_name()`, `tool_run_summary()` for run grouping.
    - 6 new unit tests.
  - `app.rs`:
    - Add `tool_collapse_threshold: usize` (default 3, 0 disables).
    - Add `expanded_tool_runs: HashSet<usize>` for click-to-toggle state.
    - Clear expanded runs on `mark_history_updated()`.
  - `widgets/mod.rs`:
    - Pre-process `app.history` before rendering: detect tool runs,
      replace collapsed runs with a single `"▼ N tools collapsed"`
      summary cell, skip collapsed members from the output.
  - `mouse_ui.rs`:
    - Add `toggle_tool_run_expand()` — click on a collapsed summary
      toggles expansion; click again to re-collapse.
  - `settings.rs`:
    - Add `tool_collapse_threshold` setting (serde default 3).
    - Read setting at `App` initialization.

Non-goals for this PR (can be follow-ups):
  - Calm-mode-only collapse (requires wiring calm mode into the widget).
  - Right-aligned timestamps and token/context meters.
  - Keyboard shortcut to expand/collapse.

Closes #2692
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 4, 2026

Thanks @idling11 for taking the time to contribute.

This repository is currently observing a maintainer-managed contribution gate in dry-run mode, so this pull request is staying open. When enforcement is enabled, pull requests from contributors who are not listed in .github/APPROVED_CONTRIBUTORS will be closed automatically.

Please read CONTRIBUTING.md for the expected contribution shape. A maintainer can grant PR access by commenting /lgtm on a pull request.

Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a feature to collapse contiguous successful tool-call runs in the TUI transcript history to reduce clutter, allowing users to expand them via mouse clicks. The review feedback identifies a critical bug where clearing expanded runs in mark_history_updated makes the expansion non-functional, missing enum variants in the status helper that prevent certain tools from collapsing, a generic display name for MCP tools, and a potential performance bottleneck in the rendering loop that should be optimized with a pre-built HashMap.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread crates/tui/src/tui/app.rs
Comment on lines 2619 to 2621
pub fn mark_history_updated(&mut self) {
self.expanded_tool_runs.clear();
self.history_version = self.history_version.wrapping_add(1);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Clearing expanded_tool_runs inside mark_history_updated() will immediately collapse any expanded tool runs whenever mark_history_updated() is called. Since toggle_tool_run_expand() calls mark_history_updated() to trigger a redraw, this means expanding a tool run immediately collapses it again on the same interaction, making the feature non-functional. It also collapses expanded runs during streaming.

Recommendation: Remove self.expanded_tool_runs.clear(); from mark_history_updated(), and instead clear it in clear_history() to ensure the expanded state persists across redraws and streaming updates.

Suggested change
pub fn mark_history_updated(&mut self) {
self.expanded_tool_runs.clear();
self.history_version = self.history_version.wrapping_add(1);
pub fn mark_history_updated(&mut self) {
self.history_version = self.history_version.wrapping_add(1);

Comment on lines +700 to +708
fn status(&self) -> Option<ToolStatus> {
match self {
ToolCell::Exec(c) => Some(c.status),
ToolCell::Review(c) => Some(c.status),
ToolCell::WebSearch(c) => Some(c.status),
ToolCell::Generic(c) => Some(c.status),
_ => None,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The status() helper does not handle PlanUpdate, PatchSummary, or Mcp variants, even though they all contain a status: ToolStatus field. Consequently, is_success() will return false for successful MCP tools or plan updates, preventing them from ever being collapsed.

Recommendation: Add matches for PlanUpdate, PatchSummary, and Mcp to the status() helper.

    fn status(&self) -> Option<ToolStatus> {
        match self {
            ToolCell::Exec(c) => Some(c.status),
            ToolCell::Review(c) => Some(c.status),
            ToolCell::WebSearch(c) => Some(c.status),
            ToolCell::Generic(c) => Some(c.status),
            ToolCell::PlanUpdate(c) => Some(c.status),
            ToolCell::PatchSummary(c) => Some(c.status),
            ToolCell::Mcp(c) => Some(c.status),
            _ => None,
        }
    }

Comment on lines +3529 to +3547
fn tool_display_name(tc: &ToolCell) -> String {
match tc {
ToolCell::Exec(c) => c
.command
.split_whitespace()
.next()
.unwrap_or("exec")
.to_string(),
ToolCell::Generic(c) => c.name.clone(),
ToolCell::Exploring(_) => "explore".to_string(),
ToolCell::PlanUpdate(_) => "update_plan".to_string(),
ToolCell::PatchSummary(_) => "apply_patch".to_string(),
ToolCell::Review(_) => "review".to_string(),
ToolCell::DiffPreview(_) => "diff".to_string(),
ToolCell::Mcp(_) => "mcp".to_string(),
ToolCell::ViewImage(_) => "view_image".to_string(),
ToolCell::WebSearch(_) => "web_search".to_string(),
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

tool_display_name returns a generic "mcp" string for any MCP tool cell. Since MCP tools have distinct names (e.g., ggr, fetch, read_file) stored in c.tool, returning "mcp" groups all different MCP tools under a single generic name in the collapsed summary.

Recommendation: Return c.tool.clone() for ToolCell::Mcp(c) to make the collapsed summary much more descriptive.

Suggested change
fn tool_display_name(tc: &ToolCell) -> String {
match tc {
ToolCell::Exec(c) => c
.command
.split_whitespace()
.next()
.unwrap_or("exec")
.to_string(),
ToolCell::Generic(c) => c.name.clone(),
ToolCell::Exploring(_) => "explore".to_string(),
ToolCell::PlanUpdate(_) => "update_plan".to_string(),
ToolCell::PatchSummary(_) => "apply_patch".to_string(),
ToolCell::Review(_) => "review".to_string(),
ToolCell::DiffPreview(_) => "diff".to_string(),
ToolCell::Mcp(_) => "mcp".to_string(),
ToolCell::ViewImage(_) => "view_image".to_string(),
ToolCell::WebSearch(_) => "web_search".to_string(),
}
}
fn tool_display_name(tc: &ToolCell) -> String {
match tc {
ToolCell::Exec(c) => c
.command
.split_whitespace()
.next()
.unwrap_or("exec")
.to_string(),
ToolCell::Generic(c) => c.name.clone(),
ToolCell::Exploring(_) => "explore".to_string(),
ToolCell::PlanUpdate(_) => "update_plan".to_string(),
ToolCell::PatchSummary(_) => "apply_patch".to_string(),
ToolCell::Review(_) => "review".to_string(),
ToolCell::DiffPreview(_) => "diff".to_string(),
ToolCell::Mcp(c) => c.tool.clone(),
ToolCell::ViewImage(_) => "view_image".to_string(),
ToolCell::WebSearch(_) => "web_search".to_string(),
}
}

Comment on lines 194 to +206
for (idx, cell) in app.history.iter().enumerate() {
if app.collapsed_cells.contains(&idx) {
continue;
}
if collapsed_skip.contains(&idx) {
continue;
}
// Replace the first cell of a collapsed tool run with
// a compact summary cell.
if let Some(run) = tool_runs
.iter()
.find(|r| r.start == idx && !app.expanded_tool_runs.contains(&r.start))
{
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Inside the loop over app.history, calling tool_runs.iter().find(...) results in $O(N \times M)$ complexity, where $N$ is the history length and $M$ is the number of tool runs. Since this is on the hot rendering path (which runs on every frame/tick), it can cause scroll lag on long transcripts.

Recommendation: Pre-build a HashMap<usize, &ToolRun> of collapsed tool runs before the loop to achieve $O(1)$ lookup complexity.

            let collapsed_runs: std::collections::HashMap<usize, &crate::tui::history::ToolRun> = tool_runs
                .iter()
                .filter(|r| !app.expanded_tool_runs.contains(&r.start))
                .map(|r| (r.start, r))
                .collect();

            for (idx, cell) in app.history.iter().enumerate() {
                if app.collapsed_cells.contains(&idx) {
                    continue;
                }
                if collapsed_skip.contains(&idx) {
                    continue;
                }
                // Replace the first cell of a collapsed tool run with
                // a compact summary cell.
                if let Some(run) = collapsed_runs.get(&idx)

Comment on lines +731 to +733
app.expanded_tool_runs.insert(original_idx);
app.mark_history_updated();
return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P0 Expand toggle immediately undone by mark_history_updated()

app.expanded_tool_runs.insert(original_idx) is called on line 731, but app.mark_history_updated() on line 732 unconditionally calls self.expanded_tool_runs.clear() (see app.rs line 2620). The insert is wiped out before the widget ever re-renders, so clicking a collapsed run will never expand it — the summary row always stays collapsed. The collapse direction (lines 721-723) is fine because the remove happens before the clear.

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +3486 to +3499
let mut ok_count = 0usize;
let mut failed_count = 0usize;
while i < history.len() && is_collapsible_tool(&history[i]) {
if let HistoryCell::Tool(tc) = &history[i] {
let name = tool_display_name(tc);
if !tool_names.contains(&name) {
tool_names.push(name);
}
if tc.is_success() {
ok_count += 1;
} else if tc.is_failed() {
failed_count += 1;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 failed_count is always 0; ToolRun carries misleading fields

is_collapsible_tool only returns true for cells where is_success() && !is_collapsible_guard(), so the inner loop body can never reach the else if tc.is_failed() branch. failed_count will always be 0 and the "N ok, M failed" branch in tool_run_summary is dead code. Similarly, has_running is hardcoded false. These fields should either be removed or the doc-comment updated to clarify that a "run" is by construction all-successful.

Fix in Codex Fix in Claude Code Fix in Cursor

Comment on lines +725 to +729
let tool_runs = if app.tool_collapse_threshold > 0 {
crate::tui::history::detect_tool_runs(&app.history, app.tool_collapse_threshold)
} else {
return false;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 detect_tool_runs called redundantly on every click

detect_tool_runs is called here (on click) and again during every render in ChatWidget::new. For a long history the two O(n) passes on each click are redundant. Consider caching the run-list alongside history_version so click handling and rendering share the same computed result.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code Fix in Cursor

…dated (Hmbown#2740)

- Remove `expanded_tool_runs.clear()` from `mark_history_updated()` which
  was called by `toggle_tool_run_expand`, causing immediate re-collapse.
- Clear expanded runs in the correct lifecycle hooks: new session, load
  session, and history trimming.
- Guard `collapsed_skip` allocation behind `!tool_runs.is_empty()` to
  avoid an unnecessary HashSet allocation when no runs are detected.
- Apply `retain` to `expanded_tool_runs` when history is truncated so
  stale indices are dropped.

Closes Hmbown#2740 review comments.
Comment on lines +696 to +708
fn is_running(&self) -> bool {
self.status() == Some(ToolStatus::Running)
}

fn status(&self) -> Option<ToolStatus> {
match self {
ToolCell::Exec(c) => Some(c.status),
ToolCell::Review(c) => Some(c.status),
ToolCell::WebSearch(c) => Some(c.status),
ToolCell::Generic(c) => Some(c.status),
_ => None,
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 McpToolCell omitted from status(), breaking run detection for MCP tools

McpToolCell has pub status: ToolStatus just like Exec, WebSearch, and Generic, but the status() match arm covers only those three plus Review. As a result is_success() always returns false for ToolCell::Mcp, so successful MCP calls are never eligible for collapse and silently break what would otherwise be a continuous run.

Fix in Codex Fix in Claude Code Fix in Cursor

@Hmbown
Copy link
Copy Markdown
Owner

Hmbown commented Jun 5, 2026

Thanks @idling11 — this follow-up is superseded by the local #2738 harvest that is now on the public v0.9.0 integration branch (codex/v0.9.0-stewardship) as c76ec4752.

The landed #2738 slice covers the common dense tool-run collapse direction for #2692, including normal rendering preservation, Enter/Space/mouse expansion, compact/expanded/calm modes, MCP grouping/name handling, and full-detail index mapping for copy/view paths. The reviewed #2740 head still had common-case collapse and MCP grouping/name issues, so it was not merged directly.

The integration branch credits you for the #2692 direction in the execution map and changelog. Closing this PR as superseded by the credited integration-branch harvest, not as rejected work.

@Hmbown Hmbown closed this Jun 5, 2026
Hmbown added a commit that referenced this pull request Jun 5, 2026
Update the execution map after closing harvested or superseded PRs #2733, #2734, #2736, #2737, #2740, and #2741, and refresh the live PR count.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants