diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 473b7a91..fd632bb9 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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] diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs index 9ee8bb24..a2112678 100644 --- a/crates/tui/src/block.rs +++ b/crates/tui/src/block.rs @@ -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. diff --git a/crates/tui/src/tool.rs b/crates/tui/src/tool.rs index e74fc8b1..c5cc3850 100644 --- a/crates/tui/src/tool.rs +++ b/crates/tui/src/tool.rs @@ -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::::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::(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>) -> ToolRenderOutput { ToolRenderOutput { lines, consumed: 1 } } diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index e415f717..5f0ac086 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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>, 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>, text: &str, width: u16, } } +fn render_task_reminder(lines: &mut Vec>, 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(),