Group active tool cards and live status (#152)

This commit is contained in:
Hunter Bown
2026-04-28 17:46:55 -05:00
committed by GitHub
parent d7b033d59e
commit 78b272e56b
3 changed files with 378 additions and 11 deletions
+202 -4
View File
@@ -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<crate::tui::widgets::tool_card::CardRail> {
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<crate::tui::widgets::tool_card::CardRail>,
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<Span<'static>>, max_width: usize) -> Vec<Span<'static>> {
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<String> {
cache
.lines()
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.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:?}"
);
}
}
}
+135 -6
View File
@@ -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<String>,
primary_any: Option<String>,
running: usize,
completed: usize,
started_at: Option<Instant>,
}
impl ActiveToolStatusSnapshot {
fn record(&mut self, label: String, status: ToolStatus, started_at: Option<Instant>) {
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<String> {
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::<Vec<_>>().join(" "),
max_width,
)
}
/// Build [`FooterProps`] from a user-configured `status_items` slice.
///
/// Variants are routed to their structural cluster: `Mode` and `Model` are
+41 -1
View File
@@ -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");