feat: add compact TUI activity overview
This commit is contained in:
@@ -2260,11 +2260,14 @@ impl App {
|
||||
}
|
||||
session_store::SystemItem::FileAttachment { body, .. }
|
||||
| session_store::SystemItem::SkillActivation { body, .. }
|
||||
| session_store::SystemItem::TaskReminder { body, .. }
|
||||
| session_store::SystemItem::Interrupt { body, .. } => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::SystemMessage { text: body });
|
||||
}
|
||||
session_store::SystemItem::TaskReminder { body, .. } => {
|
||||
self.task_store.apply_system_message_text(&body);
|
||||
self.blocks.push(Block::TaskReminder { text: body });
|
||||
}
|
||||
session_store::SystemItem::LegacyIgnored { .. } => {}
|
||||
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
|
||||
}
|
||||
@@ -2609,6 +2612,7 @@ mod rewind_refresh_tests {
|
||||
app.blocks.iter().any(|block| match block {
|
||||
Block::AssistantText { text }
|
||||
| Block::SystemMessage { text }
|
||||
| Block::TaskReminder { text }
|
||||
| Block::Alert { message: text, .. } => text.contains(needle),
|
||||
Block::UserMessage { segments } => Segment::flatten_to_text(segments).contains(needle),
|
||||
_ => false,
|
||||
@@ -3847,6 +3851,10 @@ mod completion_flow_tests {
|
||||
assert_eq!(tasks.len(), 1);
|
||||
assert_eq!(tasks[0].taskid, 4);
|
||||
assert_eq!(tasks[0].subject, "from snapshot");
|
||||
assert!(matches!(
|
||||
app.blocks.last(),
|
||||
Some(Block::TaskReminder { text }) if text == snapshot
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -25,6 +25,11 @@ pub enum Block {
|
||||
SystemMessage {
|
||||
text: String,
|
||||
},
|
||||
/// Typed task reminder. Its presentation depends on the selected history
|
||||
/// mode rather than exposing the full reminder in compact views.
|
||||
TaskReminder {
|
||||
text: String,
|
||||
},
|
||||
/// Echo of `Method::Notify` received by this Worker, surfaced as a log
|
||||
/// element so subscribers see the external input that drove any
|
||||
/// following auto-kicked turn.
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! `Read`) consume multiple consecutive blocks to produce a single
|
||||
//! aggregate display.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
@@ -38,6 +40,9 @@ pub fn render_tool(
|
||||
consumed: 1,
|
||||
};
|
||||
};
|
||||
if mode == Mode::Overview {
|
||||
return render_overview_activity(blocks, start);
|
||||
}
|
||||
|
||||
match tc.name.as_str() {
|
||||
"Read" => render_read_aggregate(blocks, start, mode),
|
||||
@@ -49,6 +54,154 @@ pub fn render_tool(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
struct ActivityCount {
|
||||
total: usize,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl ActivityCount {
|
||||
fn add(&mut self, state: &ToolCallState) {
|
||||
self.total += 1;
|
||||
self.active |= matches!(
|
||||
state,
|
||||
ToolCallState::Pending | ToolCallState::Streaming | ToolCallState::Executing
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_overview_activity(blocks: &[Block], start: usize) -> ToolRenderOutput {
|
||||
let mut end = start;
|
||||
let mut tools = Vec::new();
|
||||
while let Some(block) = blocks.get(end) {
|
||||
match block {
|
||||
Block::ToolCall(tool) => tools.push(tool),
|
||||
Block::Thinking(_) | Block::TaskReminder { .. } => {}
|
||||
_ => break,
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
|
||||
let mut reads = ActivityCount::default();
|
||||
let mut searches = ActivityCount::default();
|
||||
let mut commands = ActivityCount::default();
|
||||
let mut edits = ActivityCount::default();
|
||||
let mut writes = ActivityCount::default();
|
||||
let mut additions = 0;
|
||||
let mut deletions = 0;
|
||||
let mut failed = 0;
|
||||
let mut incomplete = 0;
|
||||
let mut others = BTreeMap::<String, ActivityCount>::new();
|
||||
|
||||
for tool in tools {
|
||||
if matches!(tool.state, ToolCallState::Error { .. }) {
|
||||
failed += 1;
|
||||
}
|
||||
if matches!(tool.state, ToolCallState::Incomplete) {
|
||||
incomplete += 1;
|
||||
}
|
||||
match tool.name.as_str() {
|
||||
"Read" => reads.add(&tool.state),
|
||||
"Glob" | "Grep" | "WebSearch" | "SearchSessionEntries" => searches.add(&tool.state),
|
||||
"Bash" => commands.add(&tool.state),
|
||||
"Edit" => {
|
||||
edits.add(&tool.state);
|
||||
if matches!(tool.state, ToolCallState::Done { .. })
|
||||
&& let Some(arguments) = tool.arguments.as_deref()
|
||||
&& let Ok(args) = serde_json::from_str::<serde_json::Value>(arguments)
|
||||
{
|
||||
if let Some(old) = args.get("old_string").and_then(|value| value.as_str()) {
|
||||
deletions += old.lines().count().max(1);
|
||||
}
|
||||
if let Some(new) = args.get("new_string").and_then(|value| value.as_str()) {
|
||||
additions += new.lines().count().max(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
"Write" => writes.add(&tool.state),
|
||||
name => others.entry(name.to_owned()).or_default().add(&tool.state),
|
||||
}
|
||||
}
|
||||
|
||||
let mut primary = Vec::new();
|
||||
if reads.total > 0 {
|
||||
primary.push(if reads.active {
|
||||
format!("reading {} file{}", reads.total, plural(reads.total))
|
||||
} else {
|
||||
format!("{} file{} read", reads.total, plural(reads.total))
|
||||
});
|
||||
}
|
||||
if searches.total > 0 {
|
||||
primary.push(if searches.active {
|
||||
format!(
|
||||
"searching {} time{}",
|
||||
searches.total,
|
||||
plural(searches.total)
|
||||
)
|
||||
} else {
|
||||
format!("searched {} time{}", searches.total, plural(searches.total))
|
||||
});
|
||||
}
|
||||
if commands.total > 0 {
|
||||
primary.push(if commands.active {
|
||||
format!(
|
||||
"running {} command{}",
|
||||
commands.total,
|
||||
plural(commands.total)
|
||||
)
|
||||
} else {
|
||||
format!("ran {} command{}", commands.total, plural(commands.total))
|
||||
});
|
||||
}
|
||||
for (name, count) in others {
|
||||
primary.push(if count.total == 1 {
|
||||
name
|
||||
} else {
|
||||
format!("{} {name}", count.total)
|
||||
});
|
||||
}
|
||||
|
||||
let mut summary = Vec::new();
|
||||
if !primary.is_empty() {
|
||||
summary.push(primary.join("・"));
|
||||
}
|
||||
if edits.total > 0 {
|
||||
summary.push(if edits.active {
|
||||
format!("editing {} file{}", edits.total, plural(edits.total))
|
||||
} else if additions > 0 || deletions > 0 {
|
||||
format!("edited +{additions}/-{deletions}")
|
||||
} else {
|
||||
format!("edited {} file{}", edits.total, plural(edits.total))
|
||||
});
|
||||
}
|
||||
if writes.total > 0 {
|
||||
summary.push(if writes.active {
|
||||
format!("writing {} file{}", writes.total, plural(writes.total))
|
||||
} else {
|
||||
format!("wrote {} file{}", writes.total, plural(writes.total))
|
||||
});
|
||||
}
|
||||
if failed > 0 {
|
||||
summary.push(format!("{failed} failed"));
|
||||
}
|
||||
if incomplete > 0 {
|
||||
summary.push(format!("{incomplete} incomplete"));
|
||||
}
|
||||
|
||||
let color = if failed > 0 {
|
||||
Color::Red
|
||||
} else {
|
||||
Color::DarkGray
|
||||
};
|
||||
ToolRenderOutput {
|
||||
lines: summary
|
||||
.into_iter()
|
||||
.map(|text| Line::from(Span::styled(text, Style::default().fg(color))))
|
||||
.collect(),
|
||||
consumed: end.saturating_sub(start).max(1),
|
||||
}
|
||||
}
|
||||
|
||||
fn single(lines: Vec<Line<'static>>) -> ToolRenderOutput {
|
||||
ToolRenderOutput { lines, consumed: 1 }
|
||||
}
|
||||
|
||||
+125
-1
@@ -344,6 +344,12 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
||||
let mut i = 0;
|
||||
while i < app.blocks.len() {
|
||||
let block = &app.blocks[i];
|
||||
if app.mode == Mode::Overview
|
||||
&& matches!(block, Block::TaskReminder { .. } | Block::Thinking(_))
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let current_selectable = block_is_selectable_text(block);
|
||||
if !first {
|
||||
// Preserve a deterministic blank-line separator when copying
|
||||
@@ -885,7 +891,10 @@ fn highlight_line_selection(line: &Line<'static>, start: usize, end: usize) -> L
|
||||
fn block_is_selectable_text(block: &Block) -> bool {
|
||||
matches!(
|
||||
block,
|
||||
Block::UserMessage { .. } | Block::SystemMessage { .. } | Block::AssistantText { .. }
|
||||
Block::UserMessage { .. }
|
||||
| Block::SystemMessage { .. }
|
||||
| Block::TaskReminder { .. }
|
||||
| Block::AssistantText { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -909,6 +918,7 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
|
||||
}
|
||||
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
|
||||
Block::SystemMessage { text } => render_system_message(lines, text, width, mode),
|
||||
Block::TaskReminder { text } => render_task_reminder(lines, text, width, mode),
|
||||
Block::Notify { message } => {
|
||||
let text = format!("[notify] {message}");
|
||||
match mode {
|
||||
@@ -1078,6 +1088,33 @@ fn render_system_message(lines: &mut Vec<Line<'static>>, text: &str, width: u16,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_task_reminder(lines: &mut Vec<Line<'static>>, text: &str, width: u16, mode: Mode) {
|
||||
match mode {
|
||||
Mode::Overview => {}
|
||||
Mode::Normal => {
|
||||
let first = text
|
||||
.lines()
|
||||
.find(|line| !line.trim().is_empty())
|
||||
.unwrap_or("");
|
||||
let summary = format!("task reminder: {first}");
|
||||
push_overview_line(lines, &summary, width, MessageKind::System, "");
|
||||
}
|
||||
Mode::Detail => {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"task reminder",
|
||||
kind_style(MessageKind::System),
|
||||
)));
|
||||
let body_style = Style::default().fg(Color::DarkGray);
|
||||
for raw in text.lines() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" ", body_style),
|
||||
Span::styled(raw.to_owned(), body_style),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_system_message(text: &str) -> (&str, &str) {
|
||||
match text.split_once('\n') {
|
||||
Some((header, body)) => (header, body.trim_start_matches('\n')),
|
||||
@@ -1944,6 +1981,7 @@ fn format_worker_event(event: &WorkerEvent) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{ActionbarNoticeLevel, ActionbarNoticeSource, App};
|
||||
use crate::block::{ToolCallBlock, ToolCallState};
|
||||
use protocol::WorkerStatus;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -2032,6 +2070,92 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_omits_task_reminders_without_leaving_a_gap() {
|
||||
let mut app = App::new("worker".to_string());
|
||||
app.mode = Mode::Overview;
|
||||
app.blocks = vec![
|
||||
Block::AssistantText {
|
||||
text: "before".to_string(),
|
||||
},
|
||||
Block::TaskReminder {
|
||||
text: "Current session steps are listed below.\nsecond line".to_string(),
|
||||
},
|
||||
Block::AssistantText {
|
||||
text: "after".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(row_texts(&app), ["before", "", "after"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_renders_task_reminder_as_one_summary_line() {
|
||||
let mut app = App::new("worker".to_string());
|
||||
app.mode = Mode::Normal;
|
||||
app.blocks = vec![Block::TaskReminder {
|
||||
text: "Current session steps are listed below.\nsecond line".to_string(),
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
row_texts(&app),
|
||||
["task reminder: Current session steps are listed below."]
|
||||
);
|
||||
}
|
||||
|
||||
fn done_tool(id: &str, name: &str, arguments: Option<&str>) -> Block {
|
||||
Block::ToolCall(ToolCallBlock {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
args_stream: String::new(),
|
||||
arguments: arguments.map(str::to_string),
|
||||
state: ToolCallState::Done {
|
||||
summary: "done".to_string(),
|
||||
output: None,
|
||||
},
|
||||
edit_snapshot: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_aggregates_tools_across_hidden_thinking() {
|
||||
let mut app = App::new("worker".to_string());
|
||||
app.mode = Mode::Overview;
|
||||
app.blocks = vec![
|
||||
done_tool("read", "Read", Some(r#"{"file_path":"a.rs"}"#)),
|
||||
finished_thinking("private reasoning"),
|
||||
done_tool("bash", "Bash", Some(r#"{"command":"cargo check"}"#)),
|
||||
done_tool(
|
||||
"edit",
|
||||
"Edit",
|
||||
Some(r#"{"old_string":"old","new_string":"new\nnext"}"#),
|
||||
),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
row_texts(&app),
|
||||
["1 file read・ran 1 command", "edited +2/-1"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_starts_a_new_activity_after_visible_output() {
|
||||
let mut app = App::new("worker".to_string());
|
||||
app.mode = Mode::Overview;
|
||||
app.blocks = vec![
|
||||
done_tool("read", "Read", None),
|
||||
Block::AssistantText {
|
||||
text: "finding".to_string(),
|
||||
},
|
||||
done_tool("bash", "Bash", None),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
row_texts(&app),
|
||||
["1 file read", "", "finding", "", "ran 1 command"]
|
||||
);
|
||||
}
|
||||
|
||||
fn finished_thinking(text: &str) -> Block {
|
||||
Block::Thinking(ThinkingBlock {
|
||||
text: text.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user