diff --git a/src/tui/app.rs b/src/tui/app.rs index f3914a03..cb0e1d09 100644 --- a/src/tui/app.rs +++ b/src/tui/app.rs @@ -123,6 +123,8 @@ pub enum Update { intent: Option>, backgrounded: bool, }, + /// ACP parent identity; absence of this update preserves the relationship. + ToolParent { id: String, parent: Option }, /// Agent-advertised slash commands for one session. AvailableCommands { session_id: String, @@ -478,6 +480,9 @@ pub enum ComposeView { /// A model-visible tool call and, for compose, the program running inside it. pub struct ToolCall { + /// Opaque ACP identity, independent of the tool name and Runlet source. + pub parent_id: Option, + pub child_page: usize, pub id: String, pub title: String, pub kind: ToolKind, @@ -676,6 +681,7 @@ pub enum Block { } pub(super) struct CachedTranscriptImage { + pub block: Option, pub source: usize, pub row: usize, pub destination: Option, @@ -831,6 +837,11 @@ pub struct App { /// Session currently associated with the ordered runtime side channel. runtime_session_id: Option, pub blocks: Vec, + tool_indices: HashMap, + /// Reverse ACP edges also retain children waiting for an unseen parent. + tool_dependents: HashMap>, + pub(super) tool_owners: HashMap, + pub(super) grouped_tools: HashMap>, pub(super) transcript_cache: Vec>, pub(super) transcript_revisions: Vec, pub(super) transcript_dirty: BTreeSet, @@ -1090,6 +1101,10 @@ impl App { session_id: None, runtime_session_id: None, blocks: Vec::new(), + tool_indices: HashMap::new(), + tool_dependents: HashMap::new(), + tool_owners: HashMap::new(), + grouped_tools: HashMap::new(), transcript_cache: Vec::new(), transcript_revisions: Vec::new(), transcript_dirty: BTreeSet::new(), @@ -1248,6 +1263,10 @@ impl App { if dynamic { self.transcript_dynamic.insert(index); } + if let Some(id) = &tool_id { + self.tool_indices.insert(id.clone(), index); + self.refresh_tool_groups(index); + } if tool_id.as_deref() == self.focused_call_id.as_deref() || tool_id.is_some() && self.focused_call_id.is_none() { @@ -1272,6 +1291,9 @@ impl App { } fn mark_block_dirty(&mut self, index: usize) { + if let Some(&owner) = self.tool_owners.get(&index) { + self.mark_block_dirty(owner); + } if let Some(revision) = self.transcript_revisions.get_mut(index) { self.next_transcript_revision = self.next_transcript_revision.wrapping_add(1); *revision = self.next_transcript_revision; @@ -1302,6 +1324,9 @@ impl App { if Self::block_is_dynamic(&self.blocks[index]) { self.transcript_dynamic.insert(index); } + if let Block::Tool(call) = &self.blocks[index] { + self.tool_indices.insert(call.id.clone(), index); + } if matches!(self.blocks[index], Block::Tool(_)) && (self.focused_call_id.is_none() || matches!( @@ -1347,10 +1372,7 @@ impl App { } fn focus_call_by_id(&mut self, id: String) { - let index = self - .blocks - .iter() - .rposition(|block| matches!(block, Block::Tool(call) if call.id == id)); + let index = self.call_index(&id); self.focused_call_id = Some(id); self.set_focus_index(index); } @@ -1940,11 +1962,17 @@ impl App { let turn_millis = self.stop_turn_timer(); self.phase = Phase::Idle; self.compacting = false; + let inherited_background: HashSet<_> = self + .tool_indices + .values() + .filter_map(|&child| self.has_background_ancestor(child).then_some(child)) + .collect(); let mut finished = Vec::new(); for (index, block) in self.blocks.iter_mut().enumerate() { if let Block::Tool(call) = block && call.running() && !call.backgrounded + && !inherited_background.contains(&index) { call.status = if successful { ToolCallStatus::Completed @@ -2098,6 +2126,30 @@ impl App { Update::AgentThought { id, text, append } => { self.apply_message(id, text, Vec::new(), append, MessageRole::Thought); } + Update::ToolParent { id, parent } => { + if let Some(index) = self.call_index(&id) + && let Block::Tool(call) = &mut self.blocks[index] + && call.parent_id != parent + { + let old = std::mem::replace(&mut call.parent_id, parent.clone()); + if let Some(old) = old + && let Some(children) = self.tool_dependents.get_mut(&old) + { + children.remove(&index); + if children.is_empty() { + self.tool_dependents.remove(&old); + } + } + if let Some(parent) = parent { + self.tool_dependents + .entry(parent) + .or_default() + .insert(index); + } + self.mark_block_dirty(index); + self.refresh_tool_groups(index); + } + } Update::ToolStarted { id, title, @@ -2106,10 +2158,19 @@ impl App { backgrounded, } => { self.close_thought(); - self.prepare_focused_call(id.clone()); + // A late parent attaches an already visible child; do not steal + // that child's selection just because its owner arrived later. + if self + .focus_call() + .is_none_or(|call| call.parent_id.as_deref() != Some(id.as_str())) + { + self.prepare_focused_call(id.clone()); + } let expanded = title == agentkit_tool_compose::COMPOSE_TOOL_NAME; self.push_block(Block::Tool(Box::new(ToolCall { id, + parent_id: None, + child_page: 0, title, kind, status: ToolCallStatus::Pending, @@ -2181,6 +2242,7 @@ impl App { let Some(call) = self.call_mut(&id) else { return; }; + let was_compose = call.is_compose(); let was_running = call.running(); if let Some(title) = title { if title == agentkit_tool_compose::COMPOSE_TOOL_NAME @@ -2228,10 +2290,14 @@ impl App { call.finalize_terminal_state(); } } + let identity_changed = was_compose != call.is_compose(); let completed_background = was_running && !call.running() && call.backgrounded; // Autonomous output after a detached call starts a new agent stream. self.agent_stream_sealed |= completed_background; if let Some(index) = self.call_index(&id) { + if identity_changed { + self.refresh_tool_groups(index); + } self.mark_block_dirty(index); self.reclassify_dynamic(index); } @@ -2610,32 +2676,197 @@ impl App { } fn call_index(&self, id: &str) -> Option { - self.blocks - .iter() - .rposition(|block| matches!(block, Block::Tool(call) if call.id == id)) + self.tool_indices.get(id).copied() } fn call_mut(&mut self, id: &str) -> Option<&mut ToolCall> { - self.find_call_mut(|call| call.id == id) + let index = self.call_index(id)?; + self.mark_block_dirty(index); + match &mut self.blocks[index] { + Block::Tool(call) => Some(call), + _ => None, + } } - fn find_call_mut(&mut self, matches: impl Fn(&ToolCall) -> bool) -> Option<&mut ToolCall> { - let (index, call) = - self.blocks - .iter_mut() - .enumerate() - .rev() - .find_map(|(index, block)| match block { - Block::Tool(call) if matches(call) => Some((index, call)), - _ => None, - })?; - // Retain the matched variant while updating only cache metadata. - if let Some(revision) = self.transcript_revisions.get_mut(index) { - self.next_transcript_revision = self.next_transcript_revision.wrapping_add(1); - *revision = self.next_transcript_revision; - self.transcript_dirty.insert(index); + /// Resolve explicit ACP edges, memoizing paths only for this affected update. + /// Missing ancestors and cycles leave calls visible without a displayed owner. + fn compose_root( + &self, + index: usize, + resolved: &mut HashMap>, + ) -> Option { + let mut path = HashSet::new(); + let mut cursor = index; + let root = loop { + if let Some(root) = resolved.get(&cursor) { + break *root; + } + if !path.insert(cursor) { + break None; + } + let Block::Tool(call) = &self.blocks[cursor] else { + break None; + }; + let Some(parent) = &call.parent_id else { + break call.is_compose().then_some(cursor); + }; + let Some(&parent) = self.tool_indices.get(parent) else { + break None; + }; + cursor = parent; + }; + for index in path { + resolved.insert(index, root); + } + root + } + + /// Only descendants of an arriving/reparented/renamed call can change owner. + /// Reverse edges include unresolved parents, so arrivals attach existing orphans. + fn refresh_tool_groups(&mut self, index: usize) { + let mut affected = BTreeSet::new(); + let mut pending = vec![index]; + while let Some(index) = pending.pop() { + if !affected.insert(index) { + continue; + } + if let Block::Tool(call) = &self.blocks[index] + && let Some(children) = self.tool_dependents.get(&call.id) + { + pending.extend(children.iter().copied()); + } + } + let mut resolved = HashMap::new(); + let mut changed = BTreeSet::new(); + for index in affected { + let owner = self + .compose_root(index, &mut resolved) + .filter(|&root| root != index); + let previous = self.tool_owners.get(&index).copied(); + if owner == previous { + continue; + } + changed.insert(index); + if let Some(previous) = previous { + self.tool_owners.remove(&index); + if let Some(children) = self.grouped_tools.get_mut(&previous) { + if let Ok(position) = children.binary_search(&index) { + children.remove(position); + } + if children.is_empty() { + self.grouped_tools.remove(&previous); + } + } + changed.insert(previous); + } + if let Some(owner) = owner { + // Child creation can precede its ACP parent patch and implicitly + // fold the previous card. Restore the running group's default + // expansion, but never override an explicit user choice. + if let Block::Tool(call) = &mut self.blocks[owner] + && call.running() + && !call.expansion_explicit + { + call.expanded = true; + } + self.tool_owners.insert(index, owner); + let children = self.grouped_tools.entry(owner).or_default(); + if let Err(position) = children.binary_search(&index) { + children.insert(position, index); + } + changed.insert(owner); + } + } + if let Some(focus) = self.transcript_focus_index + && let Some(&owner) = self.tool_owners.get(&focus) + && changed.contains(&focus) + && let Ok(position) = self.grouped_tools[&owner].binary_search(&focus) + && let Block::Tool(call) = &mut self.blocks[owner] + { + call.child_page = position / 32; + // A collapsed group (or its script view) cannot display the focused + // child. Keep keyboard actions on the visible owner instead. + if !call.expanded || call.compose_view != ComposeView::Output { + let id = call.id.clone(); + self.focus_call_by_id(id); + } + } + if !changed.is_empty() { + self.clear_transcript_interaction(); + } + for index in changed { + self.mark_block_dirty(index); } - Some(call) + } + + fn has_background_ancestor(&self, index: usize) -> bool { + let mut cursor = index; + let mut visited = HashSet::new(); + while visited.insert(cursor) { + let Block::Tool(call) = &self.blocks[cursor] else { + break; + }; + if call.backgrounded { + return true; + } + let Some(parent) = call + .parent_id + .as_ref() + .and_then(|id| self.tool_indices.get(id)) + else { + break; + }; + cursor = *parent; + } + false + } + + pub(super) fn has_grouped_tools(&self, id: &str) -> bool { + self.call_index(id) + .is_some_and(|index| self.grouped_tools.contains_key(&index)) + } + + pub(super) fn child_window(&self, owner: usize) -> (&[usize], usize, usize) { + const PAGE: usize = 32; + let Some(children) = self.grouped_tools.get(&owner) else { + return (&[], 0, 0); + }; + let Block::Tool(call) = &self.blocks[owner] else { + return (&[], 0, 0); + }; + let start = call.child_page.min((children.len() - 1) / PAGE) * PAGE; + ( + &children[start..(start + PAGE).min(children.len())], + start, + children.len(), + ) + } + + fn page_children(&mut self, forward: bool) -> bool { + let Some(focus) = self.transcript_focus_index else { + return false; + }; + let owner = self.tool_owners.get(&focus).copied().unwrap_or(focus); + let Some(children) = self.grouped_tools.get(&owner) else { + return false; + }; + let last = (children.len() - 1) / 32; + let Block::Tool(call) = &mut self.blocks[owner] else { + return false; + }; + call.child_page = if forward { + (call.child_page + 1).min(last) + } else { + call.child_page.saturating_sub(1) + }; + call.expanded = true; + call.compose_view = ComposeView::Output; + call.expansion_explicit = true; + let id = call.id.clone(); + self.focus_call_by_id(id); + self.mark_block_dirty(owner); + self.clear_transcript_interaction(); + true } fn close_thought(&mut self) { @@ -2671,6 +2902,10 @@ impl App { self.command_completion_query = None; self.command_completion_dismissed = None; self.blocks.clear(); + self.tool_indices.clear(); + self.tool_dependents.clear(); + self.tool_owners.clear(); + self.grouped_tools.clear(); self.transcript_cache.clear(); self.transcript_revisions.clear(); self.transcript_dirty.clear(); @@ -3657,6 +3892,12 @@ impl App { self.file_picker = None; return Action::Quit; } + if key.modifiers == KeyModifiers::ALT + && matches!(key.code, KeyCode::PageUp | KeyCode::PageDown) + && self.page_children(key.code == KeyCode::PageDown) + { + return Action::None; + } // Queue focus owns composer keys, not global task, view, or copy actions. // Ctrl+K is global only when it cancels background work; otherwise it // must not fall through and delete text from the parked composer. @@ -4305,7 +4546,8 @@ impl App { let selection = self.selection?; let (start, end) = selection.ordered(); let mut lines: Vec = Vec::new(); - let mut pending: Option<(Option<(usize, usize)>, String)> = None; + type LogicalLine<'a> = (usize, Option<&'a str>, usize); + let mut pending: Option<(Option>, String)> = None; for line in start.0..=end.0 { let Some((block, row)) = self.transcript_row(line) else { if let Some((_, text)) = pending.take() { @@ -4328,7 +4570,9 @@ impl App { fragment = column_slice(fragment, 2 - from, usize::MAX); } let fragment = fragment.trim_end().to_string(); - let logical = row.1.2.map(|index| (block, index)); + // Grouped rows share the displayed cache block, not their source. + // Keep each canonical call's logical lines distinct when copying. + let logical = row.1.2.map(|index| (block, row.1.0.as_deref(), index)); match &mut pending { Some((Some(previous), joined)) if logical == Some(*previous) => { let fragment = fragment.trim_start(); @@ -9268,4 +9512,242 @@ mod tests { app.handle_mouse(wheel(MouseEventKind::ScrollDown, 30, 8)); assert_eq!(app.agents_scroll(), 0); } + + mod canonical_group_tests { + use super::super::*; + + fn app() -> App { + App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ) + } + fn start(app: &mut App, id: &str, compose: bool, backgrounded: bool) { + app.apply(Update::ToolStarted { + id: id.into(), + title: if compose { "compose" } else { "shell" }.into(), + kind: ToolKind::default(), + script: None, + backgrounded, + }); + } + fn parent(app: &mut App, id: &str, owner: &str) { + app.apply(Update::ToolParent { + id: id.into(), + parent: Some(owner.into()), + }); + } + fn patch(app: &mut App, id: &str, status: Option, text: &str) { + app.apply(Update::ToolPatched { + id: id.into(), + title: None, + kind: None, + status, + script: None, + output: Some(vec![text.into()]), + images: None, + append_output: true, + intent: None, + backgrounded: false, + }); + } + fn call<'a>(app: &'a App, id: &str) -> &'a ToolCall { + let Block::Tool(call) = &app.blocks[app.call_index(id).unwrap()] else { + panic!("tool") + }; + call + } + + #[test] + fn canonical_interleaving_orphans_cycles_and_sessions() { + let mut app = app(); + start(&mut app, "arbitrary child", false, false); + parent(&mut app, "arbitrary child", "root-b"); + assert!(app.tool_owners.is_empty()); + start(&mut app, "root-a", true, false); + start(&mut app, "root-b", false, false); + assert!(app.tool_owners.is_empty()); + app.apply(Update::ToolPatched { + id: "root-b".into(), + title: Some("compose".into()), + kind: None, + status: None, + script: None, + output: None, + images: None, + append_output: false, + intent: None, + backgrounded: false, + }); + assert_eq!(app.tool_owners.get(&0), Some(&2)); + start(&mut app, "root-b/looks-related", false, false); + parent(&mut app, "root-b/looks-related", "root-a"); + assert_eq!(app.grouped_tools[&1], vec![3]); + assert_eq!(app.grouped_tools[&2], vec![0]); + parent(&mut app, "root-b", "arbitrary child"); + assert!(!app.tool_owners.contains_key(&0)); + assert!(!app.tool_owners.contains_key(&2)); + parent(&mut app, "root-a", "root-a"); + assert!(app.tool_owners.is_empty()); + app.start_session("new".into()); + assert!(app.tool_indices.is_empty()); + assert!(app.grouped_tools.is_empty()); + start(&mut app, "arbitrary child", false, false); + assert!(call(&app, "arbitrary child").parent_id.is_none()); + } + + #[test] + fn canonical_terminal_parent_does_not_finish_child_and_patches_keep_identity() { + let mut app = app(); + app.phase = Phase::Working; + start(&mut app, "root", true, true); + start(&mut app, "child", false, false); + parent(&mut app, "child", "root"); + patch( + &mut app, + "root", + Some(ToolCallStatus::Completed), + "detached", + ); + assert!(call(&app, "child").running()); + app.finish_turn_with_outcome(false, None); + assert!(call(&app, "child").running()); + patch(&mut app, "child", Some(ToolCallStatus::Failed), "cancelled"); + patch(&mut app, "child", None, "late output"); + let child = call(&app, "child"); + assert_eq!(child.parent_id.as_deref(), Some("root")); + assert_eq!(child.status, ToolCallStatus::Failed); + assert_eq!(child.output, ["cancelled", "late output"]); + assert!(!child.backgrounded); + assert!(app.transcript_dirty.contains(&0)); + } + + #[test] + fn canonical_reparenting_updates_only_the_related_tree() { + let mut app = app(); + start(&mut app, "leaf", false, false); + parent(&mut app, "leaf", "middle"); + start(&mut app, "a", true, false); + start(&mut app, "b", true, false); + start(&mut app, "middle", false, false); + parent(&mut app, "middle", "a"); + assert_eq!(app.grouped_tools[&1], vec![0, 3]); + parent(&mut app, "middle", "b"); + assert!(!app.grouped_tools.contains_key(&1)); + assert_eq!(app.grouped_tools[&2], vec![0, 3]); + parent(&mut app, "a", "b"); + parent(&mut app, "b", "a"); + assert!(app.tool_owners.is_empty()); + app.apply(Update::ToolParent { + id: "a".into(), + parent: None, + }); + assert_eq!(app.grouped_tools[&1], vec![0, 2, 3]); + parent(&mut app, "leaf", "missing"); + assert!(!app.tool_owners.contains_key(&0)); + assert_eq!(app.grouped_tools[&1], vec![2, 3]); + start(&mut app, "missing", true, false); + assert_eq!(app.grouped_tools[&4], vec![0]); + assert_eq!(app.grouped_tools[&1], vec![2, 3]); + } + + /// Opt-in benchmark at the real App update boundary. No timing assertions + /// or production instrumentation: compare scaling across transcript sizes. + #[test] + #[ignore = "manual canonical ingestion benchmark; run with --ignored --nocapture"] + fn canonical_ingestion_benchmark() { + for count in [1_000, 2_000, 4_000, 8_000, 16_000] { + let mut app = app(); + start(&mut app, "root", true, false); + let started = Instant::now(); + for i in 0..count { + let id = format!("child-{i}"); + app.apply(Update::ToolPatched { + id: id.clone(), + title: Some("shell".into()), + kind: None, + status: None, + script: None, + output: None, + images: None, + append_output: false, + intent: None, + backgrounded: false, + }); + parent(&mut app, &id, "root"); + } + let elapsed = started.elapsed(); + std::hint::black_box(&app); + eprintln!( + "canonical ingestion: {count} calls, {elapsed:?}, {} ns/call", + elapsed.as_nanos() / count + ); + } + } + + #[test] + fn canonical_automatic_focus_preserves_word_movement_and_separate_paging() { + let mut app = app(); + app.paste("first second"); + start(&mut app, "root", true, false); + for i in 0..70 { + let id = format!("child {i}"); + start(&mut app, &id, false, false); + parent(&mut app, &id, "root"); + } + assert_eq!(app.focus_call().unwrap().id, "child 69"); + let page = call(&app, "root").child_page; + assert!(page > 0); + app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT)); + assert_eq!(app.editor.cursor(), 6); + assert_eq!(call(&app, "root").child_page, page); + app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::ALT)); + assert_eq!(app.editor.cursor(), 12); + assert_eq!(call(&app, "root").child_page, page); + app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::ALT)); + assert_eq!(call(&app, "root").child_page, page - 1); + assert_eq!(app.editor.cursor(), 12); + app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::ALT)); + assert_eq!(call(&app, "root").child_page, page); + assert_eq!(app.editor.cursor(), 12); + assert_eq!(app.editor.text(), "first second"); + } + + #[test] + fn canonical_nested_background_and_bounded_navigation() { + let mut app = app(); + app.phase = Phase::Working; + start(&mut app, "root", true, false); + start(&mut app, "nested", true, true); + parent(&mut app, "nested", "root"); + for i in 0..70 { + let id = format!("child {i}"); + start(&mut app, &id, false, false); + parent(&mut app, &id, "nested"); + } + assert!(app.child_window(0).0.len() <= 32); + app.finish_turn_with_outcome(false, None); + assert!(call(&app, "child 69").running()); + app.focus_call_by_id("root".into()); + for _ in 0..3 { + assert!(app.page_children(false)); + } + let mut seen = Vec::new(); + for _ in 0..3 { + seen.extend_from_slice(app.child_window(0).0); + assert!(app.page_children(true)); + } + assert_eq!(seen, app.grouped_tools[&0]); + app.toggle_output("child 0"); + assert!(call(&app, "child 0").expanded); + app.apply(Update::ToolParent { + id: "child 0".into(), + parent: None, + }); + assert!(!app.tool_owners.contains_key(&2)); + assert!(call(&app, "child 0").expanded); + } + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 1ecad505..3fd41f8d 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -67,7 +67,7 @@ use wire::{ }; use crate::{ - events::{self, EVENTS_ENV}, + events::{self, EVENTS_ENV, RuntimeEvent}, protocols::acp::{ FileSearchRequest, MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, model_switch, }, @@ -1434,20 +1434,16 @@ pub async fn run_with_reasoning_effort_and_openrouter_key( let stderr_task = tokio::spawn(async move { let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { - let update = match events::parse(&line) { - Some(event) => Update::Runtime(event), - None if line.starts_with("A2A listening on ") => { - Update::A2aAddress(line.trim_start_matches("A2A listening on ").to_string()) - } - None => { - if let Ok(mut recent) = recorder.lock() { - recent.push(line.clone()); - let extra = recent.len().saturating_sub(FAILURE_LINES); - recent.drain(..extra); - } - Update::Log(line) - } + let Some(update) = stderr_update(line) else { + continue; }; + if let Update::Log(line) = &update + && let Ok(mut recent) = recorder.lock() + { + recent.push(line.clone()); + let extra = recent.len().saturating_sub(FAILURE_LINES); + recent.drain(..extra); + } if diagnostics.send(QueuedUpdate::global(update)).is_err() { return; } @@ -2759,6 +2755,23 @@ impl std::fmt::Display for Failure { impl std::error::Error for Failure {} +/// ACP owns tool cards, but the stderr transport lease and general diagnostics +/// still drive runtime availability, subagent status, storage, and failure reports. +fn stderr_update(line: String) -> Option { + match events::parse(&line) { + Some( + RuntimeEvent::ChildStarted { .. } + | RuntimeEvent::ChildFinished { .. } + | RuntimeEvent::RunletProgress { .. }, + ) => None, + Some(event) => Some(Update::Runtime(event)), + None if line.starts_with("A2A listening on ") => Some(Update::A2aAddress( + line.trim_start_matches("A2A listening on ").to_string(), + )), + None => Some(Update::Log(line)), + } +} + /// Explains an agent exit, quoting the last thing it said. async fn died( status: Option, @@ -3431,6 +3444,21 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { MessageKind::Thought, ), SessionUpdate::ToolCallUpdate(update) => { + let parent = match &update.meta { + MaybeUndefined::Value(meta) => meta.get("kit/parentToolCallId").and_then(|value| { + if value.is_null() { + Some(None) + } else { + value.as_str().map(|id| Some(id.to_owned())) + } + }), + MaybeUndefined::Null => Some(None), + MaybeUndefined::Undefined => None, + }; + let parent_update = parent.map(|parent| Update::ToolParent { + id: update.tool_call_id.to_string(), + parent, + }); let images = match &update.content { MaybeUndefined::Value(content) => Some(tool_images_of(content)), MaybeUndefined::Null => Some(Vec::new()), @@ -3439,7 +3467,26 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { MaybeUndefined::Undefined => None, }; let output = match &update.content { - MaybeUndefined::Value(content) => Some(output_of(Some(content))), + MaybeUndefined::Value(content) => { + let text = output_of(Some(content)); + // The TUI does not replay terminal streams yet. A parallel raw + // result still provides useful output for terminal-only content. + Some( + if text.is_empty() + && content + .iter() + .any(|item| matches!(item, ToolCallContent::Terminal(_))) + { + update + .raw_output + .value() + .map(raw_output_lines) + .unwrap_or_default() + } else { + text + }, + ) + } MaybeUndefined::Null => Some(Vec::new()), MaybeUndefined::Undefined => match &update.raw_output { MaybeUndefined::Value(output) => Some(raw_output_lines(output)), @@ -3463,7 +3510,7 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { .iter() .any(|line| line.contains("is now running in the background")) }); - vec![Update::ToolPatched { + let mut updates = vec![Update::ToolPatched { id: update.tool_call_id.to_string(), title: match update.title { MaybeUndefined::Value(title) => Some(title), @@ -3486,7 +3533,9 @@ fn translate(notification: UpdateSessionNotification) -> (String, Vec) { append_output: false, intent: None, backgrounded, - }] + }]; + updates.extend(parent_update); + updates } SessionUpdate::ToolCallContentChunk(chunk) => { let images = tool_images_of(std::slice::from_ref(&chunk.content)); @@ -3968,6 +4017,86 @@ mod tests { .collect() } + #[test] + fn stderr_filter_preserves_transport_recovery_and_general_diagnostics() { + use super::stderr_update; + use crate::events::{self, RuntimeEvent}; + + let line = |event: RuntimeEvent| { + format!( + "{}{}", + events::EVENT_MARKER, + serde_json::to_string(&event).unwrap() + ) + }; + let mut app = App::new( + PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ); + let heartbeat = || line(RuntimeEvent::RunletTransport { available: true }); + app.apply(stderr_update(heartbeat()).unwrap()); + assert!(!app.runtime_unavailable()); + app.progress_tick_at(std::time::Instant::now() + crate::runlet_progress::transport::LEASE); + assert!(app.runtime_unavailable()); + app.apply(stderr_update(heartbeat()).unwrap()); + assert!( + !app.runtime_unavailable(), + "stderr heartbeats must recover the status lease" + ); + app.apply(stderr_update(line(RuntimeEvent::RunletTransport { available: false })).unwrap()); + assert!(app.runtime_unavailable()); + for event in [ + RuntimeEvent::SessionStarted { + session_id: "session".into(), + }, + RuntimeEvent::StorageStatus { + pending: true, + exhausted: false, + }, + ] { + assert!( + matches!(stderr_update(line(event.clone())), Some(Update::Runtime(actual)) if actual == event) + ); + } + for event in [ + RuntimeEvent::ChildStarted { + call: "child".into(), + tool: "shell".into(), + summary: "running".into(), + at: 1, + }, + RuntimeEvent::ChildFinished { + call: "child".into(), + tool: "shell".into(), + ok: true, + summary: "done".into(), + millis: 1, + }, + RuntimeEvent::RunletProgress { + progress: crate::runlet_progress::Progress { + owner: "root".into(), + incarnation: 1, + sequence: 1, + change: crate::runlet_progress::Change::Finished { complete: true }, + }, + }, + ] { + assert!(stderr_update(line(event)).is_none()); + } + assert!( + matches!(stderr_update("ordinary diagnostic".into()), Some(Update::Log(line)) if line == "ordinary diagnostic") + ); + assert!( + matches!(stderr_update("A2A listening on localhost:1234".into()), Some(Update::A2aAddress(address)) if address == "localhost:1234") + ); + let malformed = format!("{}not json", events::EVENT_MARKER); + assert!( + matches!(stderr_update(malformed.clone()), Some(Update::Log(line)) if line == malformed) + ); + } + #[test] fn only_authentication_failures_enter_login_recovery() { let methods = [ diff --git a/src/tui/ui.rs b/src/tui/ui.rs index e57e7fe8..8fa92869 100644 --- a/src/tui/ui.rs +++ b/src/tui/ui.rs @@ -977,6 +977,10 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti if span_start >= end { break; } + if app.transcript_prefixes[block_index + 1] == span_start { + block_index += 1; + continue; + } let separator_rows = usize::from(span_start > 0); if separator_rows > 0 && offset <= span_start && span_start < end { materialize(&separator); @@ -997,7 +1001,7 @@ fn draw_transcript(frame: &mut Frame<'_>, app: &mut App, images: &mut ImageRunti if image_start < end && image_end > offset { let y = image_start as isize - offset as isize; visible_images.push(( - block_index, + placement.block.unwrap_or(block_index), placement.source, placement.destination.clone(), y.clamp(i16::MIN as isize, i16::MAX as isize) as i16, @@ -1153,6 +1157,12 @@ fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime } app.transcript_dirty .extend(app.transcript_dynamic.iter().copied()); + let animated_owners: std::collections::BTreeSet<_> = app + .transcript_dynamic + .iter() + .filter_map(|index| app.tool_owners.get(index).copied()) + .collect(); + app.transcript_dirty.extend(animated_owners.iter().copied()); let dirty = std::mem::take(&mut app.transcript_dirty); let mut first_changed_count = app.blocks.len(); for block_index in dirty { @@ -1164,6 +1174,7 @@ fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime let revision = app.transcript_revisions[block_index]; if !width_changed && !dynamic + && !animated_owners.contains(&block_index) && app.transcript_cache[block_index] .as_ref() .is_some_and(|cached| cached.revision == revision) @@ -1195,8 +1206,9 @@ fn refresh_transcript_cache_with_images(app: &mut App, images: &mut ImageRuntime let rows = app.transcript_cache[index] .as_ref() .map_or(0, |cached| cached.rows.len()); - app.transcript_prefixes[index + 1] = - app.transcript_prefixes[index] + rows + usize::from(app.transcript_prefixes[index] > 0); + app.transcript_prefixes[index + 1] = app.transcript_prefixes[index] + + rows + + usize::from(rows > 0 && app.transcript_prefixes[index] > 0); } if layout_changed { app.clear_transcript_interaction(); @@ -1235,6 +1247,7 @@ fn user_block_rows( ) })); placements.push(CachedTranscriptImage { + block: None, source, row, destination: None, @@ -1290,6 +1303,7 @@ fn agent_block_rows( ) })); placements.push(CachedTranscriptImage { + block: None, source: 0, row, destination: Some(destination), @@ -1366,6 +1380,7 @@ fn agent_parts_rows( ) })); placements.push(CachedTranscriptImage { + block: None, source, row, destination: None, @@ -1377,11 +1392,70 @@ fn agent_parts_rows( (rows, placements) } +/// Canonical children keep their own selection tags and image sources. Only a +/// bounded page is materialized, before wrapping rows or reserving image cells. fn transcript_block_rows( app: &App, block_index: usize, width: usize, reserve_images: bool, +) -> (Vec, Vec) { + if app.tool_owners.contains_key(&block_index) { + return (Vec::new(), Vec::new()); + } + let (mut rows, mut images) = + single_transcript_block_rows(app, block_index, width, reserve_images); + let Block::Tool(call) = &app.blocks[block_index] else { + return (rows, images); + }; + let (children, start, total) = app.child_window(block_index); + if total == 0 { + return (rows, images); + } + let show = call.expanded && call.compose_view == ComposeView::Output; + let label = if show { + format!( + " ↳ calls {}–{} of {total} · alt+PgUp/PgDn pages", + start + 1, + start + children.len() + ) + } else { + format!(" ↳ {total} calls · expand to view") + }; + rows.extend(wrap_linked_tagged( + &[( + LinkedLine::plain(Line::from(Span::styled(label, theme::dim()))), + (Some(call.id.clone()), None, None), + )], + width, + )); + if show { + for &child in children { + let (mut child_rows, child_images) = single_transcript_block_rows( + app, + child, + width.saturating_sub(3).max(1), + reserve_images, + ); + images.extend(child_images.into_iter().map(|mut image| { + image.block = Some(child); + image.row += rows.len(); + image + })); + for row in &mut child_rows { + row.0.spans.insert(0, Span::styled(" ", theme::faint())); + } + rows.extend(child_rows); + } + } + (rows, images) +} + +fn single_transcript_block_rows( + app: &App, + block_index: usize, + width: usize, + reserve_images: bool, ) -> (Vec, Vec) { let block = &app.blocks[block_index]; let (block_lines, call) = match block { @@ -1474,6 +1548,7 @@ fn transcript_block_rows( ) })); placements.push(CachedTranscriptImage { + block: None, source, row, destination: None, @@ -1542,7 +1617,12 @@ fn tool_lines(app: &App, call: &ToolCall, active: bool) -> Vec> { let mut lines = vec![Line::from(tool_header(app, call, active))]; let compose = call.is_compose(); if call.running() { - if compose && !call.script.is_empty() { + if compose + && ((call.expanded && call.compose_view == ComposeView::Script) + || (call.parent_id.is_none() + && !app.has_grouped_tools(&call.id) + && !call.script.is_empty())) + { lines.extend(script_lines(call)); } else if let Some(child) = call.children.iter().rev().find(|child| child.running()) { lines.push(Line::from(vec![ @@ -1550,6 +1630,9 @@ fn tool_lines(app: &App, call: &ToolCall, active: bool) -> Vec> { Span::styled(child.summary.clone(), theme::dim()), ])); } + if call.expanded { + lines.extend(output_lines(call)); + } return lines; } if !compose { @@ -5315,4 +5398,281 @@ mod tests { assert!(frame.contains("⏎ send ⇧⏎ newline ^l log ^c quit")); assert!(frame.contains("message kit")); } + + mod canonical_group_tests { + use super::super::*; + use crate::tui::{app::Update, translate_for_session}; + use agent_client_protocol::schema::v2::{ + self as wire, SessionUpdate, UpdateSessionNotification, + }; + use serde_json::json; + + fn app() -> App { + App::new( + std::path::PathBuf::from("/tmp"), + "provider".into(), + "model".into(), + "a2a".into(), + ) + } + fn patch(app: &mut App, value: serde_json::Value) { + let patch: wire::ToolCallUpdate = serde_json::from_value(value).unwrap(); + for update in translate_for_session( + UpdateSessionNotification::new("session", SessionUpdate::ToolCallUpdate(patch)), + "session", + ) { + app.apply(update); + } + } + fn text(rows: &[CachedTranscriptRow]) -> String { + rows.iter() + .map(|row| { + row.0 + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::() + }) + .collect::>() + .join("\n") + } + fn refresh(app: &mut App) { + refresh_transcript_cache_with_images(app, &mut ImageRuntime::disabled(), 100); + } + + #[test] + fn canonical_first_child_restores_implicit_expansion_but_not_explicit_collapse() { + for explicitly_collapsed in [false, true] { + let mut app = app(); + patch(&mut app, json!({"toolCallId":"root", "title":"compose"})); + if explicitly_collapsed { + app.toggle_output("root"); + } + // Exercise wire translation and its ordered ToolPatched / ToolParent + // updates, including creation of the previously unseen child. + patch( + &mut app, + json!({"toolCallId":"child", "title":"first canonical child", "_meta":{"kit/parentToolCallId":"root"}}), + ); + refresh(&mut app); + let root = &app.transcript_cache[0].as_ref().unwrap().rows; + assert_eq!( + text(root).contains("first canonical child"), + !explicitly_collapsed + ); + assert!(app.transcript_cache[1].as_ref().unwrap().rows.is_empty()); + assert!(app.transcript_call_is_focused(if explicitly_collapsed { 0 } else { 1 })); + let Block::Tool(call) = &app.blocks[0] else { + panic!("root") + }; + assert_eq!(call.expanded, !explicitly_collapsed); + assert_eq!(call.expansion_explicit, explicitly_collapsed); + if explicitly_collapsed { + app.toggle_output("root"); + refresh(&mut app); + assert!( + text(&app.transcript_cache[0].as_ref().unwrap().rows) + .contains("first canonical child") + ); + } + } + } + + #[test] + fn canonical_rows_attach_late_preserve_tags_images_and_absent_metadata() { + let mut app = app(); + patch( + &mut app, + json!({"toolCallId":"child", "title":"unique-child", "_meta":{"kit/parentToolCallId":"root"}}), + ); + refresh(&mut app); + assert!(text(&app.transcript_cache[0].as_ref().unwrap().rows).contains("unique-child")); + patch( + &mut app, + json!({"toolCallId":"unrelated", "title":"other-compose"}), + ); + patch(&mut app, json!({"toolCallId":"root", "title":"compose"})); + app.toggle_output("child"); + app.apply(Update::ToolPatched { + id: "child".into(), + title: None, + kind: None, + status: None, + script: None, + output: Some(vec!["live content".into()]), + images: Some(vec![ + crate::tui::app::UserImage::new("AQID".into(), "image/png".into(), 0).unwrap(), + ]), + append_output: false, + intent: None, + backgrounded: false, + }); + refresh(&mut app); + assert!(app.transcript_cache[0].as_ref().unwrap().rows.is_empty()); + assert_eq!(app.transcript_prefixes[0], app.transcript_prefixes[1]); + let root = &app.transcript_cache[2].as_ref().unwrap().rows; + assert!(text(root).contains("unique-child")); + assert!(text(root).contains("live content")); + assert!(root.iter().any(|row| row.1.0.as_deref() == Some("child"))); + let (_, images) = transcript_block_rows(&app, 2, 100, true); + assert_eq!(images.len(), 1); + assert_eq!(images[0].block, Some(0)); + patch( + &mut app, + json!({"toolCallId":"child", "status":"completed", "_meta":{"unrelated":true}}), + ); + assert_eq!(app.tool_owners.get(&0), Some(&2)); + patch( + &mut app, + json!({"toolCallId":"child", "rawOutput":"late content"}), + ); + refresh(&mut app); + assert!(text(&app.transcript_cache[2].as_ref().unwrap().rows).contains("late content")); + app.toggle_output("root"); + refresh(&mut app); + assert!( + !text(&app.transcript_cache[2].as_ref().unwrap().rows).contains("unique-child") + ); + patch(&mut app, json!({"toolCallId":"child", "_meta":null})); + refresh(&mut app); + assert!(text(&app.transcript_cache[0].as_ref().unwrap().rows).contains("unique-child")); + } + + #[test] + fn canonical_completed_owner_refreshes_for_running_child_and_pages_before_rows() { + let mut app = app(); + patch(&mut app, json!({"toolCallId":"root", "title":"compose"})); + for i in 0..70 { + patch( + &mut app, + json!({"toolCallId":format!("child-{i}"), "title":format!("unique-{i:03}"), "_meta":{"kit/parentToolCallId":"root"}}), + ); + } + patch(&mut app, json!({"toolCallId":"root", "status":"completed"})); + app.toggle_output("root"); + refresh(&mut app); + let root = &app.transcript_cache[0].as_ref().unwrap().rows; + assert!(text(root).contains("unique-069")); + assert!(!text(root).contains("unique-000")); + // Expanding the canonical child dirties its displayed owner even after + // that owner is terminal; late output still remains on the same card. + app.toggle_output("child-69"); + patch( + &mut app, + json!({"toolCallId":"child-69", "rawOutput":"still running"}), + ); + refresh(&mut app); + assert!( + text(&app.transcript_cache[0].as_ref().unwrap().rows).contains("still running") + ); + app.tick(); + refresh(&mut app); + assert!( + text(&app.transcript_cache[0].as_ref().unwrap().rows).contains("still running") + ); + let (children, _, _) = app.child_window(0); + assert_eq!(children.len(), 6); + for index in 1..app.blocks.len() { + assert!( + app.transcript_cache[index] + .as_ref() + .unwrap() + .rows + .is_empty() + ); + } + } + + #[test] + fn canonical_selection_separates_children_but_rejoins_wrapped_source_lines() { + use crate::tui::app::Selection; + for (width, titles) in [ + (100, ["first-child", "second-child"]), + ( + 24, + [ + "first child with a genuinely wrapped long title", + "second child with another genuinely wrapped long title", + ], + ), + ] { + let mut app = app(); + patch(&mut app, json!({"toolCallId":"root", "title":"compose"})); + for (id, title) in ["first", "second"].into_iter().zip(titles) { + patch( + &mut app, + json!({"toolCallId":id, "title":title, "_meta":{"kit/parentToolCallId":"root"}}), + ); + } + refresh_transcript_cache_with_images( + &mut app, + &mut ImageRuntime::disabled(), + width, + ); + let rows = &app.transcript_cache[0].as_ref().unwrap().rows; + let selected: Vec<_> = rows + .iter() + .enumerate() + .filter_map(|(i, row)| { + matches!(row.1.0.as_deref(), Some("first" | "second")).then_some(i) + }) + .collect(); + if width == 24 { + assert!(selected.len() > 2, "exercise actual row wrapping"); + } + app.selection = Some(Selection { + anchor: (selected[0], 0), + head: (*selected.last().unwrap(), width - 1), + }); + let copied = app.selection_text().unwrap(); + let lines: Vec<_> = copied.lines().collect(); + assert_eq!( + lines.len(), + 2, + "distinct canonical source headers: {copied}" + ); + assert!( + lines[0].contains(titles[0]), + "first header rejoins: {copied}" + ); + assert!( + lines[1].contains(titles[1]), + "second header rejoins: {copied}" + ); + } + } + + #[test] + fn canonical_terminal_reference_keeps_parallel_raw_output() { + let mut app = app(); + patch( + &mut app, + json!({"toolCallId":"child", "title":"shell", "content":[{"type":"terminal","terminalId":"terminal"}], "rawOutput":"useful terminal result"}), + ); + let Block::Tool(call) = &app.blocks[0] else { + panic!("tool") + }; + assert_eq!(call.output, ["useful terminal result"]); + patch(&mut app, json!({"toolCallId":"child", "content":[]})); + let Block::Tool(call) = &app.blocks[0] else { + panic!("tool") + }; + assert!(call.output.is_empty()); + } + + #[test] + fn canonical_foreign_session_metadata_is_not_applied() { + let patch: wire::ToolCallUpdate = serde_json::from_value( + json!({"toolCallId":"child", "_meta":{"kit/parentToolCallId":"root"}}), + ) + .unwrap(); + assert!( + translate_for_session( + UpdateSessionNotification::new("other", SessionUpdate::ToolCallUpdate(patch)), + "session" + ) + .is_empty() + ); + } + } }