返回 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 ratatui::layout::Rect;
7
8 use crate::settings::InlineDiffMode;
9 use crate::tools::canonical_action::canonical_action_alias;
10 use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus};
11 use crate::tui::app::{AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction};
12 use crate::tui::history::{
13 FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell,
14 };
15 use crate::tui::menu_style::{StatusKind, status_mark};
16 use crate::work_graph::{
17 AcceptanceRequirement, EdgeKind, EvidenceKind, EvidenceKindTag, NodeKind, NodeState,
18 OperationBinding, OwnerState, Provenance, WorkGraphSnapshot, WorkNode,
19 };
20
21 /// Persisted Ocean work-surface placement. Bottom is deliberately absent: the
22 /// composer and phase footer own the shell's lower edge. `Off` hides the rail
23 /// outright (rail unification, 0.9.4).
24 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
25 pub enum WorkSurfacePlacement {
26 #[default]
27 Top,
28 Left,
29 Right,
30 Off,
31 }
32
33 /// Which panel the rail shows. Orthogonal to placement: the user picks
34 /// *where* the rail sits and *what* it shows (rail unification, 0.9.4).
35 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
36 pub enum RailPanel {
37 /// Tasks / to-do / workers — the live work projection rendered through
38 /// the row/hitbox machinery in `render.rs`.
39 #[default]
40 Tasks,
41 /// Sub-agents, ported from the legacy sidebar's Agents panel.
42 Agents,
43 /// Workspace / token / cost context, ported from the Context panel.
44 Context,
45 /// Pinned work summary (goal + checklist), ported from the Pinned panel.
46 Pinned,
47 }
48
49 impl RailPanel {
50 #[must_use]
51 pub fn parse(value: &str) -> Self {
52 match value.trim().to_ascii_lowercase().as_str() {
53 "agents" => Self::Agents,
54 "context" => Self::Context,
55 "pinned" => Self::Pinned,
56 _ => Self::Tasks,
57 }
58 }
59
60 #[must_use]
61 pub const fn as_setting(self) -> &'static str {
62 match self {
63 Self::Tasks => "tasks",
64 Self::Agents => "agents",
65 Self::Context => "context",
66 Self::Pinned => "pinned",
67 }
68 }
69
70 #[must_use]
71 pub const fn title(self) -> &'static str {
72 match self {
73 Self::Tasks => "Tasks",
74 Self::Agents => "Agents",
75 Self::Context => "Context",
76 Self::Pinned => "Pinned",
77 }
78 }
79 }
80
81 impl WorkSurfacePlacement {
82 #[must_use]
83 pub fn parse(value: &str) -> Self {
84 match value.trim().to_ascii_lowercase().as_str() {
85 "left" => Self::Left,
86 "right" => Self::Right,
87 "off" => Self::Off,
88 _ => Self::Top,
89 }
90 }
91
92 #[must_use]
93 pub const fn as_setting(self) -> &'static str {
94 match self {
95 Self::Top => "top",
96 Self::Left => "left",
97 Self::Right => "right",
98 Self::Off => "off",
99 }
100 }
101 }
102
103 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
104 pub struct WorkRowId(pub String);
105
106 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
107 pub(super) enum WorkTone {
108 Heading,
109 Live,
110 Attention,
111 Success,
112 Muted,
113 }
114
115 #[derive(Debug, Clone)]
116 pub(super) struct WorkRow {
117 pub id: WorkRowId,
118 pub mark: &'static str,
119 pub label: String,
120 pub detail: String,
121 pub tone: WorkTone,
122 pub selectable: bool,
123 pub primary_action: Option<SidebarRowAction>,
124 /// Present only on sub-agent rows. Carries the fields the fleet row paints
125 /// beyond `label`, so the renderer can drop them one at a time as the
126 /// surface narrows instead of truncating one pre-joined string.
127 pub agent: Option<AgentRowFacts>,
128 }
129
130 /// The parts of a sub-agent row that are laid out as their own columns.
131 ///
132 /// `label` already carries the preferred identity column (nesting indent,
133 /// nickname when the agent has one, `(+N)` child count). This carries the
134 /// rest: the role-only spelling of that same column, what the agent is doing,
135 /// and the right-aligned receipt.
136 #[derive(Debug, Clone, Default, PartialEq, Eq)]
137 pub(super) struct AgentRowFacts {
138 /// The identity column spelled with the fleet role instead of the
139 /// nickname. Equal to `label` when the agent has no nickname. The
140 /// renderer falls back to this when a nickname is too wide for the
141 /// identity column — a name is shown whole or not at all.
142 pub role_label: String,
143 /// The status word (`running`, `completed`, `failed`, …) painted as its
144 /// own column. The glyph carries the same fact for scanning; the word is
145 /// what makes the row legible without memorizing glyph vocabulary
146 /// (owner regression report, 2026-08-04).
147 pub status: String,
148 /// What the agent was sent to do.
149 pub objective: String,
150 /// Wall-clock seconds, frozen once the agent is observed terminal so a
151 /// finished agent stops ticking. `None` when no duration is known.
152 pub elapsed_secs: Option<u64>,
153 /// Tokens received from the provider. `None` means *genuinely unknown* —
154 /// the row then renders no token figure rather than claiming zero.
155 pub tokens: Option<u64>,
156 /// Unsettled items on this child's to-do list. `None` when no list has
157 /// been published; `Some(0)` means the list exists and is fully settled
158 /// (the strip still hides a zero chip — see `agent_receipt`).
159 pub todos_remaining: Option<u32>,
160 }
161
162 #[derive(Debug, Clone)]
163 pub(super) struct WorkHitbox {
164 pub id: WorkRowId,
165 pub row_y: u16,
166 }
167
168 #[derive(Debug, Clone)]
169 enum WorkSourceState {
170 Error(String),
171 Disconnected,
172 }
173
174 impl WorkSourceState {
175 const fn label(&self) -> &'static str {
176 match self {
177 Self::Error(_) => "error",
178 Self::Disconnected => "disconnected",
179 }
180 }
181
182 fn detail(&self) -> &str {
183 match self {
184 Self::Error(error) => error,
185 Self::Disconnected => "Work Graph runtime is not attached",
186 }
187 }
188 }
189
190 /// Live Work summary recent-only presentation lifetime (#4688).
191 pub(super) const RECENT_ONLY_TTL_MS: u64 = 4_000;
192 /// Settled file/search/write receipt lifetime in the live strip (#4690).
193 pub(super) const ACTIVITY_RECEIPT_TTL_MS: u64 = 3_000;
194 pub(super) const TOP_HEIGHT_MIN: u16 = 2;
195 pub(super) const TOP_HEIGHT_MAX: u16 = 16;
196 pub(super) const SIDE_WIDTH_MIN: u16 = 26;
197 pub(super) const SIDE_WIDTH_MAX: u16 = 80;
198
199 /// Which restored work rows belong to a prior session instance (#4416).
200 ///
201 /// Decided once per session id and cached: this instance's own later
202 /// autosaves restamp the persisted record, and re-probing after that would
203 /// re-badge restored rows as live work.
204 #[derive(Debug, Clone)]
205 pub(crate) struct SessionInstanceScope {
206 pub(super) session_id: String,
207 pub(super) from_prior_instance: bool,
208 /// Node ids present in the graph supplied at session restore time: the
209 /// restored persisted rows, as opposed to work this instance creates
210 /// afterwards.
211 pub(super) restored_nodes: HashSet<String>,
212 }
213
214 #[derive(Debug, Clone)]
215 pub struct WorkSurfaceState {
216 pub placement: WorkSurfacePlacement,
217 pub(super) effective_placement: WorkSurfacePlacement,
218 /// Panel selection — orthogonal to placement.
219 pub panel: RailPanel,
220 pub top_height: u16,
221 pub side_width: u16,
222 pub(super) resizing: bool,
223 pub(super) divider_hovered: bool,
224 pub(super) resize_anchor_column: u16,
225 pub(super) resize_anchor_row: u16,
226 pub(super) resize_anchor_size: u16,
227 /// Focus owner axis — distinct from selection and detail-open.
228 pub focused: bool,
229 /// Keyboard/mouse selection highlight.
230 pub selected: Option<WorkRowId>,
231 /// Which row currently owns an open detail (pager / agent card).
232 pub opened: Option<WorkRowId>,
233 pub scroll_offset: usize,
234 pub last_area: Option<Rect>,
235 pub visible_rows: usize,
236 pub total_rows: usize,
237 pub(super) hovered: Option<WorkRowId>,
238 pub(super) hitboxes: Vec<WorkHitbox>,
239 pub(super) cached_graph: Option<WorkGraphSnapshot>,
240 pub(super) latest_rows: Vec<WorkRow>,
241 /// Full ranked catalog retained for inspector/history after live chrome expires.
242 pub(super) catalog_rows: Vec<WorkRow>,
243 /// Monotonic origin for presentation lifetimes (not wall-clock epoch).
244 presentation_origin: Instant,
245 /// Optional injected clock (ms since origin) for deterministic tests.
246 presentation_now_ms: Option<u64>,
247 /// When the projection last became recent-only (ms since origin).
248 recent_only_since_ms: Option<u64>,
249 /// Fingerprint of the recent-only set so a new completion can re-surface once.
250 recent_only_fingerprint: u64,
251 /// After TTL or user-turn, keep the live summary collapsed until new actionable work.
252 recent_only_suppressed: bool,
253 /// When the current activity receipt fingerprint first became live.
254 activity_since_ms: Option<u64>,
255 activity_fingerprint: u64,
256 activity_suppressed: bool,
257 /// Bumped on accepted user turns / newly started operations.
258 user_turn_epoch: u64,
259 last_handled_user_turn_epoch: u64,
260 /// Elapsed wall-clock, in ms, captured the first frame each sub-agent was
261 /// observed in a terminal state. The manager's `duration_ms` is
262 /// `started_at.elapsed()` recomputed per snapshot, so it keeps growing
263 /// after an agent finishes; latching the first terminal reading is what
264 /// makes a completed row stop ticking.
265 pub(super) frozen_agent_elapsed_ms: std::collections::HashMap<String, u64>,
266 /// Session-instance ownership of the restored session record (#4416).
267 pub(crate) session_instance: Option<SessionInstanceScope>,
268 /// Test override for the sessions directory the ownership probe reads;
269 /// production resolves the default location lazily.
270 pub(crate) session_owner_probe_dir: Option<std::path::PathBuf>,
271 }
272
273 impl Default for WorkSurfaceState {
274 fn default() -> Self {
275 Self::with_placement(WorkSurfacePlacement::Top)
276 }
277 }
278
279 impl WorkSurfaceState {
280 #[must_use]
281 pub(crate) fn is_resizing(&self) -> bool {
282 self.resizing
283 }
284
285 /// The placement actually rendered this frame (after the narrow-terminal
286 /// fallback), for truthful status readouts.
287 #[must_use]
288 pub fn effective_placement(&self) -> WorkSurfacePlacement {
289 self.effective_placement
290 }
291
292 #[must_use]
293 pub fn with_placement(placement: WorkSurfacePlacement) -> Self {
294 Self::with_layout(placement, 3, 30)
295 }
296
297 #[must_use]
298 pub fn with_layout(placement: WorkSurfacePlacement, top_height: u16, side_width: u16) -> Self {
299 Self {
300 placement,
301 effective_placement: placement,
302 panel: RailPanel::default(),
303 top_height: top_height.clamp(TOP_HEIGHT_MIN, TOP_HEIGHT_MAX),
304 side_width: side_width.clamp(SIDE_WIDTH_MIN, SIDE_WIDTH_MAX),
305 resizing: false,
306 divider_hovered: false,
307 resize_anchor_column: 0,
308 resize_anchor_row: 0,
309 resize_anchor_size: 0,
310 focused: false,
311 selected: None,
312 opened: None,
313 scroll_offset: 0,
314 last_area: None,
315 visible_rows: 0,
316 total_rows: 0,
317 hovered: None,
318 hitboxes: Vec::new(),
319 cached_graph: None,
320 latest_rows: Vec::new(),
321 catalog_rows: Vec::new(),
322 presentation_origin: Instant::now(),
323 presentation_now_ms: None,
324 recent_only_since_ms: None,
325 recent_only_fingerprint: 0,
326 recent_only_suppressed: false,
327 activity_since_ms: None,
328 activity_fingerprint: 0,
329 activity_suppressed: false,
330 user_turn_epoch: 0,
331 last_handled_user_turn_epoch: 0,
332 frozen_agent_elapsed_ms: std::collections::HashMap::new(),
333 session_instance: None,
334 session_owner_probe_dir: None,
335 }
336 }
337
338 /// Inject a monotonic clock for presentation-lifetime tests.
339 #[cfg(test)]
340 pub(super) fn set_presentation_now_ms(&mut self, now_ms: u64) {
341 self.presentation_now_ms = Some(now_ms);
342 }
343
344 /// Signal that the user accepted a turn or a new operation started.
345 /// Recent-only live chrome collapses immediately (#4688).
346 pub fn note_user_turn_or_new_operation(&mut self) {
347 self.user_turn_epoch = self.user_turn_epoch.wrapping_add(1);
348 }
349
350 /// Record the exact graph restored from persisted session state. This
351 /// must happen at the restore boundary: the first later runtime capture
352 /// may already contain work created by this process.
353 pub(crate) fn record_restored_session(
354 &mut self,
355 session_id: &str,
356 graph: Option<&WorkGraphSnapshot>,
357 ) {
358 let from_prior_instance = session_record_from_prior_instance(self, session_id);
359 let restored_nodes = if from_prior_instance {
360 graph
361 .into_iter()
362 .flat_map(|graph| graph.nodes.iter())
363 .map(|node| node.id.as_str().to_string())
364 .collect()
365 } else {
366 HashSet::new()
367 };
368 self.session_instance = Some(SessionInstanceScope {
369 session_id: session_id.to_string(),
370 from_prior_instance,
371 restored_nodes,
372 });
373 }
374
375 /// A restored graph row owned by a prior session instance whose terminal
376 /// failure or staleness must not render as this session's live work
377 /// (#4416). Plan steps stay: the resumed to-do list is the point of
378 /// restoring; failed/stale operations and blockers are the leak.
379 pub(super) fn is_prior_instance_residue(&self, node: &WorkNode) -> bool {
380 let Some(scope) = self.session_instance.as_ref() else {
381 return false;
382 };
383 scope.from_prior_instance
384 && scope.restored_nodes.contains(node.id.as_str())
385 && node.kind != NodeKind::PlanStep
386 && matches!(node.state, NodeState::Failed | NodeState::Stale)
387 }
388
389 fn now_ms(&self) -> u64 {
390 self.presentation_now_ms.unwrap_or_else(|| {
391 u64::try_from(self.presentation_origin.elapsed().as_millis()).unwrap_or(u64::MAX)
392 })
393 }
394
395 pub(super) fn selected_index(&self, rows: &[WorkRow]) -> Option<usize> {
396 self.selected
397 .as_ref()
398 .and_then(|selected| rows.iter().position(|row| &row.id == selected))
399 }
400
401 /// Keep row identity and the viewport offset valid without moving the
402 /// viewport to the remembered keyboard selection. Mouse-wheel scrolling
403 /// is allowed to leave that selection off-screen until keyboard
404 /// navigation resumes.
405 pub(super) fn clamp_viewport(&mut self, rows: &[WorkRow]) {
406 let selectable = rows.iter().filter(|row| row.selectable).collect::<Vec<_>>();
407 if selectable.is_empty() {
408 self.selected = None;
409 self.focused = false;
410 self.scroll_offset = 0;
411 return;
412 }
413 let established_selection = selectable
414 .iter()
415 .any(|row| Some(&row.id) == self.selected.as_ref());
416 if !established_selection {
417 let preferred = selectable
418 .iter()
419 .find(|row| row.tone == WorkTone::Attention)
420 .or_else(|| selectable.iter().find(|row| row.tone == WorkTone::Live))
421 .copied()
422 .unwrap_or(selectable[0]);
423 self.selected = Some(preferred.id.clone());
424 // Establishing a new selection should reveal the current or
425 // needs-input item without reordering the canonical list. Later
426 // redraws keep mouse-wheel ownership and do not chase selection.
427 if let Some(selected) = rows.iter().position(|row| row.id == preferred.id) {
428 if selected < self.scroll_offset {
429 self.scroll_offset = selected;
430 } else if self.visible_rows > 0
431 && selected >= self.scroll_offset.saturating_add(self.visible_rows)
432 {
433 self.scroll_offset = selected.saturating_add(1) - self.visible_rows;
434 }
435 }
436 }
437 self.scroll_offset = self
438 .scroll_offset
439 .min(rows.len().saturating_sub(self.visible_rows.max(1)));
440 }
441
442 /// Reveal the remembered selection after keyboard navigation. Rendering
443 /// alone must use `clamp_viewport`; otherwise every redraw undoes a mouse
444 /// wheel offset when the selection is above the viewport.
445 pub(super) fn clamp_selection(&mut self, rows: &[WorkRow]) {
446 self.clamp_viewport(rows);
447 let Some(selected) = self.selected_index(rows) else {
448 return;
449 };
450 if selected < self.scroll_offset {
451 self.scroll_offset = selected;
452 } else if self.visible_rows > 0
453 && selected >= self.scroll_offset.saturating_add(self.visible_rows)
454 {
455 self.scroll_offset = selected.saturating_add(1).saturating_sub(self.visible_rows);
456 }
457 self.scroll_offset = self
458 .scroll_offset
459 .min(rows.len().saturating_sub(self.visible_rows.max(1)));
460 }
461 }
462
463 pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
464 let active_session = app.current_session_id.is_some();
465 freeze_terminal_agent_elapsed(app);
466 let agents = agent_rows(app);
467 let coordination = coordination_row(app);
468 let activity = settled_file_activity(app);
469 let capture = app.runtime_services.work.as_ref().map(|work| {
470 work.try_capture(app.current_session_id.as_deref())
471 .map(|snapshot| snapshot.map(|snapshot| snapshot.graph))
472 });
473
474 let (graph, source_state) = match capture {
475 Some(Ok(Some(graph))) => {
476 app.work_surface.cached_graph = Some(graph.clone());
477 (Some(graph), None)
478 }
479 Some(Ok(None)) => {
480 app.work_surface.cached_graph = None;
481 (None, None)
482 }
483 Some(Err(error)) => (
484 app.work_surface.cached_graph.clone(),
485 active_session.then_some(WorkSourceState::Error(error)),
486 ),
487 None => (
488 app.work_surface.cached_graph.clone(),
489 active_session.then_some(WorkSourceState::Disconnected),
490 ),
491 };
492
493 update_session_instance_scope(app);
494
495 let rows = match graph {
496 Some(graph) => graph_rows(
497 &mut app.work_surface,
498 &graph,
499 source_state.as_ref(),
500 agents,
501 coordination,
502 activity,
503 ),
504 None if !agents.is_empty() || coordination.is_some() || !activity.is_empty() => {
505 ordered_rows(
506 &mut app.work_surface,
507 None,
508 source_state.as_ref(),
509 agents,
510 coordination,
511 activity,
512 )
513 }
514 None => source_state.map_or_else(Vec::new, |state| {
515 vec![section_heading(
516 "work",
517 &format!("Work · {}", state.label()),
518 state.detail(),
519 )]
520 }),
521 };
522 app.work_surface.latest_rows = rows.clone();
523 if let Some(opened) = app.work_surface.opened.as_ref()
524 && !rows.iter().any(|row| &row.id == opened)
525 && !app
526 .work_surface
527 .catalog_rows
528 .iter()
529 .any(|row| &row.id == opened)
530 {
531 app.work_surface.opened = None;
532 }
533 rows
534 }
535
536 /// Projection used by the live surface. The full Work catalog remains intact
537 /// for explicit inspectors, while persistent chrome stays literal: current
538 /// sub-agents, then plan-step to-dos. Tool operations, coordination receipts,
539 /// file activity, and generic graph headings never enter this list.
540 ///
541 /// On Top, the strip is actionable work only: running / queued / needs-input
542 /// agents, plus plan-step to-dos. Quietly completed or cancelled workers
543 /// collapse out of the strip into the group header count (e.g.
544 /// `▾ Subagents 2 running · Archived 6`) so fan-outs do not permanently eat
545 /// the transcript. Settled agents stay reachable through the Agents panel and
546 /// the work catalog — never deleted. Failed / interrupted workers stay in the
547 /// strip because they still need attention.
548 ///
549 /// **The sub-agent group outranks the to-do list for strip rows.** The strip
550 /// is a fixed-height viewport over this list (`top_height`, 2..=16 rows) and
551 /// paints from the top, so whatever is ordered last is what falls off the
552 /// bottom behind `↓ N more`. Putting the to-dos first meant a session that
553 /// already had a checklist spent every row it had on to-dos and a running
554 /// sub-agent never appeared in the top bar at all — the 2026-08-04 owner
555 /// report, pinned by `tests/work_bar_subagents_pty.rs`. The workers are the
556 /// side that must not fall off: the to-do list keeps a pinned receipt
557 /// (`To-do · 3/8 · 5 left`) that survives its rows scrolling away, and a
558 /// sub-agent row has no such summary — it is the only place a running worker
559 /// is inspectable from the bar. Sub-agent rows are also the bounded set
560 /// (`max_concurrent` plus a capped terminal-card retention) while a to-do
561 /// list is unbounded, so seating the bounded set first is what keeps both
562 /// visible in the common case.
563 pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
564 let rows = project(app);
565 if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
566 return rows;
567 }
568
569 let todo_ids = plan_step_row_ids(app);
570 let mut todos = Vec::new();
571 let mut live_agents = Vec::new();
572 let mut settled_agents = 0usize;
573 for row in rows {
574 if todo_ids.contains(&row.id.0) {
575 todos.push(row);
576 } else if row.id.0.starts_with("worker:") {
577 if agent_row_is_strip_settled(&row) {
578 settled_agents += 1;
579 } else {
580 live_agents.push(row);
581 }
582 }
583 }
584
585 let mut out = Vec::with_capacity(todos.len() + live_agents.len() + 1);
586 if !live_agents.is_empty() || settled_agents > 0 {
587 // Honest live/settled split — failed/interrupted stay as strip rows
588 // (needs attention) and are never counted as "running".
589 let attention = live_agents
590 .iter()
591 .filter(|row| agent_row_needs_attention(row))
592 .count();
593 let running = live_agents.len().saturating_sub(attention);
594 let header = match (running, attention, settled_agents) {
595 (0, 0, settled) => format!("Subagents · Archived {settled}"),
596 (live, 0, 0) => format!("Subagents {live}"),
597 (live, 0, settled) => format!("Subagents {live} running · Archived {settled}"),
598 (0, blocked, 0) => format!("Subagents {blocked} needs input"),
599 (0, blocked, settled) => {
600 format!("Subagents {blocked} needs input · Archived {settled}")
601 }
602 (live, blocked, 0) => format!("Subagents {live} running · {blocked} needs input"),
603 (live, blocked, settled) => {
604 format!("Subagents {live} running · {blocked} needs input · Archived {settled}")
605 }
606 };
607 out.push(agents_section_heading(&header));
608 out.extend(live_agents);
609 }
610 out.extend(todos);
611 app.work_surface.latest_rows = out.clone();
612 out
613 }
614
615 /// Completed/cancelled workers leave the Top strip; failed/interrupted stay
616 /// because they still need attention. Paths to receipts remain via Agents.
617 fn agent_row_is_strip_settled(row: &WorkRow) -> bool {
618 row.agent
619 .as_ref()
620 .is_some_and(|facts| matches!(facts.status.as_str(), "completed" | "cancelled"))
621 }
622
623 fn agent_row_needs_attention(row: &WorkRow) -> bool {
624 row.agent.as_ref().is_some_and(|facts| {
625 matches!(
626 facts.status.as_str(),
627 "failed" | "interrupted" | "needs_input" | "blocked"
628 )
629 })
630 }
631
632 /// Row ids of the plan-step (to-do) nodes in the cached graph.
633 fn plan_step_row_ids(app: &App) -> HashSet<String> {
634 app.work_surface
635 .cached_graph
636 .as_ref()
637 .map(|snapshot| {
638 snapshot
639 .nodes
640 .iter()
641 .filter(|node| node.kind == NodeKind::PlanStep)
642 .map(|node| format!("graph:{}", node.id.as_str()))
643 .collect::<HashSet<_>>()
644 })
645 .unwrap_or_default()
646 }
647
648 /// Rows for the selected rail panel, routed through the same row/hitbox
649 /// machinery regardless of panel: every work row a user can see is a door
650 /// (`crates/tui/AGENTS.md`, "rows are objects"), whichever panel it appears
651 /// in.
652 ///
653 /// - `Tasks` — the full live projection ([`project_visible`]).
654 /// - `Agents` — the sub-agent rows only, under the `▾ Subagents N` header.
655 /// - `Pinned` — the goal, the sub-agent group, then the plan-step to-dos.
656 /// - `Context` — empty: session facts are a line list, not work rows, and
657 /// render outside the row machinery.
658 ///
659 /// **No panel choice may hide a running sub-agent.** `Pinned` used to filter
660 /// the projection down to plan steps, which meant the owner's own
661 /// `rail_panel = "pinned"` made a live worker unreachable: with no to-dos and
662 /// no goal the projection was empty, the strip collapsed to zero rows, and
663 /// the top bar was header chrome only — the 2026-08-04 "I spawned a sub agent
664 /// and the top bar showed nothing" report, pinned by
665 /// `tests/work_bar_subagents_pty.rs`. There is no header chip or phase-strip
666 /// fallback for sub-agents, so this strip is the *only* persistent surface
667 /// they have; a panel preference about which durable work to foreground is
668 /// not consent to lose the running fleet. Sub-agent rows are durable in the
669 /// same sense the panel's name means (they survive completion — see the row
670 /// lifetime rule in the module docs), so they belong here on their own terms.
671 pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
672 match app.work_surface.panel {
673 RailPanel::Tasks => project_visible(app),
674 RailPanel::Agents => {
675 let rows = project(app);
676 let agents: Vec<WorkRow> = rows
677 .into_iter()
678 .filter(|row| row.id.0.starts_with("worker:"))
679 .collect();
680 let mut out = Vec::with_capacity(agents.len() + 1);
681 if !agents.is_empty() {
682 out.push(agents_section_heading(&format!(
683 "Subagents {}",
684 agents.len()
685 )));
686 out.extend(agents);
687 }
688 app.work_surface.latest_rows = out.clone();
689 out
690 }
691 RailPanel::Pinned => {
692 let rows = project(app);
693 let todo_ids = plan_step_row_ids(app);
694 let mut todos = Vec::new();
695 let mut agents = Vec::new();
696 for row in rows {
697 if todo_ids.contains(&row.id.0) {
698 todos.push(row);
699 } else if row.id.0.starts_with("worker:") {
700 agents.push(row);
701 }
702 }
703 let mut out = Vec::with_capacity(todos.len() + agents.len() + 2);
704 // On Top the goal is already the strip title; a side column
705 // repeats it as its first row so the durable goal home survives
706 // in every placement.
707 if app.work_surface.effective_placement != WorkSurfacePlacement::Top
708 && let Some((objective, paused)) =
709 crate::tui::footer_ui::active_goal_chip_state(app)
710 {
711 let flat = objective.trim().replace(['\n', '\r'], " ");
712 if !flat.is_empty() {
713 let label = if paused {
714 format!("Goal (paused): {flat}")
715 } else {
716 format!("Goal: {flat}")
717 };
718 out.push(section_heading("goal", &label, ""));
719 }
720 }
721 // Same priority rule as Tasks: the bounded, summary-less set is
722 // seated before the unbounded to-do list that keeps a pinned
723 // receipt. See [`project_visible`].
724 if !agents.is_empty() {
725 out.push(agents_section_heading(&format!(
726 "Subagents {}",
727 agents.len()
728 )));
729 out.extend(agents);
730 }
731 out.extend(todos);
732 app.work_surface.latest_rows = out.clone();
733 out
734 }
735 RailPanel::Context => Vec::new(),
736 }
737 }
738
739 /// Classify the current session against this process's session-instance
740 /// boot id (#4416), mirroring the `SubAgentManager` prior-session pattern
741 /// (#405). The probe runs once per session id. Persisted row identity is
742 /// recorded separately at the actual session restore boundary.
743 fn update_session_instance_scope(app: &mut App) {
744 let Some(session_id) = app.current_session_id.clone() else {
745 app.work_surface.session_instance = None;
746 return;
747 };
748 let classified = app
749 .work_surface
750 .session_instance
751 .as_ref()
752 .is_some_and(|scope| scope.session_id == session_id);
753 if !classified {
754 let from_prior_instance =
755 session_record_from_prior_instance(&app.work_surface, &session_id);
756 app.work_surface.session_instance = Some(SessionInstanceScope {
757 session_id,
758 from_prior_instance,
759 restored_nodes: HashSet::new(),
760 });
761 }
762 }
763
764 fn session_record_from_prior_instance(surface: &WorkSurfaceState, session_id: &str) -> bool {
765 let manager = match surface.session_owner_probe_dir.as_ref() {
766 Some(dir) => crate::session_manager::SessionManager::new(dir.clone()),
767 None => crate::session_manager::SessionManager::default_location(),
768 };
769 manager.is_ok_and(|manager| manager.session_from_prior_instance(session_id))
770 }
771
772 fn graph_rows(
773 surface: &mut WorkSurfaceState,
774 snapshot: &WorkGraphSnapshot,
775 source_state: Option<&WorkSourceState>,
776 agents: Vec<RankedWorkRow>,
777 coordination: Option<RankedWorkRow>,
778 activity: SettledFileActivity,
779 ) -> Vec<WorkRow> {
780 ordered_rows(
781 surface,
782 Some(snapshot),
783 source_state,
784 agents,
785 coordination,
786 activity,
787 )
788 }
789
790 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
791 enum WorkBucket {
792 Active,
793 Attention,
794 Ready,
795 Recent,
796 }
797
798 impl WorkBucket {
799 /// Presentation priority: needs-input outranks running work (#4689).
800 const fn rank(self) -> u8 {
801 match self {
802 Self::Attention => 0,
803 Self::Active => 1,
804 Self::Ready => 2,
805 Self::Recent => 3,
806 }
807 }
808
809 const fn is_actionable(self) -> bool {
810 !matches!(self, Self::Recent)
811 }
812 }
813
814 #[derive(Clone)]
815 struct RankedWorkRow {
816 bucket: WorkBucket,
817 order: usize,
818 is_plan_step: bool,
819 row: WorkRow,
820 }
821
822 #[derive(Default, Clone)]
823 struct SettledFileActivity {
824 summary: FileActivitySummary,
825 read: Vec<String>,
826 list: Vec<String>,
827 search: Vec<String>,
828 write: Vec<String>,
829 mutations: Vec<FileMutationReceipt>,
830 inline_diff_mode: InlineDiffMode,
831 }
832
833 impl SettledFileActivity {
834 fn is_empty(&self) -> bool {
835 self.summary.is_empty()
836 }
837 }
838
839 fn ordered_rows(
840 surface: &mut WorkSurfaceState,
841 snapshot: Option<&WorkGraphSnapshot>,
842 source_state: Option<&WorkSourceState>,
843 mut ranked: Vec<RankedWorkRow>,
844 coordination: Option<RankedWorkRow>,
845 activity: SettledFileActivity,
846 ) -> Vec<WorkRow> {
847 ranked.extend(coordination);
848 if let Some(snapshot) = snapshot {
849 ranked.extend(
850 snapshot
851 .nodes
852 .iter()
853 .filter(|node| {
854 matches!(
855 node.kind,
856 NodeKind::PlanStep | NodeKind::Operation | NodeKind::Blocker
857 )
858 })
859 .filter(|node| !is_settled_transient_operation(node))
860 .filter(|node| !surface.is_prior_instance_residue(node))
861 .enumerate()
862 .map(|(order, node)| RankedWorkRow {
863 bucket: node_bucket(node),
864 order: 10_000usize.saturating_add(order),
865 is_plan_step: node.kind == NodeKind::PlanStep,
866 row: graph_node_row(snapshot, node),
867 }),
868 );
869 }
870
871 // Activity is projected separately so we can apply a single aggregated
872 // transient receipt instead of one live row per tool kind (#4690).
873 let activity_row = aggregate_activity_row(&activity);
874 if let Some(row) = activity_row.clone() {
875 ranked.push(row);
876 }
877
878 ranked.sort_by(|a, b| match (a.is_plan_step, b.is_plan_step) {
879 // To-do (plan step) rows keep canonical order: a completed step must
880 // not sink below a later pending step and lose its identity. Agent and
881 // operation rows still sort by status bucket (#4689).
882 (true, true) => a.order.cmp(&b.order),
883 (true, false) => std::cmp::Ordering::Less,
884 (false, true) => std::cmp::Ordering::Greater,
885 (false, false) => a
886 .bucket
887 .rank()
888 .cmp(&b.bucket.rank())
889 .then_with(|| a.order.cmp(&b.order)),
890 });
891
892 let active = ranked
893 .iter()
894 .filter(|item| item.bucket == WorkBucket::Active)
895 .count();
896 let attention = ranked
897 .iter()
898 .filter(|item| item.bucket == WorkBucket::Attention)
899 .count();
900 let ready = ranked
901 .iter()
902 .filter(|item| item.bucket == WorkBucket::Ready)
903 .count();
904 let recent = ranked
905 .iter()
906 .filter(|item| item.bucket == WorkBucket::Recent)
907 .count();
908 let actionable = attention + active + ready;
909 let source = source_state
910 .map(|state| format!(" · {}", state.label()))
911 .unwrap_or_default();
912 let detail = match (snapshot, source_state) {
913 (Some(snapshot), Some(state)) => {
914 format!("graph revision {} · {}", snapshot.revision, state.detail())
915 }
916 (Some(snapshot), None) => format!("graph revision {}", snapshot.revision),
917 (None, Some(state)) => state.detail().to_string(),
918 (None, None) => "Current session activity".to_string(),
919 };
920
921 let now = surface.now_ms();
922 let user_turn_force_hide = surface.user_turn_epoch != surface.last_handled_user_turn_epoch;
923 if user_turn_force_hide {
924 surface.last_handled_user_turn_epoch = surface.user_turn_epoch;
925 if actionable == 0 {
926 surface.recent_only_suppressed = true;
927 }
928 surface.activity_suppressed = true;
929 }
930
931 // Recent-only lifecycle (#4688): show a brief completion, then collapse.
932 let recent_fp = fingerprint_rows(
933 ranked
934 .iter()
935 .filter(|item| item.bucket == WorkBucket::Recent)
936 .map(|item| item.row.id.0.as_str()),
937 );
938 if actionable > 0 {
939 surface.recent_only_since_ms = None;
940 surface.recent_only_suppressed = false;
941 surface.recent_only_fingerprint = recent_fp;
942 } else if recent > 0 {
943 if surface.recent_only_fingerprint != recent_fp {
944 // A new completion after expiry may surface once.
945 surface.recent_only_fingerprint = recent_fp;
946 surface.recent_only_since_ms = Some(now);
947 surface.recent_only_suppressed = false;
948 } else if surface.recent_only_since_ms.is_none() && !surface.recent_only_suppressed {
949 surface.recent_only_since_ms = Some(now);
950 }
951 if let Some(since) = surface.recent_only_since_ms
952 && now.saturating_sub(since) >= RECENT_ONLY_TTL_MS
953 {
954 surface.recent_only_suppressed = true;
955 }
956 } else {
957 surface.recent_only_since_ms = None;
958 surface.recent_only_suppressed = false;
959 surface.recent_only_fingerprint = 0;
960 }
961
962 // Activity receipt lifetime (#4690): one aggregated row, 3s, no raw payloads.
963 let activity_fp = activity_row
964 .as_ref()
965 .map(|row| fingerprint_rows(std::iter::once(row.row.label.as_str())))
966 .unwrap_or(0);
967 let show_activity = if activity_row.is_none() {
968 surface.activity_since_ms = None;
969 surface.activity_fingerprint = 0;
970 surface.activity_suppressed = false;
971 false
972 } else {
973 if surface.activity_fingerprint != activity_fp {
974 surface.activity_fingerprint = activity_fp;
975 surface.activity_since_ms = Some(now);
976 surface.activity_suppressed = false;
977 } else if surface.activity_since_ms.is_none() && !surface.activity_suppressed {
978 surface.activity_since_ms = Some(now);
979 }
980 if let Some(since) = surface.activity_since_ms
981 && now.saturating_sub(since) >= ACTIVITY_RECEIPT_TTL_MS
982 {
983 surface.activity_suppressed = true;
984 }
985 !surface.activity_suppressed
986 };
987
988 let subject = ranked
989 .iter()
990 .find(|item| item.bucket.is_actionable())
991 .map(|item| (item.bucket, sanitize_summary_title(&item.row.label)));
992 let heading_label = match (actionable > 0, subject.as_ref()) {
993 (true, Some((WorkBucket::Attention, title))) => {
994 format!("Work · Needs input: {title} · {attention} blocked{source}")
995 }
996 (true, Some((WorkBucket::Active, title))) => {
997 format!("Work · Running: {title} · {active} active{source}")
998 }
999 (true, Some((WorkBucket::Ready, title))) => {
1000 format!("Work · Ready: {title} · {ready} ready{source}")
1001 }
1002 (true, _) => format!(
1003 "Work · {active} active · {attention} needs input · {ready} ready · {recent} recent{source}"
1004 ),
1005 (false, _) => format!(
1006 "Work · {active} active · {attention} needs input · {ready} ready · {recent} recent{source}"
1007 ),
1008 };
1009
1010 // Full catalog for inspector/history even when live chrome collapses.
1011 let mut catalog = vec![section_heading("work", &heading_label, &detail)];
1012 catalog.extend(ranked.iter().map(|item| item.row.clone()));
1013 // Prior-instance terminal residue stays reachable through the explicit
1014 // catalog, labeled historical, but never as this session's live work
1015 // (#4416).
1016 if let Some(snapshot) = snapshot {
1017 catalog.extend(
1018 snapshot
1019 .nodes
1020 .iter()
1021 .filter(|node| surface.is_prior_instance_residue(node))
1022 .map(|node| {
1023 let mut row = graph_node_row(snapshot, node);
1024 row.detail = format!("prior session · {}", row.detail);
1025 row.tone = WorkTone::Muted;
1026 row
1027 }),
1028 );
1029 }
1030 surface.catalog_rows = catalog.clone();
1031
1032 // Live chrome policy (Tasks/side projections — Top uses `project_visible`):
1033 // - actionable: heading + (optional) single activity receipt
1034 // - recent-only: transient receipts collapse after the TTL / next user
1035 // turn (#4688); settled to-dos stay as durable rows. Settled sub-agents
1036 // on Top collapse into the Subagents header (see `project_visible`) and
1037 // remain reachable via the Agents panel / catalog.
1038 // - empty: no heading
1039 let is_durable =
1040 |item: &RankedWorkRow| item.is_plan_step || item.row.id.0.starts_with("worker:");
1041 let has_durable = ranked.iter().any(is_durable);
1042 if ranked.is_empty() && source_state.is_none() {
1043 return Vec::new();
1044 }
1045 if actionable == 0 && recent == 0 {
1046 // Source-only error/disconnected heading is still useful.
1047 return if source_state.is_some() {
1048 vec![section_heading("work", &heading_label, &detail)]
1049 } else {
1050 Vec::new()
1051 };
1052 }
1053 let suppress_transient_recent = actionable == 0 && surface.recent_only_suppressed;
1054 if suppress_transient_recent && !has_durable {
1055 return Vec::new();
1056 }
1057
1058 // The live heading must count the rows the live list actually shows.
1059 // Once transient recent rows are suppressed, quoting the unfiltered
1060 // `recent` total would claim receipts the reader cannot see — the
1061 // catalog heading keeps the full count because the catalog keeps the
1062 // full rows.
1063 let live_heading = if suppress_transient_recent {
1064 let live_recent = ranked
1065 .iter()
1066 .filter(|item| item.bucket == WorkBucket::Recent && is_durable(item))
1067 .count();
1068 format!(
1069 "Work · {active} active · {attention} needs input · {ready} ready · {live_recent} recent{source}"
1070 )
1071 } else {
1072 heading_label.clone()
1073 };
1074
1075 // Full ordered children remain in the projection for side rails, inspector
1076 // selection, and durable recent visibility. Live Top height is capped in
1077 // render (#4690). Recent-only *summary* lifetime is handled above (#4688).
1078 let mut live = vec![section_heading("work", &live_heading, &detail)];
1079 for item in ranked {
1080 if item.row.id.0 == "activity:aggregate" && !show_activity {
1081 continue;
1082 }
1083 if suppress_transient_recent && !is_durable(&item) {
1084 continue;
1085 }
1086 live.push(item.row);
1087 }
1088 live
1089 }
1090
1091 fn fingerprint_rows<'a>(ids: impl Iterator<Item = &'a str>) -> u64 {
1092 use std::hash::{Hash, Hasher};
1093 let mut hasher = std::collections::hash_map::DefaultHasher::new();
1094 for id in ids {
1095 id.hash(&mut hasher);
1096 }
1097 hasher.finish()
1098 }
1099
1100 fn sanitize_summary_title(raw: &str) -> String {
1101 let single_line = raw
1102 .chars()
1103 .map(|ch| {
1104 if ch.is_control() || ch == '\n' || ch == '\r' || ch == '\t' {
1105 ' '
1106 } else {
1107 ch
1108 }
1109 })
1110 .collect::<String>();
1111 let collapsed = single_line.split_whitespace().collect::<Vec<_>>().join(" ");
1112 let trimmed = collapsed.trim();
1113 if trimmed.is_empty() {
1114 return "work item".to_string();
1115 }
1116 let mut chars = trimmed.chars();
1117 let prefix = chars.by_ref().take(72).collect::<String>();
1118 if chars.next().is_some() {
1119 format!("{prefix}…")
1120 } else {
1121 prefix
1122 }
1123 }
1124
1125 fn coordination_row(app: &App) -> Option<RankedWorkRow> {
1126 let projection = app.coordination_detail.as_ref()?;
1127 let has_context_receipt = projection.context_projections.iter().any(|receipt| {
1128 !receipt.decision_ids.is_empty()
1129 || receipt.projected_bytes > 0
1130 || receipt.deduplicated > 0
1131 || receipt.omitted > 0
1132 });
1133 let has_metrics = !projection.metrics.hottest_paths.is_empty()
1134 || projection.metrics.package_or_module_growth.is_some()
1135 || projection.metrics.route_or_cost.is_some();
1136 if projection.decisions.is_empty()
1137 && projection.write_claims.is_empty()
1138 && projection.reconciliations.is_empty()
1139 && projection.contentions.is_empty()
1140 && !has_context_receipt
1141 && !has_metrics
1142 {
1143 return None;
1144 }
1145 let attention = crate::tui::coordination_detail::needs_attention(projection);
1146 let bucket = if attention {
1147 WorkBucket::Attention
1148 } else {
1149 WorkBucket::Recent
1150 };
1151 let title = app
1152 .tr(crate::localization::MessageId::CoordinationWorkTitle)
1153 .into_owned();
1154 Some(RankedWorkRow {
1155 bucket,
1156 // Coordination is a session-wide receipt, before individual workers
1157 // within the same bucket but after live/attention priority sorting.
1158 order: 100,
1159 is_plan_step: false,
1160 row: WorkRow {
1161 id: WorkRowId("coordination".to_string()),
1162 mark: if attention {
1163 crate::tui::glyphs::ATTENTION
1164 } else {
1165 crate::tui::glyphs::DONE
1166 },
1167 label: title.clone(),
1168 detail: crate::tui::coordination_detail::summary(app.ui_locale, projection),
1169 tone: bucket_tone(bucket),
1170 selectable: true,
1171 primary_action: Some(SidebarRowAction::InspectWork {
1172 title,
1173 body: crate::tui::coordination_detail::format(app.ui_locale, projection),
1174 stop_action: None,
1175 }),
1176 agent: None,
1177 },
1178 })
1179 }
1180
1181 fn node_bucket(node: &WorkNode) -> WorkBucket {
1182 match node.state {
1183 NodeState::Initializing | NodeState::Active => WorkBucket::Active,
1184 NodeState::Failed if is_transient_failed_operation(node) => WorkBucket::Recent,
1185 NodeState::Waiting | NodeState::Blocked | NodeState::Stale | NodeState::Failed => {
1186 WorkBucket::Attention
1187 }
1188 NodeState::Completed if !node.acceptance.is_empty() => WorkBucket::Attention,
1189 NodeState::Ready => WorkBucket::Ready,
1190 NodeState::Completed
1191 | NodeState::Verified
1192 | NodeState::Superseded
1193 | NodeState::Cancelled => WorkBucket::Recent,
1194 }
1195 }
1196
1197 fn is_transient_failed_operation(node: &WorkNode) -> bool {
1198 node.kind == NodeKind::Operation
1199 && node
1200 .binding
1201 .as_ref()
1202 .is_some_and(|binding| !binding.durable)
1203 && node.acceptance.is_empty()
1204 && node.state == NodeState::Failed
1205 }
1206
1207 /// One worker row before display ordering: the rendered row plus the parent
1208 /// link and fleet identity the strip uses to number, order, and indent
1209 /// nested spawns (#36).
1210 struct AgentRowSeed {
1211 agent_id: String,
1212 parent_run_id: Option<String>,
1213 role: String,
1214 /// A real nickname or stable label, never the raw agent id (#36).
1215 name: Option<String>,
1216 ranked: RankedWorkRow,
1217 }
1218
1219 /// Indent marker for a nested spawn: nothing at the top level (no permanent
1220 /// chrome for the common flat fan-out), `↳` once nesting is actually
1221 /// present, with two extra spaces per additional level (#36).
1222 fn agent_nesting_indent(depth: usize) -> String {
1223 match depth {
1224 0 => String::new(),
1225 level => format!("{}↳ ", " ".repeat(level.saturating_sub(1))),
1226 }
1227 }
1228
1229 /// Compose the sub-agent identity column: nesting indent, who the agent is,
1230 /// and `(+N)` when that agent has spawned children of its own.
1231 ///
1232 /// `who` is the agent's nickname when it has one and its fleet role when it
1233 /// does not. A nickname is identity that CodeWhale actually has, so it leads;
1234 /// the role is the honest fallback. The raw agent-id hash is never a name and
1235 /// is never rendered (#36).
1236 fn agent_strip_label(indent: &str, who: &str, children: usize) -> String {
1237 if children == 0 {
1238 format!("{indent}{who}")
1239 } else {
1240 format!("{indent}{who} (+{children})")
1241 }
1242 }
1243
1244 /// Order worker rows so nested spawns sit directly under their parent, then
1245 /// stamp each label with its display depth and a sequential number. Rows
1246 /// whose parent is not visible (e.g. the parent finished and left the cache)
1247 /// stay at the top level — honest flat rendering beats a dangling indent.
1248 fn order_agent_seeds(seeds: Vec<AgentRowSeed>) -> Vec<RankedWorkRow> {
1249 let known_ids: HashSet<&str> = seeds.iter().map(|seed| seed.agent_id.as_str()).collect();
1250 let mut children: std::collections::HashMap<&str, Vec<usize>> =
1251 std::collections::HashMap::new();
1252 let mut roots = Vec::new();
1253 for (idx, seed) in seeds.iter().enumerate() {
1254 if let Some(parent) = seed.parent_run_id.as_deref()
1255 && known_ids.contains(parent)
1256 {
1257 children.entry(parent).or_default().push(idx);
1258 continue;
1259 }
1260 roots.push(idx);
1261 }
1262
1263 fn push_tree(
1264 idx: usize,
1265 depth: usize,
1266 seeds: &[AgentRowSeed],
1267 children: &std::collections::HashMap<&str, Vec<usize>>,
1268 seen: &mut HashSet<usize>,
1269 order: &mut Vec<(usize, usize)>,
1270 ) {
1271 if !seen.insert(idx) {
1272 return;
1273 }
1274 order.push((idx, depth));
1275 if let Some(child_indices) = children.get(seeds[idx].agent_id.as_str()) {
1276 for child_idx in child_indices {
1277 push_tree(*child_idx, depth + 1, seeds, children, seen, order);
1278 }
1279 }
1280 }
1281
1282 let mut order = Vec::with_capacity(seeds.len());
1283 let mut seen = HashSet::new();
1284 for idx in roots {
1285 push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
1286 }
1287 // Cycle/orphan backstop: emit anything the walk missed at the top level.
1288 for idx in 0..seeds.len() {
1289 push_tree(idx, 0, &seeds, &children, &mut seen, &mut order);
1290 }
1291
1292 // `(+N)` counts children that are actually on this surface: the same map
1293 // the tree walk used, so the badge can never promise a child the list does
1294 // not show. Snapshot it before `seeds` is consumed — `children` borrows it.
1295 let child_counts: Vec<usize> = seeds
1296 .iter()
1297 .map(|seed| {
1298 children
1299 .get(seed.agent_id.as_str())
1300 .map_or(0, |indices| indices.len())
1301 })
1302 .collect();
1303
1304 let mut slots: Vec<Option<AgentRowSeed>> = seeds.into_iter().map(Some).collect();
1305 order
1306 .into_iter()
1307 .enumerate()
1308 .map(|(position, (idx, depth))| {
1309 let seed = slots[idx].take().expect("each row emitted exactly once");
1310 let mut ranked = seed.ranked;
1311 let indent = agent_nesting_indent(depth.min(3));
1312 let role_label = agent_strip_label(&indent, &seed.role, child_counts[idx]);
1313 ranked.row.label = match seed.name.as_deref() {
1314 Some(name) => agent_strip_label(&indent, name, child_counts[idx]),
1315 None => role_label.clone(),
1316 };
1317 if let Some(facts) = ranked.row.agent.as_mut() {
1318 facts.role_label = role_label;
1319 }
1320 // `ordered_rows` re-sorts within status buckets by `order`; stamp
1321 // the tree position so a child sorts directly under its parent
1322 // whenever they share a bucket.
1323 ranked.order = position;
1324 ranked
1325 })
1326 .collect()
1327 }
1328
1329 fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
1330 let cached_ids = app
1331 .subagent_cache
1332 .iter()
1333 .filter(|agent| !agent.from_prior_session)
1334 .map(|agent| agent.agent_id.as_str())
1335 .collect::<HashSet<_>>();
1336 let mut seeds = app
1337 .subagent_cache
1338 .iter()
1339 .filter(|agent| !agent.from_prior_session)
1340 .enumerate()
1341 .map(|(order, agent)| {
1342 let meta = app.agent_progress_meta.get(&agent.agent_id);
1343 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
1344 let status = current_activity
1345 .map(|activity| current_activity_status_label(activity.status))
1346 .or_else(|| agent.worker_status.map(worker_status_label))
1347 .unwrap_or_else(|| subagent_status_label(&agent.status));
1348 let bucket = current_activity
1349 .map(|activity| current_activity_status_bucket(activity.status))
1350 .or_else(|| agent.worker_status.map(worker_status_bucket))
1351 .unwrap_or_else(|| subagent_status_bucket(&agent.status));
1352 let role = agent
1353 .assignment
1354 .role
1355 .as_deref()
1356 .filter(|role| !role.trim().is_empty())
1357 .unwrap_or_else(|| agent.agent_type.as_str())
1358 .to_string();
1359 // A name is a nickname or stable label — never `agent.name`,
1360 // which is the raw session id hash (#36). Absent rather than
1361 // fabricated, so the identity column falls back to the role.
1362 let name = agent
1363 .nickname
1364 .clone()
1365 .filter(|name| !name.trim().is_empty() && name != &agent.agent_id)
1366 .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned());
1367 let terminal = agent_is_terminal(agent, meta);
1368 let objective = summarize_assignment(&agent.assignment.objective);
1369 let mut facts = vec![status.to_string(), objective.clone()];
1370 // Quiet completion (#36): a finished agent keeps its one-line
1371 // status and objective; in-flight metadata (current tool, step
1372 // counters, file tallies) is working state, not a receipt, and
1373 // must not linger as a spawn-metadata dump after the run ends.
1374 if !terminal {
1375 if let Some(detail) =
1376 current_activity.and_then(|activity| activity.detail.as_deref())
1377 {
1378 facts.push(detail.to_string());
1379 }
1380 if let Some(tool) =
1381 current_activity.and_then(|activity| activity.current_tool.as_deref())
1382 {
1383 facts.push(format!("using {tool}"));
1384 }
1385 if let Some(step) = current_activity.and_then(|activity| activity.step) {
1386 facts.push(format!("step {step}"));
1387 }
1388 if let Some(files) = meta
1389 .map(|meta| meta.files_touched)
1390 .filter(|count| *count > 0)
1391 {
1392 facts.push(format!("{files} files changed"));
1393 }
1394 }
1395 AgentRowSeed {
1396 agent_id: agent.agent_id.clone(),
1397 parent_run_id: agent.parent_run_id.clone(),
1398 role,
1399 name,
1400 ranked: RankedWorkRow {
1401 bucket,
1402 order,
1403 is_plan_step: false,
1404 row: WorkRow {
1405 id: WorkRowId(format!("worker:{}", agent.agent_id)),
1406 mark: agent_mark(bucket),
1407 // Stamped by `order_agent_seeds` once the display
1408 // depth (and therefore the indent) is known.
1409 label: String::new(),
1410 detail: facts.join(" · "),
1411 tone: bucket_tone(bucket),
1412 selectable: true,
1413 primary_action: Some(SidebarRowAction::OpenAgentDetail {
1414 agent_id: agent.agent_id.clone(),
1415 }),
1416 agent: Some(AgentRowFacts {
1417 // Stamped by `order_agent_seeds`, which is where
1418 // the indent and child count become known.
1419 role_label: String::new(),
1420 status: status.to_string(),
1421 objective,
1422 elapsed_secs: Some(
1423 agent_elapsed_ms(app, &agent.agent_id, agent.duration_ms) / 1_000,
1424 ),
1425 tokens: meta.and_then(|meta| meta.received_tokens),
1426 todos_remaining: meta.and_then(|meta| meta.todos_remaining),
1427 }),
1428 },
1429 },
1430 }
1431 })
1432 .collect::<Vec<_>>();
1433
1434 let mut progress_only = app
1435 .agent_progress
1436 .iter()
1437 .filter(|(id, _)| !cached_ids.contains(id.as_str()))
1438 .collect::<Vec<_>>();
1439 progress_only.sort_by_key(|(id, _)| (*id).clone());
1440 seeds.extend(
1441 progress_only
1442 .into_iter()
1443 .enumerate()
1444 .map(|(order, (id, _progress))| {
1445 let meta = app.agent_progress_meta.get(id);
1446 let current_activity = meta.and_then(|meta| meta.current_activity.as_ref());
1447 let status = current_activity
1448 .map(|activity| current_activity_status_label(activity.status))
1449 .unwrap_or("running");
1450 let bucket = current_activity
1451 .map(|activity| current_activity_status_bucket(activity.status))
1452 .unwrap_or(WorkBucket::Active);
1453 let name = app.agent_label_map.get(id).cloned();
1454 let mut facts = vec![status.to_string()];
1455 if let Some(detail) =
1456 current_activity.and_then(|activity| activity.detail.as_deref())
1457 {
1458 facts.push(detail.to_string());
1459 }
1460 if let Some(tool) =
1461 current_activity.and_then(|activity| activity.current_tool.as_deref())
1462 {
1463 facts.push(format!("using {tool}"));
1464 }
1465 if let Some(step) = current_activity.and_then(|activity| activity.step) {
1466 facts.push(format!("step {step}"));
1467 }
1468 if let Some(files) = meta
1469 .map(|meta| meta.files_touched)
1470 .filter(|count| *count > 0)
1471 {
1472 facts.push(format!("{files} files changed"));
1473 }
1474 AgentRowSeed {
1475 agent_id: id.clone(),
1476 parent_run_id: meta.and_then(|meta| meta.parent_run_id.clone()),
1477 // Role is unknown until the manager snapshot arrives;
1478 // "agent" is the honest fallback, not a fabrication.
1479 role: "agent".to_string(),
1480 name,
1481 ranked: RankedWorkRow {
1482 bucket,
1483 order: 5_000usize.saturating_add(order),
1484 is_plan_step: false,
1485 row: WorkRow {
1486 id: WorkRowId(format!("worker:{id}")),
1487 mark: agent_mark(bucket),
1488 label: String::new(),
1489 detail: facts.join(" · "),
1490 tone: bucket_tone(bucket),
1491 selectable: true,
1492 primary_action: Some(SidebarRowAction::OpenAgentDetail {
1493 agent_id: id.clone(),
1494 }),
1495 agent: Some(AgentRowFacts {
1496 role_label: String::new(),
1497 status: status.to_string(),
1498 // No manager snapshot yet, so there is no
1499 // assignment to quote: the live activity line
1500 // is the honest answer to "what is it doing".
1501 // The status word itself is the status
1502 // column's job, so it is not repeated here.
1503 objective: facts[1..].join(" · "),
1504 // Neither a duration nor a usage envelope has
1505 // been seen for this id. Both render as
1506 // nothing rather than as `0s` / `0 tokens`.
1507 elapsed_secs: None,
1508 tokens: meta.and_then(|meta| meta.received_tokens),
1509 todos_remaining: meta.and_then(|meta| meta.todos_remaining),
1510 }),
1511 },
1512 },
1513 }
1514 }),
1515 );
1516 order_agent_seeds(seeds)
1517 }
1518
1519 fn summarize_assignment(value: &str) -> String {
1520 // Flatten newlines the way the goal-title path does: a multi-line
1521 // objective must not break the one-line work-bar row (2026-08-04 review).
1522 let summary = crate::tui::history::summarize_tool_output(value);
1523 if summary.contains(['\n', '\r']) {
1524 summary.replace(['\n', '\r'], " ")
1525 } else {
1526 summary
1527 }
1528 }
1529
1530 /// Has this agent stopped working? Typed live activity wins over the worker
1531 /// status, which in turn wins over the coarse manager status — the same
1532 /// precedence the row's status label and bucket already use.
1533 fn agent_is_terminal(agent: &SubAgentResult, meta: Option<&AgentProgressMeta>) -> bool {
1534 meta.and_then(|meta| meta.current_activity.as_ref())
1535 .map(|activity| {
1536 matches!(
1537 activity.status,
1538 AgentCurrentActivityStatus::Done
1539 | AgentCurrentActivityStatus::Canceled
1540 | AgentCurrentActivityStatus::Failed
1541 | AgentCurrentActivityStatus::Interrupted
1542 )
1543 })
1544 .or_else(|| {
1545 agent.worker_status.map(|worker_status| {
1546 matches!(
1547 worker_status,
1548 AgentWorkerStatus::Completed
1549 | AgentWorkerStatus::Cancelled
1550 | AgentWorkerStatus::Failed
1551 | AgentWorkerStatus::Interrupted
1552 )
1553 })
1554 })
1555 .unwrap_or(matches!(
1556 agent.status,
1557 SubAgentStatus::Completed
1558 | SubAgentStatus::Cancelled
1559 | SubAgentStatus::Failed(_)
1560 | SubAgentStatus::Interrupted(_)
1561 | SubAgentStatus::BudgetExhausted
1562 ))
1563 }
1564
1565 /// Latch each finished agent's elapsed time the first frame it is observed
1566 /// terminal, and forget agents that have left the cache.
1567 ///
1568 /// The manager recomputes `SubAgentResult::duration_ms` as
1569 /// `started_at.elapsed()` on every snapshot, so a completed agent's duration
1570 /// keeps growing for as long as it stays listed. Without this pass a finished
1571 /// row would tick forever, which is exactly the thing a receipt must not do.
1572 fn freeze_terminal_agent_elapsed(app: &mut App) {
1573 let live: HashSet<&str> = app
1574 .subagent_cache
1575 .iter()
1576 .map(|agent| agent.agent_id.as_str())
1577 .collect();
1578 app.work_surface
1579 .frozen_agent_elapsed_ms
1580 .retain(|id, _| live.contains(id.as_str()));
1581
1582 for agent in &app.subagent_cache {
1583 if !agent_is_terminal(agent, app.agent_progress_meta.get(&agent.agent_id)) {
1584 continue;
1585 }
1586 app.work_surface
1587 .frozen_agent_elapsed_ms
1588 .entry(agent.agent_id.clone())
1589 .or_insert(agent.duration_ms);
1590 }
1591 }
1592
1593 /// Frozen elapsed for a finished agent, live elapsed for a running one.
1594 fn agent_elapsed_ms(app: &App, agent_id: &str, duration_ms: u64) -> u64 {
1595 app.work_surface
1596 .frozen_agent_elapsed_ms
1597 .get(agent_id)
1598 .copied()
1599 .unwrap_or(duration_ms)
1600 }
1601
1602 fn current_activity_status_bucket(status: AgentCurrentActivityStatus) -> WorkBucket {
1603 match status {
1604 AgentCurrentActivityStatus::Waiting
1605 | AgentCurrentActivityStatus::Interrupted
1606 | AgentCurrentActivityStatus::Failed => WorkBucket::Attention,
1607 AgentCurrentActivityStatus::Queued => WorkBucket::Ready,
1608 AgentCurrentActivityStatus::Done | AgentCurrentActivityStatus::Canceled => {
1609 WorkBucket::Recent
1610 }
1611 AgentCurrentActivityStatus::Starting
1612 | AgentCurrentActivityStatus::Running
1613 | AgentCurrentActivityStatus::ModelWait
1614 | AgentCurrentActivityStatus::RunningTool => WorkBucket::Active,
1615 }
1616 }
1617
1618 fn current_activity_status_label(status: AgentCurrentActivityStatus) -> &'static str {
1619 match status {
1620 AgentCurrentActivityStatus::Queued => "queued",
1621 AgentCurrentActivityStatus::Starting => "starting",
1622 AgentCurrentActivityStatus::Running => "running",
1623 AgentCurrentActivityStatus::ModelWait => "waiting for model",
1624 AgentCurrentActivityStatus::RunningTool => "running tool",
1625 AgentCurrentActivityStatus::Waiting => "waiting for input",
1626 AgentCurrentActivityStatus::Done => "completed",
1627 AgentCurrentActivityStatus::Failed => "failed",
1628 AgentCurrentActivityStatus::Canceled => "cancelled",
1629 AgentCurrentActivityStatus::Interrupted => "interrupted",
1630 }
1631 }
1632
1633 fn worker_status_bucket(status: AgentWorkerStatus) -> WorkBucket {
1634 match status {
1635 AgentWorkerStatus::WaitingForUser
1636 | AgentWorkerStatus::Interrupted
1637 | AgentWorkerStatus::Failed => WorkBucket::Attention,
1638 AgentWorkerStatus::Queued => WorkBucket::Ready,
1639 AgentWorkerStatus::Completed | AgentWorkerStatus::Cancelled => WorkBucket::Recent,
1640 AgentWorkerStatus::Starting
1641 | AgentWorkerStatus::Running
1642 | AgentWorkerStatus::ModelWait
1643 | AgentWorkerStatus::RunningTool => WorkBucket::Active,
1644 }
1645 }
1646
1647 fn worker_status_label(status: AgentWorkerStatus) -> &'static str {
1648 match status {
1649 AgentWorkerStatus::Queued => "queued",
1650 AgentWorkerStatus::Starting => "starting",
1651 AgentWorkerStatus::Running => "running",
1652 AgentWorkerStatus::WaitingForUser => "waiting for input",
1653 AgentWorkerStatus::ModelWait => "waiting for model",
1654 AgentWorkerStatus::RunningTool => "running tool",
1655 AgentWorkerStatus::Completed => "completed",
1656 AgentWorkerStatus::Failed => "failed",
1657 AgentWorkerStatus::Cancelled => "cancelled",
1658 AgentWorkerStatus::Interrupted => "interrupted",
1659 }
1660 }
1661
1662 fn subagent_status_bucket(status: &SubAgentStatus) -> WorkBucket {
1663 match status {
1664 SubAgentStatus::Running => WorkBucket::Active,
1665 SubAgentStatus::Interrupted(_)
1666 | SubAgentStatus::Failed(_)
1667 | SubAgentStatus::BudgetExhausted => WorkBucket::Attention,
1668 SubAgentStatus::Completed | SubAgentStatus::Cancelled => WorkBucket::Recent,
1669 }
1670 }
1671
1672 fn subagent_status_label(status: &SubAgentStatus) -> &'static str {
1673 match status {
1674 SubAgentStatus::Running => "running",
1675 SubAgentStatus::Completed => "completed",
1676 SubAgentStatus::Interrupted(_) => "interrupted",
1677 SubAgentStatus::Failed(_) => "failed",
1678 SubAgentStatus::Cancelled => "cancelled",
1679 SubAgentStatus::BudgetExhausted => "budget exhausted",
1680 }
1681 }
1682
1683 const fn bucket_tone(bucket: WorkBucket) -> WorkTone {
1684 match bucket {
1685 WorkBucket::Active => WorkTone::Live,
1686 WorkBucket::Attention => WorkTone::Attention,
1687 WorkBucket::Ready => WorkTone::Muted,
1688 WorkBucket::Recent => WorkTone::Success,
1689 }
1690 }
1691
1692 const fn agent_mark(bucket: WorkBucket) -> &'static str {
1693 match bucket {
1694 WorkBucket::Active => crate::tui::glyphs::SELECTION,
1695 WorkBucket::Attention => crate::tui::glyphs::ATTENTION,
1696 WorkBucket::Ready => crate::tui::glyphs::READY,
1697 WorkBucket::Recent => crate::tui::glyphs::DONE,
1698 }
1699 }
1700
1701 fn settled_file_activity(app: &App) -> SettledFileActivity {
1702 let mut activity = SettledFileActivity {
1703 inline_diff_mode: app.inline_diff_mode,
1704 ..SettledFileActivity::default()
1705 };
1706 let mut seen = HashSet::new();
1707 for index in 0..app.virtual_cell_count() {
1708 let Some(HistoryCell::Tool(cell)) = app.cell_at_virtual_index(index) else {
1709 continue;
1710 };
1711 if !cell.is_success() {
1712 continue;
1713 }
1714 let Some(detail) = app.tool_detail_record_for_cell(index) else {
1715 continue;
1716 };
1717 let activity_tool_name = canonical_action_alias(&detail.tool_name, &detail.input);
1718 let kind = if matches!(cell, ToolCell::PatchSummary(_)) {
1719 Some(FileActivityKind::Write)
1720 } else {
1721 FileActivitySummary::from_tool_name(activity_tool_name)
1722 };
1723 let Some(kind) = kind else {
1724 continue;
1725 };
1726 if !seen.insert(detail.tool_id.as_str()) {
1727 continue;
1728 }
1729 activity.summary.record(kind);
1730 if kind == FileActivityKind::Write
1731 && let ToolCell::PatchSummary(mutation) = cell
1732 && let Some(receipt) = mutation.receipt.as_ref()
1733 {
1734 let additional_files =
1735 u32::try_from(receipt.files.len().saturating_sub(1)).unwrap_or(u32::MAX);
1736 activity.summary.files_written = activity
1737 .summary
1738 .files_written
1739 .saturating_add(additional_files);
1740 activity.mutations.push(receipt.clone());
1741 }
1742 let target = activity_target(&app.workspace, activity_tool_name, &detail.input, kind);
1743 let details = match kind {
1744 FileActivityKind::Read => &mut activity.read,
1745 FileActivityKind::List => &mut activity.list,
1746 FileActivityKind::Search => &mut activity.search,
1747 FileActivityKind::Write => &mut activity.write,
1748 };
1749 if let Some(target) = target
1750 && details.len() < 12
1751 && !details.contains(&target)
1752 {
1753 details.push(target);
1754 }
1755 }
1756 activity
1757 }
1758
1759 fn aggregate_activity_row(activity: &SettledFileActivity) -> Option<RankedWorkRow> {
1760 if activity.is_empty() {
1761 return None;
1762 }
1763 let summaries = activity.summary.compact_display();
1764 if summaries.is_empty() {
1765 return None;
1766 }
1767 // Single aggregated live receipt; never inline raw patterns/commands (#4690).
1768 let label = if summaries.len() == 1 {
1769 summaries[0].clone()
1770 } else {
1771 summaries.join(" · ")
1772 };
1773 let mutation_detail = activity.mutations.last().map(|receipt| {
1774 if activity.inline_diff_mode == InlineDiffMode::Off {
1775 receipt.outcome_label()
1776 } else {
1777 receipt.semantic_summary()
1778 }
1779 });
1780 let mutation_body = settled_mutation_body(&activity.mutations, activity.inline_diff_mode);
1781 let mut body_parts = Vec::new();
1782 if !mutation_body.is_empty() {
1783 body_parts.push(mutation_body);
1784 }
1785 for (kind, details) in [
1786 ("Read", &activity.read),
1787 ("List", &activity.list),
1788 ("Search", &activity.search),
1789 ("Write", &activity.write),
1790 ] {
1791 if details.is_empty() {
1792 continue;
1793 }
1794 body_parts.push(format!("{kind}:\n{}", details.join("\n")));
1795 }
1796 if body_parts.is_empty() {
1797 body_parts.push("No safe target detail retained".to_string());
1798 }
1799 let detail = mutation_detail
1800 .or_else(|| {
1801 activity
1802 .write
1803 .first()
1804 .cloned()
1805 .or_else(|| activity.read.first().cloned())
1806 .or_else(|| activity.search.first().map(|_| "patterns".to_string()))
1807 .or_else(|| activity.list.first().cloned())
1808 })
1809 .unwrap_or_else(|| "settled".to_string());
1810 let detail = sanitize_summary_title(&detail);
1811 Some(RankedWorkRow {
1812 bucket: WorkBucket::Recent,
1813 order: 20_000,
1814 is_plan_step: false,
1815 row: WorkRow {
1816 id: WorkRowId("activity:aggregate".to_string()),
1817 mark: crate::tui::glyphs::DONE,
1818 label,
1819 detail,
1820 tone: WorkTone::Success,
1821 selectable: true,
1822 primary_action: Some(SidebarRowAction::InspectWork {
1823 title: "Work · file activity".to_string(),
1824 body: body_parts.join("\n\n"),
1825 stop_action: None,
1826 }),
1827 agent: None,
1828 },
1829 })
1830 }
1831
1832 #[cfg(test)]
1833 fn activity_rows(activity: SettledFileActivity) -> Vec<RankedWorkRow> {
1834 aggregate_activity_row(&activity).into_iter().collect()
1835 }
1836
1837 fn settled_mutation_body(receipts: &[FileMutationReceipt], mode: InlineDiffMode) -> String {
1838 let Some(receipt) = receipts.last() else {
1839 return String::new();
1840 };
1841 let details = crate::tui::key_shortcuts::tool_details_shortcut_action_hint(
1842 "exact change evidence on the matching File receipt",
1843 );
1844 let hint = format!("Select the matching File receipt; {details}.");
1845 match mode {
1846 InlineDiffMode::Off => format!("{}\n\n{hint}", receipt.outcome_label()),
1847 InlineDiffMode::Summary => format!("{}\n\n{hint}", receipt.semantic_summary()),
1848 InlineDiffMode::Full => {
1849 let diff = receipt
1850 .display_diff
1851 .lines()
1852 .take(40)
1853 .collect::<Vec<_>>()
1854 .join("\n");
1855 if diff.trim().is_empty() {
1856 format!("{}\n\n{hint}", receipt.semantic_summary())
1857 } else {
1858 format!("{}\n\n{diff}\n\n{hint}", receipt.semantic_summary())
1859 }
1860 }
1861 }
1862 }
1863
1864 fn activity_target(
1865 workspace: &Path,
1866 tool_name: &str,
1867 input: &serde_json::Value,
1868 kind: FileActivityKind,
1869 ) -> Option<String> {
1870 if tool_name == "apply_patch"
1871 && let Ok(preflight) = crate::tools::apply_patch::preflight_apply_patch(input)
1872 {
1873 let targets = preflight
1874 .touched_files
1875 .iter()
1876 .filter_map(|path| privacy_safe_path(workspace, path))
1877 .take(4)
1878 .collect::<Vec<_>>();
1879 if !targets.is_empty() {
1880 return Some(targets.join(", "));
1881 }
1882 }
1883 let keys: &[&str] = match kind {
1884 FileActivityKind::Search => &["pattern", "query", "path"],
1885 _ => &["path", "file_path"],
1886 };
1887 keys.iter().find_map(|key| {
1888 let value = input.get(*key)?.as_str()?.trim();
1889 if value.is_empty() {
1890 return None;
1891 }
1892 if kind == FileActivityKind::Search && *key != "path" {
1893 return Some(safe_pattern(value));
1894 }
1895 privacy_safe_path(workspace, value)
1896 })
1897 }
1898
1899 fn privacy_safe_path(workspace: &Path, raw: &str) -> Option<String> {
1900 let path = Path::new(raw);
1901 let normalized_raw = raw.replace('\\', "/");
1902 let normalized_workspace = workspace.to_string_lossy().replace('\\', "/");
1903 let relative = if path.is_absolute() || normalized_raw.starts_with('/') {
1904 let workspace_prefix = normalized_workspace.trim_end_matches('/');
1905 if normalized_raw == workspace_prefix {
1906 ""
1907 } else {
1908 normalized_raw.strip_prefix(&format!("{workspace_prefix}/"))?
1909 }
1910 } else {
1911 normalized_raw.as_str()
1912 };
1913 let relative = Path::new(relative);
1914 if relative.components().any(|component| {
1915 matches!(
1916 component,
1917 Component::ParentDir | Component::RootDir | Component::Prefix(_)
1918 )
1919 }) {
1920 return None;
1921 }
1922 let display = relative.to_string_lossy().replace('\\', "/");
1923 (!display.is_empty()).then_some(display)
1924 }
1925
1926 fn safe_pattern(raw: &str) -> String {
1927 let single_line = raw.replace(['\n', '\r', '\t'], " ");
1928 let mut chars = single_line.chars();
1929 let prefix = chars.by_ref().take(80).collect::<String>();
1930 if chars.next().is_some() {
1931 format!("{prefix}…")
1932 } else {
1933 prefix
1934 }
1935 }
1936
1937 fn is_settled_transient_operation(node: &WorkNode) -> bool {
1938 node.kind == NodeKind::Operation
1939 && node
1940 .binding
1941 .as_ref()
1942 .is_some_and(|binding| !binding.durable)
1943 && match node.state {
1944 NodeState::Completed => node.acceptance.is_empty(),
1945 NodeState::Verified | NodeState::Superseded | NodeState::Cancelled => true,
1946 _ => false,
1947 }
1948 }
1949
1950 fn section_heading(id: &str, label: &str, detail: &str) -> WorkRow {
1951 WorkRow {
1952 id: WorkRowId(format!("section:{id}")),
1953 mark: "▾",
1954 label: label.to_string(),
1955 detail: detail.to_string(),
1956 tone: WorkTone::Heading,
1957 selectable: false,
1958 primary_action: None,
1959 agent: None,
1960 }
1961 }
1962
1963 /// The sub-agent heading is a real group door: selecting it reveals the full
1964 /// Agents panel, including settled workers whose exact transcripts remain
1965 /// available after the compact strip archives them.
1966 fn agents_section_heading(label: &str) -> WorkRow {
1967 WorkRow {
1968 id: WorkRowId("section:agents".to_string()),
1969 mark: "▾",
1970 label: label.to_string(),
1971 detail: "Open the full subagent register".to_string(),
1972 tone: WorkTone::Heading,
1973 selectable: true,
1974 primary_action: Some(SidebarRowAction::ShowSubagentsPanel),
1975 agent: None,
1976 }
1977 }
1978
1979 fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
1980 let (mark, tone) = match node.state {
1981 NodeState::Ready => (crate::tui::glyphs::READY, WorkTone::Muted),
1982 NodeState::Initializing => (crate::tui::glyphs::SELECTION, WorkTone::Live),
1983 NodeState::Active => (crate::tui::glyphs::SELECTION, WorkTone::Live),
1984 NodeState::Waiting => (crate::tui::glyphs::ATTENTION, WorkTone::Attention),
1985 NodeState::Blocked => (
1986 status_mark(StatusKind::Attention).glyph,
1987 WorkTone::Attention,
1988 ),
1989 NodeState::Completed if node.acceptance.is_empty() => {
1990 (status_mark(StatusKind::Done).glyph, WorkTone::Success)
1991 }
1992 NodeState::Completed => (
1993 status_mark(StatusKind::Attention).glyph,
1994 WorkTone::Attention,
1995 ),
1996 NodeState::Verified => (status_mark(StatusKind::Done).glyph, WorkTone::Success),
1997 NodeState::Stale => ("?", WorkTone::Attention),
1998 NodeState::Superseded | NodeState::Cancelled => ("−", WorkTone::Muted),
1999 NodeState::Failed => (crate::tui::glyphs::FAILED, WorkTone::Attention),
2000 };
2001 let state = state_label(node);
2002 let kind = kind_label(node.kind);
2003 // A to-do row always carries its status word in the detail column, using
2004 // the same vocabulary as `/task digest` (pending / in progress /
2005 // completed / cancelled). Only the redundant `· plan step` KIND suffix is
2006 // dropped — the strip's checkbox marks already say the row is a plan
2007 // step, but they do not say its state in words, and dropping the state
2008 // itself was the 0.9.4 regression (a pending to-do rendered no label at
2009 // all). Non-step nodes keep the state · kind pair.
2010 let detail = if node.kind == NodeKind::PlanStep {
2011 todo_state_label(node).to_string()
2012 } else {
2013 format!("{state} · {kind}")
2014 };
2015 let stop_action = node
2016 .state
2017 .is_live()
2018 .then(|| stop_action(node.binding.as_ref()))
2019 .flatten();
2020 WorkRow {
2021 id: WorkRowId(format!("graph:{}", node.id.as_str())),
2022 mark,
2023 label: node.title.clone(),
2024 detail,
2025 tone,
2026 selectable: true,
2027 primary_action: Some(SidebarRowAction::InspectWork {
2028 title: format!("Work · {}", node.title),
2029 body: inspector_text(snapshot, node),
2030 stop_action: stop_action.map(Box::new),
2031 }),
2032 agent: None,
2033 }
2034 }
2035
2036 /// Status word for a plan-step (to-do) row, aligned with the four-state
2037 /// To-do vocabulary the `/task digest` text surface uses. Graph-only states
2038 /// keep their graph names.
2039 fn todo_state_label(node: &WorkNode) -> &'static str {
2040 match node.state {
2041 NodeState::Ready => "pending",
2042 NodeState::Initializing | NodeState::Active => "in progress",
2043 _ => state_label(node),
2044 }
2045 }
2046
2047 fn state_label(node: &WorkNode) -> &'static str {
2048 match node.state {
2049 NodeState::Ready => "ready",
2050 NodeState::Initializing => "initializing",
2051 NodeState::Active => "running",
2052 NodeState::Waiting => "waiting",
2053 NodeState::Blocked => "blocked",
2054 NodeState::Completed if node.acceptance.is_empty() => "completed",
2055 NodeState::Completed => "completed · evidence pending",
2056 NodeState::Verified => "verified",
2057 NodeState::Stale => "stale",
2058 NodeState::Superseded => "superseded",
2059 NodeState::Cancelled => "cancelled",
2060 NodeState::Failed => "failed",
2061 }
2062 }
2063
2064 const fn kind_label(kind: NodeKind) -> &'static str {
2065 match kind {
2066 NodeKind::Objective => "objective",
2067 NodeKind::PlanStep => "plan step",
2068 NodeKind::Operation => "operation",
2069 NodeKind::Evidence => "evidence",
2070 NodeKind::Blocker => "blocker",
2071 NodeKind::Approval => "approval",
2072 NodeKind::RuntimeRef => "runtime",
2073 NodeKind::LaneRef => "lane",
2074 }
2075 }
2076
2077 fn stop_action(binding: Option<&OperationBinding>) -> Option<SidebarRowAction> {
2078 let binding = binding?;
2079 if let Some(id) = binding.external.strip_prefix("task:") {
2080 Some(SidebarRowAction::Command(format!("/task cancel {id}")))
2081 } else if let Some(id) = binding.external.strip_prefix("shell:") {
2082 Some(SidebarRowAction::Command(format!("/jobs cancel {id}")))
2083 } else if let Some(id) = binding.external.strip_prefix("worker:") {
2084 Some(SidebarRowAction::CancelAgent {
2085 agent_id: id.to_string(),
2086 })
2087 } else {
2088 binding
2089 .external
2090 .strip_prefix("workflow:")
2091 .map(|id| SidebarRowAction::Command(format!("/workflow cancel {id}")))
2092 }
2093 }
2094
2095 fn inspector_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2096 let mut out = String::new();
2097 section_text(
2098 &mut out,
2099 "Objective",
2100 objective_for(snapshot, node)
2101 .as_deref()
2102 .unwrap_or("Not connected"),
2103 );
2104 section_list(
2105 &mut out,
2106 "Prerequisites",
2107 related_nodes(snapshot, node, EdgeKind::DependsOn, true),
2108 );
2109 section_text(
2110 &mut out,
2111 "Current",
2112 &format!("{} · {}", state_label(node), kind_label(node.kind)),
2113 );
2114 section_list(
2115 &mut out,
2116 "Downstream impact",
2117 related_nodes(snapshot, node, EdgeKind::DependsOn, false),
2118 );
2119 section_text(&mut out, "Binding + lifecycle owner", &binding_text(node));
2120 section_text(
2121 &mut out,
2122 "Evidence vs acceptance",
2123 &evidence_text(snapshot, node),
2124 );
2125 section_text(
2126 &mut out,
2127 "Blockers / approvals",
2128 &blockers_approvals_text(snapshot, node),
2129 );
2130 section_text(&mut out, "Why next", &why_next(snapshot, node));
2131 section_text(
2132 &mut out,
2133 "Provenance + last reconcile",
2134 &provenance_text(node),
2135 );
2136 if node.state == NodeState::Stale {
2137 section_text(
2138 &mut out,
2139 "Last bounded output",
2140 last_output_ref(snapshot, node)
2141 .as_deref()
2142 .unwrap_or("No output receipt"),
2143 );
2144 }
2145 out.trim_end().to_string()
2146 }
2147
2148 fn objective_for(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> Option<String> {
2149 if node.kind == NodeKind::Objective {
2150 return Some(node.title.clone());
2151 }
2152 let mut current = node.id.clone();
2153 let mut seen = HashSet::new();
2154 while seen.insert(current.clone()) {
2155 let Some(parent) = snapshot.edges.iter().find_map(|edge| {
2156 (edge.kind == EdgeKind::Contains && edge.to == current).then(|| edge.from.clone())
2157 }) else {
2158 break;
2159 };
2160 let Some(parent_node) = snapshot.node(&parent) else {
2161 break;
2162 };
2163 if parent_node.kind == NodeKind::Objective {
2164 return Some(parent_node.title.clone());
2165 }
2166 current = parent;
2167 }
2168 snapshot.compat.plan.objective.clone()
2169 }
2170
2171 fn related_nodes(
2172 snapshot: &WorkGraphSnapshot,
2173 node: &WorkNode,
2174 kind: EdgeKind,
2175 outgoing: bool,
2176 ) -> Vec<String> {
2177 snapshot
2178 .edges
2179 .iter()
2180 .filter(|edge| edge.kind == kind)
2181 .filter_map(|edge| {
2182 let related = if outgoing && edge.from == node.id {
2183 Some(&edge.to)
2184 } else if !outgoing && edge.to == node.id {
2185 Some(&edge.from)
2186 } else {
2187 None
2188 }?;
2189 snapshot
2190 .node(related)
2191 .map(|related| format!("{} · {}", related.title, state_label(related)))
2192 })
2193 .collect()
2194 }
2195
2196 fn binding_text(node: &WorkNode) -> String {
2197 let Some(binding) = node.binding.as_ref() else {
2198 return "Not bound".to_string();
2199 };
2200 let mut text = format!(
2201 "Owner: {}\nDurable: {}",
2202 binding.external,
2203 if binding.durable { "yes" } else { "no" }
2204 );
2205 if let Some(observation) = binding.last_observation.as_ref() {
2206 let owner_state = match observation.owner_state {
2207 OwnerState::Initializing => "initializing",
2208 OwnerState::Running => "running",
2209 OwnerState::Waiting => "waiting",
2210 OwnerState::Completed => "completed",
2211 OwnerState::Failed => "failed",
2212 OwnerState::Cancelled => "cancelled",
2213 };
2214 let _ = write!(
2215 text,
2216 "\nLast owner state: {owner_state}\nLast reconcile: {} ms UTC · sequence {}",
2217 observation.observed_at, observation.seq
2218 );
2219 } else {
2220 text.push_str("\nLast reconcile: never");
2221 }
2222 text
2223 }
2224
2225 fn evidence_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2226 let acceptance = if node.acceptance.is_empty() {
2227 vec!["- No evidence requirement".to_string()]
2228 } else {
2229 node.acceptance
2230 .iter()
2231 .map(|requirement| format!("- {}", acceptance_label(requirement)))
2232 .collect()
2233 };
2234 let evidence = evidence_for(snapshot, node);
2235 let evidence = if evidence.is_empty() {
2236 vec!["- None attached".to_string()]
2237 } else {
2238 evidence
2239 .into_iter()
2240 .map(|evidence| {
2241 let reference = evidence
2242 .evidence
2243 .as_ref()
2244 .map(|item| item.reference())
2245 .unwrap_or("invalid evidence node");
2246 format!("- {reference} · {}", state_label(evidence))
2247 })
2248 .collect()
2249 };
2250 format!(
2251 "Acceptance:\n{}\nEvidence:\n{}",
2252 acceptance.join("\n"),
2253 evidence.join("\n")
2254 )
2255 }
2256
2257 fn acceptance_label(requirement: &AcceptanceRequirement) -> String {
2258 match requirement {
2259 AcceptanceRequirement::EvidenceOfKind { kind } => {
2260 let kind = match kind {
2261 EvidenceKindTag::ToolRun => "tool run",
2262 EvidenceKindTag::Artifact => "artifact",
2263 EvidenceKindTag::TestSummary => "test summary",
2264 EvidenceKindTag::Receipt => "receipt",
2265 EvidenceKindTag::Approval => "approval",
2266 EvidenceKindTag::Route => "route",
2267 EvidenceKindTag::WebCitation => "web citation",
2268 };
2269 format!("evidence of kind {kind}")
2270 }
2271 }
2272 }
2273
2274 fn evidence_for<'a>(snapshot: &'a WorkGraphSnapshot, node: &WorkNode) -> Vec<&'a WorkNode> {
2275 snapshot
2276 .edges
2277 .iter()
2278 .filter(|edge| edge.kind == EdgeKind::Verifies && edge.to == node.id)
2279 .filter_map(|edge| snapshot.node(&edge.from))
2280 .collect()
2281 }
2282
2283 fn blockers_approvals_text(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2284 let mut lines = Vec::new();
2285 lines.extend(
2286 related_nodes(snapshot, node, EdgeKind::Blocks, false)
2287 .into_iter()
2288 .map(|item| format!("- Blocked by {item}")),
2289 );
2290 lines.extend(
2291 related_nodes(snapshot, node, EdgeKind::RequiresApproval, true)
2292 .into_iter()
2293 .map(|item| format!("- Approval {item}")),
2294 );
2295 if node.kind == NodeKind::PlanStep {
2296 lines.extend(
2297 snapshot
2298 .nodes
2299 .iter()
2300 .filter(|candidate| candidate.kind == NodeKind::Approval)
2301 .map(|approval| format!("- {} · {}", approval.title, state_label(approval))),
2302 );
2303 }
2304 if lines.is_empty() {
2305 "None".to_string()
2306 } else {
2307 lines.join("\n")
2308 }
2309 }
2310
2311 fn why_next(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> String {
2312 match node.state {
2313 NodeState::Ready => {
2314 let pending = related_nodes(snapshot, node, EdgeKind::DependsOn, true);
2315 if pending.is_empty() {
2316 "Ready with no recorded prerequisite".to_string()
2317 } else {
2318 format!("Ready after: {}", pending.join(", "))
2319 }
2320 }
2321 NodeState::Initializing => "Spawn intent is registered; awaiting owner handle".to_string(),
2322 NodeState::Active => "Lifecycle owner reports active work".to_string(),
2323 NodeState::Waiting => "Waiting on an owner or approval".to_string(),
2324 NodeState::Blocked => "Blocked; resolve the causes above".to_string(),
2325 NodeState::Completed if !node.acceptance.is_empty() => {
2326 "Execution ended, but acceptance evidence is still missing".to_string()
2327 }
2328 NodeState::Stale => "Owner cannot confirm liveness after reconciliation".to_string(),
2329 NodeState::Verified => "Acceptance evidence is satisfied".to_string(),
2330 NodeState::Completed => "Completed with no evidence requirement".to_string(),
2331 NodeState::Superseded => "A replacement node owns this work".to_string(),
2332 NodeState::Cancelled => "Cancelled by lifecycle owner".to_string(),
2333 NodeState::Failed => "Failed; inspect owner output before retrying".to_string(),
2334 }
2335 }
2336
2337 fn provenance_text(node: &WorkNode) -> String {
2338 let provenance = match &node.provenance {
2339 Provenance::Import { ordinal, .. } => ordinal
2340 .map(|ordinal| format!("legacy import · ordinal {ordinal}"))
2341 .unwrap_or_else(|| "legacy import".to_string()),
2342 Provenance::ToolUpdate { tool, call_id } => {
2343 format!("tool {tool} · call {call_id}")
2344 }
2345 Provenance::RuntimeReconcile {
2346 source,
2347 observed_at,
2348 } => format!("runtime {source} · {observed_at} ms UTC"),
2349 Provenance::UserEdit { proposal_id } => format!("user-approved diff {proposal_id}"),
2350 };
2351 let reconcile = node
2352 .binding
2353 .as_ref()
2354 .and_then(|binding| binding.last_observation.as_ref())
2355 .map(|observation| format!("{} ms UTC", observation.observed_at))
2356 .unwrap_or_else(|| "never".to_string());
2357 format!("Source: {provenance}\nLast reconcile: {reconcile}")
2358 }
2359
2360 fn last_output_ref(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> Option<String> {
2361 node.binding
2362 .as_ref()
2363 .and_then(|binding| binding.last_observation.as_ref())
2364 .and_then(|observation| observation.output.as_ref())
2365 .map(format_evidence_ref)
2366 .or_else(|| {
2367 evidence_for(snapshot, node)
2368 .into_iter()
2369 .max_by_key(|evidence| evidence.updated_at)
2370 .and_then(|evidence| evidence.evidence.as_ref())
2371 .map(format_evidence_ref)
2372 })
2373 }
2374
2375 fn format_evidence_ref(evidence: &crate::work_graph::EvidenceRef) -> String {
2376 let kind = match evidence.kind() {
2377 EvidenceKind::ToolRun => "tool run".to_string(),
2378 EvidenceKind::Artifact { .. } => "artifact".to_string(),
2379 EvidenceKind::TestSummary => "test summary".to_string(),
2380 EvidenceKind::Receipt { .. } => "receipt".to_string(),
2381 EvidenceKind::Approval => "approval".to_string(),
2382 EvidenceKind::Route => "route".to_string(),
2383 EvidenceKind::WebCitation {
2384 url, retrieved_at, ..
2385 } => format!("web citation · {url} · retrieved {retrieved_at}"),
2386 };
2387 let bytes = evidence
2388 .raw_bytes()
2389 .map(|bytes| format!(" · {bytes} raw bytes"))
2390 .unwrap_or_default();
2391 let truncation = if evidence.truncated() {
2392 " · truncated"
2393 } else {
2394 ""
2395 };
2396 format!("{} · {kind}{bytes}{truncation}", evidence.reference())
2397 }
2398
2399 fn section_text(out: &mut String, title: &str, body: &str) {
2400 let _ = writeln!(out, "{title}\n{body}\n");
2401 }
2402
2403 fn section_list(out: &mut String, title: &str, items: Vec<String>) {
2404 if items.is_empty() {
2405 section_text(out, title, "None");
2406 } else {
2407 section_text(
2408 out,
2409 title,
2410 &items
2411 .into_iter()
2412 .map(|item| format!("- {item}"))
2413 .collect::<Vec<_>>()
2414 .join("\n"),
2415 );
2416 }
2417 }
2418
2419 #[cfg(test)]
2420 mod tests {
2421 use super::*;
2422 use crate::config::Config;
2423 use crate::tools::spec::ToolResult;
2424 use crate::tui::app::TuiOptions;
2425 use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started};
2426 use crate::work_graph::{CompatTodoBinding, OperationBinding, WorkNodeId};
2427
2428 fn test_app() -> App {
2429 App::new(
2430 TuiOptions {
2431 model: "deepseek-v4-flash".to_string(),
2432 start_in_agent_mode: true,
2433 ..crate::test_support::test_tui_options(std::path::PathBuf::from(
2434 "/workspace/project",
2435 ))
2436 },
2437 &Config::default(),
2438 )
2439 }
2440
2441 fn surface() -> WorkSurfaceState {
2442 WorkSurfaceState::default()
2443 }
2444
2445 fn operation(state: NodeState, suffix: &str) -> WorkNode {
2446 WorkNode {
2447 id: WorkNodeId::derive("work-surface-test", suffix),
2448 kind: NodeKind::Operation,
2449 title: format!("operation {suffix}"),
2450 state,
2451 acceptance: Vec::new(),
2452 binding: Some(OperationBinding {
2453 external: format!("shell:{suffix}"),
2454 durable: false,
2455 last_observation: None,
2456 }),
2457 evidence: None,
2458 provenance: Provenance::ToolUpdate {
2459 tool: "test".to_string(),
2460 call_id: suffix.to_string(),
2461 },
2462 created_at: 1,
2463 updated_at: 1,
2464 }
2465 }
2466
2467 /// After the recent-only TTL suppresses transient receipts, the live
2468 /// heading must count only the recent rows the live list still shows —
2469 /// quoting the unfiltered total would claim receipts the reader cannot
2470 /// see (2026-08-04 adversarial review of the durable-row exemption).
2471 #[test]
2472 fn suppressed_transients_leave_the_live_heading_count_honest() {
2473 let mut plan_step = operation(NodeState::Completed, "shipped-step");
2474 plan_step.kind = NodeKind::PlanStep;
2475 plan_step.binding = None;
2476 let mut transient = operation(NodeState::Completed, "settled-op");
2477 transient.binding.as_mut().expect("binding").durable = true;
2478 let mut snapshot = WorkGraphSnapshot::new();
2479 snapshot.nodes = vec![plan_step, transient];
2480
2481 let mut surface = surface();
2482 surface.set_presentation_now_ms(0);
2483 let _ = graph_rows(
2484 &mut surface,
2485 &snapshot,
2486 None,
2487 Vec::new(),
2488 None,
2489 SettledFileActivity::default(),
2490 );
2491 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 1);
2492 let rows = graph_rows(
2493 &mut surface,
2494 &snapshot,
2495 None,
2496 Vec::new(),
2497 None,
2498 SettledFileActivity::default(),
2499 );
2500 let heading = &rows[0];
2501 assert!(
2502 heading.label.contains("1 recent"),
2503 "live heading counts only the surviving durable row: {}",
2504 heading.label
2505 );
2506 assert!(
2507 rows.iter().any(|row| row.label.contains("shipped-step")),
2508 "durable to-do row survives: {rows:?}"
2509 );
2510 assert!(
2511 !rows.iter().any(|row| row.label.contains("settled-op")),
2512 "transient receipt is suppressed: {rows:?}"
2513 );
2514 // The catalog keeps the full rows, so it keeps the full count.
2515 assert!(
2516 surface
2517 .catalog_rows
2518 .first()
2519 .is_some_and(|row| row.label.contains("2 recent")),
2520 "catalog heading keeps the unfiltered count: {:?}",
2521 surface.catalog_rows.first()
2522 );
2523 }
2524
2525 #[test]
2526 fn heading_counts_initializing_and_active_operations_as_running() {
2527 let mut snapshot = WorkGraphSnapshot::new();
2528 snapshot.nodes = vec![
2529 operation(NodeState::Initializing, "initializing"),
2530 operation(NodeState::Active, "active"),
2531 operation(NodeState::Ready, "ready"),
2532 ];
2533
2534 let rows = graph_rows(
2535 &mut surface(),
2536 &snapshot,
2537 None,
2538 Vec::new(),
2539 None,
2540 SettledFileActivity::default(),
2541 );
2542
2543 assert_eq!(
2544 rows.first().map(|row| row.label.as_str()),
2545 Some("Work · Running: operation initializing · 2 active")
2546 );
2547 }
2548
2549 #[test]
2550 fn live_projection_hides_clean_transient_receipts_without_duplicate_todo_group() {
2551 let todo_id = WorkNodeId::derive("work-surface-test", "todo:1");
2552 let todo = WorkNode {
2553 id: todo_id.clone(),
2554 kind: NodeKind::PlanStep,
2555 title: "Keep the durable checklist visible".to_string(),
2556 state: NodeState::Ready,
2557 acceptance: Vec::new(),
2558 binding: None,
2559 evidence: None,
2560 provenance: Provenance::ToolUpdate {
2561 tool: "work_update".to_string(),
2562 call_id: "todo-1".to_string(),
2563 },
2564 created_at: 1,
2565 updated_at: 1,
2566 };
2567 let mut snapshot = WorkGraphSnapshot::new();
2568 snapshot.nodes = vec![
2569 operation(NodeState::Completed, "settled"),
2570 operation(NodeState::Active, "running"),
2571 todo,
2572 ];
2573 snapshot.compat.todos.push(CompatTodoBinding {
2574 legacy_id: 1,
2575 node: todo_id,
2576 plan_index: None,
2577 });
2578
2579 let rows = graph_rows(
2580 &mut surface(),
2581 &snapshot,
2582 None,
2583 Vec::new(),
2584 None,
2585 SettledFileActivity::default(),
2586 );
2587 let labels = rows
2588 .iter()
2589 .map(|row| row.label.as_str())
2590 .collect::<Vec<_>>();
2591
2592 assert!(labels.contains(&"operation running"), "{labels:?}");
2593 assert!(!labels.contains(&"operation settled"), "{labels:?}");
2594 assert_eq!(
2595 labels
2596 .iter()
2597 .filter(|label| **label == "Keep the durable checklist visible")
2598 .count(),
2599 1,
2600 "one plan node must produce one Work row: {labels:?}"
2601 );
2602 assert!(
2603 !labels.iter().any(|label| label.starts_with("To-do")),
2604 "the ordered Work projection must not add a duplicate To-do heading: {labels:?}"
2605 );
2606 assert!(
2607 labels.contains(&"Keep the durable checklist visible"),
2608 "{labels:?}"
2609 );
2610 assert!(
2611 snapshot
2612 .nodes
2613 .iter()
2614 .any(|node| node.title == "operation settled"),
2615 "projection filtering must retain the historical graph receipt"
2616 );
2617 }
2618
2619 #[test]
2620 fn projection_keeps_durable_and_evidence_gated_terminal_operations() {
2621 let mut durable = operation(NodeState::Completed, "durable");
2622 durable.binding.as_mut().expect("binding").durable = true;
2623 let mut failed = operation(NodeState::Failed, "failed");
2624 failed.binding.as_mut().expect("binding").durable = true;
2625 let mut evidence_pending = operation(NodeState::Completed, "evidence-pending");
2626 evidence_pending.acceptance = vec![AcceptanceRequirement::EvidenceOfKind {
2627 kind: EvidenceKindTag::ToolRun,
2628 }];
2629 let mut snapshot = WorkGraphSnapshot::new();
2630 snapshot.nodes = vec![durable, failed, evidence_pending];
2631
2632 let rows = graph_rows(
2633 &mut surface(),
2634 &snapshot,
2635 None,
2636 Vec::new(),
2637 None,
2638 SettledFileActivity::default(),
2639 );
2640 let labels = rows
2641 .iter()
2642 .map(|row| row.label.as_str())
2643 .collect::<Vec<_>>();
2644
2645 for expected in [
2646 "operation durable",
2647 "operation failed",
2648 "operation evidence-pending",
2649 ] {
2650 assert!(labels.contains(&expected), "missing {expected}: {labels:?}");
2651 }
2652 }
2653
2654 #[test]
2655 fn transient_failed_operation_is_recent_while_durable_failure_needs_input() {
2656 let transient = operation(NodeState::Failed, "shell transient");
2657 let mut durable = operation(NodeState::Failed, "durable");
2658 durable.binding.as_mut().expect("binding").durable = true;
2659
2660 assert_eq!(node_bucket(&transient), WorkBucket::Recent);
2661 assert_eq!(node_bucket(&durable), WorkBucket::Attention);
2662 }
2663
2664 #[test]
2665 fn projection_orders_attention_before_ready_and_recent() {
2666 let mut recent = operation(NodeState::Completed, "recent");
2667 recent.binding.as_mut().expect("binding").durable = true;
2668 let mut snapshot = WorkGraphSnapshot::new();
2669 snapshot.nodes = vec![
2670 recent,
2671 operation(NodeState::Ready, "ready"),
2672 operation(NodeState::Blocked, "blocked"),
2673 operation(NodeState::Active, "active"),
2674 ];
2675
2676 let labels = graph_rows(
2677 &mut surface(),
2678 &snapshot,
2679 None,
2680 Vec::new(),
2681 None,
2682 SettledFileActivity::default(),
2683 )
2684 .into_iter()
2685 .map(|row| row.label)
2686 .collect::<Vec<_>>();
2687
2688 assert_eq!(
2689 labels,
2690 [
2691 "Work · Needs input: operation blocked · 1 blocked",
2692 "operation blocked",
2693 "operation active",
2694 "operation ready",
2695 "operation recent",
2696 ]
2697 );
2698 }
2699
2700 #[test]
2701 fn activity_targets_keep_workspace_relative_paths_and_hide_external_paths() {
2702 let workspace = Path::new("/workspace/project");
2703 assert_eq!(
2704 privacy_safe_path(workspace, "/workspace/project/src/lib.rs").as_deref(),
2705 Some("src/lib.rs")
2706 );
2707 assert_eq!(
2708 privacy_safe_path(workspace, "/Users/alice/private.txt"),
2709 None
2710 );
2711 assert_eq!(privacy_safe_path(workspace, "../private.txt"), None);
2712 assert_eq!(safe_pattern("needle\nsecret"), "needle secret");
2713 }
2714
2715 #[test]
2716 fn settled_canonical_file_actions_keep_aggregates_and_safe_targets() {
2717 let mut app = test_app();
2718 let calls = [
2719 ("read", serde_json::json!({"path": "src/read.rs"})),
2720 ("list", serde_json::json!({"path": "src"})),
2721 ("search_name", serde_json::json!({"query": "lib.rs"})),
2722 (
2723 "search_content",
2724 serde_json::json!({"pattern": "needle\nprivate", "path": "src"}),
2725 ),
2726 (
2727 "write",
2728 serde_json::json!({"path": "src/new.rs", "content": "new\n"}),
2729 ),
2730 (
2731 "edit",
2732 serde_json::json!({
2733 "path": "src/edit.rs",
2734 "search": "old",
2735 "replace": "new"
2736 }),
2737 ),
2738 (
2739 "patch",
2740 serde_json::json!({
2741 "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"
2742 }),
2743 ),
2744 ];
2745
2746 for (action, payload) in calls {
2747 let id = format!("file-{action}");
2748 let mut input = payload;
2749 input["action"] = serde_json::json!(action);
2750 handle_tool_call_started(&mut app, &id, "File", &input);
2751 handle_tool_call_complete(&mut app, &id, "File", &Ok(ToolResult::success("ok")));
2752 app.flush_active_cell();
2753 }
2754
2755 let activity = settled_file_activity(&app);
2756 assert_eq!(
2757 activity.summary,
2758 FileActivitySummary {
2759 files_read: 1,
2760 dirs_listed: 1,
2761 patterns_searched: 2,
2762 files_written: 3,
2763 }
2764 );
2765 assert_eq!(activity.read, ["src/read.rs"]);
2766 assert_eq!(activity.list, ["src"]);
2767 assert_eq!(activity.search, ["lib.rs", "needle private"]);
2768 assert_eq!(
2769 activity.write,
2770 ["src/new.rs", "src/edit.rs", "src/patch.rs"]
2771 );
2772 }
2773
2774 #[test]
2775 fn multifile_receipt_counts_semantic_file_outcomes_in_work_label() {
2776 let mut app = test_app();
2777 let input = serde_json::json!({
2778 "action": "patch",
2779 "patch": "--- a/update.rs\n+++ b/update.rs\n@@ -1 +1 @@\n-old\n+new\n"
2780 });
2781 handle_tool_call_started(&mut app, "file-multi", "File", &input);
2782 let result = ToolResult::success("ok").with_metadata(serde_json::json!({
2783 "mutation": {
2784 "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",
2785 "files": [
2786 { "path": "update.rs", "outcome": "updated" },
2787 { "path": "create.rs", "outcome": "created" },
2788 { "path": "delete.rs", "outcome": "deleted" }
2789 ],
2790 "renames": [{ "from": "old.rs", "to": "new.rs" }]
2791 }
2792 }));
2793 handle_tool_call_complete(&mut app, "file-multi", "File", &Ok(result));
2794 app.flush_active_cell();
2795
2796 let activity = settled_file_activity(&app);
2797 assert_eq!(activity.summary.files_written, 4);
2798 let write_row = activity_rows(activity)
2799 .into_iter()
2800 .find(|row| row.row.label.starts_with("Wrote"))
2801 .expect("write row");
2802 assert_eq!(write_row.row.label, "Wrote 4 files");
2803 assert_eq!(
2804 write_row.row.detail,
2805 "4 files · 1 created · 1 updated · 1 deleted · 1 renamed · +2 -2"
2806 );
2807 }
2808
2809 fn mutation_activity(mode: InlineDiffMode) -> SettledFileActivity {
2810 let result = ToolResult::success("ok").with_metadata(serde_json::json!({
2811 "mutation": {
2812 "diff": "--- /Users/alice/private.rs\n+++ /Users/alice/private.rs\n@@ -1 +1 @@\n-old\n+new\n",
2813 "files": [{
2814 "path": "/Users/alice/private.rs",
2815 "outcome": "updated"
2816 }],
2817 "renames": []
2818 }
2819 }));
2820 let receipt = FileMutationReceipt::from_success(Path::new("/workspace/project"), &result)
2821 .expect("receipt");
2822 SettledFileActivity {
2823 summary: FileActivitySummary {
2824 files_written: 1,
2825 ..FileActivitySummary::default()
2826 },
2827 write: vec!["src/public.rs".to_string()],
2828 mutations: vec![receipt],
2829 inline_diff_mode: mode,
2830 ..SettledFileActivity::default()
2831 }
2832 }
2833
2834 fn mutation_activity_body(mode: InlineDiffMode) -> (String, String, String) {
2835 let row = activity_rows(mutation_activity(mode))
2836 .into_iter()
2837 .next()
2838 .expect("activity row")
2839 .row;
2840 let SidebarRowAction::InspectWork { body, .. } =
2841 row.primary_action.expect("inspect action")
2842 else {
2843 panic!("write row must open Work inspection")
2844 };
2845 (row.label, row.detail, body)
2846 }
2847
2848 #[test]
2849 fn work_mutation_rows_keep_labels_privacy_and_all_inline_modes() {
2850 let (label, detail, full) = mutation_activity_body(InlineDiffMode::Full);
2851 assert_eq!(label, "Wrote 1 files");
2852 assert_eq!(detail, "Updated <external file> · +1 -1");
2853 assert!(full.contains("-old"), "{full}");
2854 assert!(full.contains("+new"), "{full}");
2855 assert!(!full.contains("alice"), "{full}");
2856 assert!(full.contains("exact change evidence"), "{full}");
2857
2858 let (_, _, summary) = mutation_activity_body(InlineDiffMode::Summary);
2859 assert!(
2860 summary.contains("Updated <external file> · +1 -1"),
2861 "{summary}"
2862 );
2863 assert!(!summary.contains("-old"), "{summary}");
2864 assert!(!summary.contains("+new"), "{summary}");
2865 assert!(!summary.contains("alice"), "{summary}");
2866
2867 let (_, detail, off) = mutation_activity_body(InlineDiffMode::Off);
2868 assert_eq!(detail, "Updated <external file>");
2869 assert!(off.contains("Updated <external file>"), "{off}");
2870 assert!(!off.contains("+1 -1"), "{off}");
2871 assert!(!off.contains("-old"), "{off}");
2872 assert!(!off.contains("alice"), "{off}");
2873 assert!(off.contains("exact change evidence"), "{off}");
2874 }
2875
2876 #[test]
2877 fn recent_only_summary_expires_after_ttl_and_user_turn() {
2878 let mut recent = operation(NodeState::Completed, "recent");
2879 recent.binding.as_mut().expect("binding").durable = true;
2880 let mut snapshot = WorkGraphSnapshot::new();
2881 snapshot.nodes = vec![recent];
2882
2883 let mut surface = surface();
2884 surface.set_presentation_now_ms(0);
2885 let rows = graph_rows(
2886 &mut surface,
2887 &snapshot,
2888 None,
2889 Vec::new(),
2890 None,
2891 SettledFileActivity::default(),
2892 );
2893 assert!(
2894 rows.iter().any(|row| row.id.0.starts_with("section:")),
2895 "recent-only should surface briefly: {rows:?}"
2896 );
2897
2898 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS);
2899 let expired = graph_rows(
2900 &mut surface,
2901 &snapshot,
2902 None,
2903 Vec::new(),
2904 None,
2905 SettledFileActivity::default(),
2906 );
2907 assert!(
2908 expired.is_empty(),
2909 "recent-only must collapse after TTL: {expired:?}"
2910 );
2911 // Catalog retains durable history for inspector/history.
2912 assert!(
2913 surface
2914 .catalog_rows
2915 .iter()
2916 .any(|row| row.label == "operation recent"),
2917 "catalog must keep recent work after live expiry"
2918 );
2919
2920 // New completion fingerprint re-surfaces once.
2921 let mut newer = operation(NodeState::Completed, "newer");
2922 newer.binding.as_mut().expect("binding").durable = true;
2923 snapshot.nodes.push(newer);
2924 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 10);
2925 let resurfaced = graph_rows(
2926 &mut surface,
2927 &snapshot,
2928 None,
2929 Vec::new(),
2930 None,
2931 SettledFileActivity::default(),
2932 );
2933 assert!(
2934 !resurfaced.is_empty(),
2935 "a new completion may surface once after expiry"
2936 );
2937
2938 // User turn hides immediately while still recent-only.
2939 surface.note_user_turn_or_new_operation();
2940 surface.set_presentation_now_ms(RECENT_ONLY_TTL_MS + 11);
2941 let after_turn = graph_rows(
2942 &mut surface,
2943 &snapshot,
2944 None,
2945 Vec::new(),
2946 None,
2947 SettledFileActivity::default(),
2948 );
2949 assert!(
2950 after_turn.is_empty(),
2951 "user turn must hide recent-only immediately: {after_turn:?}"
2952 );
2953 }
2954
2955 #[test]
2956 fn needs_input_and_ready_never_expire_with_clock() {
2957 let mut snapshot = WorkGraphSnapshot::new();
2958 snapshot.nodes = vec![
2959 operation(NodeState::Blocked, "blocked"),
2960 operation(NodeState::Ready, "ready"),
2961 ];
2962 let mut surface = surface();
2963 surface.set_presentation_now_ms(0);
2964 let _ = graph_rows(
2965 &mut surface,
2966 &snapshot,
2967 None,
2968 Vec::new(),
2969 None,
2970 SettledFileActivity::default(),
2971 );
2972 surface.set_presentation_now_ms(60_000);
2973 let rows = graph_rows(
2974 &mut surface,
2975 &snapshot,
2976 None,
2977 Vec::new(),
2978 None,
2979 SettledFileActivity::default(),
2980 );
2981 assert!(
2982 rows[0].label.starts_with("Work · Needs input:"),
2983 "{}",
2984 rows[0].label
2985 );
2986 assert!(rows.iter().any(|row| row.label == "operation blocked"));
2987 assert!(rows.iter().any(|row| row.label == "operation ready"));
2988 }
2989
2990 #[test]
2991 fn activity_receipts_aggregate_and_expire_without_raw_payloads() {
2992 let activity = SettledFileActivity {
2993 summary: FileActivitySummary {
2994 files_read: 1,
2995 patterns_searched: 2,
2996 files_written: 1,
2997 ..FileActivitySummary::default()
2998 },
2999 read: vec!["src/lib.rs".to_string()],
3000 search: vec!["(?i)super_secret_pattern_xyz".to_string()],
3001 write: vec!["src/main.rs".to_string()],
3002 ..SettledFileActivity::default()
3003 };
3004 let mut surface = surface();
3005 surface.set_presentation_now_ms(0);
3006 let rows = ordered_rows(&mut surface, None, None, Vec::new(), None, activity.clone());
3007 let activity_row = rows
3008 .iter()
3009 .find(|row| row.id.0 == "activity:aggregate")
3010 .expect("aggregate activity");
3011 assert!(activity_row.label.contains("Read 1 files"));
3012 assert!(activity_row.label.contains("Searched 2 patterns"));
3013 assert!(!activity_row.label.contains("super_secret_pattern_xyz"));
3014 assert!(!activity_row.detail.contains("super_secret_pattern_xyz"));
3015
3016 surface.set_presentation_now_ms(ACTIVITY_RECEIPT_TTL_MS);
3017 let expired = ordered_rows(&mut surface, None, None, Vec::new(), None, activity);
3018 assert!(
3019 expired.iter().all(|row| row.id.0 != "activity:aggregate"),
3020 "activity receipt must expire after TTL: {expired:?}"
3021 );
3022 }
3023
3024 #[test]
3025 fn summary_subject_prefers_attention_over_active_and_ready() {
3026 let mut snapshot = WorkGraphSnapshot::new();
3027 snapshot.nodes = vec![
3028 operation(NodeState::Active, "running"),
3029 operation(NodeState::Blocked, "choose a release target"),
3030 operation(NodeState::Ready, "review rebuilt binary"),
3031 ];
3032 let rows = graph_rows(
3033 &mut surface(),
3034 &snapshot,
3035 None,
3036 Vec::new(),
3037 None,
3038 SettledFileActivity::default(),
3039 );
3040 assert_eq!(
3041 rows[0].label,
3042 "Work · Needs input: operation choose a release target · 1 blocked"
3043 );
3044 }
3045 }
3046
3046 lines RUST