Group active tool cards and live status (#152)
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user