返回 CodeWhale
mod.rs
根目录 / crates / tui / src / tui / work_surface / mod.rs
1 //! Ocean Work Graph surface ownership.
2 //!
3 //! This is called the "workbar" or the "work surface". Fresh settings default
4 //! to `Bottom` (round 3, 2026-09-01); `Top`, `Left`, and `Right` remain
5 //! supported and `Off` hides it. It is not the header
6 //! ([`crate::tui::underwater`]) and not the footer.
7 //!
8 //! Two settings are orthogonal and are routinely mixed up:
9 //!
10 //! - **placement** — where it renders. `Bottom` (fresh default) | `Top` |
11 //! `Left` | `Right` | `Off`. Drag-resizing the divider persists
12 //! `work_surface_top_height` (5..=16) or `work_surface_side_width`
13 //! (26..=80) to `settings.toml`.
14 //! - **panel** — what it shows. [`RailPanel`]: `Tasks` (default) | `Agents` |
15 //! `Background` | `Files` | `Notepad` | `Context` | `Git` | `Price`, from
16 //! the `rail_panel` setting. The legacy `sidebar_focus` key migrates into
17 //! it.
18 //!
19 //! So the word "Pinned" on screen is a PANEL name, not a state.
20 //!
21 //! ## Auto-fit by placement
22 //!
23 //! Placement changes *which axis is the ceiling*, not the content rule:
24 //!
25 //! | Placement | Ceiling | Auto-fit | Empty |
26 //! |---|---|---|---|
27 //! | `Top` | `top_height` (rows) | content rows + divider, clamped to ceiling | `height() == 0` |
28 //! | `Left`/`Right` | `side_width` (cols) | full chat height at that width | no column reserved |
29 //! | `Off` | — | — | nothing |
30 //!
31 //! Shared rules: content drives size; the setting is a ceiling, never padding;
32 //! empty work is not a rail. Top never paints a chrome panel title (a checklist
33 //! reads as a checklist); side rails are named by their content's own heading
34 //! row (`Work · …`, `▾ Subagents N`, `Goal: …`) except Context, which keeps a
35 //! muted panel title over its fact list. Narrow hosts that cannot fit a side
36 //! column fall back to Top, where height auto-fit takes over.
37 //!
38 //! ## Row lifetime
39 //!
40 //! The strip is a standing register of this session's work, not a live-only
41 //! view. A to-do or sub-agent row appears when the work exists and stays for
42 //! the rest of the session after it settles — completion is quiet (glyph,
43 //! tone, frozen receipt), never an eviction, and the active goal title
44 //! outlives the work under it. Only transient receipts (aggregated file
45 //! activity, settled operations) expire on the #4688/#4690 lifetimes.
46 //! Auto-fit and the row budget decide how many rows are *visible* at once;
47 //! they never decide membership.
48 //!
49 //! ## Rows are objects — in every panel
50 //!
51 //! Tasks, Agents, and Pinned all render through one row/hitbox pipeline:
52 //! every visible work row is selectable, hoverable, and clickable, and its
53 //! primary action opens the row's world (agent transcript / work inspector).
54 //! Keyboard Enter and mouse click dispatch identically. Context is the one
55 //! line-list panel; it holds facts, not rows.
56 //!
57 //! Height is decided once per frame by [`render::height`]; the row budget it is
58 //! given comes from `crate::tui::ui::rail_row_budget`, which is its only
59 //! production caller.
60 //!
61 //! Placement, scrolling, selection, and pager ownership remain local to this
62 //! component. Every visible work row derives from the active-session graph.
63
64 mod input;
65 mod interaction;
66 mod model;
67 mod render;
68 mod views;
69
70 pub use input::{cycle_view, enter_agents, handle_key, handle_mouse};
71 pub(crate) use interaction::{agent_details_closed, release_focus, select_dock_panel};
72 pub use model::{RailPanel, WorkSurfacePlacement, WorkSurfaceState};
73 pub(crate) use render::collapse_strip;
74 pub use render::{height, render, split_chat};
75
76 #[cfg(test)]
77 mod tests {
78 use super::WorkSurfacePlacement;
79 use std::path::PathBuf;
80
81 use crossterm::event::{
82 KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
83 };
84 use ratatui::{Terminal, backend::TestBackend};
85
86 use crate::config::{ApiProvider, Config};
87 use crate::tools::subagent::{
88 AgentWorkerStatus, FleetRole, MailboxMessage, SubAgentAssignment, SubAgentResult,
89 SubAgentStatus,
90 };
91 use crate::tools::todo::TodoStatus;
92 use crate::tui::app::{
93 AgentCurrentActivity, AgentCurrentActivityStatus, App, SidebarRowAction, ToolDetailRecord,
94 TuiOptions,
95 };
96 use crate::tui::golden_harness::assert_matches_golden;
97 use crate::tui::history::{
98 FileMutationReceipt, GenericToolCell, HistoryCell, PatchSummaryCell, ToolCell, ToolStatus,
99 };
100 use crate::work_graph::{
101 AcceptanceRequirement, ChangeCtx, EdgeKind, EvidenceKindTag, NodeKind, NodeState,
102 OperationBinding, OperationOwnerSnapshot, OwnerState, Provenance, WorkEdge, WorkEdgeId,
103 WorkGraph, WorkGraphChange, WorkNode, WorkNodeId,
104 };
105
106 const SESSION: &str = "work-surface-test";
107
108 fn app() -> App {
109 let options = TuiOptions {
110 use_mouse_capture: true,
111 max_subagents: 4,
112 ..crate::test_support::test_tui_options(PathBuf::from("."))
113 };
114 let mut app = App::new(options, &Config::default());
115 app.ui_locale = codewhale_localization::Locale::En;
116 // Dogfood guard: App::new reads the developer's real settings.toml,
117 // and the 0.9.4 migration maps a legacy sidebar_focus onto the rail
118 // panel. These tests exercise the Tasks panel's row machinery, so
119 // pin it rather than depend on the host file.
120 app.work_surface.panel = super::RailPanel::Tasks;
121 // Not an explicit pick: the auto rule opens the agents view when a
122 // fixture caches a running worker, exactly as the product does.
123 app.work_surface.explicit_view = false;
124 // Most tests in this module predate the fresh left-rail default and
125 // exercise the Top strip's height, divider, overflow, and row layout.
126 // Pin both requested and effective placement; dedicated placement
127 // tests override these fields explicitly.
128 app.work_surface.placement = WorkSurfacePlacement::Top;
129 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
130 app
131 }
132
133 /// The row budget `ui::render` would hand the rail on a terminal of this
134 /// height with real work on screen. Calls the production formula rather
135 /// than restating it, so a change to the chrome accounting shows up here
136 /// instead of silently diverging. The idle-empty budget (where the
137 /// ambient floor bites) is covered end-to-end in `ui::tests`.
138 fn working_budget(app: &App, terminal_height: u16) -> u16 {
139 crate::tui::ui::rail_row_budget(app, 80, terminal_height, false)
140 }
141
142 /// A budget wide enough never to bind, for tests about something else.
143 const AMPLE_BUDGET: u16 = u16::MAX;
144
145 fn add_todos(app: &mut App, count: usize) {
146 let mut todos = app.todos.try_lock().expect("todos");
147 for index in 0..count {
148 todos.add(
149 format!("work item {index}"),
150 if index == 0 {
151 TodoStatus::InProgress
152 } else {
153 TodoStatus::Pending
154 },
155 );
156 }
157 }
158
159 fn operation_graph(state: NodeState) -> crate::work_graph::WorkGraphSnapshot {
160 let objective = WorkNodeId::derive(SESSION, "objective");
161 let operation = WorkNodeId::derive(SESSION, "operation");
162 let ctx = |now| ChangeCtx {
163 session_id: SESSION.to_string(),
164 now,
165 idempotency_key: None,
166 };
167 let node = |id: WorkNodeId, kind, title: &str, now| WorkNode {
168 id,
169 kind,
170 title: title.to_string(),
171 state: NodeState::Ready,
172 acceptance: Vec::new(),
173 binding: None,
174 evidence: None,
175 provenance: Provenance::RuntimeReconcile {
176 source: "test-owner".to_string(),
177 observed_at: now,
178 },
179 created_at: now,
180 updated_at: now,
181 };
182 let mut graph = WorkGraph::new();
183 graph
184 .apply(
185 WorkGraphChange::AddNode {
186 node: node(objective.clone(), NodeKind::Objective, "Ship v0.9.1", 1),
187 },
188 ctx(1),
189 )
190 .expect("objective");
191 graph
192 .apply(
193 WorkGraphChange::AddNode {
194 node: node(
195 operation.clone(),
196 NodeKind::Operation,
197 "Verify installed build",
198 2,
199 ),
200 },
201 ctx(2),
202 )
203 .expect("operation");
204 graph
205 .apply(
206 WorkGraphChange::AddEdge {
207 edge: WorkEdge {
208 id: WorkEdgeId::derive(SESSION, "contains"),
209 kind: EdgeKind::Contains,
210 from: objective,
211 to: operation.clone(),
212 },
213 },
214 ctx(3),
215 )
216 .expect("contains");
217 graph
218 .apply(
219 WorkGraphChange::BindOperation {
220 node: operation.clone(),
221 binding: OperationBinding {
222 external: "shell:shell_1234abcd".to_string(),
223 durable: false,
224 last_observation: None,
225 },
226 },
227 ctx(4),
228 )
229 .expect("binding");
230 if state != NodeState::Ready {
231 graph
232 .apply(
233 WorkGraphChange::UpdateNode {
234 id: operation,
235 patch: crate::work_graph::WorkNodePatch {
236 state: Some(state),
237 ..crate::work_graph::WorkNodePatch::default()
238 },
239 },
240 ctx(5),
241 )
242 .expect("state");
243 }
244 graph.into_snapshot()
245 }
246
247 fn restore_graph(app: &mut App, graph: &crate::work_graph::WorkGraphSnapshot) {
248 app.current_session_id = Some(SESSION.to_string());
249 app.runtime_services
250 .work
251 .as_ref()
252 .expect("Work Graph runtime")
253 .restore(
254 SESSION,
255 Some(graph),
256 &crate::work_graph::project_todos(graph),
257 &crate::work_graph::project_plan(graph),
258 )
259 .expect("restore graph");
260 }
261
262 fn restore_saved_graph(app: &mut App, graph: &crate::work_graph::WorkGraphSnapshot) {
263 app.current_session_id = Some(SESSION.to_string());
264 let state = crate::session_manager::SessionWorkState {
265 graph: Some(graph.clone()),
266 todos: crate::work_graph::project_todos(graph),
267 plan: crate::work_graph::project_plan(graph),
268 };
269 app.restore_work_state(SESSION, std::path::Path::new("."), Some(&state))
270 .expect("restore saved graph");
271 }
272
273 fn render_text(app: &mut App, width: u16, height: u16) -> String {
274 let backend = TestBackend::new(width, height);
275 let mut terminal = Terminal::new(backend).expect("terminal");
276 terminal
277 .draw(|frame| super::render(frame, frame.area(), app))
278 .expect("draw");
279 terminal
280 .backend()
281 .buffer()
282 .content()
283 .iter()
284 .map(|cell| cell.symbol())
285 .collect()
286 }
287
288 fn render_golden_text(app: &mut App, width: u16, height: u16) -> String {
289 let backend = TestBackend::new(width, height);
290 let mut terminal = Terminal::new(backend).expect("terminal");
291 terminal
292 .draw(|frame| super::render(frame, frame.area(), app))
293 .expect("draw");
294 format!("{}\n", terminal_text(&terminal))
295 }
296
297 #[test]
298 fn scheduled_automations_do_not_create_background_work() {
299 let mut app =
300 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
301 app.automation_panel.active_automations = 2;
302 assert!(!super::model::background_has_live_work(&mut app));
303 }
304
305 #[test]
306 fn projection_keeps_every_legacy_todo_as_a_graph_row() {
307 let mut app = app();
308 add_todos(&mut app, 4);
309
310 let rows = super::model::project(&mut app);
311
312 assert!(
313 rows[0].label.starts_with("Work · Running:")
314 || rows[0]
315 .label
316 .starts_with("Work · 1 active · 0 needs input · 3 ready"),
317 "unexpected heading {}",
318 rows[0].label
319 );
320 for index in 0..4 {
321 assert!(
322 rows.iter()
323 .any(|row| row.label == format!("work item {index}"))
324 );
325 }
326 assert!(rows.iter().all(|row| !row.id.0.starts_with("todo:")));
327 }
328
329 #[test]
330 fn coordination_projection_is_one_selectable_work_row_with_shared_details() {
331 use crate::tools::subagent::CoordinationDetailProjection;
332 use crate::tools::subagent::coord::{
333 CoordinationDetailMetrics, DecisionRecord, DecisionStatus,
334 };
335
336 let mut app = app();
337 app.coordination_detail = Some(CoordinationDetailProjection {
338 schema_version: 1,
339 sequence: 7,
340 decisions: vec![DecisionRecord {
341 decision_id: "decision-work".to_string(),
342 subject: "coordination row".to_string(),
343 status: DecisionStatus::Accepted,
344 owner: "release-owner".to_string(),
345 scope: Vec::new(),
346 constraints: vec!["PRIVATE-TRANSCRIPT-MARKER".to_string()],
347 evidence_handles: Vec::new(),
348 version: 2,
349 sequence: 7,
350 }],
351 write_claims: Vec::new(),
352 reconciliations: Vec::new(),
353 context_projections: Vec::new(),
354 contentions: Vec::new(),
355 metrics: CoordinationDetailMetrics {
356 hottest_paths: Vec::new(),
357 package_or_module_growth: None,
358 route_or_cost: None,
359 note: "No active claims".to_string(),
360 },
361 bounded: true,
362 limit: 24,
363 process_lock_held: true,
364 process_lock_note: None,
365 });
366
367 let rows = super::model::project(&mut app);
368 assert_eq!(
369 rows[0].label,
370 "Work · 0 active · 0 needs input · 0 ready · 1 recent"
371 );
372 let row = rows
373 .iter()
374 .find(|row| row.id.0 == "coordination")
375 .expect("coordination Work row");
376 assert_eq!(row.label, "Coordination Work");
377 assert_eq!(row.detail, "1 decisions · 0 contentions · 0 reconciled");
378 let Some(SidebarRowAction::InspectWork { title, body, .. }) = row.primary_action.as_ref()
379 else {
380 panic!("coordination row must open the shared Work inspector");
381 };
382 assert_eq!(title, "Coordination Work");
383 assert!(body.contains("decision-work · coordination row"), "{body}");
384 assert!(
385 body.contains("status accepted · owner release-owner · version 2"),
386 "{body}"
387 );
388 assert!(!body.contains("PRIVATE-TRANSCRIPT-MARKER"), "{body}");
389
390 app.work_surface.placement = WorkSurfacePlacement::Right;
391 app.work_surface.effective_placement = WorkSurfacePlacement::Right;
392 let narrow = render_text(&mut app, 32, 4);
393 assert!(narrow.contains("Coordination Work"), "{narrow}");
394 let _ = super::handle_key(
395 &mut app,
396 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
397 );
398 let action = super::handle_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
399 .expect("Work surface handled Enter")
400 .expect("coordination inspector action");
401 assert!(matches!(action, SidebarRowAction::InspectWork { .. }));
402 }
403
404 #[test]
405 fn empty_coordination_projection_does_not_create_work_chrome() {
406 use crate::tools::subagent::CoordinationDetailProjection;
407 use crate::tools::subagent::coord::{ContextProjectionReceipt, CoordinationDetailMetrics};
408
409 let mut app = app();
410 app.coordination_detail = Some(CoordinationDetailProjection {
411 schema_version: 1,
412 sequence: 3,
413 decisions: Vec::new(),
414 write_claims: Vec::new(),
415 reconciliations: Vec::new(),
416 context_projections: ["agent-a", "agent-b", "agent-c"]
417 .into_iter()
418 .enumerate()
419 .map(|(index, child_id)| ContextProjectionReceipt {
420 child_id: child_id.to_string(),
421 decision_ids: Vec::new(),
422 projected_bytes: 0,
423 deduplicated: 0,
424 omitted: 0,
425 sequence: u64::try_from(index + 1).expect("small fixture sequence"),
426 })
427 .collect(),
428 contentions: Vec::new(),
429 metrics: CoordinationDetailMetrics {
430 hottest_paths: Vec::new(),
431 package_or_module_growth: None,
432 route_or_cost: None,
433 note: "growth and route/cost stay null when the coordination ledger has no authoritative source".to_string(),
434 },
435 bounded: true,
436 limit: 24,
437 process_lock_held: true,
438 process_lock_note: None,
439 });
440
441 let rows = super::model::project(&mut app);
442 assert!(
443 rows.is_empty(),
444 "zero-byte, no-decision coordination receipts must not create Work chrome: {rows:?}"
445 );
446 }
447
448 #[test]
449 fn nonempty_context_projection_remains_inspectable_work() {
450 use crate::tools::subagent::CoordinationDetailProjection;
451 use crate::tools::subagent::coord::{ContextProjectionReceipt, CoordinationDetailMetrics};
452
453 let mut app = app();
454 app.coordination_detail = Some(CoordinationDetailProjection {
455 schema_version: 1,
456 sequence: 1,
457 decisions: Vec::new(),
458 write_claims: Vec::new(),
459 reconciliations: Vec::new(),
460 context_projections: vec![ContextProjectionReceipt {
461 child_id: "agent-a".to_string(),
462 decision_ids: vec!["decision-a".to_string()],
463 projected_bytes: 32,
464 deduplicated: 0,
465 omitted: 0,
466 sequence: 1,
467 }],
468 contentions: Vec::new(),
469 metrics: CoordinationDetailMetrics {
470 hottest_paths: Vec::new(),
471 package_or_module_growth: None,
472 route_or_cost: None,
473 note: String::new(),
474 },
475 bounded: true,
476 limit: 24,
477 process_lock_held: true,
478 process_lock_note: None,
479 });
480
481 let rows = super::model::project(&mut app);
482 assert!(
483 rows.iter().any(|row| row.id.0 == "coordination"),
484 "non-empty context projection must remain inspectable: {rows:?}"
485 );
486 }
487
488 #[test]
489 fn current_blocked_contention_uses_attention_bucket_mark_and_tone() {
490 use crate::tools::subagent::CoordinationDetailProjection;
491 use crate::tools::subagent::coord::{
492 CoordinationDetailMetrics, PersistedWriteClaim, WriteContentionDisposition,
493 WriteContentionReceipt, WriteScopeClaim,
494 };
495
496 let mut app = app();
497 app.coordination_detail = Some(CoordinationDetailProjection {
498 schema_version: 1,
499 sequence: 2,
500 decisions: Vec::new(),
501 write_claims: vec![PersistedWriteClaim {
502 claim: WriteScopeClaim {
503 owner: "worker-a".to_string(),
504 roots: vec!["crates/tui".to_string()],
505 exact_files: Vec::new(),
506 contracts: vec!["ui-contract".to_string()],
507 },
508 sequence: 1,
509 isolated_worktree: false,
510 present_at_claim: Vec::new(),
511 }],
512 reconciliations: Vec::new(),
513 context_projections: Vec::new(),
514 contentions: vec![WriteContentionReceipt {
515 claimant: "worker-b".to_string(),
516 conflicting_owner: "worker-a".to_string(),
517 roots: vec!["crates/tui".to_string()],
518 exact_files: Vec::new(),
519 contracts: vec!["ui-contract".to_string()],
520 disposition: WriteContentionDisposition::BlockedPendingIsolationOrSerialization,
521 resolution_sequence: None,
522 sequence: 2,
523 }],
524 metrics: CoordinationDetailMetrics {
525 hottest_paths: Vec::new(),
526 package_or_module_growth: None,
527 route_or_cost: None,
528 note: "No authoritative metric source".to_string(),
529 },
530 bounded: true,
531 limit: 24,
532 process_lock_held: true,
533 process_lock_note: None,
534 });
535
536 let rows = super::model::project(&mut app);
537 assert_eq!(
538 rows[0].label,
539 "Work · Needs input: Coordination Work · 1 blocked"
540 );
541 let row = rows
542 .iter()
543 .find(|row| row.id.0 == "coordination")
544 .expect("blocked coordination Work row");
545 assert_eq!(row.mark, crate::tui::glyphs::ATTENTION);
546 assert_eq!(row.tone, super::model::WorkTone::Attention);
547 assert_eq!(row.detail, "0 decisions · 1 contentions · 0 reconciled");
548 }
549
550 #[test]
551 fn todos_share_one_canonical_work_projection_without_a_second_heading() {
552 let mut app = app();
553 {
554 let mut todos = app.todos.try_lock().expect("todos");
555 todos.add("finished".to_string(), TodoStatus::Completed);
556 todos.add("current".to_string(), TodoStatus::InProgress);
557 todos.add("next".to_string(), TodoStatus::Pending);
558 }
559
560 let rows = super::model::project(&mut app);
561
562 assert!(
563 rows[0].label.starts_with("Work · Running:")
564 || rows[0].label.starts_with("Work · Ready:"),
565 "expected actionable title heading, got {}",
566 rows[0].label
567 );
568 assert_eq!(
569 rows.iter()
570 .skip(1)
571 .map(|row| row.label.as_str())
572 .collect::<Vec<_>>(),
573 ["finished", "current", "next"]
574 );
575 }
576
577 #[test]
578 fn top_surface_pins_one_progress_receipt_and_numbers_canonical_rows() {
579 let mut app = app();
580 {
581 let mut todos = app.todos.try_lock().expect("todos");
582 todos.add("finished".to_string(), TodoStatus::Completed);
583 todos.add("current".to_string(), TodoStatus::InProgress);
584 todos.add("next".to_string(), TodoStatus::Pending);
585 }
586
587 let text = render_text(&mut app, 80, 7);
588 let done = format!("1 · {} finished", crate::tui::glyphs::DONE);
589 let current = format!("2 · {} current", crate::tui::glyphs::SELECTION);
590 let next = format!("3 · {} next", crate::tui::glyphs::READY);
591
592 assert!(text.contains("To-do · 1/3 · 2 left"), "{text:?}");
593 assert_eq!(text.matches("To-do ·").count(), 1, "{text:?}");
594 assert!(text.contains(&done), "{text:?}");
595 assert!(text.contains(&current), "{text:?}");
596 assert!(text.contains(&next), "{text:?}");
597 assert!(
598 text.find(&done) < text.find(&current) && text.find(&current) < text.find(&next),
599 "canonical order drifted: {text:?}"
600 );
601 assert_eq!(app.work_surface.hitboxes.len(), 3);
602 assert_eq!(app.work_surface.hitboxes[0].row_y, 2);
603 }
604
605 #[test]
606 fn top_strip_auto_fits_step_count_up_to_caps() {
607 // Two steps need four literal lines, but the readable surface floor
608 // wins so the same saved size can also seat goal + Agent state.
609 let mut two_steps = app();
610 two_steps.work_surface.top_height = 8;
611 add_todos(&mut two_steps, 2);
612 let budget = working_budget(&two_steps, 40);
613 assert_eq!(
614 super::height(&mut two_steps, 100, 40, budget),
615 super::model::TOP_HEIGHT_MIN
616 );
617
618 // Ten steps: content wants 12 lines, the default 8-line cap wins.
619 let mut ten_steps = app();
620 ten_steps.work_surface.top_height = 8;
621 add_todos(&mut ten_steps, 10);
622 let budget = working_budget(&ten_steps, 40);
623 assert_eq!(super::height(&mut ten_steps, 100, 40, budget), 8);
624
625 // Short terminal: the transcript's spare rows beat both content and
626 // the configured cap. A 12-row terminal spends 1 on the header, 1 on
627 // the phase strip and 3 on the bordered composer, and owes the
628 // transcript its 3-row floor — so only 4 rows are spare. That is below
629 // the readable floor, so the whole rail yields rather than painting a
630 // divider over clipped work.
631 let mut short_terminal = app();
632 short_terminal.work_surface.top_height = 8;
633 add_todos(&mut short_terminal, 10);
634 let budget = working_budget(&short_terminal, 12);
635 assert_eq!(super::height(&mut short_terminal, 100, 12, budget), 0);
636
637 // Nothing to show: no strip at all.
638 let mut empty = app();
639 empty.work_surface.top_height = 8;
640 assert_eq!(super::height(&mut empty, 100, 40, AMPLE_BUDGET), 0);
641 }
642
643 /// A strip that reports zero rows is not on screen, so the interaction
644 /// state describing it must go with it. Stale hitboxes outlive the rows
645 /// they described: the transcript rows that replaced the strip would keep
646 /// routing clicks into a panel that is not there.
647 #[test]
648 fn a_yielded_strip_drops_its_interaction_state() {
649 // Each case is a distinct zero-return inside `height`, and every one
650 // of them has to tear down. `starve` turns a rendered strip into a
651 // yielded one; the assertions are identical either way. The first two
652 // are the returns this yield rule introduced — the ones that had no
653 // teardown at all.
654 type Starve = fn(&mut App) -> (u16, u16, u16);
655 let cases: [(&str, Starve); 3] = [
656 ("budget starves the Tasks strip", |_app| (100, 40, 0)),
657 ("budget starves a switched-to panel", |app| {
658 app.work_surface.panel = super::RailPanel::Tasks;
659 (100, 40, 0)
660 }),
661 ("placement off", |app| {
662 app.work_surface.placement = WorkSurfacePlacement::Off;
663 (100, 40, AMPLE_BUDGET)
664 }),
665 ];
666
667 for (label, starve) in cases {
668 let mut app = app();
669 app.work_surface.placement = WorkSurfacePlacement::Top;
670 // `app()` reads the developer's real settings.toml. Pin the height
671 // too, or the strip this test renders to earn its hitboxes depends
672 // on whoever runs the suite.
673 app.work_surface.top_height = 8;
674 add_todos(&mut app, 4);
675
676 // Earn a real strip, so the hitboxes under test are the ones the
677 // renderer actually produces rather than a fixture's guess.
678 render_text(&mut app, 100, 12);
679 assert!(
680 !app.work_surface.hitboxes.is_empty(),
681 "{label}: setup never rendered a strip to tear down"
682 );
683 app.work_surface.focused = true;
684 app.work_surface.resizing = true;
685 app.work_surface.divider_hovered = true;
686
687 let (width, height, budget) = starve(&mut app);
688 assert_eq!(
689 super::height(&mut app, width, height, budget),
690 0,
691 "{label}: expected the strip to yield"
692 );
693 assert!(
694 app.work_surface.hitboxes.is_empty(),
695 "{label}: left {} stale hitboxes behind",
696 app.work_surface.hitboxes.len()
697 );
698 assert!(
699 app.work_surface.last_area.is_none(),
700 "{label}: stale last_area"
701 );
702 assert!(!app.work_surface.focused, "{label}: focus survived");
703 assert!(!app.work_surface.resizing, "{label}: resize drag survived");
704 assert!(
705 !app.work_surface.divider_hovered,
706 "{label}: divider hover survived"
707 );
708 }
709 }
710
711 /// `top_height` is a ceiling, not a fixed size. The compact floor must
712 /// still seat the goal, work progress, and actionable rows; content longer
713 /// than the ceiling is clamped rather than padded with blank water.
714 #[test]
715 fn a_short_top_height_caps_content_rather_than_collapsing() {
716 let mut capped = app();
717 capped.work_surface.placement = WorkSurfacePlacement::Top;
718 capped.work_surface.panel = super::RailPanel::Tasks;
719 capped.work_surface.top_height = super::model::TOP_HEIGHT_MIN;
720 capped.composer_border = true;
721 // Goal + several checklist rows: content wants more than the readable
722 // floor, so the cap wins without hiding every actionable row.
723 capped.goal.objective = Some("ship the release".to_string());
724 add_todos(&mut capped, 6);
725 let budget = working_budget(&capped, 40);
726 assert_eq!(
727 super::height(&mut capped, 100, 40, budget),
728 super::model::TOP_HEIGHT_MIN,
729 "short top_height is a cap the strip must fit under, not a cliff"
730 );
731
732 // Content shorter than the cap shrinks to the readable floor rather
733 // than padding all the way out to the saved 8-row cap.
734 let mut short = app();
735 short.work_surface.placement = WorkSurfacePlacement::Top;
736 short.work_surface.panel = super::RailPanel::Tasks;
737 short.work_surface.top_height = 8;
738 short.goal.objective = Some("one goal only".to_string());
739 let budget = working_budget(&short, 40);
740 let h = super::height(&mut short, 100, 40, budget);
741 assert_eq!(h, super::model::TOP_HEIGHT_MIN);
742 }
743
744 /// Non-Tasks Top panels auto-fit the same way Tasks always did: content
745 /// rows + divider, never a fixed four-row chrome band. An active goal
746 /// adds exactly one title row (not a panel name).
747 #[test]
748 fn top_panel_auto_fits_content_like_tasks() {
749 let mut pinned = app();
750 pinned.work_surface.placement = WorkSurfacePlacement::Top;
751 pinned.work_surface.panel = super::RailPanel::Tasks;
752 pinned.work_surface.top_height = 12;
753 pinned.goal.objective = Some("goal".to_string());
754 add_todos(&mut pinned, 3);
755 let budget = working_budget(&pinned, 40);
756 let h = super::height(&mut pinned, 100, 40, budget);
757 // goal title + 3 checklist + divider ≈ 5; must not be the old fixed 4,
758 // and must not pad out to the 12-row cap.
759 assert!(
760 (4..=8).contains(&h),
761 "Pinned should auto-fit checklist content, got {h}"
762 );
763
764 // Empty Pinned collapses entirely.
765 let mut empty = app();
766 empty.work_surface.placement = WorkSurfacePlacement::Top;
767 empty.work_surface.panel = super::RailPanel::Tasks;
768 empty.work_surface.top_height = 12;
769 assert_eq!(
770 super::height(&mut empty, 100, 40, AMPLE_BUDGET),
771 0,
772 "empty Pinned is not a panel"
773 );
774
775 // Empty Agents collapses too (no "No agents" chrome strip).
776 let mut agents = app();
777 agents.work_surface.placement = WorkSurfacePlacement::Top;
778 agents.work_surface.panel = super::RailPanel::Agents;
779 agents.work_surface.top_height = 12;
780 assert_eq!(
781 super::height(&mut agents, 100, 40, AMPLE_BUDGET),
782 0,
783 "empty Agents is not a panel"
784 );
785 }
786
787 /// A chosen panel remains usable when only one content row fits.
788 #[test]
789 fn compact_explicit_view_keeps_content_ahead_of_goal_chrome() {
790 let mut app = app();
791 app.goal.objective = Some("ship the release".to_string());
792 super::select_dock_panel(&mut app, super::RailPanel::Agents);
793 let text = render_text(&mut app, 40, 3);
794 assert!(text.contains("no agents have run this session"), "{text:?}");
795 assert!(app.work_surface.focused);
796 assert!(
797 super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_some()
798 );
799 assert!(!app.work_surface.explicit_view);
800 }
801
802 /// Top titles only when a live goal is set — never the panel name.
803 #[test]
804 fn top_title_is_goal_only_never_panel_chrome() {
805 // With a goal: title is "Goal: …".
806 let mut with_goal = app();
807 with_goal.work_surface.placement = WorkSurfacePlacement::Top;
808 with_goal.work_surface.panel = super::RailPanel::Tasks;
809 with_goal.work_surface.top_height = 8;
810 with_goal.goal.objective = Some("ship 0.9.4".to_string());
811 let text = render_text(&mut with_goal, 80, 8);
812 assert!(
813 text.contains("Goal: ship 0.9.4"),
814 "active goal must be the Top title: {text:?}"
815 );
816 assert!(
817 !render_rows(&mut with_goal, 80, 8)
818 .iter()
819 .skip(1)
820 .any(|row| row.contains("Pinned")),
821 "panel name is not a Top title: {text:?}"
822 );
823
824 // Without a goal, only checklist: no Goal title, no Pinned chrome.
825 let mut no_goal = app();
826 no_goal.work_surface.placement = WorkSurfacePlacement::Top;
827 no_goal.work_surface.panel = super::RailPanel::Tasks;
828 no_goal.work_surface.top_height = 8;
829 add_todos(&mut no_goal, 2);
830 let text = render_text(&mut no_goal, 80, 6);
831 assert!(
832 !text.contains("Goal:"),
833 "no live goal → no Goal title: {text:?}"
834 );
835 assert!(
836 !render_rows(&mut no_goal, 80, 6)
837 .iter()
838 .skip(1)
839 .any(|row| row.contains("Pinned")),
840 "panel name is never a Top title: {text:?}"
841 );
842 }
843
844 /// Tasks with only a goal (no todos/agents) still shows a strip.
845 #[test]
846 fn top_tasks_goal_alone_still_renders_a_strip() {
847 let mut app = app();
848 app.work_surface.placement = WorkSurfacePlacement::Top;
849 app.work_surface.panel = super::RailPanel::Tasks;
850 app.work_surface.top_height = 8;
851 app.goal.objective = Some("only a goal".to_string());
852 let budget = working_budget(&app, 40);
853 let h = super::height(&mut app, 100, 40, budget);
854 assert!(h >= 2, "goal alone must reserve title + divider, got {h}");
855 let text = render_text(&mut app, 80, h);
856 assert!(
857 text.contains("Goal: only a goal"),
858 "goal-alone strip must paint the title: {text:?}"
859 );
860 }
861
862 /// Side rails share the empty-collapse rule: no content → no column.
863 /// Width stays the configured ceiling when content exists.
864 #[test]
865 fn side_rail_collapses_when_empty_and_reserves_when_contentful() {
866 let area = ratatui::layout::Rect::new(0, 0, 120, 32);
867
868 // Empty Pinned: no side column.
869 let mut empty = app();
870 empty.work_surface.placement = WorkSurfacePlacement::Right;
871 empty.work_surface.panel = super::RailPanel::Tasks;
872 empty.work_surface.side_width = 30;
873 assert_eq!(
874 super::split_chat(&mut empty, area, 0),
875 (area, None),
876 "empty Pinned must not reserve a side column"
877 );
878
879 // Contentful Pinned: full-height column at configured width.
880 let mut full = app();
881 full.work_surface.placement = WorkSurfacePlacement::Right;
882 full.work_surface.panel = super::RailPanel::Tasks;
883 full.work_surface.side_width = 30;
884 full.goal.objective = Some("ship it".to_string());
885 let (chat, rail) = super::split_chat(&mut full, area, 0);
886 let rail = rail.expect("contentful Pinned reserves a side rail");
887 assert_eq!(rail.width, 30);
888 assert_eq!(chat.width, area.width - 30);
889 assert_eq!(rail.height, area.height);
890 }
891
892 #[test]
893 fn minimum_top_surface_keeps_a_numbered_todo_selectable() {
894 let mut app = app();
895 add_todos(&mut app, 2);
896
897 let text = render_text(&mut app, 40, 5);
898
899 assert!(text.contains("1 ·"), "{text:?}");
900 assert!(!app.work_surface.hitboxes.is_empty());
901 assert_eq!(app.work_surface.hitboxes[0].row_y, 2);
902 }
903
904 #[test]
905 fn compact_progress_window_reveals_current_without_reordering() {
906 let mut app = app();
907 {
908 let mut todos = app.todos.try_lock().expect("todos");
909 todos.add("finished".to_string(), TodoStatus::Completed);
910 todos.add("current".to_string(), TodoStatus::InProgress);
911 todos.add("next".to_string(), TodoStatus::Pending);
912 }
913
914 // The current item must win the compact window while retaining its
915 // canonical ordinal.
916 let text = render_text(&mut app, 80, 6);
917
918 assert!(text.contains("To-do · 1/3 · 2 left"), "{text:?}");
919 assert!(
920 text.contains(&format!("2 · {} current", crate::tui::glyphs::SELECTION)),
921 "{text:?}"
922 );
923 assert_eq!(app.work_surface.hitboxes[0].row_y, 2);
924 }
925
926 #[test]
927 fn settled_file_tools_aggregate_once_and_keep_only_safe_targets() {
928 let mut app = app();
929 app.current_session_id = Some(SESSION.to_string());
930 app.workspace = PathBuf::from("/workspace/project");
931 for (id, name, input, status) in [
932 (
933 "read-1",
934 "read_file",
935 serde_json::json!({"path": "/workspace/project/src/lib.rs"}),
936 ToolStatus::Success,
937 ),
938 (
939 "search-1",
940 "grep_files",
941 serde_json::json!({"pattern": "WorkSurfaceState"}),
942 ToolStatus::Success,
943 ),
944 (
945 "write-1",
946 "edit_file",
947 serde_json::json!({"path": "src/lib.rs"}),
948 ToolStatus::Success,
949 ),
950 (
951 "read-external",
952 "read_file",
953 serde_json::json!({"path": "/Users/alice/private.txt"}),
954 ToolStatus::Failed,
955 ),
956 ] {
957 app.add_message(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
958 name: name.to_string(),
959 status,
960 input_summary: None,
961 output: Some("done".to_string()),
962 prompts: None,
963 spillover_path: None,
964 output_summary: None,
965 is_diff: false,
966 })));
967 let index = app.history.len() - 1;
968 app.tool_details_by_cell.insert(
969 index,
970 ToolDetailRecord {
971 tool_id: id.to_string(),
972 tool_name: name.to_string(),
973 input,
974 output: Some("done".to_string()),
975 },
976 );
977 }
978
979 let rows = super::model::project(&mut app);
980 let activity = rows
981 .iter()
982 .find(|row| row.id.0 == "activity:aggregate")
983 .expect("aggregated activity row");
984 assert!(
985 activity.label.contains("Read 1 files")
986 && activity.label.contains("Searched 1 patterns")
987 && activity.label.contains("Wrote 1 files"),
988 "aggregated label: {}",
989 activity.label
990 );
991 assert!(!activity.detail.contains("/Users/alice"));
992 assert!(!activity.label.contains("WorkSurfaceState"));
993 }
994
995 #[test]
996 fn agent_rows_show_role_assignment_and_open_the_agent_transcript() {
997 let mut app = app();
998 app.current_session_id = Some(SESSION.to_string());
999 app.subagent_cache.push(SubAgentResult {
1000 usage: None,
1001 name: "agent_worker".to_string(),
1002 agent_id: "agent_worker".to_string(),
1003 context_mode: "fresh".to_string(),
1004 fork_context: false,
1005 workspace: None,
1006 git_branch: None,
1007 agent_type: FleetRole::Builder,
1008 assignment: SubAgentAssignment {
1009 objective: "Wire settled file activity".to_string(),
1010 role: Some("general".to_string()),
1011 },
1012 model: "test-model".to_string(),
1013 nickname: Some("Blue Whale".to_string()),
1014 status: SubAgentStatus::Running,
1015 worker_status: Some(AgentWorkerStatus::RunningTool),
1016 runtime_permissions: None,
1017 parent_run_id: None,
1018 spawn_depth: 1,
1019 child_route: None,
1020 result: None,
1021 steps_taken: 2,
1022 checkpoint: None,
1023 needs_input: None,
1024 duration_ms: 50,
1025 started_at: None,
1026 from_prior_session: false,
1027 });
1028 app.agent_progress_meta.insert(
1029 "agent_worker".to_string(),
1030 crate::tui::app::AgentProgressMeta {
1031 current_activity: Some(AgentCurrentActivity::bounded(
1032 AgentCurrentActivityStatus::RunningTool,
1033 None,
1034 Some("File.apply_patch".to_string()),
1035 Some(2),
1036 )),
1037 current_tool: Some("apply_patch".to_string()),
1038 files_touched: 2,
1039 ..crate::tui::app::AgentProgressMeta::default()
1040 },
1041 );
1042
1043 let rows = super::model::project(&mut app);
1044 let row = rows
1045 .iter()
1046 .find(|row| row.id.0 == "worker:agent_worker")
1047 .expect("agent work row");
1048 // The identity column leads with the agent's nickname and keeps the
1049 // fleet role as the fallback spelling. It is never the raw agent id
1050 // (#36), and carries no `(+N)` while the agent is childless.
1051 assert_eq!(row.label, "Blue Whale");
1052 let facts = row.agent.as_ref().expect("agent row facts");
1053 assert_eq!(facts.role_label, "general");
1054 assert_eq!(facts.objective, "Wire settled file activity");
1055 assert_eq!(facts.elapsed_secs, Some(0));
1056 // No usage envelope has been seen, so there is no token figure at all.
1057 assert_eq!(facts.tokens, None);
1058 assert!(row.detail.contains("Wire settled file activity"));
1059 assert!(row.detail.contains("using File.apply_patch"));
1060 assert!(row.detail.contains("step 2"));
1061 assert!(row.detail.contains("2 files changed"));
1062 // One agent, one destination (v0.9.7): activation opens the agent's
1063 // transcript directly; Agent Details is the secondary action.
1064 assert_eq!(
1065 row.primary_action,
1066 Some(SidebarRowAction::OpenAgentTranscript {
1067 agent_id: "agent_worker".to_string(),
1068 })
1069 );
1070 }
1071
1072 fn cached_worker(
1073 id: &str,
1074 role: &str,
1075 nickname: Option<&str>,
1076 parent_run_id: Option<&str>,
1077 status: SubAgentStatus,
1078 ) -> SubAgentResult {
1079 SubAgentResult {
1080 // `name` is the raw session id in production snapshots — the
1081 // strip must never render it (#36).
1082 usage: None,
1083 name: id.to_string(),
1084 agent_id: id.to_string(),
1085 context_mode: "fresh".to_string(),
1086 fork_context: false,
1087 workspace: None,
1088 git_branch: None,
1089 agent_type: FleetRole::Builder,
1090 assignment: SubAgentAssignment {
1091 objective: format!("objective for {id}"),
1092 role: Some(role.to_string()),
1093 },
1094 model: "test-model".to_string(),
1095 nickname: nickname.map(str::to_string),
1096 status,
1097 worker_status: None,
1098 runtime_permissions: None,
1099 parent_run_id: parent_run_id.map(str::to_string),
1100 spawn_depth: u32::from(parent_run_id.is_some()) + 1,
1101 child_route: None,
1102 result: None,
1103 steps_taken: 1,
1104 checkpoint: None,
1105 needs_input: None,
1106 duration_ms: 50,
1107 started_at: None,
1108 from_prior_session: false,
1109 }
1110 }
1111
1112 #[test]
1113 fn agent_rows_identify_by_fleet_role_and_never_leak_raw_ids() {
1114 // #36: the strip identifies an agent by its fleet role; the raw agent
1115 // id hash is noise and must never render as the "name". Flat fan-outs
1116 // carry no nesting chrome.
1117 let mut app = app();
1118 app.current_session_id = Some(SESSION.to_string());
1119 app.subagent_cache.push(cached_worker(
1120 "agent_e0b2dcf1",
1121 "builder",
1122 None,
1123 None,
1124 SubAgentStatus::Running,
1125 ));
1126 app.subagent_cache.push(cached_worker(
1127 "agent_99aa77bb",
1128 "scout",
1129 None,
1130 None,
1131 SubAgentStatus::Running,
1132 ));
1133
1134 let rows = super::model::project(&mut app);
1135 let first = rows
1136 .iter()
1137 .find(|row| row.id.0 == "worker:agent_e0b2dcf1")
1138 .expect("first agent row");
1139 let second = rows
1140 .iter()
1141 .find(|row| row.id.0 == "worker:agent_99aa77bb")
1142 .expect("second agent row");
1143 assert_eq!(first.label, "builder");
1144 assert_eq!(second.label, "scout");
1145 assert!(first.detail.starts_with("running"), "{}", first.detail);
1146 for row in rows.iter().filter(|row| row.id.0.starts_with("worker:")) {
1147 assert!(!row.label.contains("agent_e0b2dcf1"), "{}", row.label);
1148 assert!(!row.label.contains("agent_99aa77bb"), "{}", row.label);
1149 assert!(
1150 !row.label.contains('↳'),
1151 "flat fan-out must not show nesting chrome: {}",
1152 row.label
1153 );
1154 }
1155 }
1156
1157 #[test]
1158 fn agent_rows_order_and_indent_nested_spawns_under_their_parent() {
1159 // #36: nesting is visible only when actually present — the child
1160 // renders directly under its parent with a `↳` indent, and the parent
1161 // advertises the child it spawned as `(+1)`.
1162 let mut app = app();
1163 app.current_session_id = Some(SESSION.to_string());
1164 app.subagent_cache.push(cached_worker(
1165 "agent_child",
1166 "scout",
1167 None,
1168 Some("agent_parent"),
1169 SubAgentStatus::Running,
1170 ));
1171 app.subagent_cache.push(cached_worker(
1172 "agent_parent",
1173 "builder",
1174 None,
1175 None,
1176 SubAgentStatus::Running,
1177 ));
1178
1179 let rows = super::model::project(&mut app);
1180 let worker_labels = rows
1181 .iter()
1182 .filter(|row| row.id.0.starts_with("worker:"))
1183 .map(|row| row.label.as_str())
1184 .collect::<Vec<_>>();
1185 let parent_pos = worker_labels
1186 .iter()
1187 .position(|label| *label == "builder (+1)")
1188 .expect("parent row label with child count");
1189 let child_pos = worker_labels
1190 .iter()
1191 .position(|label| *label == "↳ scout")
1192 .expect("indented child row label");
1193 assert!(
1194 child_pos == parent_pos + 1,
1195 "child must render directly under its parent: {worker_labels:?}"
1196 );
1197 }
1198
1199 #[test]
1200 fn agent_rows_completed_agents_render_quietly_without_spawn_metadata() {
1201 // #36: quiet completion — a finished agent keeps status + objective;
1202 // in-flight metadata (tool, step counters, file tallies) must not
1203 // linger as a receipt dump.
1204 let mut app = app();
1205 app.current_session_id = Some(SESSION.to_string());
1206 app.subagent_cache.push(cached_worker(
1207 "agent_done",
1208 "builder",
1209 None,
1210 None,
1211 SubAgentStatus::Completed,
1212 ));
1213 app.agent_progress_meta.insert(
1214 "agent_done".to_string(),
1215 crate::tui::app::AgentProgressMeta {
1216 current_activity: Some(AgentCurrentActivity::bounded(
1217 AgentCurrentActivityStatus::Done,
1218 Some("apply_patch finished".to_string()),
1219 Some("File.apply_patch".to_string()),
1220 Some(7),
1221 )),
1222 current_tool: Some("apply_patch".to_string()),
1223 files_touched: 4,
1224 ..crate::tui::app::AgentProgressMeta::default()
1225 },
1226 );
1227
1228 let rows = super::model::project(&mut app);
1229 let row = rows
1230 .iter()
1231 .find(|row| row.id.0 == "worker:agent_done")
1232 .expect("completed agent row");
1233 assert!(row.detail.contains("completed"), "{}", row.detail);
1234 assert!(
1235 row.detail.contains("objective for agent_done"),
1236 "{}",
1237 row.detail
1238 );
1239 assert!(!row.detail.contains("using "), "{}", row.detail);
1240 assert!(!row.detail.contains("step 7"), "{}", row.detail);
1241 assert!(!row.detail.contains("files changed"), "{}", row.detail);
1242 }
1243
1244 // ---- Fleet row layout -------------------------------------------------
1245
1246 /// Painted lines, one per terminal row, trailing padding removed.
1247 fn render_rows(app: &mut App, width: u16, height: u16) -> Vec<String> {
1248 let backend = TestBackend::new(width, height);
1249 let mut terminal = Terminal::new(backend).expect("terminal");
1250 terminal
1251 .draw(|frame| super::render(frame, frame.area(), app))
1252 .expect("draw");
1253 let buffer = terminal.backend().buffer().clone();
1254 (0..height)
1255 .map(|y| {
1256 (0..width)
1257 .map(|x| buffer[(x, y)].symbol())
1258 .collect::<String>()
1259 .trim_end()
1260 .to_string()
1261 })
1262 .collect()
1263 }
1264
1265 fn fleet_row(rows: &[String]) -> String {
1266 rows.iter()
1267 .find(|line| line.contains("Streaming"))
1268 .cloned()
1269 .unwrap_or_else(|| panic!("no fleet row in {rows:?}"))
1270 }
1271
1272 fn fleet_worker(
1273 id: &str,
1274 role: &str,
1275 objective: &str,
1276 duration_ms: u64,
1277 status: SubAgentStatus,
1278 ) -> SubAgentResult {
1279 let mut agent = cached_worker(id, role, None, None, status);
1280 agent.assignment.objective = objective.to_string();
1281 agent.duration_ms = duration_ms;
1282 agent
1283 }
1284
1285 /// Seed a live fleet of one, with a reported token spend.
1286 fn fleet_app(tokens: Option<u64>) -> App {
1287 let mut app = app();
1288 app.current_session_id = Some(SESSION.to_string());
1289 app.subagent_cache.push(fleet_worker(
1290 "agent_stream",
1291 "general-purpose",
1292 "Streaming dead-code removal",
1293 753_000,
1294 SubAgentStatus::Running,
1295 ));
1296 app.agent_progress_meta.insert(
1297 "agent_stream".to_string(),
1298 crate::tui::app::AgentProgressMeta {
1299 received_tokens: tokens,
1300 ..crate::tui::app::AgentProgressMeta::default()
1301 },
1302 );
1303 app
1304 }
1305
1306 #[test]
1307 fn fleet_row_lays_out_type_objective_and_a_right_aligned_receipt() {
1308 let mut app = fleet_app(Some(111_900));
1309 let rows = render_rows(&mut app, 100, 4);
1310
1311 assert_eq!(
1312 fleet_row(&rows),
1313 " ▸ general-purpose running Streaming dead-code removal \
1314 12m 33s · ↓ 111.9k tokens"
1315 );
1316 // The group header the strip already had stays put.
1317 assert!(
1318 rows.iter().any(|line| line.contains("Subagents 1")),
1319 "{rows:?}"
1320 );
1321 }
1322
1323 #[test]
1324 fn focused_worker_row_carries_the_left_marker_and_queued_follow_ups() {
1325 let mut app = fleet_app(Some(111_900));
1326 // No focus, nothing queued: the row is exactly as before.
1327 let plain = fleet_row(&render_rows(&mut app, 100, 4));
1328 assert!(!plain.starts_with("❯"), "{plain}");
1329 assert!(!plain.contains("queued"), "{plain}");
1330
1331 crate::tui::agent_focus::focus_agent(&mut app, "agent_stream");
1332 app.agent_queued_follow_ups
1333 .insert("agent_stream".to_string(), 1);
1334 let focused = fleet_row(&render_rows(&mut app, 110, 4));
1335 assert!(
1336 focused.trim_start().starts_with("❯ ▸ general-purpose"),
1337 "left-edge marker names the addressed fork: {focused}"
1338 );
1339 assert!(focused.ends_with("· 1 queued"), "{focused}");
1340
1341 // The counter is the runtime's truth: once the child takes the input
1342 // the next AgentList refresh clears it and the suffix disappears.
1343 app.agent_queued_follow_ups.clear();
1344 let drained = fleet_row(&render_rows(&mut app, 110, 4));
1345 assert!(!drained.contains("queued"), "{drained}");
1346 // Leaving focus removes the gutter again.
1347 crate::tui::agent_focus::exit_focus(&mut app);
1348 let back = fleet_row(&render_rows(&mut app, 100, 4));
1349 assert_eq!(back, plain);
1350 }
1351
1352 #[test]
1353 fn fleet_row_repaints_resolved_model_and_each_distinct_usage_total() {
1354 let mut app = fleet_app(None);
1355 crate::tui::ui::record_agent_spawned_route(&mut app, "agent_stream", "deepseek-v4-pro");
1356 let launched = fleet_row(&render_rows(&mut app, 120, 4));
1357 assert!(launched.contains("deepseek-v4-pro"), "{launched}");
1358 assert!(!launched.contains("tokens"), "{launched}");
1359
1360 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
1361 None,
1362 ApiProvider::Deepseek,
1363 ApiProvider::Deepseek.as_str(),
1364 "deepseek-v4-pro",
1365 Some(ApiProvider::Deepseek.default_base_url()),
1366 chrono::Utc::now(),
1367 );
1368 let usage = |source_id: &str, input_tokens, output_tokens| MailboxMessage::TokenUsage {
1369 agent_id: "agent_stream".to_string(),
1370 source_id: source_id.to_string(),
1371 route: Box::new(route.clone()),
1372 usage: codewhale_models::Usage {
1373 input_tokens,
1374 output_tokens,
1375 ..Default::default()
1376 },
1377 };
1378
1379 crate::tui::subagent_routing::handle_subagent_mailbox(
1380 &mut app,
1381 99,
1382 &usage("response-1", 10_000, 1_000),
1383 );
1384 let first = fleet_row(&render_rows(&mut app, 120, 4));
1385 assert!(first.contains("deepseek-v4-pro"), "{first}");
1386 assert!(first.contains("11.0k tokens"), "{first}");
1387
1388 // Replaying the same mailbox envelope must not inflate the receipt.
1389 crate::tui::subagent_routing::handle_subagent_mailbox(
1390 &mut app,
1391 1,
1392 &usage("response-1", 10_000, 1_000),
1393 );
1394 let replay = fleet_row(&render_rows(&mut app, 120, 4));
1395 assert!(replay.contains("11.0k tokens"), "{replay}");
1396
1397 crate::tui::subagent_routing::handle_subagent_mailbox(
1398 &mut app,
1399 2,
1400 &usage("response-2", 20_000, 2_000),
1401 );
1402 let second = fleet_row(&render_rows(&mut app, 120, 4));
1403 assert!(second.contains("deepseek-v4-pro"), "{second}");
1404 assert!(second.contains("33.0k tokens"), "{second}");
1405 }
1406
1407 #[test]
1408 fn fleet_row_shows_remaining_todos_only_when_the_ledger_has_unsettled_work() {
1409 let mut app = fleet_app(Some(1_200));
1410 app.agent_progress_meta
1411 .get_mut("agent_stream")
1412 .expect("meta")
1413 .todos_remaining = Some(3);
1414
1415 let with_left = fleet_row(&render_rows(&mut app, 100, 4));
1416 assert!(
1417 with_left.contains("3 left"),
1418 "unsettled ledger must surface on the receipt: {with_left}"
1419 );
1420 assert!(
1421 with_left.contains("↓") && with_left.contains("tokens"),
1422 "tokens stay alongside the remaining chip: {with_left}"
1423 );
1424
1425 // Fully settled list → quiet (no fabricated zero chip).
1426 app.agent_progress_meta
1427 .get_mut("agent_stream")
1428 .expect("meta")
1429 .todos_remaining = Some(0);
1430 let settled = fleet_row(&render_rows(&mut app, 100, 4));
1431 assert!(
1432 !settled.contains("left"),
1433 "zero remaining must not paint a chip: {settled}"
1434 );
1435
1436 // No ledger published → quiet.
1437 app.agent_progress_meta
1438 .get_mut("agent_stream")
1439 .expect("meta")
1440 .todos_remaining = None;
1441 let absent = fleet_row(&render_rows(&mut app, 100, 4));
1442 assert!(
1443 !absent.contains("left"),
1444 "missing ledger must not invent a chip: {absent}"
1445 );
1446 }
1447
1448 #[test]
1449 fn fleet_identity_prefers_the_nickname_and_falls_back_to_the_role() {
1450 // Nicknames are CodeWhale identity, so they lead. An agent that has
1451 // none falls back to its fleet role rather than showing a blank or a
1452 // fabricated name.
1453 let mut app = app();
1454 app.current_session_id = Some(SESSION.to_string());
1455 let mut named = fleet_worker(
1456 "agent_named",
1457 "general-purpose",
1458 "Streaming dead-code removal",
1459 753_000,
1460 SubAgentStatus::Running,
1461 );
1462 named.nickname = Some("Fluke".to_string());
1463 app.subagent_cache.push(named);
1464 app.subagent_cache.push(fleet_worker(
1465 "agent_plain",
1466 "general-purpose",
1467 "Ambient visual calm-down",
1468 741_000,
1469 SubAgentStatus::Running,
1470 ));
1471
1472 let rows = super::model::project(&mut app);
1473 let row = |id: &str| {
1474 rows.iter()
1475 .find(|row| row.id.0 == format!("worker:{id}"))
1476 .unwrap_or_else(|| panic!("row for {id}"))
1477 };
1478 assert_eq!(row("agent_named").label, "Fluke");
1479 assert_eq!(
1480 row("agent_named").agent.as_ref().expect("facts").role_label,
1481 "general-purpose"
1482 );
1483 // No nickname: the identity and its fallback are the same string.
1484 assert_eq!(row("agent_plain").label, "general-purpose");
1485
1486 // Both spellings share one column, so the objectives stay aligned.
1487 let painted = render_rows(&mut app, 100, 5);
1488 let named_line = painted
1489 .iter()
1490 .find(|line| line.contains("Fluke"))
1491 .expect("nicknamed row");
1492 let plain_line = painted
1493 .iter()
1494 .find(|line| line.contains("general-purpose"))
1495 .expect("un-nicknamed row");
1496 assert_eq!(
1497 named_line.find("Streaming"),
1498 plain_line.find("Ambient"),
1499 "objectives must share a column:\n{named_line}\n{plain_line}"
1500 );
1501 }
1502
1503 #[test]
1504 fn an_identity_too_wide_for_the_column_falls_back_without_widening_it() {
1505 // The identity column is shared, so one outlier must not starve every
1506 // other objective — and a name is shown whole or not at all.
1507 let mut app = app();
1508 app.current_session_id = Some(SESSION.to_string());
1509 let mut long = fleet_worker(
1510 "agent_long",
1511 "general-purpose",
1512 "Streaming dead-code removal",
1513 753_000,
1514 SubAgentStatus::Running,
1515 );
1516 long.nickname = Some("Bartholomew the Extremely Long-Winded Humpback".to_string());
1517 app.subagent_cache.push(long);
1518 app.subagent_cache.push(fleet_worker(
1519 "agent_plain",
1520 "scout",
1521 "Ambient visual calm-down",
1522 741_000,
1523 SubAgentStatus::Running,
1524 ));
1525
1526 let painted = render_rows(&mut app, 100, 5);
1527 let joined = painted.join("\n");
1528 // The oversized nickname never renders, whole or truncated.
1529 assert!(!joined.contains("Bartholomew"), "{joined}");
1530 assert!(!joined.contains("Bartholom"), "{joined}");
1531 // It falls back to its role, and the other row is untouched.
1532 assert!(joined.contains("general-purpose"), "{joined}");
1533 assert!(joined.contains("scout"), "{joined}");
1534 // Neither objective was starved by the outlier.
1535 assert!(joined.contains("Streaming dead-code removal"), "{joined}");
1536 assert!(joined.contains("Ambient visual calm-down"), "{joined}");
1537 }
1538
1539 #[test]
1540 fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() {
1541 // Settled degradation order: tokens first, then elapsed, then the
1542 // type and status columns together. The objective is the last thing
1543 // to go and every column truncates rather than wrapping. The status
1544 // word outlives the whole receipt — a fleet row that cannot say its
1545 // state in words has lost the fact the strip exists to show.
1546 let mut app = fleet_app(Some(111_900));
1547 let medium = fleet_row(&render_rows(&mut app, 72, 4));
1548 assert!(medium.contains("12m 33s"), "{medium}");
1549 assert!(!medium.contains("tokens"), "{medium}");
1550 assert!(medium.contains("general-purpose"), "{medium}");
1551 assert!(medium.contains("running"), "{medium}");
1552
1553 let narrow = fleet_row(&render_rows(&mut app, 56, 4));
1554 assert!(!narrow.contains("tokens"), "{narrow}");
1555 assert!(!narrow.contains("12m 33s"), "{narrow}");
1556 assert!(narrow.contains("general-purpose"), "{narrow}");
1557 assert!(narrow.contains("running"), "{narrow}");
1558
1559 let tight = fleet_row(&render_rows(&mut app, 28, 4));
1560 assert!(!tight.contains("general-purpose"), "{tight}");
1561 assert!(!tight.contains("running"), "{tight}");
1562 assert!(tight.contains("Streaming"), "{tight}");
1563
1564 for line in [&medium, &narrow, &tight] {
1565 assert!(line.chars().all(|ch| ch != '\n'), "{line}");
1566 }
1567 }
1568
1569 #[test]
1570 fn fleet_row_elapsed_freezes_once_the_agent_is_finished() {
1571 // The manager recomputes `duration_ms` as `started_at.elapsed()` on
1572 // every snapshot, so a finished agent's raw duration keeps growing.
1573 // The row must latch the first terminal reading instead.
1574 let mut app = fleet_app(None);
1575 app.subagent_cache[0].status = SubAgentStatus::Completed;
1576 app.subagent_cache[0].duration_ms = 753_000;
1577
1578 let first = super::model::project(&mut app);
1579 let finished = first
1580 .iter()
1581 .find(|row| row.id.0 == "worker:agent_stream")
1582 .and_then(|row| row.agent.as_ref())
1583 .expect("finished agent facts");
1584 assert_eq!(finished.elapsed_secs, Some(753));
1585
1586 // A later snapshot reports a larger duration for the same dead agent.
1587 app.subagent_cache[0].duration_ms = 999_000;
1588 let second = super::model::project(&mut app);
1589 let still = second
1590 .iter()
1591 .find(|row| row.id.0 == "worker:agent_stream")
1592 .and_then(|row| row.agent.as_ref())
1593 .expect("finished agent facts");
1594 assert_eq!(
1595 still.elapsed_secs,
1596 Some(753),
1597 "finished elapsed must freeze"
1598 );
1599 }
1600
1601 #[test]
1602 fn fleet_row_elapsed_still_advances_while_the_agent_runs() {
1603 let mut app = fleet_app(None);
1604 app.subagent_cache[0].duration_ms = 10_000;
1605 let early = super::model::project(&mut app);
1606 assert_eq!(
1607 early
1608 .iter()
1609 .find(|row| row.id.0 == "worker:agent_stream")
1610 .and_then(|row| row.agent.as_ref())
1611 .expect("running agent facts")
1612 .elapsed_secs,
1613 Some(10)
1614 );
1615
1616 app.subagent_cache[0].duration_ms = 40_000;
1617 let later = super::model::project(&mut app);
1618 assert_eq!(
1619 later
1620 .iter()
1621 .find(|row| row.id.0 == "worker:agent_stream")
1622 .and_then(|row| row.agent.as_ref())
1623 .expect("running agent facts")
1624 .elapsed_secs,
1625 Some(40)
1626 );
1627 }
1628
1629 #[test]
1630 fn fleet_row_with_no_reported_usage_shows_no_token_figure_at_all() {
1631 // An unknown number is rendered as nothing. Never `0`, which would
1632 // claim the agent spent nothing.
1633 let mut app = fleet_app(None);
1634 let row = fleet_row(&render_rows(&mut app, 100, 4));
1635 assert!(!row.contains("tokens"), "{row}");
1636 assert!(!row.contains('↓'), "{row}");
1637 assert!(row.contains("12m 33s"), "{row}");
1638
1639 let mut spent = fleet_app(Some(0));
1640 let zero = fleet_row(&render_rows(&mut spent, 100, 4));
1641 // A *reported* zero is a fact and does render.
1642 assert!(zero.contains("↓ 0 tokens"), "{zero}");
1643 }
1644
1645 #[test]
1646 fn fleet_row_child_badge_counts_children_that_are_on_the_surface() {
1647 let mut app = app();
1648 app.current_session_id = Some(SESSION.to_string());
1649 app.subagent_cache.push(cached_worker(
1650 "agent_lead",
1651 "general-purpose",
1652 None,
1653 None,
1654 SubAgentStatus::Running,
1655 ));
1656 for child in ["agent_c1", "agent_c2", "agent_c3"] {
1657 app.subagent_cache.push(cached_worker(
1658 child,
1659 "scout",
1660 None,
1661 Some("agent_lead"),
1662 SubAgentStatus::Running,
1663 ));
1664 }
1665 // A child whose parent is not on the surface must not be counted for
1666 // anyone, and must not inflate the lead's badge.
1667 app.subagent_cache.push(cached_worker(
1668 "agent_orphan",
1669 "scout",
1670 None,
1671 Some("agent_missing"),
1672 SubAgentStatus::Running,
1673 ));
1674
1675 let rows = super::model::project(&mut app);
1676 let label = |id: &str| {
1677 rows.iter()
1678 .find(|row| row.id.0 == format!("worker:{id}"))
1679 .map(|row| row.label.clone())
1680 .unwrap_or_else(|| panic!("row for {id}"))
1681 };
1682 assert_eq!(label("agent_lead"), "general-purpose (+3)");
1683 assert_eq!(label("agent_c1"), "↳ scout");
1684 assert_eq!(label("agent_orphan"), "scout");
1685 }
1686
1687 #[test]
1688 fn a_capped_fleet_list_announces_how_many_rows_it_is_hiding() {
1689 let mut app = app();
1690 app.current_session_id = Some(SESSION.to_string());
1691 for index in 0..8 {
1692 app.subagent_cache.push(cached_worker(
1693 &format!("agent_{index}"),
1694 "general-purpose",
1695 None,
1696 None,
1697 SubAgentStatus::Running,
1698 ));
1699 }
1700
1701 // Four content rows for nine projected rows (header + eight workers).
1702 let rows = render_rows(&mut app, 100, 5);
1703 let more = rows
1704 .iter()
1705 .find(|line| line.contains("more"))
1706 .unwrap_or_else(|| panic!("no overflow line in {rows:?}"));
1707 // Nine projected rows (header + eight workers); two fit, seven do not.
1708 assert!(more.contains("↓ 7 more"), "{more}");
1709 // Right-aligned against the content column, not the left margin.
1710 assert!(more.starts_with(" "), "{more}");
1711 }
1712
1713 #[test]
1714 fn fleet_rows_render_in_top_left_and_right_placements() {
1715 for placement in [
1716 super::WorkSurfacePlacement::Top,
1717 super::WorkSurfacePlacement::Left,
1718 super::WorkSurfacePlacement::Right,
1719 ] {
1720 let mut app = fleet_app(Some(111_900));
1721 app.work_surface.placement = placement;
1722 app.work_surface.effective_placement = placement;
1723 let rows = render_rows(&mut app, 40, 8);
1724 let row = fleet_row(&rows);
1725 assert!(
1726 row.contains("Streaming"),
1727 "{placement:?} lost the objective: {rows:?}"
1728 );
1729 }
1730 }
1731
1732 #[test]
1733 fn progress_only_work_rows_use_typed_activity_not_display_substrings() {
1734 let mut app = app();
1735 app.current_session_id = Some(SESSION.to_string());
1736 app.agent_progress.insert(
1737 "agent_progress_only".to_string(),
1738 "queued waiting failed completed".to_string(),
1739 );
1740
1741 let rows = super::model::project(&mut app);
1742 let row = rows
1743 .iter()
1744 .find(|row| row.id.0 == "worker:agent_progress_only")
1745 .expect("progress-only work row");
1746 assert_eq!(row.detail, "running");
1747
1748 app.agent_progress_meta.insert(
1749 "agent_progress_only".to_string(),
1750 crate::tui::app::AgentProgressMeta {
1751 current_activity: Some(AgentCurrentActivity::bounded(
1752 AgentCurrentActivityStatus::Waiting,
1753 Some("approval required".to_string()),
1754 None,
1755 Some(5),
1756 )),
1757 ..crate::tui::app::AgentProgressMeta::default()
1758 },
1759 );
1760
1761 let rows = super::model::project(&mut app);
1762 let row = rows
1763 .iter()
1764 .find(|row| row.id.0 == "worker:agent_progress_only")
1765 .expect("typed progress-only work row");
1766 assert!(row.detail.contains("waiting for input"), "{}", row.detail);
1767 assert!(row.detail.contains("approval required"), "{}", row.detail);
1768 assert!(row.detail.contains("step 5"), "{}", row.detail);
1769 }
1770
1771 // === #5906: a parked husk is not an agent waiting for input ==========
1772
1773 /// Build a child exactly the way the turn-end parking projection does:
1774 /// `Interrupted` + `WaitingForUser` + a `needs_input` note phrased as a
1775 /// question, distinguished from a real question only by the checkpoint's
1776 /// `parked_at_turn_end` flag.
1777 fn parked_worker(id: &str, objective: &str) -> SubAgentResult {
1778 let mut agent = fleet_worker(
1779 id,
1780 "general-purpose",
1781 objective,
1782 753_000,
1783 SubAgentStatus::Interrupted(
1784 "Parent turn ended before this turn-owned child settled.".to_string(),
1785 ),
1786 );
1787 agent.worker_status = Some(AgentWorkerStatus::WaitingForUser);
1788 agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput {
1789 question: format!(
1790 "Resume this parked child with agent(action=\"start\", resume_from=\"{id}\")."
1791 ),
1792 });
1793 agent.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint {
1794 checkpoint_id: format!("{id}:step:2"),
1795 agent_id: id.to_string(),
1796 continuation_handle: format!("agent:{id}:checkpoint"),
1797 reason: "Parent turn ended before this turn-owned child settled.".to_string(),
1798 continuable: true,
1799 steps_taken: 2,
1800 message_count: 4,
1801 created_at_ms: 1_000,
1802 messages: Vec::new(),
1803 omitted_messages: 0,
1804 parked_at_turn_end: true,
1805 });
1806 agent
1807 }
1808
1809 fn asking_worker(id: &str, objective: &str) -> SubAgentResult {
1810 let mut agent = fleet_worker(
1811 id,
1812 "general-purpose",
1813 objective,
1814 120_000,
1815 SubAgentStatus::Running,
1816 );
1817 agent.worker_status = Some(AgentWorkerStatus::WaitingForUser);
1818 agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput {
1819 question: "Which path should I use?".to_string(),
1820 });
1821 agent
1822 }
1823
1824 fn parked_fixture() -> App {
1825 let mut app = app();
1826 app.current_session_id = Some(SESSION.to_string());
1827 app.subagent_cache
1828 .push(parked_worker("agent_parked", "Parked dead-code removal"));
1829 app.subagent_cache
1830 .push(asking_worker("agent_asking", "Asking about the path"));
1831 app.subagent_cache.push(fleet_worker(
1832 "agent_live",
1833 "general-purpose",
1834 "Streaming dead-code removal",
1835 30_000,
1836 SubAgentStatus::Running,
1837 ));
1838 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
1839 app
1840 }
1841
1842 #[test]
1843 fn a_parked_work_row_says_parked_and_names_its_recovery() {
1844 let mut app = parked_fixture();
1845 let rows = super::model::project(&mut app);
1846
1847 let parked = rows
1848 .iter()
1849 .find(|row| row.id.0 == "worker:agent_parked")
1850 .expect("parked work row");
1851 assert!(parked.detail.starts_with("parked"), "{}", parked.detail);
1852 assert!(
1853 !parked.detail.contains("waiting for input"),
1854 "a parked husk must not wear the answerable label: {}",
1855 parked.detail
1856 );
1857 // The recovery names verbs the runtime actually exposes.
1858 assert!(parked.detail.contains("resume_from"), "{}", parked.detail);
1859 assert!(parked.detail.contains("cancel"), "{}", parked.detail);
1860 assert!(
1861 !parked.detail.contains("Resume this parked child"),
1862 "the parking note is not a question to replay at the operator: {}",
1863 parked.detail
1864 );
1865
1866 let asking = rows
1867 .iter()
1868 .find(|row| row.id.0 == "worker:agent_asking")
1869 .expect("asking work row");
1870 assert!(
1871 asking.detail.contains("waiting for input"),
1872 "a child that really asked keeps the answerable label: {}",
1873 asking.detail
1874 );
1875 assert!(
1876 asking.detail.contains("Which path should I use?"),
1877 "{}",
1878 asking.detail
1879 );
1880 }
1881
1882 #[test]
1883 fn parked_rows_sort_below_live_work_and_leave_the_needs_input_count_alone() {
1884 let mut app = parked_fixture();
1885 let rows = super::model::project(&mut app);
1886
1887 let heading = rows
1888 .iter()
1889 .find(|row| row.id.0 == "section:work")
1890 .expect("work heading");
1891 // The attention chip counts children a person is actually blocking:
1892 // one, the child that asked. Two would mean the parked husk had been
1893 // counted as waiting for input all over again.
1894 assert!(
1895 heading.label.contains("1 blocked"),
1896 "only the child that actually asked is blocked on a person: {}",
1897 heading.label
1898 );
1899
1900 let position = |id: &str| {
1901 rows.iter()
1902 .position(|row| row.id.0 == id)
1903 .unwrap_or_else(|| panic!("{id} missing from {rows:?}"))
1904 };
1905 assert!(
1906 position("worker:agent_parked") > position("worker:agent_live"),
1907 "a parked husk must not sort above live work"
1908 );
1909 assert!(
1910 position("worker:agent_parked") > position("worker:agent_asking"),
1911 "a parked husk must not sort above a child a person can answer"
1912 );
1913 }
1914
1915 #[test]
1916 fn the_parked_status_word_survives_the_narrow_row_ladder() {
1917 // The status word outlives the whole receipt as the strip narrows
1918 // (see the degradation test above); `parked` is the fact the row
1919 // exists to carry, so it must survive the same ladder `running` does.
1920 let mut app = app();
1921 app.current_session_id = Some(SESSION.to_string());
1922 app.subagent_cache
1923 .push(parked_worker("agent_parked", "Parked dead-code removal"));
1924 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
1925
1926 for width in [96u16, 72, 56] {
1927 let rows = render_rows(&mut app, width, 6);
1928 let row = rows
1929 .iter()
1930 .find(|line| line.contains("Parked dead-code"))
1931 .unwrap_or_else(|| panic!("no parked row at width {width} in {rows:?}"));
1932 assert!(row.contains("parked"), "width {width}: {row}");
1933 assert!(!row.contains("waiting for input"), "width {width}: {row}");
1934 }
1935 }
1936
1937 #[test]
1938 fn agent_transcript_keyboard_mouse_and_return_selection_converge() {
1939 fn add_worker(app: &mut App) {
1940 app.current_session_id = Some(SESSION.to_string());
1941 app.subagent_cache.push(SubAgentResult {
1942 usage: None,
1943 name: "agent_converge".to_string(),
1944 agent_id: "agent_converge".to_string(),
1945 context_mode: "fresh".to_string(),
1946 fork_context: false,
1947 workspace: None,
1948 git_branch: Some("codex/details".to_string()),
1949 agent_type: FleetRole::Builder,
1950 assignment: SubAgentAssignment {
1951 objective: "Verify keyboard and mouse convergence".to_string(),
1952 role: Some("worker".to_string()),
1953 },
1954 model: "test-model".to_string(),
1955 nickname: Some("Blue Whale".to_string()),
1956 status: SubAgentStatus::Running,
1957 worker_status: Some(AgentWorkerStatus::Running),
1958 runtime_permissions: None,
1959 parent_run_id: None,
1960 spawn_depth: 1,
1961 child_route: None,
1962 result: None,
1963 steps_taken: 1,
1964 checkpoint: None,
1965 needs_input: None,
1966 duration_ms: 100,
1967 started_at: None,
1968 from_prior_session: false,
1969 });
1970 }
1971
1972 let mut keyboard = app();
1973 add_worker(&mut keyboard);
1974 let _ = render_text(&mut keyboard, 100, 6);
1975 let _ = super::handle_key(
1976 &mut keyboard,
1977 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
1978 );
1979 let keyboard_action = super::handle_key(
1980 &mut keyboard,
1981 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
1982 )
1983 .expect("Work key handled")
1984 .expect("agent transcript action");
1985 let keyboard_selection = keyboard.work_surface.selected.clone();
1986
1987 let mut mouse = app();
1988 add_worker(&mut mouse);
1989 let _ = render_text(&mut mouse, 100, 6);
1990 let row_y = mouse
1991 .work_surface
1992 .hitboxes
1993 .iter()
1994 .find(|hit| hit.id.0 == "worker:agent_converge")
1995 .expect("agent hitbox")
1996 .row_y;
1997 let mouse_action = super::handle_mouse(
1998 &mut mouse,
1999 MouseEvent {
2000 kind: MouseEventKind::Down(MouseButton::Left),
2001 column: 2,
2002 row: row_y,
2003 modifiers: KeyModifiers::NONE,
2004 },
2005 )
2006 .action
2007 .expect("mouse agent transcript action");
2008 assert_eq!(mouse_action, keyboard_action);
2009 assert_eq!(mouse.work_surface.selected, keyboard_selection);
2010
2011 crate::tui::mouse_ui::apply_sidebar_row_action(&mut mouse, mouse_action);
2012 // One agent, one destination: activation focuses the worker in place
2013 // (its full transcript owns the conversation area) instead of opening
2014 // a modal, and leaving focus keeps the rail selection where it was.
2015 assert!(
2016 mouse
2017 .agent_focus
2018 .as_ref()
2019 .is_some_and(|focus| focus.is("agent_converge")),
2020 "activation must focus the worker"
2021 );
2022 let selected_before_close = mouse.work_surface.selected.clone();
2023 assert!(crate::tui::agent_focus::exit_focus(&mut mouse));
2024 assert_eq!(mouse.work_surface.selected, selected_before_close);
2025 assert!(mouse.work_surface.opened.is_none());
2026 }
2027
2028 #[test]
2029 fn active_session_without_work_keeps_surface_invisible() {
2030 let mut app = app();
2031 app.current_session_id = Some(SESSION.to_string());
2032
2033 let rows = super::model::project(&mut app);
2034
2035 assert!(rows.is_empty());
2036 assert_eq!(super::height(&mut app, 120, 32, AMPLE_BUDGET), 0);
2037 }
2038
2039 #[test]
2040 fn empty_work_stays_hidden_after_cached_session_state_is_cleared() {
2041 let mut app = app();
2042 app.current_session_id = Some(SESSION.to_string());
2043 app.work_surface.cached_graph = Some(operation_graph(NodeState::Active));
2044
2045 let rows = super::model::project(&mut app);
2046
2047 assert!(rows.is_empty());
2048 assert!(app.work_surface.cached_graph.is_none());
2049 }
2050
2051 #[test]
2052 fn empty_work_reserves_no_side_rail() {
2053 for placement in [
2054 super::WorkSurfacePlacement::Left,
2055 super::WorkSurfacePlacement::Right,
2056 ] {
2057 let mut app = app();
2058 app.current_session_id = Some(SESSION.to_string());
2059 app.work_surface.placement = placement;
2060 let area = ratatui::layout::Rect::new(0, 0, 120, 32);
2061
2062 assert_eq!(
2063 super::height(&mut app, area.width, area.height, AMPLE_BUDGET),
2064 0
2065 );
2066 assert_eq!(super::split_chat(&mut app, area, 0), (area, None));
2067 }
2068 }
2069
2070 fn terminal_text(terminal: &Terminal<TestBackend>) -> String {
2071 let buf = terminal.backend().buffer();
2072 (0..buf.area.height)
2073 .map(|y| {
2074 (0..buf.area.width)
2075 .map(|x| buf[(x, y)].symbol())
2076 .collect::<String>()
2077 })
2078 .collect::<Vec<_>>()
2079 .join("\n")
2080 }
2081
2082 /// Render-level smoke coverage for the ported rail panels — reinstates
2083 /// the sidebar render smoke tests removed with the classic shell
2084 /// (739616787). Top never spends a row on panel chrome (content is
2085 /// self-evident). Side rails are named by their content's own heading
2086 /// row (`▾ Subagents N`, `Goal: …`); Context is the one line-list panel
2087 /// and keeps its muted panel title.
2088 #[test]
2089 fn rail_panels_render_in_all_placements() {
2090 for panel in [
2091 super::RailPanel::Agents,
2092 super::RailPanel::Context,
2093 super::RailPanel::Tasks,
2094 ] {
2095 for placement in [
2096 super::WorkSurfacePlacement::Bottom,
2097 super::WorkSurfacePlacement::Top,
2098 super::WorkSurfacePlacement::Left,
2099 super::WorkSurfacePlacement::Right,
2100 ] {
2101 let mut app = app();
2102 app.work_surface.placement = placement;
2103 super::interaction::select_dock_panel(&mut app, panel);
2104 app.work_surface.focused = false;
2105 // Content so empty-collapse does not hide the panel. Agents
2106 // needs a cached worker; Tasks needs a goal; Context always
2107 // has a budget.
2108 app.goal.objective = Some("ship the release".to_string());
2109 if panel == super::RailPanel::Agents {
2110 app.subagent_cache.push(cached_worker(
2111 "agent-a",
2112 "explore",
2113 Some("scout"),
2114 None,
2115 SubAgentStatus::Running,
2116 ));
2117 }
2118 let area = ratatui::layout::Rect::new(0, 0, 100, 24);
2119
2120 // Render coverage, not yield coverage: a 24-row terminal with
2121 // work on screen has rows to spare, so the panel is expected
2122 // to draw. The idle-empty budget is exercised end-to-end in
2123 // `ui::tests::rail_strip_yields_the_ambient_floor_*`.
2124 let budget = working_budget(&app, area.height);
2125 let strip = super::height(&mut app, area.width, area.height, budget);
2126 let (_chat, rail) = super::split_chat(&mut app, area, 0);
2127 let backend = TestBackend::new(area.width, area.height);
2128 let mut terminal = Terminal::new(backend).expect("terminal");
2129 terminal
2130 .draw(|frame| {
2131 if strip > 0 {
2132 super::render(
2133 frame,
2134 ratatui::layout::Rect::new(0, 0, area.width, strip),
2135 &mut app,
2136 );
2137 } else if let Some(rail) = rail {
2138 super::render(frame, rail, &mut app);
2139 }
2140 })
2141 .expect("draw");
2142 let text = terminal_text(&terminal);
2143 match placement {
2144 super::WorkSurfacePlacement::Bottom => {
2145 assert!(
2146 strip > 0,
2147 "{panel:?} on Bottom should auto-fit a content strip; got height 0"
2148 );
2149 }
2150 super::WorkSurfacePlacement::Top => {
2151 assert!(
2152 strip > 0,
2153 "{panel:?} on Top should auto-fit a content strip; got height 0"
2154 );
2155 // Panel chrome ("Pinned"/"Agents") never on Top.
2156 // An active goal *is* a title — and this fixture sets one.
2157 // A chrome title would be a row saying only the
2158 // view's name; the tab row and `▾ Subagents N` both
2159 // legitimately contain the lowercase word.
2160 let strip_body = text.lines().skip(1).collect::<Vec<_>>().join("\n");
2161 assert!(
2162 !strip_body.lines().any(|line| line.trim() == panel.title()),
2163 "{panel:?} on Top must not spend a row on panel chrome; got: {text}"
2164 );
2165 // Goal title when a live goal is set.
2166 assert!(
2167 text.contains("Goal:") && text.contains("ship the release"),
2168 "Top with an active goal must title with Goal: …; got: {text}"
2169 );
2170 }
2171 super::WorkSurfacePlacement::Left | super::WorkSurfacePlacement::Right => {
2172 assert!(
2173 rail.is_some() || strip > 0,
2174 "{panel:?} in {placement:?} should reserve a rail"
2175 );
2176 // Work-row panels are named by their content heading;
2177 // only the Context fact list keeps a panel title.
2178 match panel {
2179 super::RailPanel::Agents => {
2180 assert!(
2181 text.contains("Subagents 1"),
2182 "{panel:?} in {placement:?} should render its \
2183 Subagents heading; got: {text}"
2184 );
2185 assert!(
2186 !app.work_surface.hitboxes.is_empty(),
2187 "{panel:?} in {placement:?} must record hitboxes — \
2188 every work row is a door"
2189 );
2190 }
2191 super::RailPanel::Tasks => {
2192 assert!(
2193 text.contains("Goal: ship the release"),
2194 "{panel:?} in {placement:?} should render the goal \
2195 heading; got: {text}"
2196 );
2197 }
2198 _ => {
2199 assert!(
2200 text.contains("compact now"),
2201 "{panel:?} in {placement:?} should render the budget \
2202 rows; got: {text}"
2203 );
2204 }
2205 }
2206 }
2207 super::WorkSurfacePlacement::Off => {}
2208 }
2209 }
2210 }
2211 }
2212
2213 #[test]
2214 fn off_placement_reserves_no_rail_in_any_panel() {
2215 for panel in [
2216 super::RailPanel::Tasks,
2217 super::RailPanel::Agents,
2218 super::RailPanel::Context,
2219 super::RailPanel::Tasks,
2220 ] {
2221 let mut app = app();
2222 add_todos(&mut app, 2);
2223 app.work_surface.placement = super::WorkSurfacePlacement::Off;
2224 app.work_surface.panel = panel;
2225 let area = ratatui::layout::Rect::new(0, 0, 120, 32);
2226
2227 assert_eq!(
2228 super::height(&mut app, area.width, area.height, AMPLE_BUDGET),
2229 0
2230 );
2231 assert_eq!(super::split_chat(&mut app, area, 0), (area, None));
2232 assert_eq!(app.work_surface.last_area, None);
2233 }
2234 }
2235
2236 #[test]
2237 fn context_view_renders_the_budget_in_a_side_rail() {
2238 let mut app = app();
2239 app.work_surface.placement = super::WorkSurfacePlacement::Right;
2240 super::interaction::select_dock_panel(&mut app, super::RailPanel::Context);
2241 let area = ratatui::layout::Rect::new(0, 0, 100, 24);
2242
2243 let budget = working_budget(&app, area.height);
2244 let strip = super::height(&mut app, area.width, area.height, budget);
2245 assert_eq!(strip, 0, "side placements take no top strip");
2246 let (_chat, rail) = super::split_chat(&mut app, area, 0);
2247 let rail = rail.expect("context panel reserves a side rail");
2248
2249 let backend = TestBackend::new(area.width, area.height);
2250 let mut terminal = Terminal::new(backend).expect("terminal");
2251 terminal
2252 .draw(|frame| super::render(frame, rail, &mut app))
2253 .expect("draw");
2254 let text = terminal_text(&terminal);
2255 assert!(text.contains(" of "), "budget row; got: {text}");
2256 assert!(text.contains("compact now"), "compact door; got: {text}");
2257 assert!(
2258 app.work_surface
2259 .hitboxes
2260 .iter()
2261 .any(|hit| hit.id.0 == "context:compact"),
2262 "the compact row is a hit target"
2263 );
2264 }
2265
2266 #[test]
2267 fn missing_runtime_renders_disconnected_state() {
2268 let mut app = app();
2269 app.current_session_id = Some(SESSION.to_string());
2270 app.runtime_services.work = None;
2271
2272 let rows = super::model::project(&mut app);
2273
2274 assert_eq!(rows[0].label, "Work · disconnected");
2275 }
2276
2277 #[test]
2278 fn busy_graph_authority_renders_truthful_error_without_leaking_it_into_header() {
2279 let mut app = app();
2280 app.current_session_id = Some(SESSION.to_string());
2281 let todos = app.todos.clone();
2282 let _guard = todos.try_lock().expect("hold To-do authority lock");
2283
2284 let rows = super::model::project(&mut app);
2285
2286 assert_eq!(rows.len(), 1);
2287 assert_eq!(rows[0].label, "Work · error");
2288 assert!(rows[0].detail.contains("To-do state is busy"));
2289 assert!(!rows[0].label.contains("busy"));
2290 }
2291
2292 #[test]
2293 fn graph_error_without_an_active_session_stays_suppressed() {
2294 let mut app = app();
2295 let todos = app.todos.clone();
2296 let _guard = todos.try_lock().expect("hold To-do authority lock");
2297
2298 let rows = super::model::project(&mut app);
2299
2300 assert!(rows.is_empty());
2301 }
2302
2303 #[test]
2304 fn waiting_operation_is_not_counted_as_running() {
2305 let mut app = app();
2306 let graph = operation_graph(NodeState::Waiting);
2307 restore_graph(&mut app, &graph);
2308 app.runtime_services
2309 .work
2310 .as_ref()
2311 .expect("Work Graph runtime")
2312 .reconcile_operation(
2313 SESSION,
2314 OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Waiting, 1, 6),
2315 )
2316 .expect("waiting shell owner");
2317
2318 let rows = super::model::project(&mut app);
2319
2320 assert!(
2321 rows[0].label.starts_with("Work · Needs input:")
2322 || rows[0]
2323 .label
2324 .starts_with("Work · 0 active · 1 needs input · 0 ready · 0 recent"),
2325 "{}",
2326 rows[0].label
2327 );
2328 assert!(
2329 rows[0].label.contains("blocked") || rows[0].label.contains("needs input"),
2330 "{}",
2331 rows[0].label
2332 );
2333 }
2334
2335 #[test]
2336 fn stale_operation_is_blocked_attention_with_bounded_output_section() {
2337 let mut app = app();
2338 let graph = operation_graph(NodeState::Stale);
2339 restore_graph(&mut app, &graph);
2340
2341 let rows = super::model::project(&mut app);
2342 assert!(
2343 rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"),
2344 "{}",
2345 rows[0].label
2346 );
2347 let row = rows.iter().find(|row| row.selectable).expect("stale row");
2348 assert_eq!(row.mark, "?");
2349 assert!(row.detail.starts_with("stale · operation"));
2350 let Some(SidebarRowAction::InspectWork {
2351 body, stop_action, ..
2352 }) = row.primary_action.as_ref()
2353 else {
2354 panic!("stale row must open inspector");
2355 };
2356 assert!(
2357 stop_action.is_none(),
2358 "a stale owner cannot truthfully expose a stop action"
2359 );
2360 assert!(
2361 body.contains("Last bounded output\nNo output receipt"),
2362 "{body}"
2363 );
2364 assert!(body.contains("Owner cannot confirm liveness"), "{body}");
2365 }
2366
2367 /// A durable failed operation, as a fleet agent task from a crashed or
2368 /// sibling instance leaves behind in the persisted graph (#4416).
2369 fn durable_failed_operation_graph() -> crate::work_graph::WorkGraphSnapshot {
2370 let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Failed));
2371 let operation = WorkNodeId::derive(SESSION, "operation");
2372 graph
2373 .apply(
2374 WorkGraphChange::BindOperation {
2375 node: operation,
2376 binding: OperationBinding {
2377 external: "fleet:run_1/task_1".to_string(),
2378 durable: true,
2379 last_observation: None,
2380 },
2381 },
2382 ChangeCtx {
2383 session_id: SESSION.to_string(),
2384 now: 6,
2385 idempotency_key: None,
2386 },
2387 )
2388 .expect("durable binding");
2389 graph.into_snapshot()
2390 }
2391
2392 // Regression for #4416: a persisted failed-agent record stamped by
2393 // another session instance (boot id) must not appear in the default
2394 // work listing of a fresh session in the same workspace.
2395 #[test]
2396 fn prior_instance_failed_rows_stay_out_of_the_default_listing() {
2397 let dir = tempfile::tempdir().expect("tempdir");
2398 let manager =
2399 crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager");
2400 manager
2401 .record_session_boot_owner(SESSION, "boot_other_instance")
2402 .expect("stamp other instance");
2403
2404 let mut app = app();
2405 app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf());
2406 let graph = durable_failed_operation_graph();
2407 restore_saved_graph(&mut app, &graph);
2408
2409 let rows = super::model::project(&mut app);
2410 assert!(
2411 rows.iter()
2412 .all(|row| !row.label.contains("Verify installed build")),
2413 "prior-instance failed row leaked into the default listing: {rows:#?}"
2414 );
2415 assert!(
2416 rows.iter()
2417 .all(|row| !row.label.contains("needs input") && !row.label.contains("1 active")),
2418 "prior-instance residue must not count as live work: {rows:#?}"
2419 );
2420 // The record stays reachable through the explicit catalog, clearly
2421 // marked historical.
2422 let historical = app
2423 .work_surface
2424 .catalog_rows
2425 .iter()
2426 .find(|row| row.label.contains("Verify installed build"))
2427 .expect("historical row remains in the catalog");
2428 assert!(
2429 historical.detail.starts_with("prior session · "),
2430 "historical row must be labeled: {}",
2431 historical.detail
2432 );
2433 }
2434
2435 // Ownership control for #4416: the same failed record owned by this
2436 // session instance still renders as actionable work.
2437 #[test]
2438 fn current_instance_failed_rows_still_render_in_the_default_listing() {
2439 let dir = tempfile::tempdir().expect("tempdir");
2440 let manager =
2441 crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager");
2442 manager
2443 .record_session_boot_owner(SESSION, crate::session_manager::current_session_boot_id())
2444 .expect("stamp current instance");
2445
2446 let mut app = app();
2447 app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf());
2448 let graph = durable_failed_operation_graph();
2449 restore_graph(&mut app, &graph);
2450
2451 let rows = super::model::project(&mut app);
2452 assert!(
2453 rows.iter()
2454 .any(|row| row.label.contains("Verify installed build")),
2455 "this instance's own failed work must stay visible: {rows:#?}"
2456 );
2457 }
2458
2459 // Regression for review of #5063: if a prior session persisted no graph,
2460 // the first graph captured later belongs to this process and must not be
2461 // mistaken for restored residue.
2462 #[test]
2463 fn first_live_graph_after_empty_prior_restore_stays_visible() {
2464 let dir = tempfile::tempdir().expect("tempdir");
2465 let manager =
2466 crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager");
2467 manager
2468 .record_session_boot_owner(SESSION, "boot_other_instance")
2469 .expect("stamp other instance");
2470
2471 let mut app = app();
2472 app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf());
2473 app.current_session_id = Some(SESSION.to_string());
2474 app.restore_work_state(SESSION, std::path::Path::new("."), None)
2475 .expect("restore empty prior session");
2476
2477 let graph = durable_failed_operation_graph();
2478 restore_graph(&mut app, &graph);
2479 let rows = super::model::project(&mut app);
2480 assert!(
2481 rows.iter()
2482 .any(|row| row.label.contains("Verify installed build")),
2483 "this instance's first live graph must stay visible: {rows:#?}"
2484 );
2485 }
2486
2487 #[test]
2488 fn completed_operation_with_acceptance_is_not_rendered_done() {
2489 let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Ready));
2490 let operation = WorkNodeId::derive(SESSION, "operation");
2491 graph
2492 .apply(
2493 WorkGraphChange::UpdateNode {
2494 id: operation,
2495 patch: crate::work_graph::WorkNodePatch {
2496 state: Some(NodeState::Completed),
2497 acceptance: Some(vec![AcceptanceRequirement::EvidenceOfKind {
2498 kind: EvidenceKindTag::ToolRun,
2499 }]),
2500 ..crate::work_graph::WorkNodePatch::default()
2501 },
2502 },
2503 ChangeCtx {
2504 session_id: SESSION.to_string(),
2505 now: 6,
2506 idempotency_key: None,
2507 },
2508 )
2509 .expect("completed pending evidence");
2510 let graph = graph.into_snapshot();
2511 let mut app = app();
2512 restore_graph(&mut app, &graph);
2513
2514 let rows = super::model::project(&mut app);
2515 assert!(
2516 rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"),
2517 "{}",
2518 rows[0].label
2519 );
2520 let row = rows
2521 .iter()
2522 .find(|row| row.selectable)
2523 .expect("operation row");
2524 assert_eq!(row.mark, crate::tui::glyphs::ATTENTION);
2525 assert!(row.detail.contains("completed · evidence pending"));
2526 assert_ne!(row.mark, "✓");
2527 let Some(SidebarRowAction::InspectWork { body, .. }) = row.primary_action.as_ref() else {
2528 panic!("completed operation must remain inspectable");
2529 };
2530 assert!(body.contains("evidence of kind tool run"), "{body}");
2531 assert!(
2532 body.contains("acceptance evidence is still missing"),
2533 "{body}"
2534 );
2535 }
2536
2537 #[test]
2538 fn work_rows_open_graph_inspector_without_inline_controls() {
2539 let mut app = app();
2540 app.work_surface.placement = WorkSurfacePlacement::Right;
2541 app.work_surface.effective_placement = WorkSurfacePlacement::Right;
2542 let graph = operation_graph(NodeState::Active);
2543 restore_graph(&mut app, &graph);
2544 app.runtime_services
2545 .work
2546 .as_ref()
2547 .expect("Work Graph runtime")
2548 .reconcile_operation(
2549 SESSION,
2550 OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Running, 1, 6),
2551 )
2552 .expect("live shell owner");
2553
2554 let text = render_text(&mut app, 100, 6);
2555 assert!(!text.contains("[open]"), "{text}");
2556 assert!(!text.contains("[stop]"), "{text}");
2557 let row_y = app
2558 .work_surface
2559 .hitboxes
2560 .iter()
2561 .find(|hit| hit.id.0.starts_with("graph:"))
2562 .expect("graph hitbox")
2563 .row_y;
2564 let outcome = super::handle_mouse(
2565 &mut app,
2566 MouseEvent {
2567 kind: MouseEventKind::Down(MouseButton::Left),
2568 column: 2,
2569 row: row_y,
2570 modifiers: KeyModifiers::NONE,
2571 },
2572 );
2573 let action = outcome.action.expect("inspector action");
2574 let SidebarRowAction::InspectWork {
2575 body, stop_action, ..
2576 } = &action
2577 else {
2578 panic!("expected Work inspector");
2579 };
2580 for section in [
2581 "Objective",
2582 "Prerequisites",
2583 "Downstream impact",
2584 "Binding + lifecycle owner",
2585 "Evidence vs acceptance",
2586 "Blockers / approvals",
2587 "Why next",
2588 "Provenance + last reconcile",
2589 ] {
2590 assert!(body.contains(section), "missing {section}: {body}");
2591 }
2592 assert!(matches!(
2593 stop_action.as_deref(),
2594 Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_1234abcd"
2595 ));
2596 crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
2597 assert_eq!(
2598 app.view_stack.top_kind(),
2599 Some(crate::tui::views::ModalKind::Pager)
2600 );
2601 }
2602
2603 #[test]
2604 fn narrow_render_hover_keeps_full_untruncated_row() {
2605 let mut app = app();
2606 app.todos.try_lock().expect("todos").add(
2607 "A deliberately long graph-owned work row".to_string(),
2608 TodoStatus::InProgress,
2609 );
2610
2611 let _ = render_text(&mut app, 24, 4);
2612 let hover = app
2613 .sidebar_hover
2614 .sections
2615 .last()
2616 .and_then(|section| section.rows.first())
2617 .expect("hover row");
2618 assert!(hover.is_truncated);
2619 assert!(hover.full_text.contains("deliberately long graph-owned"));
2620 assert!(hover.stop_action.is_none());
2621 }
2622
2623 #[test]
2624 fn narrow_file_activity_prioritizes_the_canonical_aggregate_label() {
2625 let mut app = app();
2626 app.workspace = PathBuf::from("/workspace/project");
2627 let result = crate::tools::spec::ToolResult::success("ok").with_metadata(
2628 serde_json::json!({
2629 "mutation": {
2630 "diff": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n--- /dev/null\n+++ b/create.rs\n@@ -0,0 +1 @@\n+created\n--- a/delete.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-deleted\n",
2631 "files": [
2632 { "path": "update.rs", "outcome": "updated" },
2633 { "path": "create.rs", "outcome": "created" },
2634 { "path": "delete.rs", "outcome": "deleted" }
2635 ],
2636 "renames": [{ "from": "old.rs", "to": "new.rs" }]
2637 }
2638 }),
2639 );
2640 let receipt = FileMutationReceipt::from_success(&app.workspace, &result).expect("receipt");
2641 app.add_message(HistoryCell::Tool(ToolCell::PatchSummary(
2642 PatchSummaryCell {
2643 path: "4 files".to_string(),
2644 summary: "ok".to_string(),
2645 status: ToolStatus::Success,
2646 error: None,
2647 receipt: Some(receipt),
2648 },
2649 )));
2650 app.tool_details_by_cell.insert(
2651 0,
2652 ToolDetailRecord {
2653 tool_id: "file-multi".to_string(),
2654 tool_name: "File".to_string(),
2655 input: serde_json::json!({"action": "patch"}),
2656 output: Some("ok".to_string()),
2657 },
2658 );
2659
2660 app.work_surface.placement = WorkSurfacePlacement::Right;
2661 app.work_surface.effective_placement = WorkSurfacePlacement::Right;
2662 let text = render_text(&mut app, 80, 6);
2663 assert!(text.contains("Wrote 4 files"), "{text}");
2664 }
2665
2666 #[test]
2667 fn overflow_scroll_and_selection_remain_panel_owned() {
2668 let mut app = app();
2669 add_todos(&mut app, 8);
2670 let _ = render_text(&mut app, 80, 5);
2671 assert!(app.work_surface.total_rows > app.work_surface.visible_rows);
2672
2673 let transcript_delta = app.viewport.pending_scroll_delta;
2674 let outcome = super::handle_mouse(
2675 &mut app,
2676 MouseEvent {
2677 kind: MouseEventKind::ScrollDown,
2678 column: 10,
2679 row: 2,
2680 modifiers: KeyModifiers::NONE,
2681 },
2682 );
2683 assert!(outcome.consumed);
2684 assert_eq!(app.viewport.pending_scroll_delta, transcript_delta);
2685 assert!(app.work_surface.scroll_offset > 0);
2686 }
2687
2688 #[test]
2689 fn mouse_wheel_reaches_last_todo_across_top_surface_heights() {
2690 for height in [3, 5, 6, 8] {
2691 let mut app = app();
2692 add_todos(&mut app, 10);
2693 let _ = render_text(&mut app, 80, height);
2694 assert!(app.work_surface.total_rows > app.work_surface.visible_rows);
2695 let transcript_delta = app.viewport.pending_scroll_delta;
2696
2697 let mut text = String::new();
2698 for _ in 0..16 {
2699 let outcome = super::handle_mouse(
2700 &mut app,
2701 MouseEvent {
2702 kind: MouseEventKind::ScrollDown,
2703 column: 10,
2704 row: 1,
2705 modifiers: KeyModifiers::NONE,
2706 },
2707 );
2708 assert!(outcome.consumed, "height {height}");
2709 text = render_text(&mut app, 80, height);
2710 }
2711
2712 assert!(
2713 text.contains("work item 9"),
2714 "last To-do was unreachable at surface height {height}: {text:?}"
2715 );
2716 assert_eq!(
2717 app.work_surface.scroll_offset,
2718 app.work_surface
2719 .total_rows
2720 .saturating_sub(app.work_surface.visible_rows.max(1)),
2721 "wheel did not reach the legal tail at surface height {height}"
2722 );
2723 assert_eq!(app.viewport.pending_scroll_delta, transcript_delta);
2724 }
2725 }
2726
2727 #[test]
2728 fn mouse_wheel_reaches_last_todo_in_side_rail_placements() {
2729 for placement in [
2730 super::WorkSurfacePlacement::Left,
2731 super::WorkSurfacePlacement::Right,
2732 ] {
2733 let mut app = app();
2734 add_todos(&mut app, 10);
2735 app.work_surface.placement = placement;
2736 app.work_surface.effective_placement = placement;
2737 let _ = render_text(&mut app, 30, 6);
2738
2739 let mut text = String::new();
2740 for _ in 0..16 {
2741 let outcome = super::handle_mouse(
2742 &mut app,
2743 MouseEvent {
2744 kind: MouseEventKind::ScrollDown,
2745 column: 10,
2746 row: 1,
2747 modifiers: KeyModifiers::NONE,
2748 },
2749 );
2750 assert!(outcome.consumed, "placement {placement:?}");
2751 text = render_text(&mut app, 30, 6);
2752 }
2753
2754 assert!(
2755 text.contains("work item 9"),
2756 "last To-do was unreachable in {placement:?}: {text:?}"
2757 );
2758 }
2759 }
2760
2761 #[test]
2762 fn keyboard_end_reveals_last_todo_after_redraw() {
2763 let mut app = app();
2764 add_todos(&mut app, 10);
2765 let _ = render_text(&mut app, 80, 5);
2766 let _ = super::handle_key(
2767 &mut app,
2768 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
2769 );
2770 let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
2771
2772 let text = render_text(&mut app, 80, 5);
2773
2774 assert!(text.contains("work item 9"), "{text:?}");
2775 assert_eq!(
2776 app.work_surface.scroll_offset,
2777 app.work_surface
2778 .total_rows
2779 .saturating_sub(app.work_surface.visible_rows.max(1))
2780 );
2781 }
2782
2783 #[test]
2784 fn keyboard_navigation_is_panel_local_when_focused() {
2785 let mut app = app();
2786 add_todos(&mut app, 3);
2787 let _ = render_text(&mut app, 80, super::model::TOP_HEIGHT_MIN);
2788 assert!(
2789 super::handle_key(
2790 &mut app,
2791 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT)
2792 )
2793 .is_some()
2794 );
2795 let first = app.work_surface.selected.clone();
2796 let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
2797 assert_ne!(app.work_surface.selected, first);
2798 assert!(app.work_surface.focused);
2799 }
2800
2801 #[test]
2802 fn clicking_agents_tab_switches_active_panel() {
2803 let mut app = app();
2804 add_todos(&mut app, 1);
2805 app.subagent_cache.push(cached_worker(
2806 "agent-tab",
2807 "explore",
2808 Some("scout"),
2809 None,
2810 SubAgentStatus::Running,
2811 ));
2812 let _ = render_text(&mut app, 80, 8);
2813 // The to-do list opened first; the running worker is one tab over.
2814 assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
2815 let tab_area = app
2816 .work_surface
2817 .dock_tabs
2818 .iter()
2819 .find(|hitbox| {
2820 hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Agents)
2821 })
2822 .map(|hitbox| hitbox.area)
2823 .expect("Agents tab");
2824
2825 let down = super::handle_mouse(
2826 &mut app,
2827 MouseEvent {
2828 kind: MouseEventKind::Down(MouseButton::Left),
2829 column: tab_area.x,
2830 row: tab_area.y,
2831 modifiers: KeyModifiers::NONE,
2832 },
2833 );
2834 assert!(down.consumed);
2835 let up = super::handle_mouse(
2836 &mut app,
2837 MouseEvent {
2838 kind: MouseEventKind::Up(MouseButton::Left),
2839 column: tab_area.x,
2840 row: tab_area.y,
2841 modifiers: KeyModifiers::NONE,
2842 },
2843 );
2844 assert!(up.consumed);
2845 assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
2846 assert!(app.work_surface.explicit_view);
2847 assert!(!app.work_surface.dismissed);
2848 }
2849
2850 #[test]
2851 fn clicking_active_tab_dismisses_the_dock_until_new_work_arrives() {
2852 let mut app = app();
2853 app.work_surface.top_height = 8;
2854 add_todos(&mut app, 2);
2855 let _ = render_text(&mut app, 80, 8);
2856 let tab_area = app
2857 .work_surface
2858 .dock_tabs
2859 .iter()
2860 .find(|hitbox| {
2861 hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Tasks)
2862 })
2863 .map(|hitbox| hitbox.area)
2864 .expect("Tasks tab");
2865
2866 super::handle_mouse(
2867 &mut app,
2868 MouseEvent {
2869 kind: MouseEventKind::Down(MouseButton::Left),
2870 column: tab_area.x,
2871 row: tab_area.y,
2872 modifiers: KeyModifiers::NONE,
2873 },
2874 );
2875 super::handle_mouse(
2876 &mut app,
2877 MouseEvent {
2878 kind: MouseEventKind::Up(MouseButton::Left),
2879 column: tab_area.x,
2880 row: tab_area.y,
2881 modifiers: KeyModifiers::NONE,
2882 },
2883 );
2884 assert!(app.work_surface.dismissed);
2885 assert_eq!(
2886 super::height(&mut app, 80, 24, AMPLE_BUDGET),
2887 0,
2888 "dismissed dock remains collapsed"
2889 );
2890
2891 app.subagent_cache.push(cached_worker(
2892 "new-work",
2893 "explore",
2894 Some("new work"),
2895 None,
2896 SubAgentStatus::Running,
2897 ));
2898 assert!(
2899 super::height(&mut app, 80, 24, AMPLE_BUDGET) > 0,
2900 "new work re-shows dismissed dock"
2901 );
2902 assert!(!app.work_surface.dismissed);
2903 }
2904
2905 #[test]
2906 fn dock_tabs_match_80x24_golden() {
2907 let mut app = app();
2908 add_todos(&mut app, 3);
2909 app.subagent_cache.push(cached_worker(
2910 "dock-golden-agent",
2911 "explore",
2912 Some("scout"),
2913 None,
2914 SubAgentStatus::Running,
2915 ));
2916
2917 assert_matches_golden("dock_80x24", &render_golden_text(&mut app, 80, 24));
2918 }
2919
2920 #[test]
2921 fn dock_selection_is_readable_and_close_target_stays_inside_small_hosts() {
2922 use super::model::DockTabTarget;
2923 use ratatui::style::Modifier;
2924 for theme_id in codewhale_palette::SELECTABLE_THEMES {
2925 let mut app = app();
2926 app.ui_theme = theme_id.ui_theme();
2927 app.work_surface.explicit_view = true;
2928 add_todos(&mut app, 1);
2929 let mut terminal = Terminal::new(TestBackend::new(80, 8)).unwrap();
2930 terminal
2931 .draw(|frame| super::render(frame, frame.area(), &mut app))
2932 .unwrap();
2933 let tab = app
2934 .work_surface
2935 .dock_tabs
2936 .iter()
2937 .find(|tab| tab.target == DockTabTarget::Panel(super::RailPanel::Tasks))
2938 .unwrap();
2939 let cell = &terminal.backend().buffer()[(tab.area.x + 1, tab.area.y)];
2940 assert_eq!(cell.bg, app.ui_theme.selection_bg, "{theme_id:?}");
2941 assert!(!cell.modifier.contains(Modifier::REVERSED), "{theme_id:?}");
2942 if let Some(ratio) = codewhale_palette::contrast_ratio(cell.fg, cell.bg) {
2943 assert!(ratio >= 4.5, "{theme_id:?}: {cell:?} ({ratio})");
2944 } else {
2945 // Native terminal colors are user supplied and cannot be measured here.
2946 assert_eq!(*theme_id, codewhale_palette::ThemeId::Terminal);
2947 }
2948 }
2949 for width in [1, 2, 3, 8, 16, 40, 60, 80] {
2950 for placement in [WorkSurfacePlacement::Top, WorkSurfacePlacement::Bottom] {
2951 let mut app = app();
2952 app.work_surface.explicit_view = true;
2953 app.work_surface.effective_placement = placement;
2954 let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
2955 terminal
2956 .draw(|frame| super::render(frame, frame.area(), &mut app))
2957 .unwrap();
2958 let close = app
2959 .work_surface
2960 .dock_tabs
2961 .iter()
2962 .find(|tab| tab.target == DockTabTarget::Close)
2963 .unwrap();
2964 assert!(close.area.right() <= width);
2965 assert!(close.area.width > 0);
2966 }
2967 }
2968 }
2969
2970 #[test]
2971 fn narrow_dock_drops_counts_before_optional_tabs() {
2972 let mut app = app();
2973 add_todos(&mut app, 3);
2974 app.subagent_cache.push(cached_worker(
2975 "agent-count",
2976 "explore",
2977 Some("scout"),
2978 None,
2979 SubAgentStatus::Running,
2980 ));
2981 let first_row = render_rows(&mut app, 40, 8)
2982 .into_iter()
2983 .next()
2984 .expect("dock tab row");
2985
2986 assert!(first_row.contains("Tasks"), "{first_row:?}");
2987 assert!(first_row.contains("Fleet"), "{first_row:?}");
2988 assert!(!first_row.contains("Tasks 3"), "{first_row:?}");
2989 assert!(!first_row.contains("Fleet 1"), "{first_row:?}");
2990 assert!(first_row.contains("Context"), "{first_row:?}");
2991 // Shed from the right: price goes before any work view.
2992 assert!(!first_row.contains("Cost"), "{first_row:?}");
2993 }
2994
2995 #[test]
2996 fn empty_panel_releases_plain_y_before_composer_dispatch() {
2997 let mut app = app();
2998 app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 80, 8));
2999 app.work_surface.focused = true;
3000 app.work_surface.explicit_view = false;
3001 let outcome = super::handle_key(
3002 &mut app,
3003 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
3004 );
3005 assert!(outcome.is_none());
3006 assert!(!app.work_surface.focused);
3007 }
3008
3009 #[test]
3010 fn printable_keys_release_panel_focus_for_composer() {
3011 let mut app = app();
3012 add_todos(&mut app, 1);
3013 let _ = render_text(&mut app, 80, super::model::TOP_HEIGHT_MIN);
3014 assert!(
3015 super::handle_key(
3016 &mut app,
3017 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
3018 )
3019 .is_some()
3020 );
3021
3022 let outcome = super::handle_key(
3023 &mut app,
3024 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
3025 );
3026
3027 assert!(outcome.is_none());
3028 assert!(!app.work_surface.focused);
3029 }
3030
3031 #[test]
3032 fn side_placements_reuse_the_same_graph_rows() {
3033 for (placement, expected_chat_x, expected_rail_x) in [
3034 (super::WorkSurfacePlacement::Left, 30, 0),
3035 (super::WorkSurfacePlacement::Right, 0, 70),
3036 ] {
3037 let mut app = app();
3038 add_todos(&mut app, 2);
3039 app.work_surface.placement = placement;
3040 assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0);
3041 let area = ratatui::layout::Rect::new(0, 0, 100, 12);
3042 let (chat, rail) = super::split_chat(&mut app, area, 0);
3043 let rail = rail.expect("side rail");
3044 assert_eq!(chat.x, expected_chat_x);
3045 assert_eq!(rail.x, expected_rail_x);
3046 assert_eq!(rail.width, 30);
3047 assert!(
3048 app.work_surface
3049 .latest_rows
3050 .iter()
3051 .any(|row| row.label == "work item 1")
3052 );
3053 }
3054 }
3055
3056 #[test]
3057 fn divider_drag_resizes_top_left_and_right_surfaces() {
3058 let mut top = app();
3059 add_todos(&mut top, 3);
3060 let _ = render_text(&mut top, 80, 3);
3061 let down = super::handle_mouse(
3062 &mut top,
3063 MouseEvent {
3064 kind: MouseEventKind::Down(MouseButton::Left),
3065 column: 20,
3066 row: 2,
3067 modifiers: KeyModifiers::NONE,
3068 },
3069 );
3070 assert!(down.consumed);
3071 let _ = super::handle_mouse(
3072 &mut top,
3073 MouseEvent {
3074 kind: MouseEventKind::Drag(MouseButton::Left),
3075 column: 20,
3076 row: 7,
3077 modifiers: KeyModifiers::NONE,
3078 },
3079 );
3080 assert_eq!(top.work_surface.top_height, 8);
3081
3082 for (placement, drag_column, expected_width) in [
3083 (WorkSurfacePlacement::Left, 39, 40),
3084 (WorkSurfacePlacement::Right, 10, 26),
3085 ] {
3086 let mut side = app();
3087 add_todos(&mut side, 2);
3088 side.work_surface.placement = placement;
3089 side.work_surface.effective_placement = placement;
3090 let _ = render_text(&mut side, 30, 8);
3091 let divider_column = if placement == WorkSurfacePlacement::Left {
3092 29
3093 } else {
3094 0
3095 };
3096 let _ = super::handle_mouse(
3097 &mut side,
3098 MouseEvent {
3099 kind: MouseEventKind::Down(MouseButton::Left),
3100 column: divider_column,
3101 row: 2,
3102 modifiers: KeyModifiers::NONE,
3103 },
3104 );
3105 let _ = super::handle_mouse(
3106 &mut side,
3107 MouseEvent {
3108 kind: MouseEventKind::Drag(MouseButton::Left),
3109 column: drag_column,
3110 row: 2,
3111 modifiers: KeyModifiers::NONE,
3112 },
3113 );
3114 assert_eq!(
3115 side.work_surface.side_width, expected_width,
3116 "{placement:?}"
3117 );
3118 }
3119 }
3120
3121 #[test]
3122 fn divider_hover_and_drag_render_a_discoverable_handle() {
3123 let mut app = app();
3124 add_todos(&mut app, 3);
3125 let resting = render_text(&mut app, 80, 3);
3126 assert!(resting.contains('─'), "{resting}");
3127
3128 let hover = super::handle_mouse(
3129 &mut app,
3130 MouseEvent {
3131 kind: MouseEventKind::Moved,
3132 column: 20,
3133 row: 2,
3134 modifiers: KeyModifiers::NONE,
3135 },
3136 );
3137 assert!(hover.consumed);
3138 assert!(app.work_surface.divider_hovered);
3139 let hovered = render_text(&mut app, 80, 3);
3140 assert!(hovered.contains('━'), "{hovered}");
3141
3142 let _ = super::handle_mouse(
3143 &mut app,
3144 MouseEvent {
3145 kind: MouseEventKind::Down(MouseButton::Left),
3146 column: 20,
3147 row: 2,
3148 modifiers: KeyModifiers::NONE,
3149 },
3150 );
3151 let dragging = render_text(&mut app, 80, 3);
3152 assert!(dragging.contains('━'), "{dragging}");
3153 }
3154
3155 #[test]
3156 fn top_bar_excludes_generic_operations() {
3157 let mut operation_app = app();
3158 let graph = operation_graph(NodeState::Failed);
3159 restore_graph(&mut operation_app, &graph);
3160
3161 assert_eq!(super::height(&mut operation_app, 100, 24, AMPLE_BUDGET), 0);
3162 assert!(operation_app.work_surface.latest_rows.is_empty());
3163
3164 let mut todo_app = app();
3165 add_todos(&mut todo_app, 2);
3166 assert!(super::height(&mut todo_app, 100, 24, AMPLE_BUDGET) > 0);
3167 assert!(
3168 todo_app
3169 .work_surface
3170 .latest_rows
3171 .iter()
3172 .all(|row| row.id.0.starts_with("graph:") || row.id.0.starts_with("worker:"))
3173 );
3174 assert!(
3175 todo_app
3176 .work_surface
3177 .latest_rows
3178 .iter()
3179 .all(|row| !row.label.starts_with("Work ·"))
3180 );
3181 }
3182
3183 #[test]
3184 fn opened_row_toggles_closed_without_losing_selection() {
3185 let mut app = app();
3186 add_todos(&mut app, 1);
3187 let row = super::model::project(&mut app)
3188 .into_iter()
3189 .find(|row| row.selectable)
3190 .expect("work row");
3191 let open = row.primary_action.clone();
3192
3193 assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
3194 // The action's pager is on screen, so the second activation is a
3195 // toggle-close.
3196 app.view_stack.push(crate::tui::pager::PagerView::from_text(
3197 "Work · test".to_string(),
3198 "body",
3199 40,
3200 ));
3201 assert!(super::interaction::activate_primary(&mut app, &row.id, open).is_none());
3202 assert!(app.work_surface.opened.is_none());
3203 assert_eq!(app.work_surface.selected.as_ref(), Some(&row.id));
3204 }
3205
3206 #[test]
3207 fn a_click_after_the_pager_closed_itself_reopens_instead_of_going_dead() {
3208 // q/Esc inside the pager pops it without clearing `opened`. The next
3209 // click on that row must reopen its world, not be swallowed by a
3210 // stale toggle (owner regression report, 2026-08-04).
3211 let mut app = app();
3212 add_todos(&mut app, 1);
3213 let row = super::model::project(&mut app)
3214 .into_iter()
3215 .find(|row| row.selectable)
3216 .expect("work row");
3217 let open = row.primary_action.clone();
3218
3219 assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
3220 // The pager was closed from inside itself; `opened` is now stale.
3221 assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
3222 assert!(app.view_stack.is_empty());
3223
3224 let reopened = super::interaction::activate_primary(&mut app, &row.id, open);
3225 assert!(
3226 reopened.is_some(),
3227 "a stale opened owner must not swallow the next activation"
3228 );
3229 assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
3230 }
3231
3232 /// Settled to-dos keep their rows across the recent-only TTL and new user
3233 /// turns. Finished sub-agents collapse into the Subagents Archived count
3234 /// (still reachable via the Agents panel) so fan-outs do not permanently
3235 /// eat the transcript.
3236 #[test]
3237 fn settled_todos_stay_and_finished_workers_collapse_after_ttl() {
3238 let mut app = app();
3239 app.current_session_id = Some(SESSION.to_string());
3240 {
3241 let mut todos = app.todos.try_lock().expect("todos");
3242 todos.add("ship the fix".to_string(), TodoStatus::Completed);
3243 todos.add("verify the fix".to_string(), TodoStatus::Completed);
3244 }
3245 app.subagent_cache.push(cached_worker(
3246 "agent-settled",
3247 "builder",
3248 None,
3249 None,
3250 SubAgentStatus::Completed,
3251 ));
3252
3253 app.work_surface.set_presentation_now_ms(0);
3254 let first = super::model::project_visible(&mut app);
3255 assert!(
3256 first.iter().any(|row| row.id.0.starts_with("graph:")),
3257 "settled to-dos must be listed: {first:?}"
3258 );
3259 assert!(
3260 !first.iter().any(|row| row.id.0.starts_with("worker:")),
3261 "workers are the agents view's rows, never the tasks view's: {first:?}"
3262 );
3263 let roster = super::model::visible_rows_for(&mut app, super::RailPanel::Agents);
3264 assert!(
3265 roster.iter().any(|row| row.id.0 == "worker:agent-settled"),
3266 "the roster retains a finished worker: {roster:?}"
3267 );
3268
3269 app.work_surface
3270 .set_presentation_now_ms(super::model::RECENT_ONLY_TTL_MS + 1);
3271 app.work_surface.note_user_turn_or_new_operation();
3272 let later = super::model::project_visible(&mut app);
3273 assert!(
3274 later.iter().any(|row| row.id.0.starts_with("graph:")),
3275 "a settled to-do must survive the TTL and the next user turn: {later:?}"
3276 );
3277 assert!(
3278 super::height(&mut app, 100, 40, AMPLE_BUDGET) > 0,
3279 "the strip must keep its height while it holds settled work"
3280 );
3281 }
3282
3283 /// A to-do row says its state in words, in the `/task digest` vocabulary.
3284 /// Dropping the words (2011b9b11 conflated them with the redundant kind
3285 /// label) was half of owner regression A1.
3286 #[test]
3287 fn todo_rows_carry_their_status_words() {
3288 let mut app = app();
3289 add_todos(&mut app, 3);
3290 let rows = super::model::project(&mut app);
3291 let todo_details: Vec<&str> = rows
3292 .iter()
3293 .filter(|row| row.id.0.starts_with("graph:"))
3294 .map(|row| row.detail.as_str())
3295 .collect();
3296 assert!(
3297 todo_details.contains(&"in progress"),
3298 "the active step says so in words: {todo_details:?}"
3299 );
3300 assert!(
3301 todo_details.contains(&"pending"),
3302 "a pending step is labeled, not blank: {todo_details:?}"
3303 );
3304
3305 // And the words are painted, not just projected.
3306 let text = render_text(&mut app, 100, 6);
3307 assert!(text.contains("in progress"), "{text}");
3308 assert!(text.contains("pending"), "{text}");
3309 }
3310
3311 /// Top strip collapses completed/cancelled workers into an Archived count
3312 /// while keeping live (and failed) workers as rows. Agents panel still
3313 /// lists every worker — see the click test below.
3314 #[test]
3315 fn the_roster_keeps_live_failed_and_finished_workers() {
3316 let mut app = app();
3317 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3318 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3319 app.current_session_id = Some(SESSION.to_string());
3320 app.subagent_cache.push(cached_worker(
3321 "agent-live",
3322 "scout",
3323 None,
3324 None,
3325 SubAgentStatus::Running,
3326 ));
3327 app.subagent_cache.push(cached_worker(
3328 "agent-done",
3329 "builder",
3330 None,
3331 None,
3332 SubAgentStatus::Completed,
3333 ));
3334 app.subagent_cache.push(cached_worker(
3335 "agent-failed",
3336 "verifier",
3337 None,
3338 None,
3339 SubAgentStatus::Failed("boom".to_string()),
3340 ));
3341
3342 // The roster is a history: every worker keeps its row, in every
3343 // state, and the tasks view never lists one.
3344 let rows = super::model::visible_rows_for(&mut app, super::RailPanel::Agents);
3345 let ids: Vec<&str> = rows.iter().map(|row| row.id.0.as_str()).collect();
3346 assert!(ids.contains(&"section:agents"), "{ids:?}");
3347 for id in [
3348 "worker:agent-live",
3349 "worker:agent-failed",
3350 "worker:agent-done",
3351 ] {
3352 assert!(ids.contains(&id), "{id} in the roster: {ids:?}");
3353 }
3354 let tasks = super::model::project_visible(&mut app);
3355 assert!(
3356 !tasks.iter().any(|row| row.id.0.starts_with("worker:")),
3357 "{tasks:?}"
3358 );
3359 assert_eq!(super::model::live_agent_row_count(&mut app), 2);
3360 }
3361
3362 #[test]
3363 fn subagent_header_opens_the_full_agents_register() {
3364 let mut app = app();
3365 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3366 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3367 app.current_session_id = Some(SESSION.to_string());
3368 app.subagent_cache.push(cached_worker(
3369 "agent-archived",
3370 "builder",
3371 None,
3372 None,
3373 SubAgentStatus::Completed,
3374 ));
3375
3376 // Nothing live: the dock stays down until the user opens agents.
3377 assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0);
3378 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3379 let top = render_text(&mut app, 100, 4);
3380 assert!(top.contains("Subagents 1"), "{top}");
3381 let header_y = app
3382 .work_surface
3383 .hitboxes
3384 .iter()
3385 .find(|hit| hit.id.0 == "section:agents")
3386 .expect("subagent header must be a real hit target")
3387 .row_y;
3388 let action = super::handle_mouse(
3389 &mut app,
3390 MouseEvent {
3391 kind: MouseEventKind::Down(MouseButton::Left),
3392 column: 2,
3393 row: header_y,
3394 modifiers: KeyModifiers::NONE,
3395 },
3396 )
3397 .action
3398 .expect("subagent header must dispatch its primary action");
3399 assert_eq!(action, SidebarRowAction::ShowSubagentsPanel);
3400 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3401
3402 let agents = render_text(&mut app, 100, 6);
3403 assert!(
3404 agents.contains("agent-archived") || agents.contains("builder"),
3405 "the full Agents register keeps the archived worker reachable: {agents}"
3406 );
3407 }
3408
3409 #[test]
3410 fn agent_entry_focuses_a_visible_row_and_esc_returns_to_composer() {
3411 let mut app = app();
3412 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3413 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3414 app.current_session_id = Some(SESSION.to_string());
3415 app.subagent_cache.push(cached_worker(
3416 "agent-live",
3417 "builder",
3418 None,
3419 None,
3420 SubAgentStatus::Running,
3421 ));
3422
3423 let rendered = render_text(&mut app, 100, 8);
3424 assert!(rendered.contains("builder"), "{rendered}");
3425
3426 assert!(super::enter_agents(&mut app));
3427 assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
3428 assert!(app.work_surface.focused);
3429 assert_eq!(
3430 app.work_surface.selected.as_ref().map(|row| row.0.as_str()),
3431 Some("worker:agent-live")
3432 );
3433
3434 let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
3435 assert!(handled.is_some());
3436 assert!(
3437 !app.work_surface.focused,
3438 "Esc returns ownership to composer"
3439 );
3440 }
3441
3442 #[test]
3443 fn agent_entry_rejects_a_surface_that_is_not_rendered() {
3444 let mut app = app();
3445 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3446 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3447 app.current_session_id = Some(SESSION.to_string());
3448 app.subagent_cache.push(cached_worker(
3449 "agent-live",
3450 "builder",
3451 None,
3452 None,
3453 SubAgentStatus::Running,
3454 ));
3455 // A previous frame's rectangle is not evidence that this Agent row
3456 // was painted. Only the renderer's current hitboxes may transfer
3457 // keyboard ownership away from the composer.
3458 app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 5));
3459 assert!(app.work_surface.hitboxes.is_empty());
3460
3461 assert!(!super::enter_agents(&mut app));
3462 assert!(
3463 !app.work_surface.focused,
3464 "hidden surface cannot own arrows"
3465 );
3466 }
3467
3468 #[test]
3469 fn compact_top_surface_keeps_goal_todos_and_named_agent_visible() {
3470 for (width, terminal_height) in [(160, 48), (120, 32), (80, 24)] {
3471 let mut app = app();
3472 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3473 app.work_surface.panel = super::RailPanel::Tasks;
3474 app.work_surface.top_height = super::model::TOP_HEIGHT_MIN;
3475 app.goal.objective = Some("ship the release".to_string());
3476 add_todos(&mut app, 3);
3477 app.current_session_id = Some(SESSION.to_string());
3478 app.subagent_cache.push(cached_worker(
3479 "agent-harbor",
3480 "builder",
3481 Some("Harbor"),
3482 None,
3483 SubAgentStatus::Running,
3484 ));
3485
3486 let budget = crate::tui::ui::rail_row_budget(&app, width, terminal_height, false);
3487 let height = super::height(&mut app, width, terminal_height, budget);
3488 assert_eq!(
3489 height,
3490 super::model::TOP_HEIGHT_MIN,
3491 "{width}x{terminal_height} must seat the readable compact surface"
3492 );
3493 // The to-do list opens first: goal title + the progress
3494 // receipt. The named agent lives one view over.
3495 let rendered = render_text(&mut app, width, height);
3496 assert!(
3497 rendered.contains("ship the release"),
3498 "{width}x{terminal_height}: {rendered}"
3499 );
3500 assert!(
3501 rendered.contains("3 left"),
3502 "{width}x{terminal_height}: {rendered}"
3503 );
3504 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3505 let height = super::height(&mut app, width, terminal_height, budget);
3506 let agents = render_text(&mut app, width, height);
3507 assert!(
3508 agents.contains("Harbor"),
3509 "{width}x{terminal_height}: {agents}"
3510 );
3511 app.work_surface.explicit_view = false;
3512 let height = super::height(&mut app, width, terminal_height, budget);
3513 let _ = render_text(&mut app, width, height);
3514
3515 assert!(super::enter_agents(&mut app));
3516 assert_eq!(
3517 app.work_surface.selected.as_ref().map(|row| row.0.as_str()),
3518 Some("worker:agent-harbor"),
3519 "the advertised Left control must focus the named visible Agent"
3520 );
3521 }
3522 }
3523
3524 #[test]
3525 fn starved_surface_cannot_take_keyboard_focus() {
3526 let mut app = app();
3527 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3528 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3529 app.current_session_id = Some(SESSION.to_string());
3530 app.subagent_cache.push(cached_worker(
3531 "agent-hidden",
3532 "builder",
3533 Some("Harbor"),
3534 None,
3535 SubAgentStatus::Running,
3536 ));
3537
3538 assert_eq!(super::height(&mut app, 80, 12, 0), 0);
3539 assert!(app.work_surface.last_area.is_none());
3540 assert!(!super::enter_agents(&mut app));
3541 assert!(!app.work_surface.focused);
3542 assert!(
3543 super::handle_key(
3544 &mut app,
3545 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
3546 )
3547 .is_none()
3548 );
3549 assert!(!app.work_surface.focused);
3550 }
3551
3552 /// Acceptance for owner regression A2: an agent row is a door in the
3553 /// Agents panel too, and a FINISHED agent's world still opens — the
3554 /// panel is a standing register, not a live-only view. Since v0.9.7 the
3555 /// door leads to the agent's transcript (which explains itself when no
3556 /// capture exists yet), not to the details projection.
3557 #[test]
3558 fn agents_panel_click_opens_the_transcript_even_for_finished_agents() {
3559 let mut app = app();
3560 app.work_surface.panel = super::RailPanel::Agents;
3561 app.current_session_id = Some(SESSION.to_string());
3562 app.subagent_cache.push(cached_worker(
3563 "agent-finished",
3564 "builder",
3565 None,
3566 None,
3567 SubAgentStatus::Completed,
3568 ));
3569
3570 let _ = render_text(&mut app, 100, 6);
3571 let row_y = app
3572 .work_surface
3573 .hitboxes
3574 .iter()
3575 .find(|hit| hit.id.0 == "worker:agent-finished")
3576 .expect("finished agent row must keep a hitbox in the Agents panel")
3577 .row_y;
3578 let action = super::handle_mouse(
3579 &mut app,
3580 MouseEvent {
3581 kind: MouseEventKind::Down(MouseButton::Left),
3582 column: 2,
3583 row: row_y,
3584 modifiers: KeyModifiers::NONE,
3585 },
3586 )
3587 .action
3588 .expect("click on a finished agent row must dispatch its primary action");
3589 assert_eq!(
3590 action,
3591 SidebarRowAction::OpenAgentTranscript {
3592 agent_id: "agent-finished".to_string()
3593 }
3594 );
3595 crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
3596 assert!(
3597 app.agent_focus
3598 .as_ref()
3599 .is_some_and(|focus| focus.is("agent-finished")),
3600 "the finished agent's transcript must actually take focus"
3601 );
3602 }
3603
3604 /// Acceptance for owner regression A1: to-do rows are doors in the
3605 /// Pinned panel too — clicking one opens the work inspector.
3606 #[test]
3607 fn tasks_view_todo_rows_stay_clickable() {
3608 let mut app = app();
3609 app.work_surface.panel = super::RailPanel::Tasks;
3610 add_todos(&mut app, 2);
3611
3612 let _ = render_text(&mut app, 100, 6);
3613 let hit = app
3614 .work_surface
3615 .hitboxes
3616 .iter()
3617 .find(|hit| hit.id.0.starts_with("graph:"))
3618 .expect("tasks view to-do rows must keep hitboxes")
3619 .clone();
3620 let action = super::handle_mouse(
3621 &mut app,
3622 MouseEvent {
3623 kind: MouseEventKind::Down(MouseButton::Left),
3624 column: 2,
3625 row: hit.row_y,
3626 modifiers: KeyModifiers::NONE,
3627 },
3628 )
3629 .action
3630 .expect("click on a to-do row must dispatch its primary action");
3631 assert!(
3632 matches!(action, SidebarRowAction::InspectWork { .. }),
3633 "a to-do row opens the work inspector: {action:?}"
3634 );
3635 }
3636
3637 /// Opening the sub-agent register must not hide the to-do list — both
3638 /// durable surfaces stay visible together (owner report, 0.9.6). The
3639 /// dock opens on TODO; AGENTS is the next tab (founder, 2026-09-03).
3640 #[test]
3641 fn agents_and_tasks_are_separate_views_and_the_dock_opens_on_todo() {
3642 let mut app = app();
3643 app.current_session_id = Some(SESSION.to_string());
3644 app.subagent_cache.push(cached_worker(
3645 "agent-live",
3646 "scout",
3647 None,
3648 None,
3649 SubAgentStatus::Running,
3650 ));
3651 add_todos(&mut app, 2);
3652
3653 // The auto rule: the to-do list first, and only to-dos in it.
3654 super::model::resolve_view(&mut app);
3655 assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
3656 let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
3657 .iter()
3658 .map(|row| row.id.0.clone())
3659 .collect();
3660 assert!(ids.iter().any(|id| id.starts_with("graph:")), "{ids:?}");
3661 assert!(!ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}");
3662
3663 // One key forward: the agents view, the roster only.
3664 super::cycle_view(&mut app, true);
3665 assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
3666 assert!(app.work_surface.explicit_view);
3667 let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
3668 .iter()
3669 .map(|row| row.id.0.clone())
3670 .collect();
3671 assert!(ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}");
3672 assert!(
3673 !ids.iter().any(|id| id.starts_with("graph:")),
3674 "the agents view is the roster, not the to-do list: {ids:?}"
3675 );
3676
3677 // Back, and Esc hands the choice back to the auto rule.
3678 super::cycle_view(&mut app, false);
3679 assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
3680 let _ = render_text(&mut app, 80, 8);
3681 assert!(app.work_surface.focused);
3682 let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
3683 assert!(handled.is_some());
3684 assert!(!app.work_surface.explicit_view);
3685 assert!(app.work_surface.dismissed);
3686 }
3687
3688 #[test]
3689 fn cycling_visits_every_view_in_order_and_an_empty_view_still_paints() {
3690 let mut app = app();
3691 add_todos(&mut app, 1);
3692 let mut seen = vec![];
3693 for _ in 0..super::RailPanel::ORDER.len() {
3694 super::cycle_view(&mut app, true);
3695 seen.push(app.work_surface.panel);
3696 let height = super::height(&mut app, 80, 24, AMPLE_BUDGET);
3697 assert!(
3698 height > 0,
3699 "{:?} must keep a strip while explicitly open",
3700 app.work_surface.panel
3701 );
3702 }
3703 let mut expected = super::RailPanel::ORDER.to_vec();
3704 expected.rotate_left(1); // the fixture starts on tasks, the first tab
3705 assert_eq!(seen, expected);
3706 // An empty explicit view names itself instead of going blank.
3707 super::interaction::select_dock_panel(&mut app, super::RailPanel::Files);
3708 let text = render_text(&mut app, 80, 5);
3709 assert!(text.contains("no files touched this session"), "{text}");
3710 }
3711
3712 /// The register header is a two-way door: open the Agents panel, then the
3713 /// same click returns to Tasks, so the to-do list is never stranded.
3714 #[test]
3715 fn subagent_header_returns_to_tasks_from_the_agents_view() {
3716 let mut app = app();
3717 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3718 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3719 app.current_session_id = Some(SESSION.to_string());
3720 app.subagent_cache.push(cached_worker(
3721 "agent-archived",
3722 "builder",
3723 None,
3724 None,
3725 SubAgentStatus::Completed,
3726 ));
3727 // A finished worker alone opens nothing; the user cycles to agents.
3728 assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0);
3729 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3730
3731 let click_header = |app: &mut App| -> SidebarRowAction {
3732 let header_y = app
3733 .work_surface
3734 .hitboxes
3735 .iter()
3736 .find(|hit| hit.id.0 == "section:agents")
3737 .expect("subagent header is a real hit target")
3738 .row_y;
3739 super::handle_mouse(
3740 app,
3741 MouseEvent {
3742 kind: MouseEventKind::Down(MouseButton::Left),
3743 column: 2,
3744 row: header_y,
3745 modifiers: KeyModifiers::NONE,
3746 },
3747 )
3748 .action
3749 .expect("subagent header dispatches its primary action")
3750 };
3751
3752 let _ = render_text(&mut app, 100, 6);
3753 let action = click_header(&mut app);
3754 assert_eq!(action, SidebarRowAction::ShowSubagentsPanel);
3755 crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
3756 assert_eq!(
3757 app.work_surface.panel,
3758 super::RailPanel::Tasks,
3759 "clicking the header inside the register returns to Tasks"
3760 );
3761 }
3762
3763 /// ⌥V opens the selected work row's own details; the transcript pager is
3764 /// only the fallback when no row is selected (owner report, 0.9.6).
3765 #[test]
3766 fn details_chord_opens_the_selected_work_row() {
3767 let mut app = app();
3768 app.current_session_id = Some(SESSION.to_string());
3769 app.work_surface.panel = super::RailPanel::Agents;
3770 add_todos(&mut app, 2);
3771 let _ = render_text(&mut app, 100, 6);
3772
3773 let rows = super::model::visible_rows_for_panel(&mut app);
3774 let todo_row = rows
3775 .iter()
3776 .find(|row| row.id.0.starts_with("graph:"))
3777 .expect("a to-do row projects")
3778 .clone();
3779 app.work_surface.focused = true;
3780 app.work_surface.selected = Some(todo_row.id.clone());
3781
3782 let handled = super::handle_key(
3783 &mut app,
3784 KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
3785 );
3786 assert!(
3787 matches!(handled, Some(Some(SidebarRowAction::InspectWork { .. }))),
3788 "⌥V opens the selected row's own details: {handled:?}"
3789 );
3790 }
3791 }
3792
3792 lines RUST