返回 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 = app
175 .agent_given_name(&agent.agent_id)
176 .or_else(|| {
177 agent
178 .nickname
179 .clone()
180 .filter(|name| !name.trim().is_empty() && *name != agent.agent_id)
181 })
182 .or_else(|| app.agent_label_map.get(&agent.agent_id).cloned());
183 let label = match name.as_deref() {
184 Some(name) if name != role => format!("{name}·{role}"),
185 _ => role.to_string(),
186 };
187 items.push(PendingItem {
188 kind: PendingItemKind::Agent,
189 state: PendingItemState::Running,
190 label: truncate_label(&label),
191 });
192 }
193 for id in app.agent_progress.keys() {
194 if !seen.insert(id.as_str()) {
195 continue;
196 }
197 let label = app.agent_display_label(id);
198 items.push(PendingItem {
199 kind: PendingItemKind::Agent,
200 state: PendingItemState::Running,
201 label: truncate_label(&label),
202 });
203 }
204
205 PendingWork { items }
206 }
207
208 /// Normalize the task-panel's serialized lifecycle token once at the
209 /// projection boundary. Consumers should use [`PendingItemState`] rather than
210 /// comparing these wire values in their render paths.
211 #[must_use]
212 pub(crate) fn pending_item_state(entry: &TaskPanelEntry) -> Option<PendingItemState> {
213 match entry.status.as_str() {
214 "queued" => Some(PendingItemState::Queued),
215 "running" => Some(PendingItemState::Running),
216 _ => None,
217 }
218 }
219
220 /// Whether a task-panel row is a currently live shell job. This is shared by
221 /// the detailed Work strip and compact live-status projections so a shell
222 /// cannot be omitted or classified differently between surfaces.
223 #[must_use]
224 pub(crate) fn is_live_shell_entry(entry: &TaskPanelEntry) -> bool {
225 pending_item_state(entry).is_some() && entry.kind == TaskPanelEntryKind::Shell
226 }
227
228 #[cfg(test)]
229 mod tests {
230 use super::*;
231 use unicode_width::UnicodeWidthStr;
232
233 fn shell(label: &str) -> PendingItem {
234 PendingItem {
235 kind: PendingItemKind::Shell,
236 state: PendingItemState::Running,
237 label: truncate_label(label),
238 }
239 }
240
241 fn task(label: &str) -> PendingItem {
242 PendingItem {
243 kind: PendingItemKind::Task,
244 state: PendingItemState::Running,
245 label: truncate_label(label),
246 }
247 }
248
249 fn agent(label: &str) -> PendingItem {
250 PendingItem {
251 kind: PendingItemKind::Agent,
252 state: PendingItemState::Running,
253 label: truncate_label(label),
254 }
255 }
256
257 #[test]
258 fn empty_pending_work_hides_the_indicator() {
259 let work = PendingWork::default();
260 assert!(work.items.is_empty());
261 assert_eq!(work.count(PendingItemKind::Shell), 0);
262 assert_eq!(work.count(PendingItemKind::Task), 0);
263 assert_eq!(work.count(PendingItemKind::Agent), 0);
264 }
265
266 #[test]
267 fn counts_cover_shells_tasks_and_agents_with_pluralization() {
268 let work = PendingWork {
269 items: vec![shell("cargo test"), task("run"), agent("Agent 3·scout")],
270 };
271 assert!(!work.items.is_empty());
272 assert_eq!(work.count(PendingItemKind::Shell), 1);
273 assert_eq!(work.count(PendingItemKind::Task), 1);
274 assert_eq!(work.count(PendingItemKind::Agent), 1);
275 assert_eq!(PendingItemKind::Shell.plural_noun(1), "shell");
276 assert_eq!(PendingItemKind::Task.plural_noun(1), "task");
277 assert_eq!(PendingItemKind::Agent.plural_noun(1), "agent");
278 }
279
280 #[test]
281 fn plural_counts_for_multiple_same_kind_items() {
282 let work = PendingWork {
283 items: vec![shell("one"), shell("two"), agent("A")],
284 };
285 assert_eq!(work.count(PendingItemKind::Shell), 2);
286 assert_eq!(PendingItemKind::Shell.plural_noun(2), "shells");
287 assert_eq!(work.count(PendingItemKind::Agent), 1);
288 }
289
290 #[test]
291 fn completion_clears_the_line() {
292 let mut work = PendingWork {
293 items: vec![shell("cargo test")],
294 };
295 assert!(!work.items.is_empty());
296 work.items.clear();
297 assert!(work.items.is_empty());
298 }
299
300 #[test]
301 fn long_labels_are_pre_truncated() {
302 let long = "cargo test -p codewhale-tui --lib background_indicator -- --exact this is long";
303 let work = PendingWork {
304 items: vec![shell(long)],
305 };
306 assert!(
307 work.items[0].label.contains('…'),
308 "over-long command ellipsized: got {}",
309 work.items[0].label
310 );
311 assert!(
312 work.items[0].label.width() <= ITEM_LABEL_MAX_WIDTH,
313 "label capped at {ITEM_LABEL_MAX_WIDTH}: got {}",
314 work.items[0].label
315 );
316 }
317
318 #[test]
319 fn pending_work_from_app_skips_non_background_entries() {
320 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
321 let app = crate::test_support::test_app_with_options(options);
322 assert!(
323 pending_work_from_app(&app).items.is_empty(),
324 "bare app has no pending background work"
325 );
326 }
327
328 #[test]
329 fn pending_work_from_app_picks_up_shell_and_agent_entries() {
330 use crate::tui::app::TaskPanelEntry;
331 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
332 let mut app = crate::test_support::test_app_with_options(options);
333 app.task_panel.push(TaskPanelEntry {
334 exit_code: None,
335 id: "shell_a1b2c3d4".to_string(),
336 status: "running".to_string(),
337 prompt_summary: "shell: cargo test -p codewhale-tui".to_string(),
338 duration_ms: Some(42_000),
339 kind: TaskPanelEntryKind::Shell,
340 stale: true,
341 elapsed_since_output_ms: Some(99_000),
342 owner_agent_id: None,
343 owner_agent_name: None,
344 current_tool: None,
345 role: None,
346 files_touched: 0,
347 });
348 app.task_panel.push(TaskPanelEntry {
349 exit_code: None,
350 id: "run".to_string(),
351 status: "running".to_string(),
352 prompt_summary: "background confirmation test".to_string(),
353 duration_ms: Some(99_000),
354 kind: TaskPanelEntryKind::Background,
355 stale: false,
356 elapsed_since_output_ms: None,
357 owner_agent_id: None,
358 owner_agent_name: None,
359 current_tool: None,
360 role: None,
361 files_touched: 0,
362 });
363 app.agent_progress
364 .insert("agent_live".to_string(), "checking the build".to_string());
365 app.agent_label_map
366 .insert("agent_live".to_string(), "Agent 1".to_string());
367
368 let work = pending_work_from_app(&app);
369 assert_eq!(
370 work.count(PendingItemKind::Shell),
371 0,
372 "shells are work-strip rows, not crumb items"
373 );
374 assert_eq!(work.count(PendingItemKind::Task), 1, "durable task counted");
375 assert_eq!(
376 work.count(PendingItemKind::Agent),
377 1,
378 "running agent counted"
379 );
380 let labels: Vec<&str> = work.items.iter().map(|item| item.label.as_str()).collect();
381 assert!(
382 !labels.iter().any(|label| label.contains("cargo test")),
383 "shell command must not occupy the counts: {labels:?}"
384 );
385 assert!(
386 labels.iter().any(|label| label.contains("run")),
387 "{labels:?}"
388 );
389 assert!(
390 labels.iter().any(|label| label.contains("Agent 1")),
391 "{labels:?}"
392 );
393
394 // Completion clears the snapshot: dropping the running entries hides
395 // the indicator entirely.
396 app.task_panel.clear();
397 app.agent_progress.clear();
398 let cleared = pending_work_from_app(&app);
399 assert!(cleared.items.is_empty(), "completion clears the indicator");
400 }
401
402 #[test]
403 fn durable_work_projection_keeps_queued_and_running_states() {
404 use crate::tui::app::TaskPanelEntry;
405 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
406 let mut app = crate::test_support::test_app_with_options(options);
407 app.task_panel.extend([
408 TaskPanelEntry {
409 exit_code: None,
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 exit_code: None,
425 id: "durable-queued".to_string(),
426 status: "queued".to_string(),
427 prompt_summary: "durable work".to_string(),
428 duration_ms: None,
429 kind: TaskPanelEntryKind::Background,
430 stale: false,
431 elapsed_since_output_ms: None,
432 owner_agent_id: None,
433 owner_agent_name: None,
434 current_tool: None,
435 role: None,
436 files_touched: 0,
437 },
438 ]);
439
440 let work = pending_work_from_app(&app);
441 assert_eq!(work.count(PendingItemKind::Task), 2);
442 assert_eq!(work.count_state(PendingItemState::Queued), 1);
443 assert_eq!(work.count_state(PendingItemState::Running), 1);
444 assert!(
445 work.items
446 .iter()
447 .any(|item| item.label == "durable-running"
448 && item.state == PendingItemState::Running),
449 "durable running task must retain its typed state: {work:?}"
450 );
451 assert!(
452 work.items.iter().any(
453 |item| item.label == "durable-queued" && item.state == PendingItemState::Queued
454 ),
455 "durable queued task must retain its typed state: {work:?}"
456 );
457 }
458
459 #[test]
460 fn pending_agents_are_labelled_by_the_name_they_were_dispatched_under() {
461 use crate::tools::subagent::{
462 FleetRole, SubAgentAssignment, SubAgentResult, SubAgentStatus,
463 };
464 let options = crate::test_support::test_tui_options(std::path::PathBuf::from("."));
465 let mut app = crate::test_support::test_app_with_options(options);
466 let running = |agent_id: &str, name: &str| SubAgentResult {
467 usage: None,
468 name: name.to_string(),
469 agent_id: agent_id.to_string(),
470 context_mode: "fresh".to_string(),
471 fork_context: false,
472 workspace: None,
473 git_branch: None,
474 agent_type: FleetRole::Worker,
475 assignment: SubAgentAssignment {
476 native_preset: None,
477 objective: "sweep the lane".to_string(),
478 role: Some("builder".to_string()),
479 },
480 model: "test-model".to_string(),
481 nickname: Some(crate::tools::subagent::whale_name_for_id_in_locale(
482 agent_id, "en",
483 )),
484 status: SubAgentStatus::Running,
485 worker_status: None,
486 runtime_permissions: None,
487 parent_run_id: None,
488 spawn_depth: 0,
489 child_route: None,
490 result: None,
491 steps_taken: 1,
492 checkpoint: None,
493 needs_input: None,
494 duration_ms: 100,
495 started_at: None,
496 from_prior_session: false,
497 idle_ms: None,
498 heartbeat_timeout_ms: None,
499 };
500 app.subagent_cache
501 .push(running("agent_named_lane", "triage"));
502 // An unnamed dispatch: `name` is still the agent id, so the whale
503 // nickname stays the honest label (#5287).
504 app.subagent_cache
505 .push(running("agent_plain_lane", "agent_plain_lane"));
506
507 let work = pending_work_from_app(&app);
508 let labels: Vec<&str> = work.items.iter().map(|item| item.label.as_str()).collect();
509 let whale = crate::tools::subagent::whale_name_for_id_in_locale("agent_plain_lane", "en");
510 assert_eq!(
511 labels,
512 ["triage·builder".to_string(), format!("{whale}·builder")]
513 );
514 }
515 }
516
516 lines RUST