feat(transcript): collapse dense tool-call runs into expandable summaries (#2692)#2738
feat(transcript): collapse dense tool-call runs into expandable summaries (#2692)#2738idling11 wants to merge 3 commits into
Conversation
…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
|
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 Please read |
There was a problem hiding this comment.
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.
| 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 | ||
| } |
There was a problem hiding this comment.
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
}| 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(), | ||
| } | ||
| } |
There was a problem hiding this comment.
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",
}
}| // 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
Performance Optimization (O(1) Lookup)
Instead of performing an tool_runs inside the loop for every single history cell (which results in HashMap mapping the start index of collapsed runs to their corresponding ToolRun reference. This optimizes the lookup inside the loop to
// 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);
}
}| // 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; | ||
| } |
There was a problem hiding this comment.
Performance Optimization (O(1) Lookup)
Use the pre-built collapsed_runs map to perform an 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;
}| 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, | ||
| } |
There was a problem hiding this comment.
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:
failed_countis always0.ok_countis always equal tocount.
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,
}| 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) | ||
| } |
There was a problem hiding this comment.
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)
}5ffe5e8 to
4242c57
Compare
…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
4242c57 to
0a9a5d7
Compare
| 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; | ||
| } |
There was a problem hiding this comment.
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.
…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
Harvested from PR Hmbown#2738 by @idling11. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com>
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:ToolRunstruct anddetect_tool_runs()grouping functionis_success(),is_failed(),is_collapsible_guard()to ToolCelltool_display_name()andtool_run_summary()helpersare never collapsed
crates/tui/src/tui/app.rs:collapsed_tool_runs: HashSet<usize>andtool_collapse_threshold: usize(default 3)crates/tui/src/tui/widgets/mod.rs:individual tool cells with one summary cell
Closes #2692
Testing
cargo fmt --all -- --checkcargo clippy --workspace --all-targets --all-featurescargo test --workspace --all-featuresChecklist
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
ToolCollapseModeenum (Expanded/Compact/Calm) controls behaviour, wired through settings and the/config tool_collapsecommand.detect_tool_runs()inhistory.rsscans history for contiguous collapsible cells and producesToolRundescriptors;widgets/mod.rsconsumes these to substitute synthetic summary cells before rendering.toggle_tool_run_expandinmouse_ui.rshandles click-to-toggle by mutatingexpanded_tool_runs, but is missing atool_collapse_modeguard — in the defaultExpandedmode it still callsdetect_tool_runson every left-click and may silently consume the event, blocking text-selection starts on run-boundary cells.detect_tool_runsgates entry viais_success() && !is_collapsible_guard(), which meansfailed_countinToolRunis structurally always zero, making the"{N} ok, {N} failed"branch oftool_run_summaryunreachable. The same gate also makesToolCell::Exploring,Mcp,DiffPreview,PlanUpdate, andViewImagevariants (which have nostatusfield) 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_runsis never pruned when history is truncated, leaving stale indices that force runs open after a backtrack; and thefailed_countfield 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
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]Comments Outside Diff (1)
crates/tui/src/tui/app.rs, line 2782-2784 (link)expanded_tool_runsnot pruned in tail-truncation pathtruncate_history_to_ncallsself.collapsed_cells.retain(|idx| *idx < new_len)to drop cells that now point past the new tail, butexpanded_tool_runshas no correspondingretain. 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.Reviews (3): Last reviewed commit: "feat(transcript): add /config tool_colla..." | Re-trigger Greptile