| 1 | //! `/workflows` — the live workflow **run** dashboard. |
| 2 | //! |
| 3 | //! Lists every run this workspace's journal knows for the current session — |
| 4 | //! active and retained, not saved definitions — with the run's status, label, |
| 5 | //! elapsed time, child count, and latest progress. The detail pane adds the |
| 6 | //! phase order, the child-agent roster, the retained progress tail, and the |
| 7 | //! run id `/workflow cancel` accepts. `x` cancels the selected running run |
| 8 | //! through the same host path as `/workflow cancel` (no model turn, no |
| 9 | //! confirmation friction — matching the workflow panel's one-press cancel); |
| 10 | //! `r` re-reads the journal. |
| 11 | //! |
| 12 | //! This view never asks the model anything: it reads |
| 13 | //! [`crate::tools::workflow::host_workflow_run_details`] and writes only |
| 14 | //! through [`crate::tools::workflow::host_cancel_workflow`]. |
| 15 | |
| 16 | use std::cell::Cell; |
| 17 | use std::path::PathBuf; |
| 18 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; |
| 19 | |
| 20 | use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; |
| 21 | use ratatui::{ |
| 22 | buffer::Buffer, |
| 23 | layout::{Constraint, Direction, Layout, Rect}, |
| 24 | style::{Modifier, Style}, |
| 25 | text::{Line, Span}, |
| 26 | widgets::{Block, Clear, Paragraph, Widget, Wrap}, |
| 27 | }; |
| 28 | |
| 29 | use super::{ActionHint, ModalKind, ModalView, ViewAction, render_modal_footer}; |
| 30 | use crate::tools::workflow::{ |
| 31 | HostWorkflowChildRow, HostWorkflowRunDetail, host_cancel_workflow, host_workflow_run_details, |
| 32 | }; |
| 33 | use crate::tui::app::App; |
| 34 | use crate::tui::list_nav::wrap_index; |
| 35 | use codewhale_palette as palette; |
| 36 | |
| 37 | fn now_ms() -> u64 { |
| 38 | SystemTime::now() |
| 39 | .duration_since(UNIX_EPOCH) |
| 40 | .map(|d| d.as_millis() as u64) |
| 41 | .unwrap_or_default() |
| 42 | } |
| 43 | |
| 44 | /// Status ink follows the workflow panel's grammar: running is working ink, |
| 45 | /// completion is outcome ink, only failure (including budget and replay |
| 46 | /// failures) spends red. |
| 47 | fn status_style(status: &str) -> Style { |
| 48 | match status { |
| 49 | "queued" | "running" | "waiting" | "pending" => { |
| 50 | Style::default().fg(palette::STATUS_WARNING) |
| 51 | } |
| 52 | "completed" | "succeeded" => Style::default().fg(palette::STATUS_SUCCESS), |
| 53 | "degraded" => Style::default().fg(palette::STATUS_WARNING), |
| 54 | "failed" | "budget_exceeded" => Style::default().fg(palette::STATUS_ERROR), |
| 55 | _ => Style::default().fg(palette::TEXT_MUTED), |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | /// One glyph per projected child state. Only a real failure reads as `✗`: |
| 60 | /// a cancellation is not a failure, and a state this view does not know |
| 61 | /// must not borrow the failure mark (U09-m3). |
| 62 | fn child_state_glyph(state: &str) -> &'static str { |
| 63 | match state { |
| 64 | "running" | "pending" => "•", |
| 65 | "succeeded" => "✓", |
| 66 | "failed" | "budget_exceeded" => "✗", |
| 67 | "cancelled" => "–", |
| 68 | _ => "?", |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /// A child with no completion receipt in a settled run did not finish as far |
| 73 | /// as this record shows; it is not still running (U09-m3). |
| 74 | fn child_display_state(child: &HostWorkflowChildRow, run_active: bool) -> &'static str { |
| 75 | if !run_active && matches!(child.state, "running" | "pending") { |
| 76 | "unknown" |
| 77 | } else { |
| 78 | child.state |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | /// Open `/workflows` unless it is already on top. `/workflows`, the |
| 83 | /// workbar's `↓ to manage`, and a click on a workbar row all land here. |
| 84 | pub(crate) fn open(app: &mut App) { |
| 85 | if app.view_stack.top_kind() != Some(ModalKind::WorkflowsManager) { |
| 86 | let view = WorkflowsManagerView::new(app); |
| 87 | app.view_stack.push(view); |
| 88 | } |
| 89 | app.needs_redraw = true; |
| 90 | } |
| 91 | |
| 92 | pub struct WorkflowsManagerView { |
| 93 | /// Newest first, so the live run is the first thing read. |
| 94 | runs: Vec<HostWorkflowRunDetail>, |
| 95 | row: usize, |
| 96 | detail_open: bool, |
| 97 | detail_scroll: usize, |
| 98 | /// Largest useful detail scroll in rendered rows, from the last paint. |
| 99 | detail_max_scroll: Cell<usize>, |
| 100 | /// Receipt line for the last host action (cancel), shown under the header. |
| 101 | status: Option<String>, |
| 102 | workspace: PathBuf, |
| 103 | owner_session_id: Option<String>, |
| 104 | /// The manager follows owner-state changes while open without polling the |
| 105 | /// journal on every terminal frame. |
| 106 | last_refresh_at: Instant, |
| 107 | /// Screen rect of the run list body, recorded at render for mouse parity. |
| 108 | list_body: Cell<Rect>, |
| 109 | } |
| 110 | |
| 111 | impl WorkflowsManagerView { |
| 112 | #[must_use] |
| 113 | pub fn new(app: &App) -> Self { |
| 114 | let mut view = Self { |
| 115 | runs: Vec::new(), |
| 116 | row: 0, |
| 117 | detail_open: false, |
| 118 | detail_scroll: 0, |
| 119 | detail_max_scroll: Cell::new(usize::MAX), |
| 120 | status: None, |
| 121 | workspace: app.workspace.clone(), |
| 122 | owner_session_id: app.current_session_id.clone(), |
| 123 | last_refresh_at: Instant::now(), |
| 124 | list_body: Cell::new(Rect::ZERO), |
| 125 | }; |
| 126 | view.refresh(); |
| 127 | view |
| 128 | } |
| 129 | |
| 130 | /// Re-read the journal (newest first), preserving the selected run id when |
| 131 | /// a newer run arrives rather than silently moving focus to a different |
| 132 | /// row. |
| 133 | fn refresh(&mut self) { |
| 134 | let selected_id = self.selected().map(|detail| detail.line.run_id.clone()); |
| 135 | self.runs = host_workflow_run_details(&self.workspace, self.owner_session_id.as_deref()) |
| 136 | .into_iter() |
| 137 | .rev() |
| 138 | .collect(); |
| 139 | self.row = selected_id |
| 140 | .and_then(|run_id| { |
| 141 | self.runs |
| 142 | .iter() |
| 143 | .position(|detail| detail.line.run_id == run_id) |
| 144 | }) |
| 145 | .unwrap_or_else(|| self.row.min(self.runs.len().saturating_sub(1))); |
| 146 | self.last_refresh_at = Instant::now(); |
| 147 | } |
| 148 | |
| 149 | fn selected(&self) -> Option<&HostWorkflowRunDetail> { |
| 150 | self.runs.get(self.row) |
| 151 | } |
| 152 | |
| 153 | fn move_row(&mut self, delta: isize) { |
| 154 | let rows = self.runs.len(); |
| 155 | if rows == 0 { |
| 156 | return; |
| 157 | } |
| 158 | self.row = wrap_index(self.row, rows, delta); |
| 159 | } |
| 160 | |
| 161 | /// Cancel the selected run through the host path — the same one |
| 162 | /// `/workflow cancel <run_id>` takes, so receipts and journal state are |
| 163 | /// identical. No model turn. |
| 164 | fn cancel_selected(&mut self) { |
| 165 | let Some(detail) = self.selected() else { |
| 166 | return; |
| 167 | }; |
| 168 | if !detail.line.active { |
| 169 | self.status = Some(format!( |
| 170 | "Run {} already {} — nothing to cancel.", |
| 171 | detail.line.run_id, detail.line.status |
| 172 | )); |
| 173 | return; |
| 174 | } |
| 175 | let run_id = detail.line.run_id.clone(); |
| 176 | match host_cancel_workflow(&self.workspace, &run_id, self.owner_session_id.as_deref()) { |
| 177 | Ok(line) => { |
| 178 | self.status = Some(format!( |
| 179 | "Workflow {} {} · {}", |
| 180 | line.run_id, line.status, line.label |
| 181 | )); |
| 182 | } |
| 183 | Err(reason) => { |
| 184 | self.status = Some(format!("Cancel failed: {reason}")); |
| 185 | } |
| 186 | } |
| 187 | self.refresh(); |
| 188 | } |
| 189 | |
| 190 | fn footer_hints(&self) -> Vec<ActionHint> { |
| 191 | let mut hints = vec![ActionHint::new("↑/↓", "move")]; |
| 192 | if self.detail_open { |
| 193 | hints.push(ActionHint::new("←", "runs")); |
| 194 | } else { |
| 195 | hints.push(ActionHint::new("Enter", "detail")); |
| 196 | } |
| 197 | if self.selected().is_some_and(|d| d.line.active) { |
| 198 | hints.push(ActionHint::new("x", "cancel")); |
| 199 | } |
| 200 | hints.push(ActionHint::new("r", "refresh")); |
| 201 | hints.push(ActionHint::new("Esc", "close")); |
| 202 | hints |
| 203 | } |
| 204 | |
| 205 | fn header_lines(&self) -> Vec<Line<'static>> { |
| 206 | let active = self.runs.iter().filter(|d| d.line.active).count(); |
| 207 | let finished = self.runs.len() - active; |
| 208 | let mut header = vec![ |
| 209 | Line::from(vec![ |
| 210 | Span::styled( |
| 211 | "─ Workflow runs ", |
| 212 | Style::default().fg(palette::WHALE_ACTION).bold(), |
| 213 | ), |
| 214 | Span::styled( |
| 215 | format!("· {active} active · {finished} finished"), |
| 216 | Style::default().fg(palette::TEXT_MUTED), |
| 217 | ), |
| 218 | ]), |
| 219 | Line::from(""), |
| 220 | ]; |
| 221 | if let Some(status) = &self.status { |
| 222 | header.push(Line::from(Span::styled( |
| 223 | format!(" {status}"), |
| 224 | Style::default().fg(palette::WHALE_HUMAN), |
| 225 | ))); |
| 226 | } |
| 227 | header |
| 228 | } |
| 229 | |
| 230 | fn render_list(&self, area: Rect, buf: &mut Buffer) { |
| 231 | self.list_body.set(area); |
| 232 | if self.runs.is_empty() { |
| 233 | Paragraph::new(Line::from(vec![ |
| 234 | Span::styled( |
| 235 | " No workflow runs in this workspace yet.", |
| 236 | Style::default().fg(palette::TEXT_MUTED), |
| 237 | ), |
| 238 | Span::styled( |
| 239 | " /workflow <objective> starts one.", |
| 240 | Style::default().fg(palette::TEXT_DIM), |
| 241 | ), |
| 242 | ])) |
| 243 | .render(area, buf); |
| 244 | return; |
| 245 | } |
| 246 | |
| 247 | let now = now_ms(); |
| 248 | let rows_visible = usize::from(area.height).max(1); |
| 249 | let scroll = self.row.saturating_sub(rows_visible.saturating_sub(1)); |
| 250 | for (idx, detail) in self.runs.iter().enumerate() { |
| 251 | if idx < scroll || idx >= scroll + rows_visible { |
| 252 | continue; |
| 253 | } |
| 254 | let y = area.y + u16::try_from(idx - scroll).unwrap_or(u16::MAX); |
| 255 | if y >= area.y + area.height { |
| 256 | break; |
| 257 | } |
| 258 | let selected = idx == self.row; |
| 259 | let row_rect = Rect { |
| 260 | x: area.x, |
| 261 | y, |
| 262 | width: area.width, |
| 263 | height: 1, |
| 264 | }; |
| 265 | let elapsed = detail |
| 266 | .line |
| 267 | .completed_at_ms |
| 268 | .unwrap_or(now) |
| 269 | .saturating_sub(detail.line.started_at_ms) |
| 270 | / 1000; |
| 271 | let marker = if selected { "▸ " } else { " " }; |
| 272 | let base = if selected { |
| 273 | Style::default().fg(palette::WHALE_ACTION).bold() |
| 274 | } else { |
| 275 | Style::default().fg(palette::TEXT_SECONDARY) |
| 276 | }; |
| 277 | let mut spans = vec![ |
| 278 | Span::styled(marker, base), |
| 279 | Span::styled( |
| 280 | format!("{:<9}", detail.line.status), |
| 281 | status_style(detail.line.status).add_modifier(if selected { |
| 282 | Modifier::BOLD |
| 283 | } else { |
| 284 | Modifier::empty() |
| 285 | }), |
| 286 | ), |
| 287 | Span::styled(detail.line.label.clone(), base), |
| 288 | Span::styled( |
| 289 | format!( |
| 290 | " · {} · {} children", |
| 291 | crate::elapsed::format_elapsed_secs(elapsed), |
| 292 | detail.line.child_count |
| 293 | ), |
| 294 | Style::default().fg(palette::TEXT_DIM), |
| 295 | ), |
| 296 | ]; |
| 297 | if let Some(progress) = detail.line.last_progress.as_deref() { |
| 298 | spans.push(Span::styled( |
| 299 | format!(" · {progress}"), |
| 300 | Style::default().fg(palette::TEXT_DIM), |
| 301 | )); |
| 302 | } |
| 303 | Line::from(spans).render(row_rect, buf); |
| 304 | } |
| 305 | } |
| 306 | |
| 307 | fn detail_lines(&self, detail: &HostWorkflowRunDetail) -> Vec<Line<'static>> { |
| 308 | let now = now_ms(); |
| 309 | let elapsed = detail |
| 310 | .line |
| 311 | .completed_at_ms |
| 312 | .unwrap_or(now) |
| 313 | .saturating_sub(detail.line.started_at_ms) |
| 314 | / 1000; |
| 315 | let mut lines = vec![ |
| 316 | Line::from(vec![ |
| 317 | Span::styled("─ ", Style::default().fg(palette::WHALE_ACTION).bold()), |
| 318 | Span::styled( |
| 319 | detail.line.label.clone(), |
| 320 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 321 | ), |
| 322 | Span::styled( |
| 323 | format!(" · {}", detail.line.status), |
| 324 | status_style(detail.line.status), |
| 325 | ), |
| 326 | ]), |
| 327 | Line::from(Span::styled( |
| 328 | format!( |
| 329 | " run {} · {} · {} children", |
| 330 | detail.line.run_id, |
| 331 | crate::elapsed::format_elapsed_secs(elapsed), |
| 332 | detail.line.child_count |
| 333 | ), |
| 334 | Style::default().fg(palette::TEXT_DIM), |
| 335 | )), |
| 336 | Line::from(""), |
| 337 | ]; |
| 338 | if !detail.phases.is_empty() { |
| 339 | lines.push(Line::from(Span::styled( |
| 340 | " Phases", |
| 341 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 342 | ))); |
| 343 | lines.push(Line::from(Span::styled( |
| 344 | format!(" {}", detail.phases.join(" → ")), |
| 345 | Style::default().fg(palette::TEXT_SECONDARY), |
| 346 | ))); |
| 347 | lines.push(Line::from("")); |
| 348 | } |
| 349 | if !detail.children.is_empty() { |
| 350 | lines.push(Line::from(Span::styled( |
| 351 | format!(" Children ({})", detail.children.len()), |
| 352 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 353 | ))); |
| 354 | for child in &detail.children { |
| 355 | lines.push(child_row_line(child, detail.line.active)); |
| 356 | } |
| 357 | lines.push(Line::from("")); |
| 358 | } |
| 359 | if !detail.progress_tail.is_empty() { |
| 360 | lines.push(Line::from(Span::styled( |
| 361 | " Recent progress", |
| 362 | Style::default().fg(palette::TEXT_PRIMARY).bold(), |
| 363 | ))); |
| 364 | for progress in &detail.progress_tail { |
| 365 | lines.push(Line::from(Span::styled( |
| 366 | format!(" {progress}"), |
| 367 | Style::default().fg(palette::TEXT_SECONDARY), |
| 368 | ))); |
| 369 | } |
| 370 | lines.push(Line::from("")); |
| 371 | } |
| 372 | if let Some(error) = detail.line.error.as_deref() { |
| 373 | lines.push(Line::from(vec![ |
| 374 | Span::styled(" Error ", Style::default().fg(palette::STATUS_ERROR)), |
| 375 | Span::styled( |
| 376 | error.to_string(), |
| 377 | Style::default().fg(palette::TEXT_SECONDARY), |
| 378 | ), |
| 379 | ])); |
| 380 | } |
| 381 | lines.push(Line::from(Span::styled( |
| 382 | if detail.has_result { |
| 383 | " Result retained in the run journal (.codewhale/workflow-runs.jsonl)." |
| 384 | } else { |
| 385 | " No result recorded yet." |
| 386 | }, |
| 387 | Style::default().fg(palette::TEXT_DIM), |
| 388 | ))); |
| 389 | lines |
| 390 | } |
| 391 | |
| 392 | fn render_detail(&self, area: Rect, buf: &mut Buffer) { |
| 393 | let Some(detail) = self.selected() else { |
| 394 | self.render_list(area, buf); |
| 395 | return; |
| 396 | }; |
| 397 | let lines = self.detail_lines(detail); |
| 398 | self.detail_max_scroll.set(super::render_wrapped_detail( |
| 399 | lines, |
| 400 | area, |
| 401 | buf, |
| 402 | self.detail_scroll, |
| 403 | )); |
| 404 | } |
| 405 | } |
| 406 | |
| 407 | fn child_row_line(child: &HostWorkflowChildRow, run_active: bool) -> Line<'static> { |
| 408 | let name = child.label.clone().unwrap_or_else(|| child.task_id.clone()); |
| 409 | let state = child_display_state(child, run_active); |
| 410 | let mut spans = vec![ |
| 411 | Span::styled( |
| 412 | format!(" {} ", child_state_glyph(state)), |
| 413 | status_style(state), |
| 414 | ), |
| 415 | Span::styled(name, Style::default().fg(palette::TEXT_SECONDARY)), |
| 416 | Span::styled(format!(" · {state}"), status_style(state)), |
| 417 | ]; |
| 418 | let mut meta = Vec::new(); |
| 419 | if let Some(role) = child.role.as_deref() { |
| 420 | meta.push(role.to_string()); |
| 421 | } |
| 422 | if let Some(model) = child.model.as_deref() { |
| 423 | meta.push(model.to_string()); |
| 424 | } |
| 425 | if let Some(phase) = child.phase.as_deref() { |
| 426 | meta.push(phase.to_string()); |
| 427 | } |
| 428 | if !meta.is_empty() { |
| 429 | spans.push(Span::styled( |
| 430 | format!(" · {}", meta.join(" · ")), |
| 431 | Style::default().fg(palette::TEXT_DIM), |
| 432 | )); |
| 433 | } |
| 434 | Line::from(spans) |
| 435 | } |
| 436 | |
| 437 | impl ModalView for WorkflowsManagerView { |
| 438 | fn kind(&self) -> ModalKind { |
| 439 | ModalKind::WorkflowsManager |
| 440 | } |
| 441 | |
| 442 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 443 | self |
| 444 | } |
| 445 | |
| 446 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 447 | match key.code { |
| 448 | KeyCode::Esc => { |
| 449 | if self.detail_open { |
| 450 | self.detail_open = false; |
| 451 | self.detail_scroll = 0; |
| 452 | ViewAction::None |
| 453 | } else { |
| 454 | ViewAction::Close |
| 455 | } |
| 456 | } |
| 457 | KeyCode::Char('q') => ViewAction::Close, |
| 458 | KeyCode::Up | KeyCode::Char('k') => { |
| 459 | if self.detail_open { |
| 460 | self.detail_scroll = self.detail_scroll.saturating_sub(1); |
| 461 | } else { |
| 462 | self.move_row(-1); |
| 463 | } |
| 464 | ViewAction::None |
| 465 | } |
| 466 | KeyCode::Down | KeyCode::Char('j') => { |
| 467 | if self.detail_open { |
| 468 | // Stop at the last rendered (wrapped) row of the detail. |
| 469 | self.detail_scroll = self |
| 470 | .detail_scroll |
| 471 | .saturating_add(1) |
| 472 | .min(self.detail_max_scroll.get()); |
| 473 | } else { |
| 474 | self.move_row(1); |
| 475 | } |
| 476 | ViewAction::None |
| 477 | } |
| 478 | KeyCode::Enter | KeyCode::Char('l') | KeyCode::Right => { |
| 479 | if !self.runs.is_empty() { |
| 480 | self.detail_open = true; |
| 481 | } |
| 482 | ViewAction::None |
| 483 | } |
| 484 | KeyCode::Left | KeyCode::Tab => { |
| 485 | self.detail_open = false; |
| 486 | self.detail_scroll = 0; |
| 487 | ViewAction::None |
| 488 | } |
| 489 | KeyCode::Char('x') | KeyCode::Char('c') => { |
| 490 | self.cancel_selected(); |
| 491 | ViewAction::None |
| 492 | } |
| 493 | KeyCode::Char('r') => { |
| 494 | self.refresh(); |
| 495 | ViewAction::None |
| 496 | } |
| 497 | KeyCode::Char('g') => ViewAction::Close, |
| 498 | _ => ViewAction::None, |
| 499 | } |
| 500 | } |
| 501 | |
| 502 | fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction { |
| 503 | if self.detail_open { |
| 504 | return ViewAction::None; |
| 505 | } |
| 506 | // The wheel moves this list, not the transcript behind it. |
| 507 | match mouse.kind { |
| 508 | MouseEventKind::ScrollUp => { |
| 509 | self.move_row(-1); |
| 510 | return ViewAction::None; |
| 511 | } |
| 512 | MouseEventKind::ScrollDown => { |
| 513 | self.move_row(1); |
| 514 | return ViewAction::None; |
| 515 | } |
| 516 | _ => {} |
| 517 | } |
| 518 | if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { |
| 519 | let body = self.list_body.get(); |
| 520 | if body.width > 0 |
| 521 | && mouse.row >= body.y |
| 522 | && mouse.row < body.y + body.height |
| 523 | && mouse.column >= body.x |
| 524 | && mouse.column < body.x + body.width |
| 525 | { |
| 526 | let offset = usize::from(mouse.row - body.y); |
| 527 | let scroll = self |
| 528 | .row |
| 529 | .saturating_sub(usize::from(body.height).saturating_sub(1)); |
| 530 | let idx = scroll + offset; |
| 531 | if idx < self.runs.len() { |
| 532 | self.row = idx; |
| 533 | } |
| 534 | } |
| 535 | } |
| 536 | ViewAction::None |
| 537 | } |
| 538 | |
| 539 | fn tick(&mut self) -> ViewAction { |
| 540 | let interval = if self.runs.iter().any(|detail| detail.line.active) { |
| 541 | Duration::from_millis(250) |
| 542 | } else { |
| 543 | Duration::from_secs(2) |
| 544 | }; |
| 545 | if self.last_refresh_at.elapsed() >= interval { |
| 546 | self.refresh(); |
| 547 | } |
| 548 | ViewAction::None |
| 549 | } |
| 550 | |
| 551 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 552 | Clear.render(area, buf); |
| 553 | Block::default() |
| 554 | .style(Style::default().bg(palette::WHALE_BG)) |
| 555 | .render(area, buf); |
| 556 | |
| 557 | let hints = self.footer_hints(); |
| 558 | let content = render_modal_footer(area, buf, &hints); |
| 559 | |
| 560 | let chunks = Layout::default() |
| 561 | .direction(Direction::Vertical) |
| 562 | .constraints([Constraint::Length(4), Constraint::Min(1)]) |
| 563 | .split(content); |
| 564 | |
| 565 | Paragraph::new(self.header_lines()) |
| 566 | .wrap(Wrap { trim: false }) |
| 567 | .render(chunks[0], buf); |
| 568 | |
| 569 | if self.detail_open { |
| 570 | self.render_detail(chunks[1], buf); |
| 571 | } else { |
| 572 | self.render_list(chunks[1], buf); |
| 573 | } |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | #[cfg(test)] |
| 578 | mod tests { |
| 579 | use super::*; |
| 580 | |
| 581 | fn child(state: &'static str) -> HostWorkflowChildRow { |
| 582 | HostWorkflowChildRow { |
| 583 | task_id: "t1".to_string(), |
| 584 | label: None, |
| 585 | role: None, |
| 586 | model: None, |
| 587 | phase: None, |
| 588 | state, |
| 589 | } |
| 590 | } |
| 591 | |
| 592 | /// U09-m3: a child with no completion receipt in a settled run is not |
| 593 | /// "running", a cancellation is not a failure, and an unknown state never |
| 594 | /// borrows the failure mark. |
| 595 | #[test] |
| 596 | fn child_rows_keep_their_source_of_truth_state() { |
| 597 | assert_eq!(child_display_state(&child("running"), true), "running"); |
| 598 | assert_eq!(child_display_state(&child("running"), false), "unknown"); |
| 599 | assert_eq!(child_display_state(&child("succeeded"), false), "succeeded"); |
| 600 | assert_eq!(child_state_glyph("unknown"), "?"); |
| 601 | assert_eq!(child_state_glyph("cancelled"), "–"); |
| 602 | assert_eq!(child_state_glyph("failed"), "✗"); |
| 603 | assert_eq!(child_state_glyph("budget_exceeded"), "✗"); |
| 604 | } |
| 605 | } |
| 606 |