diff --git a/src/console_room_runtime.rs b/src/console_room_runtime.rs index 9a6721b..6dc4dd6 100644 --- a/src/console_room_runtime.rs +++ b/src/console_room_runtime.rs @@ -171,11 +171,24 @@ impl RoomRuntimeState { match event { RoomEvent::Crep { event, host_role } => { // Per #380: drive the per-spawn lifecycle tracker off - // the same CrepEvent stream the renderer reads. This - // is a state-only update — no rendering branches - // consult the tracker yet (#381+ does), so the - // existing rail output is unchanged (AC-5). - self.spawn_lifecycle.apply_event(event.as_ref()); + // the same CrepEvent stream the renderer reads. The + // chat-stream renderer (`#381`) reads back from this + // tracker to splice working cards into scrollback at + // their original chat-time position. + let spawn_id = self.spawn_lifecycle.apply_event(event.as_ref()); + // For a fresh `TurnDispatched`, stamp the spawn's + // chat-row index now — *before* the spawner's "@host + // delegating @role …" line gets pushed below — so the + // card materializes immediately after that line in the + // stream. Using `scrollback.len()` as the index makes + // splicing trivial in the renderer: position N means + // "after scrollback row N-1, before row N". + if matches!(event.as_ref(), CrepEvent::TurnDispatched { .. }) { + if let Some(id) = spawn_id { + let position = self.scrollback.len(); + self.spawn_lifecycle.set_chat_position(id, position); + } + } let ends_turn = matches!( event.as_ref(), CrepEvent::RoleSpoke { .. } @@ -337,6 +350,13 @@ impl RoomRuntimeState { // scrolling down, so the badge must not promise more than // `scroll_offset` rows still exist below the user's view. self.unread_since_scroll = self.unread_since_scroll.min(self.scroll_offset); + // Every spawn lifecycle record carries a `chat_position` + // anchored to a scrollback index. Without this shift, every + // Working card's index points at an evicted row after the + // drain, breaking #381 AC-2 (card sits at its chat-time + // position). Saturating subtraction keeps the card visible + // at the top of the window rather than panicking. + self.spawn_lifecycle.shift_chat_positions(overflow); } } @@ -988,9 +1008,14 @@ fn render_scrollback(frame: &mut Frame<'_>, area: Rect, state: &RoomRuntimeState .last_viewport_rows .set(u16::try_from(visible_rows).unwrap_or(u16::MAX)); + // Build the merged scrollback with working cards spliced inline + // at their original chat-time positions (#381). When no spawn is + // in the `Working` state this is a clone of `scrollback`. + let merged = build_merged_scrollback(state, area.width); + // Clamp lazily: scrollback can shrink (1000-row drain in // `push_scrollback`), so the stored offset may exceed the new max. - let max_offset = state.scrollback.len().saturating_sub(visible_rows.max(1)); + let max_offset = merged.len().saturating_sub(visible_rows.max(1)); let effective_offset = state.scroll_offset.min(max_offset); let items: Vec> = if effective_offset == 0 { @@ -1001,14 +1026,14 @@ fn render_scrollback(frame: &mut Frame<'_>, area: Rect, state: &RoomRuntimeState // fresh room). let activity_lines = current_turn_lines(state); let scroll_rows = visible_rows.saturating_sub(activity_lines.len()); - let start = state.scrollback.len().saturating_sub(scroll_rows); - let mut items: Vec> = if state.scrollback.is_empty() { + let start = merged.len().saturating_sub(scroll_rows); + let mut items: Vec> = if merged.is_empty() { vec![Line::from(vec![Span::styled( "Submit a task below. Runtime output appears here.", Style::default().fg(Color::DarkGray), )])] } else { - state.scrollback[start..].to_vec() + merged[start..].to_vec() }; items.extend(activity_lines); items @@ -1017,10 +1042,10 @@ fn render_scrollback(frame: &mut Frame<'_>, area: Rect, state: &RoomRuntimeState // bottom row for a follow-back indicator so the user always // sees that they are looking at history. let scroll_rows = visible_rows.saturating_sub(1); - let total = state.scrollback.len(); + let total = merged.len(); let end = total.saturating_sub(effective_offset); let start = end.saturating_sub(scroll_rows); - let mut items = state.scrollback[start..end].to_vec(); + let mut items = merged[start..end].to_vec(); items.push(scrollback_follow_indicator(state.unread_since_scroll)); items }; @@ -1032,6 +1057,68 @@ fn render_scrollback(frame: &mut Frame<'_>, area: Rect, state: &RoomRuntimeState ); } +/// Build the scrollback to render: existing scrollback with inline +/// working cards spliced at each spawn's `chat_position`. Working +/// spawns whose chat_position is past the end of scrollback are +/// appended at the bottom (covers the race where the spawn was +/// dispatched but the spawner's confirmation line has not yet +/// rendered). +/// +/// Elapsed time is rendered at one-second granularity (AC-5) — the +/// `now` argument is the same `Instant` used for every card on this +/// frame, so even if two cards started at slightly different points +/// the displayed elapsed only changes once per wall-clock second. +fn build_merged_scrollback(state: &RoomRuntimeState, panel_width: u16) -> Vec> { + // Card width is panel_width less the surrounding borders (2 chars). + let inner_width = panel_width.saturating_sub(2); + // Collect working spawns, deduplicated and ordered by chat_position + // so the splice walk below stays linear. + let mut working: Vec<&SpawnInstance> = state + .spawn_lifecycle() + .instances() + .filter(|spawn| spawn.state == crate::spawn_lifecycle::SpawnState::Working) + .collect(); + working.sort_by_key(|spawn| spawn.chat_position); + + if working.is_empty() { + return state.scrollback.clone(); + } + + let now = Instant::now(); + let mut merged: Vec> = + Vec::with_capacity(state.scrollback.len() + working.len() * 5); + let mut spawn_iter = working.into_iter().peekable(); + for (idx, line) in state.scrollback.iter().enumerate() { + // Splice in any cards whose chat_position == idx (before this row). + while spawn_iter + .peek() + .is_some_and(|spawn| spawn.chat_position <= idx) + { + let spawn = spawn_iter.next().expect("peeked non-empty"); + merged.extend(crate::working_card::render_working_card_lines( + spawn, + &state.host_role, + inner_width, + now, + crate::working_card::DEFAULT_VISIBLE_STEPS, + )); + } + merged.push(line.clone()); + } + // Append any cards whose chat_position is past the end of scrollback + // (race: spawn registered but no scrollback row has landed yet). + for spawn in spawn_iter { + merged.extend(crate::working_card::render_working_card_lines( + spawn, + &state.host_role, + inner_width, + now, + crate::working_card::DEFAULT_VISIBLE_STEPS, + )); + } + merged +} + /// Bottom-of-Room hint shown whenever the user has scrolled away from /// the live tail. Yellow when there is unread output to flag, dim gray /// when scrollback is quiet so the user can ignore it. @@ -3736,6 +3823,101 @@ mod tests { ); } + // ------------------------------------------------------------------ + // #381 — `WorkingCard` widget integration. Verifies that the card + // materializes inside the rendered scrollback at the chat-time + // position assigned at TurnDispatched, reads role identity from + // `tui_style::role_color`, and does not break the existing + // scrollback / scroll-offset model. + // ------------------------------------------------------------------ + + fn dispatch_event(role: &str, turn: &str) -> CrepEvent { + CrepEvent::TurnDispatched { + role: role.to_owned(), + priors_hash: String::new(), + turn_id: crate::turn::TurnId::from(turn.to_owned()), + thread_id: crate::turn::TurnId::from(format!("thread-{turn}")), + parent_turn_id: None, + queue_position: 0, + } + } + + fn work_title_event(role: &str, turn: &str, title: &str) -> CrepEvent { + CrepEvent::WorkTitle { + role: role.to_owned(), + priors_hash: String::new(), + title: title.to_owned(), + turn_id: crate::turn::TurnId::from(turn.to_owned()), + thread_id: crate::turn::TurnId::from(format!("thread-{turn}")), + } + } + + fn tool_proposed_event(role: &str, turn: &str, tool: &str, tool_use_id: &str) -> CrepEvent { + CrepEvent::ToolCallProposed { + role: role.to_owned(), + priors_hash: String::new(), + tool_name: tool.to_owned(), + tool_input: serde_json::json!({}), + tool_use_id: tool_use_id.to_owned(), + turn_id: crate::turn::TurnId::from(turn.to_owned()), + thread_id: crate::turn::TurnId::from(format!("thread-{turn}")), + } + } + + fn tool_executed_event(role: &str, turn: &str, tool_use_id: &str, summary: &str) -> CrepEvent { + CrepEvent::ToolCallExecuted { + role: role.to_owned(), + priors_hash: String::new(), + tool_use_id: tool_use_id.to_owned(), + ok: true, + output_summary: summary.to_owned(), + turn_id: crate::turn::TurnId::from(turn.to_owned()), + thread_id: crate::turn::TurnId::from(format!("thread-{turn}")), + } + } + + fn crep_room_event(event: CrepEvent) -> RoomEvent { + RoomEvent::Crep { + event: Box::new(event), + host_role: "host".to_owned(), + } + } + + #[test] + fn working_card_renders_inline_with_role_title_and_state_label() { + // Dispatch @security, set its title, fire a tool call to push + // it from Spawning → Working. The rendered scrollback must + // now contain the card's top border (with @security + the + // task title + `working`) and its hotkey-hint footer. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("security", "t1"))); + state.apply_event(crep_room_event(work_title_event( + "security", + "t1", + "audit README claims", + ))); + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Read", "u1", + ))); + let text = render_room_runtime_to_text(&state, 120, 30).expect("render"); + assert!( + text.contains("@security"), + "card top border must carry @security: {text}" + ); + assert!( + text.contains("audit README claims"), + "card top border must carry the task title: {text}" + ); + assert!( + text.contains("working"), + "card top border must carry the `working` state label: {text}" + ); + assert!( + text.contains("[e]xpand [i]nterrupt [f]ocus"), + "card footer must render the hotkey hint string: {text}" + ); + } + #[test] fn footer_narration_chips_use_identity_colors() { // AC-3: role chips carry the same color as the @role mentions @@ -3758,6 +3940,265 @@ mod tests { assert_eq!(chip_color, tui_style::role_color("backend", "host")); } + #[test] + fn working_card_uses_no_title_placeholder_when_work_title_missing() { + // AC: if no `WorkTitle` event has landed for the spawn, the + // card top border shows the locked placeholder rather than + // collapsing the border. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("backend", "t1"))); + // Skip the WorkTitle event. Fire a tool call to flip to Working. + state.apply_event(crep_room_event(tool_proposed_event( + "backend", "t1", "Bash", "u1", + ))); + let text = render_room_runtime_to_text(&state, 120, 30).expect("render"); + assert!( + text.contains(crate::working_card::NO_TITLE_PLACEHOLDER), + "expected (no title) placeholder: {text}" + ); + } + + #[test] + fn working_card_only_renders_when_state_is_working_not_spawning() { + // Only `Spawning` so far — no tool call, no output delta. + // Per the ADR, Spawning has no card (it shows as a hint in + // the spawner's text). The chat stream must not render a card + // for a spawn that has not promoted to Working yet. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("backend", "t1"))); + state.apply_event(crep_room_event(work_title_event( + "backend", + "t1", + "set up scaffolding", + ))); + let text = render_room_runtime_to_text(&state, 120, 30).expect("render"); + // No card top border or footer. + assert!( + !text.contains("[e]xpand [i]nterrupt [f]ocus"), + "no card footer should render for Spawning: {text}" + ); + } + + #[test] + fn working_card_uses_role_identity_color_via_tui_style() { + // AC-3: no hard-coded role color. We can't sniff color from + // the rendered text directly, but we can verify by running + // the merge helper with a known role and inspecting the + // top-border `@role` span style. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("security", "t1"))); + state.apply_event(crep_room_event(work_title_event("security", "t1", "audit"))); + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Read", "u1", + ))); + let merged = super::build_merged_scrollback(&state, 120); + let role_token_color = merged + .iter() + .flat_map(|line| line.spans.iter()) + .find(|span| span.content.as_ref().contains("@security")) + .and_then(|span| span.style.fg) + .expect("@security span has a foreground color"); + assert_eq!( + role_token_color, + tui_style::role_color("security", "host"), + "card identity color must come from tui_style::role_color" + ); + } + + #[test] + fn working_card_body_shows_done_and_in_progress_markers() { + // Drive @security through one Done tool call + one + // InProgress. Card body must show ✓ for the done summary + // and ∴ for the in-progress one. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("security", "t1"))); + state.apply_event(crep_room_event(work_title_event("security", "t1", "audit"))); + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Read", "u1", + ))); + state.apply_event(crep_room_event(tool_executed_event( + "security", + "t1", + "u1", + "read README.md §2.4", + ))); + // A second proposal that has not executed yet. + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Grep", "u2", + ))); + let text = render_room_runtime_to_text(&state, 120, 30).expect("render"); + assert!( + text.contains('✓'), + "card body must render ✓ for the done tool call: {text}" + ); + assert!( + text.contains('∴'), + "card body must render ∴ for the in-progress tool call: {text}" + ); + assert!( + text.contains("read README.md"), + "done summary must appear in body: {text}" + ); + // Footer step count tracks done calls only. + assert!( + text.contains("1 step done"), + "footer step count should be 1: {text}" + ); + } + + #[test] + fn working_card_position_follows_spawning_event_chat_time_not_bottom() { + // AC-2: the card sits at the chat-time of TurnDispatched, not + // pinned at the bottom. We push a chat-style line *after* + // the spawn dispatches; the card must appear ABOVE that line + // in the merged scrollback. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("security", "t1"))); + state.apply_event(crep_room_event(work_title_event("security", "t1", "audit"))); + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Read", "u1", + ))); + // A post-spawn chat row from @host. + state.push_scrollback(Line::from("post-spawn host line")); + let merged = super::build_merged_scrollback(&state, 120); + let card_row = merged + .iter() + .position(|line| { + line.spans + .iter() + .any(|s| s.content.as_ref().contains("[e]xpand [i]nterrupt [f]ocus")) + }) + .expect("card footer present"); + let post_row = merged + .iter() + .position(|line| { + line.spans + .iter() + .any(|s| s.content.as_ref().contains("post-spawn host line")) + }) + .expect("post-spawn line present"); + assert!( + card_row < post_row, + "card must render before post-spawn line: card={card_row}, post={post_row}" + ); + } + + #[test] + fn working_card_position_survives_scrollback_drain() { + // Regression for the @reviewer audit on PR #392: the + // 1000-row scrollback drain in `push_scrollback` evicts the + // oldest rows from the front but used to leave the + // SpawnInstance's `chat_position` pointing at a now-dead + // index. Shipping that would have meant Working cards + // silently slipped out of place after long sessions. + // + // Drive >1000 lines of scrollback with one Working spawn + // anchored near the start, then assert: (a) `chat_position` + // has been shifted by exactly `overflow`, (b) the merged + // scrollback still places the card BEFORE the most recent + // post-spawn lines. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("security", "t1"))); + state.apply_event(crep_room_event(work_title_event( + "security", + "t1", + "audit README claims", + ))); + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Read", "u1", + ))); + let spawn_id_before = state + .spawn_lifecycle + .instances() + .next() + .expect("one spawn") + .spawn_id; + let position_before = state + .spawn_lifecycle + .get(spawn_id_before) + .expect("spawn present") + .chat_position; + + // Flood scrollback well past the 1000-row cap. + for i in 0..1_500 { + state.push_scrollback(Line::from(format!("filler {i}"))); + } + + // The drain shrunk the buffer to exactly 1000 rows. The card's + // chat_position must have shifted by `(1 + 1500) - 1000 = 501` + // (roughly — accounting for the spawn's own row contribution). + // Concretely: chat_position must be strictly less than its + // pre-drain value. + let position_after = state + .spawn_lifecycle + .get(spawn_id_before) + .expect("spawn still tracked") + .chat_position; + assert!( + position_after < position_before + || (position_before == 0 && position_after == 0), + "chat_position should shift left after drain: before={position_before}, after={position_after}" + ); + + // Add one more post-spawn row and verify ordering still holds + // in the merged scrollback. + state.push_scrollback(Line::from("very fresh line")); + let merged = super::build_merged_scrollback(&state, 120); + let card_row = merged.iter().position(|line| { + line.spans + .iter() + .any(|s| s.content.as_ref().contains("[e]xpand [i]nterrupt [f]ocus")) + }); + let fresh_row = merged.iter().position(|line| { + line.spans + .iter() + .any(|s| s.content.as_ref().contains("very fresh line")) + }); + if let (Some(card), Some(fresh)) = (card_row, fresh_row) { + assert!( + card < fresh, + "post-drain: card must still precede the latest line (card={card}, fresh={fresh})" + ); + } + } + + #[test] + fn build_merged_scrollback_is_idempotent() { + // Rerendering the same state twice must produce byte-identical + // merged scrollback. If a future change introduces hidden + // mutation (e.g., recomputing card positions on every call), + // this test surfaces it as a flake before users do. + let mut state = test_state(); + state.apply_event(crep_room_event(dispatch_event("security", "t1"))); + state.apply_event(crep_room_event(work_title_event( + "security", + "t1", + "audit README claims", + ))); + state.apply_event(crep_room_event(tool_proposed_event( + "security", "t1", "Read", "u1", + ))); + state.apply_event(crep_room_event(tool_executed_event( + "security", + "t1", + "u1", + "read README.md §2.4", + ))); + let first = super::build_merged_scrollback(&state, 120); + let second = super::build_merged_scrollback(&state, 120); + let first_text: String = first + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + let second_text: String = second + .iter() + .flat_map(|line| line.spans.iter()) + .map(|span| span.content.as_ref()) + .collect(); + assert_eq!(first_text, second_text); + } + fn test_state() -> RoomRuntimeState { let team = vec![ TeamMember { diff --git a/src/lib.rs b/src/lib.rs index c5e1a53..0b94515 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,3 +67,4 @@ pub mod turn; pub mod update; mod work; pub mod work_order; +pub mod working_card; diff --git a/src/spawn_lifecycle.rs b/src/spawn_lifecycle.rs index 6555e27..b3c3614 100644 --- a/src/spawn_lifecycle.rs +++ b/src/spawn_lifecycle.rs @@ -204,6 +204,20 @@ pub struct SpawnInstance { /// future renderer can group spawns under the spawner's chat /// thread without re-reading the original `TurnDispatched` event. pub parent_turn_id: Option, + /// One-line task title for the spawn, used by the `WorkingCard` + /// renderer (`#381`) on the card's top border. Empty when no + /// `WorkTitle` event has been observed for this spawn yet; the + /// renderer is responsible for substituting an `(no title)` + /// placeholder in that case. + pub title: String, + /// Chat-row index assigned at `Spawning` time by the renderer that + /// owns the chat scrollback. Used by the `WorkingCard` widget + /// (`#381`) to splice the card into the chat stream at the spawn + /// event's chat-time position rather than at the bottom. The + /// tracker itself never reads this field — it is a renderer hint + /// stored alongside the lifecycle record so concurrent renderers + /// do not need a parallel `spawn_id → chat_position` map. + pub chat_position: usize, } /// In-memory tracker that owns the [`SpawnInstance`] map and applies @@ -341,10 +355,10 @@ impl SpawnLifecycleTracker { CrepEvent::TurnInterrupted { turn_id, source, .. } => self.on_turn_interrupted(turn_id, *source), + CrepEvent::WorkTitle { turn_id, title, .. } => self.on_work_title(turn_id, title), // Events that do not affect a per-spawn lifecycle record. CrepEvent::RoleStarted { .. } | CrepEvent::RoleSessionUpdated { .. } - | CrepEvent::WorkTitle { .. } | CrepEvent::PhaseAdvanced { .. } | CrepEvent::PhaseBlocked { .. } | CrepEvent::PlanReviewed { .. } @@ -387,6 +401,8 @@ impl SpawnLifecycleTracker { outcome: Outcome::default(), turn_id: turn_id.clone(), parent_turn_id: parent_turn_id.cloned(), + title: String::new(), + chat_position: 0, }; self.instances.insert(spawn_id, instance); self.by_turn.insert(turn_id.clone(), spawn_id); @@ -529,6 +545,46 @@ impl SpawnLifecycleTracker { Some(spawn_id) } + fn on_work_title(&mut self, turn_id: &TurnId, title: &str) -> Option { + let spawn_id = self.by_turn.get(turn_id).copied()?; + let instance = self.instances.get_mut(&spawn_id)?; + // Last write wins — `WorkTitle` can be emitted repeatedly as a + // sub-agent refines its phase title. The renderer always + // displays the most recent one. + title.clone_into(&mut instance.title); + Some(spawn_id) + } + + /// Stamp the chat-row index the `WorkingCard` renderer will use as + /// the splice point in scrollback. Idempotent: callers should only + /// invoke this once, at `Spawning` time, before the first card + /// renders for the spawn. Subsequent calls overwrite (last write + /// wins) so test fixtures can re-pin a card without rebuilding the + /// tracker. A `None` `spawn_id` is a no-op (the calling renderer + /// did not get an id back from `apply_event`). + pub fn set_chat_position(&mut self, spawn_id: SpawnId, chat_position: usize) { + if let Some(instance) = self.instances.get_mut(&spawn_id) { + instance.chat_position = chat_position; + } + } + + /// Shift every tracked instance's `chat_position` left by `by` rows, + /// saturating at 0. Called by `RoomRuntimeState::push_scrollback` + /// after the 1000-row drain (v0.9.16 #371) so a Working card stays + /// anchored to its chat-time row instead of pointing at the old — + /// now evicted — index. A card whose original position falls inside + /// the drained range collapses to 0 and renders at the top of the + /// visible window; downstream splicing in `build_merged_scrollback` + /// treats a saturating-0 position as "earliest visible row". + pub fn shift_chat_positions(&mut self, by: usize) { + if by == 0 { + return; + } + for instance in self.instances.values_mut() { + instance.chat_position = instance.chat_position.saturating_sub(by); + } + } + fn on_turn_interrupted( &mut self, turn_id: &TurnId, @@ -903,4 +959,66 @@ mod tests { // helper is not pulled into a transition test. let _ = StopReason::Completed; } + + #[test] + fn work_title_event_populates_title_on_matching_spawn() { + // `WorkTitle` is the canonical source for the card's top-border + // title in the chat-stream renderer (`#381`). The tracker now + // listens for it and last-write-wins onto `SpawnInstance::title`. + let mut tracker = SpawnLifecycleTracker::new("host"); + let id = tracker + .apply_event(&turn_dispatched("security", "t1", None)) + .unwrap(); + // Default title is empty before any `WorkTitle` lands. + assert!(tracker.get(id).unwrap().title.is_empty()); + tracker.apply_event(&CrepEvent::WorkTitle { + role: "security".to_owned(), + priors_hash: String::new(), + title: "audit README security claims".to_owned(), + turn_id: TurnId::from("t1".to_owned()), + thread_id: TurnId::from("thread-t1".to_owned()), + }); + assert_eq!( + tracker.get(id).unwrap().title, + "audit README security claims" + ); + // Second WorkTitle replaces the first (last write wins). + tracker.apply_event(&CrepEvent::WorkTitle { + role: "security".to_owned(), + priors_hash: String::new(), + title: "refined title".to_owned(), + turn_id: TurnId::from("t1".to_owned()), + thread_id: TurnId::from("thread-t1".to_owned()), + }); + assert_eq!(tracker.get(id).unwrap().title, "refined title"); + } + + #[test] + fn work_title_for_unknown_turn_is_ignored() { + let mut tracker = SpawnLifecycleTracker::new("host"); + let result = tracker.apply_event(&CrepEvent::WorkTitle { + role: "ghost".to_owned(), + priors_hash: String::new(), + title: "no such turn".to_owned(), + turn_id: TurnId::from("unknown".to_owned()), + thread_id: TurnId::from("thread-x".to_owned()), + }); + assert!(result.is_none()); + } + + #[test] + fn set_chat_position_stamps_renderer_hint_on_instance() { + let mut tracker = SpawnLifecycleTracker::new("host"); + let id = tracker + .apply_event(&turn_dispatched("backend", "t1", None)) + .unwrap(); + // Default chat_position is 0 — meaningless until the renderer + // stamps it. + assert_eq!(tracker.get(id).unwrap().chat_position, 0); + tracker.set_chat_position(id, 42); + assert_eq!(tracker.get(id).unwrap().chat_position, 42); + // Setting on an unknown id is a silent no-op. + tracker.set_chat_position(SpawnId(9999), 7); + assert_eq!(tracker.get(id).unwrap().chat_position, 42); + } } diff --git a/src/working_card.rs b/src/working_card.rs new file mode 100644 index 0000000..4f9306f --- /dev/null +++ b/src/working_card.rs @@ -0,0 +1,616 @@ +//! Inline `WorkingCard` widget for the v0.10 chat stream. +//! +//! Renders one `Working`-state spawn instance as a multi-line ASCII +//! card in the live-room scrollback. The card composes a top border +//! (`┌─ @role · title ── working · elapsed ─┐`), a body of tool-call +//! lines (`✓` done, `∴` in progress, `⨯` failed), and a bottom border +//! carrying a step count and the locked hotkey hint string. The hint +//! string is **non-functional** in this PR — `#385` wires the keys. +//! +//! The widget reads from [`SpawnInstance`] only. It does not consult +//! the kernel, the rail, or any side channel. The caller threads in +//! the host role (for identity color via [`tui_style::role_color`]) and +//! the available inner width. +//! +//! Visual locked by `docs/v0.10-chat-stream-vs-dashboard.md` Frame B +//! and by `examples/chat-stream-demo.rs` (issue `#379`). This module +//! is the production version of that prototype, fed by real lifecycle +//! data instead of hand-built scene fixtures. + +use std::time::{Duration, Instant}; + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::spawn_lifecycle::{SpawnInstance, SpawnState, ToolCallRecord, ToolCallStatus}; +use crate::tui_style; + +/// Default count of tool-call rows visible inside the card body. +/// Anything older than the last [`DEFAULT_VISIBLE_STEPS`] entries +/// scrolls out of the card (the lines are not added to scrollback; +/// the card is the bounded region per the ADR). +pub const DEFAULT_VISIBLE_STEPS: usize = 3; + +/// Indent applied to every card line. Matches the column where +/// `@role` chat messages start in the existing scrollback layout, so +/// the card visually attaches to the spawner's most recent line. +const CARD_INDENT: &str = " "; + +/// Minimum inner card width. Below this the borders collapse to a +/// single-char marker but the body still renders — we never want a +/// negative width to panic out of the layout pass. +const MIN_CARD_WIDTH: usize = 40; + +/// Placeholder shown on the top border when a spawn has not yet +/// emitted a `WorkTitle` event. Documented in the AC: "if neither is +/// available, use `(no title)` placeholder." +pub const NO_TITLE_PLACEHOLDER: &str = "(no title)"; + +/// Build the lines that make up one working card for the given spawn. +/// +/// Returns an empty vec when `spawn.state != SpawnState::Working` — +/// the caller is free to call this for every instance and rely on the +/// state filter to drop non-working ones, which keeps the integration +/// point in `render_scrollback` simple. +/// +/// `now` is taken as a parameter (not read from `Instant::now()` +/// inside) so tests can pin the elapsed-seconds value deterministically +/// and so the renderer's "once per second" gate (AC-5) is the caller's +/// responsibility, not the widget's. +#[must_use] +pub fn render_working_card_lines( + spawn: &SpawnInstance, + host_role: &str, + inner_width: u16, + now: Instant, + visible_steps: usize, +) -> Vec> { + if spawn.state != SpawnState::Working { + return Vec::new(); + } + + let role_color = tui_style::role_color(&spawn.role, host_role); + let card_width = usable_card_width(inner_width); + let elapsed = elapsed_label(now.saturating_duration_since(spawn.started_at)); + let title = if spawn.title.is_empty() { + NO_TITLE_PLACEHOLDER.to_owned() + } else { + spawn.title.clone() + }; + + let mut lines = Vec::with_capacity(visible_steps + 2); + lines.push(top_border_line( + &spawn.role, + host_role, + role_color, + &title, + &elapsed, + card_width, + )); + for record in tail_tool_calls(&spawn.tool_calls, visible_steps) { + lines.push(body_line(record, card_width)); + } + lines.push(bottom_border_line(spawn.step_count, card_width)); + lines +} + +/// Effective inner-card width inside the scrollback panel. The card +/// borders draw inside `CARD_INDENT`; everything left of the indent +/// is the chat-style left margin that the card visually nests under. +fn usable_card_width(inner_width: u16) -> usize { + let inner = usize::from(inner_width); + inner.saturating_sub(CARD_INDENT.len()).max(MIN_CARD_WIDTH) +} + +/// Take the last `n` tool calls in stream order. We render newest at +/// the bottom of the card, matching how a chat-style log reads — +/// older entries scroll *out the top* once the card hits its visible +/// budget (AC-6c). +fn tail_tool_calls(records: &[ToolCallRecord], visible_steps: usize) -> &[ToolCallRecord] { + let n = records.len(); + let take = visible_steps.min(n); + &records[n - take..] +} + +/// Human-friendly elapsed label. Coarse to whole seconds so the +/// frame-to-frame rendering does not flicker on sub-second +/// fluctuations (AC-5). +fn elapsed_label(duration: Duration) -> String { + let secs = duration.as_secs(); + if secs < 60 { + format!("{secs}s") + } else { + let minutes = secs / 60; + let remainder = secs % 60; + format!("{minutes}m {remainder:02}s") + } +} + +/// Top border: +/// `┌─ {avatar} @{role} · {title} ── working · {elapsed} ─...─┐` +fn top_border_line( + role: &str, + host_role: &str, + role_color: Color, + title: &str, + elapsed: &str, + card_width: usize, +) -> Line<'static> { + // Budget the title to fit even on narrow terminals. The fixed + // chrome is the borders, separators, the role label (`avatar` + + // ` ` + `@role`), the state label (`working`), and the elapsed + // label. Everything left over is for the title. + let role_token = format!("@{role}"); + let chrome_width = "┌─ ".chars().count() + + 1 // avatar glyph (1 cell) + + 1 // space + + role_token.chars().count() + + " · ".chars().count() + + " ── ".chars().count() + + "working".chars().count() + + " · ".chars().count() + + elapsed.chars().count() + + " ".chars().count() + + 1; // closing ┐ + + let title_budget = card_width.saturating_sub(chrome_width).max(1); + let title_truncated = middle_truncate(title, title_budget); + + let mut spans: Vec> = Vec::with_capacity(12); + spans.push(Span::raw(CARD_INDENT)); + spans.push(Span::styled( + "┌─ ".to_owned(), + Style::default().fg(Color::DarkGray), + )); + spans.extend(tui_style::role_label_spans(role, host_role)); + spans.push(Span::styled( + format!(" · {title_truncated} "), + Style::default().fg(role_color), + )); + spans.push(Span::styled( + "── ".to_owned(), + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled( + "working".to_owned(), + Style::default() + .fg(Color::LightYellow) + .add_modifier(Modifier::BOLD), + )); + spans.push(Span::styled( + format!(" · {elapsed} "), + Style::default().fg(Color::DarkGray), + )); + + // Count visible cells (post-indent) and pad to the closing ┐. + let visible_so_far: usize = spans + .iter() + .skip(1) // skip the indent span; it does not contribute to card-width budget + .map(|s| s.content.chars().count()) + .sum(); + let pad = card_width.saturating_sub(visible_so_far + 1); + if pad > 0 { + spans.push(Span::styled( + "─".repeat(pad), + Style::default().fg(Color::DarkGray), + )); + } + spans.push(Span::styled( + "┐".to_owned(), + Style::default().fg(Color::DarkGray), + )); + Line::from(spans) +} + +/// One body line: +/// `│ {marker} {summary…} {pad}│` +fn body_line(record: &ToolCallRecord, card_width: usize) -> Line<'static> { + let (marker, marker_style) = marker_for_status(record.status); + let summary_text = if record.summary.is_empty() { + record.tool.clone() + } else { + record.summary.clone() + }; + // Chrome: `│ ` + marker (1) + ` ` + summary + trailing ` ` before `│`. + let chrome_width = 2 + 1 + 1 + 1 + 1; // = 6 + let summary_budget = card_width.saturating_sub(chrome_width).max(1); + let summary = middle_truncate(&summary_text, summary_budget); + let visible_summary_width = summary.chars().count(); + let pad = card_width.saturating_sub(2 + 1 + 1 + visible_summary_width + 1 + 1); + + Line::from(vec![ + Span::raw(CARD_INDENT), + Span::styled("│ ".to_owned(), Style::default().fg(Color::DarkGray)), + Span::styled(marker.to_owned(), marker_style), + Span::raw(" "), + Span::raw(summary), + Span::raw(" ".repeat(pad)), + Span::styled(" │".to_owned(), Style::default().fg(Color::DarkGray)), + ]) +} + +fn marker_for_status(status: ToolCallStatus) -> (&'static str, Style) { + match status { + ToolCallStatus::Done => ("✓", Style::default().fg(Color::LightGreen)), + ToolCallStatus::InProgress => ("∴", Style::default().fg(Color::LightYellow)), + ToolCallStatus::Failed => ("⨯", Style::default().fg(Color::LightRed)), + } +} + +/// Bottom border: +/// `└─ {N step(s) done · [e]xpand [i]nterrupt [f]ocus} ─...─┘` +fn bottom_border_line(done_count: usize, card_width: usize) -> Line<'static> { + let footer_text = format!( + " {done_count} step{plural} done · [e]xpand [i]nterrupt [f]ocus ", + plural = if done_count == 1 { "" } else { "s" }, + ); + let mut spans: Vec> = Vec::with_capacity(5); + spans.push(Span::raw(CARD_INDENT)); + spans.push(Span::styled( + "└─".to_owned(), + Style::default().fg(Color::DarkGray), + )); + spans.push(Span::styled( + footer_text.clone(), + Style::default().fg(Color::DarkGray), + )); + let visible_so_far: usize = spans + .iter() + .skip(1) + .map(|s| s.content.chars().count()) + .sum(); + let pad = card_width.saturating_sub(visible_so_far + 1); + if pad > 0 { + spans.push(Span::styled( + "─".repeat(pad), + Style::default().fg(Color::DarkGray), + )); + } + spans.push(Span::styled( + "┘".to_owned(), + Style::default().fg(Color::DarkGray), + )); + Line::from(spans) +} + +/// Middle-truncate a string to `max_chars`, inserting an ellipsis in +/// the middle so the head and tail both remain visible. Returns the +/// input unchanged when it already fits. Used for both the title row +/// (long task descriptions) and the tool-call summary (long paths). +fn middle_truncate(input: &str, max_chars: usize) -> String { + let count = input.chars().count(); + if count <= max_chars { + return input.to_owned(); + } + if max_chars <= 1 { + return "…".to_owned(); + } + // Reserve 1 char for the ellipsis. Bias the head one larger than + // the tail on an odd remainder so prefixes (path heads, file names) + // stay readable. + let budget = max_chars - 1; + let head_chars = budget.div_ceil(2); + let tail_chars = budget - head_chars; + let head: String = input.chars().take(head_chars).collect(); + let tail: String = input + .chars() + .skip(count.saturating_sub(tail_chars)) + .collect(); + format!("{head}…{tail}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crep::CrepEvent; + use crate::spawn_lifecycle::SpawnLifecycleTracker; + use crate::turn::TurnId; + use std::time::Duration; + + /// Build a `Working` spawn instance via the canonical tracker + /// event flow. We construct via real apply_event calls instead of + /// the struct literal because [`crate::spawn_lifecycle::SpawnId`] + /// is intentionally opaque to consumers — the renderer does not + /// need to mint ids. + fn working_spawn(role: &str, title: &str) -> SpawnInstance { + let mut tracker = SpawnLifecycleTracker::new("host"); + let id = tracker + .apply_event(&CrepEvent::TurnDispatched { + role: role.to_owned(), + priors_hash: String::new(), + turn_id: TurnId::from("t1".to_owned()), + thread_id: TurnId::from("thread-t1".to_owned()), + parent_turn_id: None, + queue_position: 0, + }) + .expect("dispatch yields a spawn id"); + // Promote to Working with a no-op output delta — keeps the + // tool_calls vec empty so each test can populate it explicitly. + tracker.apply_event(&CrepEvent::RoleOutputDelta { + role: role.to_owned(), + priors_hash: String::new(), + text_delta: String::new(), + sequence: 0, + turn_id: TurnId::from("t1".to_owned()), + thread_id: TurnId::from("thread-t1".to_owned()), + }); + if !title.is_empty() { + tracker.apply_event(&CrepEvent::WorkTitle { + role: role.to_owned(), + priors_hash: String::new(), + title: title.to_owned(), + turn_id: TurnId::from("t1".to_owned()), + thread_id: TurnId::from("thread-t1".to_owned()), + }); + } + tracker.get(id).cloned().expect("instance exists") + } + + fn tool_record(summary: &str, status: ToolCallStatus) -> ToolCallRecord { + let started_at = Instant::now(); + ToolCallRecord { + tool_use_id: "u".to_owned(), + tool: "Bash".to_owned(), + summary: summary.to_owned(), + started_at, + finished_at: if matches!(status, ToolCallStatus::InProgress) { + None + } else { + Some(started_at) + }, + status, + } + } + + fn line_to_string(line: &Line<'_>) -> String { + line.spans + .iter() + .map(|s| s.content.as_ref()) + .collect::() + } + + #[test] + fn non_working_state_returns_no_lines() { + // Spawning, Done, and Reported must not render a card. The + // caller can iterate all instances without filtering and the + // widget enforces the gate. + let mut spawn = working_spawn("security", "audit"); + let now = Instant::now(); + spawn.state = SpawnState::Spawning; + assert!( + render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS).is_empty() + ); + spawn.state = SpawnState::Done; + assert!( + render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS).is_empty() + ); + spawn.state = SpawnState::Reported; + assert!( + render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS).is_empty() + ); + } + + #[test] + fn zero_tool_calls_card_shows_just_title() { + // AC-6a: a card with no tool calls renders top border + bottom + // border only (two lines), with the title intact in the top + // border. The step count is 0 and the footer says "0 steps". + let spawn = working_spawn("security", "audit README claims"); + let now = spawn.started_at; // pin elapsed = 0 for stable assertion + let lines = render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS); + assert_eq!(lines.len(), 2, "expected exactly top + bottom border"); + let top = line_to_string(&lines[0]); + let bottom = line_to_string(&lines[1]); + assert!(top.contains("@security"), "top border missing role: {top}"); + assert!( + top.contains("audit README claims"), + "top border missing title: {top}" + ); + assert!(top.contains("working")); + assert!(top.contains("0s")); + assert!(top.starts_with(" ")); + assert!(bottom.contains("0 steps done")); + assert!(bottom.contains("[e]xpand [i]nterrupt [f]ocus")); + } + + #[test] + fn one_done_plus_one_in_progress_renders_both_markers() { + // AC-6b: a card with one ✓ done call and one ∴ in-progress call + // renders two body lines between top and bottom border. The + // step count reflects only the done call. + let mut spawn = working_spawn("security", "audit"); + spawn.tool_calls.push(tool_record( + "read README.md §2.4 security model", + ToolCallStatus::Done, + )); + spawn.tool_calls.push(tool_record( + "cross-checking claims against src/permissions/", + ToolCallStatus::InProgress, + )); + spawn.step_count = 1; + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 100, now, DEFAULT_VISIBLE_STEPS); + // top + 2 body + bottom + assert_eq!(lines.len(), 4, "expected 4 lines (top + 2 body + bottom)"); + let body_done = line_to_string(&lines[1]); + let body_in_progress = line_to_string(&lines[2]); + assert!(body_done.contains('✓'), "missing ✓ marker: {body_done}"); + assert!( + body_done.contains("read README.md"), + "done summary missing: {body_done}" + ); + assert!( + body_in_progress.contains('∴'), + "missing ∴ marker: {body_in_progress}" + ); + assert!( + body_in_progress.contains("cross-checking"), + "in-progress summary missing: {body_in_progress}" + ); + let bottom = line_to_string(&lines[3]); + assert!( + bottom.contains("1 step done"), + "footer step count: {bottom}" + ); + } + + #[test] + fn more_than_n_tool_calls_drops_oldest_visually() { + // AC-6c: with ≥ N + 1 tool calls, only the latest N body lines + // appear in the card. The oldest entry is the one that drops. + let mut spawn = working_spawn("backend", "verify claims"); + // 5 done tool calls, in order oldest → newest. + for i in 0..5 { + spawn + .tool_calls + .push(tool_record(&format!("step-{i}"), ToolCallStatus::Done)); + } + spawn.step_count = 5; + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 80, now, 3); + // top + 3 body + bottom + assert_eq!(lines.len(), 5); + let body_concat = lines[1..4] + .iter() + .map(line_to_string) + .collect::>() + .join("\n"); + // oldest two (step-0, step-1) dropped; newest three remain. + assert!( + !body_concat.contains("step-0"), + "step-0 should drop: {body_concat}" + ); + assert!( + !body_concat.contains("step-1"), + "step-1 should drop: {body_concat}" + ); + assert!(body_concat.contains("step-2")); + assert!(body_concat.contains("step-3")); + assert!(body_concat.contains("step-4")); + let bottom = line_to_string(&lines[4]); + assert!(bottom.contains("5 steps done")); + } + + #[test] + fn missing_title_renders_no_title_placeholder() { + // When `SpawnInstance::title` is empty (no WorkTitle seen yet), + // the top border renders the locked placeholder so the card + // shape stays intact. + let spawn = working_spawn("backend", ""); + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS); + let top = line_to_string(&lines[0]); + assert!( + top.contains(NO_TITLE_PLACEHOLDER), + "top border missing placeholder: {top}" + ); + } + + #[test] + fn elapsed_label_uses_whole_seconds_so_subsecond_changes_do_not_flicker() { + // AC-5: rendering must not change on sub-second drift. The + // label string for `5s` and `5.7s` must be identical. + assert_eq!(elapsed_label(Duration::from_secs(5)), "5s"); + assert_eq!(elapsed_label(Duration::from_millis(5_700)), "5s"); + assert_eq!(elapsed_label(Duration::from_secs(0)), "0s"); + } + + #[test] + fn elapsed_label_switches_to_minutes_past_a_minute() { + assert_eq!(elapsed_label(Duration::from_secs(60)), "1m 00s"); + assert_eq!(elapsed_label(Duration::from_secs(125)), "2m 05s"); + assert_eq!(elapsed_label(Duration::from_secs(3_599)), "59m 59s"); + } + + #[test] + fn middle_truncate_keeps_head_and_tail_with_ellipsis() { + // Used to keep long paths legible on narrow terminals — head + // shows the package/dir, tail shows the file. Equal-length + // inputs round up to the head per the comment. + let truncated = middle_truncate("src/permissions/policies/loader.rs", 20); + assert_eq!(truncated.chars().count(), 20); + assert!(truncated.contains('…')); + assert!(truncated.starts_with("src/perm")); // head intact + assert!(truncated.ends_with("loader.rs")); // tail intact + } + + #[test] + fn middle_truncate_passthrough_when_within_budget() { + let input = "short text"; + assert_eq!(middle_truncate(input, 80), input); + } + + #[test] + fn long_tool_summary_is_middle_truncated_inside_card_width() { + // AC-4: long summaries are middle-truncated to fit the card + // width. The card body row must not exceed `card_width` total + // visible cells (excluding the leading indent). + let mut spawn = working_spawn("backend", "x"); + spawn.tool_calls.push(tool_record( + "ran cargo test --workspace --no-fail-fast --features all-engines-and-extras", + ToolCallStatus::Done, + )); + spawn.step_count = 1; + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 60, now, DEFAULT_VISIBLE_STEPS); + let body = line_to_string(&lines[1]); + // The full summary must not be present verbatim — it got + // truncated to fit. + assert!(body.contains('…'), "expected middle ellipsis: {body}"); + // Body row width (after the indent) must equal card_width. + let after_indent = body.trim_start_matches(' '); + let total_visible = after_indent.chars().count(); + // 60 - indent (13) = 47 inner card width, but min is 40. + assert!( + total_visible >= MIN_CARD_WIDTH, + "body shorter than min card width: {total_visible} ({body})" + ); + } + + #[test] + fn identity_color_comes_from_tui_style_role_color_not_hardcoded() { + // AC-3: the role label inside the top border uses the + // canonical role color helper. We verify by comparing the + // header's role-token color against role_color directly — + // they must agree. + let spawn = working_spawn("security", "audit"); + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS); + let top = &lines[0]; + // Find the `@security` span; its fg must equal role_color. + let want = tui_style::role_color("security", "host"); + let role_span = top + .spans + .iter() + .find(|s| s.content.as_ref().contains("@security")) + .expect("top border has role token"); + assert_eq!(role_span.style.fg, Some(want)); + } + + #[test] + fn footer_singular_step_word_when_done_count_is_one() { + let mut spawn = working_spawn("backend", "x"); + spawn + .tool_calls + .push(tool_record("one done", ToolCallStatus::Done)); + spawn.step_count = 1; + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS); + let bottom = line_to_string(&lines[lines.len() - 1]); + assert!(bottom.contains("1 step done"), "singular step: {bottom}"); + assert!(!bottom.contains("1 steps done")); + } + + #[test] + fn failed_tool_call_renders_cross_marker() { + let mut spawn = working_spawn("backend", "x"); + spawn + .tool_calls + .push(tool_record("denied", ToolCallStatus::Failed)); + spawn.step_count = 1; + let now = spawn.started_at; + let lines = render_working_card_lines(&spawn, "host", 80, now, DEFAULT_VISIBLE_STEPS); + let body = line_to_string(&lines[1]); + assert!(body.contains('⨯'), "failed marker missing: {body}"); + } +}