返回 CodeWhale
model.rs
根目录 / crates / tui / src / tui / work_surface / model.rs
1 use std::collections::HashSet;
2 use std::fmt::Write as _;
3 use std::path::{Component, Path};
4 use std::time::Instant;
5
6 use codewhale_localization::{Locale, MessageId, tr};
7 use ratatui::layout::Rect;
8
9 use crate::settings::InlineDiffMode;
10 use crate::tools::canonical_action::canonical_action_alias;
11 use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus};
12 use crate::tui::app::{
13 AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction, TaskPanelEntry,
14 TaskPanelEntryKind,
15 };
16 use crate::tui::background_indicator::is_live_shell_entry;
17 use crate::tui::history::{
18 FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell,
19 };
20 use crate::tui::menu_style::{StatusKind, status_mark};
21 use crate::work_graph::{
22 AcceptanceRequirement, EdgeKind, EvidenceKind, EvidenceKindTag, NodeKind, NodeState,
23 OperationBinding, OwnerState, Provenance, WorkGraphSnapshot, WorkNode,
24 };
25
26 /// Persisted Ocean work-surface placement. Bottom is deliberately absent: the
27 /// composer and phase footer own the shell's lower edge. `Off` hides the rail
28 /// outright (rail unification, 0.9.4).
29 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
30 pub enum WorkSurfacePlacement {
31 /// Under the composer — the default (round 3, 2026-09-01: the bar's
32 /// information lives below the composer, so scrolling up reads as
33 /// intentional history).
34 #[default]
35 Bottom,
36 Top,
37 Left,
38 Right,
39 Off,
40 }
41
42 impl WorkSurfacePlacement {
43 /// Strip placements render as a horizontal band; side placements as a
44 /// rail. Bottom shares Top's auto-fit height math — only its position
45 /// in the frame differs.
46 #[must_use]
47 pub const fn is_strip(self) -> bool {
48 matches!(self, Self::Top | Self::Bottom)
49 }
50 }
51
52 /// Which view the bottom dock shows. Orthogonal to placement: the user picks
53 /// *where* the dock sits and *what* it shows.
54 ///
55 /// One bottom view, cycled in this order (founder redirect, 2026-09-02):
56 /// agents → tasks → background → files → notepad → context → git → price.
57 /// `Pinned` folded into `Tasks` (the goal heading rides the tasks view on
58 /// side placements); `pinned` stays accepted as a setting word.
59 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
60 pub enum RailPanel {
61 /// The agent roster: one row per sub-agent, retained after completion.
62 Agents,
63 /// The to-do list: plan-step rows from the work graph.
64 #[default]
65 Tasks,
66 /// Background shells and durable tasks. Scheduled work has its own manager.
67 Background,
68 /// Files edited this session (`+/−`) and files read into context.
69 Files,
70 /// Per-workspace plain-text notes (`.codewhale/notes.md`).
71 Notepad,
72 /// The context budget: used/limit, compaction threshold, breakdown.
73 Context,
74 /// Branch, ahead/behind, dirty count, last commits.
75 Git,
76 /// Session cost, per-agent cost, cache hit %, model rate.
77 Price,
78 }
79
80 impl RailPanel {
81 /// Cycle order — also the tab order in the dock strip.
82 pub const ORDER: [RailPanel; 8] = [
83 Self::Tasks,
84 Self::Agents,
85 Self::Background,
86 Self::Files,
87 Self::Notepad,
88 Self::Context,
89 Self::Git,
90 Self::Price,
91 ];
92
93 /// Views the dock opens on its own when they have content and the user
94 /// has not picked one: the to-do list first, then live agents, then
95 /// background work (founder, 2026-09-03: "To-do and Agents as the first
96 /// two, opening on To-do"). The others open only when cycled to.
97 pub const AUTO_ORDER: [RailPanel; 3] = [Self::Tasks, Self::Agents, Self::Background];
98
99 #[must_use]
100 pub fn next(self) -> Self {
101 let index = Self::ORDER.iter().position(|p| *p == self).unwrap_or(0);
102 Self::ORDER[(index + 1) % Self::ORDER.len()]
103 }
104
105 #[must_use]
106 pub fn prev(self) -> Self {
107 let index = Self::ORDER.iter().position(|p| *p == self).unwrap_or(0);
108 Self::ORDER[(index + Self::ORDER.len() - 1) % Self::ORDER.len()]
109 }
110 }
111
112 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
113 pub(super) enum DockTabTarget {
114 Panel(RailPanel),
115 Close,
116 }
117
118 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
119 pub(super) struct DockTabHitbox {
120 pub(super) target: DockTabTarget,
121 pub(super) area: Rect,
122 }
123
124 impl RailPanel {
125 #[must_use]
126 pub fn parse(value: &str) -> Self {
127 match value.trim().to_ascii_lowercase().as_str() {
128 "agents" | "subagents" | "sub-agents" => Self::Agents,
129 "tasks" | "todo" | "todos" => Self::Tasks,
130 "background" | "shells" | "jobs" => Self::Background,
131 "files" | "changes" => Self::Files,
132 "notepad" | "notes" => Self::Notepad,
133 "context" | "session" => Self::Context,
134 "git" | "branch" => Self::Git,
135 "price" | "cost" => Self::Price,
136 _ => Self::Tasks,
137 }
138 }
139
140 #[must_use]
141 pub const fn as_setting(self) -> &'static str {
142 match self {
143 Self::Agents => "agents",
144 Self::Tasks => "tasks",
145 Self::Background => "background",
146 Self::Files => "files",
147 Self::Notepad => "notepad",
148 Self::Context => "context",
149 Self::Git => "git",
150 Self::Price => "price",
151 }
152 }
153
154 /// Tab label. CAPS on purpose (founder ruling 2026-09-03): the tabs are
155 /// the dock's primary navigation and lowercase nouns read as status
156 /// text beside them. The plan-step list reads TODO, not TASKS — the
157 /// Background panel already holds running tasks, so TASKS collides
158 /// with it. `as_setting()` stays lowercase — it is the persisted
159 /// value, never a label.
160 #[must_use]
161 pub const fn title(self) -> &'static str {
162 match self {
163 Self::Agents => "AGENTS",
164 Self::Tasks => "TODO",
165 Self::Background => "BACKGROUND",
166 Self::Files => "FILES",
167 Self::Notepad => "NOTEPAD",
168 Self::Context => "CONTEXT",
169 Self::Git => "GIT",
170 Self::Price => "PRICE",
171 }
172 }
173 }
174
175 impl WorkSurfacePlacement {
176 #[must_use]
177 pub fn parse(value: &str) -> Self {
178 match value.trim().to_ascii_lowercase().as_str() {
179 "top" => Self::Top,
180 "left" => Self::Left,
181 "right" => Self::Right,
182 "off" => Self::Off,
183 _ => Self::Bottom,
184 }
185 }
186
187 #[must_use]
188 pub const fn as_setting(self) -> &'static str {
189 match self {
190 Self::Bottom => "bottom",
191 Self::Top => "top",
192 Self::Left => "left",
193 Self::Right => "right",
194 Self::Off => "off",
195 }
196 }
197 }
198
199 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
200 pub struct WorkRowId(pub String);
201
202 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
203 pub(super) enum WorkTone {
204 Heading,
205 Live,
206 /// Consequential and waiting on someone — Cognition, not Failure. A to-do
207 /// blocked on your answer has not failed.
208 Attention,
209 /// Something actually failed. The only tone that spends Failure red.
210 Failure,
211 Success,
212 Muted,
213 }
214
215 #[derive(Debug, Clone)]
216 pub(super) struct WorkRow {
217 pub id: WorkRowId,
218 pub mark: &'static str,
219 pub label: String,
220 pub detail: String,
221 pub tone: WorkTone,
222 pub selectable: bool,
223 pub primary_action: Option<SidebarRowAction>,
224 /// Present only on sub-agent rows. Carries the fields the fleet row paints
225 /// beyond `label`, so the renderer can drop them one at a time as the
226 /// surface narrows instead of truncating one pre-joined string.
227 pub agent: Option<AgentRowFacts>,
228 }
229
230 /// The parts of a sub-agent row that are laid out as their own columns.
231 ///
232 /// `label` already carries the preferred identity column (nesting indent,
233 /// nickname when the agent has one, `(+N)` child count). This carries the
234 /// rest: the role-only spelling of that same column, what the agent is doing,
235 /// and the right-aligned receipt.
236 #[derive(Debug, Clone, Default, PartialEq, Eq)]
237 pub(super) struct AgentRowFacts {
238 /// The identity column spelled with the fleet role instead of the
239 /// nickname. Equal to `label` when the agent has no nickname. The
240 /// renderer falls back to this when a nickname is too wide for the
241 /// identity column — a name is shown whole or not at all.
242 pub role_label: String,
243 /// The status word (`running`, `completed`, `failed`, …) painted as its
244 /// own column. The glyph carries the same fact for scanning; the word is
245 /// what makes the row legible without memorizing glyph vocabulary
246 /// (owner regression report, 2026-08-04).
247 pub status: String,
248 /// What the agent was sent to do.
249 pub objective: String,
250 /// Wall-clock seconds, frozen once the agent is observed terminal so a
251 /// finished agent stops ticking. `None` when no duration is known.
252 pub elapsed_secs: Option<u64>,
253 /// Model from the child's frozen spawn route or a later effective-route
254 /// usage envelope; never inferred from the parent session's model.
255 pub model: Option<String>,
256 /// Tokens used by the child. `None` means *genuinely unknown* —
257 /// the row then renders no token figure rather than claiming zero.
258 pub tokens: Option<u64>,
259 /// Unsettled items on this child's to-do list. `None` when no list has
260 /// been published; `Some(0)` means the list exists and is fully settled
261 /// (the strip still hides a zero chip — see `agent_receipt`).
262 pub todos_remaining: Option<u32>,
263 /// Whether this row on its own keeps the work dock open
264 /// ([`live_agent_row_count`]). Running, queued and answerable work does;
265 /// a finished agent does not.
266 ///
267 /// Typed at every construction site, never sniffed back out of `status` —
268 /// a renderer must not infer lifecycle from an English word
269 /// (`crates/tui/AGENTS.md`). The derivation deliberately differs by
270 /// source because the sources carry different facts: a card in the
271 /// 45-second live cache is *news*, so a fresh failure re-opens the dock
272 /// long enough to be seen, while the same worker's retained receipt stays
273 /// readable for an hour (`COMPLETED_AGENT_RETENTION`) and must not pin the
274 /// dock open for the rest of the session.
275 pub holds_dock_open: bool,
276 }
277
278 #[derive(Debug, Clone)]
279 pub(super) struct WorkHitbox {
280 pub id: WorkRowId,
281 pub row_y: u16,
282 }
283
284 #[derive(Debug, Clone)]
285 enum WorkSourceState {
286 Error(String),
287 Disconnected,
288 }
289
290 impl WorkSourceState {
291 const fn label(&self) -> &'static str {
292 match self {
293 Self::Error(_) => "error",
294 Self::Disconnected => "disconnected",
295 }
296 }
297
298 fn detail(&self) -> &str {
299 match self {
300 Self::Error(error) => error,
301 Self::Disconnected => "Work Graph runtime is not attached",
302 }
303 }
304 }
305
306 /// Live Work summary recent-only presentation lifetime (#4688).
307 pub(super) const RECENT_ONLY_TTL_MS: u64 = 4_000;
308 /// Settled file/search/write receipt lifetime in the live strip (#4690).
309 pub(super) const ACTIVITY_RECEIPT_TTL_MS: u64 = 3_000;
310 pub(super) const TOP_HEIGHT_MIN: u16 = crate::settings::WORK_SURFACE_TOP_HEIGHT_MIN;
311 pub(super) const TOP_HEIGHT_MAX: u16 = crate::settings::WORK_SURFACE_TOP_HEIGHT_MAX;
312 pub(super) const SIDE_WIDTH_MIN: u16 = 26;
313 pub(super) const SIDE_WIDTH_MAX: u16 = 80;
314
315 /// Which restored work rows belong to a prior session instance (#4416).
316 ///
317 /// Decided once per session id and cached: this instance's own later
318 /// autosaves restamp the persisted record, and re-probing after that would
319 /// re-badge restored rows as live work.
320 #[derive(Debug, Clone)]
321 pub(crate) struct SessionInstanceScope {
322 pub(super) session_id: String,
323 pub(super) from_prior_instance: bool,
324 /// Node ids present in the graph supplied at session restore time: the
325 /// restored persisted rows, as opposed to work this instance creates
326 /// afterwards.
327 pub(super) restored_nodes: HashSet<String>,
328 }
329
330 #[derive(Debug, Clone)]
331 pub struct WorkSurfaceState {
332 pub placement: WorkSurfacePlacement,
333 pub(super) effective_placement: WorkSurfacePlacement,
334 /// Panel selection — orthogonal to placement.
335 pub panel: RailPanel,
336 /// The user picked `panel` (cycle key, tab click, `/workbar <view>`), so
337 /// the auto rule leaves it alone and an empty view still paints. Esc
338 /// clears it and the dock goes back to showing whichever work view has
339 /// content.
340 pub explicit_view: bool,
341 /// The settled file activity `project()` computed this frame for the
342 /// TASKS view. The FILES view and its tab badge read it, so the history
343 /// is scanned once per frame, not once per view (#6565).
344 pub(super) file_activity: SettledFileActivity,
345 pub top_height: u16,
346 pub side_width: u16,
347 pub(super) resizing: bool,
348 pub(super) divider_hovered: bool,
349 pub(super) resize_anchor_column: u16,
350 pub(super) resize_anchor_row: u16,
351 pub(super) resize_anchor_size: u16,
352 /// Focus owner axis — distinct from selection and detail-open.
353 pub focused: bool,
354 /// Keyboard/mouse selection highlight.
355 pub selected: Option<WorkRowId>,
356 /// Which row currently owns an open detail (pager / agent card).
357 pub opened: Option<WorkRowId>,
358 pub scroll_offset: usize,
359 pub last_area: Option<Rect>,
360 pub visible_rows: usize,
361 pub total_rows: usize,
362 pub(super) dock_tabs: Vec<DockTabHitbox>,
363 pub(super) pressed_tab: Option<DockTabTarget>,
364 pub(super) hovered_tab: Option<DockTabTarget>,
365 pub dismissed: bool,
366 pub dismissed_at_rows: usize,
367 /// The auto view measured at dismissal: the dock re-opens when that
368 /// view grows or when the auto rule picks a different one (a worker
369 /// starting while the to-do list was closed is new work).
370 pub(super) dismissed_view: RailPanel,
371 pub(super) hovered: Option<WorkRowId>,
372 pub(super) hitboxes: Vec<WorkHitbox>,
373 pub(super) cached_graph: Option<WorkGraphSnapshot>,
374 pub(super) latest_rows: Vec<WorkRow>,
375 /// Full ranked catalog retained for inspector/history after live chrome expires.
376 pub(super) catalog_rows: Vec<WorkRow>,
377 /// Monotonic origin for presentation lifetimes (not wall-clock epoch).
378 presentation_origin: Instant,
379 /// Optional injected clock (ms since origin) for deterministic tests.
380 presentation_now_ms: Option<u64>,
381 /// When the projection last became recent-only (ms since origin).
382 recent_only_since_ms: Option<u64>,
383 /// Fingerprint of the recent-only set so a new completion can re-surface once.
384 recent_only_fingerprint: u64,
385 /// After TTL or user-turn, keep the live summary collapsed until new actionable work.
386 recent_only_suppressed: bool,
387 /// When the current activity receipt fingerprint first became live.
388 activity_since_ms: Option<u64>,
389 activity_fingerprint: u64,
390 activity_suppressed: bool,
391 /// Bumped on accepted user turns / newly started operations.
392 user_turn_epoch: u64,
393 last_handled_user_turn_epoch: u64,
394 /// Elapsed wall-clock, in ms, captured the first frame each sub-agent was
395 /// observed in a terminal state. The manager's `duration_ms` is
396 /// `started_at.elapsed()` recomputed per snapshot, so it keeps growing
397 /// after an agent finishes; latching the first terminal reading is what
398 /// makes a completed row stop ticking.
399 pub(super) frozen_agent_elapsed_ms: std::collections::HashMap<String, u64>,
400 /// Session-instance ownership of the restored session record (#4416).
401 pub(crate) session_instance: Option<SessionInstanceScope>,
402 /// Test override for the sessions directory the ownership probe reads;
403 /// production resolves the default location lazily.
404 pub(crate) session_owner_probe_dir: Option<std::path::PathBuf>,
405 }
406
407 impl Default for WorkSurfaceState {
408 fn default() -> Self {
409 Self::with_placement(WorkSurfacePlacement::Bottom)
410 }
411 }
412
413 impl WorkSurfaceState {
414 #[must_use]
415 pub(crate) fn is_resizing(&self) -> bool {
416 self.resizing
417 }
418
419 /// The placement actually rendered this frame (after the narrow-terminal
420 /// fallback), for truthful status readouts.
421 #[must_use]
422 pub fn effective_placement(&self) -> WorkSurfacePlacement {
423 self.effective_placement
424 }
425
426 #[must_use]
427 pub fn with_placement(placement: WorkSurfacePlacement) -> Self {
428 Self::with_layout(placement, 3, 30)
429 }
430
431 #[must_use]
432 pub fn with_layout(placement: WorkSurfacePlacement, top_height: u16, side_width: u16) -> Self {
433 Self {
434 placement,
435 effective_placement: placement,
436 panel: RailPanel::default(),
437 explicit_view: false,
438 file_activity: SettledFileActivity::default(),
439 top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX),
440 side_width: side_width.clamp(SIDE_WIDTH_MIN, SIDE_WIDTH_MAX),
441 resizing: false,
442 divider_hovered: false,
443 resize_anchor_column: 0,
444 resize_anchor_row: 0,
445 resize_anchor_size: 0,
446 focused: false,
447 selected: None,
448 opened: None,
449 scroll_offset: 0,
450 last_area: None,
451 visible_rows: 0,
452 total_rows: 0,
453 dock_tabs: Vec::new(),
454 pressed_tab: None,
455 hovered_tab: None,
456 dismissed: false,
457 dismissed_at_rows: 0,
458 dismissed_view: RailPanel::default(),
459 hovered: None,
460 hitboxes: Vec::new(),
461 cached_graph: None,
462 latest_rows: Vec::new(),
463 catalog_rows: Vec::new(),
464 presentation_origin: Instant::now(),
465 presentation_now_ms: None,
466 recent_only_since_ms: None,
467 recent_only_fingerprint: 0,
468 recent_only_suppressed: false,
469 activity_since_ms: None,
470 activity_fingerprint: 0,
471 activity_suppressed: false,
472 user_turn_epoch: 0,
473 last_handled_user_turn_epoch: 0,
474 frozen_agent_elapsed_ms: std::collections::HashMap::new(),
475 session_instance: None,
476 session_owner_probe_dir: None,
477 }
478 }
479
480 /// Inject a monotonic clock for presentation-lifetime tests.
481 #[cfg(test)]
482 pub(super) fn set_presentation_now_ms(&mut self, now_ms: u64) {
483 self.presentation_now_ms = Some(now_ms);
484 }
485
486 /// Signal that the user accepted a turn or a new operation started.
487 /// Recent-only live chrome collapses immediately (#4688).
488 pub fn note_user_turn_or_new_operation(&mut self) {
489 self.user_turn_epoch = self.user_turn_epoch.wrapping_add(1);
490 }
491
492 /// Record the exact graph restored from persisted session state. This
493 /// must happen at the restore boundary: the first later runtime capture
494 /// may already contain work created by this process.
495 pub(crate) fn record_restored_session(
496 &mut self,
497 session_id: &str,
498 graph: Option<&WorkGraphSnapshot>,
499 ) {
500 let from_prior_instance = session_record_from_prior_instance(self, session_id);
501 let restored_nodes = if from_prior_instance {
502 graph
503 .into_iter()
504 .flat_map(|graph| graph.nodes.iter())
505 .map(|node| node.id.as_str().to_string())
506 .collect()
507 } else {
508 HashSet::new()
509 };
510 self.session_instance = Some(SessionInstanceScope {
511 session_id: session_id.to_string(),
512 from_prior_instance,
513 restored_nodes,
514 });
515 }
516
517 /// A restored graph row owned by a prior session instance whose terminal
518 /// failure or staleness must not render as this session's live work
519 /// (#4416). Plan steps stay: the resumed to-do list is the point of
520 /// restoring; failed/stale operations and blockers are the leak.
521 pub(super) fn is_prior_instance_residue(&self, node: &WorkNode) -> bool {
522 let Some(scope) = self.session_instance.as_ref() else {
523 return false;
524 };
525 scope.from_prior_instance
526 && scope.restored_nodes.contains(node.id.as_str())
527 && node.kind != NodeKind::PlanStep
528 && matches!(node.state, NodeState::Failed | NodeState::Stale)
529 }
530
531 fn now_ms(&self) -> u64 {
532 self.presentation_now_ms.unwrap_or_else(|| {
533 u64::try_from(self.presentation_origin.elapsed().as_millis()).unwrap_or(u64::MAX)
534 })
535 }
536
537 pub(super) fn selected_index(&self, rows: &[WorkRow]) -> Option<usize> {
538 self.selected
539 .as_ref()
540 .and_then(|selected| rows.iter().position(|row| &row.id == selected))
541 }
542
543 /// Keep row identity and the viewport offset valid without moving the
544 /// viewport to the remembered keyboard selection. Mouse-wheel scrolling
545 /// is allowed to leave that selection off-screen until keyboard
546 /// navigation resumes.
547 pub(super) fn clamp_viewport(&mut self, rows: &[WorkRow]) {
548 let selectable = rows.iter().filter(|row| row.selectable).collect::<Vec<_>>();
549 if selectable.is_empty() {
550 self.selected = None;
551 // An explicitly opened empty panel still owns its tabs and Esc.
552 // Preserve that focus; ordinary typing can still release it.
553 self.focused &= self.explicit_view;
554 self.scroll_offset = 0;
555 return;
556 }
557 let established_selection = selectable
558 .iter()
559 .any(|row| Some(&row.id) == self.selected.as_ref());
560 if !established_selection {
561 let preferred = selectable
562 .iter()
563 // Both halves of the old `Attention` tone: splitting Failure out
564 // of it changed what red means, not what deserves focus first.
565 .find(|row| matches!(row.tone, WorkTone::Attention | WorkTone::Failure))
566 .or_else(|| selectable.iter().find(|row| row.tone == WorkTone::Live))
567 .copied()
568 .unwrap_or(selectable[0]);
569 self.selected = Some(preferred.id.clone());
570 // Establishing a new selection should reveal the current or
571 // needs-input item without reordering the canonical list. Later
572 // redraws keep mouse-wheel ownership and do not chase selection.
573 if let Some(selected) = rows.iter().position(|row| row.id == preferred.id) {
574 if selected < self.scroll_offset {
575 self.scroll_offset = selected;
576 } else if self.visible_rows > 0
577 && selected >= self.scroll_offset.saturating_add(self.visible_rows)
578 {
579 self.scroll_offset = selected.saturating_add(1) - self.visible_rows;
580 }
581 }
582 }
583 self.scroll_offset = self
584 .scroll_offset
585 .min(rows.len().saturating_sub(self.visible_rows.max(1)));
586 }
587
588 /// Reveal the remembered selection after keyboard navigation. Rendering
589 /// alone must use `clamp_viewport`; otherwise every redraw undoes a mouse
590 /// wheel offset when the selection is above the viewport.
591 pub(super) fn clamp_selection(&mut self, rows: &[WorkRow]) {
592 self.clamp_viewport(rows);
593 let Some(selected) = self.selected_index(rows) else {
594 return;
595 };
596 if selected < self.scroll_offset {
597 self.scroll_offset = selected;
598 } else if self.visible_rows > 0
599 && selected >= self.scroll_offset.saturating_add(self.visible_rows)
600 {
601 self.scroll_offset = selected.saturating_add(1).saturating_sub(self.visible_rows);
602 }
603 self.scroll_offset = self
604 .scroll_offset
605 .min(rows.len().saturating_sub(self.visible_rows.max(1)));
606 }
607 }
608
609 pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
610 let active_session = app.current_session_id.is_some();
611 freeze_terminal_agent_elapsed(app);
612 let agents = agent_rows(app);
613 let coordination = coordination_row(app);
614 let activity = settled_file_activity(app);
615 app.work_surface.file_activity = activity.clone();
616 let capture = app.runtime_services.work.as_ref().map(|work| {
617 work.try_capture(app.current_session_id.as_deref())
618 .map(|snapshot| snapshot.map(|snapshot| snapshot.graph))
619 });
620
621 let (graph, source_state) = match capture {
622 Some(Ok(Some(graph))) => {
623 app.work_surface.cached_graph = Some(graph.clone());
624 (Some(graph), None)
625 }
626 Some(Ok(None)) => {
627 app.work_surface.cached_graph = None;
628 (None, None)
629 }
630 Some(Err(error)) => (
631 app.work_surface.cached_graph.clone(),
632 active_session.then_some(WorkSourceState::Error(error)),
633 ),
634 None => (
635 app.work_surface.cached_graph.clone(),
636 active_session.then_some(WorkSourceState::Disconnected),
637 ),
638 };
639
640 update_session_instance_scope(app);
641
642 let mut rows = match graph {
643 Some(graph) => graph_rows(
644 &mut app.work_surface,
645 &graph,
646 source_state.as_ref(),
647 agents,
648 coordination,
649 activity,
650 ),
651 None if !agents.is_empty() || coordination.is_some() || !activity.is_empty() => {
652 ordered_rows(
653 &mut app.work_surface,
654 None,
655 source_state.as_ref(),
656 agents,
657 coordination,
658 activity,
659 )
660 }
661 None => source_state.map_or_else(Vec::new, |state| {
662 vec![section_heading(
663 "work",
664 &format!("Work · {}", state.label()),
665 state.detail(),
666 )]
667 }),
668 };
669 rows = with_live_shell_rows(app, rows);
670 app.work_surface.latest_rows = rows.clone();
671 if let Some(opened) = app.work_surface.opened.as_ref()
672 && !rows.iter().any(|row| &row.id == opened)
673 && !app
674 .work_surface
675 .catalog_rows
676 .iter()
677 .any(|row| &row.id == opened)
678 {
679 app.work_surface.opened = None;
680 }
681 rows
682 }
683
684 /// The tasks view: plan-step to-dos. On strips the list is literal to-dos
685 /// only; side rails keep the rest of the graph projection (operations,
686 /// blockers, receipts) under them. Sub-agents and shells have their own
687 /// views now, so nothing here competes with a running worker for rows — the
688 /// dock opens on the agents view while one runs.
689 pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
690 let rows = project(app);
691 let todo_ids = plan_step_row_ids(app);
692 let strip = app.work_surface.effective_placement.is_strip();
693 let mut out: Vec<WorkRow> = rows
694 .into_iter()
695 .filter(|row| {
696 !row.id.0.starts_with("worker:")
697 && !row.id.0.starts_with("shell:")
698 && row.id.0 != "section:shells"
699 && row.id.0 != "section:agents"
700 && (!strip || todo_ids.contains(&row.id.0))
701 })
702 .collect();
703 // On Top the goal is already the strip title; a side column repeats it
704 // as its first row so the durable goal home survives in every placement.
705 if !strip && let Some(label) = goal_row_label(app) {
706 out.insert(0, section_heading("goal", &label, ""));
707 }
708 app.work_surface.latest_rows = out.clone();
709 out
710 }
711
712 fn goal_row_label(app: &App) -> Option<String> {
713 let (objective, paused) = crate::tui::footer_ui::active_goal_chip_state(app)?;
714 let flat = objective.trim().replace(['\n', '\r'], " ");
715 if flat.is_empty() {
716 return None;
717 }
718 Some(if paused {
719 format!("Goal (paused): {flat}")
720 } else {
721 format!("Goal: {flat}")
722 })
723 }
724
725 /// The agents view: the roster. Every worker row, live or settled, under the
726 /// `▾ Subagents N` group door, oldest-first as the runtime reports them.
727 ///
728 /// ## Order and nesting
729 ///
730 /// This list is **roster-ordered** — creation order with parked husks sunk
731 /// last (`build_agent_roster`) — not tree-ordered. The `↳ ` indent and the
732 /// `(+N)` child count that `order_agent_seeds` stamps are therefore a fact
733 /// about the *live* projection carried through this merge, never a claim that
734 /// the row directly above an indented row is its parent.
735 ///
736 /// Rows built from a retained receipt stay flat, and that is deliberate.
737 /// `AgentRosterRow` does carry `parent_run_id`, but deriving depth from it
738 /// here would put a second depth authority on one list and, because the list
739 /// is not tree-ordered, would draw exactly the dangling indent
740 /// `order_agent_seeds` refuses to draw. Nesting instead degrades to flat, and
741 /// it degrades symmetrically: once either half of a pair has outlived its
742 /// live card, the surviving half loses its `↳ ` or its `(+N)` with it,
743 /// because `order_agent_seeds` only ever sees cache seeds. A pair is nested
744 /// or it is flat; one half is never marked without the other. Pinned by
745 /// `a_retained_child_renders_flat_under_a_retained_parent` and
746 /// `a_live_child_flattens_once_its_parent_has_expired_to_a_receipt`.
747 fn agents_view_rows(app: &mut App) -> Vec<WorkRow> {
748 let rows = project(app);
749 let mut agents: Vec<WorkRow> = rows
750 .into_iter()
751 .filter(|row| row.id.0.starts_with("worker:"))
752 .collect();
753 // The compact cache expires settled cards after 45 seconds. The explicit
754 // register is session history: retain its receipt rows, preserving fresh
755 // cache/progress projections by worker ID when both sources know a worker.
756 let mut retained = Vec::new();
757 let mut seen = HashSet::new();
758 for receipt in app.current_agent_roster() {
759 let id = WorkRowId(format!("worker:{}", receipt.worker_id));
760 if !seen.insert(id.clone()) {
761 continue;
762 }
763 if let Some(index) = agents.iter().position(|row| row.id == id) {
764 retained.push(agents.remove(index));
765 continue;
766 }
767 let parked = receipt.state == crate::agent_roster::RosterState::Parked;
768 let status = if parked {
769 current_activity_status_label(AgentCurrentActivityStatus::Parked, app.ui_locale)
770 } else {
771 std::borrow::Cow::Borrowed(worker_status_label(receipt.status))
772 };
773 let activity = receipt.activity.clone().unwrap_or_default();
774 // The same name every other surface shows for this worker (#6565).
775 let name = app
776 .agent_label_map
777 .get(&receipt.worker_id)
778 .cloned()
779 .unwrap_or_else(|| receipt.display_name.clone());
780 retained.push(WorkRow {
781 id,
782 mark: receipt.state.glyph(),
783 label: name.clone(),
784 detail: if activity.is_empty() {
785 status.to_string()
786 } else {
787 format!("{status} · {activity}")
788 },
789 tone: bucket_tone(if parked {
790 WorkBucket::Ready
791 } else {
792 worker_status_bucket(receipt.status)
793 }),
794 selectable: true,
795 primary_action: Some(SidebarRowAction::OpenAgentTranscript {
796 agent_id: receipt.worker_id.clone(),
797 }),
798 agent: Some(AgentRowFacts {
799 role_label: name,
800 status: status.to_string(),
801 objective: activity,
802 elapsed_secs: receipt.millis.map(|millis| millis / 1_000),
803 model: (!receipt.model.is_empty() && receipt.model != "unknown")
804 .then(|| receipt.model.clone()),
805 tokens: receipt.output_tokens,
806 todos_remaining: None,
807 // History, not news: a settled receipt has nothing running,
808 // and a parked husk asked nobody anything (#5906). Only a
809 // worker still running or still answerable holds the dock.
810 holds_dock_open: !parked && !receipt.state.is_terminal(),
811 }),
812 });
813 }
814 retained.extend(agents);
815 let agents = retained;
816 let mut out = Vec::with_capacity(agents.len() + 1);
817 if !agents.is_empty() {
818 out.push(agents_section_heading(&format!(
819 "Subagents {}",
820 agents.len()
821 )));
822 out.extend(agents);
823 }
824 app.work_surface.latest_rows = out.clone();
825 out
826 }
827
828 /// Pick the view for this frame when the user has not picked one.
829 ///
830 /// The dock is one bottom view. Cycling, a tab click, or `/workbar <view>`
831 /// makes the choice explicit and it sticks until Esc; otherwise the first of
832 /// [`RailPanel::AUTO_ORDER`] with content wins — agents while a sub-agent
833 /// runs, then the to-do list, then background work — and the persisted
834 /// `rail_panel` preference is what shows when none of them has anything.
835 pub(crate) fn resolve_view(app: &mut App) {
836 if app.work_surface.explicit_view {
837 return;
838 }
839 for panel in RailPanel::AUTO_ORDER {
840 if !view_has_work(app, panel) {
841 continue;
842 }
843 if app.work_surface.panel != panel {
844 app.work_surface.panel = panel;
845 app.work_surface.scroll_offset = 0;
846 app.work_surface.selected = None;
847 }
848 return;
849 }
850 }
851
852 /// How much work the auto-opening views hold between them: live agents,
853 /// to-dos, and running background work. The dismissed dock re-opens when
854 /// this grows, whichever view the new work lands in.
855 pub(crate) fn auto_work_rows(app: &mut App) -> usize {
856 let agents = live_agent_row_count(app);
857 let tasks = visible_rows_for(app, RailPanel::Tasks)
858 .iter()
859 .filter(|row| row.id.0.starts_with("graph:"))
860 .count();
861 let background = if background_has_live_work(app) {
862 visible_rows_for(app, RailPanel::Background).len()
863 } else {
864 0
865 };
866 agents + tasks + background
867 }
868
869 fn view_has_work(app: &mut App, panel: RailPanel) -> bool {
870 match panel {
871 RailPanel::Agents => live_agent_row_count(app) > 0,
872 // To-dos are the plan steps. A side rail also folds a summary of
873 // running work into this view; that summary is not a reason to
874 // open on TODO when the workers themselves live one tab over.
875 RailPanel::Tasks => visible_rows_for(app, panel)
876 .iter()
877 .any(|row| row.id.0.starts_with("graph:")),
878 // Scheduled automations that are not running are a fact about the
879 // account, not work in this session: they must not open the dock
880 // before the first prompt (0.9.12 defect #10). Live shells, durable
881 // tasks are work. Scheduled automation configuration belongs in /automation.
882 RailPanel::Background => background_has_live_work(app),
883 RailPanel::Files
884 | RailPanel::Notepad
885 | RailPanel::Context
886 | RailPanel::Git
887 | RailPanel::Price => false,
888 }
889 }
890
891 /// Live worker rows: the ones that make the agents view open on its own.
892 ///
893 /// Counted over [`agents_view_rows`] — the same merged projection the register
894 /// renders — not over the 45-second `subagent_cache` alone. A child that is
895 /// blocked on a person survives in the retained roster after its live card
896 /// expires, and across a session restore, where `subagent_cache` keeps only
897 /// this instance's workers; the dock has to open for it either way.
898 pub(super) fn live_agent_row_count(app: &mut App) -> usize {
899 agents_view_rows(app)
900 .iter()
901 .filter(|row| {
902 row.id.0.starts_with("worker:")
903 && row
904 .agent
905 .as_ref()
906 .is_some_and(|facts| facts.holds_dock_open)
907 })
908 .count()
909 }
910
911 /// Whether the background view holds anything actually running: a live
912 /// shell or a queued or running durable task. Finished work stays listed but
913 /// never opens the dock or counts as live (#6565).
914 pub(super) fn background_has_live_work(app: &mut App) -> bool {
915 !shell_work_rows(app).is_empty() || !durable_task_rows(app).is_empty()
916 }
917
918 /// The background view: live shells, live durable tasks, then a muted
919 /// `Finished N` group with the shells and tasks that ended, and how.
920 fn background_view_rows(app: &mut App) -> Vec<WorkRow> {
921 let mut out = Vec::new();
922 push_shell_group(&mut out, shell_work_rows(app));
923 out.extend(durable_task_rows(app));
924 let finished = finished_background_rows(app);
925 if !finished.is_empty() {
926 out.push(section_heading(
927 "finished",
928 &tr(app.ui_locale, MessageId::BackgroundFinishedHeading)
929 .replace("{count}", &finished.len().to_string()),
930 &tr(app.ui_locale, MessageId::BackgroundFinishedHint),
931 ));
932 out.extend(finished);
933 }
934 app.work_surface.latest_rows = out.clone();
935 out
936 }
937
938 /// A background shell row, live or finished.
939 fn is_shell_entry(entry: &TaskPanelEntry) -> bool {
940 entry.kind == TaskPanelEntryKind::Shell
941 }
942
943 /// Queued or running. A stale running task still needs a look, so it stays.
944 fn is_live_task_status(status: &str) -> bool {
945 matches!(status, "queued" | "running")
946 }
947
948 /// Live durable tasks (queued or running).
949 fn durable_task_rows(app: &App) -> Vec<WorkRow> {
950 app.task_panel
951 .iter()
952 .filter(|entry| !is_shell_entry(entry) && is_live_task_status(&entry.status))
953 .map(|entry| {
954 let status = if entry.stale {
955 "stale"
956 } else {
957 entry.status.as_str()
958 };
959 let bucket = if entry.stale {
960 WorkBucket::Attention
961 } else if entry.status == "queued" {
962 WorkBucket::Ready
963 } else {
964 WorkBucket::Active
965 };
966 WorkRow {
967 id: WorkRowId(format!("task:{}", entry.id)),
968 mark: agent_mark(bucket),
969 label: entry.prompt_summary.clone(),
970 detail: format!("{status} · {}", entry.id),
971 tone: bucket_tone(bucket),
972 selectable: true,
973 primary_action: Some(SidebarRowAction::Command(format!(
974 "/jobs show {}",
975 entry.id
976 ))),
977 agent: None,
978 }
979 })
980 .collect()
981 }
982
983 /// Finished shells and durable tasks, muted, each saying how it ended:
984 /// `exit 0 · 12s`, `failed · exit 2`, `killed`, `timed out`. The shell
985 /// leads with its command; its id is only in the detail and inspector.
986 fn finished_background_rows(app: &App) -> Vec<WorkRow> {
987 app.task_panel
988 .iter()
989 .filter(|entry| !is_live_task_status(&entry.status))
990 .map(|entry| {
991 let failed = matches!(entry.status.as_str(), "failed");
992 let tone = if failed {
993 WorkTone::Failure
994 } else {
995 WorkTone::Muted
996 };
997 let mark = if failed {
998 crate::tui::glyphs::ATTENTION
999 } else {
1000 crate::tui::glyphs::DONE
1001 };
1002 if is_shell_entry(entry) {
1003 let command = shell_command(entry);
1004 let outcome = finished_shell_outcome(app.ui_locale, entry);
1005 WorkRow {
1006 id: WorkRowId(format!("shell:{}", entry.id)),
1007 mark,
1008 label: command.clone(),
1009 detail: format!("{outcome} · {}", entry.id),
1010 tone,
1011 selectable: true,
1012 primary_action: Some(SidebarRowAction::InspectWork {
1013 title: format!("Shell {}", entry.id),
1014 body: shell_inspector_body(app, entry, &command, &outcome),
1015 stop_action: None,
1016 }),
1017 agent: Some(AgentRowFacts {
1018 role_label: "shell".to_string(),
1019 status: outcome,
1020 objective: command,
1021 elapsed_secs: entry.duration_ms.map(|ms| ms / 1_000),
1022 model: None,
1023 tokens: None,
1024 todos_remaining: None,
1025 holds_dock_open: false,
1026 }),
1027 }
1028 } else {
1029 let took = entry
1030 .duration_ms
1031 .map(|ms| format!(" · {}", crate::agent_roster::format_duration(ms)))
1032 .unwrap_or_default();
1033 WorkRow {
1034 id: WorkRowId(format!("task:{}", entry.id)),
1035 mark,
1036 label: entry.prompt_summary.clone(),
1037 detail: format!(
1038 "{}{took} · {}",
1039 tr(
1040 app.ui_locale,
1041 match entry.status.as_str() {
1042 "completed" => MessageId::BackgroundOutcomeDone,
1043 "failed" => MessageId::BackgroundOutcomeFailed,
1044 _ => MessageId::BackgroundOutcomeCancelled,
1045 }
1046 ),
1047 entry.id
1048 ),
1049 tone,
1050 selectable: true,
1051 primary_action: Some(SidebarRowAction::Command(format!(
1052 "/jobs show {}",
1053 entry.id
1054 ))),
1055 agent: None,
1056 }
1057 }
1058 })
1059 .collect()
1060 }
1061
1062 /// A shell entry's command, without the `shell: ` prefix; its id when the
1063 /// command is empty.
1064 fn shell_command(entry: &TaskPanelEntry) -> String {
1065 let command = entry
1066 .prompt_summary
1067 .strip_prefix("shell: ")
1068 .unwrap_or(entry.prompt_summary.as_str())
1069 .trim();
1070 if command.is_empty() {
1071 entry.id.clone()
1072 } else {
1073 crate::tui::history::summarize_tool_output(command).replace(['\n', '\r'], " ")
1074 }
1075 }
1076
1077 /// How a finished shell ended, the same words its notice uses.
1078 fn finished_shell_outcome(locale: Locale, entry: &TaskPanelEntry) -> String {
1079 let exit = entry
1080 .exit_code
1081 .map(|code| tr(locale, MessageId::BackgroundExitCode).replace("{code}", &code.to_string()));
1082 match entry.status.as_str() {
1083 "completed" => {
1084 let took = entry
1085 .duration_ms
1086 .map(|ms| format!(" · {}", crate::agent_roster::format_duration(ms)))
1087 .unwrap_or_default();
1088 format!(
1089 "{}{took}",
1090 exit.unwrap_or_else(|| tr(locale, MessageId::BackgroundOutcomeDone).into_owned())
1091 )
1092 }
1093 "failed" => {
1094 let failed = tr(locale, MessageId::BackgroundOutcomeFailed);
1095 exit.map_or_else(|| failed.to_string(), |exit| format!("{failed} · {exit}"))
1096 }
1097 "killed" => tr(locale, MessageId::BackgroundOutcomeKilled).into_owned(),
1098 "timed_out" => tr(locale, MessageId::BackgroundOutcomeTimedOut).into_owned(),
1099 other => other.to_string(),
1100 }
1101 }
1102
1103 /// Row ids of the plan-step (to-do) nodes in the cached graph.
1104 fn plan_step_row_ids(app: &App) -> HashSet<String> {
1105 app.work_surface
1106 .cached_graph
1107 .as_ref()
1108 .map(|snapshot| {
1109 snapshot
1110 .nodes
1111 .iter()
1112 .filter(|node| node.kind == NodeKind::PlanStep)
1113 .map(|node| format!("graph:{}", node.id.as_str()))
1114 .collect::<HashSet<_>>()
1115 })
1116 .unwrap_or_default()
1117 }
1118
1119 /// Rows for the selected rail panel, routed through the same row/hitbox
1120 /// machinery regardless of panel: every work row a user can see is a door
1121 /// (`crates/tui/AGENTS.md`, "rows are objects"), whichever panel it appears
1122 /// in.
1123 ///
1124 /// - `Tasks` — the full live projection ([`project_visible`]).
1125 /// - `Agents` — the full sub-agent register under the `▾ Subagents N` header,
1126 /// then live shells, then the durable to-do checklist: opening the register
1127 /// never hides the list.
1128 /// - `Pinned` — the goal, the sub-agent group, then the plan-step to-dos.
1129 /// - `Context` — empty: session facts are a line list, not work rows, and
1130 /// render outside the row machinery.
1131 ///
1132 /// **No panel choice may hide a running sub-agent.** `Pinned` used to filter
1133 /// the projection down to plan steps, which meant the owner's own
1134 /// `rail_panel = "pinned"` made a live worker unreachable: with no to-dos and
1135 /// no goal the projection was empty, the strip collapsed to zero rows, and
1136 /// the top bar was header chrome only — the 2026-08-04 "I spawned a sub agent
1137 /// and the top bar showed nothing" report, pinned by
1138 /// `tests/work_bar_subagents_pty.rs`. There is no header chip or phase-strip
1139 /// fallback for sub-agents, so this strip is the *only* persistent surface
1140 /// they have; a panel preference about which durable work to foreground is
1141 /// not consent to lose the running fleet. Sub-agent rows are durable in the
1142 /// same sense the panel's name means (they survive completion — see the row
1143 /// lifetime rule in the module docs), so they belong here on their own terms.
1144 pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
1145 let panel = app.work_surface.panel;
1146 visible_rows_for(app, panel)
1147 }
1148
1149 pub(super) fn visible_rows_for(app: &mut App, panel: RailPanel) -> Vec<WorkRow> {
1150 match panel {
1151 RailPanel::Agents => agents_view_rows(app),
1152 RailPanel::Tasks => project_visible(app),
1153 RailPanel::Background => background_view_rows(app),
1154 RailPanel::Files => super::views::files_rows(app),
1155 RailPanel::Notepad => super::views::notepad_rows(app),
1156 RailPanel::Context => super::views::context_rows(app),
1157 RailPanel::Git => super::views::git_rows(app),
1158 RailPanel::Price => super::views::price_rows(app),
1159 }
1160 }
1161
1162 /// Classify the current session against this process's session-instance
1163 /// boot id (#4416), mirroring the `SubAgentManager` prior-session pattern
1164 /// (#405). The probe runs once per session id. Persisted row identity is
1165 /// recorded separately at the actual session restore boundary.
1166 fn update_session_instance_scope(app: &mut App) {
1167 let Some(session_id) = app.current_session_id.clone() else {
1168 app.work_surface.session_instance = None;
1169 return;
1170 };
1171 let classified = app
1172 .work_surface
1173 .session_instance
1174 .as_ref()
1175 .is_some_and(|scope| scope.session_id == session_id);
1176 if !classified {
1177 let from_prior_instance =
1178 session_record_from_prior_instance(&app.work_surface, &session_id);
1179 app.work_surface.session_instance = Some(SessionInstanceScope {
1180 session_id,
1181 from_prior_instance,
1182 restored_nodes: HashSet::new(),
1183 });
1184 }
1185 }
1186
1187 fn session_record_from_prior_instance(surface: &WorkSurfaceState, session_id: &str) -> bool {
1188 let manager = match surface.session_owner_probe_dir.as_ref() {
1189 Some(dir) => crate::session_manager::SessionManager::new(dir.clone()),
1190 None => crate::session_manager::SessionManager::default_location(),
1191 };
1192 manager.is_ok_and(|manager| manager.session_from_prior_instance(session_id))
1193 }
1194
1195 fn graph_rows(
1196 surface: &mut WorkSurfaceState,
1197 snapshot: &WorkGraphSnapshot,
1198 source_state: Option<&WorkSourceState>,
1199 agents: Vec<RankedWorkRow>,
1200 coordination: Option<RankedWorkRow>,
1201 activity: SettledFileActivity,
1202 ) -> Vec<WorkRow> {
1203 ordered_rows(
1204 surface,
1205 Some(snapshot),
1206 source_state,
1207 agents,
1208 coordination,
1209 activity,
1210 )
1211 }
1212
1213 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1214 enum WorkBucket {
1215 Active,
1216 Attention,
1217 Ready,
1218 Recent,
1219 }
1220
1221 impl WorkBucket {
1222 /// Presentation priority: needs-input outranks running work (#4689).
1223 const fn rank(self) -> u8 {
1224 match self {
1225 Self::Attention => 0,
1226 Self::Active => 1,
1227 Self::Ready => 2,
1228 Self::Recent => 3,
1229 }
1230 }
1231
1232 const fn is_actionable(self) -> bool {
1233 !matches!(self, Self::Recent)
1234 }
1235 }
1236
1237 #[derive(Clone)]
1238 struct RankedWorkRow {
1239 bucket: WorkBucket,
1240 order: usize,
1241 is_plan_step: bool,
1242 row: WorkRow,
1243 }
1244
1245 /// Complete unique targets; only the aggregate summary presentation is capped.
1246 #[derive(Default, Clone)]
1247 pub(super) struct SettledFileActivity {
1248 pub(super) summary: FileActivitySummary,
1249 pub(super) read: Vec<String>,
1250 list: Vec<String>,
1251 search: Vec<String>,
1252 pub(super) write: Vec<String>,
1253 pub(super) mutations: Vec<FileMutationReceipt>,
1254 pub(super) inline_diff_mode: InlineDiffMode,
1255 }
1256
1257 impl std::fmt::Debug for SettledFileActivity {
1258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1259 f.debug_struct("SettledFileActivity")
1260 .field("read", &self.read.len())
1261 .field("write", &self.write.len())
1262 .field("mutations", &self.mutations.len())
1263 .finish_non_exhaustive()
1264 }
1265 }
1266
1267 impl SettledFileActivity {
1268 fn is_empty(&self) -> bool {
1269 self.summary.is_empty()
1270 }
1271 }
1272
1273 fn ordered_rows(
1274 surface: &mut WorkSurfaceState,
1275 snapshot: Option<&WorkGraphSnapshot>,
1276 source_state: Option<&WorkSourceState>,
1277 mut ranked: Vec<RankedWorkRow>,
1278 coordination: Option<RankedWorkRow>,
1279 activity: SettledFileActivity,
1280 ) -> Vec<WorkRow> {
1281 ranked.extend(coordination);
1282 if let Some(snapshot) = snapshot {
1283 ranked.extend(
1284 snapshot
1285 .nodes
1286 .iter()
1287 .filter(|node| {
1288 matches!(
1289 node.kind,
1290 NodeKind::PlanStep | NodeKind::Operation | NodeKind::Blocker
1291 )
1292 })
1293 .filter(|node| !is_settled_transient_operation(node))
1294 .filter(|node| !surface.is_prior_instance_residue(node))
1295 // A plan step dropped from the Plan/To-do list (e.g. the model
1296 // cleared it with an empty `todo_write`) stays in the graph
1297 // but must leave the rail (#6546).
1298 .filter(|node| {
1299 node.kind != NodeKind::PlanStep || snapshot.compat.projects(&node.id)
1300 })
1301 .enumerate()
1302 .map(|(order, node)| RankedWorkRow {
1303 bucket: node_bucket(node),
1304 order: 10_000usize.saturating_add(order),
1305 is_plan_step: node.kind == NodeKind::PlanStep,
1306 row: graph_node_row(snapshot, node),
1307 }),
1308 );
1309 }
1310
1311 // Activity is projected separately so we can apply a single aggregated
1312 // transient receipt instead of one live row per tool kind (#4690).
1313 let activity_row = aggregate_activity_row(&activity);
1314 if let Some(row) = activity_row.clone() {
1315 ranked.push(row);
1316 }
1317
1318 ranked.sort_by(|a, b| match (a.is_plan_step, b.is_plan_step) {
1319 // To-do (plan step) rows keep canonical order: a completed step must
1320 // not sink below a later pending step and lose its identity. Agent and
1321 // operation rows still sort by status bucket (#4689).
1322 (true, true) => a.order.cmp(&b.order),
1323 (true, false) => std::cmp::Ordering::Less,
1324 (false, true) => std::cmp::Ordering::Greater,
1325 (false, false) => a
1326 .bucket
1327 .rank()
1328 .cmp(&b.bucket.rank())
1329 .then_with(|| a.order.cmp(&b.order)),
1330 });
1331
1332 let active = ranked
1333 .iter()
1334 .filter(|item| item.bucket == WorkBucket::Active)
1335 .count();
1336 let attention = ranked
1337 .iter()
1338 .filter(|item| item.bucket == WorkBucket::Attention)
1339 .count();
1340 let ready = ranked
1341 .iter()
1342 .filter(|item| item.bucket == WorkBucket::Ready)
1343 .count();
1344 let recent = ranked
1345 .iter()
1346 .filter(|item| item.bucket == WorkBucket::Recent)
1347 .count();
1348 let actionable = attention + active + ready;
1349 let source = source_state
1350 .map(|state| format!(" · {}", state.label()))
1351 .unwrap_or_default();
1352 let detail = match (snapshot, source_state) {
1353 (Some(snapshot), Some(state)) => {
1354 format!("graph revision {} · {}", snapshot.revision, state.detail())
1355 }
1356 (Some(snapshot), None) => format!("graph revision {}", snapshot.revision),
1357 (None, Some(state)) => state.detail().to_string(),
1358 (None, None) => "Current session activity".to_string(),
1359 };
1360
1361 let now = surface.now_ms();
1362 let user_turn_force_hide = surface.user_turn_epoch != surface.last_handled_user_turn_epoch;
1363 if user_turn_force_hide {
1364 surface.last_handled_user_turn_epoch = surface.user_turn_epoch;
1365 if actionable == 0 {
1366 surface.recent_only_suppressed = true;
1367 }
1368 surface.activity_suppressed = true;
1369 }
1370
1371 // Recent-only lifecycle (#4688): show a brief completion, then collapse.
1372 let recent_fp = fingerprint_rows(
1373 ranked
1374 .iter()
1375 .filter(|item| item.bucket == WorkBucket::Recent)
1376 .map(|item| item.row.id.0.as_str()),
1377 );
1378 if actionable > 0 {
1379 surface.recent_only_since_ms = None;
1380 surface.recent_only_suppressed = false;
1381 surface.recent_only_fingerprint = recent_fp;
1382 } else if recent > 0 {
1383 if surface.recent_only_fingerprint != recent_fp {
1384 // A new completion after expiry may surface once.
1385 surface.recent_only_fingerprint = recent_fp;
1386 surface.recent_only_since_ms = Some(now);
1387 surface.recent_only_suppressed = false;
1388 } else if surface.recent_only_since_ms.is_none() && !surface.recent_only_suppressed {
1389 surface.recent_only_since_ms = Some(now);
1390 }
1391 if let Some(since) = surface.recent_only_since_ms
1392 && now.saturating_sub(since) >= RECENT_ONLY_TTL_MS
1393 {
1394 surface.recent_only_suppressed = true;
1395 }
1396 } else {
1397 surface.recent_only_since_ms = None;
1398 surface.recent_only_suppressed = false;
1399 surface.recent_only_fingerprint = 0;
1400 }
1401
1402 // Activity receipt lifetime (#4690): one aggregated row, 3s, no raw payloads.
1403 let activity_fp = activity_row
1404 .as_ref()
1405 .map(|row| fingerprint_rows(std::iter::once(row.row.label.as_str())))
1406 .unwrap_or(0);
1407 let show_activity = if activity_row.is_none() {
1408 surface.activity_since_ms = None;
1409 surface.activity_fingerprint = 0;
1410 surface.activity_suppressed = false;
1411 false
1412 } else {
1413 if surface.activity_fingerprint != activity_fp {
1414 surface.activity_fingerprint = activity_fp;
1415 surface.activity_since_ms = Some(now);
1416 surface.activity_suppressed = false;
1417 } else if surface.activity_since_ms.is_none() && !surface.activity_suppressed {
1418 surface.activity_since_ms = Some(now);
1419 }
1420 if let Some(since) = surface.activity_since_ms
1421 && now.saturating_sub(since) >= ACTIVITY_RECEIPT_TTL_MS
1422 {
1423 surface.activity_suppressed = true;
1424 }
1425 !surface.activity_suppressed
1426 };
1427
1428 let subject = ranked
1429 .iter()
1430 .find(|item| item.bucket.is_actionable())
1431 .map(|item| (item.bucket, sanitize_summary_title(&item.row.label)));
1432 let heading_label = match (actionable > 0, subject.as_ref()) {
1433 (true, Some((WorkBucket::Attention, title))) => {
1434 format!("Work · Needs input: {title} · {attention} blocked{source}")
1435 }
1436 (true, Some((WorkBucket::Active, title))) => {
1437 format!("Work · Running: {title} · {active} active{source}")
1438 }
1439 (true, Some((WorkBucket::Ready, title))) => {
1440 format!("Work · Ready: {title} · {ready} ready{source}")
1441 }
1442 (true, _) => format!(
1443 "Work · {active} active · {attention} needs input · {ready} ready · {recent} recent{source}"
1444 ),
1445 (false, _) => format!(
1446 "Work · {active} active · {attention} needs input · {ready} ready · {recent} recent{source}"
1447 ),
1448 };
1449
1450 // Full catalog for inspector/history even when live chrome collapses.
1451 let mut catalog = vec![section_heading("work", &heading_label, &detail)];
1452 catalog.extend(ranked.iter().map(|item| item.row.clone()));
1453 // Prior-instance terminal residue stays reachable through the explicit
1454 // catalog, labeled historical, but never as this session's live work
1455 // (#4416).
1456 if let Some(snapshot) = snapshot {
1457 catalog.extend(
1458 snapshot
1459 .nodes
1460 .iter()
1461 .filter(|node| surface.is_prior_instance_residue(node))
1462 .map(|node| {
1463 let mut row = graph_node_row(snapshot, node);
1464 row.detail = format!("prior session · {}", row.detail);
1465 row.tone = WorkTone::Muted;
1466 row
1467 }),
1468 );
1469 }
1470 surface.catalog_rows = catalog.clone();
1471
1472 // Live chrome policy (Tasks/side projections — Top uses `project_visible`):
1473 // - actionable: heading + (optional) single activity receipt
1474 // - recent-only: transient receipts collapse after the TTL / next user
1475 // turn (#4688); settled to-dos stay as durable rows. Settled sub-agents
1476 // on Top collapse into the Subagents header (see `project_visible`) and
1477 // remain reachable via the Agents panel / catalog.
1478 // - empty: no heading
1479 let is_durable =
1480 |item: &RankedWorkRow| item.is_plan_step || item.row.id.0.starts_with("worker:");
1481 let has_durable = ranked.iter().any(is_durable);
1482 if ranked.is_empty() && source_state.is_none() {
1483 return Vec::new();
1484 }
1485 if actionable == 0 && recent == 0 {
1486 // Source-only error/disconnected heading is still useful.
1487 return if source_state.is_some() {
1488 vec![section_heading("work", &heading_label, &detail)]
1489 } else {
1490 Vec::new()
1491 };
1492 }
1493 let suppress_transient_recent = actionable == 0 && surface.recent_only_suppressed;
1494 if suppress_transient_recent && !has_durable {
1495 return Vec::new();
1496 }
1497
1498 // The live heading must count the rows the live list actually shows.
1499 // Once transient recent rows are suppressed, quoting the unfiltered
1500 // `recent` total would claim receipts the reader cannot see — the
1501 // catalog heading keeps the full count because the catalog keeps the
1502 // full rows.
1503 let live_heading = if suppress_transient_recent {
1504 let live_recent = ranked
1505 .iter()
1506 .filter(|item| item.bucket == WorkBucket::Recent && is_durable(item))
1507 .count();
1508 format!(
1509 "Work · {active} active · {attention} needs input · {ready} ready · {live_recent} recent{source}"
1510 )
1511 } else {
1512 heading_label.clone()
1513 };
1514
1515 // Full ordered children remain in the projection for side rails, inspector
1516 // selection, and durable recent visibility. Live Top height is capped in
1517 // render (#4690). Recent-only *summary* lifetime is handled above (#4688).
1518 let mut live = vec![section_heading("work", &live_heading, &detail)];
1519 for item in ranked {
1520 if item.row.id.0 == "activity:aggregate" && !show_activity {
1521 continue;
1522 }
1523 if suppress_transient_recent && !is_durable(&item) {
1524 continue;
1525 }
1526 live.push(item.row);
1527 }
1528 live
1529 }
1530
1531 fn fingerprint_rows<'a>(ids: impl Iterator<Item = &'a str>) -> u64 {
1532 use std::hash::{Hash, Hasher};
1533 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1534 for id in ids {
1535 id.hash(&mut hasher);
1536 }
1537 hasher.finish()
1538 }
1539
1540 fn sanitize_summary_title(raw: &str) -> String {
1541 let single_line = raw
1542 .chars()
1543 .map(|ch| {
1544 if ch.is_control() || ch == '\n' || ch == '\r' || ch == '\t' {
1545 ' '
1546 } else {
1547 ch
1548 }
1549 })
1550 .collect::<String>();
1551 let collapsed = single_line.split_whitespace().collect::<Vec<_>>().join(" ");
1552 let trimmed = collapsed.trim();
1553 if trimmed.is_empty() {
1554 return "work item".to_string();
1555 }
1556 let mut chars = trimmed.chars();
1557 let prefix = chars.by_ref().take(72).collect::<String>();
1558 if chars.next().is_some() {
1559 format!("{prefix}…")
1560 } else {
1561 prefix
1562 }
1563 }
1564
1565 fn coordination_row(app: &App) -> Option<RankedWorkRow> {
1566 let projection = app.coordination_detail.as_ref()?;
1567 let has_context_receipt = projection.context_projections.iter().any(|receipt| {
1568 !receipt.decision_ids.is_empty()
1569 || receipt.projected_bytes > 0
1570 || receipt.deduplicated > 0
1571 || receipt.omitted > 0
1572 });
1573 let has_metrics = !projection.metrics.hottest_paths.is_empty()
1574 || projection.metrics.package_or_module_growth.is_some()
1575 || projection.metrics.route_or_cost.is_some();
1576 if projection.decisions.is_empty()
1577 && projection.write_claims.is_empty()
1578 && projection.reconciliations.is_empty()
1579 && projection.contentions.is_empty()
1580 && !has_context_receipt
1581 && !has_metrics
1582 {
1583 return None;
1584 }
1585 let attention = crate::tui::coordination_detail::needs_attention(projection);
1586 let bucket = if attention {
1587 WorkBucket::Attention
1588 } else {
1589 WorkBucket::Recent
1590 };
1591 let title = app
1592 .tr(codewhale_localization::MessageId::CoordinationWorkTitle)
1593 .into_owned();
1594 Some(RankedWorkRow {
1595 bucket,
1596 // Coordination is a session-wide receipt, before individual workers
1597 // within the same bucket but after live/attention priority sorting.
1598 order: 100,
1599 is_plan_step: false,
1600 row: WorkRow {
1601 id: WorkRowId("coordination".to_string()),
1602 mark: if attention {
1603 crate::tui::glyphs::ATTENTION
1604 } else {
1605 crate::tui::glyphs::DONE
1606 },
1607 label: title.clone(),
1608 detail: crate::tui::coordination_detail::summary(app.ui_locale, projection),
1609 tone: bucket_tone(bucket),
1610 selectable: true,
1611 primary_action: Some(SidebarRowAction::InspectWork {
1612 title,
1613 body: crate::tui::coordination_detail::format(app.ui_locale, projection),
1614 stop_action: None,
1615 }),
1616 agent: None,
1617 },
1618 })
1619 }
1620
1621 fn node_bucket(node: &WorkNode) -> WorkBucket {
1622 match node.state {
1623 NodeState::Initializing | NodeState::Active => WorkBucket::Active,
1624 NodeState::Failed if is_transient_failed_operation(node) => WorkBucket::Recent,
1625 NodeState::Waiting | NodeState::Blocked | NodeState::Stale | NodeState::Failed => {
1626 WorkBucket::Attention
1627 }
1628 NodeState::Completed if !node.acceptance.is_empty() => WorkBucket::Attention,
1629 NodeState::Ready => WorkBucket::Ready,
1630 NodeState::Completed
1631 | NodeState::Verified
1632 | NodeState::Superseded
1633 | NodeState::Cancelled => WorkBucket::Recent,
1634 }
1635 }
1636
1637 fn is_transient_failed_operation(node: &WorkNode) -> bool {
1638 node.kind == NodeKind::Operation
1639 && node
1640 .binding
1641 .as_ref()
1642 .is_some_and(|binding| !binding.durable)
1643 && node.acceptance.is_empty()
1644 && node.state == NodeState::Failed
1645 }
1646
1647 /// One worker row before display ordering: the rendered row plus the parent
1648 /// link and fleet identity the strip uses to number, order, and indent
1649 /// nested spawns (#36).
1650 struct AgentRowSeed {
1651 agent_id: String,
1652 parent_run_id: Option<String>,
1653 role: String,
1654 /// A real nickname or stable label, never the raw agent id (#36).
1655 name: Option<String>,
1656 ranked: RankedWorkRow,
1657 }
1658
1659 /// Indent marker for a nested spawn: nothing at the top level (no permanent
1660 /// chrome for the common flat fan-out), `↳` once nesting is actually
1661 /// present, with two extra spaces per additional level (#36).
1662 fn agent_nesting_indent(depth: usize) -> String {
1663 match depth {
1664 0 => String::new(),
1665 level => format!("{}↳ ", " ".repeat(level.saturating_sub(1))),
1666 }
1667 }
1668
1669 /// Compose the sub-agent identity column: nesting indent, who the agent is,
1670 /// and `(+N)` when that agent has spawned children of its own.
1671 ///
1672 /// `who` is the agent's nickname when it has one and its fleet role when it
1673 /// does not. A nickname is identity that CodeWhale actually has, so it leads;
1674 /// the role is the honest fallback. The raw agent-id hash is never a name and
1675 /// is never rendered (#36).
1676 fn agent_strip_label(indent: &str, who: &str, children: usize) -> String {
1677 if children == 0 {
1678 format!("{indent}{who}")
1679 } else {
1680 format!("{indent}{who} (+{children})")
1681 }
1682 }
1683
1684 /// Order worker rows so nested spawns sit directly under their parent, then
1685 /// stamp each label with its display depth and a sequential number. Rows
1686 /// whose parent is not visible (e.g. the parent finished and left the cache)
1687 /// stay at the top level — honest flat rendering beats a dangling indent.
1688 fn order_agent_seeds(seeds: Vec<AgentRowSeed>) -> Vec<RankedWorkRow> {
1689 let known_ids: HashSet<&str> = seeds.iter().map(|seed| seed.agent_id.as_str()).collect();
1690 let mut children: std::collections::HashMap<&str, Vec<usize>> =
1691 std::collections::HashMap::new();
1692 let mut roots = Vec::new();
1693 for (idx, seed) in seeds.iter().enumerate() {
1694 if let Some(parent) = seed.parent_run_id.as_deref()
1695 && known_ids.contains(parent)
1696 {
1697 children.entry(parent).or_default().push(idx);
1698 continue;
1699 }
1700 roots.push(idx);
1701 }
1702
1703 fn push_tree(
1704 idx: usize,
1705 depth: usize,
1706 seeds: &[AgentRowSeed],
1707 children: &std::collections::HashMap<&str, Vec<usize>>,
1708 seen: &mut HashSet<usize>,
1709 order: &mut Vec<(usize, usize)>,
1710 ) {
1711 if !seen.insert(idx) {
1712 return;
1713 }
1714 order.push((idx, depth));
1715 if let Some(child_indices) = children.get(seeds[idx].agent_id.as_str()) {
1716 for child_idx in child_indices {
1717 push_tree(*child_idx, depth + 1, seeds, children, seen, order);
1718 }
1719 }
1720 }
1721
1722 let mut order = Vec::with_capacity(seeds.len());
1723 let mut seen = HashSet::new();
1724 for idx in roots {
1725 push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
1726 }
1727 // Cycle/orphan backstop: emit anything the walk missed at the top level.
1728 for idx in 0..seeds.len() {
1729 push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
1730 }
1731
1732 // `(+N)` counts children that are actually on this surface: the same map
1733 // the tree walk used, so the badge can never promise a child the list does
1734 // not show. Snapshot it before `seeds` is consumed — `children` borrows it.
1735 let child_counts: Vec<usize> = seeds
1736 .iter()
1737 .map(|seed| {
1738 children
1739 .get(seed.agent_id.as_str())
1740 .map_or(0, |indices| indices.len())
1741 })
1742 .collect();
1743
1744 let mut slots: Vec<Option<AgentRowSeed>> = seeds.into_iter().map(Some).collect();
1745 order
1746 .into_iter()
1747 .enumerate()
1748 .map(|(position, (idx, depth))| {
1749 let seed = slots[idx].take().expect("each row emitted exactly once");
1750 let mut ranked = seed.ranked;
1751 let indent = agent_nesting_indent(depth.min(3));
1752 let role_label = agent_strip_label(&indent, &seed.role, child_counts[idx]);
1753 ranked.row.label = match seed.name.as_deref() {
1754 Some(name) => agent_strip_label(&indent, name, child_counts[idx]),
1755 None => role_label.clone(),
1756 };
1757 if let Some(facts) = ranked.row.agent.as_mut() {
1758 facts.role_label = role_label;
1759 }
1760 // `ordered_rows` re-sorts within status buckets by `order`; stamp
1761 // the tree position so a child sorts directly under its parent
1762 // whenever they share a bucket.
1763 ranked.order = position;
1764 ranked
1765 })
1766 .collect()
1767 }
1768
1769 fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
1770 let cached_ids = app
1771 .subagent_cache
1772 .iter()
1773 .filter(|agent| !agent.from_prior_session)
1774 .map(|agent| agent.agent_id.as_str())
1775 .collect::<HashSet<_>>();
1776 let mut seeds = app
1777 .subagent_cache
1778 .iter()
1779 .filter(|agent| !agent.from_prior_session)
1780 .enumerate()
1781 .map(|(order, agent)| {
1782 let meta = app.agent_progress_meta.get(&agent.agent_id);
1783 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
1784 let status = current_activity
1785 .map(|activity| current_activity_status_label(activity.status, app.ui_locale))
1786 .or_else(|| {
1787 agent
1788 .worker_status
1789 .map(|status| std::borrow::Cow::Borrowed(worker_status_label(status)))
1790 })
1791 .unwrap_or_else(|| {
1792 std::borrow::Cow::Borrowed(subagent_status_label(&agent.status))
1793 });
1794 let bucket = current_activity
1795 .map(|activity| current_activity_status_bucket(activity.status))
1796 .or_else(|| agent.worker_status.map(worker_status_bucket))
1797 .unwrap_or_else(|| subagent_status_bucket(&agent.status));
1798 // Read failure from the same source the bucket came from, so the
1799 // tone can never disagree with the row it is painting.
1800 let failed = current_activity.map_or_else(
1801 || {
1802 agent.worker_status.map_or_else(
1803 || matches!(agent.status, SubAgentStatus::Failed(_)),
1804 |status| matches!(status, AgentWorkerStatus::Failed),
1805 )
1806 },
1807 |activity| matches!(activity.status, AgentCurrentActivityStatus::Failed),
1808 );
1809 let resolved_profile = agent
1810 .child_route
1811 .as_ref()
1812 .and_then(|route| route.resolved_profile_id.as_deref())
1813 .map(str::trim)
1814 .filter(|profile| !profile.is_empty());
1815 let role = resolved_profile
1816 .or(agent
1817 .assignment
1818 .role
1819 .as_deref()
1820 .filter(|role| !role.trim().is_empty()))
1821 .unwrap_or_else(|| agent.agent_type.as_str())
1822 .to_string();
1823 // The dispatch name leads (#5287); a nickname or stable label
1824 // names the agents dispatched without one. Never the bare agent
1825 // id (#36) — absent rather than fabricated, so the identity
1826 // column falls back to the role.
1827 let name = app
1828 .agent_given_name(&agent.agent_id)
1829 .or_else(|| resolved_profile.map(str::to_string))
1830 .or_else(|| {
1831 agent
1832 .nickname
1833 .clone()
1834 .filter(|name| !name.trim().is_empty() && name != &agent.agent_id)
1835 })
1836 .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned());
1837 let terminal = agent_is_terminal(agent, meta);
1838 // A finished agent's row says what it produced, not what it was
1839 // asked (#6565): the headline of its result, or why it stopped.
1840 // Enter opens the full text.
1841 let objective = terminal
1842 .then(|| crate::tui::agent_focus::settled_headline(app, &agent.agent_id))
1843 .flatten()
1844 .unwrap_or_else(|| summarize_assignment(&agent.assignment.objective));
1845 let mut facts = vec![status.to_string(), objective.clone()];
1846 // Quiet completion (#36): in-flight metadata (current tool, step
1847 // counters) is working state, not a receipt, and must not linger
1848 // after the run ends. What it changed is the receipt, so a
1849 // finished row keeps its file count.
1850 if terminal {
1851 if let Some(files) = meta
1852 .map(|meta| meta.files_touched)
1853 .filter(|count| *count > 0)
1854 {
1855 facts.push(
1856 tr(app.ui_locale, MessageId::BackgroundFilesChanged)
1857 .replace("{count}", &files.to_string()),
1858 );
1859 }
1860 } else {
1861 facts.extend(live_activity_facts(app, &agent.agent_id));
1862 facts.extend(quiet_fact(app, agent, meta));
1863 }
1864 AgentRowSeed {
1865 agent_id: agent.agent_id.clone(),
1866 parent_run_id: agent.parent_run_id.clone(),
1867 role,
1868 name,
1869 ranked: RankedWorkRow {
1870 bucket,
1871 order,
1872 is_plan_step: false,
1873 row: WorkRow {
1874 id: WorkRowId(format!("worker:{}", agent.agent_id)),
1875 mark: agent_mark(bucket),
1876 // Stamped by `order_agent_seeds` once the display
1877 // depth (and therefore the indent) is known.
1878 label: String::new(),
1879 detail: facts.join(" · "),
1880 tone: agent_tone(bucket, failed),
1881 selectable: true,
1882 // One agent, one destination (v0.9.7): activation
1883 // opens the agent's transcript directly; Agent
1884 // Details is the secondary action from there.
1885 primary_action: Some(SidebarRowAction::OpenAgentTranscript {
1886 agent_id: agent.agent_id.clone(),
1887 }),
1888 agent: Some(AgentRowFacts {
1889 // Stamped by `order_agent_seeds`, which is where
1890 // the indent and child count become known.
1891 role_label: String::new(),
1892 status: status.to_string(),
1893 objective,
1894 elapsed_secs: Some(agent_elapsed_ms(app, agent) / 1_000),
1895 model: meta.and_then(|meta| meta.resolved_model.clone()),
1896 tokens: meta.and_then(|meta| meta.received_tokens),
1897 todos_remaining: meta.and_then(|meta| meta.todos_remaining),
1898 holds_dock_open: bucket.is_actionable(),
1899 }),
1900 },
1901 },
1902 }
1903 })
1904 .collect::<Vec<_>>();
1905
1906 let mut progress_only = app
1907 .agent_progress
1908 .iter()
1909 .filter(|(id, _)| !cached_ids.contains(id.as_str()))
1910 .collect::<Vec<_>>();
1911 progress_only.sort_by_key(|(id, _)| (*id).clone());
1912 seeds.extend(
1913 progress_only
1914 .into_iter()
1915 .enumerate()
1916 .map(|(order, (id, _progress))| {
1917 let meta = app.agent_progress_meta.get(id);
1918 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
1919 let status = current_activity
1920 .map(|activity| current_activity_status_label(activity.status, app.ui_locale))
1921 .unwrap_or(std::borrow::Cow::Borrowed("running"));
1922 let bucket = current_activity
1923 .map(|activity| current_activity_status_bucket(activity.status))
1924 .unwrap_or(WorkBucket::Active);
1925 let failed = current_activity
1926 .is_some_and(|a| matches!(a.status, AgentCurrentActivityStatus::Failed));
1927 let name = app.agent_label_map.get(id).cloned();
1928 let mut facts = vec![status.to_string()];
1929 facts.extend(live_activity_facts(app, id));
1930 AgentRowSeed {
1931 agent_id: id.clone(),
1932 parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()),
1933 // Role is unknown until the manager snapshot arrives;
1934 // "agent" is the honest fallback, not a fabrication.
1935 role: "agent".to_string(),
1936 name,
1937 ranked: RankedWorkRow {
1938 bucket,
1939 order: 5_000usize.saturating_add(order),
1940 is_plan_step: false,
1941 row: WorkRow {
1942 id: WorkRowId(format!("worker:{id}")),
1943 mark: agent_mark(bucket),
1944 label: String::new(),
1945 detail: facts.join(" · "),
1946 tone: agent_tone(bucket, failed),
1947 selectable: true,
1948 // Same destination as the cached-seed rows above.
1949 primary_action: Some(SidebarRowAction::OpenAgentTranscript {
1950 agent_id: id.clone(),
1951 }),
1952 agent: Some(AgentRowFacts {
1953 role_label: String::new(),
1954 status: status.to_string(),
1955 // No manager snapshot yet, so there is no
1956 // assignment to quote: the live activity line
1957 // is the honest answer to "what is it doing".
1958 // The status word itself is the status
1959 // column's job, so it is not repeated here.
1960 objective: facts[1..].join(" · "),
1961 // Neither a duration nor a usage envelope has
1962 // been seen for this id. Both render as
1963 // nothing rather than as `0s` / `0 tokens`.
1964 elapsed_secs: None,
1965 model: meta.and_then(|meta| meta.resolved_model.clone()),
1966 tokens: meta.and_then(|meta| meta.received_tokens),
1967 todos_remaining: meta.and_then(|meta| meta.todos_remaining),
1968 holds_dock_open: bucket.is_actionable(),
1969 }),
1970 },
1971 },
1972 }
1973 }),
1974 );
1975 order_agent_seeds(seeds)
1976 }
1977
1978 fn summarize_assignment(value: &str) -> String {
1979 // Flatten newlines the way the goal-title path does: a multi-line
1980 // objective must not break the one-line work-bar row (2026-08-04 review).
1981 let summary = crate::tui::history::summarize_tool_output(value);
1982 if summary.contains(['\n', '\r']) {
1983 summary.replace(['\n', '\r'], " ")
1984 } else {
1985 summary
1986 }
1987 }
1988
1989 /// A running agent's live facts for its dock row, each said once (#6565).
1990 /// The activity detail usually already names the step and the tool ("step 6:
1991 /// finished tool 'read_file'"); repeating them as `using read_file · step 6`
1992 /// made every row say the same thing twice.
1993 fn live_activity_facts(app: &App, agent_id: &str) -> Vec<String> {
1994 let meta = app.agent_progress_meta.get(agent_id);
1995 let activity = meta.and_then(|meta| meta.current_activity.as_ref());
1996 let detail = activity.and_then(|activity| activity.detail.as_deref());
1997 let mut facts = Vec::new();
1998 if let Some(detail) = detail {
1999 facts.push(detail.to_string());
2000 }
2001 let said = |fact: &str| detail.is_some_and(|detail| names_whole(detail, fact));
2002 if let Some(tool) = activity.and_then(|activity| activity.current_tool.as_deref())
2003 && !said(tool)
2004 {
2005 facts.push(tr(app.ui_locale, MessageId::BackgroundUsingTool).replace("{tool}", tool));
2006 }
2007 if let Some(step) = activity.and_then(|activity| activity.step)
2008 && !said(&format!("step {step}"))
2009 {
2010 facts.push(
2011 tr(app.ui_locale, MessageId::BackgroundStep).replace("{step}", &step.to_string()),
2012 );
2013 }
2014 if let Some(files) = meta
2015 .map(|meta| meta.files_touched)
2016 .filter(|count| *count > 0)
2017 {
2018 facts.push(
2019 tr(app.ui_locale, MessageId::BackgroundFilesChanged)
2020 .replace("{count}", &files.to_string()),
2021 );
2022 }
2023 facts
2024 }
2025
2026 /// A running agent quiet this long says so on its row.
2027 const QUIET_AFTER_MS: u64 = 60_000;
2028
2029 /// `quiet 2m · auto-stop at 5m` for a running agent that has shown the manager
2030 /// no progress for a minute and has no tool in flight (#6565).
2031 ///
2032 /// This reads the engine's own clock (`idle_ms`, the one its heartbeat reads)
2033 /// and the bound that heartbeat enforces, capped by the last envelope the TUI
2034 /// saw from the child, because `AgentList` snapshots are not refreshed by
2035 /// ordinary progress. A tool in flight is never quiet: a long tool is expected, and the
2036 /// heartbeat bound sits above the tool timeout. When the engine does stop the
2037 /// agent, the row's result says so ("Auto-cancelled after 300s without
2038 /// sub-agent progress"), and Stop on the row ends it sooner.
2039 fn quiet_fact(
2040 app: &App,
2041 agent: &SubAgentResult,
2042 meta: Option<&AgentProgressMeta>,
2043 ) -> Option<String> {
2044 if agent.worker_status == Some(AgentWorkerStatus::RunningTool)
2045 || meta.and_then(|meta| meta.current_tool.as_ref()).is_some()
2046 {
2047 return None;
2048 }
2049 let since_snapshot = app.subagent_cache_received_at.map_or(0, |at| {
2050 u64::try_from(at.elapsed().as_millis()).unwrap_or(u64::MAX)
2051 });
2052 let mut idle = agent.idle_ms?.saturating_add(since_snapshot);
2053 // The snapshot is not refreshed by ordinary progress, so an agent that
2054 // kept working after it would otherwise read as quiet. Any envelope the
2055 // TUI saw from the child since then caps the estimate.
2056 if let Some(at) = meta.and_then(|meta| meta.last_progress_at) {
2057 idle = idle.min(u64::try_from(at.elapsed().as_millis()).unwrap_or(u64::MAX));
2058 }
2059 if idle < QUIET_AFTER_MS {
2060 return None;
2061 }
2062 let bound = agent.heartbeat_timeout_ms?;
2063 let coarse = |millis: u64| {
2064 let seconds = millis / 1_000;
2065 if seconds < 60 {
2066 format!("{seconds}s")
2067 } else {
2068 format!("{}m", seconds / 60)
2069 }
2070 };
2071 Some(
2072 tr(app.ui_locale, MessageId::BackgroundQuiet)
2073 .replace("{idle}", &coarse(idle.min(bound)))
2074 .replace("{bound}", &coarse(bound)),
2075 )
2076 }
2077
2078 /// Whether `text` names `fact` as a whole token: "step 1" is not said by
2079 /// "step 10", nor a tool `read` by "read_file".
2080 fn names_whole(text: &str, fact: &str) -> bool {
2081 let is_word = |c: char| c.is_alphanumeric() || c == '_';
2082 !fact.is_empty()
2083 && text.match_indices(fact).any(|(at, _)| {
2084 let before = text[..at].chars().next_back();
2085 let after = text[at + fact.len()..].chars().next();
2086 !before.is_some_and(is_word) && !after.is_some_and(is_word)
2087 })
2088 }
2089
2090 /// Has this agent stopped working? Typed live activity wins over the worker
2091 /// status, which in turn wins over the coarse manager status — the same
2092 /// precedence the row's status label and bucket already use.
2093 fn agent_is_terminal(agent: &SubAgentResult, meta: Option<&AgentProgressMeta>) -> bool {
2094 meta.and_then(|meta| meta.current_activity.as_ref())
2095 .map(|activity| {
2096 matches!(
2097 activity.status,
2098 AgentCurrentActivityStatus::Done
2099 | AgentCurrentActivityStatus::Canceled
2100 | AgentCurrentActivityStatus::Failed
2101 | AgentCurrentActivityStatus::Interrupted
2102 )
2103 })
2104 .or_else(|| {
2105 agent.worker_status.map(|worker_status| {
2106 matches!(
2107 worker_status,
2108 AgentWorkerStatus::Completed
2109 | AgentWorkerStatus::Cancelled
2110 | AgentWorkerStatus::Failed
2111 | AgentWorkerStatus::Interrupted
2112 )
2113 })
2114 })
2115 .unwrap_or(matches!(
2116 agent.status,
2117 SubAgentStatus::Completed
2118 | SubAgentStatus::Cancelled
2119 | SubAgentStatus::Failed(_)
2120 | SubAgentStatus::Interrupted(_)
2121 | SubAgentStatus::BudgetExhausted
2122 ))
2123 }
2124
2125 /// Latch each finished agent's elapsed time the first frame it is observed
2126 /// terminal, and forget agents that have left the cache.
2127 ///
2128 /// The manager recomputes `SubAgentResult::duration_ms` as
2129 /// `started_at.elapsed()` on every snapshot, so a completed agent's duration
2130 /// keeps growing for as long as it stays listed. Without this pass a finished
2131 /// row would tick forever, which is exactly the thing a receipt must not do.
2132 fn freeze_terminal_agent_elapsed(app: &mut App) {
2133 let live: HashSet<&str> = app
2134 .subagent_cache
2135 .iter()
2136 .map(|agent| agent.agent_id.as_str())
2137 .collect();
2138 app.work_surface
2139 .frozen_agent_elapsed_ms
2140 .retain(|id, _| live.contains(id.as_str()));
2141
2142 for agent in &app.subagent_cache {
2143 if !agent_is_terminal(agent, app.agent_progress_meta.get(&agent.agent_id)) {
2144 continue;
2145 }
2146 app.work_surface
2147 .frozen_agent_elapsed_ms
2148 .entry(agent.agent_id.clone())
2149 .or_insert(agent.duration_ms);
2150 }
2151 }
2152
2153 /// Frozen elapsed for a finished agent, live elapsed for a running one.
2154 fn agent_elapsed_ms(app: &App, agent: &crate::tools::subagent::SubAgentResult) -> u64 {
2155 // Live ticking (4b): derive from start timestamp at render when running,
2156 // otherwise use frozen snapshot. The redraw already happens; stale cached
2157 // duration is the bug.
2158 if matches!(
2159 agent.status,
2160 crate::tools::subagent::SubAgentStatus::Running
2161 ) && let Some(started_at) = agent.started_at
2162 {
2163 return u64::try_from(started_at.elapsed().as_millis()).unwrap_or(agent.duration_ms);
2164 }
2165 app.work_surface
2166 .frozen_agent_elapsed_ms
2167 .get(&agent.agent_id)
2168 .copied()
2169 .unwrap_or(agent.duration_ms)
2170 }
2171
2172 fn current_activity_status_bucket(status: AgentCurrentActivityStatus) -> WorkBucket {
2173 match status {
2174 AgentCurrentActivityStatus::Waiting
2175 | AgentCurrentActivityStatus::Interrupted
2176 | AgentCurrentActivityStatus::Failed => WorkBucket::Attention,
2177 // Not Attention (#5906): a parked husk asked nobody anything, so it
2178 // must not sort above live work nor be counted in the `needs input`
2179 // chip. Ready ranks below Attention and Active and stays actionable,
2180 // which is what it is — resumable, or dismissable.
2181 AgentCurrentActivityStatus::Parked | AgentCurrentActivityStatus::Queued => {
2182 WorkBucket::Ready
2183 }
2184 AgentCurrentActivityStatus::Done | AgentCurrentActivityStatus::Canceled => {
2185 WorkBucket::Recent
2186 }
2187 AgentCurrentActivityStatus::Starting
2188 | AgentCurrentActivityStatus::Running
2189 | AgentCurrentActivityStatus::ModelWait
2190 | AgentCurrentActivityStatus::RunningTool => WorkBucket::Active,
2191 }
2192 }
2193
2194 fn current_activity_status_label(
2195 status: AgentCurrentActivityStatus,
2196 locale: codewhale_localization::Locale,
2197 ) -> std::borrow::Cow<'static, str> {
2198 // `parked` is the one word here that has to be translated: it is new
2199 // vocabulary a reader has never seen on this row, and the whole point is
2200 // that it does not read as "waiting for input" (#5906).
2201 if status == AgentCurrentActivityStatus::Parked {
2202 return codewhale_localization::tr(
2203 locale,
2204 codewhale_localization::MessageId::AgentStatusParked,
2205 );
2206 }
2207 std::borrow::Cow::Borrowed(match status {
2208 AgentCurrentActivityStatus::Queued => "queued",
2209 AgentCurrentActivityStatus::Starting => "starting",
2210 AgentCurrentActivityStatus::Running => "running",
2211 AgentCurrentActivityStatus::ModelWait => "waiting for model",
2212 AgentCurrentActivityStatus::RunningTool => "running tool",
2213 AgentCurrentActivityStatus::Waiting => "waiting for input",
2214 AgentCurrentActivityStatus::Done => "completed",
2215 AgentCurrentActivityStatus::Failed => "failed",
2216 AgentCurrentActivityStatus::Canceled => "cancelled",
2217 AgentCurrentActivityStatus::Interrupted => "interrupted",
2218 AgentCurrentActivityStatus::Parked => unreachable!("handled above"),
2219 })
2220 }
2221
2222 fn worker_status_bucket(status: AgentWorkerStatus) -> WorkBucket {
2223 match status {
2224 AgentWorkerStatus::WaitingForUser
2225 | AgentWorkerStatus::Interrupted
2226 | AgentWorkerStatus::Failed => WorkBucket::Attention,
2227 AgentWorkerStatus::Queued => WorkBucket::Ready,
2228 AgentWorkerStatus::Completed | AgentWorkerStatus::Cancelled => WorkBucket::Recent,
2229 AgentWorkerStatus::Starting
2230 | AgentWorkerStatus::Running
2231 | AgentWorkerStatus::ModelWait
2232 | AgentWorkerStatus::RunningTool => WorkBucket::Active,
2233 }
2234 }
2235
2236 fn worker_status_label(status: AgentWorkerStatus) -> &'static str {
2237 match status {
2238 AgentWorkerStatus::Queued => "queued",
2239 AgentWorkerStatus::Starting => "starting",
2240 AgentWorkerStatus::Running => "running",
2241 AgentWorkerStatus::WaitingForUser => "waiting for input",
2242 AgentWorkerStatus::ModelWait => "waiting for model",
2243 AgentWorkerStatus::RunningTool => "running tool",
2244 AgentWorkerStatus::Completed => "completed",
2245 AgentWorkerStatus::Failed => "failed",
2246 AgentWorkerStatus::Cancelled => "cancelled",
2247 AgentWorkerStatus::Interrupted => "interrupted",
2248 }
2249 }
2250
2251 fn subagent_status_bucket(status: &SubAgentStatus) -> WorkBucket {
2252 match status {
2253 SubAgentStatus::Running => WorkBucket::Active,
2254 SubAgentStatus::Interrupted(_)
2255 | SubAgentStatus::Failed(_)
2256 | SubAgentStatus::BudgetExhausted => WorkBucket::Attention,
2257 SubAgentStatus::Completed | SubAgentStatus::Cancelled => WorkBucket::Recent,
2258 }
2259 }
2260
2261 fn subagent_status_label(status: &SubAgentStatus) -> &'static str {
2262 match status {
2263 SubAgentStatus::Running => "running",
2264 SubAgentStatus::Completed => "completed",
2265 SubAgentStatus::Interrupted(_) => "interrupted",
2266 SubAgentStatus::Failed(_) => "failed",
2267 SubAgentStatus::Cancelled => "cancelled",
2268 SubAgentStatus::BudgetExhausted => "budget exhausted",
2269 }
2270 }
2271
2272 /// `WorkBucket::Attention` deliberately groups a wait with a failure so they
2273 /// sort together — both need you. Tone must not follow it that far: only an
2274 /// actual failure spends Failure red.
2275 const fn agent_tone(bucket: WorkBucket, failed: bool) -> WorkTone {
2276 match bucket {
2277 WorkBucket::Attention if failed => WorkTone::Failure,
2278 other => bucket_tone(other),
2279 }
2280 }
2281
2282 const fn bucket_tone(bucket: WorkBucket) -> WorkTone {
2283 match bucket {
2284 WorkBucket::Active => WorkTone::Live,
2285 WorkBucket::Attention => WorkTone::Attention,
2286 WorkBucket::Ready => WorkTone::Muted,
2287 WorkBucket::Recent => WorkTone::Success,
2288 }
2289 }
2290
2291 const fn agent_mark(bucket: WorkBucket) -> &'static str {
2292 match bucket {
2293 WorkBucket::Active => crate::tui::glyphs::SELECTION,
2294 WorkBucket::Attention => crate::tui::glyphs::ATTENTION,
2295 WorkBucket::Ready => crate::tui::glyphs::READY,
2296 WorkBucket::Recent => crate::tui::glyphs::DONE,
2297 }
2298 }
2299
2300 pub(super) fn settled_file_activity(app: &App) -> SettledFileActivity {
2301 let mut activity = SettledFileActivity {
2302 inline_diff_mode: app.inline_diff_mode,
2303 ..SettledFileActivity::default()
2304 };
2305 let mut seen = HashSet::new();
2306 for index in 0..app.virtual_cell_count() {
2307 let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index(index) else {
2308 continue;
2309 };
2310 if !cell.is_success() {
2311 continue;
2312 }
2313 let Some(detail) = app.tool_detail_record_for_cell(index) else {
2314 continue;
2315 };
2316 let activity_tool_name = canonical_action_alias(&detail.tool_name, &detail.input);
2317 let kind = if matches!(cell, ToolCell::PatchSummary(_)) {
2318 Some(FileActivityKind::Write)
2319 } else {
2320 FileActivitySummary::from_tool_name(activity_tool_name)
2321 };
2322 let Some(kind) = kind else {
2323 continue;
2324 };
2325 if !seen.insert(detail.tool_id.as_str()) {
2326 continue;
2327 }
2328 activity.summary.record(kind);
2329 if kind == FileActivityKind::Write
2330 && let ToolCell::PatchSummary(mutation) = cell
2331 && let Some(receipt) = mutation.receipt.as_ref()
2332 {
2333 let additional_files =
2334 u32::try_from(receipt.files.len().saturating_sub(1)).unwrap_or(u32::MAX);
2335 activity.summary.files_written = activity
2336 .summary
2337 .files_written
2338 .saturating_add(additional_files);
2339 activity.mutations.push(receipt.clone());
2340 }
2341 let target = activity_target(&app.workspace, activity_tool_name, &detail.input, kind);
2342 let details = match kind {
2343 FileActivityKind::Read => &mut activity.read,
2344 FileActivityKind::List => &mut activity.list,
2345 FileActivityKind::Search => &mut activity.search,
2346 FileActivityKind::Write => &mut activity.write,
2347 };
2348 if let Some(target) = target
2349 && !details.contains(&target)
2350 {
2351 details.push(target);
2352 }
2353 }
2354 activity
2355 }
2356
2357 fn aggregate_activity_row(activity: &SettledFileActivity) -> Option<RankedWorkRow> {
2358 if activity.is_empty() {
2359 return None;
2360 }
2361 let summaries = activity.summary.compact_display();
2362 if summaries.is_empty() {
2363 return None;
2364 }
2365 // Single aggregated live receipt; never inline raw patterns/commands (#4690).
2366 let label = if summaries.len() == 1 {
2367 summaries[0].clone()
2368 } else {
2369 summaries.join(" · ")
2370 };
2371 let mutation_detail = activity.mutations.last().map(|receipt| {
2372 if activity.inline_diff_mode == InlineDiffMode::Off {
2373 receipt.outcome_label()
2374 } else {
2375 receipt.semantic_summary()
2376 }
2377 });
2378 let mutation_body = settled_mutation_body(&activity.mutations, activity.inline_diff_mode);
2379 let mut body_parts = Vec::new();
2380 if !mutation_body.is_empty() {
2381 body_parts.push(mutation_body);
2382 }
2383 for (kind, details) in [
2384 ("Read", &activity.read),
2385 ("List", &activity.list),
2386 ("Search", &activity.search),
2387 ("Write", &activity.write),
2388 ] {
2389 if details.is_empty() {
2390 continue;
2391 }
2392 body_parts.push(format!(
2393 "{kind}:\n{}",
2394 details[..details.len().min(12)].join("\n")
2395 ));
2396 }
2397 if body_parts.is_empty() {
2398 body_parts.push("No safe target detail retained".to_string());
2399 }
2400 let detail = mutation_detail
2401 .or_else(|| {
2402 activity
2403 .write
2404 .first()
2405 .cloned()
2406 .or_else(|| activity.read.first().cloned())
2407 .or_else(|| activity.search.first().map(|_| "patterns".to_string()))
2408 .or_else(|| activity.list.first().cloned())
2409 })
2410 .unwrap_or_else(|| "settled".to_string());
2411 let detail = sanitize_summary_title(&detail);
2412 Some(RankedWorkRow {
2413 bucket: WorkBucket::Recent,
2414 order: 20_000,
2415 is_plan_step: false,
2416 row: WorkRow {
2417 id: WorkRowId("activity:aggregate".to_string()),
2418 mark: crate::tui::glyphs::DONE,
2419 label,
2420 detail,
2421 tone: WorkTone::Success,
2422 selectable: true,
2423 primary_action: Some(SidebarRowAction::InspectWork {
2424 title: "Work · file activity".to_string(),
2425 body: body_parts.join("\n\n"),
2426 stop_action: None,
2427 }),
2428 agent: None,
2429 },
2430 })
2431 }
2432
2433 #[cfg(test)]
2434 fn activity_rows(activity: SettledFileActivity) -> Vec<RankedWorkRow> {
2435 aggregate_activity_row(&activity).into_iter().collect()
2436 }
2437
2438 pub(super) fn settled_mutation_body(
2439 receipts: &[FileMutationReceipt],
2440 mode: InlineDiffMode,
2441 ) -> String {
2442 let Some(receipt) = receipts.last() else {
2443 return String::new();
2444 };
2445 let details = crate::tui::key_shortcuts::tool_details_shortcut_action_hint(
2446 "exact change evidence on the matching File receipt",
2447 );
2448 let hint = format!("Select the matching File receipt; {details}.");
2449 match mode {
2450 InlineDiffMode::Off => format!("{}\n\n{hint}", receipt.outcome_label()),
2451 InlineDiffMode::Summary => format!("{}\n\n{hint}", receipt.semantic_summary()),
2452 InlineDiffMode::Full => {
2453 let diff = receipt
2454 .display_diff
2455 .lines()
2456 .take(40)
2457 .collect::<Vec<_>>()
2458 .join("\n");
2459 if diff.trim().is_empty() {
2460 format!("{}\n\n{hint}", receipt.semantic_summary())
2461 } else {
2462 format!("{}\n\n{diff}\n\n{hint}", receipt.semantic_summary())
2463 }
2464 }
2465 }
2466 }
2467
2468 fn activity_target(
2469 workspace: &Path,
2470 tool_name: &str,
2471 input: &serde_json::Value,
2472 kind: FileActivityKind,
2473 ) -> Option<String> {
2474 if tool_name == "apply_patch"
2475 && let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input)
2476 {
2477 let targets = preflight
2478 .touched_files
2479 .iter()
2480 .filter_map(|path| privacy_safe_path(workspace, path))
2481 .take(4)
2482 .collect::<Vec<_>>();
2483 if !targets.is_empty() {
2484 return Some(targets.join(", "));
2485 }
2486 }
2487 let keys: &[&str] = match kind {
2488 FileActivityKind::Search => &["pattern", "query", "path"],
2489 _ => &["path", "file_path"],
2490 };
2491 keys.iter().find_map(|key| {
2492 let value = input.get(*key)?.as_str()?.trim();
2493 if value.is_empty() {
2494 return None;
2495 }
2496 if kind == FileActivityKind::Search && *key != "path" {
2497 return Some(safe_pattern(value));
2498 }
2499 privacy_safe_path(workspace, value)
2500 })
2501 }
2502
2503 fn privacy_safe_path(workspace: &Path, raw: &str) -> Option<String> {
2504 let path = Path::new(raw);
2505 let normalized_raw = raw.replace('\\', "/");
2506 let normalized_workspace = workspace.to_string_lossy().replace('\\', "/");
2507 let relative = if path.is_absolute() || normalized_raw.starts_with('/') {
2508 let workspace_prefix = normalized_workspace.trim_end_matches('/');
2509 if normalized_raw == workspace_prefix {
2510 ""
2511 } else {
2512 normalized_raw.strip_prefix(&format!("{workspace_prefix}/"))?
2513 }
2514 } else {
2515 normalized_raw.as_str()
2516 };
2517 let relative = Path::new(relative);
2518 if relative.components().any(|component| {
2519 matches!(
2520 component,
2521 Component::ParentDir | Component::RootDir | Component::Prefix(_)
2522 )
2523 }) {
2524 return None;
2525 }
2526 let display = relative.to_string_lossy().replace('\\', "/");
2527 (!display.is_empty()).then_some(display)
2528 }
2529
2530 fn safe_pattern(raw: &str) -> String {
2531 let single_line = raw.replace(['\n', '\r', '\t'], " ");
2532 let mut chars = single_line.chars();
2533 let prefix = chars.by_ref().take(80).collect::<String>();
2534 if chars.next().is_some() {
2535 format!("{prefix}…")
2536 } else {
2537 prefix
2538 }
2539 }
2540
2541 fn is_settled_transient_operation(node: &WorkNode) -> bool {
2542 node.kind == NodeKind::Operation
2543 && node
2544 .binding
2545 .as_ref()
2546 .is_some_and(|binding| !binding.durable)
2547 && match node.state {
2548 NodeState::Completed => node.acceptance.is_empty(),
2549 NodeState::Verified | NodeState::Superseded | NodeState::Cancelled => true,
2550 _ => false,
2551 }
2552 }
2553
2554 fn section_heading(id: &str, label: &str, detail: &str) -> WorkRow {
2555 WorkRow {
2556 id: WorkRowId(format!("section:{id}")),
2557 mark: "▾",
2558 label: label.to_string(),
2559 detail: detail.to_string(),
2560 tone: WorkTone::Heading,
2561 selectable: false,
2562 primary_action: None,
2563 agent: None,
2564 }
2565 }
2566
2567 /// The sub-agent heading is a real group door: selecting it reveals the full
2568 /// Agents panel, including settled workers whose exact transcripts remain
2569 /// available after the compact strip archives them.
2570 fn agents_section_heading(label: &str) -> WorkRow {
2571 WorkRow {
2572 id: WorkRowId("section:agents".to_string()),
2573 mark: "▾",
2574 label: label.to_string(),
2575 detail: "Open the full subagent register".to_string(),
2576 tone: WorkTone::Heading,
2577 selectable: true,
2578 primary_action: Some(SidebarRowAction::ShowSubagentsPanel),
2579 agent: None,
2580 }
2581 }
2582
2583 fn with_live_shell_rows(app: &App, mut rows: Vec<WorkRow>) -> Vec<WorkRow> {
2584 rows.retain(|row| !row.id.0.starts_with("shell:") && row.id.0 != "section:shells");
2585 let shells = shell_work_rows(app);
2586 if shells.is_empty() {
2587 return rows;
2588 }
2589 let mut out = Vec::with_capacity(rows.len() + shells.len() + 1);
2590 push_shell_group(&mut out, shells);
2591 out.extend(rows);
2592 out
2593 }
2594
2595 fn push_shell_group(out: &mut Vec<WorkRow>, shells: Vec<WorkRow>) {
2596 if shells.is_empty() {
2597 return;
2598 }
2599 out.push(shells_section_heading(&shells));
2600 out.extend(shells);
2601 }
2602
2603 fn shell_work_rows(app: &App) -> Vec<WorkRow> {
2604 app.task_panel
2605 .iter()
2606 .filter(|entry| is_live_shell_entry(entry))
2607 .map(|entry| {
2608 let command = shell_command(entry);
2609 let command = command.as_str();
2610 let status = if entry.stale {
2611 "stale"
2612 } else {
2613 entry.status.as_str()
2614 };
2615 let elapsed_secs = entry.duration_ms.map(|ms| ms / 1_000);
2616 let objective = command.to_string();
2617 WorkRow {
2618 id: WorkRowId(format!("shell:{}", entry.id)),
2619 mark: agent_mark(WorkBucket::Active),
2620 // The command is what a person recognises; the id stays in
2621 // the detail and the inspector (#6565).
2622 label: objective.clone(),
2623 detail: format!("{status} · {}", entry.id),
2624 tone: WorkTone::Live,
2625 selectable: true,
2626 primary_action: Some(SidebarRowAction::InspectWork {
2627 title: format!("Shell {}", entry.id),
2628 body: shell_inspector_body(app, entry, command, status),
2629 stop_action: Some(Box::new(SidebarRowAction::Command(format!(
2630 "/jobs cancel {}",
2631 entry.id
2632 )))),
2633 }),
2634 agent: Some(AgentRowFacts {
2635 role_label: "shell".to_string(),
2636 status: status.to_string(),
2637 objective,
2638 elapsed_secs,
2639 model: None,
2640 tokens: None,
2641 todos_remaining: None,
2642 // Only live shells reach this list; the agents counter
2643 // never sees a `shell:` row either way.
2644 holds_dock_open: true,
2645 }),
2646 }
2647 })
2648 .collect()
2649 }
2650
2651 fn shells_section_heading(shells: &[WorkRow]) -> WorkRow {
2652 let ids: Vec<&str> = shells
2653 .iter()
2654 .filter_map(|row| row.id.0.strip_prefix("shell:"))
2655 .collect();
2656 let listing = if ids.is_empty() {
2657 String::new()
2658 } else {
2659 format!(
2660 "{}\n\n",
2661 ids.iter()
2662 .map(|id| format!("- {id}"))
2663 .collect::<Vec<_>>()
2664 .join("\n")
2665 )
2666 };
2667 WorkRow {
2668 id: WorkRowId("section:shells".to_string()),
2669 mark: "▾",
2670 label: format!("Shells {}", shells.len()),
2671 detail: "Watch live output · cancel by the id on the row".to_string(),
2672 tone: WorkTone::Heading,
2673 selectable: true,
2674 primary_action: Some(SidebarRowAction::InspectWork {
2675 title: format!("Shells {}", shells.len()),
2676 body: format!(
2677 "Running shells ({})\n\n{listing}Open a row to watch output. Cancel with /jobs cancel <id> or /jobs cancel all.",
2678 shells.len()
2679 ),
2680 stop_action: Some(Box::new(SidebarRowAction::Command(
2681 "/jobs cancel all".to_string(),
2682 ))),
2683 }),
2684 agent: None,
2685 }
2686 }
2687
2688 fn shell_inspector_body(app: &App, entry: &TaskPanelEntry, command: &str, status: &str) -> String {
2689 let mut body = format!(
2690 "Job: {}\nStatus: {status}\nCommand: {command}\n\nCancel: /jobs cancel {}\n",
2691 entry.id, entry.id
2692 );
2693 let session = app.current_session_id.as_deref().unwrap_or("");
2694 if let Some(manager) = app.runtime_services.shell_manager.as_ref()
2695 && let Ok(mut manager) = manager.try_lock()
2696 && let Ok(detail) = manager.inspect_job_for_session(session, &entry.id)
2697 {
2698 if !detail.stdout.is_empty() {
2699 body.push_str("\nSTDOUT:\n");
2700 body.push_str(&detail.stdout);
2701 }
2702 if !detail.stderr.is_empty() {
2703 body.push_str("\nSTDERR:\n");
2704 body.push_str(&detail.stderr);
2705 }
2706 if detail.stdout.is_empty() && detail.stderr.is_empty() {
2707 body.push_str("\n(no output yet — reopen this row to watch the shell)");
2708 }
2709 } else {
2710 body.push_str("\nOpen this row to watch live output.");
2711 }
2712 body
2713 }
2714
2715 fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
2716 let (mark, tone) = match node.state {
2717 NodeState::Ready => (crate::tui::glyphs::READY, WorkTone::Muted),
2718 NodeState::Initializing => (crate::tui::glyphs::SELECTION, WorkTone::Live),
2719 NodeState::Active => (crate::tui::glyphs::SELECTION, WorkTone::Live),
2720 NodeState::Waiting => (crate::tui::glyphs::ATTENTION, WorkTone::Attention),
2721 NodeState::Blocked => (
2722 status_mark(StatusKind::Attention).glyph,
2723 WorkTone::Attention,
2724 ),
2725 NodeState::Completed if node.acceptance.is_empty() => {
2726 (status_mark(StatusKind::Done).glyph, WorkTone::Success)
2727 }
2728 NodeState::Completed => (
2729 status_mark(StatusKind::Attention).glyph,
2730 WorkTone::Attention,
2731 ),
2732 NodeState::Verified => (status_mark(StatusKind::Done).glyph, WorkTone::Success),
2733 NodeState::Stale => ("?", WorkTone::Attention),
2734 NodeState::Superseded | NodeState::Cancelled => ("−", WorkTone::Muted),
2735 NodeState::Failed => (crate::tui::glyphs::FAILED, WorkTone::Failure),
2736 };
2737 let state = state_label(node);
2738 let kind = kind_label(node.kind);
2739 // A to-do row always carries its status word in the detail column, using
2740 // the same vocabulary as `/task digest` (pending / in progress /
2741 // completed / cancelled). Only the redundant `· plan step` KIND suffix is
2742 // dropped — the strip's checkbox marks already say the row is a plan
2743 // step, but they do not say its state in words, and dropping the state
2744 // itself was the 0.9.4 regression (a pending to-do rendered no label at
2745 // all). Non-step nodes keep the state · kind pair.
2746 let detail = if node.kind == NodeKind::PlanStep {
2747 todo_state_label(node).to_string()
2748 } else {
2749 format!("{state} · {kind}")
2750 };
2751 let stop_action = node
2752 .state
2753 .is_live()
2754 .then(|| stop_action(node.binding.as_ref()))
2755 .flatten();
2756 WorkRow {
2757 id: WorkRowId(format!("graph:{}", node.id.as_str())),
2758 mark,
2759 label: node.title.clone(),
2760 detail,
2761 tone,
2762 selectable: true,
2763 primary_action: Some(SidebarRowAction::InspectWork {
2764 title: format!("Work · {}", node.title),
2765 body: inspector_text(snapshot, node),
2766 stop_action: stop_action.map(Box::new),
2767 }),
2768 agent: None,
2769 }
2770 }
2771
2772 /// Status word for a plan-step (to-do) row, aligned with the four-state
2773 /// To-do vocabulary the `/task digest` text surface uses. Graph-only states
2774 /// keep their graph names.
2775 fn todo_state_label(node: &WorkNode) -> &'static str {
2776 match node.state {
2777 NodeState::Ready => "pending",
2778 NodeState::Initializing | NodeState::Active => "in progress",
2779 _ => state_label(node),
2780 }
2781 }
2782
2783 fn state_label(node: &WorkNode) -> &'static str {
2784 match node.state {
2785 NodeState::Ready => "ready",
2786 NodeState::Initializing => "initializing",
2787 NodeState::Active => "running",
2788 NodeState::Waiting => "waiting",
2789 NodeState::Blocked => "blocked",
2790 NodeState::Completed if node.acceptance.is_empty() => "completed",
2791 NodeState::Completed => "completed · evidence pending",
2792 NodeState::Verified => "verified",
2793 NodeState::Stale => "stale",
2794 NodeState::Superseded => "superseded",
2795 NodeState::Cancelled => "cancelled",
2796 NodeState::Failed => "failed",
2797 }
2798 }
2799
2800 const fn kind_label(kind: NodeKind) -> &'static str {
2801 match kind {
2802 NodeKind::Objective => "objective",
2803 NodeKind::PlanStep => "plan step",
2804 NodeKind::Operation => "operation",
2805 NodeKind::Evidence => "evidence",
2806 NodeKind::Blocker => "blocker",
2807 NodeKind::Approval => "approval",
2808 NodeKind::RuntimeRef => "runtime",
2809 NodeKind::LaneRef => "lane",
2810 }
2811 }
2812
2813 fn stop_action(binding: Option<&OperationBinding>) -> Option<SidebarRowAction> {
2814 let binding = binding?;
2815 if let Some(id) = binding.external.strip_prefix("task:") {
2816 Some(SidebarRowAction::Command(format!("/task cancel {id}")))
2817 } else if let Some(id) = binding.external.strip_prefix("shell:") {
2818 Some(SidebarRowAction::Command(format!("/jobs cancel {id}")))
2819 } else if let Some(id) = binding.external.strip_prefix("worker:") {
2820 Some(SidebarRowAction::CancelAgent {
2821 agent_id: id.to_string(),
2822 })
2823 } else {
2824 binding
2825 .external
2826 .strip_prefix("workflow:")
2827 .map(|id| SidebarRowAction::Command(format!("/workflow cancel {id}")))
2828 }
2829 }
2830
2831 fn inspector_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2832 let mut out = String::new();
2833 section_text(
2834 &mut out,
2835 "Objective",
2836 objective_for(snapshot, node)
2837 .as_deref()
2838 .unwrap_or("Not connected"),
2839 );
2840 section_list(
2841 &mut out,
2842 "Prerequisites",
2843 related_nodes(snapshot, node, EdgeKind::DependsOn, true),
2844 );
2845 section_text(
2846 &mut out,
2847 "Current",
2848 &format!("{} · {}", state_label(node), kind_label(node.kind)),
2849 );
2850 section_list(
2851 &mut out,
2852 "Downstream impact",
2853 related_nodes(snapshot, node, EdgeKind::DependsOn, false),
2854 );
2855 section_text(&mut out, "Binding + lifecycle owner", &binding_text(node));
2856 section_text(
2857 &mut out,
2858 "Evidence vs acceptance",
2859 &evidence_text(snapshot, node),
2860 );
2861 section_text(
2862 &mut out,
2863 "Blockers / approvals",
2864 &blockers_approvals_text(snapshot, node),
2865 );
2866 section_text(&mut out, "Why next", &why_next(snapshot, node));
2867 section_text(
2868 &mut out,
2869 "Provenance + last reconcile",
2870 &provenance_text(node),
2871 );
2872 if node.state == NodeState::Stale {
2873 section_text(
2874 &mut out,
2875 "Last bounded output",
2876 last_output_ref(snapshot, node)
2877 .as_deref()
2878 .unwrap_or("No output receipt"),
2879 );
2880 }
2881 out.trim_end().to_string()
2882 }
2883
2884 fn objective_for(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> Option<String> {
2885 if node.kind == NodeKind::Objective {
2886 return Some(node.title.clone());
2887 }
2888 let mut current = node.id.clone();
2889 let mut seen = HashSet::new();
2890 while seen.insert(current.clone()) {
2891 let Some(parent) = snapshot.edges.iter().find_map(|edge| {
2892 (edge.kind == EdgeKind::Contains && edge.to == current).then(|| edge.from.clone())
2893 }) else {
2894 break;
2895 };
2896 let Some(parent_node) = snapshot.node(&parent) else {
2897 break;
2898 };
2899 if parent_node.kind == NodeKind::Objective {
2900 return Some(parent_node.title.clone());
2901 }
2902 current = parent;
2903 }
2904 snapshot.compat.plan.objective.clone()
2905 }
2906
2907 fn related_nodes(
2908 snapshot: &WorkGraphSnapshot,
2909 node: &WorkNode,
2910 kind: EdgeKind,
2911 outgoing: bool,
2912 ) -> Vec<String> {
2913 snapshot
2914 .edges
2915 .iter()
2916 .filter(|edge| edge.kind == kind)
2917 .filter_map(|edge| {
2918 let related = if outgoing && edge.from == node.id {
2919 Some(&edge.to)
2920 } else if !outgoing && edge.to == node.id {
2921 Some(&edge.from)
2922 } else {
2923 None
2924 }?;
2925 snapshot
2926 .node(related)
2927 .map(|related| format!("{} · {}", related.title, state_label(related)))
2928 })
2929 .collect()
2930 }
2931
2932 fn binding_text(node: &WorkNode) -> String {
2933 let Some(binding) = node.binding.as_ref() else {
2934 return "Not bound".to_string();
2935 };
2936 let mut text = format!(
2937 "Owner: {}\nDurable: {}",
2938 binding.external,
2939 if binding.durable { "yes" } else { "no" }
2940 );
2941 if let Some(observation) = binding.last_observation.as_ref() {
2942 let owner_state = match observation.owner_state {
2943 OwnerState::Initializing => "initializing",
2944 OwnerState::Running => "running",
2945 OwnerState::Waiting => "waiting",
2946 OwnerState::Completed => "completed",
2947 OwnerState::Degraded => "degraded (finished with dropped slots)",
2948 OwnerState::Failed => "failed",
2949 OwnerState::Cancelled => "cancelled",
2950 };
2951 let _ = write!(
2952 text,
2953 "\nLast owner state: {owner_state}\nLast reconcile: {} ms UTC · sequence {}",
2954 observation.observed_at, observation.seq
2955 );
2956 } else {
2957 text.push_str("\nLast reconcile: never");
2958 }
2959 text
2960 }
2961
2962 fn evidence_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2963 let acceptance = if node.acceptance.is_empty() {
2964 vec!["- No evidence requirement".to_string()]
2965 } else {
2966 node.acceptance
2967 .iter()
2968 .map(|requirement| format!("- {}", acceptance_label(requirement)))
2969 .collect()
2970 };
2971 let evidence = evidence_for(snapshot, node);
2972 let evidence = if evidence.is_empty() {
2973 vec!["- None attached".to_string()]
2974 } else {
2975 evidence
2976 .into_iter()
2977 .map(|evidence| {
2978 let reference = evidence
2979 .evidence
2980 .as_ref()
2981 .map(|item| item.reference())
2982 .unwrap_or("invalid evidence node");
2983 format!("- {reference} · {}", state_label(evidence))
2984 })
2985 .collect()
2986 };
2987 format!(
2988 "Acceptance:\n{}\nEvidence:\n{}",
2989 acceptance.join("\n"),
2990 evidence.join("\n")
2991 )
2992 }
2993
2994 fn acceptance_label(requirement: &AcceptanceRequirement) -> String {
2995 match requirement {
2996 AcceptanceRequirement::EvidenceOfKind { kind } => {
2997 let kind = match kind {
2998 EvidenceKindTag::ToolRun => "tool run",
2999 EvidenceKindTag::Artifact => "artifact",
3000 EvidenceKindTag::TestSummary => "test summary",
3001 EvidenceKindTag::Receipt => "receipt",
3002 EvidenceKindTag::Approval => "approval",
3003 EvidenceKindTag::Route => "route",
3004 EvidenceKindTag::WebCitation => "web citation",
3005 };
3006 format!("evidence of kind {kind}")
3007 }
3008 }
3009 }
3010
3011 fn evidence_for<'a>(snapshot: &'a WorkGraphSnapshot, node: &WorkNode) -> Vec<&'a WorkNode> {
3012 snapshot
3013 .edges
3014 .iter()
3015 .filter(|edge| edge.kind == EdgeKind::Verifies && edge.to == node.id)
3016 .filter_map(|edge| snapshot.node(&edge.from))
3017 .collect()
3018 }
3019
3020 fn blockers_approvals_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
3021 let mut lines = Vec::new();
3022 lines.extend(
3023 related_nodes(snapshot, node, EdgeKind::Blocks, false)
3024 .into_iter()
3025 .map(|item| format!("- Blocked by {item}")),
3026 );
3027 lines.extend(
3028 related_nodes(snapshot, node, EdgeKind::RequiresApproval, true)
3029 .into_iter()
3030 .map(|item| format!("- Approval {item}")),
3031 );
3032 if node.kind == NodeKind::PlanStep {
3033 lines.extend(
3034 snapshot
3035 .nodes
3036 .iter()
3037 .filter(|candidate| candidate.kind == NodeKind::Approval)
3038 .map(|approval| format!("- {} · {}", approval.title, state_label(approval))),
3039 );
3040 }
3041 if lines.is_empty() {
3042 "None".to_string()
3043 } else {
3044 lines.join("\n")
3045 }
3046 }
3047
3048 fn why_next(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
3049 match node.state {
3050 NodeState::Ready => {
3051 let pending = related_nodes(snapshot, node, EdgeKind::DependsOn, true);
3052 if pending.is_empty() {
3053 "Ready with no recorded prerequisite".to_string()
3054 } else {
3055 format!("Ready after: {}", pending.join(", "))
3056 }
3057 }
3058 NodeState::Initializing => "Spawn intent is registered; awaiting owner handle".to_string(),
3059 NodeState::Active => "Lifecycle owner reports active work".to_string(),
3060 NodeState::Waiting => "Waiting on an owner or approval".to_string(),
3061 NodeState::Blocked => "Blocked; resolve the causes above".to_string(),
3062 NodeState::Completed if !node.acceptance.is_empty() => {
3063 "Execution ended, but acceptance evidence is still missing".to_string()
3064 }
3065 NodeState::Stale => "Owner cannot confirm liveness after reconciliation".to_string(),
3066 NodeState::Verified => "Acceptance evidence is satisfied".to_string(),
3067 NodeState::Completed => "Completed with no evidence requirement".to_string(),
3068 NodeState::Superseded => "A replacement node owns this work".to_string(),
3069 NodeState::Cancelled => "Cancelled by lifecycle owner".to_string(),
3070 NodeState::Failed => "Failed; inspect owner output before retrying".to_string(),
3071 }
3072 }
3073
3074 fn provenance_text(node: &WorkNode) -> String {
3075 let provenance = match &node.provenance {
3076 Provenance::Import { ordinal, .. } => ordinal
3077 .map(|ordinal| format!("legacy import · ordinal {ordinal}"))
3078 .unwrap_or_else(|| "legacy import".to_string()),
3079 Provenance::ToolUpdate { tool, call_id } => {
3080 format!("tool {tool} · call {call_id}")
3081 }
3082 Provenance::RuntimeReconcile {
3083 source,
3084 observed_at,
3085 } => format!("runtime {source} · {observed_at} ms UTC"),
3086 Provenance::UserEdit { proposal_id } => format!("user-approved diff {proposal_id}"),
3087 };
3088 let reconcile = node
3089 .binding
3090 .as_ref()
3091 .and_then(|binding| binding.last_observation.as_ref())
3092 .map(|observation| format!("{} ms UTC", observation.observed_at))
3093 .unwrap_or_else(|| "never".to_string());
3094 format!("Source: {provenance}\nLast reconcile: {reconcile}")
3095 }
3096
3097 fn last_output_ref(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> Option<String> {
3098 node.binding
3099 .as_ref()
3100 .and_then(|binding| binding.last_observation.as_ref())
3101 .and_then(|observation| observation.output.as_ref())
3102 .map(format_evidence_ref)
3103 .or_else(|| {
3104 evidence_for(snapshot, node)
3105 .into_iter()
3106 .max_by_key(|evidence| evidence.updated_at)
3107 .and_then(|evidence| evidence.evidence.as_ref())
3108 .map(format_evidence_ref)
3109 })
3110 }
3111
3112 fn format_evidence_ref(evidence: &crate::work_graph::EvidenceRef) -> String {
3113 let kind = match evidence.kind() {
3114 EvidenceKind::ToolRun => "tool run".to_string(),
3115 EvidenceKind::Artifact { .. } => "artifact".to_string(),
3116 EvidenceKind::TestSummary => "test summary".to_string(),
3117 EvidenceKind::Receipt { .. } => "receipt".to_string(),
3118 EvidenceKind::Approval => "approval".to_string(),
3119 EvidenceKind::Route => "route".to_string(),
3120 EvidenceKind::WebCitation {
3121 url, retrieved_at, ..
3122 } => format!("web citation · {url} · retrieved {retrieved_at}"),
3123 };
3124 let bytes = evidence
3125 .raw_bytes()
3126 .map(|bytes| format!(" · {bytes} raw bytes"))
3127 .unwrap_or_default();
3128 let truncation = if evidence.truncated() {
3129 " · truncated"
3130 } else {
3131 ""
3132 };
3133 format!("{} · {kind}{bytes}{truncation}", evidence.reference())
3134 }
3135
3136 fn section_text(out: &mut String, title: &str, body: &str) {
3137 let _ = writeln!(out, "{title}\n{body}\n");
3138 }
3139
3140 fn section_list(out: &mut String, title: &str, items: Vec<String>) {
3141 if items.is_empty() {
3142 section_text(out, title, "None");
3143 } else {
3144 section_text(
3145 out,
3146 title,
3147 &items
3148 .into_iter()
3149 .map(|item| format!("- {item}"))
3150 .collect::<Vec<_>>()
3151 .join("\n"),
3152 );
3153 }
3154 }
3155
3156 #[cfg(test)]
3157 mod tests {
3158 use super::*;
3159 use crate::config::Config;
3160 use crate::tools::spec::ToolResult;
3161
3162 /// #6565: a fact is dropped from a dock row only when the detail names
3163 /// that exact fact, never a longer one that contains it.
3164 #[test]
3165 fn a_fact_is_said_only_by_its_whole_name() {
3166 assert!(names_whole("step 6: finished tool 'read_file'", "step 6"));
3167 assert!(names_whole(
3168 "step 6: finished tool 'read_file'",
3169 "read_file"
3170 ));
3171 assert!(!names_whole("step 10: finished tool 'read_file'", "step 1"));
3172 assert!(!names_whole("finished tool 'read_file'", "read"));
3173 assert!(names_whole("read then step 1", "step 1"));
3174 assert!(!names_whole("anything", ""));
3175 }
3176 use crate::tui::app::TuiOptions;
3177 use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started};
3178 use crate::work_graph::{CompatTodoBinding, OperationBinding, WorkNodeId};
3179
3180 fn test_app() -> App {
3181 App::new(
3182 TuiOptions {
3183 model: "deepseek-v4-flash".to_string(),
3184 start_in_agent_mode: true,
3185 ..crate::test_support::test_tui_options(std::path::PathBuf::from(
3186 "/workspace/project",
3187 ))
3188 },
3189 &Config::default(),
3190 )
3191 }
3192
3193 fn surface() -> WorkSurfaceState {
3194 WorkSurfaceState::default()
3195 }
3196
3197 fn operation(state: NodeState, suffix: &str) -> WorkNode {
3198 WorkNode {
3199 id: WorkNodeId::derive("work-surface-test", suffix),
3200 kind: NodeKind::Operation,
3201 title: format!("operation {suffix}"),
3202 state,
3203 acceptance: Vec::new(),
3204 binding: Some(OperationBinding {
3205 external: format!("shell:{suffix}"),
3206 durable: false,
3207 last_observation: None,
3208 }),
3209 evidence: None,
3210 provenance: Provenance::ToolUpdate {
3211 tool: "test".to_string(),
3212 call_id: suffix.to_string(),
3213 },
3214 created_at: 1,
3215 updated_at: 1,
3216 }
3217 }
3218
3219 fn running_agent(agent_id: &str) -> SubAgentResult {
3220 SubAgentResult {
3221 usage: None,
3222 name: agent_id.to_string(),
3223 agent_id: agent_id.to_string(),
3224 context_mode: "fresh".to_string(),
3225 fork_context: false,
3226 workspace: None,
3227 git_branch: None,
3228 agent_type: crate::tools::subagent::FleetRole::Worker,
3229 assignment: crate::tools::subagent::SubAgentAssignment {
3230 native_preset: None,
3231 objective: "sweep the lane".to_string(),
3232 role: Some("builder".to_string()),
3233 },
3234 model: "test-model".to_string(),
3235 nickname: Some(crate::tools::subagent::whale_name_for_id_in_locale(
3236 agent_id, "en",
3237 )),
3238 status: SubAgentStatus::Running,
3239 worker_status: None,
3240 runtime_permissions: None,
3241 parent_run_id: None,
3242 spawn_depth: 0,
3243 child_route: None,
3244 result: None,
3245 steps_taken: 1,
3246 checkpoint: None,
3247 needs_input: None,
3248 duration_ms: 100,
3249 started_at: None,
3250 from_prior_session: false,
3251 idle_ms: None,
3252 heartbeat_timeout_ms: None,
3253 }
3254 }
3255
3256 fn retained_agent_receipt(
3257 id: &str,
3258 status: AgentWorkerStatus,
3259 state: crate::agent_roster::RosterState,
3260 ) -> crate::agent_roster::AgentRosterRow {
3261 crate::agent_roster::AgentRosterRow {
3262 worker_id: id.to_string(),
3263 display_name: format!("retained {id}"),
3264 model: "test-model".to_string(),
3265 state,
3266 status,
3267 activity: None,
3268 outcome: None,
3269 millis: Some(3_000),
3270 input_tokens: None,
3271 output_tokens: None,
3272 cost_microusd: None,
3273 steps_taken: 3,
3274 parent_run_id: None,
3275 run_id: id.to_string(),
3276 }
3277 }
3278
3279 /// The register merges the retained roster with the live cache; the
3280 /// counter that opens the dock has to read the same merged view, or a
3281 /// child blocked on a person goes unseen the moment its 45-second card
3282 /// expires (or a session restore filters it out of `subagent_cache`).
3283 #[test]
3284 fn a_roster_only_waiting_worker_opens_the_dock() {
3285 use crate::agent_roster::RosterState;
3286
3287 let mut app = test_app();
3288 app.current_session_id = Some("roster-owner".to_string());
3289 app.agent_roster_session_id = app.current_session_id.clone();
3290 assert!(app.subagent_cache.is_empty());
3291 app.agent_roster = vec![retained_agent_receipt(
3292 "blocked",
3293 AgentWorkerStatus::WaitingForUser,
3294 RosterState::Waiting,
3295 )];
3296
3297 assert_eq!(live_agent_row_count(&mut app), 1);
3298 assert!(view_has_work(&mut app, RailPanel::Agents));
3299 }
3300
3301 /// The other half of reading the roster: it is retained for an hour, so a
3302 /// settled receipt must not pin the dock open for the rest of the session.
3303 /// A parked husk counts as settled too — nothing will answer it (#5906).
3304 #[test]
3305 fn retained_settled_receipts_do_not_hold_the_dock_open() {
3306 use crate::agent_roster::RosterState;
3307
3308 let mut app = test_app();
3309 app.current_session_id = Some("roster-owner".to_string());
3310 app.agent_roster_session_id = app.current_session_id.clone();
3311 app.agent_roster = vec![
3312 retained_agent_receipt("done", AgentWorkerStatus::Completed, RosterState::Done),
3313 retained_agent_receipt("failed", AgentWorkerStatus::Failed, RosterState::Failed),
3314 retained_agent_receipt(
3315 "cancelled",
3316 AgentWorkerStatus::Cancelled,
3317 RosterState::Cancelled,
3318 ),
3319 retained_agent_receipt(
3320 "parked",
3321 AgentWorkerStatus::WaitingForUser,
3322 RosterState::Parked,
3323 ),
3324 ];
3325
3326 // Every receipt is still readable in the register...
3327 assert_eq!(
3328 agents_view_rows(&mut app)
3329 .iter()
3330 .filter(|row| row.agent.is_some())
3331 .count(),
3332 4
3333 );
3334 // ...and none of them re-opens the dock.
3335 assert_eq!(live_agent_row_count(&mut app), 0);
3336 assert!(!view_has_work(&mut app, RailPanel::Agents));
3337 }
3338
3339 /// #36 nesting is a fact about the *live* projection: `order_agent_seeds`
3340 /// indents a child only when its parent is on the same surface, and
3341 /// `(+N)` counts only children the list actually shows.
3342 #[test]
3343 fn nesting_survives_while_both_rows_are_live() {
3344 let mut app = test_app();
3345 app.current_session_id = Some("roster-owner".to_string());
3346 app.agent_roster_session_id = app.current_session_id.clone();
3347 let mut child = running_agent("child");
3348 child.parent_run_id = Some("parent".to_string());
3349 app.subagent_cache = vec![running_agent("parent"), child];
3350
3351 let rows = agents_view_rows(&mut app);
3352 let label = |id: &str| {
3353 rows.iter()
3354 .find(|row| row.id.0 == format!("worker:{id}"))
3355 .unwrap_or_else(|| panic!("{id} row: {rows:?}"))
3356 .label
3357 .clone()
3358 };
3359 assert!(label("child").starts_with("↳ "), "{:?}", label("child"));
3360 assert!(label("parent").contains("(+1)"), "{:?}", label("parent"));
3361 }
3362
3363 /// Deliberate, and asserted so it is not "fixed" into a lie: a receipt
3364 /// that has outlived its live card renders FLAT, with no `↳ ` and no
3365 /// `(+N)`, even when the roster still knows its `parent_run_id`.
3366 ///
3367 /// The register is ordered by the roster (creation order, parked last),
3368 /// not by the tree, so an indent here would be an adjacency claim the
3369 /// list cannot keep — exactly the dangling indent `order_agent_seeds`
3370 /// refuses to draw. Deriving depth here instead would also put a second
3371 /// depth authority on the same list. Nesting degrades to flat, and it
3372 /// degrades symmetrically: see the sibling test below.
3373 #[test]
3374 fn a_retained_child_renders_flat_under_a_retained_parent() {
3375 use crate::agent_roster::RosterState;
3376
3377 let mut app = test_app();
3378 app.current_session_id = Some("roster-owner".to_string());
3379 app.agent_roster_session_id = app.current_session_id.clone();
3380 let mut child =
3381 retained_agent_receipt("child", AgentWorkerStatus::Completed, RosterState::Done);
3382 child.parent_run_id = Some("parent".to_string());
3383 app.agent_roster = vec![
3384 retained_agent_receipt("parent", AgentWorkerStatus::Completed, RosterState::Done),
3385 child,
3386 ];
3387
3388 let rows = agents_view_rows(&mut app);
3389 for id in ["parent", "child"] {
3390 let label = rows
3391 .iter()
3392 .find(|row| row.id.0 == format!("worker:{id}"))
3393 .unwrap_or_else(|| panic!("{id} row: {rows:?}"))
3394 .label
3395 .clone();
3396 assert!(!label.contains('↳'), "{id}: {label:?}");
3397 assert!(!label.contains("(+"), "{id}: {label:?}");
3398 }
3399 }
3400
3401 /// The other direction, which is what makes the flattening honest rather
3402 /// than half-applied: when the PARENT has expired to a receipt and only
3403 /// the child is still live, the live child flattens too — its parent is
3404 /// not among the cache seeds, so `order_agent_seeds` leaves it at the top
3405 /// level. Nesting is present for a pair or absent for a pair; it is never
3406 /// stamped on one half of one.
3407 #[test]
3408 fn a_live_child_flattens_once_its_parent_has_expired_to_a_receipt() {
3409 use crate::agent_roster::RosterState;
3410
3411 let mut app = test_app();
3412 app.current_session_id = Some("roster-owner".to_string());
3413 app.agent_roster_session_id = app.current_session_id.clone();
3414 let mut child = running_agent("child");
3415 child.parent_run_id = Some("parent".to_string());
3416 app.subagent_cache = vec![child];
3417 let mut retained_child =
3418 retained_agent_receipt("child", AgentWorkerStatus::Running, RosterState::Running);
3419 retained_child.parent_run_id = Some("parent".to_string());
3420 app.agent_roster = vec![
3421 retained_agent_receipt("parent", AgentWorkerStatus::Completed, RosterState::Done),
3422 retained_child,
3423 ];
3424
3425 let rows = agents_view_rows(&mut app);
3426 let child_label = rows
3427 .iter()
3428 .find(|row| row.id.0 == "worker:child")
3429 .unwrap_or_else(|| panic!("child row: {rows:?}"))
3430 .label
3431 .clone();
3432 assert!(!child_label.contains('↳'), "{child_label:?}");
3433 let parent_label = rows
3434 .iter()
3435 .find(|row| row.id.0 == "worker:parent")
3436 .unwrap_or_else(|| panic!("parent row: {rows:?}"))
3437 .label
3438 .clone();
3439 assert!(!parent_label.contains("(+"), "{parent_label:?}");
3440 }
3441
3442 #[test]
3443 fn agents_register_retains_expired_receipts_alongside_fresh_live_rows() {
3444 use crate::agent_roster::RosterState;
3445 use crate::tui::subagent_routing::reconcile_subagent_activity_state_at;
3446 use std::time::Duration;
3447
3448 let mut app = test_app();
3449 app.current_session_id = Some("roster-owner".to_string());
3450 app.agent_roster_session_id = app.current_session_id.clone();
3451 let mut done = running_agent("done");
3452 done.status = SubAgentStatus::Completed;
3453 app.subagent_cache = vec![done, running_agent("live")];
3454 let receipt =
3455 retained_agent_receipt("done", AgentWorkerStatus::Completed, RosterState::Done);
3456 app.agent_roster = vec![
3457 receipt.clone(),
3458 receipt,
3459 // A newer live cache row must win over an older retained receipt.
3460 retained_agent_receipt("live", AgentWorkerStatus::Completed, RosterState::Done),
3461 ];
3462 app.agent_progress
3463 .insert("done".to_string(), "old progress".to_string());
3464 let observed = Instant::now();
3465 reconcile_subagent_activity_state_at(&mut app, observed);
3466 reconcile_subagent_activity_state_at(&mut app, observed + Duration::from_secs(46));
3467 assert!(
3468 !app.subagent_cache
3469 .iter()
3470 .any(|agent| agent.agent_id == "done")
3471 );
3472 assert!(!app.agent_progress.contains_key("done"));
3473
3474 let rows = agents_view_rows(&mut app);
3475 let workers: Vec<_> = rows.iter().filter(|row| row.agent.is_some()).collect();
3476 assert_eq!(workers.len(), 2, "one row per retained/live worker ID");
3477 let done = workers
3478 .iter()
3479 .find(|row| row.id.0 == "worker:done")
3480 .unwrap();
3481 assert_eq!(done.agent.as_ref().unwrap().status, "completed");
3482 assert_eq!(done.agent.as_ref().unwrap().elapsed_secs, Some(3));
3483 assert_eq!(done.agent.as_ref().unwrap().tokens, None);
3484 let live = workers
3485 .iter()
3486 .find(|row| row.id.0 == "worker:live")
3487 .unwrap();
3488 assert_eq!(live.agent.as_ref().unwrap().status, "running");
3489
3490 app.subagent_cache[0].status = SubAgentStatus::Completed;
3491 reconcile_subagent_activity_state_at(&mut app, observed + Duration::from_secs(47));
3492 reconcile_subagent_activity_state_at(&mut app, observed + Duration::from_secs(93));
3493 assert!(app.subagent_cache.is_empty());
3494 assert!(app.agent_progress.is_empty());
3495 let rows = agents_view_rows(&mut app);
3496 assert_eq!(rows.iter().filter(|row| row.agent.is_some()).count(), 2);
3497 }
3498
3499 #[test]
3500 fn retained_agents_register_is_bound_to_the_exact_parent_session() {
3501 use crate::agent_roster::RosterState;
3502
3503 let mut app = test_app();
3504 app.agent_roster_session_id = Some("original-session".to_string());
3505 app.agent_roster = vec![retained_agent_receipt(
3506 "private-worker",
3507 AgentWorkerStatus::Completed,
3508 RosterState::Done,
3509 )];
3510 app.agent_roster[0].cost_microusd = Some(1_000);
3511 for owner in [None, Some(""), Some("other-session")] {
3512 app.current_session_id = owner.map(str::to_string);
3513 assert!(app.current_agent_roster().is_empty());
3514 assert!(
3515 !agents_view_rows(&mut app)
3516 .iter()
3517 .any(|row| row.agent.is_some())
3518 );
3519 assert!(
3520 !super::super::views::price_rows(&mut app)
3521 .iter()
3522 .any(|row| row.id.0.starts_with("price:agent:"))
3523 );
3524 }
3525 app.current_session_id = Some("original-session".to_string());
3526 assert_eq!(app.current_agent_roster().len(), 1);
3527 assert_eq!(
3528 agents_view_rows(&mut app)
3529 .iter()
3530 .filter(|row| row.agent.is_some())
3531 .count(),
3532 1
3533 );
3534 }
3535
3536 #[test]
3537 fn retained_agents_register_distinguishes_parked_from_waiting_for_input() {
3538 use crate::agent_roster::RosterState;
3539
3540 let mut app = test_app();
3541 app.current_session_id = Some("roster-owner".to_string());
3542 app.agent_roster_session_id = app.current_session_id.clone();
3543 app.agent_roster = vec![
3544 retained_agent_receipt(
3545 "parked",
3546 AgentWorkerStatus::WaitingForUser,
3547 RosterState::Parked,
3548 ),
3549 retained_agent_receipt(
3550 "asked",
3551 AgentWorkerStatus::WaitingForUser,
3552 RosterState::Waiting,
3553 ),
3554 ];
3555 let rows = agents_view_rows(&mut app);
3556 let parked = rows.iter().find(|row| row.id.0 == "worker:parked").unwrap();
3557 let asked = rows.iter().find(|row| row.id.0 == "worker:asked").unwrap();
3558 assert_eq!(parked.mark, RosterState::Parked.glyph());
3559 assert_eq!(parked.tone, WorkTone::Muted);
3560 assert_ne!(parked.agent.as_ref().unwrap().status, "waiting for input");
3561 assert_eq!(asked.agent.as_ref().unwrap().status, "waiting for input");
3562 assert_eq!(asked.tone, WorkTone::Attention);
3563 assert!(
3564 matches!(&parked.primary_action, Some(SidebarRowAction::OpenAgentTranscript { agent_id }) if agent_id == "parked")
3565 );
3566 }
3567
3568 /// #5287: the identity column leads with the name the lane was dispatched
3569 /// under; the whale only names an agent that was dispatched without one.
3570 #[test]
3571 fn agent_identity_column_leads_with_the_dispatch_name() {
3572 let mut app = test_app();
3573 let mut named = running_agent("agent_named_lane");
3574 named.name = "branch-triage".to_string();
3575 app.subagent_cache.push(named);
3576 app.subagent_cache.push(running_agent("agent_plain_lane"));
3577
3578 let rows = agent_rows(&app);
3579 let label = |id: &str| {
3580 rows.iter()
3581 .find(|ranked| ranked.row.id.0 == format!("worker:{id}"))
3582 .unwrap_or_else(|| panic!("row for {id}"))
3583 .row
3584 .label
3585 .clone()
3586 };
3587 assert_eq!(label("agent_named_lane"), "branch-triage");
3588 assert_eq!(
3589 label("agent_plain_lane"),
3590 crate::tools::subagent::whale_name_for_id_in_locale("agent_plain_lane", "en")
3591 );
3592 }
3593
3594 #[test]
3595 fn agent_identity_column_prefers_resolved_profile_over_generated_whale() {
3596 let mut app = test_app();
3597 let mut agent = running_agent("agent_flash_lane");
3598 agent.child_route = Some(crate::tools::subagent::ChildRouteReceipt {
3599 requested_type: "custom".to_string(),
3600 requested_profile: Some("DeepSeek V4 Flash".to_string()),
3601 resolved_profile_id: Some("flash-scout".to_string()),
3602 profile_origin: Some("fleet:release".to_string()),
3603 canonical_role: "scout".to_string(),
3604 provider_id: "deepseek".to_string(),
3605 model_id: "deepseek-v4-flash-vision-exp".to_string(),
3606 route_source: "fleet".to_string(),
3607 fallback_note: None,
3608 requested_reasoning: "inherit".to_string(),
3609 effective_reasoning: None,
3610 runtime_version: "test".to_string(),
3611 runtime_build_sha: "unknown".to_string(),
3612 });
3613 app.subagent_cache.push(agent);
3614
3615 let row = agent_rows(&app)
3616 .into_iter()
3617 .find(|ranked| ranked.row.id.0 == "worker:agent_flash_lane")
3618 .expect("resolved Fleet row")
3619 .row;
3620 assert_eq!(row.label, "flash-scout");
3621 assert_eq!(
3622 row.agent.as_ref().map(|facts| facts.role_label.as_str()),
3623 Some("flash-scout")
3624 );
3625 }
3626
3627 /// After the recent-only TTL suppresses transient receipts, the live
3628 /// heading must count only the recent rows the live list still shows —
3629 /// quoting the unfiltered total would claim receipts the reader cannot
3630 /// see (2026-08-04 adversarial review of the durable-row exemption).
3631 #[test]
3632 fn suppressed_transients_leave_the_live_heading_count_honest() {
3633 let mut plan_step = operation(NodeState::Completed, "shipped-step");
3634 plan_step.kind = NodeKind::PlanStep;
3635 plan_step.binding = None;
3636 let mut transient = operation(NodeState::Completed, "settled-op");
3637 transient.binding.as_mut().expect("binding").durable = true;
3638 let mut snapshot = WorkGraphSnapshot::new();
3639 snapshot.compat.todos.push(CompatTodoBinding {
3640 legacy_id: 1,
3641 node: plan_step.id.clone(),
3642 plan_index: None,
3643 });
3644 snapshot.nodes = vec![plan_step, transient];
3645
3646 let mut surface = surface();
3647 surface.set_presentation_now_ms(0);
3648 let _ = graph_rows(
3649 &mut surface,
3650 &snapshot,
3651 None,
3652 Vec::new(),
3653 None,
3654 SettledFileActivity::default(),
3655 );
3656 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 1);
3657 let rows = graph_rows(
3658 &mut surface,
3659 &snapshot,
3660 None,
3661 Vec::new(),
3662 None,
3663 SettledFileActivity::default(),
3664 );
3665 let heading = &rows[0];
3666 assert!(
3667 heading.label.contains("1 recent"),
3668 "live heading counts only the surviving durable row: {}",
3669 heading.label
3670 );
3671 assert!(
3672 rows.iter().any(|row| row.label.contains("shipped-step")),
3673 "durable to-do row survives: {rows:?}"
3674 );
3675 assert!(
3676 !rows.iter().any(|row| row.label.contains("settled-op")),
3677 "transient receipt is suppressed: {rows:?}"
3678 );
3679 // The catalog keeps the full rows, so it keeps the full count.
3680 assert!(
3681 surface
3682 .catalog_rows
3683 .first()
3684 .is_some_and(|row| row.label.contains("2 recent")),
3685 "catalog heading keeps the unfiltered count: {:?}",
3686 surface.catalog_rows.first()
3687 );
3688 }
3689
3690 #[test]
3691 fn heading_counts_initializing_and_active_operations_as_running() {
3692 let mut snapshot = WorkGraphSnapshot::new();
3693 snapshot.nodes = vec![
3694 operation(NodeState::Initializing, "initializing"),
3695 operation(NodeState::Active, "active"),
3696 operation(NodeState::Ready, "ready"),
3697 ];
3698
3699 let rows = graph_rows(
3700 &mut surface(),
3701 &snapshot,
3702 None,
3703 Vec::new(),
3704 None,
3705 SettledFileActivity::default(),
3706 );
3707
3708 assert_eq!(
3709 rows.first().map(|row| row.label.as_str()),
3710 Some("Work · Running: operation initializing · 2 active")
3711 );
3712 }
3713
3714 #[test]
3715 fn live_projection_hides_clean_transient_receipts_without_duplicate_todo_group() {
3716 let todo_id = WorkNodeId::derive("work-surface-test", "todo:1");
3717 let todo = WorkNode {
3718 id: todo_id.clone(),
3719 kind: NodeKind::PlanStep,
3720 title: "Keep the durable checklist visible".to_string(),
3721 state: NodeState::Ready,
3722 acceptance: Vec::new(),
3723 binding: None,
3724 evidence: None,
3725 provenance: Provenance::ToolUpdate {
3726 tool: "work_update".to_string(),
3727 call_id: "todo-1".to_string(),
3728 },
3729 created_at: 1,
3730 updated_at: 1,
3731 };
3732 let mut snapshot = WorkGraphSnapshot::new();
3733 snapshot.nodes = vec![
3734 operation(NodeState::Completed, "settled"),
3735 operation(NodeState::Active, "running"),
3736 todo,
3737 ];
3738 snapshot.compat.todos.push(CompatTodoBinding {
3739 legacy_id: 1,
3740 node: todo_id,
3741 plan_index: None,
3742 });
3743
3744 let rows = graph_rows(
3745 &mut surface(),
3746 &snapshot,
3747 None,
3748 Vec::new(),
3749 None,
3750 SettledFileActivity::default(),
3751 );
3752 let labels = rows
3753 .iter()
3754 .map(|row| row.label.as_str())
3755 .collect::<Vec<_>>();
3756
3757 assert!(labels.contains(&"operation running"), "{labels:?}");
3758 assert!(!labels.contains(&"operation settled"), "{labels:?}");
3759 assert_eq!(
3760 labels
3761 .iter()
3762 .filter(|label| **label == "Keep the durable checklist visible")
3763 .count(),
3764 1,
3765 "one plan node must produce one Work row: {labels:?}"
3766 );
3767 assert!(
3768 !labels.iter().any(|label| label.starts_with("To-do")),
3769 "the ordered Work projection must not add a duplicate To-do heading: {labels:?}"
3770 );
3771 assert!(
3772 labels.contains(&"Keep the durable checklist visible"),
3773 "{labels:?}"
3774 );
3775 assert!(
3776 snapshot
3777 .nodes
3778 .iter()
3779 .any(|node| node.title == "operation settled"),
3780 "projection filtering must retain the historical graph receipt"
3781 );
3782 }
3783
3784 /// #6546: clearing the To-do list (an empty `todo_write`) drops the
3785 /// compat bindings but keeps the old plan-step nodes in the graph. The
3786 /// rail must follow the projection, not every plan step ever written.
3787 #[tokio::test]
3788 async fn cleared_todo_list_leaves_no_plan_step_rows() {
3789 use crate::tools::todo::{TodoList, TodoStatus};
3790
3791 let todos = crate::tools::todo::new_shared_todo_list();
3792 let plan = crate::tools::plan::new_shared_plan_state();
3793 let work = crate::work_graph::new_shared_work_runtime(todos.clone(), plan);
3794 let mut written = TodoList::new();
3795 written.add("stale finished item".to_string(), TodoStatus::Completed);
3796 work.apply_todo_update("session-6546", "todo_write", &written.snapshot())
3797 .await
3798 .expect("write todo");
3799 work.publish_pending().await.expect("publish write");
3800 let rows_for = |graph: &WorkGraphSnapshot| {
3801 graph_rows(
3802 &mut surface(),
3803 graph,
3804 None,
3805 Vec::new(),
3806 None,
3807 SettledFileActivity::default(),
3808 )
3809 .into_iter()
3810 .map(|row| row.label)
3811 .collect::<Vec<_>>()
3812 };
3813 let before = work
3814 .capture(Some("session-6546"))
3815 .expect("capture")
3816 .expect("graph");
3817 assert!(
3818 rows_for(&before.graph)
3819 .iter()
3820 .any(|label| label.contains("stale finished item")),
3821 "precondition: the written item renders"
3822 );
3823
3824 work.apply_todo_update("session-6546", "todo_write", &TodoList::new().snapshot())
3825 .await
3826 .expect("clear todos");
3827 work.publish_pending().await.expect("publish clear");
3828 let after = work
3829 .capture(Some("session-6546"))
3830 .expect("capture")
3831 .expect("graph keeps its history");
3832 assert!(after.todos.is_empty());
3833 assert!(
3834 after
3835 .graph
3836 .nodes
3837 .iter()
3838 .any(|node| node.title == "stale finished item"),
3839 "precondition: the retired node is still in the graph"
3840 );
3841 let labels = rows_for(&after.graph);
3842 assert!(
3843 !labels
3844 .iter()
3845 .any(|label| label.contains("stale finished item")),
3846 "cleared To-do item must leave the rail: {labels:?}"
3847 );
3848 }
3849
3850 #[test]
3851 fn projection_keeps_durable_and_evidence_gated_terminal_operations() {
3852 let mut durable = operation(NodeState::Completed, "durable");
3853 durable.binding.as_mut().expect("binding").durable = true;
3854 let mut failed = operation(NodeState::Failed, "failed");
3855 failed.binding.as_mut().expect("binding").durable = true;
3856 let mut evidence_pending = operation(NodeState::Completed, "evidence-pending");
3857 evidence_pending.acceptance = vec![AcceptanceRequirement::EvidenceOfKind {
3858 kind: EvidenceKindTag::ToolRun,
3859 }];
3860 let mut snapshot = WorkGraphSnapshot::new();
3861 snapshot.nodes = vec![durable, failed, evidence_pending];
3862
3863 let rows = graph_rows(
3864 &mut surface(),
3865 &snapshot,
3866 None,
3867 Vec::new(),
3868 None,
3869 SettledFileActivity::default(),
3870 );
3871 let labels = rows
3872 .iter()
3873 .map(|row| row.label.as_str())
3874 .collect::<Vec<_>>();
3875
3876 for expected in [
3877 "operation durable",
3878 "operation failed",
3879 "operation evidence-pending",
3880 ] {
3881 assert!(labels.contains(&expected), "missing {expected}: {labels:?}");
3882 }
3883 }
3884
3885 #[test]
3886 fn transient_failed_operation_is_recent_while_durable_failure_needs_input() {
3887 let transient = operation(NodeState::Failed, "shell transient");
3888 let mut durable = operation(NodeState::Failed, "durable");
3889 durable.binding.as_mut().expect("binding").durable = true;
3890
3891 assert_eq!(node_bucket(&transient), WorkBucket::Recent);
3892 assert_eq!(node_bucket(&durable), WorkBucket::Attention);
3893 }
3894
3895 #[test]
3896 fn projection_orders_attention_before_ready_and_recent() {
3897 let mut recent = operation(NodeState::Completed, "recent");
3898 recent.binding.as_mut().expect("binding").durable = true;
3899 let mut snapshot = WorkGraphSnapshot::new();
3900 snapshot.nodes = vec![
3901 recent,
3902 operation(NodeState::Ready, "ready"),
3903 operation(NodeState::Blocked, "blocked"),
3904 operation(NodeState::Active, "active"),
3905 ];
3906
3907 let labels = graph_rows(
3908 &mut surface(),
3909 &snapshot,
3910 None,
3911 Vec::new(),
3912 None,
3913 SettledFileActivity::default(),
3914 )
3915 .into_iter()
3916 .map(|row| row.label)
3917 .collect::<Vec<_>>();
3918
3919 assert_eq!(
3920 labels,
3921 [
3922 "Work · Needs input: operation blocked · 1 blocked",
3923 "operation blocked",
3924 "operation active",
3925 "operation ready",
3926 "operation recent",
3927 ]
3928 );
3929 }
3930
3931 #[test]
3932 fn activity_targets_keep_workspace_relative_paths_and_hide_external_paths() {
3933 let workspace = Path::new("/workspace/project");
3934 assert_eq!(
3935 privacy_safe_path(workspace, "/workspace/project/src/lib.rs").as_deref(),
3936 Some("src/lib.rs")
3937 );
3938 assert_eq!(
3939 privacy_safe_path(workspace, "/Users/alice/private.txt"),
3940 None
3941 );
3942 assert_eq!(privacy_safe_path(workspace, "../private.txt"), None);
3943 assert_eq!(safe_pattern("needle\nsecret"), "needle secret");
3944 }
3945
3946 #[test]
3947 fn settled_canonical_file_actions_keep_aggregates_and_safe_targets() {
3948 let mut app = test_app();
3949 let calls = [
3950 ("read", serde_json::json!({"path": "src/read.rs"})),
3951 ("list", serde_json::json!({"path": "src"})),
3952 ("search_name", serde_json::json!({"query": "lib.rs"})),
3953 (
3954 "search_content",
3955 serde_json::json!({"pattern": "needle\nprivate", "path": "src"}),
3956 ),
3957 (
3958 "write",
3959 serde_json::json!({"path": "src/new.rs", "content": "new\n"}),
3960 ),
3961 (
3962 "edit",
3963 serde_json::json!({
3964 "path": "src/edit.rs",
3965 "search": "old",
3966 "replace": "new"
3967 }),
3968 ),
3969 (
3970 "patch",
3971 serde_json::json!({
3972 "patch": "diff --git a/src/patch.rs b/src/patch.rs\n--- a/src/patch.rs\n+++ b/src/patch.rs\n@@ -1 +1 @@\n-old\n+new\n"
3973 }),
3974 ),
3975 ];
3976
3977 for (action, payload) in calls {
3978 let id = format!("file-{action}");
3979 let mut input = payload;
3980 input["action"] = serde_json::json!(action);
3981 handle_tool_call_started(&mut app, &id, "File", &input);
3982 handle_tool_call_complete(&mut app, &id, "File", &Ok(ToolResult::success("ok")));
3983 app.flush_active_cell();
3984 }
3985
3986 let activity = settled_file_activity(&app);
3987 assert_eq!(
3988 activity.summary,
3989 FileActivitySummary {
3990 files_read: 1,
3991 dirs_listed: 1,
3992 patterns_searched: 2,
3993 files_written: 3,
3994 }
3995 );
3996 assert_eq!(activity.read, ["src/read.rs"]);
3997 assert_eq!(activity.list, ["src"]);
3998 assert_eq!(activity.search, ["lib.rs", "needle private"]);
3999 assert_eq!(
4000 activity.write,
4001 ["src/new.rs", "src/edit.rs", "src/patch.rs"]
4002 );
4003 }
4004
4005 #[test]
4006 fn multifile_receipt_counts_semantic_file_outcomes_in_work_label() {
4007 let mut app = test_app();
4008 let input = serde_json::json!({
4009 "action": "patch",
4010 "patch": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n"
4011 });
4012 handle_tool_call_started(&mut app, "file-multi", "File", &input);
4013 let result = ToolResult::success("ok").with_metadata(serde_json::json!({
4014 "mutation": {
4015 "diff": "diff --git a/old.rs b/new.rs\nrename from old.rs\nrename to new.rs\n--- 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",
4016 "files": [
4017 { "path": "update.rs", "outcome": "updated" },
4018 { "path": "create.rs", "outcome": "created" },
4019 { "path": "delete.rs", "outcome": "deleted" }
4020 ],
4021 "renames": [{ "from": "old.rs", "to": "new.rs" }]
4022 }
4023 }));
4024 handle_tool_call_complete(&mut app, "file-multi", "File", &Ok(result));
4025 app.flush_active_cell();
4026
4027 let activity = settled_file_activity(&app);
4028 assert_eq!(activity.summary.files_written, 4);
4029 let write_row = activity_rows(activity)
4030 .into_iter()
4031 .find(|row| row.row.label.starts_with("Wrote"))
4032 .expect("write row");
4033 assert_eq!(write_row.row.label, "Wrote 4 files");
4034 assert_eq!(
4035 write_row.row.detail,
4036 "4 files · 1 created · 1 updated · 1 deleted · 1 renamed · +2 -2"
4037 );
4038 }
4039
4040 fn mutation_activity(mode: InlineDiffMode) -> SettledFileActivity {
4041 let result = ToolResult::success("ok").with_metadata(serde_json::json!({
4042 "mutation": {
4043 "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -1 +1 @@\n-old\n+new\n",
4044 "files": [{
4045 "path": "/Users/alice/private.rs",
4046 "outcome": "updated"
4047 }],
4048 "renames": []
4049 }
4050 }));
4051 let receipt = FileMutationReceipt::from_success(Path::new("/workspace/project"), &result)
4052 .expect("receipt");
4053 SettledFileActivity {
4054 summary: FileActivitySummary {
4055 files_written: 1,
4056 ..FileActivitySummary::default()
4057 },
4058 write: vec!["src/public.rs".to_string()],
4059 mutations: vec![receipt],
4060 inline_diff_mode: mode,
4061 ..SettledFileActivity::default()
4062 }
4063 }
4064
4065 fn mutation_activity_body(mode: InlineDiffMode) -> (String, String, String) {
4066 let row = activity_rows(mutation_activity(mode))
4067 .into_iter()
4068 .next()
4069 .expect("activity row")
4070 .row;
4071 let SidebarRowAction::InspectWork { body, .. } =
4072 row.primary_action.expect("inspect action")
4073 else {
4074 panic!("write row must open Work inspection")
4075 };
4076 (row.label, row.detail, body)
4077 }
4078
4079 #[test]
4080 fn work_mutation_rows_keep_labels_privacy_and_all_inline_modes() {
4081 let (label, detail, full) = mutation_activity_body(InlineDiffMode::Full);
4082 assert_eq!(label, "Wrote 1 files");
4083 assert_eq!(detail, "Updated <external file> · +1 -1");
4084 assert!(full.contains("-old"), "{full}");
4085 assert!(full.contains("+new"), "{full}");
4086 assert!(!full.contains("alice"), "{full}");
4087 assert!(full.contains("exact change evidence"), "{full}");
4088
4089 let (_, _, summary) = mutation_activity_body(InlineDiffMode::Summary);
4090 assert!(
4091 summary.contains("Updated <external file> · +1 -1"),
4092 "{summary}"
4093 );
4094 assert!(!summary.contains("-old"), "{summary}");
4095 assert!(!summary.contains("+new"), "{summary}");
4096 assert!(!summary.contains("alice"), "{summary}");
4097
4098 let (_, detail, off) = mutation_activity_body(InlineDiffMode::Off);
4099 assert_eq!(detail, "Updated <external file>");
4100 assert!(off.contains("Updated <external file>"), "{off}");
4101 assert!(!off.contains("+1 -1"), "{off}");
4102 assert!(!off.contains("-old"), "{off}");
4103 assert!(!off.contains("alice"), "{off}");
4104 assert!(off.contains("exact change evidence"), "{off}");
4105 }
4106
4107 #[test]
4108 fn recent_only_summary_expires_after_ttl_and_user_turn() {
4109 let mut recent = operation(NodeState::Completed, "recent");
4110 recent.binding.as_mut().expect("binding").durable = true;
4111 let mut snapshot = WorkGraphSnapshot::new();
4112 snapshot.nodes = vec![recent];
4113
4114 let mut surface = surface();
4115 surface.set_presentation_now_ms(0);
4116 let rows = graph_rows(
4117 &mut surface,
4118 &snapshot,
4119 None,
4120 Vec::new(),
4121 None,
4122 SettledFileActivity::default(),
4123 );
4124 assert!(
4125 rows.iter().any(|row| row.id.0.starts_with("section:")),
4126 "recent-only should surface briefly: {rows:?}"
4127 );
4128
4129 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS);
4130 let expired = graph_rows(
4131 &mut surface,
4132 &snapshot,
4133 None,
4134 Vec::new(),
4135 None,
4136 SettledFileActivity::default(),
4137 );
4138 assert!(
4139 expired.is_empty(),
4140 "recent-only must collapse after TTL: {expired:?}"
4141 );
4142 // Catalog retains durable history for inspector/history.
4143 assert!(
4144 surface
4145 .catalog_rows
4146 .iter()
4147 .any(|row| row.label == "operation recent"),
4148 "catalog must keep recent work after live expiry"
4149 );
4150
4151 // New completion fingerprint re-surfaces once.
4152 let mut newer = operation(NodeState::Completed, "newer");
4153 newer.binding.as_mut().expect("binding").durable = true;
4154 snapshot.nodes.push(newer);
4155 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 10);
4156 let resurfaced = graph_rows(
4157 &mut surface,
4158 &snapshot,
4159 None,
4160 Vec::new(),
4161 None,
4162 SettledFileActivity::default(),
4163 );
4164 assert!(
4165 !resurfaced.is_empty(),
4166 "a new completion may surface once after expiry"
4167 );
4168
4169 // User turn hides immediately while still recent-only.
4170 surface.note_user_turn_or_new_operation();
4171 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 11);
4172 let after_turn = graph_rows(
4173 &mut surface,
4174 &snapshot,
4175 None,
4176 Vec::new(),
4177 None,
4178 SettledFileActivity::default(),
4179 );
4180 assert!(
4181 after_turn.is_empty(),
4182 "user turn must hide recent-only immediately: {after_turn:?}"
4183 );
4184 }
4185
4186 #[test]
4187 fn needs_input_and_ready_never_expire_with_clock() {
4188 let mut snapshot = WorkGraphSnapshot::new();
4189 snapshot.nodes = vec![
4190 operation(NodeState::Blocked, "blocked"),
4191 operation(NodeState::Ready, "ready"),
4192 ];
4193 let mut surface = surface();
4194 surface.set_presentation_now_ms(0);
4195 let _ = graph_rows(
4196 &mut surface,
4197 &snapshot,
4198 None,
4199 Vec::new(),
4200 None,
4201 SettledFileActivity::default(),
4202 );
4203 surface.set_presentation_now_ms(60_000);
4204 let rows = graph_rows(
4205 &mut surface,
4206 &snapshot,
4207 None,
4208 Vec::new(),
4209 None,
4210 SettledFileActivity::default(),
4211 );
4212 assert!(
4213 rows[0].label.starts_with("Work · Needs input:"),
4214 "{}",
4215 rows[0].label
4216 );
4217 assert!(rows.iter().any(|row| row.label == "operation blocked"));
4218 assert!(rows.iter().any(|row| row.label == "operation ready"));
4219 }
4220
4221 #[test]
4222 fn files_keep_all_unique_paths_while_activity_summary_is_capped() {
4223 use super::super::views::{files_rows, files_touched_count};
4224 let mut app = test_app();
4225 for action in ["read", "write"] {
4226 // Repeat the thirteenth path under a new call ID to check uniqueness.
4227 for index in 0..14 {
4228 let id = format!("{action}-{index}");
4229 let path = format!("{action}-{:02}.rs", index.min(12));
4230 let input = serde_json::json!({"action": action, "path": path, "content": "new"});
4231 handle_tool_call_started(&mut app, &id, "File", &input);
4232 handle_tool_call_complete(&mut app, &id, "File", &Ok(ToolResult::success("ok")));
4233 app.flush_active_cell();
4234 }
4235 }
4236 app.work_surface.file_activity = settled_file_activity(&app);
4237 assert!(app.work_surface.file_activity.mutations.is_empty());
4238 assert_eq!(files_touched_count(&mut app), 26);
4239 let rows = files_rows(&mut app);
4240 assert!(rows.iter().any(|row| row.label == "Read 13"));
4241 assert!(rows.iter().any(|row| row.label == "Edited 13"));
4242 for action in ["read", "write"] {
4243 for index in 0..13 {
4244 let path = format!("{action}-{index:02}.rs");
4245 assert_eq!(
4246 rows.iter().filter(|row| row.label == path).count(),
4247 1,
4248 "{path}"
4249 );
4250 }
4251 }
4252 let summary = aggregate_activity_row(&app.work_surface.file_activity).unwrap();
4253 let Some(SidebarRowAction::InspectWork { body, .. }) = summary.row.primary_action else {
4254 panic!("activity summary opens its preview");
4255 };
4256 for action in ["read", "write"] {
4257 assert!(body.contains(&format!("{action}-11.rs")));
4258 assert!(!body.contains(&format!("{action}-12.rs")));
4259 }
4260 }
4261
4262 #[test]
4263 fn activity_receipts_aggregate_and_expire_without_raw_payloads() {
4264 let activity = SettledFileActivity {
4265 summary: FileActivitySummary {
4266 files_read: 1,
4267 patterns_searched: 2,
4268 files_written: 1,
4269 ..FileActivitySummary::default()
4270 },
4271 read: vec!["src/lib.rs".to_string()],
4272 search: vec!["(?i)super_secret_pattern_xyz".to_string()],
4273 write: vec!["src/main.rs".to_string()],
4274 ..SettledFileActivity::default()
4275 };
4276 let mut surface = surface();
4277 surface.set_presentation_now_ms(0);
4278 let rows = ordered_rows(&mut surface, None, None, Vec::new(), None, activity.clone());
4279 let activity_row = rows
4280 .iter()
4281 .find(|row| row.id.0 == "activity:aggregate")
4282 .expect("aggregate activity");
4283 assert!(activity_row.label.contains("Read 1 files"));
4284 assert!(activity_row.label.contains("Searched 2 patterns"));
4285 assert!(!activity_row.label.contains("super_secret_pattern_xyz"));
4286 assert!(!activity_row.detail.contains("super_secret_pattern_xyz"));
4287
4288 surface.set_presentation_now_ms(ACTIVITY_RECEIPT_TTL_MS);
4289 let expired = ordered_rows(&mut surface, None, None, Vec::new(), None, activity);
4290 assert!(
4291 expired.iter().all(|row| row.id.0 != "activity:aggregate"),
4292 "activity receipt must expire after TTL: {expired:?}"
4293 );
4294 }
4295
4296 #[test]
4297 fn summary_subject_prefers_attention_over_active_and_ready() {
4298 let mut snapshot = WorkGraphSnapshot::new();
4299 snapshot.nodes = vec![
4300 operation(NodeState::Active, "running"),
4301 operation(NodeState::Blocked, "choose a release target"),
4302 operation(NodeState::Ready, "review rebuilt binary"),
4303 ];
4304 let rows = graph_rows(
4305 &mut surface(),
4306 &snapshot,
4307 None,
4308 Vec::new(),
4309 None,
4310 SettledFileActivity::default(),
4311 );
4312 assert_eq!(
4313 rows[0].label,
4314 "Work · Needs input: operation choose a release target · 1 blocked"
4315 );
4316 }
4317
4318 fn running_shell_entry(id: &str, command: &str) -> crate::tui::app::TaskPanelEntry {
4319 crate::tui::app::TaskPanelEntry {
4320 exit_code: None,
4321 id: id.to_string(),
4322 status: "running".to_string(),
4323 prompt_summary: format!("shell: {command}"),
4324 duration_ms: Some(42_000),
4325 kind: crate::tui::app::TaskPanelEntryKind::Shell,
4326 stale: false,
4327 elapsed_since_output_ms: None,
4328 owner_agent_id: None,
4329 owner_agent_name: None,
4330 current_tool: None,
4331 role: None,
4332 files_touched: 0,
4333 }
4334 }
4335
4336 #[test]
4337 fn live_shells_are_first_class_background_rows() {
4338 let mut app = test_app();
4339 app.work_surface.placement = WorkSurfacePlacement::Top;
4340 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
4341 app.task_panel.push(running_shell_entry(
4342 "shell_a1b2c3d4",
4343 "cd /workspace/example-project",
4344 ));
4345 app.subagent_cache
4346 .push(running_agent("doc-scout-spec-arch"));
4347
4348 // Shells never crowd the agents or tasks views; they are the
4349 // background view, and the dock badges them there.
4350 let agents = visible_rows_for(&mut app, RailPanel::Agents);
4351 assert!(
4352 agents
4353 .iter()
4354 .any(|row| row.id.0 == "section:agents" && row.label.contains("Subagents")),
4355 "subagent visibility must not regress: {agents:?}"
4356 );
4357 assert!(!agents.iter().any(|row| row.id.0.starts_with("shell:")));
4358 let rows = visible_rows_for(&mut app, RailPanel::Background);
4359 let heading = rows
4360 .iter()
4361 .find(|row| row.id.0 == "section:shells")
4362 .expect("Shells group heading");
4363 assert_eq!(heading.label, "Shells 1");
4364 assert!(heading.selectable);
4365 let shell = rows
4366 .iter()
4367 .find(|row| row.id.0 == "shell:shell_a1b2c3d4")
4368 .expect("navigable shell row");
4369 // #6565: the command leads; the id stays in the detail.
4370 assert_eq!(shell.label, "cd /workspace/example-project");
4371 assert!(shell.detail.contains("shell_a1b2c3d4"), "{}", shell.detail);
4372 let Some(SidebarRowAction::InspectWork {
4373 title,
4374 body,
4375 stop_action,
4376 }) = shell.primary_action.as_ref()
4377 else {
4378 panic!(
4379 "shell row must open live output, got {:?}",
4380 shell.primary_action
4381 );
4382 };
4383 assert!(title.contains("shell_a1b2c3d4"), "{title}");
4384 assert!(body.contains("Job: shell_a1b2c3d4"), "{body}");
4385 assert!(body.contains("/jobs cancel shell_a1b2c3d4"), "{body}");
4386 assert!(matches!(
4387 stop_action.as_deref(),
4388 Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_a1b2c3d4"
4389 ));
4390 let facts = shell
4391 .agent
4392 .as_ref()
4393 .expect("shell uses the same row columns");
4394 assert_eq!(facts.role_label, "shell");
4395 assert_eq!(facts.status, "running");
4396 assert!(
4397 facts.objective.contains("example-project"),
4398 "{}",
4399 facts.objective
4400 );
4401 }
4402
4403 #[test]
4404 fn durable_tasks_are_not_promoted_as_shell_rows() {
4405 let mut app = test_app();
4406 app.work_surface.placement = WorkSurfacePlacement::Top;
4407 app.work_surface.effective_placement = WorkSurfacePlacement::Top;
4408 app.task_panel.push(crate::tui::app::TaskPanelEntry {
4409 exit_code: None,
4410 id: "run".to_string(),
4411 status: "running".to_string(),
4412 prompt_summary: "background confirmation test".to_string(),
4413 duration_ms: Some(99_000),
4414 kind: crate::tui::app::TaskPanelEntryKind::Background,
4415 stale: false,
4416 elapsed_since_output_ms: None,
4417 owner_agent_id: None,
4418 owner_agent_name: None,
4419 current_tool: None,
4420 role: None,
4421 files_touched: 0,
4422 });
4423 let rows = project_visible(&mut app);
4424 assert!(
4425 rows.iter()
4426 .all(|row| !row.id.0.starts_with("shell:") && row.id.0 != "section:shells"),
4427 "non-shell task_panel entries must not become Shells rows: {rows:?}"
4428 );
4429 }
4430
4431 fn finished_entry(
4432 id: &str,
4433 summary: &str,
4434 status: &str,
4435 exit_code: Option<i64>,
4436 ) -> crate::tui::app::TaskPanelEntry {
4437 crate::tui::app::TaskPanelEntry {
4438 exit_code,
4439 status: status.to_string(),
4440 prompt_summary: summary.to_string(),
4441 duration_ms: Some(12_000),
4442 ..running_shell_entry(id, "unused")
4443 }
4444 }
4445
4446 #[test]
4447 fn finished_shells_and_tasks_sit_muted_under_finished_and_never_count_as_live() {
4448 // #6565: a finished task looked live and reopened the dock; a
4449 // finished shell vanished with no trace of how it ended.
4450 let mut app = test_app();
4451 app.task_panel
4452 .push(running_shell_entry("shell_dev", "npm run dev"));
4453 app.task_panel.push(finished_entry(
4454 "shell_ok",
4455 "shell: cargo build",
4456 "completed",
4457 Some(0),
4458 ));
4459 app.task_panel.push(finished_entry(
4460 "shell_bad",
4461 "shell: npm test",
4462 "failed",
4463 Some(2),
4464 ));
4465 app.task_panel.push(finished_entry(
4466 "shell_kill",
4467 "shell: sleep 99",
4468 "killed",
4469 None,
4470 ));
4471 app.task_panel.push(finished_entry(
4472 "shell_slow",
4473 "shell: make e2e",
4474 "timed_out",
4475 None,
4476 ));
4477 app.task_panel.push(TaskPanelEntry {
4478 kind: TaskPanelEntryKind::Background,
4479 ..finished_entry("task_done", "summarize the logs", "completed", None)
4480 });
4481 let rows = visible_rows_for(&mut app, RailPanel::Background);
4482 let ids = rows.iter().map(|row| row.id.0.as_str()).collect::<Vec<_>>();
4483 assert_eq!(
4484 ids,
4485 [
4486 "section:shells",
4487 "shell:shell_dev",
4488 "section:finished",
4489 "shell:shell_ok",
4490 "shell:shell_bad",
4491 "shell:shell_kill",
4492 "shell:shell_slow",
4493 "task:task_done",
4494 ]
4495 );
4496 let row = |id: &str| rows.iter().find(|row| row.id.0 == id).unwrap();
4497 assert_eq!(row("section:finished").label, "Finished 5");
4498 assert_eq!(row("shell:shell_ok").label, "cargo build");
4499 assert_eq!(row("shell:shell_ok").detail, "exit 0 · 12s · shell_ok");
4500 assert_eq!(row("shell:shell_ok").tone, WorkTone::Muted);
4501 assert_eq!(row("shell:shell_bad").detail, "failed · exit 2 · shell_bad");
4502 assert_eq!(row("shell:shell_bad").tone, WorkTone::Failure);
4503 assert_eq!(row("shell:shell_kill").detail, "killed · shell_kill");
4504 assert_eq!(row("shell:shell_slow").detail, "timed out · shell_slow");
4505 assert_eq!(row("task:task_done").tone, WorkTone::Muted);
4506 assert!(
4507 row("shell:shell_ok")
4508 .agent
4509 .as_ref()
4510 .is_some_and(|facts| !facts.holds_dock_open && facts.role_label == "shell")
4511 );
4512
4513 // The running dev server is the only live work.
4514 assert!(background_has_live_work(&mut app));
4515 app.task_panel.remove(0);
4516 assert!(!background_has_live_work(&mut app));
4517 assert_eq!(auto_work_rows(&mut app), 0);
4518 }
4519
4520 #[test]
4521 fn a_quiet_running_agent_shows_the_engines_idle_clock_and_bound() {
4522 let mut app = test_app();
4523 let mut agent = running_agent("agent_quiet");
4524 agent.idle_ms = Some(125_000);
4525 agent.heartbeat_timeout_ms = Some(300_000);
4526 app.subagent_cache.push(agent);
4527 let detail = |app: &App| {
4528 agent_rows(app)
4529 .into_iter()
4530 .find(|ranked| ranked.row.id.0 == "worker:agent_quiet")
4531 .expect("row")
4532 .row
4533 .detail
4534 };
4535 assert!(
4536 detail(&app).ends_with("quiet 2m · auto-stop at 5m"),
4537 "{}",
4538 detail(&app)
4539 );
4540 // A tool in flight is never quiet.
4541 app.agent_progress_meta
4542 .entry("agent_quiet".to_string())
4543 .or_default()
4544 .current_tool = Some("exec_shell".to_string());
4545 assert!(!detail(&app).contains("quiet"), "{}", detail(&app));
4546 // Under a minute says nothing.
4547 app.agent_progress_meta.clear();
4548 app.subagent_cache[0].idle_ms = Some(20_000);
4549 assert!(!detail(&app).contains("quiet"), "{}", detail(&app));
4550 }
4551
4552 #[test]
4553 fn progress_after_an_old_snapshot_keeps_a_working_agent_from_reading_quiet() {
4554 let mut app = test_app();
4555 let mut agent = running_agent("agent_busy");
4556 // The snapshot is ten minutes old; its idle clock would read quiet.
4557 agent.idle_ms = Some(0);
4558 agent.heartbeat_timeout_ms = Some(300_000);
4559 app.subagent_cache.push(agent);
4560 app.subagent_cache_received_at =
4561 std::time::Instant::now().checked_sub(std::time::Duration::from_secs(600));
4562 let detail = |app: &App| {
4563 agent_rows(app)
4564 .into_iter()
4565 .find(|ranked| ranked.row.id.0 == "worker:agent_busy")
4566 .expect("row")
4567 .row
4568 .detail
4569 };
4570 assert!(detail(&app).contains("quiet 5m"), "{}", detail(&app));
4571 // A progress envelope since then, with no tool in flight between
4572 // read-only tools, means the agent is working.
4573 app.agent_progress_meta
4574 .entry("agent_busy".to_string())
4575 .or_default()
4576 .last_progress_at = Some(std::time::Instant::now());
4577 assert!(!detail(&app).contains("quiet"), "{}", detail(&app));
4578 // The TUI's clock only caps the engine's, never extends it: a child
4579 // quiet since its last envelope two minutes ago reads 2m.
4580 app.agent_progress_meta
4581 .get_mut("agent_busy")
4582 .expect("meta")
4583 .last_progress_at =
4584 std::time::Instant::now().checked_sub(std::time::Duration::from_secs(125));
4585 assert!(detail(&app).contains("quiet 2m"), "{}", detail(&app));
4586 }
4587
4588 #[test]
4589 fn background_review_shell_prefixed_tasks_keep_task_actions() {
4590 let mut app = test_app();
4591 for (id, summary) in [
4592 ("task_real", "shell: explain this command"),
4593 ("shell_legacy", "ordinary task"),
4594 ] {
4595 app.task_panel.push(TaskPanelEntry {
4596 kind: TaskPanelEntryKind::Background,
4597 prompt_summary: summary.into(),
4598 ..running_shell_entry(id, "unused")
4599 });
4600 }
4601 assert!(
4602 app.task_panel
4603 .iter()
4604 .all(|entry| !is_live_shell_entry(entry))
4605 );
4606 assert_eq!(
4607 crate::tui::background_indicator::pending_work_from_app(&app)
4608 .count(crate::tui::background_indicator::PendingItemKind::Task),
4609 2
4610 );
4611 for status in ["running", "completed"] {
4612 for entry in &mut app.task_panel {
4613 entry.status = status.into();
4614 }
4615 let rows = visible_rows_for(&mut app, RailPanel::Background);
4616 for id in ["task_real", "shell_legacy"] {
4617 let row = rows
4618 .iter()
4619 .find(|row| row.id.0 == format!("task:{id}"))
4620 .expect("task row");
4621 assert!(
4622 matches!(&row.primary_action, Some(SidebarRowAction::Command(command)) if command == &format!("/jobs show {id}"))
4623 );
4624 }
4625 }
4626 }
4627
4628 #[test]
4629 fn background_review_snapshot_tool_is_never_quiet_without_progress_metadata() {
4630 let app = test_app();
4631 let mut agent = running_agent("agent_tool");
4632 agent.idle_ms = Some(125_000);
4633 agent.heartbeat_timeout_ms = Some(300_000);
4634 agent.worker_status = Some(AgentWorkerStatus::RunningTool);
4635 assert_eq!(quiet_fact(&app, &agent, None), None);
4636 assert_eq!(
4637 quiet_fact(&app, &agent, Some(&AgentProgressMeta::default())),
4638 None
4639 );
4640 agent.worker_status = None;
4641 assert!(quiet_fact(&app, &agent, None).is_some());
4642 }
4643
4644 #[test]
4645 fn background_review_finished_rows_and_quiet_copy_use_the_selected_locale() {
4646 let mut app = test_app();
4647 app.ui_locale = Locale::Fr;
4648 app.task_panel.push(finished_entry(
4649 "shell_kill",
4650 "shell: sleep 99",
4651 "killed",
4652 None,
4653 ));
4654 let rows = visible_rows_for(&mut app, RailPanel::Background);
4655 assert_eq!(rows[0].label, "Terminés 1");
4656 assert_eq!(rows[0].detail, "Ouvrez une ligne pour voir sa sortie");
4657 assert!(rows[1].detail.starts_with("arrêté de force"));
4658 for (status, expected) in [
4659 ("completed", "code de sortie 0"),
4660 ("failed", "échec"),
4661 ("timed_out", "délai dépassé"),
4662 ] {
4663 let entry = finished_entry("shell", "shell: command", status, Some(0));
4664 assert!(finished_shell_outcome(Locale::Fr, &entry).contains(expected));
4665 }
4666 for (status, expected) in [("failed", "échec"), ("canceled", "annulation")] {
4667 app.task_panel = vec![TaskPanelEntry {
4668 kind: TaskPanelEntryKind::Background,
4669 ..finished_entry("task", "vérifier", status, None)
4670 }];
4671 let rows = finished_background_rows(&app);
4672 assert_eq!(rows[0].detail, format!("{expected} · 12s · task"));
4673 }
4674 let mut agent = running_agent("agent_quiet");
4675 agent.idle_ms = Some(125_000);
4676 agent.heartbeat_timeout_ms = Some(300_000);
4677 assert_eq!(
4678 quiet_fact(&app, &agent, None).as_deref(),
4679 Some("sans progrès depuis 2m · arrêt automatique à 5m")
4680 );
4681 }
4682 }
4683
4683 lines RUST