返回 CodeWhale
background_indicator.rs
根目录 / crates / tui / src / tui / background_indicator.rs
1 //! Compact live "pending background work" indicator near the composer.
2 //!
3 //! When the main turn is waiting on background shells, durable tasks, or
4 //! running sub-agents, a single chip row renders directly above the composer
5 //! so the user can see — at the exact place they are looking — that the model
6 //! is blocked on background work and on what. It auto-updates as items start
7 //! and finish, and the row collapses to zero rows entirely when nothing is
8 //! pending.
9 //!
10 //! # Source of truth
11 //!
12 //! The indicator mirrors state the Work strip and the `/jobs` surface already
13 //! read — it introduces no new registry and takes no lock in the render path:
14 //!
15 //! - **Durable tasks**: `App::task_panel` entries that are not live shells.
16 //! Background shells are first-class work-strip rows (`▾ Shells N`) and
17 //! are not mirrored here.
18 //! - **Sub-agents**: the union of `App::subagent_cache` entries still in
19 //! `Running` state and `App::agent_progress` keys — the same live projection
20 //! `running_agent_count` uses, so spawn/completion events update the chip
21 //! immediately.
22 //!
23 //! # Rendering
24 //!
25 //! `ui/frame.rs` reserves one extra layout row between the pending-input
26 //! preview and the composer, carves it from the auxiliary budget (so compact
27 //! terminals shed the chip before they shed chat/composer space), and calls
28 //! [`render`] only when [`PendingWork::is_empty`] is false.
29
30 use std::collections::HashSet;
31
32 use crate::tui::app::{App, TaskPanelEntry, TaskPanelEntryKind};
33 use codewhale_localization::truncate_to_width;
34
35 /// Per-item label cap so one long command or objective cannot eat the whole
36 /// row before the whole-line truncation kicks in.
37 const ITEM_LABEL_MAX_WIDTH: usize = 20;
38
39 /// Kind of in-flight background work the main turn is waiting on.
40 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
41 pub enum PendingItemKind {
42 /// Background shell job (the `/jobs` surface).
43 Shell,
44 /// Durable task / RLM run tracked by the TaskManager.
45 Task,
46 /// Running sub-agent / fleet worker.
47 Agent,
48 }
49
50 /// Lifecycle state carried by the App's background-work projection.
51 ///
52 /// The task panel currently receives wire-status tokens, so normalize them at
53 /// the projection boundary. Renderers then consume this typed state instead
54 /// of re-interpreting status strings.
55 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
56 pub enum PendingItemState {
57 Queued,
58 Running,
59 }
60
61 impl PendingItemKind {
62 /// Singular noun used for the count summary ("1 shell", "2 agents").
63 #[must_use]
64 pub fn noun(self) -> &'static str {
65 match self {
66 Self::Shell => "shell",
67 Self::Task => "task",
68 Self::Agent => "agent",
69 }
70 }
71
72 pub(crate) fn plural_noun(self, count: usize) -> String {
73 if count == 1 {
74 self.noun().to_string()
75 } else {
76 format!("{}s", self.noun())
77 }
78 }
79 }
80
81 /// One in-flight item shown in the chip row.
82 #[derive(Debug, Clone, PartialEq, Eq)]
83 pub struct PendingItem {
84 pub kind: PendingItemKind,
85 pub state: PendingItemState,
86 /// Short human label (task id / role / name / command), pre-truncated.
87 pub label: String,
88 }
89
90 /// Snapshot of the background work the main turn is waiting on.
91 #[derive(Debug, Clone, PartialEq, Eq, Default)]
92 pub struct PendingWork {
93 /// Shells and tasks first (in `task_panel` order), then agents in stable
94 /// id order. Empty means the indicator row is hidden.
95 pub items: Vec<PendingItem>,
96 }
97
98 impl PendingWork {
99 #[must_use]
100 pub fn count(&self, kind: PendingItemKind) -> usize {
101 self.items.iter().filter(|item| item.kind == kind).count()
102 }
103
104 #[must_use]
105 #[cfg(test)]
106 pub fn count_state(&self, state: PendingItemState) -> usize {
107 self.items.iter().filter(|item| item.state == state).count()
108 }
109 }
110
111 fn truncate_label(label: &str) -> String {
112 let trimmed = label.trim();
113 if trimmed.is_empty() {
114 "…".to_string()
115 } else {
116 truncate_to_width(trimmed, ITEM_LABEL_MAX_WIDTH)
117 }
118 }
119
120 /// Build the composer pending-work snapshot from the same state the Work
121 /// strip and `/jobs` surface render. Read-only; no locks, no registries.
122 ///
123 /// Live shells deliberately remain only on the detailed Work strip, rather
124 /// than being repeated in the composer crumb.
125 #[must_use]
126 pub fn pending_work_from_app(app: &App) -> PendingWork {
127 collect_pending_work(app)
128 }
129
130 fn collect_pending_work(app: &App) -> PendingWork {
131 let mut items: Vec<PendingItem> = Vec::new();
132
133 // Background shells and durable tasks: the merged task_panel snapshot
134 // refreshed by `refresh_active_task_panel` on the event loop. Shell jobs
135 // carry a `shell: <command>` summary; durable/RLM tasks carry their own
136 // prompt summary and their task id is the stable label.
137 for entry in &app.task_panel {
138 let Some(state) = pending_item_state(entry) else {
139 continue;
140 };
141 // Live shells belong on the work strip (`▾ Shells N`), not this
142 // composer crumb. A dual surface hid the PTY behind hourglasses.
143 let is_shell = is_live_shell_entry(entry);
144 if is_shell {
145 continue;
146 }
147 items.push(PendingItem {
148 kind: PendingItemKind::Task,
149 state,
150 label: truncate_label(entry.id.as_str()),
151 });
152 }
153
154 // Running sub-agents: subagent_cache first (richer: nickname/role), then
155 // agent_progress-only ids, deduped by id exactly like running_agent_count.
156 let mut seen: HashSet<&str> = HashSet::new();
157 for agent in app.subagent_cache.iter().filter(|agent| {
158 matches!(
159 agent.status,
160 crate::tools::subagent::SubAgentStatus::Running
161 )
162 }) {
163 if !seen.insert(agent.agent_id.as_str()) {
164 continue;
165 }
166 let role = agent
167 .assignment
168 .role
169 .as_deref()
170 .filter(|role| !role.trim().is_empty())
171 .unwrap_or_else(|| agent.agent_type.as_str());
172 // The name this lane was dispatched under is what the operator
173 // thinks in; the whale nickname only names an unnamed one (#5287).
174 let name = crate::tui::sidebar::dispatched_agent_name(agent)
175 .or_else(|| {
176 agent
177 .nickname
178 .as_deref()
179 .filter(|name| !name.trim().is_empty() && *name != agent.agent_id)
180 })
181 .or_else(|| app.agent_label_map.get(&agent.agent_id).map(String::as_str));
182 let label = match name {
183 Some(name) if name != role => format!("{name}·{role}"),
184 _ => role.to_string(),
185 };
186 items.push(PendingItem {
187 kind: PendingItemKind::Agent,
188 state: PendingItemState::Running,
189 label: truncate_label(&label),
190 });
191 }
192 for id in app.agent_progress.keys() {
193 if !seen.insert(id.as_str()) {
194 continue;
195 }
196 let label = app.agent_display_label(id);
197 items.push(PendingItem {
198 kind: PendingItemKind::Agent,
199 state: PendingItemState::Running,
200 label: truncate_label(&label),
201 });
202 }
203
204 PendingWork { items }
205 }
206
207 /// Normalize the task-panel's serialized lifecycle token once at the
208 /// projection boundary. Consumers should use [`PendingItemState`] rather than
209 /// comparing these wire values in their render paths.
210 #[must_use]
211 pub(crate) fn pending_item_state(entry: &TaskPanelEntry) -> Option<PendingItemState> {
212 if entry.kind != TaskPanelEntryKind::Background {
213 return None;
214 }
215 match entry.status.as_str() {
216 "queued" => Some(PendingItemState::Queued),
217 "running" => Some(PendingItemState::Running),
218 _ => None,
219 }
220 }
221
222 /// Whether a task-panel row is a currently live shell job. This is shared by
223 /// the detailed Work strip and compact live-status projections so a shell
224 /// cannot be omitted or classified differently between surfaces.
225 #[must_use]
226 pub(crate) fn is_live_shell_entry(entry: &TaskPanelEntry) -> bool {
227 pending_item_state(entry).is_some()
228 && (entry.prompt_summary.starts_with("shell: ") || entry.id.starts_with("shell_"))
229 }
230
231 #[cfg(test)]
232 mod tests {
233 use super::*;
234 use unicode_width::UnicodeWidthStr;
235
236 fn shell(label: &str) -> PendingItem {
237 PendingItem {
238 kind: PendingItemKind::Shell,
239 state: PendingItemState::Running,
240 label: truncate_label(label),
241 }
242 }
243
244 fn task(label: &str) -> PendingItem {
245 PendingItem {
246 kind: PendingItemKind::Task,
247 state: PendingItemState::Running,
248 label: truncate_label(label),
249 }
250 }
251
252 fn agent(label: &str) -> PendingItem {
253 PendingItem {
254 kind: PendingItemKind::Agent,
255 state: PendingItemState::Running,
256 label: truncate_label(label),
257 }
258 }
259
260 #[test]
261 fn empty_pending_work_hides_the_indicator() {
262 let work = PendingWork::default();
263 assert!(work.items.is_empty());
264 assert_eq!(work.count(PendingItemKind::Shell), 0);
265 assert_eq!(work.count(PendingItemKind::Task), 0);
266 assert_eq!(work.count(PendingItemKind::Agent), 0);
267 }
268
269 #[test]
270 fn counts_cover_shells_tasks_and_agents_with_pluralization() {
271 let work = PendingWork {
272 items: vec![shell("cargo test"), task("run"), agent("Agent 3·scout")],
273 };
274 assert!(!work.items.is_empty());
275 assert_eq!(work.count(PendingItemKind::Shell), 1);
276 assert_eq!(work.count(PendingItemKind::Task), 1);
277 assert_eq!(work.count(PendingItemKind::Agent), 1);
278 assert_eq!(PendingItemKind::Shell.plural_noun(1), "shell");
279 assert_eq!(PendingItemKind::Task.plural_noun(1), "task");
280 assert_eq!(PendingItemKind::Agent.plural_noun(1), "agent");
281 }
282
283 #[test]
284 fn plural_counts_for_multiple_same_kind_items() {
285 let work = PendingWork {
286 items: vec![shell("one"), shell("two"), agent("A")],
287 };
288 assert_eq!(work.count(PendingItemKind::Shell), 2);
289 assert_eq!(PendingItemKind::Shell.plural_noun(2), "shells");
290 assert_eq!(work.count(PendingItemKind::Agent), 1);
291 }
292
293 #[test]
294 fn completion_clears_the_line() {
295 let mut work = PendingWork {
296 items: vec![shell("cargo test")],
297 };
298 assert!(!work.items.is_empty());
299 work.items.clear();
300 assert!(work.items.is_empty());
301 }
302
303 #[test]
304 fn long_labels_are_pre_truncated() {
305 let long = "cargo test -p codewhale-tui --lib background_indicator -- --exact this is long";
306 let work = PendingWork {
307 items: vec![shell(long)],
308 };
309 assert!(
310 work.items[0].label.contains('…'),
311 "over-long command ellipsized: got {}",
312 work.items[0].label
313 );
314 assert!(
315 work.items[0].label.width() <= ITEM_LABEL_MAX_WIDTH,
316 "label capped at {ITEM_LABEL_MAX_WIDTH}: got {}",
317 work.items[0].label
318 );
319 }
320
321 #[test]
322 fn pending_work_from_app_skips_non_background_entries() {
323 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
324 let app = crate::test_support::test_app_with_options(options);
325 assert!(
326 pending_work_from_app(&app).items.is_empty(),
327 "bare app has no pending background work"
328 );
329 }
330
331 #[test]
332 fn pending_work_from_app_picks_up_shell_and_agent_entries() {
333 use crate::tui::app::TaskPanelEntry;
334 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
335 let mut app = crate::test_support::test_app_with_options(options);
336 app.task_panel.push(TaskPanelEntry {
337 id: "shell_a1b2c3d4".to_string(),
338 status: "running".to_string(),
339 prompt_summary: "shell: cargo test -p codewhale-tui".to_string(),
340 duration_ms: Some(42_000),
341 kind: TaskPanelEntryKind::Background,
342 stale: true,
343 elapsed_since_output_ms: Some(99_000),
344 owner_agent_id: None,
345 owner_agent_name: None,
346 current_tool: None,
347 role: None,
348 files_touched: 0,
349 });
350 app.task_panel.push(TaskPanelEntry {
351 id: "run".to_string(),
352 status: "running".to_string(),
353 prompt_summary: "background confirmation test".to_string(),
354 duration_ms: Some(99_000),
355 kind: TaskPanelEntryKind::Background,
356 stale: false,
357 elapsed_since_output_ms: None,
358 owner_agent_id: None,
359 owner_agent_name: None,
360 current_tool: None,
361 role: None,
362 files_touched: 0,
363 });
364 app.agent_progress
365 .insert("agent_live".to_string(), "checking the build".to_string());
366 app.agent_label_map
367 .insert("agent_live".to_string(), "Agent 1".to_string());
368
369 let work = pending_work_from_app(&app);
370 assert_eq!(
371 work.count(PendingItemKind::Shell),
372 0,
373 "shells are work-strip rows, not crumb items"
374 );
375 assert_eq!(work.count(PendingItemKind::Task), 1, "durable task counted");
376 assert_eq!(
377 work.count(PendingItemKind::Agent),
378 1,
379 "running agent counted"
380 );
381 let labels: Vec<&str> = work.items.iter().map(|item| item.label.as_str()).collect();
382 assert!(
383 !labels.iter().any(|label| label.contains("cargo test")),
384 "shell command must not occupy the counts: {labels:?}"
385 );
386 assert!(
387 labels.iter().any(|label| label.contains("run")),
388 "{labels:?}"
389 );
390 assert!(
391 labels.iter().any(|label| label.contains("Agent 1")),
392 "{labels:?}"
393 );
394
395 // Completion clears the snapshot: dropping the running entries hides
396 // the indicator entirely.
397 app.task_panel.clear();
398 app.agent_progress.clear();
399 let cleared = pending_work_from_app(&app);
400 assert!(cleared.items.is_empty(), "completion clears the indicator");
401 }
402
403 #[test]
404 fn durable_work_projection_keeps_queued_and_running_states() {
405 use crate::tui::app::TaskPanelEntry;
406 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
407 let mut app = crate::test_support::test_app_with_options(options);
408 app.task_panel.extend([
409 TaskPanelEntry {
410 id: "durable-running".to_string(),
411 status: "running".to_string(),
412 prompt_summary: "durable work".to_string(),
413 duration_ms: Some(42_000),
414 kind: TaskPanelEntryKind::Background,
415 stale: false,
416 elapsed_since_output_ms: None,
417 owner_agent_id: None,
418 owner_agent_name: None,
419 current_tool: None,
420 role: None,
421 files_touched: 0,
422 },
423 TaskPanelEntry {
424 id: "durable-queued".to_string(),
425 status: "queued".to_string(),
426 prompt_summary: "durable work".to_string(),
427 duration_ms: None,
428 kind: TaskPanelEntryKind::Background,
429 stale: false,
430 elapsed_since_output_ms: None,
431 owner_agent_id: None,
432 owner_agent_name: None,
433 current_tool: None,
434 role: None,
435 files_touched: 0,
436 },
437 ]);
438
439 let work = pending_work_from_app(&app);
440 assert_eq!(work.count(PendingItemKind::Task), 2);
441 assert_eq!(work.count_state(PendingItemState::Queued), 1);
442 assert_eq!(work.count_state(PendingItemState::Running), 1);
443 assert!(
444 work.items
445 .iter()
446 .any(|item| item.label == "durable-running"
447 && item.state == PendingItemState::Running),
448 "durable running task must retain its typed state: {work:?}"
449 );
450 assert!(
451 work.items.iter().any(
452 |item| item.label == "durable-queued" && item.state == PendingItemState::Queued
453 ),
454 "durable queued task must retain its typed state: {work:?}"
455 );
456 }
457
458 #[test]
459 fn pending_agents_are_labelled_by_the_name_they_were_dispatched_under() {
460 use crate::tools::subagent::{
461 FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus,
462 };
463 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
464 let mut app = crate::test_support::test_app_with_options(options);
465 let running = |agent_id: &str, name: &str| SubAgentResult {
466 usage: None,
467 name: name.to_string(),
468 agent_id: agent_id.to_string(),
469 context_mode: "fresh".to_string(),
470 fork_context: false,
471 workspace: None,
472 git_branch: None,
473 agent_type: FleetRole::Worker,
474 assignment: SubAgentAssignment {
475 objective: "sweep the lane".to_string(),
476 role: Some("builder".to_string()),
477 },
478 model: "test-model".to_string(),
479 nickname: Some("Blue Whale".to_string()),
480 status: SubAgentStatus::Running,
481 worker_status: None,
482 runtime_permissions: None,
483 parent_run_id: None,
484 spawn_depth: 0,
485 child_route: None,
486 result: None,
487 steps_taken: 1,
488 checkpoint: None,
489 needs_input: None,
490 duration_ms: 100,
491 started_at: None,
492 from_prior_session: false,
493 };
494 app.subagent_cache
495 .push(running("agent_named_lane", "triage"));
496 // An unnamed dispatch: `name` is still the agent id, so the whale
497 // nickname stays the honest label (#5287).
498 app.subagent_cache
499 .push(running("agent_plain_lane", "agent_plain_lane"));
500
501 let work = pending_work_from_app(&app);
502 let labels: Vec<&str> = work.items.iter().map(|item| item.label.as_str()).collect();
503 assert_eq!(labels, ["triage·builder", "Blue Whale·builder"]);
504 }
505 }
506
506 lines RUST