返回 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::{Config, ProviderKind};
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 native_preset: None,
1010 objective: "Wire settled file activity".to_string(),
1011 role: Some("general".to_string()),
1012 },
1013 model: "test-model".to_string(),
1014 nickname: Some(crate::tools::subagent::whale_name_for_id_in_locale(
1015 "agent_worker",
1016 "en",
1017 )),
1018 status: SubAgentStatus::Running,
1019 worker_status: Some(AgentWorkerStatus::RunningTool),
1020 runtime_permissions: None,
1021 parent_run_id: None,
1022 spawn_depth: 1,
1023 child_route: None,
1024 result: None,
1025 steps_taken: 2,
1026 checkpoint: None,
1027 needs_input: None,
1028 duration_ms: 50,
1029 started_at: None,
1030 from_prior_session: false,
1031 idle_ms: None,
1032 heartbeat_timeout_ms: None,
1033 });
1034 app.agent_progress_meta.insert(
1035 "agent_worker".to_string(),
1036 crate::tui::app::AgentProgressMeta {
1037 current_activity: Some(AgentCurrentActivity::bounded(
1038 AgentCurrentActivityStatus::RunningTool,
1039 None,
1040 Some("File.apply_patch".to_string()),
1041 Some(2),
1042 )),
1043 current_tool: Some("apply_patch".to_string()),
1044 files_touched: 2,
1045 ..crate::tui::app::AgentProgressMeta::default()
1046 },
1047 );
1048
1049 let rows = super::model::project(&mut app);
1050 let row = rows
1051 .iter()
1052 .find(|row| row.id.0 == "worker:agent_worker")
1053 .expect("agent work row");
1054 // The identity column leads with the agent's nickname and keeps the
1055 // fleet role as the fallback spelling. It is never the raw agent id
1056 // (#36), and carries no `(+N)` while the agent is childless.
1057 assert_eq!(
1058 row.label,
1059 crate::tools::subagent::whale_name_for_id_in_locale("agent_worker", "en")
1060 );
1061 let facts = row.agent.as_ref().expect("agent row facts");
1062 assert_eq!(facts.role_label, "general");
1063 assert_eq!(facts.objective, "Wire settled file activity");
1064 assert_eq!(facts.elapsed_secs, Some(0));
1065 // No usage envelope has been seen, so there is no token figure at all.
1066 assert_eq!(facts.tokens, None);
1067 assert!(row.detail.contains("Wire settled file activity"));
1068 assert!(row.detail.contains("using File.apply_patch"));
1069 assert!(row.detail.contains("step 2"));
1070 assert!(row.detail.contains("2 files changed"));
1071 // One agent, one destination (v0.9.7): activation opens the agent's
1072 // transcript directly; Agent Details is the secondary action.
1073 assert_eq!(
1074 row.primary_action,
1075 Some(SidebarRowAction::OpenAgentTranscript {
1076 agent_id: "agent_worker".to_string(),
1077 })
1078 );
1079 }
1080
1081 fn cached_worker(
1082 id: &str,
1083 role: &str,
1084 nickname: Option<&str>,
1085 parent_run_id: Option<&str>,
1086 status: SubAgentStatus,
1087 ) -> SubAgentResult {
1088 SubAgentResult {
1089 // `name` is the raw session id in production snapshots — the
1090 // strip must never render it (#36).
1091 usage: None,
1092 name: id.to_string(),
1093 agent_id: id.to_string(),
1094 context_mode: "fresh".to_string(),
1095 fork_context: false,
1096 workspace: None,
1097 git_branch: None,
1098 agent_type: FleetRole::Builder,
1099 assignment: SubAgentAssignment {
1100 native_preset: None,
1101 objective: format!("objective for {id}"),
1102 role: Some(role.to_string()),
1103 },
1104 model: "test-model".to_string(),
1105 nickname: nickname.map(str::to_string),
1106 status,
1107 worker_status: None,
1108 runtime_permissions: None,
1109 parent_run_id: parent_run_id.map(str::to_string),
1110 spawn_depth: u32::from(parent_run_id.is_some()) + 1,
1111 child_route: None,
1112 result: None,
1113 steps_taken: 1,
1114 checkpoint: None,
1115 needs_input: None,
1116 duration_ms: 50,
1117 started_at: None,
1118 from_prior_session: false,
1119 idle_ms: None,
1120 heartbeat_timeout_ms: None,
1121 }
1122 }
1123
1124 #[test]
1125 fn agent_rows_identify_by_fleet_role_and_never_leak_raw_ids() {
1126 // #36: the strip identifies an agent by its fleet role; the raw agent
1127 // id hash is noise and must never render as the "name". Flat fan-outs
1128 // carry no nesting chrome.
1129 let mut app = app();
1130 app.current_session_id = Some(SESSION.to_string());
1131 app.subagent_cache.push(cached_worker(
1132 "agent_e0b2dcf1",
1133 "builder",
1134 None,
1135 None,
1136 SubAgentStatus::Running,
1137 ));
1138 app.subagent_cache.push(cached_worker(
1139 "agent_99aa77bb",
1140 "scout",
1141 None,
1142 None,
1143 SubAgentStatus::Running,
1144 ));
1145
1146 let rows = super::model::project(&mut app);
1147 let first = rows
1148 .iter()
1149 .find(|row| row.id.0 == "worker:agent_e0b2dcf1")
1150 .expect("first agent row");
1151 let second = rows
1152 .iter()
1153 .find(|row| row.id.0 == "worker:agent_99aa77bb")
1154 .expect("second agent row");
1155 assert_eq!(first.label, "builder");
1156 assert_eq!(second.label, "scout");
1157 assert!(first.detail.starts_with("running"), "{}", first.detail);
1158 for row in rows.iter().filter(|row| row.id.0.starts_with("worker:")) {
1159 assert!(!row.label.contains("agent_e0b2dcf1"), "{}", row.label);
1160 assert!(!row.label.contains("agent_99aa77bb"), "{}", row.label);
1161 assert!(
1162 !row.label.contains('↳'),
1163 "flat fan-out must not show nesting chrome: {}",
1164 row.label
1165 );
1166 }
1167 }
1168
1169 #[test]
1170 fn agent_rows_order_and_indent_nested_spawns_under_their_parent() {
1171 // #36: nesting is visible only when actually present — the child
1172 // renders directly under its parent with a `↳` indent, and the parent
1173 // advertises the child it spawned as `(+1)`.
1174 let mut app = app();
1175 app.current_session_id = Some(SESSION.to_string());
1176 app.subagent_cache.push(cached_worker(
1177 "agent_child",
1178 "scout",
1179 None,
1180 Some("agent_parent"),
1181 SubAgentStatus::Running,
1182 ));
1183 app.subagent_cache.push(cached_worker(
1184 "agent_parent",
1185 "builder",
1186 None,
1187 None,
1188 SubAgentStatus::Running,
1189 ));
1190
1191 let rows = super::model::project(&mut app);
1192 let worker_labels = rows
1193 .iter()
1194 .filter(|row| row.id.0.starts_with("worker:"))
1195 .map(|row| row.label.as_str())
1196 .collect::<Vec<_>>();
1197 let parent_pos = worker_labels
1198 .iter()
1199 .position(|label| *label == "builder (+1)")
1200 .expect("parent row label with child count");
1201 let child_pos = worker_labels
1202 .iter()
1203 .position(|label| *label == "↳ scout")
1204 .expect("indented child row label");
1205 assert!(
1206 child_pos == parent_pos + 1,
1207 "child must render directly under its parent: {worker_labels:?}"
1208 );
1209 }
1210
1211 #[test]
1212 fn agent_rows_completed_agents_render_quietly_without_spawn_metadata() {
1213 // #36: quiet completion — a finished agent keeps status + what it
1214 // did; in-flight metadata (tool, step counters) must not linger as a
1215 // receipt dump. #6565: what it changed is the receipt, and once its
1216 // result is known the row says what it produced.
1217 let mut app = app();
1218 app.current_session_id = Some(SESSION.to_string());
1219 app.subagent_cache.push(cached_worker(
1220 "agent_done",
1221 "builder",
1222 None,
1223 None,
1224 SubAgentStatus::Completed,
1225 ));
1226 app.agent_progress_meta.insert(
1227 "agent_done".to_string(),
1228 crate::tui::app::AgentProgressMeta {
1229 current_activity: Some(AgentCurrentActivity::bounded(
1230 AgentCurrentActivityStatus::Done,
1231 Some("apply_patch finished".to_string()),
1232 Some("File.apply_patch".to_string()),
1233 Some(7),
1234 )),
1235 current_tool: Some("apply_patch".to_string()),
1236 files_touched: 4,
1237 ..crate::tui::app::AgentProgressMeta::default()
1238 },
1239 );
1240
1241 let rows = super::model::project(&mut app);
1242 let row = rows
1243 .iter()
1244 .find(|row| row.id.0 == "worker:agent_done")
1245 .expect("completed agent row");
1246 assert!(row.detail.contains("completed"), "{}", row.detail);
1247 assert!(
1248 row.detail.contains("objective for agent_done"),
1249 "{}",
1250 row.detail
1251 );
1252 assert!(!row.detail.contains("using "), "{}", row.detail);
1253 assert!(!row.detail.contains("step 7"), "{}", row.detail);
1254 assert!(row.detail.contains("4 files changed"), "{}", row.detail);
1255
1256 app.subagent_cache[0].result =
1257 Some("## Summary\n\nPatched the parser. Tests pass.".to_string());
1258 let rows = super::model::project(&mut app);
1259 let row = rows
1260 .iter()
1261 .find(|row| row.id.0 == "worker:agent_done")
1262 .expect("completed agent row");
1263 assert_eq!(
1264 row.detail, "completed · Patched the parser. · 4 files changed",
1265 "the headline replaces the assignment"
1266 );
1267 assert_eq!(
1268 row.agent.as_ref().map(|facts| facts.objective.as_str()),
1269 Some("Patched the parser.")
1270 );
1271 }
1272
1273 // ---- Fleet row layout -------------------------------------------------
1274
1275 /// Painted lines, one per terminal row, trailing padding removed.
1276 fn render_rows(app: &mut App, width: u16, height: u16) -> Vec<String> {
1277 let backend = TestBackend::new(width, height);
1278 let mut terminal = Terminal::new(backend).expect("terminal");
1279 terminal
1280 .draw(|frame| super::render(frame, frame.area(), app))
1281 .expect("draw");
1282 let buffer = terminal.backend().buffer().clone();
1283 (0..height)
1284 .map(|y| {
1285 (0..width)
1286 .map(|x| buffer[(x, y)].symbol())
1287 .collect::<String>()
1288 .trim_end()
1289 .to_string()
1290 })
1291 .collect()
1292 }
1293
1294 fn fleet_row(rows: &[String]) -> String {
1295 rows.iter()
1296 .find(|line| line.contains("Streaming"))
1297 .cloned()
1298 .unwrap_or_else(|| panic!("no fleet row in {rows:?}"))
1299 }
1300
1301 fn fleet_worker(
1302 id: &str,
1303 role: &str,
1304 objective: &str,
1305 duration_ms: u64,
1306 status: SubAgentStatus,
1307 ) -> SubAgentResult {
1308 let mut agent = cached_worker(id, role, None, None, status);
1309 agent.assignment.objective = objective.to_string();
1310 agent.duration_ms = duration_ms;
1311 agent
1312 }
1313
1314 /// Seed a live fleet of one, with a reported token spend.
1315 fn fleet_app(tokens: Option<u64>) -> App {
1316 let mut app = app();
1317 app.current_session_id = Some(SESSION.to_string());
1318 app.subagent_cache.push(fleet_worker(
1319 "agent_stream",
1320 "general-purpose",
1321 "Streaming dead-code removal",
1322 753_000,
1323 SubAgentStatus::Running,
1324 ));
1325 app.agent_progress_meta.insert(
1326 "agent_stream".to_string(),
1327 crate::tui::app::AgentProgressMeta {
1328 received_tokens: tokens,
1329 ..crate::tui::app::AgentProgressMeta::default()
1330 },
1331 );
1332 app
1333 }
1334
1335 #[test]
1336 fn fleet_row_lays_out_type_objective_and_a_right_aligned_receipt() {
1337 let mut app = fleet_app(Some(111_900));
1338 let rows = render_rows(&mut app, 100, 4);
1339
1340 assert_eq!(
1341 fleet_row(&rows),
1342 " ▸ general-purpose running Streaming dead-code removal \
1343 12m 33s · ↓ 111.9k tokens"
1344 );
1345 // The group header the strip already had stays put.
1346 assert!(
1347 rows.iter().any(|line| line.contains("Subagents 1")),
1348 "{rows:?}"
1349 );
1350 }
1351
1352 #[test]
1353 fn focused_worker_row_carries_the_left_marker_and_queued_follow_ups() {
1354 let mut app = fleet_app(Some(111_900));
1355 // No focus, nothing queued: the row is exactly as before.
1356 let plain = fleet_row(&render_rows(&mut app, 100, 4));
1357 assert!(!plain.starts_with("❯"), "{plain}");
1358 assert!(!plain.contains("queued"), "{plain}");
1359
1360 crate::tui::agent_focus::focus_agent(&mut app, "agent_stream");
1361 app.agent_queued_follow_ups
1362 .insert("agent_stream".to_string(), 1);
1363 let focused = fleet_row(&render_rows(&mut app, 110, 4));
1364 assert!(
1365 focused.trim_start().starts_with("❯ ▸ general-purpose"),
1366 "left-edge marker names the addressed fork: {focused}"
1367 );
1368 assert!(focused.ends_with("· 1 queued"), "{focused}");
1369
1370 // The counter is the runtime's truth: once the child takes the input
1371 // the next AgentList refresh clears it and the suffix disappears.
1372 app.agent_queued_follow_ups.clear();
1373 let drained = fleet_row(&render_rows(&mut app, 110, 4));
1374 assert!(!drained.contains("queued"), "{drained}");
1375 // Leaving focus removes the gutter again.
1376 crate::tui::agent_focus::exit_focus(&mut app);
1377 let back = fleet_row(&render_rows(&mut app, 100, 4));
1378 assert_eq!(back, plain);
1379 }
1380
1381 #[test]
1382 fn fleet_row_repaints_resolved_model_and_each_distinct_usage_total() {
1383 let mut app = fleet_app(None);
1384 crate::tui::ui::record_agent_spawned_route(&mut app, "agent_stream", "deepseek-v4-pro");
1385 let launched = fleet_row(&render_rows(&mut app, 120, 4));
1386 assert!(launched.contains("deepseek-v4-pro"), "{launched}");
1387 assert!(!launched.contains("tokens"), "{launched}");
1388
1389 let route = crate::cost_status::EffectiveRouteEnvelope::capture(
1390 None,
1391 ProviderKind::Deepseek,
1392 ProviderKind::Deepseek.as_str(),
1393 "deepseek-v4-pro",
1394 Some(ProviderKind::Deepseek.provider().default_base_url()),
1395 chrono::Utc::now(),
1396 );
1397 let usage = |source_id: &str, input_tokens, output_tokens| MailboxMessage::TokenUsage {
1398 agent_id: "agent_stream".to_string(),
1399 source_id: source_id.to_string(),
1400 route: Box::new(route.clone()),
1401 usage: codewhale_models::Usage {
1402 input_tokens,
1403 output_tokens,
1404 ..Default::default()
1405 },
1406 };
1407
1408 crate::tui::subagent_routing::handle_subagent_mailbox(
1409 &mut app,
1410 99,
1411 &usage("response-1", 10_000, 1_000),
1412 );
1413 let first = fleet_row(&render_rows(&mut app, 120, 4));
1414 assert!(first.contains("deepseek-v4-pro"), "{first}");
1415 assert!(first.contains("11.0k tokens"), "{first}");
1416
1417 // Replaying the same mailbox envelope must not inflate the receipt.
1418 crate::tui::subagent_routing::handle_subagent_mailbox(
1419 &mut app,
1420 1,
1421 &usage("response-1", 10_000, 1_000),
1422 );
1423 let replay = fleet_row(&render_rows(&mut app, 120, 4));
1424 assert!(replay.contains("11.0k tokens"), "{replay}");
1425
1426 crate::tui::subagent_routing::handle_subagent_mailbox(
1427 &mut app,
1428 2,
1429 &usage("response-2", 20_000, 2_000),
1430 );
1431 let second = fleet_row(&render_rows(&mut app, 120, 4));
1432 assert!(second.contains("deepseek-v4-pro"), "{second}");
1433 assert!(second.contains("33.0k tokens"), "{second}");
1434 }
1435
1436 #[test]
1437 fn fleet_row_shows_remaining_todos_only_when_the_ledger_has_unsettled_work() {
1438 let mut app = fleet_app(Some(1_200));
1439 app.agent_progress_meta
1440 .get_mut("agent_stream")
1441 .expect("meta")
1442 .todos_remaining = Some(3);
1443
1444 let with_left = fleet_row(&render_rows(&mut app, 100, 4));
1445 assert!(
1446 with_left.contains("3 left"),
1447 "unsettled ledger must surface on the receipt: {with_left}"
1448 );
1449 assert!(
1450 with_left.contains("↓") && with_left.contains("tokens"),
1451 "tokens stay alongside the remaining chip: {with_left}"
1452 );
1453
1454 // Fully settled list → quiet (no fabricated zero chip).
1455 app.agent_progress_meta
1456 .get_mut("agent_stream")
1457 .expect("meta")
1458 .todos_remaining = Some(0);
1459 let settled = fleet_row(&render_rows(&mut app, 100, 4));
1460 assert!(
1461 !settled.contains("left"),
1462 "zero remaining must not paint a chip: {settled}"
1463 );
1464
1465 // No ledger published → quiet.
1466 app.agent_progress_meta
1467 .get_mut("agent_stream")
1468 .expect("meta")
1469 .todos_remaining = None;
1470 let absent = fleet_row(&render_rows(&mut app, 100, 4));
1471 assert!(
1472 !absent.contains("left"),
1473 "missing ledger must not invent a chip: {absent}"
1474 );
1475 }
1476
1477 #[test]
1478 fn fleet_identity_prefers_the_nickname_and_falls_back_to_the_role() {
1479 // Nicknames are CodeWhale identity, so they lead. An agent that has
1480 // none falls back to its fleet role rather than showing a blank or a
1481 // fabricated name.
1482 let mut app = app();
1483 app.current_session_id = Some(SESSION.to_string());
1484 let mut named = fleet_worker(
1485 "agent_named",
1486 "general-purpose",
1487 "Streaming dead-code removal",
1488 753_000,
1489 SubAgentStatus::Running,
1490 );
1491 named.nickname = Some("Fluke".to_string());
1492 app.subagent_cache.push(named);
1493 app.subagent_cache.push(fleet_worker(
1494 "agent_plain",
1495 "general-purpose",
1496 "Ambient visual calm-down",
1497 741_000,
1498 SubAgentStatus::Running,
1499 ));
1500
1501 let rows = super::model::project(&mut app);
1502 let row = |id: &str| {
1503 rows.iter()
1504 .find(|row| row.id.0 == format!("worker:{id}"))
1505 .unwrap_or_else(|| panic!("row for {id}"))
1506 };
1507 assert_eq!(row("agent_named").label, "Fluke");
1508 assert_eq!(
1509 row("agent_named").agent.as_ref().expect("facts").role_label,
1510 "general-purpose"
1511 );
1512 // No nickname: the identity and its fallback are the same string.
1513 assert_eq!(row("agent_plain").label, "general-purpose");
1514
1515 // Both spellings share one column, so the objectives stay aligned.
1516 let painted = render_rows(&mut app, 100, 5);
1517 let named_line = painted
1518 .iter()
1519 .find(|line| line.contains("Fluke"))
1520 .expect("nicknamed row");
1521 let plain_line = painted
1522 .iter()
1523 .find(|line| line.contains("general-purpose"))
1524 .expect("un-nicknamed row");
1525 assert_eq!(
1526 named_line.find("Streaming"),
1527 plain_line.find("Ambient"),
1528 "objectives must share a column:\n{named_line}\n{plain_line}"
1529 );
1530 }
1531
1532 #[test]
1533 fn an_identity_too_wide_for_the_column_falls_back_without_widening_it() {
1534 // The identity column is shared, so one outlier must not starve every
1535 // other objective — and a name is shown whole or not at all.
1536 let mut app = app();
1537 app.current_session_id = Some(SESSION.to_string());
1538 let mut long = fleet_worker(
1539 "agent_long",
1540 "general-purpose",
1541 "Streaming dead-code removal",
1542 753_000,
1543 SubAgentStatus::Running,
1544 );
1545 long.nickname = Some("Bartholomew the Extremely Long-Winded Humpback".to_string());
1546 app.subagent_cache.push(long);
1547 app.subagent_cache.push(fleet_worker(
1548 "agent_plain",
1549 "scout",
1550 "Ambient visual calm-down",
1551 741_000,
1552 SubAgentStatus::Running,
1553 ));
1554
1555 let painted = render_rows(&mut app, 100, 5);
1556 let joined = painted.join("\n");
1557 // The oversized nickname never renders, whole or truncated.
1558 assert!(!joined.contains("Bartholomew"), "{joined}");
1559 assert!(!joined.contains("Bartholom"), "{joined}");
1560 // It falls back to its role, and the other row is untouched.
1561 assert!(joined.contains("general-purpose"), "{joined}");
1562 assert!(joined.contains("scout"), "{joined}");
1563 // Neither objective was starved by the outlier.
1564 assert!(joined.contains("Streaming dead-code removal"), "{joined}");
1565 assert!(joined.contains("Ambient visual calm-down"), "{joined}");
1566 }
1567
1568 #[test]
1569 fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() {
1570 // Settled degradation order: tokens first, then elapsed, then the
1571 // type and status columns together. The objective is the last thing
1572 // to go and every column truncates rather than wrapping. The status
1573 // word outlives the whole receipt — a fleet row that cannot say its
1574 // state in words has lost the fact the strip exists to show.
1575 let mut app = fleet_app(Some(111_900));
1576 let medium = fleet_row(&render_rows(&mut app, 72, 4));
1577 assert!(medium.contains("12m 33s"), "{medium}");
1578 assert!(!medium.contains("tokens"), "{medium}");
1579 assert!(medium.contains("general-purpose"), "{medium}");
1580 assert!(medium.contains("running"), "{medium}");
1581
1582 let narrow = fleet_row(&render_rows(&mut app, 56, 4));
1583 assert!(!narrow.contains("tokens"), "{narrow}");
1584 assert!(!narrow.contains("12m 33s"), "{narrow}");
1585 assert!(narrow.contains("general-purpose"), "{narrow}");
1586 assert!(narrow.contains("running"), "{narrow}");
1587
1588 let tight = fleet_row(&render_rows(&mut app, 28, 4));
1589 assert!(!tight.contains("general-purpose"), "{tight}");
1590 assert!(!tight.contains("running"), "{tight}");
1591 assert!(tight.contains("Streaming"), "{tight}");
1592
1593 for line in [&medium, &narrow, &tight] {
1594 assert!(line.chars().all(|ch| ch != '\n'), "{line}");
1595 }
1596 }
1597
1598 #[test]
1599 fn fleet_row_elapsed_freezes_once_the_agent_is_finished() {
1600 // The manager recomputes `duration_ms` as `started_at.elapsed()` on
1601 // every snapshot, so a finished agent's raw duration keeps growing.
1602 // The row must latch the first terminal reading instead.
1603 let mut app = fleet_app(None);
1604 app.subagent_cache[0].status = SubAgentStatus::Completed;
1605 app.subagent_cache[0].duration_ms = 753_000;
1606
1607 let first = super::model::project(&mut app);
1608 let finished = first
1609 .iter()
1610 .find(|row| row.id.0 == "worker:agent_stream")
1611 .and_then(|row| row.agent.as_ref())
1612 .expect("finished agent facts");
1613 assert_eq!(finished.elapsed_secs, Some(753));
1614
1615 // A later snapshot reports a larger duration for the same dead agent.
1616 app.subagent_cache[0].duration_ms = 999_000;
1617 let second = super::model::project(&mut app);
1618 let still = second
1619 .iter()
1620 .find(|row| row.id.0 == "worker:agent_stream")
1621 .and_then(|row| row.agent.as_ref())
1622 .expect("finished agent facts");
1623 assert_eq!(
1624 still.elapsed_secs,
1625 Some(753),
1626 "finished elapsed must freeze"
1627 );
1628 }
1629
1630 #[test]
1631 fn fleet_row_elapsed_still_advances_while_the_agent_runs() {
1632 let mut app = fleet_app(None);
1633 app.subagent_cache[0].duration_ms = 10_000;
1634 let early = super::model::project(&mut app);
1635 assert_eq!(
1636 early
1637 .iter()
1638 .find(|row| row.id.0 == "worker:agent_stream")
1639 .and_then(|row| row.agent.as_ref())
1640 .expect("running agent facts")
1641 .elapsed_secs,
1642 Some(10)
1643 );
1644
1645 app.subagent_cache[0].duration_ms = 40_000;
1646 let later = super::model::project(&mut app);
1647 assert_eq!(
1648 later
1649 .iter()
1650 .find(|row| row.id.0 == "worker:agent_stream")
1651 .and_then(|row| row.agent.as_ref())
1652 .expect("running agent facts")
1653 .elapsed_secs,
1654 Some(40)
1655 );
1656 }
1657
1658 #[test]
1659 fn fleet_row_with_no_reported_usage_shows_no_token_figure_at_all() {
1660 // An unknown number is rendered as nothing. Never `0`, which would
1661 // claim the agent spent nothing.
1662 let mut app = fleet_app(None);
1663 let row = fleet_row(&render_rows(&mut app, 100, 4));
1664 assert!(!row.contains("tokens"), "{row}");
1665 assert!(!row.contains('↓'), "{row}");
1666 assert!(row.contains("12m 33s"), "{row}");
1667
1668 let mut spent = fleet_app(Some(0));
1669 let zero = fleet_row(&render_rows(&mut spent, 100, 4));
1670 // A *reported* zero is a fact and does render.
1671 assert!(zero.contains("↓ 0 tokens"), "{zero}");
1672 }
1673
1674 #[test]
1675 fn fleet_row_child_badge_counts_children_that_are_on_the_surface() {
1676 let mut app = app();
1677 app.current_session_id = Some(SESSION.to_string());
1678 app.subagent_cache.push(cached_worker(
1679 "agent_lead",
1680 "general-purpose",
1681 None,
1682 None,
1683 SubAgentStatus::Running,
1684 ));
1685 for child in ["agent_c1", "agent_c2", "agent_c3"] {
1686 app.subagent_cache.push(cached_worker(
1687 child,
1688 "scout",
1689 None,
1690 Some("agent_lead"),
1691 SubAgentStatus::Running,
1692 ));
1693 }
1694 // A child whose parent is not on the surface must not be counted for
1695 // anyone, and must not inflate the lead's badge.
1696 app.subagent_cache.push(cached_worker(
1697 "agent_orphan",
1698 "scout",
1699 None,
1700 Some("agent_missing"),
1701 SubAgentStatus::Running,
1702 ));
1703
1704 let rows = super::model::project(&mut app);
1705 let label = |id: &str| {
1706 rows.iter()
1707 .find(|row| row.id.0 == format!("worker:{id}"))
1708 .map(|row| row.label.clone())
1709 .unwrap_or_else(|| panic!("row for {id}"))
1710 };
1711 assert_eq!(label("agent_lead"), "general-purpose (+3)");
1712 assert_eq!(label("agent_c1"), "↳ scout");
1713 assert_eq!(label("agent_orphan"), "scout");
1714 }
1715
1716 #[test]
1717 fn a_capped_fleet_list_announces_how_many_rows_it_is_hiding() {
1718 let mut app = app();
1719 app.current_session_id = Some(SESSION.to_string());
1720 for index in 0..8 {
1721 app.subagent_cache.push(cached_worker(
1722 &format!("agent_{index}"),
1723 "general-purpose",
1724 None,
1725 None,
1726 SubAgentStatus::Running,
1727 ));
1728 }
1729
1730 // Four content rows for nine projected rows (header + eight workers).
1731 let rows = render_rows(&mut app, 100, 5);
1732 let more = rows
1733 .iter()
1734 .find(|line| line.contains("more"))
1735 .unwrap_or_else(|| panic!("no overflow line in {rows:?}"));
1736 // Nine projected rows (header + eight workers); two fit, seven do not.
1737 assert!(more.contains("↓ 7 more"), "{more}");
1738 // Right-aligned against the content column, not the left margin.
1739 assert!(more.starts_with(" "), "{more}");
1740 }
1741
1742 #[test]
1743 fn fleet_rows_render_in_top_left_and_right_placements() {
1744 for placement in [
1745 super::WorkSurfacePlacement::Top,
1746 super::WorkSurfacePlacement::Left,
1747 super::WorkSurfacePlacement::Right,
1748 ] {
1749 let mut app = fleet_app(Some(111_900));
1750 app.work_surface.placement = placement;
1751 app.work_surface.effective_placement = placement;
1752 let rows = render_rows(&mut app, 40, 8);
1753 let row = fleet_row(&rows);
1754 assert!(
1755 row.contains("Streaming"),
1756 "{placement:?} lost the objective: {rows:?}"
1757 );
1758 }
1759 }
1760
1761 #[test]
1762 fn progress_only_work_rows_use_typed_activity_not_display_substrings() {
1763 let mut app = app();
1764 app.current_session_id = Some(SESSION.to_string());
1765 app.agent_progress.insert(
1766 "agent_progress_only".to_string(),
1767 "queued waiting failed completed".to_string(),
1768 );
1769
1770 let rows = super::model::project(&mut app);
1771 let row = rows
1772 .iter()
1773 .find(|row| row.id.0 == "worker:agent_progress_only")
1774 .expect("progress-only work row");
1775 assert_eq!(row.detail, "running");
1776
1777 app.agent_progress_meta.insert(
1778 "agent_progress_only".to_string(),
1779 crate::tui::app::AgentProgressMeta {
1780 current_activity: Some(AgentCurrentActivity::bounded(
1781 AgentCurrentActivityStatus::Waiting,
1782 Some("approval required".to_string()),
1783 None,
1784 Some(5),
1785 )),
1786 ..crate::tui::app::AgentProgressMeta::default()
1787 },
1788 );
1789
1790 let rows = super::model::project(&mut app);
1791 let row = rows
1792 .iter()
1793 .find(|row| row.id.0 == "worker:agent_progress_only")
1794 .expect("typed progress-only work row");
1795 assert!(row.detail.contains("waiting for input"), "{}", row.detail);
1796 assert!(row.detail.contains("approval required"), "{}", row.detail);
1797 assert!(row.detail.contains("step 5"), "{}", row.detail);
1798 }
1799
1800 /// #6565: when the activity detail already names the step and the tool,
1801 /// the dock row says them once, not again as `using …` and `step N`.
1802 #[test]
1803 fn dock_agent_row_states_each_step_fact_once() {
1804 let mut app = app();
1805 app.current_session_id = Some(SESSION.to_string());
1806 app.agent_progress
1807 .insert("agent_reader".to_string(), String::new());
1808 app.agent_progress_meta.insert(
1809 "agent_reader".to_string(),
1810 crate::tui::app::AgentProgressMeta {
1811 current_activity: Some(AgentCurrentActivity::bounded(
1812 AgentCurrentActivityStatus::RunningTool,
1813 Some("step 6: finished tool 'read_file'".to_string()),
1814 Some("read_file".to_string()),
1815 Some(6),
1816 )),
1817 ..crate::tui::app::AgentProgressMeta::default()
1818 },
1819 );
1820
1821 let rows = super::model::project(&mut app);
1822 let row = rows
1823 .iter()
1824 .find(|row| row.id.0 == "worker:agent_reader")
1825 .expect("progress-only work row");
1826 assert_eq!(row.detail.matches("step 6").count(), 1, "{}", row.detail);
1827 assert_eq!(row.detail.matches("read_file").count(), 1, "{}", row.detail);
1828 assert!(!row.detail.contains("using read_file"), "{}", row.detail);
1829 }
1830
1831 // === #5906: a parked husk is not an agent waiting for input ==========
1832
1833 /// Build a child exactly the way the turn-end parking projection does:
1834 /// `Interrupted` + `WaitingForUser` + a `needs_input` note phrased as a
1835 /// question, distinguished from a real question only by the checkpoint's
1836 /// `parked_at_turn_end` flag.
1837 fn parked_worker(id: &str, objective: &str) -> SubAgentResult {
1838 let mut agent = fleet_worker(
1839 id,
1840 "general-purpose",
1841 objective,
1842 753_000,
1843 SubAgentStatus::Interrupted(
1844 "Parent turn ended before this turn-owned child settled.".to_string(),
1845 ),
1846 );
1847 agent.worker_status = Some(AgentWorkerStatus::WaitingForUser);
1848 agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput {
1849 question: format!(
1850 "Resume this parked child with agent(action=\"start\", resume_from=\"{id}\")."
1851 ),
1852 });
1853 agent.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint {
1854 checkpoint_id: format!("{id}:step:2"),
1855 agent_id: id.to_string(),
1856 continuation_handle: format!("agent:{id}:checkpoint"),
1857 reason: "Parent turn ended before this turn-owned child settled.".to_string(),
1858 continuable: true,
1859 steps_taken: 2,
1860 message_count: 4,
1861 created_at_ms: 1_000,
1862 messages: Vec::new(),
1863 omitted_messages: 0,
1864 parked_at_turn_end: true,
1865 });
1866 agent
1867 }
1868
1869 fn asking_worker(id: &str, objective: &str) -> SubAgentResult {
1870 let mut agent = fleet_worker(
1871 id,
1872 "general-purpose",
1873 objective,
1874 120_000,
1875 SubAgentStatus::Running,
1876 );
1877 agent.worker_status = Some(AgentWorkerStatus::WaitingForUser);
1878 agent.needs_input = Some(crate::tools::subagent::SubAgentNeedsInput {
1879 question: "Which path should I use?".to_string(),
1880 });
1881 agent
1882 }
1883
1884 fn parked_fixture() -> App {
1885 let mut app = app();
1886 app.current_session_id = Some(SESSION.to_string());
1887 app.subagent_cache
1888 .push(parked_worker("agent_parked", "Parked dead-code removal"));
1889 app.subagent_cache
1890 .push(asking_worker("agent_asking", "Asking about the path"));
1891 app.subagent_cache.push(fleet_worker(
1892 "agent_live",
1893 "general-purpose",
1894 "Streaming dead-code removal",
1895 30_000,
1896 SubAgentStatus::Running,
1897 ));
1898 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
1899 app
1900 }
1901
1902 #[test]
1903 fn a_parked_work_row_says_parked_and_names_its_recovery() {
1904 let mut app = parked_fixture();
1905 let rows = super::model::project(&mut app);
1906
1907 let parked = rows
1908 .iter()
1909 .find(|row| row.id.0 == "worker:agent_parked")
1910 .expect("parked work row");
1911 assert!(parked.detail.starts_with("parked"), "{}", parked.detail);
1912 assert!(
1913 !parked.detail.contains("waiting for input"),
1914 "a parked husk must not wear the answerable label: {}",
1915 parked.detail
1916 );
1917 // The recovery names verbs the runtime actually exposes.
1918 assert!(parked.detail.contains("resume_from"), "{}", parked.detail);
1919 assert!(parked.detail.contains("cancel"), "{}", parked.detail);
1920 assert!(
1921 !parked.detail.contains("Resume this parked child"),
1922 "the parking note is not a question to replay at the operator: {}",
1923 parked.detail
1924 );
1925
1926 let asking = rows
1927 .iter()
1928 .find(|row| row.id.0 == "worker:agent_asking")
1929 .expect("asking work row");
1930 assert!(
1931 asking.detail.contains("waiting for input"),
1932 "a child that really asked keeps the answerable label: {}",
1933 asking.detail
1934 );
1935 assert!(
1936 asking.detail.contains("Which path should I use?"),
1937 "{}",
1938 asking.detail
1939 );
1940 }
1941
1942 #[test]
1943 fn parked_rows_sort_below_live_work_and_leave_the_needs_input_count_alone() {
1944 let mut app = parked_fixture();
1945 let rows = super::model::project(&mut app);
1946
1947 let heading = rows
1948 .iter()
1949 .find(|row| row.id.0 == "section:work")
1950 .expect("work heading");
1951 // The attention chip counts children a person is actually blocking:
1952 // one, the child that asked. Two would mean the parked husk had been
1953 // counted as waiting for input all over again.
1954 assert!(
1955 heading.label.contains("1 blocked"),
1956 "only the child that actually asked is blocked on a person: {}",
1957 heading.label
1958 );
1959
1960 let position = |id: &str| {
1961 rows.iter()
1962 .position(|row| row.id.0 == id)
1963 .unwrap_or_else(|| panic!("{id} missing from {rows:?}"))
1964 };
1965 assert!(
1966 position("worker:agent_parked") > position("worker:agent_live"),
1967 "a parked husk must not sort above live work"
1968 );
1969 assert!(
1970 position("worker:agent_parked") > position("worker:agent_asking"),
1971 "a parked husk must not sort above a child a person can answer"
1972 );
1973 }
1974
1975 #[test]
1976 fn the_parked_status_word_survives_the_narrow_row_ladder() {
1977 // The status word outlives the whole receipt as the strip narrows
1978 // (see the degradation test above); `parked` is the fact the row
1979 // exists to carry, so it must survive the same ladder `running` does.
1980 let mut app = app();
1981 app.current_session_id = Some(SESSION.to_string());
1982 app.subagent_cache
1983 .push(parked_worker("agent_parked", "Parked dead-code removal"));
1984 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
1985
1986 for width in [96u16, 72, 56] {
1987 let rows = render_rows(&mut app, width, 6);
1988 let row = rows
1989 .iter()
1990 .find(|line| line.contains("Parked dead-code"))
1991 .unwrap_or_else(|| panic!("no parked row at width {width} in {rows:?}"));
1992 assert!(row.contains("parked"), "width {width}: {row}");
1993 assert!(!row.contains("waiting for input"), "width {width}: {row}");
1994 }
1995 }
1996
1997 #[test]
1998 fn agent_transcript_keyboard_mouse_and_return_selection_converge() {
1999 fn add_worker(app: &mut App) {
2000 app.current_session_id = Some(SESSION.to_string());
2001 app.subagent_cache.push(SubAgentResult {
2002 usage: None,
2003 name: "agent_converge".to_string(),
2004 agent_id: "agent_converge".to_string(),
2005 context_mode: "fresh".to_string(),
2006 fork_context: false,
2007 workspace: None,
2008 git_branch: Some("codex/details".to_string()),
2009 agent_type: FleetRole::Builder,
2010 assignment: SubAgentAssignment {
2011 native_preset: None,
2012 objective: "Verify keyboard and mouse convergence".to_string(),
2013 role: Some("worker".to_string()),
2014 },
2015 model: "test-model".to_string(),
2016 nickname: Some(crate::tools::subagent::whale_name_for_id_in_locale(
2017 "agent_converge",
2018 "en",
2019 )),
2020 status: SubAgentStatus::Running,
2021 worker_status: Some(AgentWorkerStatus::Running),
2022 runtime_permissions: None,
2023 parent_run_id: None,
2024 spawn_depth: 1,
2025 child_route: None,
2026 result: None,
2027 steps_taken: 1,
2028 checkpoint: None,
2029 needs_input: None,
2030 duration_ms: 100,
2031 started_at: None,
2032 from_prior_session: false,
2033 idle_ms: None,
2034 heartbeat_timeout_ms: None,
2035 });
2036 }
2037
2038 let mut keyboard = app();
2039 add_worker(&mut keyboard);
2040 let _ = render_text(&mut keyboard, 100, 6);
2041 let _ = super::handle_key(
2042 &mut keyboard,
2043 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
2044 );
2045 let keyboard_action = super::handle_key(
2046 &mut keyboard,
2047 KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
2048 )
2049 .expect("Work key handled")
2050 .expect("agent transcript action");
2051 let keyboard_selection = keyboard.work_surface.selected.clone();
2052
2053 let mut mouse = app();
2054 add_worker(&mut mouse);
2055 let _ = render_text(&mut mouse, 100, 6);
2056 let row_y = mouse
2057 .work_surface
2058 .hitboxes
2059 .iter()
2060 .find(|hit| hit.id.0 == "worker:agent_converge")
2061 .expect("agent hitbox")
2062 .row_y;
2063 let mouse_action = super::handle_mouse(
2064 &mut mouse,
2065 MouseEvent {
2066 kind: MouseEventKind::Down(MouseButton::Left),
2067 column: 2,
2068 row: row_y,
2069 modifiers: KeyModifiers::NONE,
2070 },
2071 )
2072 .action
2073 .expect("mouse agent transcript action");
2074 assert_eq!(mouse_action, keyboard_action);
2075 assert_eq!(mouse.work_surface.selected, keyboard_selection);
2076
2077 crate::tui::mouse_ui::apply_sidebar_row_action(&mut mouse, mouse_action);
2078 // One agent, one destination: activation focuses the worker in place
2079 // (its full transcript owns the conversation area) instead of opening
2080 // a modal, and leaving focus keeps the rail selection where it was.
2081 assert!(
2082 mouse
2083 .agent_focus
2084 .as_ref()
2085 .is_some_and(|focus| focus.is("agent_converge")),
2086 "activation must focus the worker"
2087 );
2088 let selected_before_close = mouse.work_surface.selected.clone();
2089 assert!(crate::tui::agent_focus::exit_focus(&mut mouse));
2090 assert_eq!(mouse.work_surface.selected, selected_before_close);
2091 assert!(mouse.work_surface.opened.is_none());
2092 }
2093
2094 #[test]
2095 fn active_session_without_work_keeps_surface_invisible() {
2096 let mut app = app();
2097 app.current_session_id = Some(SESSION.to_string());
2098
2099 let rows = super::model::project(&mut app);
2100
2101 assert!(rows.is_empty());
2102 assert_eq!(super::height(&mut app, 120, 32, AMPLE_BUDGET), 0);
2103 }
2104
2105 #[test]
2106 fn empty_work_stays_hidden_after_cached_session_state_is_cleared() {
2107 let mut app = app();
2108 app.current_session_id = Some(SESSION.to_string());
2109 app.work_surface.cached_graph = Some(operation_graph(NodeState::Active));
2110
2111 let rows = super::model::project(&mut app);
2112
2113 assert!(rows.is_empty());
2114 assert!(app.work_surface.cached_graph.is_none());
2115 }
2116
2117 #[test]
2118 fn empty_work_reserves_no_side_rail() {
2119 for placement in [
2120 super::WorkSurfacePlacement::Left,
2121 super::WorkSurfacePlacement::Right,
2122 ] {
2123 let mut app = app();
2124 app.current_session_id = Some(SESSION.to_string());
2125 app.work_surface.placement = placement;
2126 let area = ratatui::layout::Rect::new(0, 0, 120, 32);
2127
2128 assert_eq!(
2129 super::height(&mut app, area.width, area.height, AMPLE_BUDGET),
2130 0
2131 );
2132 assert_eq!(super::split_chat(&mut app, area, 0), (area, None));
2133 }
2134 }
2135
2136 fn terminal_text(terminal: &Terminal<TestBackend>) -> String {
2137 let buf = terminal.backend().buffer();
2138 (0..buf.area.height)
2139 .map(|y| {
2140 (0..buf.area.width)
2141 .map(|x| buf[(x, y)].symbol())
2142 .collect::<String>()
2143 })
2144 .collect::<Vec<_>>()
2145 .join("\n")
2146 }
2147
2148 /// Render-level smoke coverage for the ported rail panels — reinstates
2149 /// the sidebar render smoke tests removed with the classic shell
2150 /// (739616787). Top never spends a row on panel chrome (content is
2151 /// self-evident). Side rails are named by their content's own heading
2152 /// row (`▾ Subagents N`, `Goal: …`); Context is the one line-list panel
2153 /// and keeps its muted panel title.
2154 #[test]
2155 fn rail_panels_render_in_all_placements() {
2156 for panel in [
2157 super::RailPanel::Agents,
2158 super::RailPanel::Context,
2159 super::RailPanel::Tasks,
2160 ] {
2161 for placement in [
2162 super::WorkSurfacePlacement::Bottom,
2163 super::WorkSurfacePlacement::Top,
2164 super::WorkSurfacePlacement::Left,
2165 super::WorkSurfacePlacement::Right,
2166 ] {
2167 let mut app = app();
2168 app.work_surface.placement = placement;
2169 super::interaction::select_dock_panel(&mut app, panel);
2170 app.work_surface.focused = false;
2171 // Content so empty-collapse does not hide the panel. Agents
2172 // needs a cached worker; Tasks needs a goal; Context always
2173 // has a budget.
2174 app.goal.objective = Some("ship the release".to_string());
2175 if panel == super::RailPanel::Agents {
2176 app.subagent_cache.push(cached_worker(
2177 "agent-a",
2178 "explore",
2179 Some("scout"),
2180 None,
2181 SubAgentStatus::Running,
2182 ));
2183 }
2184 let area = ratatui::layout::Rect::new(0, 0, 100, 24);
2185
2186 // Render coverage, not yield coverage: a 24-row terminal with
2187 // work on screen has rows to spare, so the panel is expected
2188 // to draw. The idle-empty budget is exercised end-to-end in
2189 // `ui::tests::rail_strip_yields_the_ambient_floor_*`.
2190 let budget = working_budget(&app, area.height);
2191 let strip = super::height(&mut app, area.width, area.height, budget);
2192 let (_chat, rail) = super::split_chat(&mut app, area, 0);
2193 let backend = TestBackend::new(area.width, area.height);
2194 let mut terminal = Terminal::new(backend).expect("terminal");
2195 terminal
2196 .draw(|frame| {
2197 if strip > 0 {
2198 super::render(
2199 frame,
2200 ratatui::layout::Rect::new(0, 0, area.width, strip),
2201 &mut app,
2202 );
2203 } else if let Some(rail) = rail {
2204 super::render(frame, rail, &mut app);
2205 }
2206 })
2207 .expect("draw");
2208 let text = terminal_text(&terminal);
2209 match placement {
2210 super::WorkSurfacePlacement::Bottom => {
2211 assert!(
2212 strip > 0,
2213 "{panel:?} on Bottom should auto-fit a content strip; got height 0"
2214 );
2215 }
2216 super::WorkSurfacePlacement::Top => {
2217 assert!(
2218 strip > 0,
2219 "{panel:?} on Top should auto-fit a content strip; got height 0"
2220 );
2221 // Panel chrome ("Pinned"/"Agents") never on Top.
2222 // An active goal *is* a title — and this fixture sets one.
2223 // A chrome title would be a row saying only the
2224 // view's name; the tab row and `▾ Subagents N` both
2225 // legitimately contain the lowercase word.
2226 let strip_body = text.lines().skip(1).collect::<Vec<_>>().join("\n");
2227 assert!(
2228 !strip_body.lines().any(|line| line.trim() == panel.title()),
2229 "{panel:?} on Top must not spend a row on panel chrome; got: {text}"
2230 );
2231 // Goal title when a live goal is set.
2232 assert!(
2233 text.contains("Goal:") && text.contains("ship the release"),
2234 "Top with an active goal must title with Goal: …; got: {text}"
2235 );
2236 }
2237 super::WorkSurfacePlacement::Left | super::WorkSurfacePlacement::Right => {
2238 assert!(
2239 rail.is_some() || strip > 0,
2240 "{panel:?} in {placement:?} should reserve a rail"
2241 );
2242 // Work-row panels are named by their content heading;
2243 // only the Context fact list keeps a panel title.
2244 match panel {
2245 super::RailPanel::Agents => {
2246 assert!(
2247 text.contains("Subagents 1"),
2248 "{panel:?} in {placement:?} should render its \
2249 Subagents heading; got: {text}"
2250 );
2251 assert!(
2252 !app.work_surface.hitboxes.is_empty(),
2253 "{panel:?} in {placement:?} must record hitboxes — \
2254 every work row is a door"
2255 );
2256 }
2257 super::RailPanel::Tasks => {
2258 assert!(
2259 text.contains("Goal: ship the release"),
2260 "{panel:?} in {placement:?} should render the goal \
2261 heading; got: {text}"
2262 );
2263 }
2264 _ => {
2265 assert!(
2266 text.contains("compact now"),
2267 "{panel:?} in {placement:?} should render the budget \
2268 rows; got: {text}"
2269 );
2270 }
2271 }
2272 }
2273 super::WorkSurfacePlacement::Off => {}
2274 }
2275 }
2276 }
2277 }
2278
2279 #[test]
2280 fn off_placement_reserves_no_rail_in_any_panel() {
2281 for panel in [
2282 super::RailPanel::Tasks,
2283 super::RailPanel::Agents,
2284 super::RailPanel::Context,
2285 super::RailPanel::Tasks,
2286 ] {
2287 let mut app = app();
2288 add_todos(&mut app, 2);
2289 app.work_surface.placement = super::WorkSurfacePlacement::Off;
2290 app.work_surface.panel = panel;
2291 let area = ratatui::layout::Rect::new(0, 0, 120, 32);
2292
2293 assert_eq!(
2294 super::height(&mut app, area.width, area.height, AMPLE_BUDGET),
2295 0
2296 );
2297 assert_eq!(super::split_chat(&mut app, area, 0), (area, None));
2298 assert_eq!(app.work_surface.last_area, None);
2299 }
2300 }
2301
2302 #[test]
2303 fn context_view_renders_the_budget_in_a_side_rail() {
2304 let mut app = app();
2305 app.work_surface.placement = super::WorkSurfacePlacement::Right;
2306 super::interaction::select_dock_panel(&mut app, super::RailPanel::Context);
2307 let area = ratatui::layout::Rect::new(0, 0, 100, 24);
2308
2309 let budget = working_budget(&app, area.height);
2310 let strip = super::height(&mut app, area.width, area.height, budget);
2311 assert_eq!(strip, 0, "side placements take no top strip");
2312 let (_chat, rail) = super::split_chat(&mut app, area, 0);
2313 let rail = rail.expect("context panel reserves a side rail");
2314
2315 let backend = TestBackend::new(area.width, area.height);
2316 let mut terminal = Terminal::new(backend).expect("terminal");
2317 terminal
2318 .draw(|frame| super::render(frame, rail, &mut app))
2319 .expect("draw");
2320 let text = terminal_text(&terminal);
2321 assert!(text.contains(" of "), "budget row; got: {text}");
2322 assert!(text.contains("compact now"), "compact door; got: {text}");
2323 assert!(
2324 app.work_surface
2325 .hitboxes
2326 .iter()
2327 .any(|hit| hit.id.0 == "context:compact"),
2328 "the compact row is a hit target"
2329 );
2330 }
2331
2332 #[test]
2333 fn missing_runtime_renders_disconnected_state() {
2334 let mut app = app();
2335 app.current_session_id = Some(SESSION.to_string());
2336 app.runtime_services.work = None;
2337
2338 let rows = super::model::project(&mut app);
2339
2340 assert_eq!(rows[0].label, "Work · disconnected");
2341 }
2342
2343 #[test]
2344 fn busy_graph_authority_renders_truthful_error_without_leaking_it_into_header() {
2345 let mut app = app();
2346 app.current_session_id = Some(SESSION.to_string());
2347 let todos = app.todos.clone();
2348 let _guard = todos.try_lock().expect("hold To-do authority lock");
2349
2350 let rows = super::model::project(&mut app);
2351
2352 assert_eq!(rows.len(), 1);
2353 assert_eq!(rows[0].label, "Work · error");
2354 assert!(rows[0].detail.contains("To-do state is busy"));
2355 assert!(!rows[0].label.contains("busy"));
2356 }
2357
2358 #[test]
2359 fn graph_error_without_an_active_session_stays_suppressed() {
2360 let mut app = app();
2361 let todos = app.todos.clone();
2362 let _guard = todos.try_lock().expect("hold To-do authority lock");
2363
2364 let rows = super::model::project(&mut app);
2365
2366 assert!(rows.is_empty());
2367 }
2368
2369 #[test]
2370 fn waiting_operation_is_not_counted_as_running() {
2371 let mut app = app();
2372 let graph = operation_graph(NodeState::Waiting);
2373 restore_graph(&mut app, &graph);
2374 app.runtime_services
2375 .work
2376 .as_ref()
2377 .expect("Work Graph runtime")
2378 .reconcile_operation(
2379 SESSION,
2380 OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Waiting, 1, 6),
2381 )
2382 .expect("waiting shell owner");
2383
2384 let rows = super::model::project(&mut app);
2385
2386 assert!(
2387 rows[0].label.starts_with("Work · Needs input:")
2388 || rows[0]
2389 .label
2390 .starts_with("Work · 0 active · 1 needs input · 0 ready · 0 recent"),
2391 "{}",
2392 rows[0].label
2393 );
2394 assert!(
2395 rows[0].label.contains("blocked") || rows[0].label.contains("needs input"),
2396 "{}",
2397 rows[0].label
2398 );
2399 }
2400
2401 #[test]
2402 fn stale_operation_is_blocked_attention_with_bounded_output_section() {
2403 let mut app = app();
2404 let graph = operation_graph(NodeState::Stale);
2405 restore_graph(&mut app, &graph);
2406
2407 let rows = super::model::project(&mut app);
2408 assert!(
2409 rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"),
2410 "{}",
2411 rows[0].label
2412 );
2413 let row = rows.iter().find(|row| row.selectable).expect("stale row");
2414 assert_eq!(row.mark, "?");
2415 assert!(row.detail.starts_with("stale · operation"));
2416 let Some(SidebarRowAction::InspectWork {
2417 body, stop_action, ..
2418 }) = row.primary_action.as_ref()
2419 else {
2420 panic!("stale row must open inspector");
2421 };
2422 assert!(
2423 stop_action.is_none(),
2424 "a stale owner cannot truthfully expose a stop action"
2425 );
2426 assert!(
2427 body.contains("Last bounded output\nNo output receipt"),
2428 "{body}"
2429 );
2430 assert!(body.contains("Owner cannot confirm liveness"), "{body}");
2431 }
2432
2433 /// A durable failed operation, as a fleet agent task from a crashed or
2434 /// sibling instance leaves behind in the persisted graph (#4416).
2435 fn durable_failed_operation_graph() -> crate::work_graph::WorkGraphSnapshot {
2436 let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Failed));
2437 let operation = WorkNodeId::derive(SESSION, "operation");
2438 graph
2439 .apply(
2440 WorkGraphChange::BindOperation {
2441 node: operation,
2442 binding: OperationBinding {
2443 external: "fleet:run_1/task_1".to_string(),
2444 durable: true,
2445 last_observation: None,
2446 },
2447 },
2448 ChangeCtx {
2449 session_id: SESSION.to_string(),
2450 now: 6,
2451 idempotency_key: None,
2452 },
2453 )
2454 .expect("durable binding");
2455 graph.into_snapshot()
2456 }
2457
2458 // Regression for #4416: a persisted failed-agent record stamped by
2459 // another session instance (boot id) must not appear in the default
2460 // work listing of a fresh session in the same workspace.
2461 #[test]
2462 fn prior_instance_failed_rows_stay_out_of_the_default_listing() {
2463 let dir = tempfile::tempdir().expect("tempdir");
2464 let manager =
2465 crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager");
2466 manager
2467 .record_session_boot_owner(SESSION, "boot_other_instance")
2468 .expect("stamp other instance");
2469
2470 let mut app = app();
2471 app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf());
2472 let graph = durable_failed_operation_graph();
2473 restore_saved_graph(&mut app, &graph);
2474
2475 let rows = super::model::project(&mut app);
2476 assert!(
2477 rows.iter()
2478 .all(|row| !row.label.contains("Verify installed build")),
2479 "prior-instance failed row leaked into the default listing: {rows:#?}"
2480 );
2481 assert!(
2482 rows.iter()
2483 .all(|row| !row.label.contains("needs input") && !row.label.contains("1 active")),
2484 "prior-instance residue must not count as live work: {rows:#?}"
2485 );
2486 // The record stays reachable through the explicit catalog, clearly
2487 // marked historical.
2488 let historical = app
2489 .work_surface
2490 .catalog_rows
2491 .iter()
2492 .find(|row| row.label.contains("Verify installed build"))
2493 .expect("historical row remains in the catalog");
2494 assert!(
2495 historical.detail.starts_with("prior session · "),
2496 "historical row must be labeled: {}",
2497 historical.detail
2498 );
2499 }
2500
2501 // Ownership control for #4416: the same failed record owned by this
2502 // session instance still renders as actionable work.
2503 #[test]
2504 fn current_instance_failed_rows_still_render_in_the_default_listing() {
2505 let dir = tempfile::tempdir().expect("tempdir");
2506 let manager =
2507 crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager");
2508 manager
2509 .record_session_boot_owner(SESSION, crate::session_manager::current_session_boot_id())
2510 .expect("stamp current instance");
2511
2512 let mut app = app();
2513 app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf());
2514 let graph = durable_failed_operation_graph();
2515 restore_graph(&mut app, &graph);
2516
2517 let rows = super::model::project(&mut app);
2518 assert!(
2519 rows.iter()
2520 .any(|row| row.label.contains("Verify installed build")),
2521 "this instance's own failed work must stay visible: {rows:#?}"
2522 );
2523 }
2524
2525 // Regression for review of #5063: if a prior session persisted no graph,
2526 // the first graph captured later belongs to this process and must not be
2527 // mistaken for restored residue.
2528 #[test]
2529 fn first_live_graph_after_empty_prior_restore_stays_visible() {
2530 let dir = tempfile::tempdir().expect("tempdir");
2531 let manager =
2532 crate::session_manager::SessionManager::new(dir.path().to_path_buf()).expect("manager");
2533 manager
2534 .record_session_boot_owner(SESSION, "boot_other_instance")
2535 .expect("stamp other instance");
2536
2537 let mut app = app();
2538 app.work_surface.session_owner_probe_dir = Some(dir.path().to_path_buf());
2539 app.current_session_id = Some(SESSION.to_string());
2540 app.restore_work_state(SESSION, std::path::Path::new("."), None)
2541 .expect("restore empty prior session");
2542
2543 let graph = durable_failed_operation_graph();
2544 restore_graph(&mut app, &graph);
2545 let rows = super::model::project(&mut app);
2546 assert!(
2547 rows.iter()
2548 .any(|row| row.label.contains("Verify installed build")),
2549 "this instance's first live graph must stay visible: {rows:#?}"
2550 );
2551 }
2552
2553 #[test]
2554 fn completed_operation_with_acceptance_is_not_rendered_done() {
2555 let mut graph = WorkGraph::from_snapshot(operation_graph(NodeState::Ready));
2556 let operation = WorkNodeId::derive(SESSION, "operation");
2557 graph
2558 .apply(
2559 WorkGraphChange::UpdateNode {
2560 id: operation,
2561 patch: crate::work_graph::WorkNodePatch {
2562 state: Some(NodeState::Completed),
2563 acceptance: Some(vec![AcceptanceRequirement::EvidenceOfKind {
2564 kind: EvidenceKindTag::ToolRun,
2565 }]),
2566 ..crate::work_graph::WorkNodePatch::default()
2567 },
2568 },
2569 ChangeCtx {
2570 session_id: SESSION.to_string(),
2571 now: 6,
2572 idempotency_key: None,
2573 },
2574 )
2575 .expect("completed pending evidence");
2576 let graph = graph.into_snapshot();
2577 let mut app = app();
2578 restore_graph(&mut app, &graph);
2579
2580 let rows = super::model::project(&mut app);
2581 assert!(
2582 rows[0].label.contains("Needs input") || rows[0].label.contains("1 needs input"),
2583 "{}",
2584 rows[0].label
2585 );
2586 let row = rows
2587 .iter()
2588 .find(|row| row.selectable)
2589 .expect("operation row");
2590 assert_eq!(row.mark, crate::tui::glyphs::ATTENTION);
2591 assert!(row.detail.contains("completed · evidence pending"));
2592 assert_ne!(row.mark, "✓");
2593 let Some(SidebarRowAction::InspectWork { body, .. }) = row.primary_action.as_ref() else {
2594 panic!("completed operation must remain inspectable");
2595 };
2596 assert!(body.contains("evidence of kind tool run"), "{body}");
2597 assert!(
2598 body.contains("acceptance evidence is still missing"),
2599 "{body}"
2600 );
2601 }
2602
2603 #[test]
2604 fn work_rows_open_graph_inspector_without_inline_controls() {
2605 let mut app = app();
2606 app.work_surface.placement = WorkSurfacePlacement::Right;
2607 app.work_surface.effective_placement = WorkSurfacePlacement::Right;
2608 let graph = operation_graph(NodeState::Active);
2609 restore_graph(&mut app, &graph);
2610 app.runtime_services
2611 .work
2612 .as_ref()
2613 .expect("Work Graph runtime")
2614 .reconcile_operation(
2615 SESSION,
2616 OperationOwnerSnapshot::new("shell:shell_1234abcd", OwnerState::Running, 1, 6),
2617 )
2618 .expect("live shell owner");
2619
2620 let text = render_text(&mut app, 100, 6);
2621 assert!(!text.contains("[open]"), "{text}");
2622 assert!(!text.contains("[stop]"), "{text}");
2623 let row_y = app
2624 .work_surface
2625 .hitboxes
2626 .iter()
2627 .find(|hit| hit.id.0.starts_with("graph:"))
2628 .expect("graph hitbox")
2629 .row_y;
2630 let outcome = super::handle_mouse(
2631 &mut app,
2632 MouseEvent {
2633 kind: MouseEventKind::Down(MouseButton::Left),
2634 column: 2,
2635 row: row_y,
2636 modifiers: KeyModifiers::NONE,
2637 },
2638 );
2639 let action = outcome.action.expect("inspector action");
2640 let SidebarRowAction::InspectWork {
2641 body, stop_action, ..
2642 } = &action
2643 else {
2644 panic!("expected Work inspector");
2645 };
2646 for section in [
2647 "Objective",
2648 "Prerequisites",
2649 "Downstream impact",
2650 "Binding + lifecycle owner",
2651 "Evidence vs acceptance",
2652 "Blockers / approvals",
2653 "Why next",
2654 "Provenance + last reconcile",
2655 ] {
2656 assert!(body.contains(section), "missing {section}: {body}");
2657 }
2658 assert!(matches!(
2659 stop_action.as_deref(),
2660 Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_1234abcd"
2661 ));
2662 crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
2663 assert_eq!(
2664 app.view_stack.top_kind(),
2665 Some(crate::tui::views::ModalKind::Pager)
2666 );
2667 }
2668
2669 #[test]
2670 fn narrow_render_hover_keeps_full_untruncated_row() {
2671 let mut app = app();
2672 app.todos.try_lock().expect("todos").add(
2673 "A deliberately long graph-owned work row".to_string(),
2674 TodoStatus::InProgress,
2675 );
2676
2677 let _ = render_text(&mut app, 24, 4);
2678 let hover = app
2679 .sidebar_hover
2680 .sections
2681 .last()
2682 .and_then(|section| section.rows.first())
2683 .expect("hover row");
2684 assert!(hover.is_truncated);
2685 assert!(hover.full_text.contains("deliberately long graph-owned"));
2686 assert!(hover.stop_action.is_none());
2687 }
2688
2689 #[test]
2690 fn narrow_file_activity_prioritizes_the_canonical_aggregate_label() {
2691 let mut app = app();
2692 app.workspace = PathBuf::from("/workspace/project");
2693 let result = crate::tools::spec::ToolResult::success("ok").with_metadata(
2694 serde_json::json!({
2695 "mutation": {
2696 "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",
2697 "files": [
2698 { "path": "update.rs", "outcome": "updated" },
2699 { "path": "create.rs", "outcome": "created" },
2700 { "path": "delete.rs", "outcome": "deleted" }
2701 ],
2702 "renames": [{ "from": "old.rs", "to": "new.rs" }]
2703 }
2704 }),
2705 );
2706 let receipt = FileMutationReceipt::from_success(&app.workspace, &result).expect("receipt");
2707 app.add_message(HistoryCell::Tool(ToolCell::PatchSummary(
2708 PatchSummaryCell {
2709 path: "4 files".to_string(),
2710 summary: "ok".to_string(),
2711 status: ToolStatus::Success,
2712 error: None,
2713 receipt: Some(receipt),
2714 },
2715 )));
2716 app.tool_details_by_cell.insert(
2717 0,
2718 ToolDetailRecord {
2719 tool_id: "file-multi".to_string(),
2720 tool_name: "File".to_string(),
2721 input: serde_json::json!({"action": "patch"}),
2722 output: Some("ok".to_string()),
2723 },
2724 );
2725
2726 app.work_surface.placement = WorkSurfacePlacement::Right;
2727 app.work_surface.effective_placement = WorkSurfacePlacement::Right;
2728 let text = render_text(&mut app, 80, 6);
2729 assert!(text.contains("Wrote 4 files"), "{text}");
2730 }
2731
2732 #[test]
2733 fn overflow_scroll_and_selection_remain_panel_owned() {
2734 let mut app = app();
2735 add_todos(&mut app, 8);
2736 let _ = render_text(&mut app, 80, 5);
2737 assert!(app.work_surface.total_rows > app.work_surface.visible_rows);
2738
2739 let transcript_delta = app.viewport.pending_scroll_delta;
2740 let outcome = super::handle_mouse(
2741 &mut app,
2742 MouseEvent {
2743 kind: MouseEventKind::ScrollDown,
2744 column: 10,
2745 row: 2,
2746 modifiers: KeyModifiers::NONE,
2747 },
2748 );
2749 assert!(outcome.consumed);
2750 assert_eq!(app.viewport.pending_scroll_delta, transcript_delta);
2751 assert!(app.work_surface.scroll_offset > 0);
2752 }
2753
2754 #[test]
2755 fn mouse_wheel_reaches_last_todo_across_top_surface_heights() {
2756 for height in [3, 5, 6, 8] {
2757 let mut app = app();
2758 add_todos(&mut app, 10);
2759 let _ = render_text(&mut app, 80, height);
2760 assert!(app.work_surface.total_rows > app.work_surface.visible_rows);
2761 let transcript_delta = app.viewport.pending_scroll_delta;
2762
2763 let mut text = String::new();
2764 for _ in 0..16 {
2765 let outcome = super::handle_mouse(
2766 &mut app,
2767 MouseEvent {
2768 kind: MouseEventKind::ScrollDown,
2769 column: 10,
2770 row: 1,
2771 modifiers: KeyModifiers::NONE,
2772 },
2773 );
2774 assert!(outcome.consumed, "height {height}");
2775 text = render_text(&mut app, 80, height);
2776 }
2777
2778 assert!(
2779 text.contains("work item 9"),
2780 "last To-do was unreachable at surface height {height}: {text:?}"
2781 );
2782 assert_eq!(
2783 app.work_surface.scroll_offset,
2784 app.work_surface
2785 .total_rows
2786 .saturating_sub(app.work_surface.visible_rows.max(1)),
2787 "wheel did not reach the legal tail at surface height {height}"
2788 );
2789 assert_eq!(app.viewport.pending_scroll_delta, transcript_delta);
2790 }
2791 }
2792
2793 #[test]
2794 fn mouse_wheel_reaches_last_todo_in_side_rail_placements() {
2795 for placement in [
2796 super::WorkSurfacePlacement::Left,
2797 super::WorkSurfacePlacement::Right,
2798 ] {
2799 let mut app = app();
2800 add_todos(&mut app, 10);
2801 app.work_surface.placement = placement;
2802 app.work_surface.effective_placement = placement;
2803 let _ = render_text(&mut app, 30, 6);
2804
2805 let mut text = String::new();
2806 for _ in 0..16 {
2807 let outcome = super::handle_mouse(
2808 &mut app,
2809 MouseEvent {
2810 kind: MouseEventKind::ScrollDown,
2811 column: 10,
2812 row: 1,
2813 modifiers: KeyModifiers::NONE,
2814 },
2815 );
2816 assert!(outcome.consumed, "placement {placement:?}");
2817 text = render_text(&mut app, 30, 6);
2818 }
2819
2820 assert!(
2821 text.contains("work item 9"),
2822 "last To-do was unreachable in {placement:?}: {text:?}"
2823 );
2824 }
2825 }
2826
2827 #[test]
2828 fn keyboard_end_reveals_last_todo_after_redraw() {
2829 let mut app = app();
2830 add_todos(&mut app, 10);
2831 let _ = render_text(&mut app, 80, 5);
2832 let _ = super::handle_key(
2833 &mut app,
2834 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
2835 );
2836 let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
2837
2838 let text = render_text(&mut app, 80, 5);
2839
2840 assert!(text.contains("work item 9"), "{text:?}");
2841 assert_eq!(
2842 app.work_surface.scroll_offset,
2843 app.work_surface
2844 .total_rows
2845 .saturating_sub(app.work_surface.visible_rows.max(1))
2846 );
2847 }
2848
2849 #[test]
2850 fn keyboard_navigation_is_panel_local_when_focused() {
2851 let mut app = app();
2852 add_todos(&mut app, 3);
2853 let _ = render_text(&mut app, 80, super::model::TOP_HEIGHT_MIN);
2854 assert!(
2855 super::handle_key(
2856 &mut app,
2857 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT)
2858 )
2859 .is_some()
2860 );
2861 let first = app.work_surface.selected.clone();
2862 let _ = super::handle_key(&mut app, KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
2863 assert_ne!(app.work_surface.selected, first);
2864 assert!(app.work_surface.focused);
2865 }
2866
2867 #[test]
2868 fn clicking_agents_tab_switches_active_panel() {
2869 let mut app = app();
2870 add_todos(&mut app, 1);
2871 app.subagent_cache.push(cached_worker(
2872 "agent-tab",
2873 "explore",
2874 Some("scout"),
2875 None,
2876 SubAgentStatus::Running,
2877 ));
2878 let _ = render_text(&mut app, 80, 8);
2879 // The to-do list opened first; the running worker is one tab over.
2880 assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
2881 let tab_area = app
2882 .work_surface
2883 .dock_tabs
2884 .iter()
2885 .find(|hitbox| {
2886 hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Agents)
2887 })
2888 .map(|hitbox| hitbox.area)
2889 .expect("Agents tab");
2890
2891 let down = super::handle_mouse(
2892 &mut app,
2893 MouseEvent {
2894 kind: MouseEventKind::Down(MouseButton::Left),
2895 column: tab_area.x,
2896 row: tab_area.y,
2897 modifiers: KeyModifiers::NONE,
2898 },
2899 );
2900 assert!(down.consumed);
2901 let up = super::handle_mouse(
2902 &mut app,
2903 MouseEvent {
2904 kind: MouseEventKind::Up(MouseButton::Left),
2905 column: tab_area.x,
2906 row: tab_area.y,
2907 modifiers: KeyModifiers::NONE,
2908 },
2909 );
2910 assert!(up.consumed);
2911 assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
2912 assert!(app.work_surface.explicit_view);
2913 assert!(!app.work_surface.dismissed);
2914 }
2915
2916 #[test]
2917 fn clicking_active_tab_dismisses_the_dock_until_new_work_arrives() {
2918 let mut app = app();
2919 app.work_surface.top_height = 8;
2920 add_todos(&mut app, 2);
2921 let _ = render_text(&mut app, 80, 8);
2922 let tab_area = app
2923 .work_surface
2924 .dock_tabs
2925 .iter()
2926 .find(|hitbox| {
2927 hitbox.target == super::model::DockTabTarget::Panel(super::RailPanel::Tasks)
2928 })
2929 .map(|hitbox| hitbox.area)
2930 .expect("Tasks tab");
2931
2932 super::handle_mouse(
2933 &mut app,
2934 MouseEvent {
2935 kind: MouseEventKind::Down(MouseButton::Left),
2936 column: tab_area.x,
2937 row: tab_area.y,
2938 modifiers: KeyModifiers::NONE,
2939 },
2940 );
2941 super::handle_mouse(
2942 &mut app,
2943 MouseEvent {
2944 kind: MouseEventKind::Up(MouseButton::Left),
2945 column: tab_area.x,
2946 row: tab_area.y,
2947 modifiers: KeyModifiers::NONE,
2948 },
2949 );
2950 assert!(app.work_surface.dismissed);
2951 assert_eq!(
2952 super::height(&mut app, 80, 24, AMPLE_BUDGET),
2953 0,
2954 "dismissed dock remains collapsed"
2955 );
2956
2957 app.subagent_cache.push(cached_worker(
2958 "new-work",
2959 "explore",
2960 Some("new work"),
2961 None,
2962 SubAgentStatus::Running,
2963 ));
2964 assert!(
2965 super::height(&mut app, 80, 24, AMPLE_BUDGET) > 0,
2966 "new work re-shows dismissed dock"
2967 );
2968 assert!(!app.work_surface.dismissed);
2969 }
2970
2971 #[test]
2972 fn dock_tabs_match_80x24_golden() {
2973 let mut app = app();
2974 add_todos(&mut app, 3);
2975 app.subagent_cache.push(cached_worker(
2976 "dock-golden-agent",
2977 "explore",
2978 Some("scout"),
2979 None,
2980 SubAgentStatus::Running,
2981 ));
2982
2983 assert_matches_golden("dock_80x24", &render_golden_text(&mut app, 80, 24));
2984 }
2985
2986 #[test]
2987 fn dock_selection_is_readable_and_close_target_stays_inside_small_hosts() {
2988 use super::model::DockTabTarget;
2989 use ratatui::style::Modifier;
2990 for theme_id in codewhale_palette::SELECTABLE_THEMES {
2991 let mut app = app();
2992 app.ui_theme = theme_id.ui_theme();
2993 app.work_surface.explicit_view = true;
2994 add_todos(&mut app, 1);
2995 let mut terminal = Terminal::new(TestBackend::new(80, 8)).unwrap();
2996 terminal
2997 .draw(|frame| super::render(frame, frame.area(), &mut app))
2998 .unwrap();
2999 let tab = app
3000 .work_surface
3001 .dock_tabs
3002 .iter()
3003 .find(|tab| tab.target == DockTabTarget::Panel(super::RailPanel::Tasks))
3004 .unwrap();
3005 let cell = &terminal.backend().buffer()[(tab.area.x + 1, tab.area.y)];
3006 assert_eq!(cell.bg, app.ui_theme.selection_bg, "{theme_id:?}");
3007 assert!(!cell.modifier.contains(Modifier::REVERSED), "{theme_id:?}");
3008 if let Some(ratio) = codewhale_palette::contrast_ratio(cell.fg, cell.bg) {
3009 assert!(ratio >= 4.5, "{theme_id:?}: {cell:?} ({ratio})");
3010 } else {
3011 // Native terminal colors are user supplied and cannot be measured here.
3012 assert_eq!(*theme_id, codewhale_palette::ThemeId::Terminal);
3013 }
3014 }
3015 for width in [1, 2, 3, 8, 16, 40, 60, 80] {
3016 for placement in [WorkSurfacePlacement::Top, WorkSurfacePlacement::Bottom] {
3017 let mut app = app();
3018 app.work_surface.explicit_view = true;
3019 app.work_surface.effective_placement = placement;
3020 let mut terminal = Terminal::new(TestBackend::new(width, 8)).unwrap();
3021 terminal
3022 .draw(|frame| super::render(frame, frame.area(), &mut app))
3023 .unwrap();
3024 let close = app
3025 .work_surface
3026 .dock_tabs
3027 .iter()
3028 .find(|tab| tab.target == DockTabTarget::Close)
3029 .unwrap();
3030 assert!(close.area.right() <= width);
3031 assert!(close.area.width > 0);
3032 }
3033 }
3034 }
3035
3036 /// #6502: the close control names Esc only while Esc closes the dock.
3037 /// Unfocused, Esc stops the running turn, so the `×` stands alone and
3038 /// the Esc hint stays with the turn status.
3039 #[test]
3040 fn close_control_names_esc_only_while_esc_closes_the_dock() {
3041 let tab_row = |app: &mut App| {
3042 render_rows(app, 80, 8)
3043 .into_iter()
3044 .find(|row| row.contains("Tasks"))
3045 .expect("dock tab row")
3046 };
3047 let mut app = app();
3048 add_todos(&mut app, 3);
3049 app.is_loading = true;
3050
3051 let row = tab_row(&mut app);
3052 assert!(
3053 !row.contains("Esc"),
3054 "unfocused dock must not claim Esc: {row:?}"
3055 );
3056
3057 app.work_surface.focused = true;
3058 let row = tab_row(&mut app);
3059 assert!(
3060 row.contains("Esc"),
3061 "focused dock names its close key: {row:?}"
3062 );
3063 assert!(
3064 super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_some(),
3065 "the advertised Esc is the one that closes the dock"
3066 );
3067 assert!(app.work_surface.dismissed);
3068 assert!(!app.work_surface.focused);
3069 }
3070
3071 /// #6502 review: a row action that opens no view (the Context panel's
3072 /// `/compact` row) leaves a stale `opened` owner. The hint must follow
3073 /// what Esc will really do: close the dock when nothing is stacked above
3074 /// it, and stay quiet while a detail pager owns Esc.
3075 #[test]
3076 fn esc_hint_follows_the_pending_detail_state() {
3077 let tab_row = |app: &mut App| {
3078 render_rows(app, 80, 8)
3079 .into_iter()
3080 .find(|row| row.contains("Tasks"))
3081 .expect("dock tab row")
3082 };
3083 let mut app = app();
3084 add_todos(&mut app, 3);
3085 app.is_loading = true;
3086 app.work_surface.focused = true;
3087 let row = super::model::project(&mut app)
3088 .into_iter()
3089 .find(|row| row.selectable)
3090 .expect("work row");
3091
3092 // A detail pager is on screen: Esc closes it, not the dock.
3093 app.work_surface.opened = Some(row.id.clone());
3094 app.view_stack.push(crate::tui::pager::PagerView::from_text(
3095 "Work · test".to_string(),
3096 "body",
3097 40,
3098 ));
3099 let rendered = tab_row(&mut app);
3100 assert!(
3101 !rendered.contains("Esc"),
3102 "Esc belongs to the open detail: {rendered:?}"
3103 );
3104 app.view_stack.pop();
3105
3106 // The row's command opened nothing: `opened` is stale, and the
3107 // advertised Esc closes the dock in one press.
3108 let rendered = tab_row(&mut app);
3109 assert!(
3110 rendered.contains("Esc"),
3111 "stale owner must not hide the dock's close key: {rendered:?}"
3112 );
3113 assert!(
3114 super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)).is_some()
3115 );
3116 assert!(app.work_surface.dismissed, "one Esc closes the dock");
3117 assert!(app.work_surface.opened.is_none());
3118 }
3119
3120 #[test]
3121 fn narrow_dock_drops_counts_before_optional_tabs() {
3122 let mut app = app();
3123 add_todos(&mut app, 3);
3124 app.subagent_cache.push(cached_worker(
3125 "agent-count",
3126 "explore",
3127 Some("scout"),
3128 None,
3129 SubAgentStatus::Running,
3130 ));
3131 let first_row = render_rows(&mut app, 40, 8)
3132 .into_iter()
3133 .next()
3134 .expect("dock tab row");
3135
3136 assert!(first_row.contains("Tasks"), "{first_row:?}");
3137 assert!(first_row.contains("Fleet"), "{first_row:?}");
3138 assert!(!first_row.contains("Tasks 3"), "{first_row:?}");
3139 assert!(!first_row.contains("Fleet 1"), "{first_row:?}");
3140 assert!(first_row.contains("Context"), "{first_row:?}");
3141 // Shed from the right: price goes before any work view.
3142 assert!(!first_row.contains("Cost"), "{first_row:?}");
3143 }
3144
3145 #[test]
3146 fn empty_panel_releases_plain_y_before_composer_dispatch() {
3147 let mut app = app();
3148 app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 80, 8));
3149 app.work_surface.focused = true;
3150 app.work_surface.explicit_view = false;
3151 let outcome = super::handle_key(
3152 &mut app,
3153 KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE),
3154 );
3155 assert!(outcome.is_none());
3156 assert!(!app.work_surface.focused);
3157 }
3158
3159 #[test]
3160 fn printable_keys_release_panel_focus_for_composer() {
3161 let mut app = app();
3162 add_todos(&mut app, 1);
3163 let _ = render_text(&mut app, 80, super::model::TOP_HEIGHT_MIN);
3164 assert!(
3165 super::handle_key(
3166 &mut app,
3167 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
3168 )
3169 .is_some()
3170 );
3171
3172 let outcome = super::handle_key(
3173 &mut app,
3174 KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE),
3175 );
3176
3177 assert!(outcome.is_none());
3178 assert!(!app.work_surface.focused);
3179 }
3180
3181 #[test]
3182 fn side_placements_reuse_the_same_graph_rows() {
3183 for (placement, expected_chat_x, expected_rail_x) in [
3184 (super::WorkSurfacePlacement::Left, 30, 0),
3185 (super::WorkSurfacePlacement::Right, 0, 70),
3186 ] {
3187 let mut app = app();
3188 add_todos(&mut app, 2);
3189 app.work_surface.placement = placement;
3190 assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0);
3191 let area = ratatui::layout::Rect::new(0, 0, 100, 12);
3192 let (chat, rail) = super::split_chat(&mut app, area, 0);
3193 let rail = rail.expect("side rail");
3194 assert_eq!(chat.x, expected_chat_x);
3195 assert_eq!(rail.x, expected_rail_x);
3196 assert_eq!(rail.width, 30);
3197 assert!(
3198 app.work_surface
3199 .latest_rows
3200 .iter()
3201 .any(|row| row.label == "work item 1")
3202 );
3203 }
3204 }
3205
3206 #[test]
3207 fn divider_drag_resizes_top_left_and_right_surfaces() {
3208 let mut top = app();
3209 add_todos(&mut top, 3);
3210 let _ = render_text(&mut top, 80, 3);
3211 let down = super::handle_mouse(
3212 &mut top,
3213 MouseEvent {
3214 kind: MouseEventKind::Down(MouseButton::Left),
3215 column: 20,
3216 row: 2,
3217 modifiers: KeyModifiers::NONE,
3218 },
3219 );
3220 assert!(down.consumed);
3221 let _ = super::handle_mouse(
3222 &mut top,
3223 MouseEvent {
3224 kind: MouseEventKind::Drag(MouseButton::Left),
3225 column: 20,
3226 row: 7,
3227 modifiers: KeyModifiers::NONE,
3228 },
3229 );
3230 assert_eq!(top.work_surface.top_height, 8);
3231
3232 for (placement, drag_column, expected_width) in [
3233 (WorkSurfacePlacement::Left, 39, 40),
3234 (WorkSurfacePlacement::Right, 10, 26),
3235 ] {
3236 let mut side = app();
3237 add_todos(&mut side, 2);
3238 side.work_surface.placement = placement;
3239 side.work_surface.effective_placement = placement;
3240 let _ = render_text(&mut side, 30, 8);
3241 let divider_column = if placement == WorkSurfacePlacement::Left {
3242 29
3243 } else {
3244 0
3245 };
3246 let _ = super::handle_mouse(
3247 &mut side,
3248 MouseEvent {
3249 kind: MouseEventKind::Down(MouseButton::Left),
3250 column: divider_column,
3251 row: 2,
3252 modifiers: KeyModifiers::NONE,
3253 },
3254 );
3255 let _ = super::handle_mouse(
3256 &mut side,
3257 MouseEvent {
3258 kind: MouseEventKind::Drag(MouseButton::Left),
3259 column: drag_column,
3260 row: 2,
3261 modifiers: KeyModifiers::NONE,
3262 },
3263 );
3264 assert_eq!(
3265 side.work_surface.side_width, expected_width,
3266 "{placement:?}"
3267 );
3268 }
3269 }
3270
3271 #[test]
3272 fn divider_hover_and_drag_render_a_discoverable_handle() {
3273 let mut app = app();
3274 add_todos(&mut app, 3);
3275 let resting = render_text(&mut app, 80, 3);
3276 assert!(resting.contains('─'), "{resting}");
3277
3278 let hover = super::handle_mouse(
3279 &mut app,
3280 MouseEvent {
3281 kind: MouseEventKind::Moved,
3282 column: 20,
3283 row: 2,
3284 modifiers: KeyModifiers::NONE,
3285 },
3286 );
3287 assert!(hover.consumed);
3288 assert!(app.work_surface.divider_hovered);
3289 let hovered = render_text(&mut app, 80, 3);
3290 assert!(hovered.contains('━'), "{hovered}");
3291
3292 let _ = super::handle_mouse(
3293 &mut app,
3294 MouseEvent {
3295 kind: MouseEventKind::Down(MouseButton::Left),
3296 column: 20,
3297 row: 2,
3298 modifiers: KeyModifiers::NONE,
3299 },
3300 );
3301 let dragging = render_text(&mut app, 80, 3);
3302 assert!(dragging.contains('━'), "{dragging}");
3303 }
3304
3305 #[test]
3306 fn top_bar_excludes_generic_operations() {
3307 let mut operation_app = app();
3308 let graph = operation_graph(NodeState::Failed);
3309 restore_graph(&mut operation_app, &graph);
3310
3311 assert_eq!(super::height(&mut operation_app, 100, 24, AMPLE_BUDGET), 0);
3312 assert!(operation_app.work_surface.latest_rows.is_empty());
3313
3314 let mut todo_app = app();
3315 add_todos(&mut todo_app, 2);
3316 assert!(super::height(&mut todo_app, 100, 24, AMPLE_BUDGET) > 0);
3317 assert!(
3318 todo_app
3319 .work_surface
3320 .latest_rows
3321 .iter()
3322 .all(|row| row.id.0.starts_with("graph:") || row.id.0.starts_with("worker:"))
3323 );
3324 assert!(
3325 todo_app
3326 .work_surface
3327 .latest_rows
3328 .iter()
3329 .all(|row| !row.label.starts_with("Work ·"))
3330 );
3331 }
3332
3333 #[test]
3334 fn opened_row_toggles_closed_without_losing_selection() {
3335 let mut app = app();
3336 add_todos(&mut app, 1);
3337 let row = super::model::project(&mut app)
3338 .into_iter()
3339 .find(|row| row.selectable)
3340 .expect("work row");
3341 let open = row.primary_action.clone();
3342
3343 assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
3344 // The action's pager is on screen, so the second activation is a
3345 // toggle-close.
3346 app.view_stack.push(crate::tui::pager::PagerView::from_text(
3347 "Work · test".to_string(),
3348 "body",
3349 40,
3350 ));
3351 assert!(super::interaction::activate_primary(&mut app, &row.id, open).is_none());
3352 assert!(app.work_surface.opened.is_none());
3353 assert_eq!(app.work_surface.selected.as_ref(), Some(&row.id));
3354 }
3355
3356 #[test]
3357 fn a_click_after_the_pager_closed_itself_reopens_instead_of_going_dead() {
3358 // q/Esc inside the pager pops it without clearing `opened`. The next
3359 // click on that row must reopen its world, not be swallowed by a
3360 // stale toggle (owner regression report, 2026-08-04).
3361 let mut app = app();
3362 add_todos(&mut app, 1);
3363 let row = super::model::project(&mut app)
3364 .into_iter()
3365 .find(|row| row.selectable)
3366 .expect("work row");
3367 let open = row.primary_action.clone();
3368
3369 assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
3370 // The pager was closed from inside itself; `opened` is now stale.
3371 assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
3372 assert!(app.view_stack.is_empty());
3373
3374 let reopened = super::interaction::activate_primary(&mut app, &row.id, open);
3375 assert!(
3376 reopened.is_some(),
3377 "a stale opened owner must not swallow the next activation"
3378 );
3379 assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
3380 }
3381
3382 /// Settled to-dos keep their rows across the recent-only TTL and new user
3383 /// turns. Finished sub-agents collapse into the Subagents Archived count
3384 /// (still reachable via the Agents panel) so fan-outs do not permanently
3385 /// eat the transcript.
3386 #[test]
3387 fn settled_todos_stay_and_finished_workers_collapse_after_ttl() {
3388 let mut app = app();
3389 app.current_session_id = Some(SESSION.to_string());
3390 {
3391 let mut todos = app.todos.try_lock().expect("todos");
3392 todos.add("ship the fix".to_string(), TodoStatus::Completed);
3393 todos.add("verify the fix".to_string(), TodoStatus::Completed);
3394 }
3395 app.subagent_cache.push(cached_worker(
3396 "agent-settled",
3397 "builder",
3398 None,
3399 None,
3400 SubAgentStatus::Completed,
3401 ));
3402
3403 app.work_surface.set_presentation_now_ms(0);
3404 let first = super::model::project_visible(&mut app);
3405 assert!(
3406 first.iter().any(|row| row.id.0.starts_with("graph:")),
3407 "settled to-dos must be listed: {first:?}"
3408 );
3409 assert!(
3410 !first.iter().any(|row| row.id.0.starts_with("worker:")),
3411 "workers are the agents view's rows, never the tasks view's: {first:?}"
3412 );
3413 let roster = super::model::visible_rows_for(&mut app, super::RailPanel::Agents);
3414 assert!(
3415 roster.iter().any(|row| row.id.0 == "worker:agent-settled"),
3416 "the roster retains a finished worker: {roster:?}"
3417 );
3418
3419 app.work_surface
3420 .set_presentation_now_ms(super::model::RECENT_ONLY_TTL_MS + 1);
3421 app.work_surface.note_user_turn_or_new_operation();
3422 let later = super::model::project_visible(&mut app);
3423 assert!(
3424 later.iter().any(|row| row.id.0.starts_with("graph:")),
3425 "a settled to-do must survive the TTL and the next user turn: {later:?}"
3426 );
3427 assert!(
3428 super::height(&mut app, 100, 40, AMPLE_BUDGET) > 0,
3429 "the strip must keep its height while it holds settled work"
3430 );
3431 }
3432
3433 /// A to-do row says its state in words, in the `/task digest` vocabulary.
3434 /// Dropping the words (2011b9b11 conflated them with the redundant kind
3435 /// label) was half of owner regression A1.
3436 #[test]
3437 fn todo_rows_carry_their_status_words() {
3438 let mut app = app();
3439 add_todos(&mut app, 3);
3440 let rows = super::model::project(&mut app);
3441 let todo_details: Vec<&str> = rows
3442 .iter()
3443 .filter(|row| row.id.0.starts_with("graph:"))
3444 .map(|row| row.detail.as_str())
3445 .collect();
3446 assert!(
3447 todo_details.contains(&"in progress"),
3448 "the active step says so in words: {todo_details:?}"
3449 );
3450 assert!(
3451 todo_details.contains(&"pending"),
3452 "a pending step is labeled, not blank: {todo_details:?}"
3453 );
3454
3455 // And the words are painted, not just projected.
3456 let text = render_text(&mut app, 100, 6);
3457 assert!(text.contains("in progress"), "{text}");
3458 assert!(text.contains("pending"), "{text}");
3459 }
3460
3461 /// Top strip collapses completed/cancelled workers into an Archived count
3462 /// while keeping live (and failed) workers as rows. Agents panel still
3463 /// lists every worker — see the click test below.
3464 #[test]
3465 fn the_roster_keeps_live_failed_and_finished_workers() {
3466 let mut app = app();
3467 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3468 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3469 app.current_session_id = Some(SESSION.to_string());
3470 app.subagent_cache.push(cached_worker(
3471 "agent-live",
3472 "scout",
3473 None,
3474 None,
3475 SubAgentStatus::Running,
3476 ));
3477 app.subagent_cache.push(cached_worker(
3478 "agent-done",
3479 "builder",
3480 None,
3481 None,
3482 SubAgentStatus::Completed,
3483 ));
3484 app.subagent_cache.push(cached_worker(
3485 "agent-failed",
3486 "verifier",
3487 None,
3488 None,
3489 SubAgentStatus::Failed("boom".to_string()),
3490 ));
3491
3492 // The roster is a history: every worker keeps its row, in every
3493 // state, and the tasks view never lists one.
3494 let rows = super::model::visible_rows_for(&mut app, super::RailPanel::Agents);
3495 let ids: Vec<&str> = rows.iter().map(|row| row.id.0.as_str()).collect();
3496 assert!(ids.contains(&"section:agents"), "{ids:?}");
3497 for id in [
3498 "worker:agent-live",
3499 "worker:agent-failed",
3500 "worker:agent-done",
3501 ] {
3502 assert!(ids.contains(&id), "{id} in the roster: {ids:?}");
3503 }
3504 let tasks = super::model::project_visible(&mut app);
3505 assert!(
3506 !tasks.iter().any(|row| row.id.0.starts_with("worker:")),
3507 "{tasks:?}"
3508 );
3509 assert_eq!(super::model::live_agent_row_count(&mut app), 2);
3510 }
3511
3512 #[test]
3513 fn subagent_header_opens_the_full_agents_register() {
3514 let mut app = app();
3515 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3516 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3517 app.current_session_id = Some(SESSION.to_string());
3518 app.subagent_cache.push(cached_worker(
3519 "agent-archived",
3520 "builder",
3521 None,
3522 None,
3523 SubAgentStatus::Completed,
3524 ));
3525
3526 // Nothing live: the dock stays down until the user opens agents.
3527 assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0);
3528 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3529 let top = render_text(&mut app, 100, 4);
3530 assert!(top.contains("Subagents 1"), "{top}");
3531 let header_y = app
3532 .work_surface
3533 .hitboxes
3534 .iter()
3535 .find(|hit| hit.id.0 == "section:agents")
3536 .expect("subagent header must be a real hit target")
3537 .row_y;
3538 let action = super::handle_mouse(
3539 &mut app,
3540 MouseEvent {
3541 kind: MouseEventKind::Down(MouseButton::Left),
3542 column: 2,
3543 row: header_y,
3544 modifiers: KeyModifiers::NONE,
3545 },
3546 )
3547 .action
3548 .expect("subagent header must dispatch its primary action");
3549 assert_eq!(action, SidebarRowAction::ShowSubagentsPanel);
3550 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3551
3552 let agents = render_text(&mut app, 100, 6);
3553 assert!(
3554 agents.contains("agent-archived") || agents.contains("builder"),
3555 "the full Agents register keeps the archived worker reachable: {agents}"
3556 );
3557 }
3558
3559 #[test]
3560 fn agent_entry_focuses_a_visible_row_and_esc_returns_to_composer() {
3561 let mut app = app();
3562 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3563 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3564 app.current_session_id = Some(SESSION.to_string());
3565 app.subagent_cache.push(cached_worker(
3566 "agent-live",
3567 "builder",
3568 None,
3569 None,
3570 SubAgentStatus::Running,
3571 ));
3572
3573 let rendered = render_text(&mut app, 100, 8);
3574 assert!(rendered.contains("builder"), "{rendered}");
3575
3576 assert!(super::enter_agents(&mut app));
3577 assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
3578 assert!(app.work_surface.focused);
3579 assert_eq!(
3580 app.work_surface.selected.as_ref().map(|row| row.0.as_str()),
3581 Some("worker:agent-live")
3582 );
3583
3584 let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
3585 assert!(handled.is_some());
3586 assert!(
3587 !app.work_surface.focused,
3588 "Esc returns ownership to composer"
3589 );
3590 }
3591
3592 #[test]
3593 fn agent_entry_rejects_a_surface_that_is_not_rendered() {
3594 let mut app = app();
3595 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3596 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3597 app.current_session_id = Some(SESSION.to_string());
3598 app.subagent_cache.push(cached_worker(
3599 "agent-live",
3600 "builder",
3601 None,
3602 None,
3603 SubAgentStatus::Running,
3604 ));
3605 // A previous frame's rectangle is not evidence that this Agent row
3606 // was painted. Only the renderer's current hitboxes may transfer
3607 // keyboard ownership away from the composer.
3608 app.work_surface.last_area = Some(ratatui::layout::Rect::new(0, 0, 100, 5));
3609 assert!(app.work_surface.hitboxes.is_empty());
3610
3611 assert!(!super::enter_agents(&mut app));
3612 assert!(
3613 !app.work_surface.focused,
3614 "hidden surface cannot own arrows"
3615 );
3616 }
3617
3618 #[test]
3619 fn compact_top_surface_keeps_goal_todos_and_named_agent_visible() {
3620 for (width, terminal_height) in [(160, 48), (120, 32), (80, 24)] {
3621 let mut app = app();
3622 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3623 app.work_surface.panel = super::RailPanel::Tasks;
3624 app.work_surface.top_height = super::model::TOP_HEIGHT_MIN;
3625 app.goal.objective = Some("ship the release".to_string());
3626 add_todos(&mut app, 3);
3627 app.current_session_id = Some(SESSION.to_string());
3628 app.subagent_cache.push(cached_worker(
3629 "agent-harbor",
3630 "builder",
3631 Some("Harbor"),
3632 None,
3633 SubAgentStatus::Running,
3634 ));
3635
3636 let budget = crate::tui::ui::rail_row_budget(&app, width, terminal_height, false);
3637 let height = super::height(&mut app, width, terminal_height, budget);
3638 assert_eq!(
3639 height,
3640 super::model::TOP_HEIGHT_MIN,
3641 "{width}x{terminal_height} must seat the readable compact surface"
3642 );
3643 // The to-do list opens first: goal title + the progress
3644 // receipt. The named agent lives one view over.
3645 let rendered = render_text(&mut app, width, height);
3646 assert!(
3647 rendered.contains("ship the release"),
3648 "{width}x{terminal_height}: {rendered}"
3649 );
3650 assert!(
3651 rendered.contains("3 left"),
3652 "{width}x{terminal_height}: {rendered}"
3653 );
3654 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3655 let height = super::height(&mut app, width, terminal_height, budget);
3656 let agents = render_text(&mut app, width, height);
3657 assert!(
3658 agents.contains("Harbor"),
3659 "{width}x{terminal_height}: {agents}"
3660 );
3661 app.work_surface.explicit_view = false;
3662 let height = super::height(&mut app, width, terminal_height, budget);
3663 let _ = render_text(&mut app, width, height);
3664
3665 assert!(super::enter_agents(&mut app));
3666 assert_eq!(
3667 app.work_surface.selected.as_ref().map(|row| row.0.as_str()),
3668 Some("worker:agent-harbor"),
3669 "the advertised Left control must focus the named visible Agent"
3670 );
3671 }
3672 }
3673
3674 #[test]
3675 fn starved_surface_cannot_take_keyboard_focus() {
3676 let mut app = app();
3677 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3678 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3679 app.current_session_id = Some(SESSION.to_string());
3680 app.subagent_cache.push(cached_worker(
3681 "agent-hidden",
3682 "builder",
3683 Some("Harbor"),
3684 None,
3685 SubAgentStatus::Running,
3686 ));
3687
3688 assert_eq!(super::height(&mut app, 80, 12, 0), 0);
3689 assert!(app.work_surface.last_area.is_none());
3690 assert!(!super::enter_agents(&mut app));
3691 assert!(!app.work_surface.focused);
3692 assert!(
3693 super::handle_key(
3694 &mut app,
3695 KeyEvent::new(KeyCode::Char('w'), KeyModifiers::ALT),
3696 )
3697 .is_none()
3698 );
3699 assert!(!app.work_surface.focused);
3700 }
3701
3702 /// Acceptance for owner regression A2: an agent row is a door in the
3703 /// Agents panel too, and a FINISHED agent's world still opens — the
3704 /// panel is a standing register, not a live-only view. Since v0.9.7 the
3705 /// door leads to the agent's transcript (which explains itself when no
3706 /// capture exists yet), not to the details projection.
3707 #[test]
3708 fn agents_panel_click_opens_the_transcript_even_for_finished_agents() {
3709 let mut app = app();
3710 app.work_surface.panel = super::RailPanel::Agents;
3711 app.current_session_id = Some(SESSION.to_string());
3712 app.subagent_cache.push(cached_worker(
3713 "agent-finished",
3714 "builder",
3715 None,
3716 None,
3717 SubAgentStatus::Completed,
3718 ));
3719
3720 let _ = render_text(&mut app, 100, 6);
3721 let row_y = app
3722 .work_surface
3723 .hitboxes
3724 .iter()
3725 .find(|hit| hit.id.0 == "worker:agent-finished")
3726 .expect("finished agent row must keep a hitbox in the Agents panel")
3727 .row_y;
3728 let action = super::handle_mouse(
3729 &mut app,
3730 MouseEvent {
3731 kind: MouseEventKind::Down(MouseButton::Left),
3732 column: 2,
3733 row: row_y,
3734 modifiers: KeyModifiers::NONE,
3735 },
3736 )
3737 .action
3738 .expect("click on a finished agent row must dispatch its primary action");
3739 assert_eq!(
3740 action,
3741 SidebarRowAction::OpenAgentTranscript {
3742 agent_id: "agent-finished".to_string()
3743 }
3744 );
3745 crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
3746 assert!(
3747 app.agent_focus
3748 .as_ref()
3749 .is_some_and(|focus| focus.is("agent-finished")),
3750 "the finished agent's transcript must actually take focus"
3751 );
3752 }
3753
3754 /// Acceptance for owner regression A1: to-do rows are doors in the
3755 /// Pinned panel too — clicking one opens the work inspector.
3756 #[test]
3757 fn tasks_view_todo_rows_stay_clickable() {
3758 let mut app = app();
3759 app.work_surface.panel = super::RailPanel::Tasks;
3760 add_todos(&mut app, 2);
3761
3762 let _ = render_text(&mut app, 100, 6);
3763 let hit = app
3764 .work_surface
3765 .hitboxes
3766 .iter()
3767 .find(|hit| hit.id.0.starts_with("graph:"))
3768 .expect("tasks view to-do rows must keep hitboxes")
3769 .clone();
3770 let action = super::handle_mouse(
3771 &mut app,
3772 MouseEvent {
3773 kind: MouseEventKind::Down(MouseButton::Left),
3774 column: 2,
3775 row: hit.row_y,
3776 modifiers: KeyModifiers::NONE,
3777 },
3778 )
3779 .action
3780 .expect("click on a to-do row must dispatch its primary action");
3781 assert!(
3782 matches!(action, SidebarRowAction::InspectWork { .. }),
3783 "a to-do row opens the work inspector: {action:?}"
3784 );
3785 }
3786
3787 /// Opening the sub-agent register must not hide the to-do list — both
3788 /// durable surfaces stay visible together (owner report, 0.9.6). The
3789 /// dock opens on TODO; AGENTS is the next tab (founder, 2026-09-03).
3790 #[test]
3791 fn agents_and_tasks_are_separate_views_and_the_dock_opens_on_todo() {
3792 let mut app = app();
3793 app.current_session_id = Some(SESSION.to_string());
3794 app.subagent_cache.push(cached_worker(
3795 "agent-live",
3796 "scout",
3797 None,
3798 None,
3799 SubAgentStatus::Running,
3800 ));
3801 add_todos(&mut app, 2);
3802
3803 // The auto rule: the to-do list first, and only to-dos in it.
3804 super::model::resolve_view(&mut app);
3805 assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
3806 let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
3807 .iter()
3808 .map(|row| row.id.0.clone())
3809 .collect();
3810 assert!(ids.iter().any(|id| id.starts_with("graph:")), "{ids:?}");
3811 assert!(!ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}");
3812
3813 // One key forward: the agents view, the roster only.
3814 super::cycle_view(&mut app, true);
3815 assert_eq!(app.work_surface.panel, super::RailPanel::Agents);
3816 assert!(app.work_surface.explicit_view);
3817 let ids: Vec<String> = super::model::visible_rows_for_panel(&mut app)
3818 .iter()
3819 .map(|row| row.id.0.clone())
3820 .collect();
3821 assert!(ids.iter().any(|id| id.starts_with("worker:")), "{ids:?}");
3822 assert!(
3823 !ids.iter().any(|id| id.starts_with("graph:")),
3824 "the agents view is the roster, not the to-do list: {ids:?}"
3825 );
3826
3827 // Back, and Esc hands the choice back to the auto rule.
3828 super::cycle_view(&mut app, false);
3829 assert_eq!(app.work_surface.panel, super::RailPanel::Tasks);
3830 let _ = render_text(&mut app, 80, 8);
3831 assert!(app.work_surface.focused);
3832 let handled = super::handle_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
3833 assert!(handled.is_some());
3834 assert!(!app.work_surface.explicit_view);
3835 assert!(app.work_surface.dismissed);
3836 }
3837
3838 #[test]
3839 fn cycling_visits_every_view_in_order_and_an_empty_view_still_paints() {
3840 let mut app = app();
3841 add_todos(&mut app, 1);
3842 let mut seen = vec![];
3843 for _ in 0..super::RailPanel::ORDER.len() {
3844 super::cycle_view(&mut app, true);
3845 seen.push(app.work_surface.panel);
3846 let height = super::height(&mut app, 80, 24, AMPLE_BUDGET);
3847 assert!(
3848 height > 0,
3849 "{:?} must keep a strip while explicitly open",
3850 app.work_surface.panel
3851 );
3852 }
3853 let mut expected = super::RailPanel::ORDER.to_vec();
3854 expected.rotate_left(1); // the fixture starts on tasks, the first tab
3855 assert_eq!(seen, expected);
3856 // An empty explicit view names itself instead of going blank.
3857 super::interaction::select_dock_panel(&mut app, super::RailPanel::Files);
3858 let text = render_text(&mut app, 80, 5);
3859 assert!(text.contains("no files touched this session"), "{text}");
3860 }
3861
3862 /// The register header is a two-way door: open the Agents panel, then the
3863 /// same click returns to Tasks, so the to-do list is never stranded.
3864 #[test]
3865 fn subagent_header_returns_to_tasks_from_the_agents_view() {
3866 let mut app = app();
3867 app.work_surface.placement = super::WorkSurfacePlacement::Top;
3868 app.work_surface.effective_placement = super::WorkSurfacePlacement::Top;
3869 app.current_session_id = Some(SESSION.to_string());
3870 app.subagent_cache.push(cached_worker(
3871 "agent-archived",
3872 "builder",
3873 None,
3874 None,
3875 SubAgentStatus::Completed,
3876 ));
3877 // A finished worker alone opens nothing; the user cycles to agents.
3878 assert_eq!(super::height(&mut app, 100, 24, AMPLE_BUDGET), 0);
3879 super::interaction::select_dock_panel(&mut app, super::RailPanel::Agents);
3880
3881 let click_header = |app: &mut App| -> SidebarRowAction {
3882 let header_y = app
3883 .work_surface
3884 .hitboxes
3885 .iter()
3886 .find(|hit| hit.id.0 == "section:agents")
3887 .expect("subagent header is a real hit target")
3888 .row_y;
3889 super::handle_mouse(
3890 app,
3891 MouseEvent {
3892 kind: MouseEventKind::Down(MouseButton::Left),
3893 column: 2,
3894 row: header_y,
3895 modifiers: KeyModifiers::NONE,
3896 },
3897 )
3898 .action
3899 .expect("subagent header dispatches its primary action")
3900 };
3901
3902 let _ = render_text(&mut app, 100, 6);
3903 let action = click_header(&mut app);
3904 assert_eq!(action, SidebarRowAction::ShowSubagentsPanel);
3905 crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
3906 assert_eq!(
3907 app.work_surface.panel,
3908 super::RailPanel::Tasks,
3909 "clicking the header inside the register returns to Tasks"
3910 );
3911 }
3912
3913 /// ⌥V opens the selected work row's own details; the transcript pager is
3914 /// only the fallback when no row is selected (owner report, 0.9.6).
3915 #[test]
3916 fn details_chord_opens_the_selected_work_row() {
3917 let mut app = app();
3918 app.current_session_id = Some(SESSION.to_string());
3919 app.work_surface.panel = super::RailPanel::Agents;
3920 add_todos(&mut app, 2);
3921 let _ = render_text(&mut app, 100, 6);
3922
3923 let rows = super::model::visible_rows_for_panel(&mut app);
3924 let todo_row = rows
3925 .iter()
3926 .find(|row| row.id.0.starts_with("graph:"))
3927 .expect("a to-do row projects")
3928 .clone();
3929 app.work_surface.focused = true;
3930 app.work_surface.selected = Some(todo_row.id.clone());
3931
3932 let handled = super::handle_key(
3933 &mut app,
3934 KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT),
3935 );
3936 assert!(
3937 matches!(handled, Some(Some(SidebarRowAction::InspectWork { .. }))),
3938 "⌥V opens the selected row's own details: {handled:?}"
3939 );
3940 }
3941
3942 /// T1: a real right-click over an agent row opens that row's menu. The
3943 /// work surface's catch-all arm marked every event consumed, so
3944 /// `handle_mouse_event` returned before its right-click branch and no
3945 /// menu ever opened over the dock.
3946 #[test]
3947 fn right_click_on_an_agent_row_opens_its_menu() {
3948 use crate::tui::views::{ContextMenuAction, ModalKind, ViewEvent};
3949 use ratatui::{buffer::Buffer, layout::Rect};
3950
3951 let mut app = app();
3952 app.work_surface.panel = super::RailPanel::Agents;
3953 app.current_session_id = Some(SESSION.to_string());
3954 app.subagent_cache.push(cached_worker(
3955 "agent-live",
3956 "builder",
3957 None,
3958 None,
3959 SubAgentStatus::Running,
3960 ));
3961 let _ = render_text(&mut app, 100, 6);
3962 let row_y = app
3963 .work_surface
3964 .hitboxes
3965 .iter()
3966 .find(|hit| hit.id.0 == "worker:agent-live")
3967 .expect("the live agent row is painted")
3968 .row_y;
3969
3970 let events = crate::tui::mouse_ui::handle_mouse_event(
3971 &mut app,
3972 MouseEvent {
3973 kind: MouseEventKind::Down(MouseButton::Right),
3974 column: 2,
3975 row: row_y,
3976 modifiers: KeyModifiers::NONE,
3977 },
3978 );
3979 assert!(events.is_empty());
3980 assert_eq!(app.view_stack.top_kind(), Some(ModalKind::ContextMenu));
3981
3982 let area = Rect::new(0, 0, 100, 30);
3983 let mut buf = Buffer::empty(area);
3984 app.view_stack.render(area, &mut buf);
3985 let text: String = buf.content().iter().map(|cell| cell.symbol()).collect();
3986 assert_eq!(text.matches("Focus agent").count(), 1, "{text}");
3987 assert!(
3988 text.contains("Stop agent…"),
3989 "a running agent can be stopped"
3990 );
3991 assert!(!text.contains("Message agent") && !text.contains("Open transcript"));
3992 assert!(
3993 !text.contains("Command palette"),
3994 "app chrome stays off a row's menu"
3995 );
3996
3997 // Enter runs the primary entry: the same focus a left click runs.
3998 let events = app
3999 .view_stack
4000 .handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
4001 assert!(
4002 matches!(
4003 events.as_slice(),
4004 [ViewEvent::ContextMenuSelected {
4005 action: ContextMenuAction::Row(SidebarRowAction::OpenAgentTranscript { agent_id }),
4006 }] if agent_id == "agent-live"
4007 ),
4008 "{events:?}"
4009 );
4010 }
4011 }
4012
4012 lines RUST