| 1 | //! Engine facts and localization for the shared terminal kit's workflow rows. |
| 2 | //! |
| 3 | //! The kit replaces the former RowCells, column fitter, bar partition and |
| 4 | //! clipping renderer. WorkflowPanel remains the Engine's lifecycle authority; |
| 5 | //! this adapter passes its reported facts into WorkflowProgress. The existing |
| 6 | //! backend still owns terminal capabilities and custom palette adaptation. |
| 7 | |
| 8 | use std::time::Duration; |
| 9 | |
| 10 | use codewhale_localization::{Locale, MessageId, tr}; |
| 11 | use codewhale_palette::UiTheme; |
| 12 | use codewhale_ratatui::{ |
| 13 | Caps, Role, Theme, TuiInk, TuiPalette, WorkflowProgress, WorkflowProgressWords, WorkflowRun, |
| 14 | WorkflowRunState, color::ColorDepth, detect::Appearance, |
| 15 | }; |
| 16 | use ratatui::text::Line; |
| 17 | |
| 18 | use crate::tui::widgets::workflow_panel::{WorkflowPanel, WorkflowPanelLifecycle}; |
| 19 | |
| 20 | #[cfg(test)] |
| 21 | const LARGE_WORKFLOW_AGENTS: usize = 25; |
| 22 | #[cfg(test)] |
| 23 | const MAX_RUN_ROWS: usize = WorkflowProgress::desired_rows_for(usize::MAX) as usize - 1; |
| 24 | |
| 25 | pub(crate) struct WorkbarRun<'a> { |
| 26 | pub panel: &'a WorkflowPanel, |
| 27 | pub queued: usize, |
| 28 | } |
| 29 | |
| 30 | #[must_use] |
| 31 | pub(crate) fn desired_rows(runs: usize) -> u16 { |
| 32 | WorkflowProgress::desired_rows_for(runs) |
| 33 | } |
| 34 | |
| 35 | pub(crate) fn render( |
| 36 | area: ratatui::layout::Rect, |
| 37 | buf: &mut ratatui::buffer::Buffer, |
| 38 | runs: &[WorkbarRun<'_>], |
| 39 | now_ms: u64, |
| 40 | theme: &UiTheme, |
| 41 | locale: Locale, |
| 42 | ) { |
| 43 | use ratatui::widgets::{Paragraph, Widget}; |
| 44 | let area = area.intersection(buf.area); |
| 45 | Paragraph::new(lines( |
| 46 | runs, |
| 47 | area.width, |
| 48 | usize::from(area.height), |
| 49 | now_ms, |
| 50 | theme, |
| 51 | locale, |
| 52 | )) |
| 53 | .render(area, buf); |
| 54 | } |
| 55 | |
| 56 | #[must_use] |
| 57 | pub(crate) fn lines( |
| 58 | runs: &[WorkbarRun<'_>], |
| 59 | width: u16, |
| 60 | max_rows: usize, |
| 61 | now_ms: u64, |
| 62 | theme: &UiTheme, |
| 63 | locale: Locale, |
| 64 | ) -> Vec<Line<'static>> { |
| 65 | let word = |id| tr(locale, id).into_owned().into(); |
| 66 | let progress = WorkflowProgress::new( |
| 67 | runs.iter().map(|run| facts(run, now_ms, locale)).collect(), |
| 68 | ) |
| 69 | .words(WorkflowProgressWords { |
| 70 | done: word(MessageId::WorkflowSettledOfTotal), |
| 71 | failed: word(MessageId::WorkflowCountFailed), |
| 72 | cancelled: word(MessageId::WorkflowCountCancelled), |
| 73 | queued: word(MessageId::AgentRailQueuedCount), |
| 74 | no_tasks: word(MessageId::WorkflowNoTasksYet), |
| 75 | large: word(MessageId::WorkbarLargeWorkflow), |
| 76 | gaps: word(MessageId::WorkflowLineFinishedWithGaps), |
| 77 | stopped: word(MessageId::WorkflowLineStopped), |
| 78 | more: word(MessageId::WorkbarMoreRuns), |
| 79 | manage: word(MessageId::FooterHintToManage), |
| 80 | }); |
| 81 | // The selected/custom UiTheme and existing backend retain palette authority. |
| 82 | // A fixed full-color kit palette supplies distinct semantic ink identities; |
| 83 | // map those identities before the backend's single capability pass. |
| 84 | let source_theme = Theme::new(Caps { |
| 85 | depth: ColorDepth::TrueColor, |
| 86 | ascii: false, |
| 87 | appearance: Appearance::Dark, |
| 88 | }) |
| 89 | .tui_palette(TuiPalette::Whale); |
| 90 | let inks = [ |
| 91 | (source_theme.color(Role::Foreground), theme.text_body), |
| 92 | (source_theme.color(Role::Muted), theme.text_muted), |
| 93 | (source_theme.color(Role::Hint), theme.text_hint), |
| 94 | (source_theme.color(Role::Danger), theme.error_fg), |
| 95 | ( |
| 96 | source_theme.tui_ink(TuiInk::Working).fg, |
| 97 | theme.accent_action, |
| 98 | ), |
| 99 | (source_theme.tui_ink(TuiInk::Success).fg, theme.success), |
| 100 | (source_theme.tui_ink(TuiInk::Warning).fg, theme.warning), |
| 101 | ]; |
| 102 | let mut rows = progress.lines(width, max_rows, &source_theme); |
| 103 | for row in &mut rows { |
| 104 | for span in &mut row.spans { |
| 105 | if let Some((_, color)) = inks |
| 106 | .iter() |
| 107 | .find(|(ink, _)| ink.is_some() && *ink == span.style.fg) |
| 108 | { |
| 109 | span.style.fg = Some(*color); |
| 110 | } |
| 111 | } |
| 112 | } |
| 113 | rows |
| 114 | } |
| 115 | |
| 116 | fn facts(run: &WorkbarRun<'_>, now_ms: u64, locale: Locale) -> WorkflowRun<'static> { |
| 117 | let panel = run.panel; |
| 118 | let (succeeded, _, _, total) = panel.row_outcomes(); |
| 119 | let (failed, cancelled) = panel.failure_cancel_counts(); |
| 120 | let state = match panel.lifecycle { |
| 121 | WorkflowPanelLifecycle::Pending => WorkflowRunState::Pending, |
| 122 | WorkflowPanelLifecycle::Running => WorkflowRunState::Running, |
| 123 | WorkflowPanelLifecycle::Succeeded => WorkflowRunState::Succeeded, |
| 124 | WorkflowPanelLifecycle::Degraded => WorkflowRunState::Degraded, |
| 125 | WorkflowPanelLifecycle::Failed => WorkflowRunState::Failed, |
| 126 | WorkflowPanelLifecycle::Cancelled => WorkflowRunState::Cancelled, |
| 127 | }; |
| 128 | let mut facts = WorkflowRun::new(panel.short_title(), state) |
| 129 | .outcomes(succeeded, failed, cancelled, total) |
| 130 | .queued(run.queued); |
| 131 | if panel.started_at_ms != 0 { |
| 132 | let end = panel.completed_at_ms.unwrap_or(now_ms); |
| 133 | facts = facts.elapsed(Duration::from_millis( |
| 134 | end.saturating_sub(panel.started_at_ms), |
| 135 | )); |
| 136 | } |
| 137 | if let Some(tokens) = panel.tokens_so_far() { |
| 138 | facts = facts.tokens(tokens); |
| 139 | } |
| 140 | if let Some(reason) = panel.outcome_reason() { |
| 141 | facts = facts.reason(reason); |
| 142 | } else if state == WorkflowRunState::Failed { |
| 143 | facts = facts.reason(tr(locale, MessageId::WorkflowLineFailed).into_owned()); |
| 144 | } |
| 145 | facts |
| 146 | } |
| 147 | |
| 148 | #[cfg(test)] |
| 149 | mod tests { |
| 150 | use super::*; |
| 151 | use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowRowStatus}; |
| 152 | use ratatui::{Terminal, backend::TestBackend}; |
| 153 | use unicode_width::UnicodeWidthStr; |
| 154 | |
| 155 | const NOW: u64 = 1_000_000; |
| 156 | |
| 157 | fn run(label: &str, started: u64, agents: usize, settled: usize) -> WorkflowPanel { |
| 158 | let mut panel = WorkflowPanel::new(format!("run-{label}"), label, started); |
| 159 | panel.apply_event(WorkflowPanelEvent::PhaseStarted { |
| 160 | title: "Survey".to_string(), |
| 161 | at_ms: started, |
| 162 | }); |
| 163 | for index in 0..agents { |
| 164 | let value = serde_json::json!({ |
| 165 | "type": "task_started", |
| 166 | "task_id": format!("{label}-{index}"), |
| 167 | "workflow_task_label": format!("agent-{index}"), |
| 168 | "at_ms": started, |
| 169 | }); |
| 170 | panel.apply_event(WorkflowPanelEvent::from_json_value(&value).expect("task_started")); |
| 171 | } |
| 172 | for index in 0..settled { |
| 173 | complete(&mut panel, label, index, WorkflowRowStatus::Succeeded, None); |
| 174 | } |
| 175 | panel |
| 176 | } |
| 177 | |
| 178 | fn complete( |
| 179 | panel: &mut WorkflowPanel, |
| 180 | label: &str, |
| 181 | index: usize, |
| 182 | status: WorkflowRowStatus, |
| 183 | reason: Option<&str>, |
| 184 | ) { |
| 185 | panel.apply_event(WorkflowPanelEvent::TaskCompleted { |
| 186 | task_id: format!("{label}-{index}"), |
| 187 | status, |
| 188 | usage: None, |
| 189 | reason: reason.map(str::to_string), |
| 190 | at_ms: panel.started_at_ms + 1_000, |
| 191 | }); |
| 192 | } |
| 193 | |
| 194 | fn settled( |
| 195 | label: &str, |
| 196 | status: WorkflowPanelLifecycle, |
| 197 | agents: usize, |
| 198 | done: usize, |
| 199 | ) -> WorkflowPanel { |
| 200 | let mut panel = run(label, NOW - 60_000, agents, done); |
| 201 | panel.apply_event(WorkflowPanelEvent::RunCompleted { |
| 202 | status, |
| 203 | error: None, |
| 204 | at_ms: NOW - 1_000, |
| 205 | }); |
| 206 | panel |
| 207 | } |
| 208 | |
| 209 | /// The founder's run from 2026-09-28, as its status record came back: a |
| 210 | /// read-only audit whose two agents both failed auth 355 ms in. |
| 211 | fn founder_all_failed() -> WorkflowPanel { |
| 212 | let goal = "Read-only release-readiness audit for Codewhale v0.10.1. Determine \ |
| 213 | actionable remaining blockers from live evidence, separating engine \ |
| 214 | release from desktop and deferred backlog. No edits."; |
| 215 | let reason = "[auth] Authorization failed: You have run out of credits or need a \ |
| 216 | Grok subscription. Add credits at https://grok.com/?_s=usage.\n\ |
| 217 | (provider `xAI` · requested model `grok-4.6`)"; |
| 218 | let error = "no task produced a result: all 2 task(s) failed and 1 fan-out(s) lost \ |
| 219 | every slot (no work survived them); the recorded result reflects no \ |
| 220 | completed work"; |
| 221 | WorkflowPanel::from_run_json(&serde_json::json!({ |
| 222 | "run_id": "workflow_6409ebe6", |
| 223 | "status": "failed", |
| 224 | "started_at_ms": 1_790_651_979_185_u64, |
| 225 | "completed_at_ms": 1_790_651_979_540_u64, |
| 226 | "workflow_goal": goal, |
| 227 | "error": error, |
| 228 | "events": [ |
| 229 | {"at_ms": 1_790_651_979_185_u64, "type": "run_started", "workflow_goal": goal}, |
| 230 | {"at_ms": 1_790_651_979_187_u64, "type": "phase_started", "title": "parallel-evidence"}, |
| 231 | {"at_ms": 1_790_651_979_265_u64, "type": "task_started", "task_id": "agent_f6665537", |
| 232 | "workflow_task_label": "engine-readiness"}, |
| 233 | {"at_ms": 1_790_651_979_302_u64, "type": "task_started", "task_id": "agent_94fe8e6a", |
| 234 | "workflow_task_label": "desktop-readiness"}, |
| 235 | {"at_ms": 1_790_651_979_514_u64, "type": "task_completed", "task_id": "agent_f6665537", |
| 236 | "status": "failed", "reason": reason}, |
| 237 | {"at_ms": 1_790_651_979_523_u64, "type": "task_completed", "task_id": "agent_94fe8e6a", |
| 238 | "status": "failed", "reason": reason}, |
| 239 | {"at_ms": 1_790_651_979_540_u64, "type": "run_completed", "status": "failed", |
| 240 | "error": error}, |
| 241 | ], |
| 242 | })) |
| 243 | .expect("run record") |
| 244 | } |
| 245 | |
| 246 | fn partial_success() -> WorkflowPanel { |
| 247 | let mut panel = run("Port the fixture suite", NOW - 134_000, 4, 3); |
| 248 | complete( |
| 249 | &mut panel, |
| 250 | "Port the fixture suite", |
| 251 | 3, |
| 252 | WorkflowRowStatus::Failed, |
| 253 | Some("Timed out waiting for the model after 600s. Retried once."), |
| 254 | ); |
| 255 | panel.apply_event(WorkflowPanelEvent::RunCompleted { |
| 256 | status: WorkflowPanelLifecycle::Degraded, |
| 257 | error: Some("completed with dropped slots".to_string()), |
| 258 | at_ms: NOW, |
| 259 | }); |
| 260 | panel |
| 261 | } |
| 262 | |
| 263 | fn render(runs: &[WorkbarRun<'_>], width: u16, rows: u16) -> String { |
| 264 | buffer_rows(&render_band(runs, width, rows)).join("\n") |
| 265 | } |
| 266 | |
| 267 | fn render_band(runs: &[WorkbarRun<'_>], width: u16, height: u16) -> ratatui::buffer::Buffer { |
| 268 | let area = ratatui::layout::Rect::new(0, 0, width, height); |
| 269 | let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal"); |
| 270 | terminal |
| 271 | .draw(|frame| { |
| 272 | super::render( |
| 273 | area, |
| 274 | frame.buffer_mut(), |
| 275 | runs, |
| 276 | NOW, |
| 277 | &codewhale_palette::UI_THEME, |
| 278 | Locale::En, |
| 279 | ); |
| 280 | }) |
| 281 | .expect("draw"); |
| 282 | terminal.backend().buffer().clone() |
| 283 | } |
| 284 | |
| 285 | fn buffer_rows(buf: &ratatui::buffer::Buffer) -> Vec<String> { |
| 286 | let area = buf.area; |
| 287 | (area.y..area.bottom()) |
| 288 | .map(|y| { |
| 289 | (area.x..area.right()) |
| 290 | .map(|x| buf[(x, y)].symbol().to_string()) |
| 291 | .collect::<String>() |
| 292 | .trim_end() |
| 293 | .to_string() |
| 294 | }) |
| 295 | .collect() |
| 296 | } |
| 297 | |
| 298 | fn one(panel: &WorkflowPanel) -> [WorkbarRun<'_>; 1] { |
| 299 | [WorkbarRun { panel, queued: 0 }] |
| 300 | } |
| 301 | |
| 302 | /// The four states the founder's screenshot needed, as the buffer paints |
| 303 | /// them. These strings are the PR's evidence; keep them exact. |
| 304 | #[test] |
| 305 | fn snapshot_running_all_failed_partial_and_narrow() { |
| 306 | let mut running = run("Compare Cline with Codewhale", NOW - 134_000, 10, 4); |
| 307 | complete( |
| 308 | &mut running, |
| 309 | "Compare Cline with Codewhale", |
| 310 | 4, |
| 311 | WorkflowRowStatus::Failed, |
| 312 | Some("rate limited"), |
| 313 | ); |
| 314 | running.budget_spent = 1_234_567; |
| 315 | let founder = founder_all_failed(); |
| 316 | let partial = partial_success(); |
| 317 | let cases = [ |
| 318 | ( |
| 319 | "running", |
| 320 | render(&one(&running), 110, 1), |
| 321 | " • Compare Cline with Codewhale ████████××░░░░░░░░░░ 4/10 done · 1 failed 2m 14s ↓1.2M", |
| 322 | ), |
| 323 | ( |
| 324 | "all failed", |
| 325 | render(&one(&founder), 140, 1), |
| 326 | " ✕ Read-only release-readiness audit for… ×××××××××××××××××××× 0/2 done · 2 failed 355ms Authorization failed: You have run out of…", |
| 327 | ), |
| 328 | ( |
| 329 | "partial success", |
| 330 | render(&one(&partial), 140, 1), |
| 331 | " ◆ Port the fixture suite ███████████████××××× 3/4 done · 1 failed 2m 14s finished with gaps · Timed out waiting for the model after…", |
| 332 | ), |
| 333 | ( |
| 334 | "narrow 60", |
| 335 | render(&one(&founder), 60, 1), |
| 336 | " ✕ Read-only release-readiness… 0/2 done · 2 failed 355ms", |
| 337 | ), |
| 338 | ( |
| 339 | "narrow 40", |
| 340 | render(&one(&founder), 40, 1), |
| 341 | " ✕ Read-only… 0/2 done · 2 failed", |
| 342 | ), |
| 343 | ]; |
| 344 | let wrong: Vec<String> = cases |
| 345 | .iter() |
| 346 | .filter(|(_, actual, expected)| actual != expected) |
| 347 | .map(|(name, actual, expected)| { |
| 348 | format!("{name}:\n got {actual:?}\n want {expected:?}") |
| 349 | }) |
| 350 | .collect(); |
| 351 | assert!(wrong.is_empty(), "{}", wrong.join("\n")); |
| 352 | for (_, row, _) in &cases[3..] { |
| 353 | assert!(!row.contains('█') && !row.contains('×'), "{row}"); |
| 354 | } |
| 355 | assert!(cases[3].1.width() <= 60 && cases[4].1.width() <= 40); |
| 356 | } |
| 357 | |
| 358 | #[test] |
| 359 | fn a_failed_run_never_paints_a_success_bar_or_rounds_to_zero_seconds() { |
| 360 | let founder = founder_all_failed(); |
| 361 | let band = render_band(&one(&founder), 140, 1); |
| 362 | let row = &buffer_rows(&band)[0]; |
| 363 | assert!(!row.contains('█'), "no agent succeeded: {row}"); |
| 364 | assert!(row.contains("0/2 done · 2 failed"), "{row}"); |
| 365 | assert!(row.contains("355ms") && !row.contains(" 0s"), "{row}"); |
| 366 | // The failed state mark carries error ink; the custom-theme regression |
| 367 | // separately covers failure bar cells. |
| 368 | let theme = codewhale_palette::UI_THEME; |
| 369 | let failed_cell = (0..140) |
| 370 | .find(|&x| band[(x, 0)].symbol() == codewhale_ratatui::glyphs::FAILED) |
| 371 | .expect("failed state mark"); |
| 372 | assert_eq!(band[(failed_cell, 0)].fg, theme.error_fg); |
| 373 | // The agents' own reason, not the run's aggregate, and not cut mid-word. |
| 374 | assert!(row.contains("Authorization failed"), "{row}"); |
| 375 | assert!(!row.contains("no task produced"), "{row}"); |
| 376 | assert!(row.ends_with('…') || row.ends_with("subscription"), "{row}"); |
| 377 | } |
| 378 | |
| 379 | #[test] |
| 380 | fn settled_rows_read_without_colour() { |
| 381 | let done = settled("audit", WorkflowPanelLifecycle::Succeeded, 3, 3); |
| 382 | let mut failed = run("migrate", NOW - 60_000, 2, 0); |
| 383 | failed.apply_event(WorkflowPanelEvent::RunCompleted { |
| 384 | status: WorkflowPanelLifecycle::Failed, |
| 385 | error: Some("script error".to_string()), |
| 386 | at_ms: NOW - 1_000, |
| 387 | }); |
| 388 | let gaps = settled("review", WorkflowPanelLifecycle::Degraded, 2, 1); |
| 389 | let runs = [ |
| 390 | WorkbarRun { |
| 391 | panel: &done, |
| 392 | queued: 0, |
| 393 | }, |
| 394 | WorkbarRun { |
| 395 | panel: &failed, |
| 396 | queued: 0, |
| 397 | }, |
| 398 | WorkbarRun { |
| 399 | panel: &gaps, |
| 400 | queued: 0, |
| 401 | }, |
| 402 | ]; |
| 403 | assert_eq!( |
| 404 | render(&runs, 80, 3), |
| 405 | [ |
| 406 | " ✓ audit ████████████████████ 3/3 done 59s", |
| 407 | " ✕ migrate ░░░░░░░░░░░░░░░░░░░░ 0/2 done 59s script error", |
| 408 | " ◆ review ██████████░░░░░░░░░░ 1/2 done 59s finished with gaps", |
| 409 | ] |
| 410 | .join("\n") |
| 411 | ); |
| 412 | } |
| 413 | |
| 414 | #[test] |
| 415 | fn chips_are_only_ever_true() { |
| 416 | // Small and healthy: no chip at all. |
| 417 | let small = run("small", NOW - 5_000, 3, 1); |
| 418 | let plain = render(&one(&small), 100, 1); |
| 419 | assert!( |
| 420 | !plain.contains('⚠') && !plain.contains("queued") && !plain.contains("failed"), |
| 421 | "{plain}" |
| 422 | ); |
| 423 | |
| 424 | // Large only at the threshold, a failure count only after a failure, |
| 425 | // queued only when the runtime reports waiting follow-ups. |
| 426 | let mut large = run("large", NOW - 5_000, LARGE_WORKFLOW_AGENTS, 0); |
| 427 | complete(&mut large, "large", 0, WorkflowRowStatus::Failed, None); |
| 428 | let busy = render( |
| 429 | &[WorkbarRun { |
| 430 | panel: &large, |
| 431 | queued: 2, |
| 432 | }], |
| 433 | 120, |
| 434 | 1, |
| 435 | ); |
| 436 | assert!(busy.contains("0/25 done · 1 failed"), "{busy}"); |
| 437 | assert!(busy.contains("⚠ Large workflow"), "{busy}"); |
| 438 | assert!(busy.ends_with("· 2 queued"), "{busy}"); |
| 439 | let under = run("under", NOW - 5_000, LARGE_WORKFLOW_AGENTS - 1, 0); |
| 440 | let under = render(&one(&under), 120, 1); |
| 441 | assert!(!under.contains("Large"), "{under}"); |
| 442 | } |
| 443 | |
| 444 | #[test] |
| 445 | fn one_failure_in_a_large_run_still_gets_a_bar_cell() { |
| 446 | let mut panel = run("large", NOW - 5_000, 100, 99); |
| 447 | complete(&mut panel, "large", 99, WorkflowRowStatus::Failed, None); |
| 448 | let shown = render(&one(&panel), 160, 1); |
| 449 | assert_eq!(shown.matches('×').count(), 1, "{shown}"); |
| 450 | assert_eq!(shown.matches('█').count(), 19, "{shown}"); |
| 451 | } |
| 452 | |
| 453 | #[test] |
| 454 | fn many_runs_fold_into_a_more_row_that_names_the_key() { |
| 455 | let panels: Vec<WorkflowPanel> = (0..12) |
| 456 | .map(|index| run(&format!("wf-{index:02}"), NOW - 10_000, 8, index % 8)) |
| 457 | .collect(); |
| 458 | let runs: Vec<WorkbarRun<'_>> = panels |
| 459 | .iter() |
| 460 | .map(|panel| WorkbarRun { panel, queued: 0 }) |
| 461 | .collect(); |
| 462 | // Six run rows and the fold row; no rules. |
| 463 | assert_eq!(desired_rows(runs.len()), MAX_RUN_ROWS as u16 + 1); |
| 464 | let snapshot = render(&runs, 100, MAX_RUN_ROWS as u16 + 1); |
| 465 | let rows: Vec<&str> = snapshot.lines().collect(); |
| 466 | assert_eq!(rows.len(), MAX_RUN_ROWS + 1); |
| 467 | assert_eq!(rows[MAX_RUN_ROWS], " +6 more · ↓ to manage"); |
| 468 | } |
| 469 | |
| 470 | #[test] |
| 471 | fn the_band_is_one_row_per_run_and_draws_no_rules() { |
| 472 | let live = run("Audit the parser", NOW - 30_000, 4, 1); |
| 473 | let done = settled("Port fixtures", WorkflowPanelLifecycle::Succeeded, 2, 2); |
| 474 | let runs = [ |
| 475 | WorkbarRun { |
| 476 | panel: &live, |
| 477 | queued: 3, |
| 478 | }, |
| 479 | WorkbarRun { |
| 480 | panel: &done, |
| 481 | queued: 0, |
| 482 | }, |
| 483 | ]; |
| 484 | assert_eq!(desired_rows(runs.len()), 2); |
| 485 | assert_eq!(desired_rows(0), 0); |
| 486 | let rows = buffer_rows(&render_band(&runs, 90, desired_rows(runs.len()))); |
| 487 | assert_eq!( |
| 488 | rows, |
| 489 | vec![ |
| 490 | " • Audit the parser █████░░░░░░░░░░░░░░░ 1/4 done 30s · 3 queued".to_string(), |
| 491 | " ✓ Port fixtures ████████████████████ 2/2 done 59s".to_string(), |
| 492 | ] |
| 493 | ); |
| 494 | assert!(!rows.iter().any(|row| row.contains('─')), "{rows:?}"); |
| 495 | } |
| 496 | |
| 497 | /// NO_COLOR / 16-colour / ASCII terminals: every state still reads. Under |
| 498 | /// monochrome the text is unchanged and each state keeps its own mark; |
| 499 | /// at 16 colours the state inks stay apart; ASCII-safe marks stay apart |
| 500 | /// except failed/stopped, which their words tell apart. |
| 501 | #[test] |
| 502 | fn states_read_on_no_color_sixteen_colour_and_ascii_terminals() { |
| 503 | use crate::tui::color_compat::{adapt_cell_colors, adapt_cell_symbol_for_ascii}; |
| 504 | use codewhale_palette::{ColorDepth, PaletteMode, ThemeId}; |
| 505 | let theme = codewhale_palette::UI_THEME; |
| 506 | let running = run("running", NOW - 10_000, 4, 1); |
| 507 | let ok = settled("ok", WorkflowPanelLifecycle::Succeeded, 2, 2); |
| 508 | let gaps = settled("gaps", WorkflowPanelLifecycle::Degraded, 2, 1); |
| 509 | let failed = settled("failed", WorkflowPanelLifecycle::Failed, 2, 0); |
| 510 | let stopped = settled("stopped", WorkflowPanelLifecycle::Cancelled, 2, 0); |
| 511 | let panels = [&running, &ok, &gaps, &failed, &stopped]; |
| 512 | let runs: Vec<WorkbarRun<'_>> = panels |
| 513 | .iter() |
| 514 | .map(|panel| WorkbarRun { panel, queued: 0 }) |
| 515 | .collect(); |
| 516 | let source = render_band(&runs, 100, desired_rows(runs.len())); |
| 517 | let text = buffer_rows(&source); |
| 518 | let marks: Vec<String> = text[0..5] |
| 519 | .iter() |
| 520 | .map(|row| row.chars().nth(1).expect("mark").to_string()) |
| 521 | .collect(); |
| 522 | for (index, mark) in marks.iter().enumerate() { |
| 523 | assert!( |
| 524 | !marks[index + 1..].contains(mark), |
| 525 | "state marks collide: {marks:?}" |
| 526 | ); |
| 527 | } |
| 528 | assert!(text[2].contains("finished with gaps"), "{text:?}"); |
| 529 | assert!(text[3].ends_with("failed"), "{text:?}"); |
| 530 | assert!(text[4].contains("stopped"), "{text:?}"); |
| 531 | |
| 532 | let adapted = |depth: ColorDepth| { |
| 533 | let mut buf = source.clone(); |
| 534 | for cell in buf.content.iter_mut() { |
| 535 | adapt_cell_colors(cell, depth, PaletteMode::Dark, ThemeId::Whale, &theme, None); |
| 536 | } |
| 537 | buf |
| 538 | }; |
| 539 | let mono = adapted(ColorDepth::Monochrome); |
| 540 | assert_eq!(buffer_rows(&mono), text, "monochrome changes no text"); |
| 541 | assert!( |
| 542 | mono.content.iter().all(|cell| { |
| 543 | cell.fg == ratatui::style::Color::Reset && cell.bg == ratatui::style::Color::Reset |
| 544 | }), |
| 545 | "monochrome paints no colour" |
| 546 | ); |
| 547 | let ansi16 = adapted(ColorDepth::Ansi16); |
| 548 | let mark_ink: Vec<ratatui::style::Color> = (0..5).map(|y| ansi16[(1, y)].fg).collect(); |
| 549 | for (index, ink) in mark_ink.iter().enumerate() { |
| 550 | assert!( |
| 551 | !mark_ink[index + 1..].contains(ink), |
| 552 | "16-colour state inks collide: {mark_ink:?}" |
| 553 | ); |
| 554 | } |
| 555 | |
| 556 | let mut ascii = source.clone(); |
| 557 | for cell in ascii.content.iter_mut() { |
| 558 | adapt_cell_symbol_for_ascii(cell); |
| 559 | } |
| 560 | let ascii_rows = buffer_rows(&ascii); |
| 561 | assert!( |
| 562 | ascii_rows.iter().all(|row| row.is_ascii()), |
| 563 | "{ascii_rows:?}" |
| 564 | ); |
| 565 | let ascii_marks: Vec<char> = ascii_rows[0..4] |
| 566 | .iter() |
| 567 | .map(|row| row.chars().nth(1).expect("mark")) |
| 568 | .collect(); |
| 569 | for (index, mark) in ascii_marks.iter().enumerate() { |
| 570 | assert!( |
| 571 | !ascii_marks[index + 1..].contains(mark), |
| 572 | "ASCII marks collide: {ascii_marks:?}" |
| 573 | ); |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | #[test] |
| 578 | fn localized_folded_counts_and_custom_failure_ink_survive_kit_adoption() { |
| 579 | use ratatui::style::Color; |
| 580 | let panels: Vec<_> = (0..8) |
| 581 | .map(|n| settled(&format!("run-{n}"), WorkflowPanelLifecycle::Failed, 2, 0)) |
| 582 | .collect(); |
| 583 | let runs: Vec<_> = panels |
| 584 | .iter() |
| 585 | .map(|panel| WorkbarRun { panel, queued: 0 }) |
| 586 | .collect(); |
| 587 | let mut theme = codewhale_palette::UI_THEME; |
| 588 | theme.error_fg = Color::Rgb(231, 12, 56); |
| 589 | let folded = lines(&runs, 100, 1, NOW, &theme, Locale::Ja); |
| 590 | let text = folded[0] |
| 591 | .spans |
| 592 | .iter() |
| 593 | .map(|span| span.content.as_ref()) |
| 594 | .collect::<String>(); |
| 595 | assert!(text.contains("ほか 8 件"), "{text}"); |
| 596 | assert!(!text.contains("{count}"), "{text}"); |
| 597 | let row = lines(&runs[..1], 100, 1, NOW, &theme, Locale::En); |
| 598 | assert_eq!(row[0].spans[1].style.fg, Some(theme.error_fg)); |
| 599 | assert!( |
| 600 | row[0] |
| 601 | .spans |
| 602 | .iter() |
| 603 | .filter(|span| span.content.contains('×')) |
| 604 | .all(|span| { span.style.fg == Some(theme.error_fg) }) |
| 605 | ); |
| 606 | } |
| 607 | |
| 608 | #[test] |
| 609 | fn seventy_five_agents_across_ten_runs_render_in_one_pass() { |
| 610 | let panels: Vec<WorkflowPanel> = (0..10) |
| 611 | .map(|index| run(&format!("wf-{index}"), NOW - 10_000, 8, 3)) |
| 612 | .collect(); |
| 613 | let runs: Vec<WorkbarRun<'_>> = panels |
| 614 | .iter() |
| 615 | .map(|panel| WorkbarRun { panel, queued: 0 }) |
| 616 | .collect(); |
| 617 | let started = std::time::Instant::now(); |
| 618 | for _ in 0..100 { |
| 619 | let _ = lines( |
| 620 | &runs, |
| 621 | 120, |
| 622 | MAX_RUN_ROWS + 1, |
| 623 | NOW, |
| 624 | &codewhale_palette::UI_THEME, |
| 625 | Locale::En, |
| 626 | ); |
| 627 | } |
| 628 | assert!( |
| 629 | started.elapsed() < std::time::Duration::from_secs(1), |
| 630 | "100 frames of 10 runs × 8 agents took {:?}", |
| 631 | started.elapsed() |
| 632 | ); |
| 633 | } |
| 634 | } |
| 635 |