返回 CodeWhale
workbar.rs
根目录 / crates / tui / src / tui / widgets / workbar.rs
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
635 lines RUST