From 78b272e56b8dfac750ea3bf58d1fe191f9f797f5 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 28 Apr 2026 17:46:55 -0500 Subject: [PATCH] Group active tool cards and live status (#152) --- crates/tui/src/tui/transcript.rs | 206 ++++++++++++++++++++++++++++++- crates/tui/src/tui/ui.rs | 141 ++++++++++++++++++++- crates/tui/src/tui/ui/tests.rs | 42 ++++++- 3 files changed, 378 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/tui/transcript.rs b/crates/tui/src/tui/transcript.rs index afb1c8cf..d9fbcda4 100644 --- a/crates/tui/src/tui/transcript.rs +++ b/crates/tui/src/tui/transcript.rs @@ -18,7 +18,10 @@ use std::sync::Arc; -use ratatui::text::Line; +use ratatui::{ + style::Style, + text::{Line, Span}, +}; use crate::tui::app::TranscriptSpacing; use crate::tui::history::{HistoryCell, TranscriptRenderOptions}; @@ -54,6 +57,8 @@ struct CachedCell { is_conversational: bool, /// Whether this cell is a System or Tool cell (affects spacer rules). is_system_or_tool: bool, + /// Whether this cell participates in the compact tool-card rail group. + is_tool_groupable: bool, } /// Cache of rendered transcript lines for the current viewport. @@ -159,7 +164,13 @@ impl TranscriptViewCache { } any_dirty = true; - let rendered = cell.lines_with_options(width, options); + let is_tool_groupable = matches!(cell, HistoryCell::Tool(_)); + let render_width = if is_tool_groupable { + width.saturating_sub(2).max(1) + } else { + width + }; + let rendered = cell.lines_with_options(render_width, options); let is_empty = rendered.is_empty(); new_per_cell.push(CachedCell { revision: current_rev, @@ -174,6 +185,7 @@ impl TranscriptViewCache { | HistoryCell::Tool(_) | HistoryCell::SubAgent(_) ), + is_tool_groupable, }); idx += 1; } @@ -202,8 +214,18 @@ impl TranscriptViewCache { // Arc::make_mut would deep-clone only on write; since we just // rebuilt `lines` from scratch we always need the owned data. // Deref is zero-cost and gives us &[Line]. + let rendered_line_count = cached.lines.len(); for (line_in_cell, line) in cached.lines.iter().enumerate() { - lines.push(line.clone()); + lines.push(line_with_group_rail( + line, + tool_group_rail( + self.per_cell.as_slice(), + cell_index, + line_in_cell, + rendered_line_count, + ), + usize::from(self.width), + )); meta.push(TranscriptLineMeta::CellLine { cell_index, line_in_cell, @@ -251,6 +273,10 @@ fn spacer_rows_between( return 0; } + if current.is_tool_groupable && next.is_tool_groupable { + return 0; + } + let conversational_gap = match spacing { TranscriptSpacing::Compact => 0, TranscriptSpacing::Comfortable => 1, @@ -270,10 +296,121 @@ fn spacer_rows_between( } } +fn tool_group_rail( + cells: &[CachedCell], + cell_index: usize, + line_in_cell: usize, + rendered_line_count: usize, +) -> Option { + let cached = cells.get(cell_index)?; + if !cached.is_tool_groupable || rendered_line_count == 0 { + return None; + } + + let previous_is_tool = cell_index + .checked_sub(1) + .and_then(|idx| cells.get(idx)) + .is_some_and(|cell| cell.is_tool_groupable && !cell.is_empty); + let next_is_tool = cells + .get(cell_index + 1) + .is_some_and(|cell| cell.is_tool_groupable && !cell.is_empty); + let first_line_in_group = !previous_is_tool && line_in_cell == 0; + let last_line_in_group = !next_is_tool && line_in_cell + 1 == rendered_line_count; + + let rail = match (first_line_in_group, last_line_in_group) { + (true, true) if rendered_line_count == 1 => { + crate::tui::widgets::tool_card::CardRail::Single + } + (true, _) => crate::tui::widgets::tool_card::CardRail::Top, + (_, true) => crate::tui::widgets::tool_card::CardRail::Bottom, + _ => crate::tui::widgets::tool_card::CardRail::Middle, + }; + Some(rail) +} + +fn line_with_group_rail( + line: &Line<'static>, + rail: Option, + max_width: usize, +) -> Line<'static> { + let Some(rail) = rail else { + return line.clone(); + }; + let glyph = crate::tui::widgets::tool_card::rail_glyph(rail); + if glyph.is_empty() { + let mut rendered = line.clone(); + rendered.spans = truncate_spans_to_width(rendered.spans, max_width); + return rendered; + } + + let mut rendered = line.clone(); + let mut spans = Vec::with_capacity(rendered.spans.len() + 1); + spans.push(Span::styled( + format!("{glyph} "), + Style::default().fg(crate::palette::TEXT_DIM), + )); + spans.extend(rendered.spans); + rendered.spans = truncate_spans_to_width(spans, max_width); + rendered +} + +fn truncate_spans_to_width(spans: Vec>, max_width: usize) -> Vec> { + if max_width == 0 || spans.is_empty() { + return Vec::new(); + } + let current_width: usize = spans + .iter() + .map(|span| unicode_width::UnicodeWidthStr::width(span.content.as_ref())) + .sum(); + if current_width <= max_width { + return spans; + } + + let ellipsis = if max_width > 3 { "..." } else { "" }; + let content_budget = max_width.saturating_sub(ellipsis.len()); + let mut used = 0usize; + let mut truncated = Vec::with_capacity(spans.len() + usize::from(!ellipsis.is_empty())); + let mut last_style = Style::default(); + + 'outer: for span in spans { + last_style = span.style; + let mut content = String::new(); + for ch in span.content.chars() { + let width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); + if used + width > content_budget { + break 'outer; + } + content.push(ch); + used += width; + } + if !content.is_empty() { + truncated.push(Span::styled(content, span.style)); + } + } + + if !ellipsis.is_empty() { + truncated.push(Span::styled(ellipsis.to_string(), last_style)); + } + truncated +} + #[cfg(test)] mod tests { use super::*; - use crate::tui::history::HistoryCell; + use crate::tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus}; + + fn plain_lines(cache: &TranscriptViewCache) -> Vec { + cache + .lines() + .iter() + .map(|line| { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect() + } fn user_cell(content: &str) -> HistoryCell { HistoryCell::User { @@ -288,6 +425,18 @@ mod tests { } } + fn exec_tool_cell(command: &str) -> HistoryCell { + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: command.to_string(), + status: ToolStatus::Running, + output: None, + started_at: None, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + })) + } + #[test] fn cache_reuses_cells_when_revision_unchanged() { let cells = vec![ @@ -555,4 +704,53 @@ mod tests { assert_eq!(cache.per_cell.len(), 2); assert!(!cache.lines().is_empty()); } + + #[test] + fn adjacent_tool_cells_render_as_one_railed_group() { + let cells = vec![exec_tool_cell("cargo test"), exec_tool_cell("cargo clippy")]; + let revisions = vec![1u64, 1]; + let mut cache = TranscriptViewCache::new(); + + cache.ensure(&cells, &revisions, 80, TranscriptRenderOptions::default()); + let lines = plain_lines(&cache); + + assert!( + lines + .first() + .is_some_and(|line| line.starts_with("\u{256D} ")), + "first tool line should open the shared rail: {lines:?}" + ); + assert!( + lines.iter().any(|line| line.starts_with("\u{2502} ")), + "middle tool lines should continue the shared rail: {lines:?}" + ); + assert!( + lines + .last() + .is_some_and(|line| line.starts_with("\u{2570} ")), + "last tool line should close the shared rail: {lines:?}" + ); + assert!( + !lines.iter().any(String::is_empty), + "adjacent tool cells should not be separated by blank spacer rows: {lines:?}" + ); + } + + #[test] + fn tool_rails_preserve_rendered_width_budget() { + let cells = vec![exec_tool_cell( + "printf 'this is a command with enough text to wrap in narrow terminals'", + )]; + let revisions = vec![1u64]; + let mut cache = TranscriptViewCache::new(); + + cache.ensure(&cells, &revisions, 24, TranscriptRenderOptions::default()); + + for line in plain_lines(&cache) { + assert!( + unicode_width::UnicodeWidthStr::width(line.as_str()) <= 24, + "tool rail line exceeded narrow width: {line:?}" + ); + } + } } diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 0855492f..5212448c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -3966,12 +3966,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &mut App) { .map(|d| d.as_millis() as u64) .unwrap_or(0); let dot_frame = now_ms / 400; - // Surface a `working`-with-dot-pulse label whenever a turn is live. - // This replaces the plain "working" / no-label state for the - // duration of the turn so the user always has a textual signal, - // even on terminals where the spout strip is disabled. - let working_label = crate::tui::widgets::footer_working_label(dot_frame); - props.state_label = working_label; + // Surface one compact live status row in the footer whenever a turn + // is live. Tool turns get the current action plus active/done counts; + // non-tool work falls back to the existing dot-pulse label. + props.state_label = active_tool_status_label(app) + .unwrap_or_else(|| crate::tui::widgets::footer_working_label(dot_frame)); props.state_color = palette::DEEPSEEK_SKY; // Spout drift: only animate when low_motion is off. The textual @@ -4001,6 +4000,136 @@ fn footer_working_strip_active(app: &App) -> bool { app.is_loading || app.is_compacting || running_agent_count(app) > 0 || turn_in_progress } +#[derive(Default)] +struct ActiveToolStatusSnapshot { + primary_running: Option, + primary_any: Option, + running: usize, + completed: usize, + started_at: Option, +} + +impl ActiveToolStatusSnapshot { + fn record(&mut self, label: String, status: ToolStatus, started_at: Option) { + if self.primary_any.is_none() { + self.primary_any = Some(label.clone()); + } + if status == ToolStatus::Running { + self.running += 1; + if self.primary_running.is_none() { + self.primary_running = Some(label); + } + } else { + self.completed += 1; + } + if let Some(started) = started_at { + self.started_at = Some(match self.started_at { + Some(current) => current.min(started), + None => started, + }); + } + } + + fn total(&self) -> usize { + self.running + self.completed + } +} + +fn active_tool_status_label(app: &App) -> Option { + let active = app.active_cell.as_ref()?; + if active.is_empty() { + return None; + } + + let mut snapshot = ActiveToolStatusSnapshot::default(); + for cell in active.entries() { + collect_active_tool_status(cell, &mut snapshot); + } + if snapshot.total() == 0 { + return None; + } + + let primary = snapshot + .primary_running + .or(snapshot.primary_any) + .unwrap_or_else(|| "tools".to_string()); + let primary = truncate_line_to_width(&primary, 30); + let elapsed = snapshot + .started_at + .or(app.turn_started_at) + .map(|started| format!("{}s", started.elapsed().as_secs())); + + let mut parts = vec![ + primary, + format!("{} active", snapshot.running), + format!("{} done", snapshot.completed), + ]; + if let Some(elapsed) = elapsed { + parts.push(elapsed); + } + parts.push("Alt+V".to_string()); + Some(parts.join(" \u{00B7} ")) +} + +fn collect_active_tool_status(cell: &HistoryCell, snapshot: &mut ActiveToolStatusSnapshot) { + let HistoryCell::Tool(tool) = cell else { + return; + }; + match tool { + ToolCell::Exec(exec) => snapshot.record( + format!("run {}", one_line_summary(&exec.command, 80)), + exec.status, + exec.started_at, + ), + ToolCell::Exploring(explore) => { + for entry in &explore.entries { + snapshot.record( + format!("read {}", one_line_summary(&entry.label, 80)), + entry.status, + None, + ); + } + } + ToolCell::PlanUpdate(plan) => { + snapshot.record("update plan".to_string(), plan.status, None); + } + ToolCell::PatchSummary(patch) => { + snapshot.record(format!("patch {}", patch.path), patch.status, None); + } + ToolCell::Review(review) => { + let target = one_line_summary(&review.target, 80); + let label = if target.is_empty() { + "review".to_string() + } else { + format!("review {target}") + }; + snapshot.record(label, review.status, None); + } + ToolCell::DiffPreview(diff) => { + snapshot.record(format!("diff {}", diff.title), ToolStatus::Success, None); + } + ToolCell::Mcp(mcp) => snapshot.record(format!("tool {}", mcp.tool), mcp.status, None), + ToolCell::ViewImage(image) => snapshot.record( + format!("image {}", image.path.display()), + ToolStatus::Success, + None, + ), + ToolCell::WebSearch(search) => { + snapshot.record(format!("search {}", search.query), search.status, None); + } + ToolCell::Generic(generic) => { + snapshot.record(format!("tool {}", generic.name), generic.status, None); + } + } +} + +fn one_line_summary(text: &str, max_width: usize) -> String { + truncate_line_to_width( + &text.split_whitespace().collect::>().join(" "), + max_width, + ) +} + /// Build [`FooterProps`] from a user-configured `status_items` slice. /// /// Variants are routed to their structural cluster: `Mode` and `Model` are diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 06195660..41f60377 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4,11 +4,14 @@ use crate::tui::file_mention::{ apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor, try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries, }; -use crate::tui::history::{GenericToolCell, HistoryCell, ToolCell, ToolStatus}; +use crate::tui::history::{ + ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolStatus, +}; use crate::tui::views::{ModalView, ViewAction}; use crate::working_set::Workspace; use std::path::PathBuf; use std::process::Command; +use std::time::{Duration, Instant}; use tempfile::TempDir; #[test] @@ -133,6 +136,43 @@ fn create_test_app() -> App { App::new(options, &Config::default()) } +#[test] +fn active_tool_status_label_summarizes_live_tool_group() { + let mut app = create_test_app(); + app.turn_started_at = Some(Instant::now() - Duration::from_secs(5)); + let mut active = ActiveCell::new(); + active.push_tool( + "exec-1", + HistoryCell::Tool(ToolCell::Exec(ExecCell { + command: "cargo test --workspace --all-features".to_string(), + status: ToolStatus::Running, + output: None, + started_at: app.turn_started_at, + duration_ms: None, + source: ExecSource::Assistant, + interaction: None, + })), + ); + active.push_tool( + "tool-2", + HistoryCell::Tool(ToolCell::Generic(GenericToolCell { + name: "grep_files".to_string(), + status: ToolStatus::Success, + input_summary: Some("pattern: TODO".to_string()), + output: Some("done".to_string()), + prompts: None, + })), + ); + app.active_cell = Some(active); + + let label = active_tool_status_label(&app).expect("status label"); + + assert!(label.contains("run cargo test")); + assert!(label.contains("1 active")); + assert!(label.contains("1 done")); + assert!(label.contains("Alt+V")); +} + #[test] fn file_mentions_add_local_text_context_to_model_payload() { let tmpdir = TempDir::new().expect("tempdir");