From c76ec47526c56e5ef372993483ce2eda71ec5948 Mon Sep 17 00:00:00 2001 From: Hunter B Date: Wed, 3 Jun 2026 22:00:46 -0700 Subject: [PATCH] feat(transcript): collapse dense tool runs Harvested from PR #2738 by @idling11. Co-authored-by: idling11 <8055620+idling11@users.noreply.github.com> --- CHANGELOG.md | 7 +- crates/tui/src/commands/config.rs | 9 + crates/tui/src/settings.rs | 49 +++++ crates/tui/src/tui/app.rs | 149 ++++++++++++++- crates/tui/src/tui/history.rs | 298 ++++++++++++++++++++++++++++++ crates/tui/src/tui/mouse_ui.rs | 27 ++- crates/tui/src/tui/ui.rs | 19 +- crates/tui/src/tui/views/mod.rs | 9 + crates/tui/src/tui/widgets/mod.rs | 227 +++++++++++++++++++++-- docs/V0_9_0_EXECUTION_MAP.md | 6 +- 10 files changed, 771 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8695693..f85fd010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tool-agent sub-agent routing now inherits the parent session model, or an explicit tool-agent override, instead of hard-coding `deepseek-v4-flash`; the fast lane still disables thinking through provider-aware request shaping. +- Dense successful read/search/list tool runs now collapse into a single + expandable transcript row by default, while running, failed, shell, patch, + review, diff, and other risky tool cells remain visible. The setting + `tool_collapse = "compact" | "expanded" | "calm"` controls the behavior. ### Community @@ -70,7 +74,8 @@ prefix-cache stability work (#2517), **@xyuai** for canonical CodeWhale settings-path migration work (#2730), **@gaord** for the runtime thread workspace update API (#2640), **@shenjackyuanjie** for the HarmonyOS/OpenHarmony port and MatePad Edge validation trail (#2634), -**@idling11** for the PlanArtifact direction in Plan mode (#2733), and +**@idling11** for the PlanArtifact direction in Plan mode (#2733) and the +dense tool-call transcript collapse direction (#2738, #2692), and **@h3c-hexin** for the tool-agent model inheritance and configured `skills_dir` fixes (#2736, #2737). diff --git a/crates/tui/src/commands/config.rs b/crates/tui/src/commands/config.rs index 36d5e2fd..c7718fb6 100644 --- a/crates/tui/src/commands/config.rs +++ b/crates/tui/src/commands/config.rs @@ -204,6 +204,9 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { "max_history" | "history" => Some(app.max_input_history.to_string()), "sidebar_width" | "sidebar" => Some(app.sidebar_width_percent.to_string()), "sidebar_focus" | "focus" => Some(app.sidebar_focus.as_setting().to_string()), + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + Some(app.tool_collapse_mode.as_setting().to_string()) + } "context_panel" | "context" | "session_panel" => { Some(if app.context_panel { "true" } else { "false" }.to_string()) } @@ -847,6 +850,12 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); app.mark_history_updated(); } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + app.tool_collapse_mode = + crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode); + app.expanded_tool_runs.clear(); + app.mark_history_updated(); + } "default_mode" | "mode" => { let mode = AppMode::from_setting(&settings.default_mode); app.set_mode(mode); diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 24636e1e..ebcb34f5 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -203,6 +203,8 @@ pub struct Settings { pub auto_compact_threshold_percent: f64, /// Reduce status noise and collapse details more aggressively pub calm_mode: bool, + /// Dense tool-run collapse mode: compact, expanded, or calm. + pub tool_collapse_mode: String, /// Streaming pacing mode. `true` pins the chunker to one-character-per- /// commit-tick (typewriter); `false` drains the upstream cadence (each /// commit flushes everything queued, which matches V4-pro's burst pattern @@ -330,6 +332,7 @@ impl Default for Settings { auto_compact: false, auto_compact_threshold_percent: 80.0, calm_mode: false, + tool_collapse_mode: "compact".to_string(), low_motion: false, fancy_animations: true, bracketed_paste: true, @@ -401,6 +404,7 @@ impl Settings { s.default_mode = normalize_mode(&s.default_mode).to_string(); s.composer_density = normalize_composer_density(&s.composer_density).to_string(); s.transcript_spacing = normalize_transcript_spacing(&s.transcript_spacing).to_string(); + s.tool_collapse_mode = normalize_tool_collapse_mode(&s.tool_collapse_mode).to_string(); s.sidebar_focus = normalize_sidebar_focus(&s.sidebar_focus).to_string(); s.status_indicator = normalize_status_indicator(&s.status_indicator).to_string(); s.synchronized_output = @@ -565,6 +569,15 @@ impl Settings { "calm_mode" | "calm" => { self.calm_mode = parse_bool(value)?; } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + let normalized = normalize_tool_collapse_mode(value); + if !matches!(normalized, "compact" | "expanded" | "calm") { + return Err(anyhow::anyhow!( + "Failed to update setting: invalid tool collapse mode '{value}'. Expected: compact, expanded, or calm." + )); + } + self.tool_collapse_mode = normalized.to_string(); + } "low_motion" | "motion" => { self.low_motion = parse_bool(value)?; } @@ -774,6 +787,7 @@ impl Settings { self.auto_compact_threshold_percent )); lines.push(format!(" calm_mode: {}", self.calm_mode)); + lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode)); lines.push(format!(" low_motion: {}", self.low_motion)); lines.push(format!(" fancy_animations: {}", self.fancy_animations)); lines.push(format!(" bracketed_paste: {}", self.bracketed_paste)); @@ -849,6 +863,10 @@ impl Settings { "Auto-compact trigger threshold percent when auto_compact is on: 10-100 (default 80)", ), ("calm_mode", "Calmer UI defaults: on/off"), + ( + "tool_collapse", + "Dense tool-run collapse mode: compact, expanded, calm", + ), ( "low_motion", "Streaming pacing: on = typewriter (one char/tick), off = upstream cadence", @@ -1178,6 +1196,15 @@ fn normalize_transcript_spacing(value: &str) -> &str { } } +fn normalize_tool_collapse_mode(value: &str) -> &str { + match value.trim().to_ascii_lowercase().as_str() { + "compact" | "default" | "on" | "true" => "compact", + "expanded" | "expand" | "off" | "none" | "false" => "expanded", + "calm" | "calm_mode" | "calm-mode" | "calm_only" | "calm-only" => "calm", + _ => value, + } +} + /// Normalize the `status_indicator` header chip setting. Accepts the /// canonical names plus common aliases ("none"/"hidden" → "off", /// "dot" → "dots"). Unknown values fall through unchanged so the parser @@ -1542,6 +1569,28 @@ mod tests { assert!(!settings.context_panel); } + #[test] + fn tool_collapse_mode_is_configurable() { + let mut settings = Settings::default(); + assert_eq!(settings.tool_collapse_mode, "compact"); + + settings + .set("tool_collapse", "expanded") + .expect("expanded mode"); + assert_eq!(settings.tool_collapse_mode, "expanded"); + + settings.set("collapse", "calm-only").expect("calm alias"); + assert_eq!(settings.tool_collapse_mode, "calm"); + + settings.set("collapse", "off").expect("off alias"); + assert_eq!(settings.tool_collapse_mode, "expanded"); + + let err = settings + .set("tool_collapse", "mystery") + .expect_err("invalid collapse mode"); + assert!(err.to_string().contains("invalid tool collapse mode")); + } + #[test] fn display_localizes_header_and_config_file_label() { let settings = Settings::default(); diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 1449c430..f82a200e 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -327,6 +327,46 @@ impl SidebarFocus { } } +/// Controls how dense tool-call runs are collapsed in the transcript. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCollapseMode { + /// Collapse qualifying tool runs by default. + Compact, + /// Never collapse tool runs automatically. + Expanded, + /// Collapse only when calm mode is active. + Calm, +} + +impl ToolCollapseMode { + #[must_use] + pub fn from_setting(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "expanded" | "off" | "none" => Self::Expanded, + "calm" | "calm-mode" | "calm_only" | "calm-only" => Self::Calm, + _ => Self::Compact, + } + } + + #[must_use] + pub fn as_setting(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Expanded => "expanded", + Self::Calm => "calm", + } + } + + #[must_use] + pub fn is_active(self, calm_mode: bool) -> bool { + match self { + Self::Compact => true, + Self::Expanded => false, + Self::Calm => calm_mode, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum StatusToastLevel { Info, @@ -1320,6 +1360,12 @@ pub struct App { pub sidebar_width_dirty: bool, /// Whether the session-context panel is enabled (#504). pub context_panel: bool, + /// Minimum number of consecutive safe tool cells needed for auto-collapse. + pub tool_collapse_threshold: usize, + /// Tool runs the user explicitly expanded. Stores original history indices. + pub expanded_tool_runs: HashSet, + /// Current dense tool-run collapse behavior. + pub tool_collapse_mode: ToolCollapseMode, /// File-tree pane state. `None` when hidden; `Some` when visible. pub file_tree: Option, /// Whether the file-tree pane was actually rendered in the last frame. @@ -2048,6 +2094,9 @@ impl App { sidebar_resize_total_width: 0, sidebar_width_dirty: false, context_panel: settings.context_panel, + tool_collapse_threshold: 3, + expanded_tool_runs: HashSet::new(), + tool_collapse_mode: ToolCollapseMode::from_setting(&settings.tool_collapse_mode), file_tree: None, file_tree_visible: false, compact_threshold, @@ -2607,6 +2656,10 @@ impl App { .into_iter() .filter_map(|idx| if idx >= n { Some(idx - n) } else { None }) .collect(); + self.expanded_tool_runs = std::mem::take(&mut self.expanded_tool_runs) + .into_iter() + .filter_map(|idx| if idx >= n { Some(idx - n) } else { None }) + .collect(); self.collapsed_cell_map.clear(); } @@ -2701,6 +2754,7 @@ impl App { self.session_context_references.clear(); self.session_artifacts.clear(); self.collapsed_cells.clear(); + self.expanded_tool_runs.clear(); self.collapsed_cell_map.clear(); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; @@ -2713,6 +2767,8 @@ impl App { self.history_revisions.pop(); self.context_references_by_cell.remove(&self.history.len()); self.rebuild_session_context_references(); + self.expanded_tool_runs + .retain(|idx| *idx < self.history.len()); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; } @@ -2750,11 +2806,42 @@ impl App { } // Drop collapsed cells that reference indices past the new tail. self.collapsed_cells.retain(|idx| *idx < new_len); + self.expanded_tool_runs.retain(|idx| *idx < new_len); self.collapsed_cell_map.clear(); self.history_version = self.history_version.wrapping_add(1); self.needs_redraw = true; } + #[must_use] + pub fn tool_collapse_active(&self) -> bool { + self.tool_collapse_threshold > 0 && self.tool_collapse_mode.is_active(self.calm_mode) + } + + #[must_use] + pub fn tool_run_start_for_history_index(&self, index: usize) -> Option { + if !self.tool_collapse_active() || index >= self.history.len() { + return None; + } + crate::tui::history::detect_tool_runs(&self.history, self.tool_collapse_threshold) + .into_iter() + .find(|run| index >= run.start && index < run.start.saturating_add(run.count)) + .map(|run| run.start) + } + + pub fn toggle_tool_run_expansion_at(&mut self, index: usize) -> bool { + let Some(start) = self.tool_run_start_for_history_index(index) else { + return false; + }; + if self.expanded_tool_runs.remove(&start) { + self.status_message = Some("Tool group collapsed".to_string()); + } else { + self.expanded_tool_runs.insert(start); + self.status_message = Some("Tool group expanded".to_string()); + } + self.mark_history_updated(); + true + } + /// Bump the active-cell revision counter and request a redraw. /// /// Use this whenever an entry inside `active_cell` is mutated. The @@ -2787,6 +2874,14 @@ impl App { self.virtual_cell_count() } + #[must_use] + pub fn original_cell_index_for_rendered(&self, rendered_index: usize) -> usize { + self.collapsed_cell_map + .get(rendered_index) + .copied() + .unwrap_or(rendered_index) + } + /// Resolve a virtual cell index to either a committed history cell or an /// active-cell entry. Used by the pager / details lookup code so it can /// transparently address still-in-flight cells. @@ -2842,7 +2937,7 @@ impl App { .ordered_endpoints() .and_then(|(start, _)| line_meta.get(start.line_index)) .and_then(TranscriptLineMeta::cell_line) - .map(|(cell_index, _)| cell_index) + .map(|(cell_index, _)| self.original_cell_index_for_rendered(cell_index)) .filter(|&idx| self.cell_has_detail_target(idx)); if selected_cell.is_some() { return selected_cell; @@ -2854,6 +2949,7 @@ impl App { let Some((cell_index, _)) = meta.cell_line() else { continue; }; + let cell_index = self.original_cell_index_for_rendered(cell_index); if self.cell_has_detail_target(cell_index) { return Some(cell_index); } @@ -4996,6 +5092,7 @@ mod tests { use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; use crate::tools::todo::TodoStatus; use crate::tui::clipboard::PastedImage; + use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus}; fn test_options(yolo: bool) -> TuiOptions { TuiOptions { @@ -6155,6 +6252,56 @@ mod tests { assert!(app.history_version > initial_version); } + #[test] + fn expanded_tool_runs_rebase_when_history_prefix_shifts() { + let mut app = App::new(test_options(false), &Config::default()); + app.expanded_tool_runs = std::collections::HashSet::from([2usize, 6usize]); + + app.shift_history_maps_down(3); + + assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([3])); + } + + #[test] + fn expanded_tool_runs_prune_when_history_is_truncated() { + let mut app = App::new(test_options(false), &Config::default()); + for idx in 0..5 { + app.add_message(HistoryCell::System { + content: format!("cell {idx}"), + }); + } + app.expanded_tool_runs = std::collections::HashSet::from([1usize, 4usize]); + + app.truncate_history_to(3); + + assert_eq!(app.expanded_tool_runs, std::collections::HashSet::from([1])); + } + + #[test] + fn tool_run_expansion_toggle_opens_and_closes_run() { + let mut app = App::new(test_options(false), &Config::default()); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + for name in ["read_file", "list_dir", "web_search"] { + app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Success, + input_summary: None, + output: Some("ok".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + }))); + } + + assert!(app.toggle_tool_run_expansion_at(0)); + assert!(app.expanded_tool_runs.contains(&0)); + assert!(app.toggle_tool_run_expansion_at(2)); + assert!(!app.expanded_tool_runs.contains(&0)); + assert!(!app.toggle_tool_run_expansion_at(99)); + } + #[test] fn test_scroll_operations() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/history.rs b/crates/tui/src/tui/history.rs index 30e92dfc..1f77083f 100644 --- a/crates/tui/src/tui/history.rs +++ b/crates/tui/src/tui/history.rs @@ -683,6 +683,68 @@ pub enum ToolCell { } impl ToolCell { + /// Status for cells that have a concrete lifecycle state. + pub fn status(&self) -> Option { + match self { + ToolCell::Exec(cell) => Some(cell.status), + ToolCell::Exploring(cell) => { + let has_running = cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Running); + let has_failed = cell + .entries + .iter() + .any(|entry| entry.status == ToolStatus::Failed); + Some(if has_running { + ToolStatus::Running + } else if has_failed { + ToolStatus::Failed + } else { + ToolStatus::Success + }) + } + ToolCell::PlanUpdate(cell) => Some(cell.status), + ToolCell::PatchSummary(cell) => Some(cell.status), + ToolCell::Review(cell) => Some(cell.status), + ToolCell::Mcp(cell) => Some(cell.status), + ToolCell::WebSearch(cell) => Some(cell.status), + ToolCell::Generic(cell) => Some(cell.status), + ToolCell::DiffPreview(_) | ToolCell::ViewImage(_) => Some(ToolStatus::Success), + } + } + + #[must_use] + pub fn is_success(&self) -> bool { + self.status() == Some(ToolStatus::Success) + } + + #[must_use] + pub fn is_running(&self) -> bool { + self.status() == Some(ToolStatus::Running) + } + + #[must_use] + pub fn is_failed(&self) -> bool { + self.status() == Some(ToolStatus::Failed) + } + + /// Whether this cell should stay visible even inside a dense tool run. + #[must_use] + pub fn is_collapsible_guard(&self) -> bool { + self.is_running() + || self.is_failed() + || matches!( + self, + ToolCell::Exec(_) + | ToolCell::PatchSummary(_) + | ToolCell::Review(_) + | ToolCell::DiffPreview(_) + | ToolCell::PlanUpdate(_) + ) + || matches!(self, ToolCell::Generic(cell) if generic_tool_name_is_collapse_guard(&cell.name) || cell.is_diff) + } + /// Render the tool cell into lines. pub fn lines(&self, width: u16) -> Vec> { self.lines_with_motion(width, false) @@ -715,6 +777,104 @@ impl ToolCell { } } +// ── Tool-run grouping for transcript collapse (#2692) ────────────── + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToolRun { + /// Original index of the first tool cell in `App::history`. + pub start: usize, + /// Number of collapsed cells in the run. + pub count: usize, + /// Dominant tool names, deduplicated and capped for summary rendering. + pub tool_families: Vec, +} + +/// Detect contiguous runs of successful, low-risk tool cells. +/// +/// Failed, running, shell, patch, review, diff, and plan-update cells split +/// runs so important state never disappears into a summary row. +pub fn detect_tool_runs(history: &[HistoryCell], min_size: usize) -> Vec { + if min_size == 0 { + return Vec::new(); + } + + let mut runs = Vec::new(); + let mut index = 0; + while index < history.len() { + if !is_collapsible_tool_cell(&history[index]) { + index += 1; + continue; + } + + let start = index; + let mut names: Vec = Vec::new(); + while index < history.len() && is_collapsible_tool_cell(&history[index]) { + if let HistoryCell::Tool(tool) = &history[index] { + let name = tool_display_name(tool); + if !names.iter().any(|existing| existing == name) { + names.push(name.to_string()); + } + } + index += 1; + } + + let count = index - start; + if count >= min_size { + names.truncate(3); + runs.push(ToolRun { + start, + count, + tool_families: names, + }); + } + } + + runs +} + +fn is_collapsible_tool_cell(cell: &HistoryCell) -> bool { + matches!(cell, HistoryCell::Tool(tool) if tool.is_success() && !tool.is_collapsible_guard()) +} + +fn generic_tool_name_is_collapse_guard(name: &str) -> bool { + let normalized = name.trim().to_ascii_lowercase(); + normalized.contains("patch") + || normalized.contains("write") + || normalized.contains("edit") + || normalized.contains("delete") + || normalized.contains("remove") + || normalized.contains("commit") + || normalized.contains("push") + || normalized.contains("shell") + || normalized.contains("exec") + || normalized.contains("review") +} + +fn tool_display_name(tool: &ToolCell) -> &str { + match tool { + ToolCell::Generic(cell) => cell.name.as_str(), + ToolCell::Mcp(cell) => cell.tool.as_str(), + ToolCell::WebSearch(_) => "web_search", + ToolCell::ViewImage(_) => "view_image", + ToolCell::Exploring(_) => "explore", + ToolCell::Exec(_) => "shell", + ToolCell::PlanUpdate(_) => "update_plan", + ToolCell::PatchSummary(_) => "apply_patch", + ToolCell::Review(_) => "review", + ToolCell::DiffPreview(_) => "diff", + } +} + +#[must_use] +pub fn tool_run_summary(run: &ToolRun) -> String { + let tools = if run.tool_families.is_empty() { + "tools".to_string() + } else { + run.tool_families.join(", ") + }; + format!("{} tools ({tools}) · all ok", run.count) +} + /// Overall status for a tool execution. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToolStatus { @@ -5393,4 +5553,142 @@ mod tests { assert_eq!(label_span.content.as_ref(), "Info"); assert_eq!(label_span.style.fg, Some(palette::TEXT_DIM)); } + + fn success_generic_tool(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Success, + input_summary: Some(format!("args for {name}")), + output: Some(format!("output for {name}")), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn failed_generic_tool(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Failed, + input_summary: None, + output: Some("failed".to_string()), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn running_generic_tool(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Running, + input_summary: None, + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn shell_tool(command: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: command.to_string(), + status: ToolStatus::Success, + output: Some("ok".to_string()), + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + output_summary: None, + })) + } + + #[test] + fn detect_tool_runs_finds_contiguous_successful_safe_tools() { + let history = vec![ + HistoryCell::User { + content: "go".to_string(), + }, + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + success_generic_tool("web_search"), + HistoryCell::Assistant { + content: "done".to_string(), + streaming: false, + }, + ]; + + let runs = super::detect_tool_runs(&history, 3); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].start, 1); + assert_eq!(runs[0].count, 3); + assert_eq!( + runs[0].tool_families, + vec!["read_file", "list_dir", "web_search"] + ); + } + + #[test] + fn detect_tool_runs_honors_threshold_and_boundaries() { + let short = vec![ + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + ]; + assert!(super::detect_tool_runs(&short, 3).is_empty()); + + let with_assistant_boundary = vec![ + success_generic_tool("read_file"), + HistoryCell::Assistant { + content: "pause".to_string(), + streaming: false, + }, + success_generic_tool("list_dir"), + success_generic_tool("web_search"), + ]; + assert!(super::detect_tool_runs(&with_assistant_boundary, 3).is_empty()); + } + + #[test] + fn detect_tool_runs_keeps_failed_running_and_shell_cells_visible() { + let history = vec![ + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + failed_generic_tool("web_search"), + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + running_generic_tool("web_search"), + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + shell_tool("rm -rf target"), + success_generic_tool("read_file"), + success_generic_tool("list_dir"), + success_generic_tool("web_search"), + ]; + + let runs = super::detect_tool_runs(&history, 3); + + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].start, 9); + assert_eq!(runs[0].count, 3); + } + + #[test] + fn tool_run_summary_reports_compact_success_group() { + let run = super::ToolRun { + start: 4, + count: 5, + tool_families: vec!["read_file".to_string(), "list_dir".to_string()], + }; + + let summary = super::tool_run_summary(&run); + + assert!(summary.contains("5 tools")); + assert!(summary.contains("read_file")); + assert!(summary.contains("list_dir")); + assert!(summary.contains("all ok")); + } } diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 8e455d42..26aec094 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -42,6 +42,20 @@ pub(crate) fn should_drop_loading_mouse_motion(app: &App, mouse: MouseEvent) -> } } +fn toggle_tool_run_expand(app: &mut App, mouse: MouseEvent) -> bool { + if !app.tool_collapse_active() { + return false; + } + let Some(rendered_idx) = transcript_cell_index_from_mouse(app, mouse) else { + return false; + }; + let original_idx = app.original_cell_index_for_rendered(rendered_idx); + if app.tool_run_start_for_history_index(original_idx) != Some(original_idx) { + return false; + } + app.toggle_tool_run_expansion_at(original_idx) +} + /// Handle mouse events on the sidebar resize handle (the 1-col vertical bar /// between the chat area and the sidebar). Returns true when the event was /// consumed so other handlers skip it. @@ -367,6 +381,10 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec Vec + { + continue; + } KeyCode::Char('l') if key_shortcuts::alt_nav_modifiers(key.modifiers) && app.input.is_empty() @@ -3259,6 +3267,9 @@ async fn run_event_loop( if key.modifiers == KeyModifiers::NONE && app.input.is_empty() => { if let Some(idx) = detail_target_cell_index(app) { + if app.toggle_tool_run_expansion_at(idx) { + continue; + } let is_thinking = app .history .get(idx) @@ -8286,7 +8297,7 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool let current_cell = line_meta .get(top) .and_then(crate::tui::scrolling::TranscriptLineMeta::cell_line) - .map(|(cell_index, _)| cell_index); + .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index)); let mut scan_indices = Vec::new(); match direction { @@ -8302,6 +8313,7 @@ fn jump_to_adjacent_tool_cell(app: &mut App, direction: SearchDirection) -> bool let Some((cell_index, _)) = line_meta[idx].cell_line() else { continue; }; + let cell_index = app.original_cell_index_for_rendered(cell_index); if current_cell.is_some_and(|current| current == cell_index) { continue; } @@ -8579,7 +8591,7 @@ fn selected_transcript_cell_index(app: &App) -> Option { .line_meta() .get(start.line_index) .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index) + .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index)) }) } @@ -9124,7 +9136,7 @@ fn detail_target_cell_index(app: &App) -> Option { .line_meta() .get(start.line_index) .and_then(|meta| meta.cell_line()) - .map(|(cell_index, _)| cell_index); + .map(|(cell_index, _)| app.original_cell_index_for_rendered(cell_index)); } app.detail_cell_index_for_viewport( @@ -9171,6 +9183,7 @@ fn activity_footer_target_cell_index(app: &App) -> Option { let Some((cell_index, _)) = meta.cell_line() else { continue; }; + let cell_index = app.original_cell_index_for_rendered(cell_index); if app .cell_at_virtual_index(cell_index) .is_some_and(is_meaningful_activity_cell) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index f11d846e..d041885a 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -745,6 +745,13 @@ impl ConfigView { editable: true, scope: ConfigScope::Saved, }, + ConfigRow { + section: ConfigSection::Display, + key: "tool_collapse".to_string(), + value: settings.tool_collapse_mode.clone(), + editable: true, + scope: ConfigScope::Saved, + }, ConfigRow { section: ConfigSection::Composer, key: "composer_density".to_string(), @@ -1244,6 +1251,7 @@ fn config_hint_for_key(key: &str) -> &'static str { | "composer_border" | "paste_burst_detection" => "on/off, true/false, yes/no, 1/0", "composer_density" | "transcript_spacing" => "compact | comfortable | spacious", + "tool_collapse" => "compact | expanded | calm", "theme" => "system | dark | light | grayscale", "locale" => "auto | en | ja | zh-Hans | pt-BR", "background_color" => "#RRGGBB | default", @@ -2392,6 +2400,7 @@ mod tests { assert!(keys.contains(&"status_indicator")); assert!(keys.contains(&"synchronized_output")); assert!(keys.contains(&"auto_compact")); + assert!(keys.contains(&"tool_collapse")); assert!(keys.contains(&"composer_border")); assert!(keys.contains(&"composer_vim_mode")); assert!(keys.contains(&"bracketed_paste")); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 92deb9be..03acf4aa 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -22,6 +22,7 @@ pub use footer::{ pub use header::{HeaderData, HeaderWidget, header_status_indicator_frame}; pub use renderable::Renderable; +use std::collections::HashSet; use std::time::Duration; use crate::localization::Locale; @@ -30,7 +31,7 @@ use crate::tui::app::{App, AppMode, ComposerDensity, VimMode}; use crate::tui::approval::{ ApprovalRequest, ApprovalView, ElevationOption, ElevationRequest, RiskLevel, ToolCategory, }; -use crate::tui::history::HistoryCell; +use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus}; use crate::tui::scrolling::TranscriptLineMeta; use crate::{ commands, @@ -129,7 +130,25 @@ impl ChatWidget { .map_or(&[], |active| active.entries()); let history_len = app.history.len(); - let has_collapsed = !app.collapsed_cells.is_empty(); + let tool_runs = if app.tool_collapse_active() { + crate::tui::history::detect_tool_runs(&app.history, app.tool_collapse_threshold) + } else { + Vec::new() + }; + let collapsed_run_starts: HashSet = tool_runs + .iter() + .filter_map(|run| (!app.expanded_tool_runs.contains(&run.start)).then_some(run.start)) + .collect(); + let mut collapsed_tool_indices: HashSet = HashSet::new(); + for run in &tool_runs { + if !collapsed_run_starts.contains(&run.start) { + continue; + } + for offset in 1..run.count { + collapsed_tool_indices.insert(run.start + offset); + } + } + let has_collapsed = !app.collapsed_cells.is_empty() || !collapsed_run_starts.is_empty(); // Fast path: no collapsed cells — use original slices directly. if !has_collapsed { @@ -174,6 +193,18 @@ impl ChatWidget { if app.collapsed_cells.contains(&idx) { continue; } + if collapsed_tool_indices.contains(&idx) { + continue; + } + if let Some(run) = tool_runs + .iter() + .find(|run| run.start == idx && collapsed_run_starts.contains(&idx)) + { + filtered_cells.push(tool_run_summary_cell(run)); + filtered_revs.push(tool_run_summary_revision(run, &app.history_revisions)); + filtered_to_original.push(idx); + continue; + } filtered_cells.push(cell.clone()); filtered_revs.push(app.history_revisions[idx]); filtered_to_original.push(idx); @@ -277,14 +308,26 @@ impl ChatWidget { && let Some(send_at) = app.last_send_at { if send_at.elapsed() < SEND_FLASH_DURATION { - apply_send_flash(&mut lines, top, &app.history, line_meta); + apply_send_flash( + &mut lines, + top, + &app.history, + line_meta, + &app.collapsed_cell_map, + ); } else { app.last_send_at = None; } } if let Some(target_cell) = detail_target_cell { - apply_detail_target_highlight(&mut lines, top, target_cell, line_meta); + apply_detail_target_highlight( + &mut lines, + top, + target_cell, + line_meta, + &app.collapsed_cell_map, + ); } apply_selection(&mut lines, top, app); @@ -323,6 +366,27 @@ impl ChatWidget { } } +fn tool_run_summary_cell(run: &ToolRun) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "activity_group".to_string(), + status: ToolStatus::Success, + input_summary: Some(crate::tui::history::tool_run_summary(run)), + output: None, + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) +} + +fn tool_run_summary_revision(run: &ToolRun, revisions: &[u64]) -> u64 { + let mut revision = 0xA11C_EA5E_D00D_2692u64 ^ ((run.start as u64) << 32) ^ (run.count as u64); + for idx in run.start..run.start.saturating_add(run.count) { + revision = revision.rotate_left(7) ^ revisions.get(idx).copied().unwrap_or(u64::MAX); + } + revision +} + impl Renderable for ChatWidget { fn render(&self, _area: Rect, buf: &mut Buffer) { // Use the passed render area, not self.content_area — those can @@ -1741,12 +1805,17 @@ fn apply_detail_target_highlight( top: usize, target_cell: usize, line_meta: &[TranscriptLineMeta], + original_index_map: &[usize], ) { let highlight_bg = Color::Reset; for (idx, line) in lines.iter_mut().enumerate() { let line_index = top + idx; if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index) - && *cell_index == target_cell + && original_index_map + .get(*cell_index) + .copied() + .unwrap_or(*cell_index) + == target_cell { for span in &mut line.spans { span.style = span.style.bg(highlight_bg); @@ -1761,6 +1830,7 @@ fn apply_send_flash( top: usize, history: &[HistoryCell], line_meta: &[TranscriptLineMeta], + original_index_map: &[usize], ) { // Find the last User cell index. let last_user_cell = history @@ -1775,7 +1845,11 @@ fn apply_send_flash( for (idx, line) in lines.iter_mut().enumerate() { let line_index = top + idx; if let Some(TranscriptLineMeta::CellLine { cell_index, .. }) = line_meta.get(line_index) - && *cell_index == target_cell + && original_index_map + .get(*cell_index) + .copied() + .unwrap_or(*cell_index) + == target_cell { for span in &mut line.spans { span.style = span.style.bg(flash_bg); @@ -2611,22 +2685,22 @@ fn line_spans_with_selection<'a>( mod tests { use super::{ ApprovalWidget, COMPOSER_PANEL_HEIGHT, ChatWidget, ComposerWidget, Renderable, - SlashMenuEntry, apply_selection_to_line, build_empty_state_lines, composer_height, - composer_max_height, composer_min_input_rows, composer_top_padding, compute_takeover_area, - cursor_row_col, layout_input, pad_lines_to_bottom, placeholder_visual_lines, - push_command_entry, should_render_empty_state, slash_completion_hints, wrap_input_lines, - wrap_text, + SlashMenuEntry, apply_detail_target_highlight, apply_selection_to_line, apply_send_flash, + build_empty_state_lines, composer_height, composer_max_height, composer_min_input_rows, + composer_top_padding, compute_takeover_area, cursor_row_col, layout_input, + pad_lines_to_bottom, placeholder_visual_lines, push_command_entry, + should_render_empty_state, slash_completion_hints, wrap_input_lines, wrap_text, }; use crate::config::{ApiProvider, Config}; use crate::localization::Locale; use crate::palette; - use crate::tui::app::{App, ComposerDensity, TuiOptions}; + use crate::tui::app::{App, ComposerDensity, ToolCollapseMode, TuiOptions}; use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; - use crate::tui::scrolling::TranscriptScroll; + use crate::tui::scrolling::{TranscriptLineMeta, TranscriptScroll}; use ratatui::{ buffer::Buffer, layout::Rect, - style::Style, + style::{Color, Style}, text::{Line, Span}, }; use std::path::PathBuf; @@ -2668,6 +2742,131 @@ mod tests { text } + fn success_tool_cell(name: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: name.to_string(), + status: ToolStatus::Success, + input_summary: Some(format!("path: {name}.txt")), + output: Some(format!("full output from {name}")), + prompts: None, + spillover_path: None, + output_summary: None, + is_diff: false, + })) + } + + fn add_dense_tool_run(app: &mut App) { + app.add_message(success_tool_cell("read_file")); + app.add_message(success_tool_cell("list_dir")); + app.add_message(success_tool_cell("web_search")); + } + + #[test] + fn send_flash_uses_original_index_map_for_collapsed_rows() { + let history = vec![ + success_tool_cell("read_file"), + success_tool_cell("list_dir"), + HistoryCell::User { + content: "sent".to_string(), + }, + ]; + let mut lines = vec![Line::from("sent")]; + let line_meta = vec![TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0, + copy_prefix_width: 0, + copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline, + }]; + let original_index_map = vec![2]; + + apply_send_flash(&mut lines, 0, &history, &line_meta, &original_index_map); + + assert_eq!(lines[0].spans[0].style.bg, Some(Color::Rgb(30, 40, 55))); + } + + #[test] + fn detail_highlight_uses_original_index_map_for_collapsed_rows() { + let mut lines = vec![Line::from("tool group")]; + let line_meta = vec![TranscriptLineMeta::CellLine { + cell_index: 0, + line_in_cell: 0, + copy_prefix_width: 0, + copy_separator_after: crate::tui::ui_text::CopyLineSeparator::Newline, + }]; + let original_index_map = vec![4]; + + apply_detail_target_highlight(&mut lines, 0, 4, &line_meta, &original_index_map); + + assert_eq!(lines[0].spans[0].style.bg, Some(Color::Reset)); + } + + #[test] + fn chat_widget_collapses_dense_tool_runs_by_default() { + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + add_dense_tool_run(&mut app); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 8, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + assert_eq!(app.collapsed_cell_map, vec![0]); + assert!(rendered.contains("3 tools"), "{rendered}"); + assert!( + !rendered.contains("full output from list_dir"), + "{rendered}" + ); + } + + #[test] + fn chat_widget_expands_dense_tool_runs_on_demand() { + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Compact; + app.tool_collapse_threshold = 3; + add_dense_tool_run(&mut app); + app.expanded_tool_runs.insert(0); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 12, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + let rendered = buffer_text(&buf, area); + + assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]); + assert!(rendered.contains("full output from list_dir"), "{rendered}"); + } + + #[test] + fn chat_widget_expanded_mode_leaves_dense_tool_runs_visible() { + let mut app = create_test_app(); + app.tool_collapse_mode = ToolCollapseMode::Expanded; + app.tool_collapse_threshold = 3; + add_dense_tool_run(&mut app); + + let area = Rect { + x: 0, + y: 0, + width: 80, + height: 12, + }; + let _widget = ChatWidget::new(&mut app, area); + + assert_eq!(app.collapsed_cell_map, vec![0, 1, 2]); + } + #[test] fn pad_lines_to_bottom_noop_when_already_filled() { let mut lines = vec![Line::from("one"), Line::from("two")]; diff --git a/docs/V0_9_0_EXECUTION_MAP.md b/docs/V0_9_0_EXECUTION_MAP.md index af246522..9d378b38 100644 --- a/docs/V0_9_0_EXECUTION_MAP.md +++ b/docs/V0_9_0_EXECUTION_MAP.md @@ -48,6 +48,7 @@ harvest/stewardship commits: | #2733 PlanArtifact for Plan mode | Locally harvested as a broader continuity-artifact slice. | Added rich `update_plan` fields for objective, context, sources, files, constraints, verification, risks, and handoff notes; renders them in the transcript card and Plan confirmation prompt; preserves them through `/relay`, fork-state, and saved-session replay. `cargo test -p codewhale-tui --bin codewhale-tui --locked plan_ -- --nocapture`, `cargo test -p codewhale-tui --bin codewhale-tui --locked relay_slash_command_routes_to_session_relay_instruction -- --nocapture`, and `cargo clippy -p codewhale-tui --locked -- -D warnings` passed. | | #2736 sub-agent model inheritance | Locally harvested with explicit-override and provider-shaping tests. | Tool-agent routing now inherits the parent runtime model instead of hard-coding `deepseek-v4-flash`, while explicit DeepSeek-style tool-agent overrides still win. The `reasoning_effort = off` fast lane is covered by strict OpenAI-like provider request-shaping tests. Credit @h3c-hexin; comment/close the original after the integration branch is public. | | #2737 configured `skills_dir` discovery | Locally harvested with explicit-config precedence. | The system prompt now unions workspace-discovered skills and configured `skills_dir` skills instead of treating the configured directory as a fallback. Explicit configured skills are inserted before global defaults so they are not lost behind a large global skill library. Credit @h3c-hexin; comment/close the original after the integration branch is public. | +| #2738 dense tool-call transcript collapse | Locally harvested with expansion, cache-key, and safety fixes. | Successful read/search/list-style tool runs collapse by default once they cross the density threshold; failures, running cells, shell/exec, patch/write/edit/delete, diff preview, plan update, and review cells stay visible. Users can expand a group with Enter/Space/mouse and can set `tool_collapse = "compact" | "expanded" | "calm"`. Credit @idling11 and issue #2692; comment/close the original after the integration branch is public. | | #2636 project-context mtime cache | Defer direct merge; harvest only after cache key/signature is widened. | Must include constitution changes, auto-generated context deletion, canonical path equivalence, and overwrite detection before landing. | | #2634 HarmonyOS port | Locally harvested with additional Nix-chain clearance; keep credited and do not close until the integration branch is public. | User-supplied MatePad Edge demo (`https://bilibili.com/video/av116689597368905`) confirms real-device interest. Added env-driven OpenHarmony SDK setup, OHOS platform guards/fallbacks, self-update disablement, and OHOS target gating for Starlark execpolicy parsing plus PTY support so published OHOS builds do not pull `nix` 0.28 through `rustyline` or `portable-pty`. `cargo check --workspace --all-features --locked`, focused PTY/clipboard tests, and `cargo tree --locked -p codewhale-tui --target aarch64-unknown-linux-ohos -i nix@0.28.0` passed; full OHOS target check is blocked on this host because `OHOS_NATIVE_SDK`/target CC/sysroot are not configured and `ring` cannot find `assert.h`. | | #2687 append-only mode/approval prompt | Defer direct merge; draft has compile failures and Plan-mode prompt correctness risks. | Any future harvest must keep stable `message[0]` genuinely mode-agnostic, preserve mode/approval suffixes after capacity replans, and distinguish external overrides from persisted generated prompts. | @@ -119,7 +120,7 @@ harvest/stewardship commits: | #2733 PlanArtifact UI | Mergeable | Locally harvested with richer schema, rendering, relay/fork-state propagation, and replay tests. Comment/close original after integration branch is public, crediting @idling11 and issue #2691; keep #2691 open only if additional PlanReview product work remains. | | #2736 sub-agent model inheritance | Mergeable | Locally harvested with parent-model inheritance, explicit override coverage, and strict OpenAI-like `reasoning_effort = off` shaping coverage. Comment/close original after the integration branch is public, crediting @h3c-hexin. | | #2737 configured `skills_dir` discovery | Mergeable | Locally harvested with extra configured-before-global precedence tests. Comment/close original after the integration branch is public, crediting @h3c-hexin. | -| #2738 dense tool-call transcript collapse | Mergeable | Do not merge as-is. The compaction idea matches the `/relay` direction, but the PR currently bypasses normal rendering, lacks expansion wiring, defaults to expanded mode, and has cache-key/index maintenance risks. Harvest only after completing those behaviors. | +| #2738 dense tool-call transcript collapse | Mergeable / locally harvested | Harvested with normal rendering preserved, expansion wired through Enter/Space/mouse, compact default restored, full-detail index mapping preserved for Alt+V/copy-style paths, and revision keys mixed across hidden cells. Comment/close original after the integration branch is public, crediting @idling11 and issue #2692. | ## Issue Reduction Strategy @@ -140,6 +141,7 @@ Issue count should drop through evidence-backed consolidation, not bulk closing. ## Immediate Next Actions 1. Prepare public comments for #2708, #2502, #2513, #2530, #2576, #2581, #2627, - #2634, #2636, #2687, #2736, #2737, and already-harvested performance PRs. + #2634, #2636, #2687, #2736, #2737, #2738, and already-harvested performance + PRs. 2. Start file decomposition Phase 1 only after the PR harvest table has no unknown high-priority provider/prompt/cache branches.