Skip to content

feat(transcript): collapse dense tool-call runs into expandable summaries (#2692)#2738

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

feat(transcript): collapse dense tool-call runs into expandable summaries (#2692)#2738
idling11 wants to merge 3 commits into
Hmbown:mainfrom
idling11:feat/transcript-tool-collapse

Conversation

@idling11

@idling11 idling11 commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

hen many consecutive tool calls appear in the transcript, collapse
them into a compact summary row showing count, dominant tool families,
and success/failure state. Full details are preserved behind expansion.

Changes:

  • crates/tui/src/tui/history.rs:
    • Add ToolRun struct and detect_tool_runs() grouping function
    • Add is_success(), is_failed(), is_collapsible_guard() to ToolCell
    • Add tool_display_name() and tool_run_summary() helpers
    • Tools that are running, failed, or destructive (patches, reviews)
      are never collapsed
  • crates/tui/src/tui/app.rs:
    • Add collapsed_tool_runs: HashSet<usize> and
      tool_collapse_threshold: usize (default 3)
  • crates/tui/src/tui/widgets/mod.rs:
    • Pre-process tool runs before rendering: when collapsed, replace N
      individual tool cells with one summary cell

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 introduces collapsible tool-call runs in the transcript: consecutive successful tool cells are detected, grouped, and replaced with a single expandable summary row showing count, dominant tool families, and success state. A new ToolCollapseMode enum (Expanded / Compact / Calm) controls behaviour, wired through settings and the /config tool_collapse command.

  • detect_tool_runs() in history.rs scans history for contiguous collapsible cells and produces ToolRun descriptors; widgets/mod.rs consumes these to substitute synthetic summary cells before rendering.
  • toggle_tool_run_expand in mouse_ui.rs handles click-to-toggle by mutating expanded_tool_runs, but is missing a tool_collapse_mode guard — in the default Expanded mode it still calls detect_tool_runs on every left-click and may silently consume the event, blocking text-selection starts on run-boundary cells.
  • detect_tool_runs gates entry via is_success() && !is_collapsible_guard(), which means failed_count in ToolRun is structurally always zero, making the "{N} ok, {N} failed" branch of tool_run_summary unreachable. The same gate also makes ToolCell::Exploring, Mcp, DiffPreview, PlanUpdate, and ViewImage variants (which have no status field) permanently ineligible for collapsing.

Confidence Score: 3/5

The feature ships with several interacting correctness gaps that affect default-mode users and the accuracy of the summary display.

Three independent defects exist in the changed code: the mouse handler runs and mutates state (and consumes clicks) even when collapsing is completely disabled; expanded_tool_runs is never pruned when history is truncated, leaving stale indices that force runs open after a backtrack; and the failed_count field plus the failure-summary branch are structurally unreachable because the run-detection gate already excludes failed cells.

mouse_ui.rs (missing mode guard in toggle_tool_run_expand) and app.rs (truncate_history_to does not retain expanded_tool_runs) need the most attention before this is merged.

Important Files Changed

Filename Overview
crates/tui/src/tui/history.rs Adds ToolRun struct, detect_tool_runs(), is_success/is_failed/is_collapsible_guard helpers, and tool_run_summary(). The failed_count field and the "{N} ok, {N} failed" summary branch are structurally unreachable because is_collapsible_tool gates on is_success(), excluding any cell that could set failed_count > 0. The format!("all ok") call is a Clippy lint.
crates/tui/src/tui/app.rs Adds tool_collapse_threshold, expanded_tool_runs, and tool_collapse_mode fields. expanded_tool_runs is correctly mutated by toggle_tool_run_expand, but truncate_history_to cleans up collapsed_cells without a matching retain on expanded_tool_runs, leaving stale indices that can cause incorrect expansion state after history truncation.
crates/tui/src/tui/mouse_ui.rs Adds toggle_tool_run_expand() to handle click-to-expand collapsed summaries. Missing tool_collapse_mode guard: in the default Expanded mode the function still calls detect_tool_runs on every Left-Down event and may insert into expanded_tool_runs and consume the click, silently preventing text-selection starts on run-boundary cells.
crates/tui/src/tui/widgets/mod.rs Pre-render collapse pass correctly builds collapsed_skip and replaces run-start cells with synthetic GenericToolCell summaries. collapse_active guard properly gates on tool_collapse_mode, which toggle_tool_run_expand in mouse_ui.rs lacks.
crates/tui/src/settings.rs Adds tool_collapse_mode String field defaulting to "expanded". Straightforward and correct.
crates/tui/src/commands/config.rs Wires /config tool_collapse show and set commands. The set branch falls through to the default display_value arm, so the echo back shows the raw user input rather than the normalised stored value (e.g. "COMPACT" instead of "compact"), but this is cosmetic only.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[ChatWidget render] --> B{collapse_active?}
    B -- Compact / Calm+calm_mode --> C[detect_tool_runs app.history]
    B -- Expanded --> D[tool_runs = empty]
    C --> E[Build collapsed_skip set]
    E --> F{For each history cell}
    F -- in collapsed_cells --> G[skip]
    F -- in collapsed_skip --> G
    F -- run.start AND not expanded --> H[Push synthetic summary cell]
    F -- normal cell --> I[Push original cell]
    H --> J[filtered_to_original → collapsed_cell_map]
    I --> J
    K[Left mouse Down in transcript] --> L[toggle_tool_run_expand]
    L --> M{No mode guard!}
    M --> N[transcript_cell_index_from_mouse]
    N --> O[collapsed_cell_map → original_idx]
    O --> P{in expanded_tool_runs?}
    P -- yes --> Q[Remove → collapse → consume click]
    P -- no --> R[detect_tool_runs again]
    R --> S{is a run start?}
    S -- yes --> T[Insert → expand → consume click]
    S -- no --> U[return false → text selection proceeds]
Loading

Comments Outside Diff (1)

  1. crates/tui/src/tui/app.rs, line 2782-2784 (link)

    P1 expanded_tool_runs not pruned in tail-truncation path

    truncate_history_to_n calls self.collapsed_cells.retain(|idx| *idx < new_len) to drop cells that now point past the new tail, but expanded_tool_runs has no corresponding retain. After truncation, stale run-start indices remain in the set. If new tool runs are appended that coincidentally reuse those indices, they will render as already expanded, bypassing the collapsed summary row unexpectedly.

    Fix in Codex Fix in Claude Code Fix in Cursor

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

Reviews (3): Last reviewed commit: "feat(transcript): add /config tool_colla..." | Re-trigger Greptile

…ries (#2692)

When many consecutive tool calls appear in the transcript, collapse
them into a compact summary row showing count, dominant tool families,
and success/failure state. Full details are preserved behind expansion.

Changes:
- `crates/tui/src/tui/history.rs`:
  - Add `ToolRun` struct and `detect_tool_runs()` grouping function
  - Add `is_success()`, `is_failed()`, `is_collapsible_guard()` to ToolCell
  - Add `tool_display_name()` and `tool_run_summary()` helpers
  - Tools that are running, failed, or destructive (patches, reviews)
    are never collapsed
- `crates/tui/src/tui/app.rs`:
  - Add `collapsed_tool_runs: HashSet<usize>` and
    `tool_collapse_threshold: usize` (default 3)
- `crates/tui/src/tui/widgets/mod.rs`:
  - Pre-process tool runs before rendering: when collapsed, replace N
    individual tool cells with one summary cell

Closes #2692
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

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.

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

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.

Code Review

This pull request introduces a feature to collapse contiguous successful tool runs in the transcript history into a single summary cell. It adds tracking fields to the App state, implements run-detection logic in history.rs, and updates the rendering logic in ChatWidget to replace collapsed runs with a summary cell. The review feedback highlights several excellent performance optimizations and code simplifications. Specifically, it suggests avoiding string allocations by returning &str from tool_display_name, optimizing the collapsed run lookup in the rendering loop from O(N * M) to O(1) using a HashMap, and removing redundant success/failure counters since collapsed runs only contain successful tools.

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 on lines +3483 to +3527
pub fn detect_tool_runs(history: &[HistoryCell], min_size: usize) -> Vec<ToolRun> {
let mut runs = Vec::new();
let mut i = 0;
while i < history.len() {
if is_collapsible_tool(&history[i]) {
let start = i;
let mut tool_names: Vec<String> = Vec::new();
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;
}
}
i += 1;
}
let count = i - start;
if count >= min_size {
let families = if tool_names.len() <= 3 {
tool_names
} else {
tool_names.into_iter().take(3).collect()
};
runs.push(ToolRun {
start,
count,
tool_families: families,
ok_count,
failed_count,
has_running: false, // we never include running tools
});
}
} else {
i += 1;
}
}
runs
}

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

Performance Optimization (Zero-Allocation Scan)

In the current implementation, detect_tool_runs performs string allocations (via tool_display_name returning String) for every single collapsible tool cell in the history on every render frame. This can cause significant memory churn and potential scroll lag in large sessions.

By changing tool_display_name to return &str and using Vec<&str> for tool_names during the scan, we can make this function completely allocation-free during the history traversal. We only allocate the final tool_families strings for runs that actually exceed min_size.

Additionally, we can remove the redundant ok_count and failed_count tracking since collapsed runs only contain successful tools.

/// Detect contiguous runs of tool-call cells exceeding `min_size`.
/// Never includes running or failed tools — they always render individually.
pub fn detect_tool_runs(history: &[HistoryCell], min_size: usize) -> Vec<ToolRun> {
    let mut runs = Vec::new();
    let mut i = 0;
    while i < history.len() {
        if is_collapsible_tool(&history[i]) {
            let start = i;
            let mut tool_names: Vec<&str> = Vec::new();
            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);
                    }
                }
                i += 1;
            }
            let count = i - start;
            if count >= min_size {
                let families = tool_names
                    .into_iter()
                    .take(3)
                    .map(String::from)
                    .collect();
                runs.push(ToolRun {
                    start,
                    count,
                    tool_families: families,
                    has_running: false, // we never include running tools
                });
            }
        } else {
            i += 1;
        }
    }
    runs
}

Comment on lines +3533 to +3551
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.

high

Performance Optimization (Zero-Allocation)

Change tool_display_name to return &str instead of String to avoid allocating memory on every frame for every tool cell in the history.

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

Comment on lines +182 to +191
// Build a quick-lookup set of indices to skip.
let mut collapsed_skip: HashSet<usize> = HashSet::new();
for run in &tool_runs {
if !app.collapsed_tool_runs.contains(&run.start) {
continue;
}
for offset in 1..run.count {
collapsed_skip.insert(run.start + offset);
}
}

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

Performance Optimization (O(1) Lookup)

Instead of performing an $O(M)$ linear scan over tool_runs inside the loop for every single history cell (which results in $O(N \times M)$ complexity), we can build a HashMap mapping the start index of collapsed runs to their corresponding ToolRun reference. This optimizes the lookup inside the loop to $O(1)$.

            // Build a quick-lookup set of indices to skip and collapsed runs to summarize.
            let mut collapsed_runs = std::collections::HashMap::new();
            let mut collapsed_skip: HashSet<usize> = HashSet::new();
            for run in &tool_runs {
                if !app.collapsed_tool_runs.contains(&run.start) {
                    continue;
                }
                collapsed_runs.insert(run.start, run);
                for offset in 1..run.count {
                    collapsed_skip.insert(run.start + offset);
                }
            }

Comment on lines +200 to +221
// 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.collapsed_tool_runs.contains(&r.start))
{
let summary = crate::tui::history::tool_run_summary(run);
let summary_cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: format!("▼ {} tools collapsed", run.count),
status: ToolStatus::Success,
input_summary: Some(summary),
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
}));
filtered_cells.push(summary_cell);
filtered_revs.push(app.history_revisions[idx]);
filtered_to_original.push(idx);
continue;
}

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

Performance Optimization (O(1) Lookup)

Use the pre-built collapsed_runs map to perform an $O(1)$ lookup instead of a linear scan over tool_runs on every history cell.

                // Replace the first cell of a collapsed tool run with
                // a compact summary cell.
                if let Some(run) = collapsed_runs.get(&idx) {
                    let summary = crate::tui::history::tool_run_summary(run);
                    let summary_cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
                        name: format!("▼ {} tools collapsed", run.count),
                        status: ToolStatus::Success,
                        input_summary: Some(summary),
                        output: None,
                        prompts: None,
                        spillover_path: None,
                        output_summary: None,
                        is_diff: false,
                    }));
                    filtered_cells.push(summary_cell);
                    filtered_revs.push(app.history_revisions[idx]);
                    filtered_to_original.push(idx);
                    continue;
                }

Comment on lines +3465 to +3479
pub struct ToolRun {
/// Index of the first tool cell in `app.history`.
pub start: usize,
/// Number of cells in this run.
pub count: usize,
/// Dominant tool names (up to 3, deduplicated).
pub tool_families: Vec<String>,
/// Count of successful tools.
pub ok_count: usize,
/// Count of failed tools.
pub failed_count: usize,
/// Whether any tool in the run is still running.
#[allow(dead_code)]
pub has_running: bool,
}

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

Performance & Simplification Opportunity

Since detect_tool_runs only groups tools that satisfy is_collapsible_tool (which explicitly requires tc.is_success()), any collapsed tool run is guaranteed to consist entirely of successful tools.

Consequently:

  1. failed_count is always 0.
  2. ok_count is always equal to count.

We can simplify the ToolRun struct by removing these redundant fields.

/// A contiguous run of tool-call cells in the transcript history.
#[derive(Debug, Clone)]
pub struct ToolRun {
    /// Index of the first tool cell in `app.history`.
    pub start: usize,
    /// Number of cells in this run.
    pub count: usize,
    /// Dominant tool names (up to 3, deduplicated).
    pub tool_families: Vec<String>,
    /// Whether any tool in the run is still running.
    #[allow(dead_code)]
    pub has_running: bool,
}

Comment on lines +3554 to +3562
pub fn tool_run_summary(run: &ToolRun) -> String {
let tools = run.tool_families.join(", ");
let status = if run.failed_count == 0 {
format!("all ok")
} else {
format!("{} ok, {} failed", run.ok_count, run.failed_count)
};
format!("{} tools ({}) — {}", run.count, tools, status)
}

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

Simplification Opportunity

Since collapsed runs only contain successful tools, we can simplify the summary text and remove the dead else branch.

/// Generate a summary text for a collapsed tool run.
pub fn tool_run_summary(run: &ToolRun) -> String {
    let tools = run.tool_families.join(", ");
    format!("{} tools ({}) — all ok", run.count, tools)
}

Comment thread crates/tui/src/tui/app.rs Outdated
Comment thread crates/tui/src/tui/history.rs
Comment thread crates/tui/src/tui/history.rs
@idling11 idling11 force-pushed the feat/transcript-tool-collapse branch from 5ffe5e8 to 4242c57 Compare June 4, 2026 04:35
…ries (#2692)

When many consecutive tool calls appear in the transcript, collapse
them into a compact summary row showing count, dominant tool families,
and success/failure state. Full details are preserved behind expansion.

Changes:
- `crates/tui/src/tui/history.rs`:
  - Add `ToolRun` struct and `detect_tool_runs()` grouping function
  - Add `is_success()`, `is_failed()`, `is_collapsible_guard()` to ToolCell
  - Add `tool_display_name()` and `tool_run_summary()` helpers
  - Tools that are running, failed, or destructive (patches, reviews)
    are never collapsed
- `crates/tui/src/tui/app.rs`:
  - Add `collapsed_tool_runs: HashSet<usize>` and
    `tool_collapse_threshold: usize` (default 3)
- `crates/tui/src/tui/widgets/mod.rs`:
  - Pre-process tool runs before rendering: when collapsed, replace N
    individual tool cells with one summary cell

Closes #2692
@idling11 idling11 force-pushed the feat/transcript-tool-collapse branch from 4242c57 to 0a9a5d7 Compare June 4, 2026 04:37
Comment on lines +66 to +82
if app.expanded_tool_runs.contains(&original_idx) {
app.expanded_tool_runs.remove(&original_idx);
app.mark_history_updated();
return true;
}
// Check tool runs: if the original_idx is a detected run start and
// not expanded, expand it (this means it was collapsed).
let tool_runs = if app.tool_collapse_threshold > 0 {
crate::tui::history::detect_tool_runs(&app.history, app.tool_collapse_threshold)
} else {
return false;
};
if tool_runs.iter().any(|r| r.start == original_idx) {
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.

P1 Click events consumed in Expanded mode

toggle_tool_run_expand does not check tool_collapse_mode before calling detect_tool_runs. In Expanded mode (the default, since Settings::tool_collapse_mode defaults to "expanded"), no summary rows are rendered, but the function still runs and — if original_idx happens to match a run start — returns true, consuming the MouseButton::Left Down event. This silently prevents the selection_point_from_mouse handler from firing, so users cannot start a text selection from the first cell of any ≥3-tool run in the default configuration.

An early return should be added when collapsing is inactive, mirroring the collapse_active guard in widgets/mod.rs.

Fix in Codex Fix in Claude Code Fix in Cursor

…2692)

Completes the tool-run collapse feature:

- `ToolCollapseMode` enum: Expanded, Compact (always collapse),
  Calm (only during calm mode rendering)
- `/config tool_collapse expanded|compact|calm` get/set support
- `settings.tool_collapse_mode` persisted to TOML (default "expanded")
- Click on a collapsed tool-run summary cell to expand it back to
  individual tool cells; click again to re-collapse
- `expanded_tool_runs` tracks per-run expansion state
- App initializes `tool_collapse_mode` from settings on startup

Closes #2692
@idling11 idling11 closed this Jun 4, 2026
timothybrush pushed a commit to timothybrush/DeepSeek-TUI that referenced this pull request Jun 8, 2026
Harvested from PR Hmbown#2738 by @idling11.

Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com>
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.

1 participant