返回 CodeWhale
workflow_panel.rs
根目录 / crates / tui / src / tui / widgets / workflow_panel.rs
1 //! WorkflowPanel — unified activity surface for workflow / sub-agent progress.
2 //!
3 //! Issue #4121 (CODEWHALE_0_8_68 §2.4). Progress lives here instead of flooding
4 //! the chat transcript: a collapsible header above the composer plus an
5 //! expanded phase/row body. Events are applied through [`WorkflowPanelEvent`].
6 //!
7 //! Issue #4122 routes the same event stream into a compact history card that
8 //! reuses this state machine: collapsed summarizes lifecycle/children/phases/
9 //! failures/elapsed; expanded adds phase/child summaries, artifact links,
10 //! final result, and failure details. Direct sub-agent cards share helpers
11 //! from this module where practical.
12
13 use std::path::PathBuf;
14 use std::time::{SystemTime, UNIX_EPOCH};
15
16 use ratatui::buffer::Buffer;
17 use ratatui::layout::Rect;
18 use ratatui::style::{Modifier, Style};
19 use ratatui::text::{Line, Span};
20 use ratatui::widgets::{Paragraph, Widget};
21 use serde_json::{Value, json};
22 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
23
24 use crate::localization::{Locale, MessageId, tr};
25 use crate::palette;
26 use crate::tui::ui_text::truncate_line_to_width;
27 use crate::tui::widgets::Renderable;
28
29 /// Maximum worker rows rendered under the selected phase.
30 const MAX_VISIBLE_ROWS: usize = 8;
31 /// Maximum phase summary chips shown in the expanded body.
32 const MAX_PHASE_SUMMARY: usize = 6;
33
34 /// Lifecycle of the active (or most recently completed) workflow run.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub enum WorkflowPanelLifecycle {
37 Pending,
38 Running,
39 Succeeded,
40 Failed,
41 Cancelled,
42 }
43
44 impl WorkflowPanelLifecycle {
45 #[must_use]
46 pub fn is_running(self) -> bool {
47 matches!(self, Self::Running | Self::Pending)
48 }
49
50 #[must_use]
51 pub fn is_terminal(self) -> bool {
52 matches!(self, Self::Succeeded | Self::Failed | Self::Cancelled)
53 }
54
55 #[must_use]
56 pub fn label(self) -> &'static str {
57 match self {
58 Self::Pending => "pending",
59 Self::Running => "running",
60 Self::Succeeded => "success",
61 Self::Failed => "failed",
62 Self::Cancelled => "cancelled",
63 }
64 }
65
66 fn color(self) -> ratatui::style::Color {
67 match self {
68 Self::Pending => palette::TEXT_MUTED,
69 Self::Running => palette::STATUS_WARNING,
70 Self::Succeeded => palette::STATUS_SUCCESS,
71 Self::Failed => palette::STATUS_ERROR,
72 Self::Cancelled => palette::TEXT_MUTED,
73 }
74 }
75 }
76
77 /// Per-task / per-worker row status.
78 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
79 pub enum WorkflowRowStatus {
80 Pending,
81 Running,
82 Waiting,
83 Succeeded,
84 Failed,
85 Cancelled,
86 SchemaFailed,
87 }
88
89 impl WorkflowRowStatus {
90 #[must_use]
91 pub fn label(self) -> &'static str {
92 match self {
93 Self::Pending => "pending",
94 Self::Running => "running",
95 Self::Waiting => "waiting",
96 Self::Succeeded => "done",
97 Self::Failed => "failed",
98 Self::Cancelled => "cancelled",
99 Self::SchemaFailed => "schema",
100 }
101 }
102
103 /// Localized display variant of [`Self::label`]. `label()` stays
104 /// English because it doubles as the machine-readable `status` token in
105 /// [`WorkflowPanel::to_run_json`]; this method is for rendered rows only.
106 #[must_use]
107 pub fn display_label(self, locale: Locale) -> std::borrow::Cow<'static, str> {
108 match self {
109 Self::Waiting => tr(locale, MessageId::WorkflowStatusWaiting),
110 other => std::borrow::Cow::Borrowed(other.label()),
111 }
112 }
113
114 #[must_use]
115 pub fn is_running(self) -> bool {
116 matches!(self, Self::Pending | Self::Running | Self::Waiting)
117 }
118
119 #[must_use]
120 pub fn is_failure(self) -> bool {
121 matches!(self, Self::Failed | Self::SchemaFailed)
122 }
123
124 #[must_use]
125 pub fn is_cancel(self) -> bool {
126 matches!(self, Self::Cancelled)
127 }
128
129 fn color(self) -> ratatui::style::Color {
130 match self {
131 Self::Pending => palette::TEXT_MUTED,
132 Self::Running => palette::STATUS_WARNING,
133 Self::Waiting => palette::STATUS_ERROR,
134 Self::Succeeded => palette::STATUS_SUCCESS,
135 Self::Failed | Self::SchemaFailed => palette::STATUS_ERROR,
136 Self::Cancelled => palette::TEXT_MUTED,
137 }
138 }
139
140 fn from_ir_status(status: &str) -> Self {
141 match status {
142 "succeeded" | "completed" | "success" | "done" => Self::Succeeded,
143 "failed" | "error" | "replay_diverged" => Self::Failed,
144 "cancelled" | "canceled" => Self::Cancelled,
145 "budget_exceeded" => Self::Failed,
146 "running" => Self::Running,
147 "waiting" | "blocked" | "needs_user" => Self::Waiting,
148 "pending" => Self::Pending,
149 other if other.contains("schema") => Self::SchemaFailed,
150 _ => Self::Failed,
151 }
152 }
153 }
154
155 /// Closed route-source vocabulary minted by the spawn resolver. Persisted
156 /// journals are untrusted input: an unrecognized value stays unknown rather
157 /// than becoming UI copy (#4039).
158 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
159 pub enum WorkflowRouteSource {
160 TaskModel,
161 TaskModelStrength,
162 AgentProfileModel,
163 AgentProfileLoadout,
164 RoleDefault,
165 RunModel,
166 }
167
168 impl WorkflowRouteSource {
169 fn parse(value: &str) -> Option<Self> {
170 match value.trim() {
171 "task.model" => Some(Self::TaskModel),
172 "task.model_strength" => Some(Self::TaskModelStrength),
173 "agent_profile.model" => Some(Self::AgentProfileModel),
174 "agent_profile.loadout" => Some(Self::AgentProfileLoadout),
175 "role.default" => Some(Self::RoleDefault),
176 "run.model" => Some(Self::RunModel),
177 _ => None,
178 }
179 }
180
181 const fn as_str(self) -> &'static str {
182 match self {
183 Self::TaskModel => "task.model",
184 Self::TaskModelStrength => "task.model_strength",
185 Self::AgentProfileModel => "agent_profile.model",
186 Self::AgentProfileLoadout => "agent_profile.loadout",
187 Self::RoleDefault => "role.default",
188 Self::RunModel => "run.model",
189 }
190 }
191 }
192
193 /// Closed token provenance carried by a terminal usage receipt.
194 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
195 pub enum WorkflowTokenSource {
196 ProviderReported,
197 Estimated,
198 }
199
200 impl WorkflowTokenSource {
201 fn parse(value: &str) -> Option<Self> {
202 match value.trim() {
203 "provider_reported" => Some(Self::ProviderReported),
204 "estimated" => Some(Self::Estimated),
205 _ => None,
206 }
207 }
208
209 const fn as_str(self) -> &'static str {
210 match self {
211 Self::ProviderReported => "provider_reported",
212 Self::Estimated => "estimated",
213 }
214 }
215 }
216
217 /// Immutable launch receipt for one row, consumed verbatim from the
218 /// `task_started` event the Workflow runtime emitted at spawn (#4039).
219 ///
220 /// Nothing here is ever re-derived from current session configuration: a row
221 /// launched on provider A at `high` keeps saying so after the user switches the
222 /// session to provider B, because these strings were copied out of the event
223 /// that admitted the task.
224 #[derive(Debug, Clone, Default, PartialEq, Eq)]
225 pub struct WorkflowRowRoute {
226 /// Fleet role resolved for the worker (`resolved_role`, else `role`).
227 pub role: Option<String>,
228 /// Provider identity the child client was installed on.
229 pub provider: Option<String>,
230 /// Wire model the child was launched with.
231 pub model: Option<String>,
232 /// Reasoning the task asked for (`inherit`/`auto`/effort).
233 pub requested_reasoning: Option<String>,
234 /// Reasoning the child runtime actually carried.
235 pub effective_reasoning: Option<String>,
236 /// Which closed spawn decision produced the route (`task.model`,
237 /// `agent_profile.model`, …).
238 pub route_source: Option<WorkflowRouteSource>,
239 }
240
241 impl WorkflowRowRoute {
242 fn field(value: Option<&String>, locale: Locale) -> String {
243 value
244 .map(String::as_str)
245 .map(crate::tui::app::bound_agent_activity_text)
246 // Route metadata is untrusted persisted input. Flatten every
247 // whitespace/control run before packing it into a ratatui Line so
248 // CR/LF/tab cannot inject rows or evade display-width accounting.
249 .map(|value| {
250 value
251 .chars()
252 .map(|ch| if ch.is_control() { ' ' } else { ch })
253 .collect::<String>()
254 .split_whitespace()
255 .collect::<Vec<_>>()
256 .join(" ")
257 })
258 .filter(|value| !value.is_empty())
259 .unwrap_or_else(|| tr(locale, MessageId::WorkflowReceiptUnknown).into_owned())
260 }
261 }
262
263 /// Terminal usage receipt for one row, consumed from `task_completed` (#4039).
264 ///
265 /// Every counter is optional on purpose. A provider that reported no usage
266 /// leaves the token fields `None`, and the row says `tokens unknown` — the one
267 /// thing it must never do is render that as `0`.
268 #[derive(Debug, Clone, Default, PartialEq, Eq)]
269 pub struct WorkflowRowUsage {
270 pub input_tokens: Option<u64>,
271 pub output_tokens: Option<u64>,
272 pub total_tokens: Option<u64>,
273 pub tool_calls: Option<u32>,
274 pub duration_ms: Option<u64>,
275 /// `provider_reported` / `estimated` as stated by the runtime; `None` when
276 /// the tokens are unknown.
277 pub token_source: Option<WorkflowTokenSource>,
278 }
279
280 impl WorkflowRowUsage {
281 fn token_total(&self) -> Option<u64> {
282 self.total_tokens.or_else(|| {
283 match (self.input_tokens, self.output_tokens) {
284 // Only a real pair sums to a real total; one half alone is not
285 // a total and must not be presented as one.
286 (Some(input), Some(output)) => Some(input.saturating_add(output)),
287 _ => None,
288 }
289 })
290 }
291
292 fn token_source_label(&self, locale: Locale) -> String {
293 match self.token_source {
294 Some(WorkflowTokenSource::ProviderReported) => {
295 tr(locale, MessageId::WorkflowReceiptProviderReported).into_owned()
296 }
297 Some(WorkflowTokenSource::Estimated) => {
298 tr(locale, MessageId::WorkflowReceiptEstimated).into_owned()
299 }
300 None => tr(locale, MessageId::WorkflowReceiptUnknown).into_owned(),
301 }
302 }
303 }
304
305 /// One worker/task row under a phase.
306 #[derive(Debug, Clone, PartialEq, Eq)]
307 pub struct WorkflowPanelRow {
308 pub task_id: String,
309 pub label: String,
310 pub profile: Option<String>,
311 pub model: Option<String>,
312 pub strength: Option<String>,
313 pub worktree: bool,
314 pub workspace: Option<PathBuf>,
315 pub status: WorkflowRowStatus,
316 pub started_at_ms: u64,
317 pub completed_at_ms: Option<u64>,
318 pub error: Option<String>,
319 pub schema_error: Option<String>,
320 /// Launch receipt copied from `task_started` (#4039).
321 pub route: WorkflowRowRoute,
322 /// Terminal usage receipt copied from `task_completed` (#4039). `None`
323 /// while the row is still running — a running row reports no totals rather
324 /// than provisional ones.
325 pub usage: Option<WorkflowRowUsage>,
326 }
327
328 /// One lane gate status line surfaced by the Workflow runtime (#4179).
329 #[derive(Debug, Clone, PartialEq, Eq)]
330 pub struct WorkflowPanelGateLine {
331 pub gate_id: String,
332 pub role: Option<String>,
333 pub gate: Option<String>,
334 pub state: String,
335 pub blocked_role: Option<String>,
336 pub blocked_reason: Option<String>,
337 }
338
339 /// One ordered phase group.
340 #[derive(Debug, Clone, PartialEq, Eq)]
341 pub struct WorkflowPanelPhase {
342 pub title: String,
343 pub rows: Vec<WorkflowPanelRow>,
344 }
345
346 impl WorkflowPanelPhase {
347 fn new(title: impl Into<String>) -> Self {
348 Self {
349 title: title.into(),
350 rows: Vec::new(),
351 }
352 }
353
354 fn counts(&self) -> (usize, usize, usize, usize) {
355 let mut done = 0usize;
356 let mut running = 0usize;
357 let mut failed = 0usize;
358 let mut cancelled = 0usize;
359 for row in &self.rows {
360 match row.status {
361 WorkflowRowStatus::Succeeded => done += 1,
362 WorkflowRowStatus::Running
363 | WorkflowRowStatus::Pending
364 | WorkflowRowStatus::Waiting => running += 1,
365 WorkflowRowStatus::Failed | WorkflowRowStatus::SchemaFailed => failed += 1,
366 WorkflowRowStatus::Cancelled => cancelled += 1,
367 }
368 }
369 (done, running, failed, cancelled)
370 }
371 }
372
373 /// Events the panel understands. Mirrors the tool-side `WorkflowUiEvent`
374 /// shape so #4122 can forward JSON without re-encoding.
375 #[derive(Debug, Clone, PartialEq, Eq)]
376 pub enum WorkflowPanelEvent {
377 RunStarted {
378 run_id: String,
379 workflow_id: Option<String>,
380 workflow_goal: Option<String>,
381 source_path: Option<PathBuf>,
382 token_budget: Option<u64>,
383 at_ms: u64,
384 },
385 RunCompleted {
386 status: WorkflowPanelLifecycle,
387 error: Option<String>,
388 at_ms: u64,
389 },
390 RunCancelled {
391 reason: String,
392 at_ms: u64,
393 },
394 PhaseStarted {
395 title: String,
396 at_ms: u64,
397 },
398 TaskStarted {
399 task_id: String,
400 label: Option<String>,
401 profile: Option<String>,
402 model: Option<String>,
403 strength: Option<String>,
404 resolved_model: Option<String>,
405 worktree: bool,
406 workspace: Option<PathBuf>,
407 /// Launch receipt carried by this event (#4039).
408 route: WorkflowRowRoute,
409 at_ms: u64,
410 },
411 TaskCompleted {
412 task_id: String,
413 status: WorkflowRowStatus,
414 /// Terminal usage receipt carried by this event, if any (#4039).
415 usage: Option<WorkflowRowUsage>,
416 at_ms: u64,
417 },
418 GateUpdated {
419 gate_id: String,
420 role: Option<String>,
421 gate: Option<String>,
422 state: String,
423 blocked_role: Option<String>,
424 blocked_reason: Option<String>,
425 at_ms: u64,
426 },
427 TaskSchemaValidationFailed {
428 task_id: String,
429 message: String,
430 at_ms: u64,
431 },
432 BudgetUpdated {
433 total: Option<u64>,
434 spent: u64,
435 remaining: Option<u64>,
436 at_ms: u64,
437 },
438 }
439
440 impl WorkflowPanelEvent {
441 /// Parse one flattened tool UI event (`{"type":"…", …}`).
442 pub fn from_json_value(value: &Value) -> Option<Self> {
443 let event_type = value.get("type")?.as_str()?;
444 let at_ms = value
445 .get("at_ms")
446 .and_then(Value::as_u64)
447 .unwrap_or_else(now_ms);
448 match event_type {
449 "run_started" => Some(Self::RunStarted {
450 run_id: value
451 .get("run_id")
452 .and_then(Value::as_str)
453 .unwrap_or("workflow")
454 .to_string(),
455 workflow_id: opt_str(value, "workflow_id"),
456 workflow_goal: opt_str(value, "workflow_goal"),
457 source_path: opt_str(value, "source_path").map(PathBuf::from),
458 token_budget: value.get("token_budget").and_then(Value::as_u64),
459 at_ms,
460 }),
461 "run_completed" => {
462 let status = value
463 .get("status")
464 .and_then(Value::as_str)
465 .map(lifecycle_from_status)
466 .unwrap_or(WorkflowPanelLifecycle::Succeeded);
467 Some(Self::RunCompleted {
468 status,
469 error: opt_str(value, "error"),
470 at_ms,
471 })
472 }
473 "run_cancelled" => Some(Self::RunCancelled {
474 reason: opt_str(value, "reason").unwrap_or_else(|| "cancelled".to_string()),
475 at_ms,
476 }),
477 "phase_started" => Some(Self::PhaseStarted {
478 title: opt_str(value, "title").unwrap_or_else(|| "Phase".to_string()),
479 at_ms,
480 }),
481 "task_started" => Some(Self::TaskStarted {
482 task_id: opt_str(value, "task_id")?,
483 // Prefer typed workflow metadata over generic label so rows
484 // never fall back to prompt parsing (#4119).
485 label: opt_str(value, "workflow_task_label").or_else(|| opt_str(value, "label")),
486 profile: opt_str(value, "profile"),
487 model: opt_str(value, "model").or_else(|| opt_str(value, "resolved_model")),
488 strength: opt_str(value, "strength"),
489 resolved_model: opt_str(value, "resolved_model"),
490 worktree: value
491 .get("worktree")
492 .and_then(Value::as_bool)
493 .unwrap_or(false),
494 workspace: opt_str(value, "workspace").map(PathBuf::from),
495 // #4039: the receipt is read only out of this event.
496 route: WorkflowRowRoute {
497 role: opt_str(value, "resolved_role").or_else(|| opt_str(value, "role")),
498 provider: opt_str(value, "resolved_provider"),
499 // Requested `model` is useful for the compact status row,
500 // but is not proof of the effective launch model.
501 model: opt_str(value, "resolved_model"),
502 requested_reasoning: opt_str(value, "requested_reasoning")
503 .or_else(|| opt_str(value, "thinking")),
504 effective_reasoning: opt_str(value, "effective_reasoning"),
505 route_source: opt_str(value, "route_source")
506 .as_deref()
507 .and_then(WorkflowRouteSource::parse),
508 },
509 at_ms,
510 }),
511 "task_completed" => {
512 let status = value
513 .get("status")
514 .and_then(Value::as_str)
515 .map(WorkflowRowStatus::from_ir_status)
516 .unwrap_or(WorkflowRowStatus::Succeeded);
517 Some(Self::TaskCompleted {
518 task_id: opt_str(value, "task_id")?,
519 status,
520 usage: value.get("usage").and_then(usage_from_json),
521 at_ms,
522 })
523 }
524 "gate_updated" => Some(Self::GateUpdated {
525 gate_id: opt_str(value, "gate_id")?,
526 role: opt_str(value, "role"),
527 gate: opt_str(value, "gate"),
528 state: opt_str(value, "state").unwrap_or_else(|| "pending".to_string()),
529 blocked_role: opt_str(value, "blocked_role"),
530 blocked_reason: opt_str(value, "blocked_reason"),
531 at_ms,
532 }),
533 "task_schema_validation_failed" => Some(Self::TaskSchemaValidationFailed {
534 task_id: opt_str(value, "task_id")?,
535 message: opt_str(value, "message").unwrap_or_else(|| "schema failed".to_string()),
536 at_ms,
537 }),
538 "budget_updated" => Some(Self::BudgetUpdated {
539 total: value.get("total").and_then(Value::as_u64),
540 spent: value.get("spent").and_then(Value::as_u64).unwrap_or(0),
541 remaining: value.get("remaining").and_then(Value::as_u64),
542 at_ms,
543 }),
544 // Logs are intentionally not surfaced in the panel body — they
545 // would re-flood the surface the panel exists to protect.
546 "log" => None,
547 _ => None,
548 }
549 }
550 }
551
552 /// Collapsible workflow activity panel.
553 #[derive(Debug, Clone)]
554 pub struct WorkflowPanel {
555 pub run_id: String,
556 pub label: String,
557 pub lifecycle: WorkflowPanelLifecycle,
558 pub expanded: bool,
559 /// When true the panel accepts `t`/`c` keyboard shortcuts.
560 pub keyboard_focus: bool,
561 pub phases: Vec<WorkflowPanelPhase>,
562 pub selected_phase: usize,
563 pub gates: Vec<WorkflowPanelGateLine>,
564 pub budget_total: Option<u64>,
565 pub budget_spent: u64,
566 pub budget_remaining: Option<u64>,
567 pub started_at_ms: u64,
568 pub completed_at_ms: Option<u64>,
569 pub error: Option<String>,
570 /// Optional final result / verification summary for the history card.
571 pub result_summary: Option<String>,
572 /// Source script path or other durable artifact pointer.
573 pub source_path: Option<PathBuf>,
574 /// Spillover / full-output path when the tool result was large.
575 pub spillover_path: Option<PathBuf>,
576 /// UI locale for rendered copy. Defaults to English; hosts with app
577 /// access set it after construction (#4057 wave 2).
578 pub locale: Locale,
579 /// Direct-agent cards reuse the Workflow history layout but do not carry a
580 /// Workflow launch receipt. Keep that distinction explicit so the shared
581 /// renderer never invents unknown Workflow provenance for them (#4039).
582 show_workflow_receipts: bool,
583 }
584
585 /// Extra fields the history card can show that are not part of the live panel
586 /// progress surface (artifact links, final result text).
587 #[derive(Debug, Clone, Default)]
588 pub struct WorkflowHistoryExtras {
589 pub result_summary: Option<String>,
590 pub source_path: Option<PathBuf>,
591 pub spillover_path: Option<PathBuf>,
592 pub verification_summary: Option<String>,
593 }
594
595 impl WorkflowPanel {
596 #[must_use]
597 pub fn new(run_id: impl Into<String>, label: impl Into<String>, at_ms: u64) -> Self {
598 Self {
599 run_id: run_id.into(),
600 label: label.into(),
601 lifecycle: WorkflowPanelLifecycle::Running,
602 expanded: true, // auto-expand while running
603 keyboard_focus: false,
604 phases: Vec::new(),
605 selected_phase: 0,
606 gates: Vec::new(),
607 budget_total: None,
608 budget_spent: 0,
609 budget_remaining: None,
610 started_at_ms: at_ms,
611 completed_at_ms: None,
612 error: None,
613 result_summary: None,
614 source_path: None,
615 spillover_path: None,
616 locale: Locale::En,
617 show_workflow_receipts: true,
618 }
619 }
620
621 /// Hydrate panel state from a workflow tool JSON payload (run record or a
622 /// snapshot produced by [`Self::to_run_json`]). Prefers the typed `events`
623 /// array when present; falls back to summary + phase fields.
624 #[must_use]
625 pub fn from_run_json(value: &Value) -> Option<Self> {
626 if value.get("action").and_then(Value::as_str) == Some("status") {
627 return None;
628 }
629 let run_id = value
630 .get("run_id")
631 .and_then(Value::as_str)
632 .filter(|s| !s.is_empty())?
633 .to_string();
634 let label = value
635 .get("workflow_goal")
636 .and_then(Value::as_str)
637 .or_else(|| value.get("workflow_id").and_then(Value::as_str))
638 .filter(|s| !s.trim().is_empty())
639 .unwrap_or(&run_id)
640 .to_string();
641 let at_ms = value
642 .get("started_at_ms")
643 .and_then(Value::as_u64)
644 .unwrap_or(0);
645 let mut panel = Self::new(run_id.clone(), label.clone(), at_ms);
646
647 if let Some(events) = value.get("events").and_then(Value::as_array) {
648 for event in events {
649 let mut event = event.clone();
650 if let Some(obj) = event.as_object_mut() {
651 obj.entry("run_id".to_string())
652 .or_insert_with(|| Value::String(run_id.clone()));
653 }
654 panel.apply_json_event(&event);
655 }
656 } else if let Some(phases) = value.get("phases").and_then(Value::as_array) {
657 for phase_val in phases {
658 let title = phase_val
659 .get("title")
660 .and_then(Value::as_str)
661 .unwrap_or("Work");
662 panel.phases.push(WorkflowPanelPhase::new(title));
663 let phase_idx = panel.phases.len() - 1;
664 if let Some(rows) = phase_val.get("rows").and_then(Value::as_array) {
665 for row in rows {
666 let task_id = row
667 .get("task_id")
668 .and_then(Value::as_str)
669 .unwrap_or("task")
670 .to_string();
671 let status = row
672 .get("status")
673 .and_then(Value::as_str)
674 .map(WorkflowRowStatus::from_ir_status)
675 .unwrap_or(WorkflowRowStatus::Pending);
676 panel.phases[phase_idx].rows.push(WorkflowPanelRow {
677 task_id: task_id.clone(),
678 label: row
679 .get("label")
680 .and_then(Value::as_str)
681 .unwrap_or(&task_id)
682 .to_string(),
683 profile: opt_str(row, "profile"),
684 model: opt_str(row, "model"),
685 strength: opt_str(row, "strength"),
686 worktree: row
687 .get("worktree")
688 .and_then(Value::as_bool)
689 .unwrap_or(false),
690 workspace: opt_str(row, "workspace").map(PathBuf::from),
691 status,
692 started_at_ms: row
693 .get("started_at_ms")
694 .and_then(Value::as_u64)
695 .unwrap_or(at_ms),
696 completed_at_ms: row.get("completed_at_ms").and_then(Value::as_u64),
697 error: opt_str(row, "error"),
698 schema_error: opt_str(row, "schema_error"),
699 route: WorkflowRowRoute {
700 role: opt_str(row, "role"),
701 provider: opt_str(row, "provider"),
702 model: opt_str(row, "resolved_model"),
703 requested_reasoning: opt_str(row, "requested_reasoning"),
704 effective_reasoning: opt_str(row, "effective_reasoning"),
705 route_source: opt_str(row, "route_source")
706 .as_deref()
707 .and_then(WorkflowRouteSource::parse),
708 },
709 usage: row.get("usage").and_then(usage_from_json),
710 });
711 }
712 }
713 }
714 if !panel.phases.is_empty() {
715 panel.selected_phase = panel.phases.len() - 1;
716 }
717 } else if let Some(child_count) =
718 value
719 .get("child_count")
720 .and_then(Value::as_u64)
721 .or_else(|| {
722 value
723 .get("child_ids")
724 .and_then(Value::as_array)
725 .map(|a| a.len() as u64)
726 })
727 {
728 // Bare summary without events: synthesize a Work phase so child
729 // count still surfaces on the history card.
730 if child_count > 0 {
731 let mut phase = WorkflowPanelPhase::new("Work");
732 for i in 0..child_count {
733 let id = value
734 .get("child_ids")
735 .and_then(Value::as_array)
736 .and_then(|ids| ids.get(i as usize))
737 .and_then(Value::as_str)
738 .map(str::to_string)
739 .unwrap_or_else(|| format!("child-{i}"));
740 phase.rows.push(WorkflowPanelRow {
741 task_id: id.clone(),
742 label: id,
743 profile: None,
744 model: None,
745 strength: None,
746 worktree: false,
747 workspace: None,
748 status: WorkflowRowStatus::Succeeded,
749 started_at_ms: at_ms,
750 completed_at_ms: value.get("completed_at_ms").and_then(Value::as_u64),
751 error: None,
752 schema_error: None,
753 // A bare child-count summary carries no receipt at all;
754 // the row must show that rather than infer one (#4039).
755 route: WorkflowRowRoute::default(),
756 usage: Some(WorkflowRowUsage::default()),
757 });
758 }
759 panel.phases.push(phase);
760 }
761 }
762
763 if let Some(gates) = value
764 .get("gate_status")
765 .or_else(|| value.get("gates"))
766 .and_then(Value::as_array)
767 {
768 for gate in gates {
769 if let Some(gate_id) = opt_str(gate, "gate_id") {
770 panel.upsert_gate(WorkflowPanelGateLine {
771 gate_id,
772 role: opt_str(gate, "role"),
773 gate: opt_str(gate, "gate"),
774 state: opt_str(gate, "state").unwrap_or_else(|| "pending".to_string()),
775 blocked_role: opt_str(gate, "blocked_role"),
776 blocked_reason: opt_str(gate, "blocked_reason"),
777 });
778 }
779 }
780 }
781
782 if let Some(status) = value.get("status").and_then(Value::as_str) {
783 let life = lifecycle_from_status(status);
784 if life.is_terminal() {
785 panel.lifecycle = life;
786 panel.completed_at_ms = value
787 .get("completed_at_ms")
788 .and_then(Value::as_u64)
789 .or(panel.completed_at_ms);
790 } else if panel.lifecycle.is_running() {
791 panel.lifecycle = life;
792 }
793 }
794 if let Some(error) = opt_str(value, "error") {
795 panel.error = Some(error);
796 }
797 if let Some(spent) = value.get("budget_spent").and_then(Value::as_u64) {
798 panel.budget_spent = spent;
799 }
800 if let Some(total) = value
801 .get("token_budget")
802 .or_else(|| value.get("budget_total"))
803 .and_then(Value::as_u64)
804 {
805 panel.budget_total = Some(total);
806 }
807 if let Some(remaining) = value.get("budget_remaining").and_then(Value::as_u64) {
808 panel.budget_remaining = Some(remaining);
809 }
810 // Apply extras after events so RunStarted reset does not wipe them.
811 if panel.source_path.is_none() {
812 panel.source_path = opt_str(value, "source_path").map(PathBuf::from);
813 }
814 if panel.result_summary.is_none() {
815 panel.result_summary = value
816 .get("result")
817 .and_then(summarize_result_value)
818 .or_else(|| opt_str(value, "result_summary"));
819 }
820 if let Some(verification) = value.get("verification")
821 && let Some(summary) = verification.get("summary").and_then(Value::as_str)
822 {
823 let trimmed = summary.trim();
824 if !trimmed.is_empty() {
825 panel.result_summary = Some(match panel.result_summary.take() {
826 Some(existing) => format!("{existing} · verify: {trimmed}"),
827 None => format!("verify: {trimmed}"),
828 });
829 }
830 }
831 // Prefer the goal label from the payload when events used a fallback.
832 if !label.is_empty() && panel.label == run_id {
833 panel.label = label;
834 }
835 Some(panel)
836 }
837
838 /// Snapshot panel state into a JSON blob suitable for the history cell
839 /// (and re-hydration via [`Self::from_run_json`]).
840 #[must_use]
841 pub fn to_run_json(&self) -> Value {
842 let status = match self.lifecycle {
843 WorkflowPanelLifecycle::Pending => "pending",
844 WorkflowPanelLifecycle::Running => "running",
845 WorkflowPanelLifecycle::Succeeded => "completed",
846 WorkflowPanelLifecycle::Failed => "failed",
847 WorkflowPanelLifecycle::Cancelled => "cancelled",
848 };
849 let (done, total) = self.done_total();
850 let (failed, cancelled) = self.failure_cancel_counts();
851 json!({
852 "run_id": self.run_id,
853 "status": status,
854 "workflow_goal": self.label,
855 "started_at_ms": self.started_at_ms,
856 "completed_at_ms": self.completed_at_ms,
857 "child_count": total,
858 "done_count": done,
859 "phase_count": self.phase_count(),
860 "failure_count": failed,
861 "cancel_count": cancelled,
862 "error": self.error,
863 "result_summary": self.result_summary,
864 "source_path": self.source_path.as_ref().map(|p| p.display().to_string()),
865 "spillover_path": self.spillover_path.as_ref().map(|p| p.display().to_string()),
866 "token_budget": self.budget_total,
867 "budget_spent": self.budget_spent,
868 "budget_remaining": self.budget_remaining,
869 "gates": self.gates.iter().map(|gate| {
870 json!({
871 "gate_id": gate.gate_id.as_str(),
872 "role": gate.role.as_deref(),
873 "gate": gate.gate.as_deref(),
874 "state": gate.state.as_str(),
875 "blocked_role": gate.blocked_role.as_deref(),
876 "blocked_reason": gate.blocked_reason.as_deref(),
877 })
878 }).collect::<Vec<_>>(),
879 "phases": self.phases.iter().map(workflow_phase_run_json).collect::<Vec<_>>(),
880 })
881 }
882
883 /// Compact one-line history-card summary: lifecycle, children, phases,
884 /// failures, elapsed (#4122 AC). The free-text goal lives on the expanded
885 /// body so the fixed header summary budget (≈56 cols) never drops counts.
886 #[must_use]
887 pub fn compact_summary_text(&self, width: usize) -> String {
888 let (_done, total) = self.done_total();
889 let (failed, _cancelled) = self.failure_cancel_counts();
890 let phases = self.phase_count();
891 let elapsed = self.elapsed_label();
892 let child_word = if total == 1 { "child" } else { "children" };
893 let phase_word = if phases == 1 { "phase" } else { "phases" };
894 let raw = format!(
895 "workflow {life} · {total} {child_word} · {phases} {phase_word} · {failed} fail · {elapsed}",
896 life = self.lifecycle.label(),
897 );
898 truncate_line_to_width(&raw, width.max(1))
899 }
900
901 /// One-chip summary for the top status bar (#5040): lifecycle,
902 /// done/total children, failures, elapsed. Intentionally terse — the
903 /// expanded panel and history card carry the detail.
904 #[must_use]
905 pub fn top_bar_chip(&self) -> String {
906 let (done, total) = self.done_total();
907 let (failed, _cancelled) = self.failure_cancel_counts();
908 let mut chip = format!("wf {} {done}/{total}", self.lifecycle.label());
909 if failed > 0 {
910 chip.push_str(&format!(" · {failed} fail"));
911 }
912 chip.push_str(&format!(" · {}", self.elapsed_label()));
913 chip
914 }
915
916 /// Elapsed label shared with direct sub-agent cards.
917 #[must_use]
918 pub fn elapsed_label(&self) -> String {
919 // Guard against epoch-zero starts (bare status payloads without
920 // timestamps) which would otherwise render multi-year elapsed times.
921 if self.started_at_ms == 0 {
922 if let Some(completed) = self.completed_at_ms {
923 return crate::elapsed::format_elapsed_ms(completed);
924 }
925 return "0s".to_string();
926 }
927 let end = self.completed_at_ms.unwrap_or_else(now_ms);
928 crate::elapsed::format_elapsed_ms(end.saturating_sub(self.started_at_ms))
929 }
930
931 /// Compact summary line content (without card chrome). Callers in
932 /// `history.rs` wrap this with the shared tool-header + rail.
933 #[must_use]
934 pub fn history_header_summary(&self, width: usize) -> String {
935 self.compact_summary_text(width)
936 }
937
938 /// Expanded history-card body lines (phase/child summaries, links,
939 /// result, failures). Empty when the card should stay compact.
940 #[must_use]
941 pub fn history_expanded_lines(
942 &self,
943 width: u16,
944 extras: &WorkflowHistoryExtras,
945 ) -> Vec<Line<'static>> {
946 let content_width = usize::from(width).max(1);
947 let mut lines = Vec::new();
948
949 if !self.label.trim().is_empty() {
950 lines.push(Line::from(Span::styled(
951 truncate_line_to_width(
952 &format!("goal: {}", short_label(self.label.trim(), 160)),
953 content_width,
954 ),
955 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
956 )));
957 }
958
959 // Phase summary strip (same chips as the panel body).
960 if !self.phases.is_empty() {
961 let mut chips = Vec::new();
962 for (idx, phase) in self.phases.iter().take(MAX_PHASE_SUMMARY).enumerate() {
963 let (done, running, failed, cancelled) = phase.counts();
964 let marker = crate::tui::glyphs::selection_marker(idx == self.selected_phase);
965 chips.push(format!(
966 "{marker}{title}[{done}✓ {running}… {failed}! {cancelled}⊘]",
967 title = short_label(&phase.title, 14),
968 ));
969 }
970 if self.phases.len() > MAX_PHASE_SUMMARY {
971 chips.push(format!("+{}", self.phases.len() - MAX_PHASE_SUMMARY));
972 }
973 lines.push(Line::from(Span::styled(
974 truncate_line_to_width(&format!("phases: {}", chips.join(" ")), content_width),
975 Style::default().fg(palette::TEXT_MUTED),
976 )));
977 }
978
979 if !self.gates.is_empty() {
980 lines.push(Line::from(Span::styled(
981 truncate_line_to_width(&format!("gates: {}", self.gates_summary()), content_width),
982 Style::default().fg(palette::TEXT_MUTED),
983 )));
984 }
985
986 // Child summary across all phases.
987 let children: Vec<String> = self
988 .phases
989 .iter()
990 .flat_map(|p| p.rows.iter())
991 .take(8)
992 .map(|row| {
993 format!(
994 "{mark} {label} ({status})",
995 mark = role_mark(row.profile.as_deref()),
996 label = short_label(&row.label, 16),
997 status = row.status.display_label(self.locale)
998 )
999 })
1000 .collect();
1001 if !children.is_empty() {
1002 let more = self
1003 .phases
1004 .iter()
1005 .map(|p| p.rows.len())
1006 .sum::<usize>()
1007 .saturating_sub(children.len());
1008 let mut body = children.join(" · ");
1009 if more > 0 {
1010 body = format!("{body} · +{more} more");
1011 }
1012 lines.push(Line::from(Span::styled(
1013 truncate_line_to_width(&format!("children: {body}"), content_width),
1014 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
1015 )));
1016 }
1017
1018 // The history variant uses the same real-data lane vocabulary as the
1019 // live panel. Durations are proportional within the run; gates remain
1020 // a separate named line because runtime events do not yet timestamp
1021 // them precisely enough to place them on a synthetic timeline.
1022 let rows = self.phases.iter().flat_map(|phase| phase.rows.iter());
1023 let max_elapsed = rows
1024 .clone()
1025 .map(|row| row_elapsed_ms(row, now_ms()))
1026 .max()
1027 .unwrap_or(0);
1028 for row in rows.take(8) {
1029 lines.push(Line::from(Span::styled(
1030 truncate_line_to_width(
1031 &format!(
1032 "lane {mark} {label:<14} {track} {elapsed} {status}",
1033 mark = role_mark(row.profile.as_deref()),
1034 label = short_label(&row.label, 14),
1035 track = lane_track(row, max_elapsed, 16, now_ms()),
1036 elapsed = crate::elapsed::format_elapsed_ms(row_elapsed_ms(row, now_ms())),
1037 status = row.status.display_label(self.locale),
1038 ),
1039 content_width,
1040 ),
1041 Style::default().fg(row.status.color()),
1042 )));
1043 // #4039: the history card shows the same immutable receipt as the
1044 // live panel, so a finished run stays auditable after the fact.
1045 if self.show_workflow_receipts {
1046 lines.extend(
1047 receipt_line_strings(row, self.locale, content_width, 2)
1048 .into_iter()
1049 .map(|text| {
1050 Line::from(Span::styled(text, Style::default().fg(palette::TEXT_MUTED)))
1051 }),
1052 );
1053 }
1054 }
1055
1056 if self.lifecycle.is_terminal() {
1057 let (done, total) = self.done_total();
1058 let (failed, cancelled) = self.failure_cancel_counts();
1059 lines.push(Line::from(Span::styled(
1060 truncate_line_to_width(
1061 &tr(self.locale, MessageId::WorkflowDebrief)
1062 .replace("{done}", &done.to_string())
1063 .replace("{total}", &total.to_string())
1064 .replace("{failed}", &failed.to_string())
1065 .replace("{cancelled}", &cancelled.to_string())
1066 .replace("{elapsed}", &self.elapsed_label()),
1067 content_width,
1068 ),
1069 Style::default().fg(palette::TEXT_MUTED),
1070 )));
1071 }
1072
1073 let result = extras
1074 .result_summary
1075 .as_deref()
1076 .or(self.result_summary.as_deref())
1077 .or(extras.verification_summary.as_deref());
1078 if let Some(result) = result.filter(|s| !s.trim().is_empty()) {
1079 lines.push(Line::from(Span::styled(
1080 truncate_line_to_width(
1081 &format!("result: {}", short_label(result.trim(), 160)),
1082 content_width,
1083 ),
1084 Style::default().fg(palette::TEXT_TOOL_OUTPUT),
1085 )));
1086 }
1087
1088 let source = extras
1089 .source_path
1090 .as_ref()
1091 .or(self.source_path.as_ref())
1092 .map(|p| p.display().to_string());
1093 if let Some(path) = source.filter(|s| !s.is_empty()) {
1094 lines.push(Line::from(Span::styled(
1095 truncate_line_to_width(&format!("source: {path}"), content_width),
1096 Style::default().fg(palette::TEXT_MUTED),
1097 )));
1098 }
1099 let spill = extras
1100 .spillover_path
1101 .as_ref()
1102 .or(self.spillover_path.as_ref())
1103 .map(|p| p.display().to_string());
1104 if let Some(path) = spill.filter(|s| !s.is_empty()) {
1105 lines.push(Line::from(Span::styled(
1106 truncate_line_to_width(&format!("artifact: {path}"), content_width),
1107 Style::default().fg(palette::TEXT_MUTED),
1108 )));
1109 } else if self.lifecycle.is_terminal() {
1110 let details = crate::tui::shell_key_routing::tool_details_chord();
1111 let transcript_hint = tr(self.locale, MessageId::WorkflowTranscriptDetails)
1112 .replace("{details}", details.as_ref());
1113 lines.push(Line::from(Span::styled(
1114 truncate_line_to_width(&transcript_hint, content_width),
1115 Style::default().fg(palette::TEXT_MUTED),
1116 )));
1117 }
1118
1119 if let Some(error) = self.error.as_deref().filter(|s| !s.trim().is_empty()) {
1120 lines.push(Line::from(Span::styled(
1121 truncate_line_to_width(
1122 &format!("error: {}", short_label(error, 160)),
1123 content_width,
1124 ),
1125 Style::default().fg(palette::STATUS_ERROR),
1126 )));
1127 }
1128 for row in self.phases.iter().flat_map(|p| p.rows.iter()) {
1129 if let Some(schema) = row.schema_error.as_deref() {
1130 lines.push(Line::from(Span::styled(
1131 truncate_line_to_width(
1132 &format!(
1133 "schema {}: {}",
1134 short_label(&row.task_id, 12),
1135 short_label(schema, 120)
1136 ),
1137 content_width,
1138 ),
1139 Style::default().fg(palette::STATUS_ERROR),
1140 )));
1141 } else if row.status.is_failure()
1142 && let Some(err) = row.error.as_deref()
1143 {
1144 lines.push(Line::from(Span::styled(
1145 truncate_line_to_width(
1146 &format!(
1147 "fail {}: {}",
1148 short_label(&row.label, 14),
1149 short_label(err, 120)
1150 ),
1151 content_width,
1152 ),
1153 Style::default().fg(palette::STATUS_ERROR),
1154 )));
1155 }
1156 }
1157
1158 lines
1159 }
1160
1161 /// Full history-card lines including a simple self-contained header so
1162 /// unit tests (and direct sub-agent cards) can render without history.rs.
1163 ///
1164 /// Public convergence API for #4122 — also exercised by unit tests and
1165 /// `DelegateCard::as_workflow_history_panel`.
1166 #[must_use]
1167 #[allow(dead_code)] // public API used by direct sub-agent projection + tests
1168 pub fn render_history_card(
1169 &self,
1170 width: u16,
1171 expanded: bool,
1172 extras: &WorkflowHistoryExtras,
1173 ) -> Vec<Line<'static>> {
1174 let content_width = usize::from(width).max(1);
1175 let mut lines = Vec::new();
1176 let glyph = if expanded { '▼' } else { '▶' };
1177 let summary = self.compact_summary_text(content_width.saturating_sub(2));
1178 lines.push(Line::from(Span::styled(
1179 truncate_line_to_width(&format!("{glyph} {summary}"), content_width),
1180 Style::default()
1181 .fg(self.lifecycle.color())
1182 .add_modifier(Modifier::BOLD),
1183 )));
1184 if expanded {
1185 lines.extend(self.history_expanded_lines(width, extras));
1186 }
1187 lines
1188 }
1189
1190 /// Single-agent "mini workflow" view for direct sub-agent cards so they
1191 /// share the same lifecycle/elapsed/result concepts as workflow runs.
1192 #[must_use]
1193 #[allow(dead_code)] // public API used by DelegateCard + tests
1194 pub fn from_direct_subagent(
1195 agent_id: impl Into<String>,
1196 role: impl Into<String>,
1197 lifecycle: WorkflowPanelLifecycle,
1198 started_at_ms: u64,
1199 completed_at_ms: Option<u64>,
1200 summary: Option<String>,
1201 error: Option<String>,
1202 ) -> Self {
1203 let agent_id = agent_id.into();
1204 let role = role.into();
1205 let mut panel = Self::new(agent_id.clone(), role.clone(), started_at_ms);
1206 panel.lifecycle = lifecycle;
1207 panel.completed_at_ms = completed_at_ms;
1208 panel.expanded = false;
1209 panel.show_workflow_receipts = false;
1210 panel.result_summary = summary.clone();
1211 panel.error = error.clone();
1212 let status = match lifecycle {
1213 WorkflowPanelLifecycle::Pending => WorkflowRowStatus::Pending,
1214 WorkflowPanelLifecycle::Running => WorkflowRowStatus::Running,
1215 WorkflowPanelLifecycle::Succeeded => WorkflowRowStatus::Succeeded,
1216 WorkflowPanelLifecycle::Failed => WorkflowRowStatus::Failed,
1217 WorkflowPanelLifecycle::Cancelled => WorkflowRowStatus::Cancelled,
1218 };
1219 let mut phase = WorkflowPanelPhase::new("Agent");
1220 phase.rows.push(WorkflowPanelRow {
1221 task_id: agent_id,
1222 label: role,
1223 profile: None,
1224 model: None,
1225 strength: None,
1226 worktree: false,
1227 workspace: None,
1228 status,
1229 started_at_ms,
1230 completed_at_ms,
1231 error,
1232 schema_error: None,
1233 // A direct sub-agent card projects a single agent, not a Workflow
1234 // task, so it carries no Workflow launch/usage receipt (#4039).
1235 route: WorkflowRowRoute::default(),
1236 usage: completed_at_ms.map(|_| WorkflowRowUsage::default()),
1237 });
1238 panel.phases.push(phase);
1239 panel
1240 }
1241
1242 /// Apply a stream of events. `RunStarted` replaces any prior completed run.
1243 pub fn apply_event(&mut self, event: WorkflowPanelEvent) {
1244 match event {
1245 WorkflowPanelEvent::RunStarted {
1246 run_id,
1247 workflow_id,
1248 workflow_goal,
1249 source_path,
1250 token_budget,
1251 at_ms,
1252 } => {
1253 // New run replaces preserved completed state.
1254 let locale = self.locale;
1255 *self = Self::new(
1256 run_id,
1257 workflow_goal
1258 .or(workflow_id)
1259 .unwrap_or_else(|| "workflow".to_string()),
1260 at_ms,
1261 );
1262 self.locale = locale;
1263 self.budget_total = token_budget;
1264 self.budget_remaining = token_budget;
1265 self.source_path = source_path;
1266 }
1267 WorkflowPanelEvent::RunCompleted {
1268 status,
1269 error,
1270 at_ms,
1271 } => {
1272 self.lifecycle = if matches!(status, WorkflowPanelLifecycle::Running) {
1273 WorkflowPanelLifecycle::Succeeded
1274 } else {
1275 status
1276 };
1277 self.error = error;
1278 self.completed_at_ms = Some(at_ms);
1279 // Preserve expanded/collapsed choice; do not auto-hide.
1280 }
1281 WorkflowPanelEvent::RunCancelled { reason, at_ms } => {
1282 self.finalize_running_rows(WorkflowRowStatus::Cancelled, at_ms);
1283 self.lifecycle = WorkflowPanelLifecycle::Cancelled;
1284 self.error = Some(reason);
1285 self.completed_at_ms = Some(at_ms);
1286 }
1287 WorkflowPanelEvent::PhaseStarted { title, at_ms: _ } => {
1288 if self.phases.last().is_some_and(|phase| phase.title == title) {
1289 return;
1290 }
1291 self.phases.push(WorkflowPanelPhase::new(title));
1292 self.selected_phase = self.phases.len().saturating_sub(1);
1293 if self.lifecycle.is_running() {
1294 self.expanded = true;
1295 }
1296 }
1297 WorkflowPanelEvent::TaskStarted {
1298 task_id,
1299 label,
1300 profile,
1301 model,
1302 strength,
1303 resolved_model,
1304 worktree,
1305 workspace,
1306 route,
1307 at_ms,
1308 } => {
1309 if self.phases.is_empty() {
1310 self.phases.push(WorkflowPanelPhase::new("Work"));
1311 self.selected_phase = 0;
1312 }
1313 let phase_idx = self.selected_phase.min(self.phases.len().saturating_sub(1));
1314 let display_model = resolved_model.or(model);
1315 let row = WorkflowPanelRow {
1316 task_id: task_id.clone(),
1317 label: label
1318 .filter(|s| !s.trim().is_empty())
1319 .unwrap_or_else(|| task_id.clone()),
1320 profile,
1321 model: display_model,
1322 strength,
1323 worktree,
1324 workspace,
1325 status: WorkflowRowStatus::Running,
1326 started_at_ms: at_ms,
1327 completed_at_ms: None,
1328 error: None,
1329 schema_error: None,
1330 route,
1331 usage: None,
1332 };
1333 if let Some(existing) = self.find_row_mut(&task_id) {
1334 *existing = row;
1335 } else if let Some(phase) = self.phases.get_mut(phase_idx) {
1336 phase.rows.push(row);
1337 }
1338 self.lifecycle = WorkflowPanelLifecycle::Running;
1339 self.expanded = true;
1340 }
1341 WorkflowPanelEvent::TaskCompleted {
1342 task_id,
1343 status,
1344 usage,
1345 at_ms,
1346 } => {
1347 if let Some(row) = self.find_row_mut(&task_id) {
1348 row.status = status;
1349 row.completed_at_ms = Some(at_ms);
1350 // A completed row always carries a usage receipt, even when
1351 // every counter in it is unknown (#4039).
1352 row.usage = Some(usage.unwrap_or_default());
1353 }
1354 }
1355 WorkflowPanelEvent::GateUpdated {
1356 gate_id,
1357 role,
1358 gate,
1359 state,
1360 blocked_role,
1361 blocked_reason,
1362 at_ms: _,
1363 } => {
1364 self.upsert_gate(WorkflowPanelGateLine {
1365 gate_id,
1366 role,
1367 gate,
1368 state,
1369 blocked_role,
1370 blocked_reason,
1371 });
1372 if self.lifecycle.is_running() {
1373 self.expanded = true;
1374 }
1375 }
1376 WorkflowPanelEvent::TaskSchemaValidationFailed {
1377 task_id,
1378 message,
1379 at_ms,
1380 } => {
1381 if let Some(row) = self.find_row_mut(&task_id) {
1382 row.status = WorkflowRowStatus::SchemaFailed;
1383 row.schema_error = Some(message);
1384 row.completed_at_ms = Some(at_ms);
1385 } else {
1386 // Schema can fire before/without a started task.
1387 if self.phases.is_empty() {
1388 self.phases.push(WorkflowPanelPhase::new("Work"));
1389 }
1390 let phase_idx = self.selected_phase.min(self.phases.len().saturating_sub(1));
1391 if let Some(phase) = self.phases.get_mut(phase_idx) {
1392 phase.rows.push(WorkflowPanelRow {
1393 task_id,
1394 label: "schema".to_string(),
1395 profile: None,
1396 model: None,
1397 strength: None,
1398 worktree: false,
1399 workspace: None,
1400 status: WorkflowRowStatus::SchemaFailed,
1401 started_at_ms: at_ms,
1402 completed_at_ms: Some(at_ms),
1403 error: None,
1404 schema_error: Some(message),
1405 // Schema failure without a task_started: nothing was
1406 // received about the route, so nothing is claimed.
1407 route: WorkflowRowRoute::default(),
1408 usage: Some(WorkflowRowUsage::default()),
1409 });
1410 }
1411 }
1412 }
1413 WorkflowPanelEvent::BudgetUpdated {
1414 total,
1415 spent,
1416 remaining,
1417 at_ms: _,
1418 } => {
1419 if total.is_some() {
1420 self.budget_total = total;
1421 }
1422 self.budget_spent = spent;
1423 self.budget_remaining = remaining;
1424 }
1425 }
1426 }
1427
1428 pub fn apply_json_event(&mut self, value: &Value) {
1429 if let Some(event) = WorkflowPanelEvent::from_json_value(value) {
1430 self.apply_event(event);
1431 }
1432 }
1433
1434 pub fn apply_json_events(&mut self, values: &[Value]) {
1435 for value in values {
1436 self.apply_json_event(value);
1437 }
1438 }
1439
1440 #[must_use]
1441 pub fn toggle_expanded(&mut self) -> bool {
1442 self.expanded = !self.expanded;
1443 true
1444 }
1445
1446 pub fn select_next_phase(&mut self) {
1447 if self.phases.is_empty() {
1448 return;
1449 }
1450 self.selected_phase = (self.selected_phase + 1) % self.phases.len();
1451 }
1452
1453 pub fn select_prev_phase(&mut self) {
1454 if self.phases.is_empty() {
1455 return;
1456 }
1457 self.selected_phase = self
1458 .selected_phase
1459 .checked_sub(1)
1460 .unwrap_or(self.phases.len() - 1);
1461 }
1462
1463 /// Interrupt finalizes every still-running child as cancelled and marks
1464 /// the run cancelled. Preserves the panel until the next workflow starts.
1465 pub fn finalize_interrupt(&mut self) {
1466 if self.lifecycle.is_terminal() {
1467 return;
1468 }
1469 let at = now_ms();
1470 self.finalize_running_rows(WorkflowRowStatus::Cancelled, at);
1471 self.lifecycle = WorkflowPanelLifecycle::Cancelled;
1472 self.completed_at_ms = Some(at);
1473 if self.error.is_none() {
1474 self.error = Some("interrupted".to_string());
1475 }
1476 }
1477
1478 #[must_use]
1479 pub fn done_total(&self) -> (usize, usize) {
1480 let mut done = 0usize;
1481 let mut total = 0usize;
1482 for phase in &self.phases {
1483 for row in &phase.rows {
1484 total += 1;
1485 if !row.status.is_running() {
1486 done += 1;
1487 }
1488 }
1489 }
1490 (done, total)
1491 }
1492
1493 #[must_use]
1494 pub fn phase_count(&self) -> usize {
1495 self.phases.len()
1496 }
1497
1498 #[must_use]
1499 pub fn failure_cancel_counts(&self) -> (usize, usize) {
1500 let mut failed = 0usize;
1501 let mut cancelled = 0usize;
1502 for phase in &self.phases {
1503 for row in &phase.rows {
1504 if row.status.is_failure() {
1505 failed += 1;
1506 } else if row.status.is_cancel() {
1507 cancelled += 1;
1508 }
1509 }
1510 }
1511 (failed, cancelled)
1512 }
1513
1514 /// Header line: expand glyph, lifecycle, label, done/total, phases,
1515 /// fail/cancel counts, budget spent/remaining.
1516 #[must_use]
1517 pub fn header_text(&self, width: usize) -> String {
1518 let glyph = if self.expanded { '▼' } else { '▶' };
1519 let (done, total) = self.done_total();
1520 let (failed, cancelled) = self.failure_cancel_counts();
1521 let phases = self.phase_count();
1522 let budget =
1523 format_budget_chrome(self.budget_spent, self.budget_remaining, self.budget_total);
1524 let cancel_hint = if self.lifecycle.is_running() {
1525 " · [c] cancel"
1526 } else {
1527 ""
1528 };
1529 let elapsed = {
1530 let end = self.completed_at_ms.unwrap_or_else(now_ms);
1531 crate::elapsed::format_elapsed_ms(end.saturating_sub(self.started_at_ms))
1532 };
1533 let focus = if self.keyboard_focus { "*" } else { "" };
1534 let raw = format!(
1535 "{glyph}{focus} workflow {life} · {label} · {done}/{total} · {phases} phases · {failed} fail · {cancelled} cancel · {elapsed}{budget}{cancel_hint}",
1536 life = self.lifecycle.label(),
1537 label = self.label,
1538 );
1539 truncate_line_to_width(&raw, width.max(1))
1540 }
1541
1542 /// Return the display-column span of the cancel hint in the exact header
1543 /// string that `render_lines` paints, after truncation.
1544 #[must_use]
1545 pub fn cancel_hint_span(&self, width: u16) -> Option<(u16, u16)> {
1546 let header = self.header_text(usize::from(width));
1547 let start = header.find("[c] cancel")?;
1548 let start = unicode_width::UnicodeWidthStr::width(&header[..start]);
1549 let end = start + unicode_width::UnicodeWidthStr::width("[c] cancel");
1550 Some((start as u16, end as u16))
1551 }
1552
1553 #[must_use]
1554 pub fn render_lines(&self, width: u16) -> Vec<Line<'static>> {
1555 self.render_lines_bounded(width, None)
1556 }
1557
1558 fn render_lines_bounded(&self, width: u16, max_height: Option<usize>) -> Vec<Line<'static>> {
1559 if max_height == Some(0) {
1560 return Vec::new();
1561 }
1562 let content_width = usize::from(width).max(1);
1563 let mut lines = Vec::with_capacity(12);
1564 lines.push(Line::from(Span::styled(
1565 self.header_text(content_width),
1566 Style::default()
1567 .fg(self.lifecycle.color())
1568 .add_modifier(Modifier::BOLD),
1569 )));
1570
1571 if !self.expanded {
1572 return lines;
1573 }
1574
1575 // Phase summary strip.
1576 if !self.phases.is_empty() {
1577 let mut chips = Vec::new();
1578 for (idx, phase) in self.phases.iter().take(MAX_PHASE_SUMMARY).enumerate() {
1579 let (done, running, failed, cancelled) = phase.counts();
1580 let marker = crate::tui::glyphs::selection_marker(idx == self.selected_phase);
1581 chips.push(format!(
1582 "{marker}{title}[{done}✓ {running}… {failed}! {cancelled}⊘]",
1583 title = short_label(&phase.title, 14),
1584 ));
1585 }
1586 if self.phases.len() > MAX_PHASE_SUMMARY {
1587 chips.push(format!("+{}", self.phases.len() - MAX_PHASE_SUMMARY));
1588 }
1589 lines.push(Line::from(Span::styled(
1590 truncate_line_to_width(&chips.join(" "), content_width),
1591 Style::default().fg(palette::TEXT_MUTED),
1592 )));
1593 }
1594
1595 if !self.gates.is_empty() {
1596 lines.push(Line::from(Span::styled(
1597 truncate_line_to_width(&format!("gates: {}", self.gates_summary()), content_width),
1598 Style::default().fg(palette::TEXT_MUTED),
1599 )));
1600 }
1601
1602 // Selected phase rows.
1603 if let Some(phase) = self.phases.get(self.selected_phase) {
1604 lines.push(Line::from(Span::styled(
1605 truncate_line_to_width(
1606 &format!("phase: {} ({} rows)", phase.title, phase.rows.len()),
1607 content_width,
1608 ),
1609 Style::default()
1610 .fg(palette::WHALE_INFO)
1611 .add_modifier(Modifier::BOLD),
1612 )));
1613
1614 let now = now_ms();
1615 let mut shown = 0usize;
1616 for row in phase.rows.iter().take(MAX_VISIBLE_ROWS) {
1617 let block = self.render_row_lines(row, content_width, now);
1618 let more_after = phase.rows.len() > shown + 1;
1619 let reserved_tail = usize::from(more_after)
1620 + usize::from(self.error.is_some())
1621 + usize::from(self.keyboard_focus);
1622 if max_height
1623 .is_some_and(|height| lines.len() + block.len() + reserved_tail > height)
1624 {
1625 break;
1626 }
1627 lines.extend(block);
1628 shown += 1;
1629 }
1630 if phase.rows.len() > shown {
1631 lines.push(Line::from(Span::styled(
1632 format!(" … {} more", phase.rows.len() - shown),
1633 Style::default().fg(palette::TEXT_MUTED),
1634 )));
1635 }
1636 } else if self.lifecycle.is_running() {
1637 lines.push(Line::from(Span::styled(
1638 truncate_line_to_width("waiting for phases…", content_width),
1639 Style::default().fg(palette::TEXT_MUTED),
1640 )));
1641 }
1642
1643 if let Some(error) = self.error.as_deref() {
1644 lines.push(Line::from(Span::styled(
1645 truncate_line_to_width(&format!("error: {error}"), content_width),
1646 Style::default().fg(palette::STATUS_ERROR),
1647 )));
1648 }
1649
1650 if self.keyboard_focus {
1651 lines.push(Line::from(Span::styled(
1652 truncate_line_to_width(
1653 "[enter] toggle [del] cancel [up/down] phase [esc] chat",
1654 content_width,
1655 ),
1656 Style::default()
1657 .fg(palette::TEXT_MUTED)
1658 .add_modifier(Modifier::ITALIC),
1659 )));
1660 }
1661
1662 // Every producer above is independently useful, but the terminal owns
1663 // the final hard boundary. This also covers headers and tail rows,
1664 // which cannot be accounted for solely by the per-worker row budget.
1665 if let Some(height) = max_height {
1666 lines.truncate(height);
1667 }
1668 lines
1669 }
1670
1671 /// One row renders as its status line plus its receipt line (#4039).
1672 ///
1673 /// The receipt is not optional and not hover-gated: a row that is live or
1674 /// completed always states the route it was launched on, and a completed
1675 /// row always states what that route cost.
1676 fn render_row_lines(
1677 &self,
1678 row: &WorkflowPanelRow,
1679 width: usize,
1680 now_ms: u64,
1681 ) -> Vec<Line<'static>> {
1682 let mut lines = vec![self.render_row_line(row, width, now_ms)];
1683 lines.extend(
1684 receipt_line_strings(row, self.locale, width, 4)
1685 .into_iter()
1686 .map(|text| {
1687 Line::from(Span::styled(text, Style::default().fg(palette::TEXT_MUTED)))
1688 }),
1689 );
1690 lines
1691 }
1692
1693 fn render_row_line(&self, row: &WorkflowPanelRow, width: usize, now_ms: u64) -> Line<'static> {
1694 let elapsed_ms = row_elapsed_ms(row, now_ms);
1695 let elapsed = crate::elapsed::format_elapsed_ms(elapsed_ms);
1696 let role = row.profile.as_deref().unwrap_or("-");
1697 let model = match (row.model.as_deref(), row.strength.as_deref()) {
1698 (Some(m), Some(s)) => format!("{m}/{s}"),
1699 (Some(m), None) => m.to_string(),
1700 (None, Some(s)) => s.to_string(),
1701 (None, None) => "-".to_string(),
1702 };
1703 let worktree = if row.worktree { "wt" } else { "main" };
1704 let schema = row
1705 .schema_error
1706 .as_deref()
1707 .or(row.error.as_deref())
1708 .map(|e| format!(" !{}", short_label(e, 24)))
1709 .unwrap_or_default();
1710 let text = format!(
1711 " {mark} {status:<9} {label} · {role} · {model} · {worktree} · {lane} · {elapsed}{schema}",
1712 mark = role_mark(row.profile.as_deref()),
1713 status = row.status.display_label(self.locale),
1714 label = short_label(&row.label, 18),
1715 lane = lane_track(row, elapsed_ms.max(1), 10, now_ms),
1716 );
1717 Line::from(Span::styled(
1718 truncate_line_to_width(&text, width),
1719 Style::default().fg(row.status.color()),
1720 ))
1721 }
1722
1723 fn find_row_mut(&mut self, task_id: &str) -> Option<&mut WorkflowPanelRow> {
1724 for phase in &mut self.phases {
1725 if let Some(row) = phase.rows.iter_mut().find(|r| r.task_id == task_id) {
1726 return Some(row);
1727 }
1728 }
1729 None
1730 }
1731
1732 fn gates_summary(&self) -> String {
1733 self.gates
1734 .iter()
1735 .take(6)
1736 .map(|gate| {
1737 let target = gate
1738 .blocked_role
1739 .as_deref()
1740 .or(gate.role.as_deref())
1741 .unwrap_or("-");
1742 if let Some(reason) = gate.blocked_reason.as_deref() {
1743 format!(
1744 "{}:{}->{} ({})",
1745 short_label(&gate.gate_id, 18),
1746 gate.state,
1747 target,
1748 short_label(reason, 40)
1749 )
1750 } else {
1751 format!(
1752 "{}:{}->{}",
1753 short_label(&gate.gate_id, 18),
1754 gate.state,
1755 target
1756 )
1757 }
1758 })
1759 .collect::<Vec<_>>()
1760 .join(" ")
1761 }
1762
1763 fn upsert_gate(&mut self, gate: WorkflowPanelGateLine) {
1764 if let Some(existing) = self
1765 .gates
1766 .iter_mut()
1767 .find(|existing| existing.gate_id == gate.gate_id)
1768 {
1769 *existing = gate;
1770 } else {
1771 self.gates.push(gate);
1772 }
1773 }
1774
1775 fn finalize_running_rows(&mut self, status: WorkflowRowStatus, at_ms: u64) {
1776 for phase in &mut self.phases {
1777 for row in &mut phase.rows {
1778 if row.status.is_running() {
1779 row.status = status;
1780 row.completed_at_ms = Some(at_ms);
1781 // A terminal Workflow row must keep the receipt shape even
1782 // when cancellation arrived before provider telemetry.
1783 // Unknown counters remain unknown; they never disappear or
1784 // become fabricated zeros (#4039).
1785 row.usage.get_or_insert_with(WorkflowRowUsage::default);
1786 }
1787 }
1788 }
1789 }
1790 }
1791
1792 fn workflow_phase_run_json(phase: &WorkflowPanelPhase) -> Value {
1793 json!({
1794 "title": phase.title,
1795 "rows": phase.rows.iter().map(workflow_row_run_json).collect::<Vec<_>>(),
1796 })
1797 }
1798
1799 fn workflow_row_run_json(row: &WorkflowPanelRow) -> Value {
1800 let usage = row.usage.as_ref().map(|usage| {
1801 json!({
1802 "input_tokens": usage.input_tokens,
1803 "output_tokens": usage.output_tokens,
1804 "total_tokens": usage.total_tokens,
1805 "tool_calls": usage.tool_calls,
1806 "duration_ms": usage.duration_ms,
1807 "token_source": usage.token_source.map(WorkflowTokenSource::as_str),
1808 })
1809 });
1810 json!({
1811 "task_id": row.task_id,
1812 "label": row.label,
1813 "profile": row.profile,
1814 "model": row.model,
1815 "strength": row.strength,
1816 "worktree": row.worktree,
1817 "workspace": row.workspace.as_ref().map(|p| p.display().to_string()),
1818 "status": row.status.label(),
1819 "started_at_ms": row.started_at_ms,
1820 "completed_at_ms": row.completed_at_ms,
1821 "error": row.error,
1822 "schema_error": row.schema_error,
1823 // #4039 receipts survive the history round trip; absent fields stay
1824 // absent, never defaulted.
1825 "role": row.route.role,
1826 "provider": row.route.provider,
1827 "resolved_model": row.route.model,
1828 "requested_reasoning": row.route.requested_reasoning,
1829 "effective_reasoning": row.route.effective_reasoning,
1830 "route_source": row.route.route_source.map(WorkflowRouteSource::as_str),
1831 "usage": usage,
1832 })
1833 }
1834
1835 impl Renderable for WorkflowPanel {
1836 fn render(&self, area: Rect, buf: &mut Buffer) {
1837 if area.width == 0 || area.height == 0 {
1838 return;
1839 }
1840 let lines = self.render_lines_bounded(area.width, Some(usize::from(area.height)));
1841 let paragraph = Paragraph::new(lines);
1842 paragraph.render(area, buf);
1843 }
1844
1845 fn desired_height(&self, width: u16) -> u16 {
1846 if width == 0 {
1847 return 0;
1848 }
1849 self.render_lines(width).len() as u16
1850 }
1851 }
1852
1853 fn lifecycle_from_status(status: &str) -> WorkflowPanelLifecycle {
1854 match status {
1855 "running" => WorkflowPanelLifecycle::Running,
1856 "completed" | "succeeded" | "success" => WorkflowPanelLifecycle::Succeeded,
1857 "failed" | "error" => WorkflowPanelLifecycle::Failed,
1858 "cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
1859 "pending" => WorkflowPanelLifecycle::Pending,
1860 _ => WorkflowPanelLifecycle::Failed,
1861 }
1862 }
1863
1864 fn localized_field(locale: Locale, id: MessageId, value: &str) -> String {
1865 tr(locale, id).replace("{value}", value)
1866 }
1867
1868 fn receipt_parts(row: &WorkflowPanelRow, locale: Locale) -> Vec<String> {
1869 let unknown = tr(locale, MessageId::WorkflowReceiptUnknown).into_owned();
1870 let role = WorkflowRowRoute::field(row.route.role.as_ref(), locale);
1871 let provider = WorkflowRowRoute::field(row.route.provider.as_ref(), locale);
1872 let model = WorkflowRowRoute::field(row.route.model.as_ref(), locale);
1873 let requested = WorkflowRowRoute::field(row.route.requested_reasoning.as_ref(), locale);
1874 let effective = WorkflowRowRoute::field(row.route.effective_reasoning.as_ref(), locale);
1875 let source = row
1876 .route
1877 .route_source
1878 .map(WorkflowRouteSource::as_str)
1879 .unwrap_or(unknown.as_str());
1880 let mut parts = vec![
1881 localized_field(locale, MessageId::WorkflowReceiptRole, &role),
1882 format!("{provider}/{model}"),
1883 localized_field(
1884 locale,
1885 MessageId::WorkflowReceiptReasoning,
1886 &format!("{requested}→{effective}"),
1887 ),
1888 localized_field(locale, MessageId::WorkflowReceiptVia, source),
1889 ];
1890
1891 if let Some(usage) = row.usage.as_ref() {
1892 let tokens = usage.token_total().map_or_else(
1893 || unknown.clone(),
1894 |total| format!("{total} ({})", usage.token_source_label(locale)),
1895 );
1896 let tools = usage
1897 .tool_calls
1898 .map_or_else(|| unknown.clone(), |calls| calls.to_string());
1899 let duration = usage
1900 .duration_ms
1901 .map_or_else(|| unknown.clone(), crate::elapsed::format_elapsed_ms);
1902 parts.extend([
1903 localized_field(locale, MessageId::WorkflowReceiptTokens, &tokens),
1904 localized_field(locale, MessageId::WorkflowReceiptTools, &tools),
1905 localized_field(locale, MessageId::WorkflowReceiptDuration, &duration),
1906 ]);
1907 }
1908 parts
1909 }
1910
1911 /// Full English receipt retained for history serialization tests and text
1912 /// exports. Renderers use [`receipt_line_strings`] so narrow terminals wrap
1913 /// fields instead of dropping them.
1914 #[must_use]
1915 #[cfg(test)]
1916 pub fn row_receipt_text(row: &WorkflowPanelRow) -> String {
1917 receipt_parts(row, Locale::En).join(" · ")
1918 }
1919
1920 fn receipt_line_strings(
1921 row: &WorkflowPanelRow,
1922 locale: Locale,
1923 width: usize,
1924 requested_indent: usize,
1925 ) -> Vec<String> {
1926 let indent = requested_indent.min(width.saturating_sub(1));
1927 let prefix = " ".repeat(indent);
1928 let available = width.saturating_sub(indent).max(1);
1929 let mut packed = Vec::new();
1930 let mut current = String::new();
1931
1932 for part in receipt_parts(row, locale) {
1933 if UnicodeWidthStr::width(part.as_str()) > available {
1934 if !current.is_empty() {
1935 packed.push(std::mem::take(&mut current));
1936 }
1937 packed.extend(hard_wrap_display(&part, available));
1938 continue;
1939 }
1940 let combined_width = if current.is_empty() {
1941 UnicodeWidthStr::width(part.as_str())
1942 } else {
1943 UnicodeWidthStr::width(current.as_str()) + 3 + UnicodeWidthStr::width(part.as_str())
1944 };
1945 if !current.is_empty() && combined_width > available {
1946 packed.push(std::mem::take(&mut current));
1947 }
1948 if current.is_empty() {
1949 current = part;
1950 } else {
1951 current.push_str(" · ");
1952 current.push_str(&part);
1953 }
1954 }
1955 if !current.is_empty() {
1956 packed.push(current);
1957 }
1958 packed
1959 .into_iter()
1960 .map(|line| format!("{prefix}{line}"))
1961 .collect()
1962 }
1963
1964 fn hard_wrap_display(text: &str, width: usize) -> Vec<String> {
1965 let width = width.max(1);
1966 let mut lines = Vec::new();
1967 let mut line = String::new();
1968 let mut used = 0usize;
1969 for ch in text.chars() {
1970 let ch_width = ch.width().unwrap_or(0);
1971 if !line.is_empty() && used + ch_width > width {
1972 lines.push(std::mem::take(&mut line));
1973 used = 0;
1974 }
1975 line.push(ch);
1976 used += ch_width;
1977 }
1978 if !line.is_empty() {
1979 lines.push(line);
1980 }
1981 if lines.is_empty() {
1982 lines.push(String::new());
1983 }
1984 lines
1985 }
1986
1987 /// Read a terminal usage receipt out of a `task_completed` payload (#4039).
1988 ///
1989 /// Absent counters stay `None`. An object that carries no counter at all still
1990 /// produces a receipt so the row can say `unknown` in every column instead of
1991 /// silently omitting the line.
1992 fn usage_from_json(value: &Value) -> Option<WorkflowRowUsage> {
1993 let object = value.as_object()?;
1994 let number = |key: &str| object.get(key).and_then(Value::as_u64);
1995 Some(WorkflowRowUsage {
1996 input_tokens: number("input_tokens"),
1997 output_tokens: number("output_tokens"),
1998 total_tokens: number("total_tokens"),
1999 tool_calls: number("tool_calls").and_then(|calls| u32::try_from(calls).ok()),
2000 duration_ms: number("duration_ms"),
2001 token_source: opt_str(value, "token_source")
2002 .as_deref()
2003 .and_then(WorkflowTokenSource::parse),
2004 })
2005 }
2006
2007 fn opt_str(value: &Value, key: &str) -> Option<String> {
2008 value
2009 .get(key)
2010 .and_then(Value::as_str)
2011 .map(str::trim)
2012 .filter(|s| !s.is_empty())
2013 .map(str::to_string)
2014 }
2015
2016 /// Honest workflow budget chrome: "used / budget" (or "X left of Y").
2017 /// Never renders confusing "spent/0 left" when remaining is zeroed while
2018 /// spent is large — that read as an inverted kill-budget signal.
2019 #[must_use]
2020 pub(crate) fn format_budget_chrome(
2021 spent: u64,
2022 remaining: Option<u64>,
2023 total: Option<u64>,
2024 ) -> String {
2025 let total = total.or_else(|| remaining.map(|left| spent.saturating_add(left)));
2026 match (spent, remaining, total) {
2027 (spent, _, Some(total)) if total > 0 => {
2028 let left = remaining.unwrap_or_else(|| total.saturating_sub(spent));
2029 format!(" budget {spent} used / {total} ({left} left)")
2030 }
2031 (spent, Some(remaining), None) => {
2032 let total = spent.saturating_add(remaining);
2033 if total == 0 {
2034 String::new()
2035 } else {
2036 format!(" budget {spent} used / {total} ({remaining} left)")
2037 }
2038 }
2039 (spent, None, None) if spent > 0 => format!(" budget {spent} used"),
2040 _ => String::new(),
2041 }
2042 }
2043
2044 fn short_label(text: &str, max: usize) -> String {
2045 let trimmed = text.trim();
2046 if trimmed.width() <= max {
2047 return trimmed.to_string();
2048 }
2049 truncate_line_to_width(trimmed, max)
2050 }
2051
2052 /// Terminal-safe role grammar from the underwater design contract. Labels
2053 /// remain authoritative; the marks make siblings scan as the same work kind.
2054 fn role_mark(profile: Option<&str>) -> &'static str {
2055 let role = profile.unwrap_or_default().trim().to_ascii_lowercase();
2056 if role.contains("operator") {
2057 "@"
2058 } else if role.contains("manager") || role.contains("lead") || role.contains("coordinator") {
2059 "/\\"
2060 } else if role.contains("scout") || role.contains("research") || role.contains("explor") {
2061 "<>"
2062 } else if role.contains("build") || role.contains("implement") || role.contains("engineer") {
2063 "[]"
2064 } else if role.contains("verif") || role.contains("test") || role.contains("qa") {
2065 "()"
2066 } else if role.contains("review") || role.contains("critic") {
2067 "**"
2068 } else {
2069 "--"
2070 }
2071 }
2072
2073 fn row_elapsed_ms(row: &WorkflowPanelRow, now_ms: u64) -> u64 {
2074 row.completed_at_ms
2075 .unwrap_or(now_ms)
2076 .saturating_sub(row.started_at_ms)
2077 }
2078
2079 fn lane_track(row: &WorkflowPanelRow, max_elapsed_ms: u64, width: usize, now_ms: u64) -> String {
2080 let width = width.max(4);
2081 let elapsed = row_elapsed_ms(row, now_ms);
2082 let filled = if max_elapsed_ms == 0 {
2083 1
2084 } else {
2085 ((elapsed as u128 * width as u128) / max_elapsed_ms as u128).clamp(1, width as u128)
2086 as usize
2087 };
2088 let end = match row.status {
2089 WorkflowRowStatus::Succeeded => "OK",
2090 WorkflowRowStatus::Failed | WorkflowRowStatus::SchemaFailed => "!!",
2091 WorkflowRowStatus::Cancelled => "XX",
2092 WorkflowRowStatus::Waiting => "? ",
2093 WorkflowRowStatus::Pending => ". ",
2094 WorkflowRowStatus::Running => "> ",
2095 };
2096 let body_width = width.saturating_sub(2);
2097 let active = filled.saturating_sub(2).min(body_width);
2098 format!(
2099 "{}{}{}",
2100 "=".repeat(active),
2101 end,
2102 "-".repeat(body_width.saturating_sub(active))
2103 )
2104 }
2105
2106 fn now_ms() -> u64 {
2107 SystemTime::now()
2108 .duration_since(UNIX_EPOCH)
2109 .map(|d| d.as_millis() as u64)
2110 .unwrap_or(0)
2111 }
2112
2113 fn summarize_result_value(value: &Value) -> Option<String> {
2114 match value {
2115 Value::Null => None,
2116 Value::String(s) => {
2117 let t = s.trim();
2118 if t.is_empty() {
2119 None
2120 } else {
2121 Some(short_label(t, 200))
2122 }
2123 }
2124 Value::Number(n) => Some(n.to_string()),
2125 Value::Bool(b) => Some(b.to_string()),
2126 Value::Array(items) => Some(format!("{} item(s)", items.len())),
2127 Value::Object(map) => {
2128 if let Some(s) = map
2129 .get("summary")
2130 .or_else(|| map.get("message"))
2131 .or_else(|| map.get("text"))
2132 .and_then(Value::as_str)
2133 {
2134 let t = s.trim();
2135 if !t.is_empty() {
2136 return Some(short_label(t, 200));
2137 }
2138 }
2139 Some(format!("{} field(s)", map.len()))
2140 }
2141 }
2142 }
2143
2144 #[cfg(test)]
2145 mod tests {
2146 use super::*;
2147 use serde_json::json;
2148
2149 /// Exactly the flattened `task_started` payload the Workflow runtime emits
2150 /// (`WorkflowTaskStartedEvent` + `run_id`), so the projection is tested on
2151 /// the production wire shape rather than a hand-shaped struct.
2152 fn task_started_json(task_id: &str, provider: &str, model: &str) -> Value {
2153 json!({
2154 "type": "task_started",
2155 "at_ms": 1_200,
2156 "run_id": "workflow_abc",
2157 "task_id": task_id,
2158 "label": task_id,
2159 "role": "implementer",
2160 "profile": "impl-1",
2161 "model": "flash",
2162 "strength": "same",
2163 "thinking": "high",
2164 "requested_reasoning": "high",
2165 "effective_reasoning": "max",
2166 "resolved_role": "verifier",
2167 "resolved_profile": "verify-1",
2168 "resolved_provider": provider,
2169 "resolved_model": model,
2170 "route_source": "agent_profile.model",
2171 "worktree": false,
2172 "depth": 1,
2173 "workflow_run_id": "workflow_abc",
2174 "workflow_task_label": task_id,
2175 })
2176 }
2177
2178 fn rendered(panel: &WorkflowPanel, width: u16) -> String {
2179 panel
2180 .render_lines(width)
2181 .iter()
2182 .map(|line| {
2183 line.spans
2184 .iter()
2185 .map(|span| span.content.as_ref())
2186 .collect::<String>()
2187 })
2188 .collect::<Vec<_>>()
2189 .join("\n")
2190 }
2191
2192 /// #4039: a live row states the exact role, provider, model, requested →
2193 /// effective reasoning, and route source the runtime reported — and keeps
2194 /// stating them after the session routes somewhere else.
2195 #[test]
2196 fn row_route_receipt_is_exact_and_survives_a_later_model_switch() {
2197 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2198 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2199 let before = rendered(&panel, 200);
2200 assert!(before.contains("role verifier"), "{before}");
2201 assert!(before.contains("deepseek/deepseek-v4-flash"), "{before}");
2202 assert!(before.contains("reasoning high→max"), "{before}");
2203 assert!(before.contains("via agent_profile.model"), "{before}");
2204 // A running row claims no totals at all.
2205 assert!(!before.contains("tokens"), "{before}");
2206
2207 // The session now routes elsewhere: a later task lands on another
2208 // provider/model. The already-launched row must not follow it.
2209 panel.apply_json_event(&task_started_json("t2", "moonshot", "kimi-k3"));
2210 let after = rendered(&panel, 200);
2211 assert!(after.contains("deepseek/deepseek-v4-flash"), "{after}");
2212 assert!(after.contains("moonshot/kimi-k3"), "{after}");
2213 let t1 = panel
2214 .phases
2215 .iter()
2216 .flat_map(|phase| phase.rows.iter())
2217 .find(|row| row.task_id == "t1")
2218 .expect("t1 row");
2219 assert_eq!(t1.route.provider.as_deref(), Some("deepseek"));
2220 assert_eq!(t1.route.model.as_deref(), Some("deepseek-v4-flash"));
2221 assert_eq!(t1.route.requested_reasoning.as_deref(), Some("high"));
2222 assert_eq!(t1.route.effective_reasoning.as_deref(), Some("max"));
2223 assert_eq!(
2224 t1.route.route_source,
2225 Some(WorkflowRouteSource::AgentProfileModel)
2226 );
2227 }
2228
2229 /// #4039: completed rows show provider-reported totals with their
2230 /// provenance, and unreported telemetry stays `unknown` — never `0`.
2231 #[test]
2232 fn completed_row_usage_is_reported_or_unknown_but_never_a_fabricated_zero() {
2233 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2234 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2235 panel.apply_json_event(&task_started_json("t2", "deepseek", "deepseek-v4-flash"));
2236 panel.apply_json_event(&json!({
2237 "type": "task_completed",
2238 "at_ms": 3_200,
2239 "task_id": "t1",
2240 "status": "succeeded",
2241 "usage": {
2242 "input_tokens": 128,
2243 "output_tokens": 32,
2244 "total_tokens": 160,
2245 "tool_calls": 3,
2246 "duration_ms": 2_000,
2247 "token_source": "provider_reported",
2248 },
2249 }));
2250 // A provider that reported nothing: the runtime omits `usage`.
2251 panel.apply_json_event(&json!({
2252 "type": "task_completed",
2253 "at_ms": 3_400,
2254 "task_id": "t2",
2255 "status": "succeeded",
2256 }));
2257
2258 let text = rendered(&panel, 200);
2259 assert!(text.contains("tokens 160 (provider-reported)"), "{text}");
2260 assert!(text.contains("tools 3"), "{text}");
2261 assert!(text.contains("tokens unknown · tools unknown"), "{text}");
2262 let t2 = panel
2263 .phases
2264 .iter()
2265 .flat_map(|phase| phase.rows.iter())
2266 .find(|row| row.task_id == "t2")
2267 .expect("t2 row");
2268 let usage = t2.usage.as_ref().expect("completed rows carry a receipt");
2269 assert_eq!(usage.total_tokens, None);
2270 assert_eq!(usage.tool_calls, None);
2271 }
2272
2273 /// #4039: the projection is built once from the events already applied —
2274 /// the history round trip must carry the receipts rather than force a
2275 /// re-scan of the durable journal.
2276 #[test]
2277 fn row_receipts_survive_the_history_round_trip() {
2278 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2279 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2280 panel.apply_json_event(&json!({
2281 "type": "task_completed",
2282 "at_ms": 3_200,
2283 "task_id": "t1",
2284 "status": "succeeded",
2285 "usage": {
2286 "total_tokens": 160,
2287 "tool_calls": 3,
2288 "duration_ms": 2_000,
2289 "token_source": "provider_reported",
2290 },
2291 }));
2292 let original = panel
2293 .phases
2294 .iter()
2295 .flat_map(|phase| phase.rows.iter())
2296 .map(row_receipt_text)
2297 .collect::<Vec<_>>();
2298
2299 let rehydrated = WorkflowPanel::from_run_json(&panel.to_run_json()).expect("rehydrate");
2300 let round_tripped = rehydrated
2301 .phases
2302 .iter()
2303 .flat_map(|phase| phase.rows.iter())
2304 .map(row_receipt_text)
2305 .collect::<Vec<_>>();
2306 assert_eq!(original, round_tripped);
2307 }
2308
2309 /// #4039 compatibility: journals written before the receipt fields existed
2310 /// still project, and every missing field reads `unknown`.
2311 #[test]
2312 fn legacy_task_events_project_as_unknown_not_as_defaults() {
2313 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2314 panel.apply_json_event(&json!({
2315 "type": "task_started",
2316 "at_ms": 1_200,
2317 "task_id": "t1",
2318 "label": "legacy",
2319 "worktree": false,
2320 }));
2321 panel.apply_json_event(&json!({
2322 "type": "task_completed",
2323 "at_ms": 1_900,
2324 "task_id": "t1",
2325 "status": "succeeded",
2326 }));
2327 let text = rendered(&panel, 200);
2328 let unknown_route = "role unknown · unknown/unknown · reasoning unknown→unknown";
2329 assert!(text.contains(unknown_route), "{text}");
2330 assert!(text.contains("via unknown"), "{text}");
2331 assert!(
2332 text.contains("tokens unknown · tools unknown · duration unknown"),
2333 "{text}"
2334 );
2335 }
2336
2337 #[test]
2338 fn requested_model_and_foreign_provenance_never_become_effective_receipts() {
2339 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2340 panel.apply_json_event(&json!({
2341 "type": "task_started",
2342 "at_ms": 1_200,
2343 "task_id": "legacy",
2344 "model": "requested-only",
2345 "thinking": "high",
2346 "route_source": "foreign.source",
2347 "worktree": false,
2348 }));
2349 let row = panel
2350 .phases
2351 .iter()
2352 .flat_map(|phase| phase.rows.iter())
2353 .find(|row| row.task_id == "legacy")
2354 .expect("legacy row");
2355 assert_eq!(row.model.as_deref(), Some("requested-only"));
2356 assert_eq!(row.route.model, None);
2357 assert_eq!(row.route.route_source, None);
2358 let receipt = row_receipt_text(row);
2359 assert!(receipt.contains("unknown/unknown"), "{receipt}");
2360 assert!(receipt.contains("reasoning high→unknown"), "{receipt}");
2361 assert!(receipt.contains("via unknown"), "{receipt}");
2362 }
2363
2364 #[test]
2365 fn receipt_provenance_is_closed_and_a_reported_zero_stays_zero() {
2366 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2367 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2368 panel.apply_json_event(&json!({
2369 "type": "task_completed",
2370 "at_ms": 1_300,
2371 "task_id": "t1",
2372 "status": "succeeded",
2373 "usage": {
2374 "input_tokens": 0,
2375 "output_tokens": 0,
2376 "total_tokens": 0,
2377 "tool_calls": 0,
2378 "duration_ms": 0,
2379 "token_source": "foreign-source",
2380 },
2381 }));
2382 let text = rendered(&panel, 200);
2383 assert!(text.contains("tokens 0 (unknown)"), "{text}");
2384 assert!(!text.contains("foreign-source"), "{text}");
2385 }
2386
2387 #[test]
2388 fn required_receipt_fields_survive_release_terminal_sizes() {
2389 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2390 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2391 panel.apply_json_event(&json!({
2392 "type": "task_completed",
2393 "at_ms": 3_200,
2394 "task_id": "t1",
2395 "status": "succeeded",
2396 "usage": {
2397 "total_tokens": 160,
2398 "tool_calls": 3,
2399 "duration_ms": 2_000,
2400 "token_source": "provider_reported",
2401 },
2402 }));
2403
2404 for (width, height) in [(40_u16, 12_usize), (60, 16), (80, 24)] {
2405 let lines = panel.render_lines_bounded(width, Some(height));
2406 assert!(
2407 lines.len() <= height,
2408 "{width}x{height}: {} lines",
2409 lines.len()
2410 );
2411 let text = lines
2412 .iter()
2413 .map(|line| {
2414 line.spans
2415 .iter()
2416 .map(|span| span.content.as_ref())
2417 .collect::<String>()
2418 })
2419 .collect::<Vec<_>>()
2420 .join("\n");
2421 for required in [
2422 "role verifier",
2423 "deepseek/deepseek-v4-flash",
2424 "reasoning high→max",
2425 "via agent_profile.model",
2426 "tokens 160 (provider-reported)",
2427 "tools 3",
2428 "duration 2s",
2429 ] {
2430 assert!(
2431 text.contains(required),
2432 "{width}x{height} lost {required}: {text}"
2433 );
2434 }
2435 assert!(lines.iter().all(|line| line.width() <= usize::from(width)));
2436 }
2437 }
2438
2439 #[test]
2440 fn bounded_renderer_never_exceeds_tiny_height_with_all_tails() {
2441 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2442 panel.apply_json_event(&task_started_json("t1", "deepseek", "deepseek-v4-flash"));
2443 panel.apply_json_event(&task_started_json("t2", "deepseek", "deepseek-v4-flash"));
2444 panel.gates.push(WorkflowPanelGateLine {
2445 gate_id: "review".to_string(),
2446 role: Some("verifier".to_string()),
2447 gate: Some("approval".to_string()),
2448 state: "blocked".to_string(),
2449 blocked_role: Some("implementer".to_string()),
2450 blocked_reason: Some("needs review".to_string()),
2451 });
2452 panel.error = Some("terminal failure".to_string());
2453 panel.keyboard_focus = true;
2454
2455 for height in 0..=3 {
2456 let lines = panel.render_lines_bounded(40, Some(height));
2457 assert!(
2458 lines.len() <= height,
2459 "height {height} rendered {} lines: {lines:?}",
2460 lines.len()
2461 );
2462 }
2463
2464 panel.expanded = false;
2465 assert!(panel.render_lines_bounded(40, Some(0)).is_empty());
2466 assert_eq!(panel.render_lines_bounded(40, Some(1)).len(), 1);
2467 }
2468
2469 #[test]
2470 fn receipt_fields_flatten_controls_strip_ansi_and_redact_secrets() {
2471 let mut panel = WorkflowPanel::new("workflow_abc", "audit", 1_000);
2472 panel.apply_json_event(&json!({
2473 "type": "task_started",
2474 "at_ms": 1_200,
2475 "task_id": "hostile",
2476 "resolved_role": "verifier\r\nFORGED ROLE",
2477 "resolved_provider": "\u{1b}[31mdeepseek\u{1b}[0m\tFORGED PROVIDER",
2478 "resolved_model": "model\napi_key=sk-receipt-secret-1234567890",
2479 "requested_reasoning": "high\rFORGED REASONING",
2480 "effective_reasoning": "max\tFORGED EFFECTIVE",
2481 "route_source": "task.model",
2482 "worktree": false,
2483 }));
2484 let row = panel
2485 .phases
2486 .iter()
2487 .flat_map(|phase| phase.rows.iter())
2488 .find(|row| row.task_id == "hostile")
2489 .expect("hostile row");
2490 let receipt = row_receipt_text(row);
2491
2492 assert!(receipt.contains("verifier FORGED ROLE"), "{receipt:?}");
2493 assert!(receipt.contains("deepseek FORGED PROVIDER"), "{receipt:?}");
2494 assert!(receipt.contains("api_key=[redacted]"), "{receipt:?}");
2495 assert!(!receipt.contains("sk-receipt-secret"), "{receipt:?}");
2496 assert!(!receipt.chars().any(char::is_control), "{receipt:?}");
2497 for line in receipt_line_strings(row, Locale::En, 18, 2) {
2498 assert!(!line.chars().any(char::is_control), "{line:?}");
2499 assert!(line.width() <= 18, "{line:?}");
2500 }
2501 }
2502
2503 fn started_panel() -> WorkflowPanel {
2504 let mut panel = WorkflowPanel::new("workflow_abc", "ship v0.8.68", 1_000);
2505 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
2506 title: "Analyze".to_string(),
2507 at_ms: 1_100,
2508 });
2509 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2510 task_id: "t1".to_string(),
2511 label: Some("scout crates".to_string()),
2512 profile: Some("explore".to_string()),
2513 model: Some("flash".to_string()),
2514 strength: Some("low".to_string()),
2515 resolved_model: Some("deepseek-v4-flash".to_string()),
2516 worktree: true,
2517 workspace: Some(PathBuf::from("/tmp/wt-1")),
2518 route: WorkflowRowRoute::default(),
2519 at_ms: 1_200,
2520 });
2521 panel
2522 }
2523
2524 #[test]
2525 fn cancel_hint_span_matches_rendered_header_and_truncation() {
2526 let panel = started_panel();
2527 let header = panel.header_text(120);
2528 let (start, end) = panel.cancel_hint_span(120).expect("running cancel hint");
2529 let marker = header.find("[c] cancel").expect("rendered cancel hint");
2530 assert_eq!(UnicodeWidthStr::width(&header[..marker]), start as usize);
2531 assert_eq!(end - start, UnicodeWidthStr::width("[c] cancel") as u16);
2532
2533 assert!(panel.cancel_hint_span(8).is_none());
2534 }
2535
2536 /// #4208: every decorative glyph the run map emits — expand marks, role
2537 /// marks, lane glyphs, gates, status marks across running, waiting,
2538 /// failed, cancelled, and completed members — must narrow to an
2539 /// ASCII-safe alternative.
2540 #[test]
2541 fn workflow_panel_glyphs_all_have_ascii_alternatives() {
2542 let mut panel = started_panel();
2543 for (task_id, status) in [
2544 ("t1", WorkflowRowStatus::Succeeded),
2545 ("t2", WorkflowRowStatus::Failed),
2546 ("t3", WorkflowRowStatus::Cancelled),
2547 ("t4", WorkflowRowStatus::Waiting),
2548 ] {
2549 if task_id != "t1" {
2550 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2551 task_id: task_id.to_string(),
2552 label: Some(format!("lane {task_id}")),
2553 profile: Some("implementer".to_string()),
2554 model: None,
2555 strength: None,
2556 resolved_model: None,
2557 worktree: false,
2558 workspace: None,
2559 route: WorkflowRowRoute::default(),
2560 at_ms: 1_400,
2561 });
2562 }
2563 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2564 task_id: task_id.to_string(),
2565 status,
2566 usage: None,
2567 at_ms: 2_500,
2568 });
2569 }
2570 panel.apply_event(WorkflowPanelEvent::GateUpdated {
2571 gate_id: "gate-1".to_string(),
2572 role: Some("verifier".to_string()),
2573 gate: Some("tests-green".to_string()),
2574 state: "blocked".to_string(),
2575 blocked_role: Some("implementer".to_string()),
2576 blocked_reason: Some("waiting on tests".to_string()),
2577 at_ms: 2_600,
2578 });
2579
2580 let mut glyphs: Vec<char> = panel.header_text(120).chars().collect();
2581 for line in panel.render_lines(100) {
2582 for span in &line.spans {
2583 glyphs.extend(span.content.chars());
2584 }
2585 }
2586 for ch in glyphs.into_iter().filter(|ch| !ch.is_ascii()) {
2587 let mut cell = ratatui::buffer::Cell::default();
2588 cell.set_symbol(&ch.to_string());
2589 crate::tui::color_compat::adapt_cell_symbol_for_ascii(&mut cell);
2590 assert!(
2591 cell.symbol().is_ascii(),
2592 "workflow glyph {ch:?} (U+{:04X}) lacks an ASCII-safe alternative",
2593 ch as u32
2594 );
2595 }
2596 }
2597
2598 #[test]
2599 fn budget_chrome_uses_honest_used_of_total_labels() {
2600 assert_eq!(
2601 format_budget_chrome(839_866, Some(0), None),
2602 " budget 839866 used / 839866 (0 left)"
2603 );
2604 assert_eq!(
2605 format_budget_chrome(1_200, Some(8_800), Some(10_000)),
2606 " budget 1200 used / 10000 (8800 left)"
2607 );
2608 assert_eq!(
2609 format_budget_chrome(500, None, Some(2_000)),
2610 " budget 500 used / 2000 (1500 left)"
2611 );
2612 assert_eq!(format_budget_chrome(42, None, None), " budget 42 used");
2613 assert_eq!(format_budget_chrome(0, None, None), "");
2614 }
2615
2616 #[test]
2617 fn header_shows_lifecycle_counts_budget_and_expand_glyph() {
2618 let mut panel = started_panel();
2619 panel.apply_event(WorkflowPanelEvent::BudgetUpdated {
2620 total: Some(10_000),
2621 spent: 1_200,
2622 remaining: Some(8_800),
2623 at_ms: 1_300,
2624 });
2625 let header = panel.header_text(120);
2626 assert!(header.contains('▼'), "running auto-expands: {header}");
2627 assert!(header.contains("running"), "{header}");
2628 assert!(header.contains("ship v0.8.68"), "{header}");
2629 assert!(header.contains("0/1"), "{header}");
2630 assert!(header.contains("1 phases"), "{header}");
2631 assert!(header.contains("0 fail"), "{header}");
2632 assert!(header.contains("0 cancel"), "{header}");
2633 assert!(
2634 header.contains("budget 1200 used / 10000")
2635 || header.contains("budget 1.2k used / 10k")
2636 || header.contains("budget 1200 used"),
2637 "{header}"
2638 );
2639 }
2640
2641 #[test]
2642 fn body_shows_phases_and_selected_phase_rows() {
2643 let mut panel = started_panel();
2644 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
2645 title: "Verify".to_string(),
2646 at_ms: 2_000,
2647 });
2648 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2649 task_id: "t2".to_string(),
2650 label: Some("run tests".to_string()),
2651 profile: Some("implementer".to_string()),
2652 model: Some("pro".to_string()),
2653 strength: None,
2654 resolved_model: None,
2655 worktree: false,
2656 workspace: None,
2657 route: WorkflowRowRoute::default(),
2658 at_ms: 2_100,
2659 });
2660 // selected phase is Verify (latest)
2661 let lines = panel.render_lines(100);
2662 let text: Vec<String> = lines
2663 .iter()
2664 .map(|l| {
2665 l.spans
2666 .iter()
2667 .map(|s| s.content.as_ref())
2668 .collect::<String>()
2669 })
2670 .collect();
2671 let joined = text.join("\n");
2672 assert!(joined.contains("Analyze"), "{joined}");
2673 assert!(joined.contains("Verify"), "{joined}");
2674 assert!(joined.contains("run tests"), "{joined}");
2675 assert!(joined.contains("implementer"), "{joined}");
2676 assert!(joined.contains("pro"), "{joined}");
2677 assert!(joined.contains("main"), "{joined}"); // no worktree
2678 // Analyze scout is not in selected phase body
2679 assert!(!joined.contains("scout crates"), "{joined}");
2680 }
2681
2682 #[test]
2683 fn rows_show_status_label_role_model_worktree_elapsed_schema() {
2684 let mut panel = started_panel();
2685 panel.apply_event(WorkflowPanelEvent::TaskSchemaValidationFailed {
2686 task_id: "t1".to_string(),
2687 message: "missing field foo".to_string(),
2688 at_ms: 1_500,
2689 });
2690 let lines = panel.render_lines(120);
2691 let joined: String = lines
2692 .iter()
2693 .map(|l| {
2694 l.spans
2695 .iter()
2696 .map(|s| s.content.as_ref())
2697 .collect::<String>()
2698 })
2699 .collect::<Vec<_>>()
2700 .join("\n");
2701 assert!(joined.contains("schema"), "{joined}");
2702 assert!(joined.contains("scout crates"), "{joined}");
2703 assert!(joined.contains("explore"), "{joined}");
2704 assert!(joined.contains("deepseek-v4-flash"), "{joined}");
2705 assert!(joined.contains("wt"), "{joined}");
2706 assert!(joined.contains("missing field"), "{joined}");
2707 }
2708
2709 #[test]
2710 fn auto_expands_while_running_and_preserves_completed_until_next() {
2711 let mut panel = started_panel();
2712 assert!(panel.expanded);
2713 panel.expanded = false;
2714 // Task start while running forces re-expand
2715 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2716 task_id: "t3".to_string(),
2717 label: Some("more".to_string()),
2718 profile: None,
2719 model: None,
2720 strength: None,
2721 resolved_model: None,
2722 worktree: false,
2723 workspace: None,
2724 route: WorkflowRowRoute::default(),
2725 at_ms: 1_400,
2726 });
2727 assert!(panel.expanded);
2728
2729 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2730 task_id: "t1".to_string(),
2731 status: WorkflowRowStatus::Succeeded,
2732 usage: None,
2733 at_ms: 2_000,
2734 });
2735 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2736 task_id: "t3".to_string(),
2737 status: WorkflowRowStatus::Succeeded,
2738 usage: None,
2739 at_ms: 2_100,
2740 });
2741 panel.apply_event(WorkflowPanelEvent::RunCompleted {
2742 status: WorkflowPanelLifecycle::Succeeded,
2743 error: None,
2744 at_ms: 2_200,
2745 });
2746 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
2747 // Still visible (preserved)
2748 assert_eq!(panel.run_id, "workflow_abc");
2749 let header = panel.header_text(80);
2750 assert!(header.contains("success"), "{header}");
2751
2752 // Next workflow replaces
2753 panel.apply_event(WorkflowPanelEvent::RunStarted {
2754 run_id: "workflow_next".to_string(),
2755 workflow_id: None,
2756 workflow_goal: Some("next run".to_string()),
2757 source_path: None,
2758 token_budget: None,
2759 at_ms: 3_000,
2760 });
2761 assert_eq!(panel.run_id, "workflow_next");
2762 assert_eq!(panel.label, "next run");
2763 assert!(panel.phases.is_empty());
2764 assert!(panel.expanded);
2765 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Running);
2766 }
2767
2768 #[test]
2769 fn interrupt_finalizes_running_children_as_cancelled() {
2770 let mut panel = started_panel();
2771 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2772 task_id: "t2".to_string(),
2773 label: Some("second".to_string()),
2774 profile: None,
2775 model: None,
2776 strength: None,
2777 resolved_model: None,
2778 worktree: false,
2779 workspace: None,
2780 route: WorkflowRowRoute::default(),
2781 at_ms: 1_300,
2782 });
2783 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2784 task_id: "t1".to_string(),
2785 status: WorkflowRowStatus::Succeeded,
2786 usage: None,
2787 at_ms: 1_400,
2788 });
2789 panel.finalize_interrupt();
2790 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
2791 let t1 = panel
2792 .phases
2793 .iter()
2794 .flat_map(|p| p.rows.iter())
2795 .find(|r| r.task_id == "t1")
2796 .expect("t1");
2797 let t2 = panel
2798 .phases
2799 .iter()
2800 .flat_map(|p| p.rows.iter())
2801 .find(|r| r.task_id == "t2")
2802 .expect("t2");
2803 assert_eq!(t1.status, WorkflowRowStatus::Succeeded);
2804 assert_eq!(t2.status, WorkflowRowStatus::Cancelled);
2805 assert!(
2806 t2.usage.is_some(),
2807 "cancelled row must retain an unknown usage receipt"
2808 );
2809 let cancelled_receipt = row_receipt_text(t2);
2810 assert!(
2811 cancelled_receipt.contains("tokens unknown"),
2812 "{cancelled_receipt}"
2813 );
2814 assert!(
2815 cancelled_receipt.contains("tools unknown"),
2816 "{cancelled_receipt}"
2817 );
2818 assert!(
2819 cancelled_receipt.contains("duration unknown"),
2820 "{cancelled_receipt}"
2821 );
2822 let (failed, cancelled) = panel.failure_cancel_counts();
2823 assert_eq!(failed, 0);
2824 assert_eq!(cancelled, 1);
2825 }
2826
2827 #[test]
2828 fn panel_toggle_is_independent_of_text_input_routing() {
2829 let mut panel = started_panel();
2830 assert!(panel.expanded);
2831 assert!(panel.toggle_expanded());
2832 assert!(!panel.expanded);
2833 assert!(panel.toggle_expanded());
2834 assert!(panel.expanded);
2835 }
2836
2837 #[test]
2838 fn json_events_round_trip_without_log_flood() {
2839 let mut panel = WorkflowPanel::new("w1", "goal", 0);
2840 let events = vec![
2841 json!({
2842 "type": "run_started",
2843 "at_ms": 10,
2844 "run_id": "w1",
2845 "workflow_goal": "demo",
2846 "token_budget": 5000
2847 }),
2848 json!({"type": "log", "at_ms": 11, "message": "should not appear"}),
2849 json!({"type": "phase_started", "at_ms": 12, "title": "Analyze"}),
2850 json!({
2851 "type": "task_started",
2852 "at_ms": 13,
2853 "task_id": "a",
2854 "label": "scout",
2855 "profile": "explore",
2856 "resolved_model": "flash",
2857 "worktree": true
2858 }),
2859 json!({
2860 "type": "budget_updated",
2861 "at_ms": 14,
2862 "total": 5000,
2863 "spent": 100,
2864 "remaining": 4900
2865 }),
2866 json!({
2867 "type": "task_completed",
2868 "at_ms": 15,
2869 "task_id": "a",
2870 "status": "succeeded"
2871 }),
2872 json!({
2873 "type": "gate_updated",
2874 "at_ms": 15,
2875 "gate_id": "reviewer-diff",
2876 "role": "reviewer",
2877 "gate": "review",
2878 "state": "blocked",
2879 "blocked_role": "verifier",
2880 "blocked_reason": "review found regression"
2881 }),
2882 json!({
2883 "type": "run_completed",
2884 "at_ms": 16,
2885 "status": "completed"
2886 }),
2887 ];
2888 panel.apply_json_events(&events);
2889 assert_eq!(panel.label, "demo");
2890 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
2891 assert_eq!(panel.budget_spent, 100);
2892 assert_eq!(panel.budget_remaining, Some(4900));
2893 let joined: String = panel
2894 .render_lines(100)
2895 .iter()
2896 .map(|l| {
2897 l.spans
2898 .iter()
2899 .map(|s| s.content.as_ref())
2900 .collect::<String>()
2901 })
2902 .collect::<Vec<_>>()
2903 .join("\n");
2904 assert!(!joined.contains("should not appear"), "{joined}");
2905 assert!(joined.contains("scout"), "{joined}");
2906 assert!(joined.contains("done"), "{joined}");
2907 assert!(joined.contains("reviewer-diff"), "{joined}");
2908 assert!(joined.contains("review found regression"), "{joined}");
2909 }
2910
2911 #[test]
2912 fn desired_height_is_zero_width_safe_and_collapsed_is_one() {
2913 let mut panel = started_panel();
2914 assert_eq!(panel.desired_height(0), 0);
2915 panel.expanded = false;
2916 assert_eq!(panel.desired_height(80), 1);
2917 panel.expanded = true;
2918 assert!(panel.desired_height(80) >= 3);
2919 }
2920
2921 #[test]
2922 fn failure_and_cancel_counts_roll_up_in_header() {
2923 let mut panel = started_panel();
2924 panel.apply_event(WorkflowPanelEvent::TaskStarted {
2925 task_id: "t2".to_string(),
2926 label: Some("b".to_string()),
2927 profile: None,
2928 model: None,
2929 strength: None,
2930 resolved_model: None,
2931 worktree: false,
2932 workspace: None,
2933 route: WorkflowRowRoute::default(),
2934 at_ms: 1_300,
2935 });
2936 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2937 task_id: "t1".to_string(),
2938 status: WorkflowRowStatus::Failed,
2939 usage: None,
2940 at_ms: 1_400,
2941 });
2942 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2943 task_id: "t2".to_string(),
2944 status: WorkflowRowStatus::Cancelled,
2945 usage: None,
2946 at_ms: 1_500,
2947 });
2948 let (failed, cancelled) = panel.failure_cancel_counts();
2949 assert_eq!(failed, 1);
2950 assert_eq!(cancelled, 1);
2951 let header = panel.header_text(100);
2952 assert!(header.contains("1 fail"), "{header}");
2953 assert!(header.contains("1 cancel"), "{header}");
2954 assert!(header.contains("2/2"), "{header}");
2955 }
2956
2957 #[test]
2958 fn task_started_json_prefers_workflow_task_label_over_generic_label() {
2959 // #4119: panel rows use typed workflow metadata, not prompt text.
2960 let event = WorkflowPanelEvent::from_json_value(&json!({
2961 "type": "task_started",
2962 "task_id": "child-1",
2963 "label": "fallback-label",
2964 "workflow_task_label": "typed-label",
2965 "workflow_run_id": "run-xyz",
2966 "workflow_phase_id": "dispatch",
2967 "workflow_child_index": 2,
2968 "at_ms": 42,
2969 }))
2970 .expect("task_started parses");
2971 match event {
2972 WorkflowPanelEvent::TaskStarted { label, .. } => {
2973 assert_eq!(label.as_deref(), Some("typed-label"));
2974 }
2975 other => panic!("expected TaskStarted, got {other:?}"),
2976 }
2977
2978 let mut panel = WorkflowPanel::new("run-xyz", "goal", 1);
2979 panel.apply_json_event(&json!({
2980 "type": "task_started",
2981 "task_id": "child-1",
2982 "label": "fallback-label",
2983 "workflow_task_label": "typed-label",
2984 "at_ms": 42,
2985 }));
2986 let row = panel
2987 .phases
2988 .iter()
2989 .flat_map(|phase| phase.rows.iter())
2990 .find(|row| row.task_id == "child-1")
2991 .expect("row recorded");
2992 assert_eq!(row.label, "typed-label");
2993 }
2994
2995 #[test]
2996 fn compact_history_card_summarizes_lifecycle_children_phases_failures_elapsed() {
2997 let mut panel = started_panel();
2998 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
2999 task_id: "t1".to_string(),
3000 status: WorkflowRowStatus::Failed,
3001 usage: None,
3002 at_ms: 2_000,
3003 });
3004 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3005 status: WorkflowPanelLifecycle::Failed,
3006 error: Some("scout failed".to_string()),
3007 at_ms: 2_100,
3008 });
3009 let lines = panel.render_history_card(120, false, &WorkflowHistoryExtras::default());
3010 assert_eq!(lines.len(), 1, "compact is a single summary line");
3011 let joined: String = lines
3012 .iter()
3013 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3014 .collect();
3015 assert!(joined.contains('▶'), "collapsed glyph: {joined}");
3016 assert!(
3017 joined.contains("failed") || joined.contains("fail"),
3018 "{joined}"
3019 );
3020 assert!(joined.contains("1 child"), "{joined}");
3021 assert!(joined.contains("1 phase"), "{joined}");
3022 assert!(joined.contains("1 fail"), "{joined}");
3023 // elapsed is present (0s or more depending on timestamps)
3024 assert!(
3025 joined.contains('s') || joined.contains('m'),
3026 "elapsed time expected: {joined}"
3027 );
3028 // Goal is reserved for the expanded body so compact stays under the
3029 // tool-header summary budget.
3030 assert!(
3031 !joined.contains("ship v0.8.68"),
3032 "compact must not spend budget on free-text goal: {joined}"
3033 );
3034 }
3035
3036 #[test]
3037 fn expanded_history_card_shows_phase_child_result_links_and_failures() {
3038 let mut panel = started_panel();
3039 panel.source_path = Some(PathBuf::from("workflows/demo.workflow.js"));
3040 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3041 task_id: "t1".to_string(),
3042 status: WorkflowRowStatus::Failed,
3043 usage: None,
3044 at_ms: 2_000,
3045 });
3046 if let Some(row) = panel.find_row_mut("t1") {
3047 row.error = Some("timeout waiting for model".to_string());
3048 }
3049 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3050 status: WorkflowPanelLifecycle::Failed,
3051 error: Some("phase Analyze failed".to_string()),
3052 at_ms: 2_100,
3053 });
3054 let extras = WorkflowHistoryExtras {
3055 result_summary: Some("no ship blockers found".to_string()),
3056 source_path: None,
3057 spillover_path: Some(PathBuf::from("/tmp/workflow-out.json")),
3058 verification_summary: None,
3059 };
3060 let lines = panel.render_history_card(120, true, &extras);
3061 let joined: String = lines
3062 .iter()
3063 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3064 .collect::<Vec<_>>()
3065 .join("\n");
3066 assert!(joined.contains('▼'), "expanded glyph: {joined}");
3067 assert!(joined.contains("goal:"), "{joined}");
3068 assert!(joined.contains("ship v0.8.68"), "{joined}");
3069 assert!(joined.contains("phases:"), "{joined}");
3070 assert!(joined.contains("Analyze"), "{joined}");
3071 assert!(joined.contains("children:"), "{joined}");
3072 assert!(joined.contains("scout crates"), "{joined}");
3073 assert!(joined.contains("result:"), "{joined}");
3074 assert!(joined.contains("no ship blockers"), "{joined}");
3075 assert!(
3076 joined.contains("source:") || joined.contains("demo.workflow"),
3077 "{joined}"
3078 );
3079 assert!(joined.contains("artifact:"), "{joined}");
3080 assert!(joined.contains("error:"), "{joined}");
3081 assert!(joined.contains("phase Analyze failed"), "{joined}");
3082 assert!(
3083 joined.contains("fail") || joined.contains("timeout"),
3084 "{joined}"
3085 );
3086 }
3087
3088 #[test]
3089 fn direct_subagent_card_reuses_history_renderer() {
3090 let panel = WorkflowPanel::from_direct_subagent(
3091 "agent_abc",
3092 "explore",
3093 WorkflowPanelLifecycle::Succeeded,
3094 1_000,
3095 Some(4_500),
3096 Some("found 3 call sites".to_string()),
3097 None,
3098 );
3099 let compact = panel.render_history_card(100, false, &WorkflowHistoryExtras::default());
3100 let joined: String = compact
3101 .iter()
3102 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3103 .collect();
3104 assert!(
3105 joined.contains("success") || joined.contains("explore"),
3106 "{joined}"
3107 );
3108 assert!(
3109 joined.contains("1 child") || joined.contains("1 children"),
3110 "{joined}"
3111 );
3112 assert!(joined.contains("3s") || joined.contains("s"), "{joined}");
3113
3114 let expanded = panel.render_history_card(
3115 100,
3116 true,
3117 &WorkflowHistoryExtras {
3118 result_summary: Some("found 3 call sites".to_string()),
3119 ..WorkflowHistoryExtras::default()
3120 },
3121 );
3122 let joined: String = expanded
3123 .iter()
3124 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3125 .collect::<Vec<_>>()
3126 .join("\n");
3127 assert!(joined.contains("children:"), "{joined}");
3128 assert!(joined.contains("result:"), "{joined}");
3129 assert!(joined.contains("found 3 call sites"), "{joined}");
3130 assert!(!joined.contains("reasoning unknown"), "{joined}");
3131 assert!(!joined.contains("via unknown"), "{joined}");
3132 assert!(!joined.contains("tokens unknown"), "{joined}");
3133 assert!(
3134 joined.contains(crate::tui::shell_key_routing::tool_details_chord().as_ref()),
3135 "history details hint must use the platform chord: {joined}"
3136 );
3137 assert!(!joined.contains("details (v)"), "{joined}");
3138 }
3139
3140 #[test]
3141 fn from_run_json_round_trips_events_into_history_card() {
3142 let value = json!({
3143 "run_id": "workflow_demo",
3144 "status": "completed",
3145 "workflow_goal": "ship it",
3146 "started_at_ms": 1000,
3147 "completed_at_ms": 5000,
3148 "events": [
3149 {
3150 "type": "run_started",
3151 "at_ms": 1000,
3152 "run_id": "workflow_demo",
3153 "workflow_goal": "ship it"
3154 },
3155 {"type": "phase_started", "at_ms": 1100, "title": "Build"},
3156 {
3157 "type": "task_started",
3158 "at_ms": 1200,
3159 "task_id": "t1",
3160 "label": "compile",
3161 "profile": "implementer"
3162 },
3163 {
3164 "type": "task_completed",
3165 "at_ms": 4000,
3166 "task_id": "t1",
3167 "status": "succeeded"
3168 },
3169 {"type": "run_completed", "at_ms": 5000, "status": "completed"}
3170 ]
3171 });
3172 let panel = WorkflowPanel::from_run_json(&value).expect("hydrate");
3173 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Succeeded);
3174 let compact = panel.compact_summary_text(120);
3175 assert!(compact.contains("1 child"), "{compact}");
3176 assert!(compact.contains("success"), "{compact}");
3177 let expanded = panel.history_expanded_lines(120, &WorkflowHistoryExtras::default());
3178 let joined: String = expanded
3179 .iter()
3180 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3181 .collect::<Vec<_>>()
3182 .join("\n");
3183 assert!(joined.contains("goal:"), "{joined}");
3184 assert!(joined.contains("ship it"), "{joined}");
3185 assert!(joined.contains("Build"), "{joined}");
3186 assert!(joined.contains("compile"), "{joined}");
3187 }
3188
3189 // ── #4131 dogfood scenario projections ──────────────────────────────────
3190
3191 /// WF-A1: read-only repo audit — scout phase on main workspace, labeled
3192 /// children, no worktree marker, synthesizer phase present.
3193 #[test]
3194 fn dogfood_read_only_repo_audit_panel() {
3195 let mut panel = WorkflowPanel::new("wf_a1", "read-only repo audit", 1_000);
3196 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3197 title: "Scout".to_string(),
3198 at_ms: 1_100,
3199 });
3200 for (id, label, role) in [
3201 ("t1", "map crates", "explore"),
3202 ("t2", "scan unsafe", "explore"),
3203 ("t3", "scan unwrap", "explore"),
3204 ] {
3205 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3206 task_id: id.to_string(),
3207 label: Some(label.to_string()),
3208 profile: Some(role.to_string()),
3209 model: Some("flash".to_string()),
3210 strength: Some("low".to_string()),
3211 resolved_model: Some("deepseek-v4-flash".to_string()),
3212 worktree: false,
3213 workspace: None,
3214 route: WorkflowRowRoute::default(),
3215 at_ms: 1_200,
3216 });
3217 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3218 task_id: id.to_string(),
3219 status: WorkflowRowStatus::Succeeded,
3220 usage: None,
3221 at_ms: 1_500,
3222 });
3223 }
3224 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3225 title: "Synthesize".to_string(),
3226 at_ms: 1_600,
3227 });
3228 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3229 task_id: "t4".to_string(),
3230 label: Some("audit summary".to_string()),
3231 profile: Some("general".to_string()),
3232 model: None,
3233 strength: None,
3234 resolved_model: None,
3235 worktree: false,
3236 workspace: None,
3237 route: WorkflowRowRoute::default(),
3238 at_ms: 1_700,
3239 });
3240 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3241 task_id: "t4".to_string(),
3242 status: WorkflowRowStatus::Succeeded,
3243 usage: None,
3244 at_ms: 2_000,
3245 });
3246 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3247 status: WorkflowPanelLifecycle::Succeeded,
3248 error: None,
3249 at_ms: 2_100,
3250 });
3251
3252 let header = panel.header_text(140);
3253 assert!(
3254 header.contains("success") || header.contains("completed"),
3255 "{header}"
3256 );
3257 assert!(header.contains("0 fail"), "{header}");
3258 assert!(
3259 header.contains("4/") || header.contains("4 child") || header.contains("0/"),
3260 "{header}"
3261 );
3262
3263 // Selected phase is Synthesize; scout labels live in earlier phases.
3264 panel.selected_phase = 0;
3265 let scout_body = panel.render_lines(120);
3266 let scout_joined: String = scout_body
3267 .iter()
3268 .map(|l| {
3269 l.spans
3270 .iter()
3271 .map(|s| s.content.as_ref())
3272 .collect::<String>()
3273 })
3274 .collect::<Vec<_>>()
3275 .join("\n");
3276 assert!(scout_joined.contains("map crates"), "{scout_joined}");
3277 assert!(scout_joined.contains("main"), "{scout_joined}");
3278 assert!(
3279 !scout_joined.contains(" wt "),
3280 "read-only scouts stay on main: {scout_joined}"
3281 );
3282
3283 let card = panel.render_history_card(
3284 120,
3285 true,
3286 &WorkflowHistoryExtras {
3287 result_summary: Some("no critical issues".to_string()),
3288 ..WorkflowHistoryExtras::default()
3289 },
3290 );
3291 let card_text: String = card
3292 .iter()
3293 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3294 .collect::<Vec<_>>()
3295 .join("\n");
3296 assert!(
3297 card_text.contains("Scout") || card_text.contains("Synthesize"),
3298 "{card_text}"
3299 );
3300 assert!(card_text.contains("no critical issues"), "{card_text}");
3301 assert!(
3302 !card_text.to_ascii_lowercase().contains("unknown child"),
3303 "{card_text}"
3304 );
3305 }
3306
3307 /// WF-A2: staged bugfix — implementer worktree + verifier on main.
3308 #[test]
3309 fn dogfood_staged_worktree_implementer_verifier() {
3310 let mut panel = WorkflowPanel::new("wf_a2", "staged docs fix", 1_000);
3311 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3312 title: "Implement".to_string(),
3313 at_ms: 1_100,
3314 });
3315 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3316 task_id: "impl".to_string(),
3317 label: Some("implementer".to_string()),
3318 profile: Some("implementer".to_string()),
3319 model: Some("pro".to_string()),
3320 strength: None,
3321 resolved_model: Some("deepseek-v4-pro".to_string()),
3322 worktree: true,
3323 workspace: Some(PathBuf::from("/tmp/wt-impl")),
3324 route: WorkflowRowRoute::default(),
3325 at_ms: 1_200,
3326 });
3327 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3328 task_id: "impl".to_string(),
3329 status: WorkflowRowStatus::Succeeded,
3330 usage: None,
3331 at_ms: 2_000,
3332 });
3333 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3334 title: "Verify".to_string(),
3335 at_ms: 2_100,
3336 });
3337 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3338 task_id: "ver".to_string(),
3339 label: Some("verifier".to_string()),
3340 profile: Some("verifier".to_string()),
3341 model: Some("flash".to_string()),
3342 strength: None,
3343 resolved_model: None,
3344 worktree: false,
3345 workspace: None,
3346 route: WorkflowRowRoute::default(),
3347 at_ms: 2_200,
3348 });
3349 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3350 task_id: "ver".to_string(),
3351 status: WorkflowRowStatus::Succeeded,
3352 usage: None,
3353 at_ms: 3_000,
3354 });
3355 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3356 status: WorkflowPanelLifecycle::Succeeded,
3357 error: None,
3358 at_ms: 3_100,
3359 });
3360
3361 assert_eq!(panel.phases.len(), 2);
3362 assert_eq!(panel.phases[0].title, "Implement");
3363 assert_eq!(panel.phases[1].title, "Verify");
3364
3365 panel.selected_phase = 0;
3366 let implement_body = panel.render_lines(140);
3367 let impl_text: String = implement_body
3368 .iter()
3369 .map(|l| {
3370 l.spans
3371 .iter()
3372 .map(|s| s.content.as_ref())
3373 .collect::<String>()
3374 })
3375 .collect::<Vec<_>>()
3376 .join("\n");
3377 assert!(impl_text.contains("implementer"), "{impl_text}");
3378 assert!(
3379 impl_text.contains("wt") || impl_text.contains("worktree"),
3380 "implementer should show worktree marker: {impl_text}"
3381 );
3382
3383 panel.selected_phase = 1;
3384 let verify_body = panel.render_lines(140);
3385 let ver_text: String = verify_body
3386 .iter()
3387 .map(|l| {
3388 l.spans
3389 .iter()
3390 .map(|s| s.content.as_ref())
3391 .collect::<String>()
3392 })
3393 .collect::<Vec<_>>()
3394 .join("\n");
3395 assert!(ver_text.contains("verifier"), "{ver_text}");
3396 assert!(ver_text.contains("main"), "{ver_text}");
3397 }
3398
3399 /// WF-A3: partial failure + synthesis — fail count visible, summary card.
3400 #[test]
3401 fn dogfood_partial_failure_and_synthesis() {
3402 let mut panel = WorkflowPanel::new("wf_a3", "partial failure synthesis", 1_000);
3403 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3404 title: "Parallel scouts".to_string(),
3405 at_ms: 1_100,
3406 });
3407 for (id, label, status) in [
3408 ("a", "scout-a", WorkflowRowStatus::Succeeded),
3409 ("b", "scout-b-fail", WorkflowRowStatus::Failed),
3410 ("c", "scout-c", WorkflowRowStatus::Succeeded),
3411 ] {
3412 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3413 task_id: id.to_string(),
3414 label: Some(label.to_string()),
3415 profile: Some("explore".to_string()),
3416 model: None,
3417 strength: None,
3418 resolved_model: None,
3419 worktree: false,
3420 workspace: None,
3421 route: WorkflowRowRoute::default(),
3422 at_ms: 1_200,
3423 });
3424 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3425 task_id: id.to_string(),
3426 status,
3427 usage: None,
3428 at_ms: 1_500,
3429 });
3430 }
3431 if let Some(row) = panel.find_row_mut("b") {
3432 row.error = Some("scout refused to produce summary".to_string());
3433 }
3434 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3435 title: "Synthesize".to_string(),
3436 at_ms: 1_600,
3437 });
3438 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3439 task_id: "syn".to_string(),
3440 label: Some("synthesizer".to_string()),
3441 profile: Some("general".to_string()),
3442 model: None,
3443 strength: None,
3444 resolved_model: None,
3445 worktree: false,
3446 workspace: None,
3447 route: WorkflowRowRoute::default(),
3448 at_ms: 1_700,
3449 });
3450 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3451 task_id: "syn".to_string(),
3452 status: WorkflowRowStatus::Succeeded,
3453 usage: None,
3454 at_ms: 2_000,
3455 });
3456 // Partial success at run level: completed with surviving synthesis.
3457 panel.apply_event(WorkflowPanelEvent::RunCompleted {
3458 status: WorkflowPanelLifecycle::Succeeded,
3459 error: None,
3460 at_ms: 2_100,
3461 });
3462
3463 let (failed, cancelled) = panel.failure_cancel_counts();
3464 assert_eq!(failed, 1, "exactly one parallel slot failed");
3465 assert_eq!(cancelled, 0);
3466 let header = panel.header_text(140);
3467 assert!(header.contains("1 fail"), "{header}");
3468
3469 let card = panel.render_history_card(
3470 140,
3471 true,
3472 &WorkflowHistoryExtras {
3473 result_summary: Some("2/3 scouts ok; scout-b failed".to_string()),
3474 ..WorkflowHistoryExtras::default()
3475 },
3476 );
3477 let joined: String = card
3478 .iter()
3479 .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
3480 .collect::<Vec<_>>()
3481 .join("\n");
3482 assert!(
3483 joined.contains("scout-b-fail") || joined.contains("fail"),
3484 "{joined}"
3485 );
3486 assert!(joined.contains("2/3 scouts ok"), "{joined}");
3487 }
3488
3489 /// WF-A4: cancellation mid-run — running children cancelled, done preserved.
3490 #[test]
3491 fn dogfood_cancellation_mid_run() {
3492 let mut panel = WorkflowPanel::new("wf_a4", "cancel mid-run", 1_000);
3493 panel.apply_event(WorkflowPanelEvent::PhaseStarted {
3494 title: "Long work".to_string(),
3495 at_ms: 1_100,
3496 });
3497 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3498 task_id: "slow-1".to_string(),
3499 label: Some("slow-1".to_string()),
3500 profile: Some("explore".to_string()),
3501 model: None,
3502 strength: None,
3503 resolved_model: None,
3504 worktree: false,
3505 workspace: None,
3506 route: WorkflowRowRoute::default(),
3507 at_ms: 1_200,
3508 });
3509 panel.apply_event(WorkflowPanelEvent::TaskStarted {
3510 task_id: "slow-2".to_string(),
3511 label: Some("slow-2".to_string()),
3512 profile: Some("explore".to_string()),
3513 model: None,
3514 strength: None,
3515 resolved_model: None,
3516 worktree: false,
3517 workspace: None,
3518 route: WorkflowRowRoute::default(),
3519 at_ms: 1_210,
3520 });
3521 panel.apply_event(WorkflowPanelEvent::TaskCompleted {
3522 task_id: "slow-1".to_string(),
3523 status: WorkflowRowStatus::Succeeded,
3524 usage: None,
3525 at_ms: 1_500,
3526 });
3527
3528 // A confirmed host interrupt finalizes remaining runners. The widget
3529 // itself never claims cancellation before that runtime event.
3530 panel.finalize_interrupt();
3531 assert_eq!(panel.lifecycle, WorkflowPanelLifecycle::Cancelled);
3532
3533 let slow1 = panel
3534 .phases
3535 .iter()
3536 .flat_map(|p| p.rows.iter())
3537 .find(|r| r.task_id == "slow-1")
3538 .expect("slow-1");
3539 let slow2 = panel
3540 .phases
3541 .iter()
3542 .flat_map(|p| p.rows.iter())
3543 .find(|r| r.task_id == "slow-2")
3544 .expect("slow-2");
3545 assert_eq!(slow1.status, WorkflowRowStatus::Succeeded);
3546 assert_eq!(slow2.status, WorkflowRowStatus::Cancelled);
3547
3548 let (failed, cancelled) = panel.failure_cancel_counts();
3549 assert_eq!(failed, 0);
3550 assert_eq!(cancelled, 1);
3551 let header = panel.header_text(120);
3552 assert!(
3553 header.contains("cancel") || header.contains("cancelled"),
3554 "{header}"
3555 );
3556 }
3557 }
3558
3558 lines RUST