agentmux_launcher/splash_core.rs
1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shared startup-splash timeline logic: the row model, event application
5//! with same-tick count-up deferral, a frozen-row finalizer, duration
6//! formatting, and total/other reconciliation — used identically by all
7//! three platform splash renderers (`splash.rs` Windows, `splash_mac.rs`
8//! macOS, `splash_linux/` Linux).
9//!
10//! WHY THIS EXISTS NOW, NOT EARLIER. Before this module, each platform
11//! reimplemented this exact logic from scratch — `splash_mac.rs`'s own doc
12//! comment explained why at the time: "editing two already-working splashes
13//! I cannot build or run to verify" was too risky for the PR that first
14//! needed a macOS stage panel. The predictable result (see
15//! `docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md`
16//! §1): the same-tick count-up deferral fix landed only on macOS, the
17//! frozen-row defect (a row whose `End` never arrives stays visually
18//! "running" forever once the render loop stops redrawing on a timer) was
19//! never fixed on ANY platform — including macOS, despite it having the
20//! count-up fix — and total/other reconciliation existed only on macOS, as
21//! a bare unreconciled `"total:"` line on Windows and not at all on Linux.
22//! Three independent implementations meant every fix needed to land three
23//! times, and in practice none of them ever did.
24//!
25//! Platform-specific compositing (Win32 GDI blitting, Cocoa NSTextField
26//! layout, X11/Wayland pixel buffers) stays in each platform's own file —
27//! only the pure logic (no FFI, no windowing, no `Instant` mocking needed)
28//! lives here, which is also what makes it unit-testable without a live
29//! window on any platform, including the two this was written on without
30//! being able to run them.
31
32#[cfg(any(target_os = "windows", target_os = "macos", test))]
33use std::time::Duration;
34use std::time::Instant;
35
36use crate::startup_events::{StartupEvent, StartupStatus};
37
38/// A completed sub-item or stage's outcome: elapsed ms, status, and an
39/// optional short annotation carried over from the originating event.
40pub type Outcome = (u64, StartupStatus, Option<String>);
41
42pub struct SubEntry {
43 pub id: String,
44 pub label: String,
45 pub started_at: Instant,
46 pub done: Option<Outcome>,
47}
48
49pub struct StageEntry {
50 pub stage: &'static str,
51 pub label: &'static str,
52 pub started_at: Instant,
53 pub done: Option<Outcome>,
54 pub subs: Vec<SubEntry>,
55}
56
57/// Identifies which row a `Begin` event created, so the same-tick deferral
58/// in `apply_tick` can recognize an `End` for a row created in the very same
59/// batch.
60#[derive(PartialEq)]
61enum BeginKey {
62 Stage(&'static str),
63 Sub(&'static str, String),
64}
65
66/// The full timeline: every stage/sub-item seen so far, plus the state the
67/// same-tick deferral needs to carry between calls.
68#[derive(Default)]
69pub struct StageTimeline {
70 pub stages: Vec<StageEntry>,
71 deferred: Vec<StartupEvent>,
72}
73
74impl StageTimeline {
75 pub fn new() -> Self {
76 Self { stages: Vec::new(), deferred: Vec::new() }
77 }
78
79 fn apply_one(&mut self, ev: StartupEvent) {
80 match ev {
81 StartupEvent::StageBegin { stage, label } => {
82 self.stages.push(StageEntry {
83 stage,
84 label,
85 started_at: Instant::now(),
86 done: None,
87 subs: Vec::new(),
88 });
89 }
90 StartupEvent::StageEnd { stage, duration_ms, status, detail } => {
91 if let Some(row) = self.stages.iter_mut().rev().find(|r| r.stage == stage) {
92 row.done = Some((duration_ms, status, detail));
93 }
94 }
95 StartupEvent::SubBegin { stage, id, label } => {
96 if let Some(row) = self.stages.iter_mut().rev().find(|r| r.stage == stage) {
97 row.subs.push(SubEntry { id, label, started_at: Instant::now(), done: None });
98 }
99 }
100 StartupEvent::SubEnd { stage, id, duration_ms, status, detail } => {
101 if let Some(row) = self.stages.iter_mut().rev().find(|r| r.stage == stage) {
102 if let Some(sub) = row.subs.iter_mut().rev().find(|s| s.id == id) {
103 sub.done = Some((duration_ms, status, detail));
104 }
105 }
106 }
107 }
108 }
109
110 /// Apply one tick's worth of freshly-drained events, deferring any `End`
111 /// whose matching `Begin` *also* arrived in this same batch to the next
112 /// call — guaranteeing every row spends at least one call (and, since
113 /// every platform's render loop redraws every tick while anything is
114 /// running, at least one paint) in the "running" state before it can
115 /// complete.
116 ///
117 /// Without this, a step whose real work finishes inside a single drain
118 /// interval creates its row already-done — no "running" frame is ever
119 /// painted, so it just snaps to its final value instead of visibly
120 /// counting up
121 /// (`docs/analysis/ANALYSIS_SPLASH_SCREEN_TIMING_2026_07_20.md` §2 first
122 /// diagnosed this; fixed only on macOS at the time — see this module's
123 /// doc comment for why it's here, for every platform, now).
124 ///
125 /// Returns `true` if anything changed (a redraw is warranted).
126 pub fn apply_tick(&mut self, fresh: Vec<StartupEvent>) -> bool {
127 let mut changed = false;
128 let deferred = std::mem::take(&mut self.deferred);
129 for ev in deferred {
130 self.apply_one(ev);
131 changed = true;
132 }
133 let mut began_this_tick: Vec<BeginKey> = Vec::new();
134 for ev in fresh {
135 let defer = match &ev {
136 StartupEvent::StageEnd { stage, .. } => {
137 began_this_tick.contains(&BeginKey::Stage(stage))
138 }
139 StartupEvent::SubEnd { stage, id, .. } => {
140 began_this_tick.contains(&BeginKey::Sub(stage, id.clone()))
141 }
142 _ => false,
143 };
144 if defer {
145 self.deferred.push(ev);
146 continue;
147 }
148 match &ev {
149 StartupEvent::StageBegin { stage, .. } => {
150 began_this_tick.push(BeginKey::Stage(stage));
151 }
152 StartupEvent::SubBegin { stage, id, .. } => {
153 began_this_tick.push(BeginKey::Sub(stage, id.clone()));
154 }
155 _ => {}
156 }
157 self.apply_one(ev);
158 changed = true;
159 }
160 changed
161 }
162
163 /// Called once, at the moment the splash detects readiness (or its
164 /// safety timeout), for every stage/sub-item still lacking a matching
165 /// `End`. Without this, such a row stays visually "running" (live
166 /// counter, running color) forever once the render loop stops
167 /// redrawing on a timer and switches to redrawing only on-change (or,
168 /// pre-refactor Linux, stops updating entirely once composited for
169 /// dismiss) — the row is drawn once more at the freeze and then never
170 /// again, frozen exactly where it was.
171 ///
172 /// See `docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md`
173 /// §2.2 for an already-shipping concrete instance this closes: Windows'
174 /// `first-run-wait` sub-row has no `sub_end` call anywhere in the
175 /// codebase and was frozen "running" for the rest of every boot that
176 /// triggered it, on every platform, including macOS despite macOS
177 /// already having the count-up fix — the two defects are independent.
178 ///
179 /// Reuses `StartupStatus::Warn` rather than adding a new status variant:
180 /// "didn't receive a definitive completion signal before the splash
181 /// dismissed" is itself a real warning-worthy condition, not a third
182 /// rendering concept every platform's color table would need a new arm
183 /// for.
184 ///
185 /// Flushes `self.deferred` first, as genuine completions — NOT as
186 /// abandoned/interrupted work. An entry sitting in `deferred` is a real
187 /// `End` that already arrived; it was only held back one tick so its
188 /// row got at least one "running" paint first (`apply_tick`'s deferral).
189 /// If readiness is detected in that exact same tick, finalizing before
190 /// flushing would discard the row's true, already-known outcome and
191 /// replace it with a synthetic "interrupted" one — wrong, and worse on
192 /// Windows than elsewhere: its dismiss path calls this once and then
193 /// freezes the summary for the whole hold + fade with no further
194 /// `apply_tick` call, so the wrong value would be the only one the user
195 /// ever sees (codex P1 / reagent P1 on PR #3222).
196 pub fn finalize_running(&mut self, at: Instant) {
197 let deferred = std::mem::take(&mut self.deferred);
198 for ev in deferred {
199 self.apply_one(ev);
200 }
201 for stage in &mut self.stages {
202 if stage.done.is_none() {
203 let ms = at.saturating_duration_since(stage.started_at).as_millis() as u64;
204 stage.done = Some((ms, StartupStatus::Warn, Some("interrupted".to_string())));
205 }
206 for sub in &mut stage.subs {
207 if sub.done.is_none() {
208 let ms = at.saturating_duration_since(sub.started_at).as_millis() as u64;
209 sub.done = Some((ms, StartupStatus::Warn, Some("interrupted".to_string())));
210 }
211 }
212 }
213 }
214}
215
216// ── Visible rows: summarize, then keep the latest in view ────────────────────
217
218/// How many of a stage's sub-items are rendered individually. Anything older
219/// collapses into a single tally row.
220///
221/// Two, not one: the newest sub-item is usually the one still running, and
222/// seeing only it gives no sense of progress — the one just finished, with its
223/// real duration, is what tells you the batch is moving. Anything beyond that
224/// is history the user cannot act on, which is exactly the "too many detailed
225/// entries" complaint in
226/// `docs/reports/REPORT_SPLASH_MIGRATION_ROW_OVERFLOW_AND_SUMMARY_2026_09_15.md`.
227pub const SUB_DETAIL_MAX: usize = 2;
228
229/// What one rendered line stands for. Platform renderers match on this and
230/// draw it their own way (GDI text, an `NSTextField` pair, a formatted
231/// string) — the *selection* of what to draw is shared, which is the part
232/// that was wrong in three places independently.
233pub enum RowKind<'a> {
234 Stage(&'a StageEntry),
235 Sub(&'a SubEntry),
236 /// Stands in for sub-items collapsed out of the detail list.
237 /// `done` of `total` sub-items in this stage have completed.
238 SubTally { done: usize, total: usize },
239}
240
241pub struct Row<'a> {
242 pub kind: RowKind<'a>,
243}
244
245/// The label for a collapsed-sub-items row. Shared so all three platforms
246/// word it identically.
247pub fn tally_label(done: usize, total: usize) -> String {
248 format!("{done}/{total} done")
249}
250
251/// Flatten `stages` into at most `budget` rows: summarize each stage's older
252/// sub-items into a tally, then — if that still doesn't fit — keep the
253/// **newest** rows rather than the oldest.
254///
255/// The keep-the-newest half is the behavior change. Every platform previously
256/// iterated stages oldest-first and stopped dead at its row cap
257/// (`if row >= MAX_STAGE_ROWS { break }`), so a long sub-item burst filled the
258/// panel with the *earliest* entries and never showed the one currently
259/// running — the opposite of what a progress display is for, and the reason a
260/// slow startup looked frozen rather than busy. See the report above.
261pub fn visible_rows(stages: &[StageEntry], budget: usize) -> Vec<Row<'_>> {
262 // One block per stage: its own row first, then its (summarized) sub rows.
263 // Trimming happens block-wise below — an indented row must never outlive
264 // the stage row it belongs under.
265 let mut blocks: Vec<Vec<Row<'_>>> = Vec::new();
266 for stage in stages {
267 let mut block = vec![Row { kind: RowKind::Stage(stage) }];
268 let subs = &stage.subs;
269 if subs.len() > SUB_DETAIL_MAX {
270 // Counted over ALL of the stage's sub-items, not just the hidden
271 // ones: this is the stage's progress ratio, so it has to keep
272 // rising as the two always-shown items finish too.
273 let done = subs.iter().filter(|s| s.done.is_some()).count();
274 block.push(Row { kind: RowKind::SubTally { done, total: subs.len() } });
275 for sub in &subs[subs.len() - SUB_DETAIL_MAX..] {
276 block.push(Row { kind: RowKind::Sub(sub) });
277 }
278 } else {
279 for sub in subs {
280 block.push(Row { kind: RowKind::Sub(sub) });
281 }
282 }
283 blocks.push(block);
284 }
285
286 // Fill from the newest block backwards. A block that only partially fits
287 // keeps its stage row plus as many of its newest sub rows as are left —
288 // never the children alone, which would render as indented rows dangling
289 // under whatever stage happened to precede them.
290 let mut out: Vec<Row<'_>> = Vec::new();
291 let mut remaining = budget;
292 for block in blocks.into_iter().rev() {
293 if remaining == 0 {
294 break;
295 }
296 let mut chunk: Vec<Row<'_>> = if block.len() <= remaining {
297 block
298 } else {
299 let mut it = block.into_iter();
300 let header = it.next().expect("every block starts with its stage row");
301 let subs: Vec<Row<'_>> = it.collect();
302 let keep = remaining - 1;
303 let skip = subs.len() - keep;
304 let mut chunk = Vec::with_capacity(remaining);
305 chunk.push(header);
306 chunk.extend(subs.into_iter().skip(skip));
307 chunk
308 };
309 remaining -= chunk.len();
310 chunk.append(&mut out);
311 out = chunk;
312 }
313 out
314}
315
316// ── Formatting ───────────────────────────────────────────────────────────────
317
318#[cfg(any(target_os = "windows", target_os = "macos"))]
319pub fn format_ms(ms: u64) -> String {
320 if ms >= 10_000 {
321 format!("{:.0}s", ms as f64 / 1000.0)
322 } else if ms >= 1_000 {
323 format!("{:.1}s", ms as f64 / 1000.0)
324 } else {
325 format!("{ms}ms")
326 }
327}
328
329#[cfg(any(target_os = "windows", target_os = "macos"))]
330pub fn format_running(started_at: Instant) -> String {
331 let s = started_at.elapsed().as_secs_f32();
332 if s >= 10.0 {
333 format!("> {:.0}s", s)
334 } else {
335 format!("> {:.1}s", s)
336 }
337}
338
339/// Truncates to `max` chars, appending `..` when it does. Char-based (not
340/// byte-based) so it never splits a multi-byte codepoint.
341pub fn trunc(s: &str, max: usize) -> String {
342 let chars: Vec<char> = s.chars().collect();
343 if chars.len() <= max {
344 s.to_string()
345 } else {
346 chars[..max].iter().collect::<String>() + ".."
347 }
348}
349
350// ── Total / other reconciliation ────────────────────────────────────────────
351
352/// Sum of every *completed* top-level stage's own reported duration.
353/// Only completed stages are summed: a stage's own duration already covers
354/// whatever its subs took (summing subs too would double-count), and an
355/// undone stage would inflate the sum with an ever-changing partial value.
356#[cfg(any(target_os = "windows", target_os = "macos", test))]
357pub fn accounted_ms(stages: &[StageEntry]) -> u64 {
358 stages.iter().filter_map(|s| s.done.as_ref().map(|(dur, ..)| *dur)).sum()
359}
360
361/// `(accounted_ms, other_ms)` given a wall-clock `total_ms`. `other_ms` is
362/// real elapsed time genuinely uncovered by any instrumented stage (process-
363/// spawn scheduling gaps, cef_init-end -> first-paint, etc.) — not a double-
364/// count or a bug; see
365/// `docs/analysis/ANALYSIS_SPLASH_SCREEN_TIMING_2026_07_20.md` §5.
366/// `saturating_sub` guards the should-be-impossible case where accounted
367/// time exceeds the wall-clock total.
368#[cfg(any(target_os = "windows", target_os = "macos", test))]
369pub fn reconcile(stages: &[StageEntry], total_ms: u64) -> (u64, u64) {
370 let accounted = accounted_ms(stages);
371 (accounted, total_ms.saturating_sub(accounted))
372}
373
374// ── Hold duration ────────────────────────────────────────────────────────────
375
376/// How long the splash holds its completed, frozen summary on screen before
377/// fading out. Same env var, same default, on all three platforms — before
378/// this module, Windows and macOS each parsed `AGENTMUX_SPLASH_HOLD_MS`
379/// independently (both happened to converge on the same `2000` default, not
380/// the original spec's documented `3000` — kept here as the actual shipped
381/// behavior rather than "fixing" it to match a spec nothing currently
382/// honors) and Linux used a differently-named, differently-scoped
383/// `min_hold()` answering a different question ("minimum total time
384/// visible" rather than "pause after ready, before dismissing") — see
385/// `docs/reports/REPORT_SPLASH_SCREEN_ARCHITECTURE_RETHINK_2026_09_14.md`
386/// §4.4. Shortened to `min(1000ms)` for a very fast start (<500ms total) so
387/// a near-instant cold start doesn't force an artificially long hold.
388#[cfg(any(target_os = "windows", target_os = "macos", test))]
389pub fn hold_duration(total_ms: u64) -> Duration {
390 let hold_ms = std::env::var("AGENTMUX_SPLASH_HOLD_MS")
391 .ok()
392 .and_then(|v| v.parse::<u64>().ok())
393 .unwrap_or(2000);
394 let hold_ms = if total_ms < 500 { hold_ms.min(1000) } else { hold_ms };
395 Duration::from_millis(hold_ms)
396}
397
398#[cfg(test)]
399mod tests {
400 use super::*;
401 use std::thread::sleep;
402
403 fn begin(stage: &'static str) -> StartupEvent {
404 StartupEvent::StageBegin { stage, label: stage }
405 }
406 fn end(stage: &'static str, ms: u64) -> StartupEvent {
407 StartupEvent::StageEnd { stage, duration_ms: ms, status: StartupStatus::Ok, detail: None }
408 }
409
410 #[test]
411 fn a_begin_alone_leaves_the_row_running() {
412 let mut tl = StageTimeline::new();
413 assert!(tl.apply_tick(vec![begin("prep")]));
414 assert_eq!(tl.stages.len(), 1);
415 assert!(tl.stages[0].done.is_none());
416 }
417
418 #[test]
419 fn an_end_on_a_later_tick_completes_the_row() {
420 let mut tl = StageTimeline::new();
421 tl.apply_tick(vec![begin("prep")]);
422 assert!(tl.apply_tick(vec![end("prep", 42)]));
423 assert_eq!(tl.stages[0].done.as_ref().unwrap().0, 42);
424 }
425
426 #[test]
427 fn a_same_tick_begin_and_end_is_deferred_not_applied_immediately() {
428 // The count-up bug this module exists to fix on every platform: a
429 // fast step whose Begin+End land in the same drain batch must NOT
430 // be created already-done — the row needs at least one call in the
431 // "running" state.
432 let mut tl = StageTimeline::new();
433 tl.apply_tick(vec![begin("prep"), end("prep", 5)]);
434 assert!(tl.stages[0].done.is_none(), "same-tick End must be deferred, not applied immediately");
435 tl.apply_tick(vec![]);
436 assert_eq!(tl.stages[0].done.as_ref().unwrap().0, 5, "deferred End must apply on the next tick");
437 }
438
439 #[test]
440 fn two_events_in_one_block_do_not_interfere() {
441 let mut tl = StageTimeline::new();
442 tl.apply_tick(vec![begin("prep"), begin("migrations"), end("prep", 5)]);
443 // "prep" is deferred (same-tick begin+end); "migrations" is not
444 // (its Begin has no matching End this tick) and stays running.
445 assert!(tl.stages[0].done.is_none());
446 assert!(tl.stages[1].done.is_none());
447 tl.apply_tick(vec![]);
448 assert_eq!(tl.stages[0].done.as_ref().unwrap().0, 5);
449 assert!(tl.stages[1].done.is_none(), "an unrelated sibling row must not be finalized by an unrelated tick");
450 }
451
452 #[test]
453 fn finalize_running_completes_every_open_row_and_leaves_finished_ones_alone() {
454 let mut tl = StageTimeline::new();
455 tl.apply_tick(vec![begin("prep")]);
456 tl.apply_tick(vec![]);
457 tl.apply_tick(vec![end("prep", 10)]);
458 tl.apply_tick(vec![begin("host")]); // never completes
459 sleep(Duration::from_millis(5));
460
461 tl.finalize_running(Instant::now());
462
463 assert_eq!(tl.stages[0].done.as_ref().unwrap().0, 10, "an already-done row must be left exactly as it was");
464 let host_done = tl.stages[1].done.as_ref().expect("finalize must complete every still-open row");
465 assert_eq!(host_done.1, StartupStatus::Warn);
466 }
467
468 #[test]
469 fn finalize_running_also_closes_open_sub_rows() {
470 // The concrete shipping bug this closes: a sub-row (e.g. Windows'
471 // `first-run-wait`) with no matching SubEnd anywhere.
472 let mut tl = StageTimeline::new();
473 tl.apply_tick(vec![begin("host")]);
474 tl.apply_tick(vec![]);
475 tl.apply_tick(vec![StartupEvent::SubBegin {
476 stage: "host",
477 id: "first-run-wait".into(),
478 label: "First run can take longer".into(),
479 }]);
480 tl.apply_tick(vec![]);
481
482 tl.finalize_running(Instant::now());
483
484 assert!(tl.stages[0].subs[0].done.is_some(), "an orphaned sub-row must be finalized, not left running forever");
485 }
486
487 #[test]
488 fn finalize_running_flushes_a_same_tick_deferral_as_the_real_completion_not_as_interrupted() {
489 // codex P1 / reagent P1 on PR #3222: readiness detected in the exact
490 // same tick a fast stage's Begin+End were deferred (same-tick count-up
491 // fix) must not discard that real, already-known completion and
492 // replace it with a synthetic "interrupted" Warn outcome.
493 let mut tl = StageTimeline::new();
494 tl.apply_tick(vec![begin("prep"), end("prep", 7)]);
495 assert!(tl.stages[0].done.is_none(), "precondition: the End is deferred, not yet applied");
496
497 tl.finalize_running(Instant::now());
498
499 let (ms, status, _) = tl.stages[0].done.as_ref().expect("must be completed one way or another");
500 assert_eq!(*ms, 7, "the row's TRUE duration must survive, not a synthetic elapsed-since-start value");
501 assert_eq!(*status, StartupStatus::Ok, "a real completion must not be downgraded to Warn/interrupted");
502 }
503
504 #[test]
505 fn accounted_and_other_reconcile_exactly_against_total() {
506 let mut tl = StageTimeline::new();
507 tl.apply_tick(vec![begin("prep")]);
508 tl.apply_tick(vec![end("prep", 100)]);
509 tl.apply_tick(vec![begin("backend")]);
510 tl.apply_tick(vec![end("backend", 250)]);
511
512 let (accounted, other) = reconcile(&tl.stages, 500);
513 assert_eq!(accounted, 350);
514 assert_eq!(other, 150);
515 assert_eq!(accounted + other, 500, "accounted + other must equal total exactly, by construction");
516 }
517
518 #[test]
519 fn reconcile_never_panics_when_accounted_exceeds_total() {
520 // Should-be-impossible (a stage can't legitimately take longer than
521 // the whole splash), but a formatting bug must never become a panic.
522 let mut tl = StageTimeline::new();
523 tl.apply_tick(vec![begin("prep")]);
524 tl.apply_tick(vec![end("prep", 1000)]);
525 let (accounted, other) = reconcile(&tl.stages, 10);
526 assert_eq!(accounted, 1000);
527 assert_eq!(other, 0);
528 }
529
530 #[test]
531 fn an_undone_stage_is_excluded_from_accounted_ms() {
532 let mut tl = StageTimeline::new();
533 tl.apply_tick(vec![begin("prep")]);
534 tl.apply_tick(vec![end("prep", 100)]);
535 tl.apply_tick(vec![begin("backend")]); // still running
536 assert_eq!(accounted_ms(&tl.stages), 100);
537 }
538
539 /// Builds a timeline with one stage carrying `n` sub-items, the last of
540 /// which is still running — the shape a migration batch actually has.
541 fn timeline_with_subs(n: usize) -> StageTimeline {
542 let mut tl = StageTimeline::new();
543 tl.apply_tick(vec![begin("backend")]);
544 tl.apply_tick(vec![]);
545 for i in 0..n {
546 let id = format!("m{i:04}");
547 tl.apply_tick(vec![StartupEvent::SubBegin {
548 stage: "backend",
549 id: id.clone(),
550 label: format!("migration {i}"),
551 }]);
552 // Every sub but the last completes.
553 if i + 1 < n {
554 tl.apply_tick(vec![StartupEvent::SubEnd {
555 stage: "backend",
556 id,
557 duration_ms: 10,
558 status: StartupStatus::Ok,
559 detail: None,
560 }]);
561 }
562 }
563 tl.apply_tick(vec![]);
564 tl
565 }
566
567 fn labels(rows: &[Row<'_>]) -> Vec<String> {
568 rows.iter()
569 .map(|r| match &r.kind {
570 RowKind::Stage(s) => s.label.to_string(),
571 RowKind::Sub(s) => s.label.clone(),
572 RowKind::SubTally { done, total } => tally_label(*done, *total),
573 })
574 .collect()
575 }
576
577 #[test]
578 fn a_short_sub_list_is_shown_in_full_with_no_tally() {
579 let tl = timeline_with_subs(2);
580 let rows = visible_rows(&tl.stages, 12);
581 assert_eq!(labels(&rows), vec!["backend", "migration 0", "migration 1"]);
582 }
583
584 #[test]
585 fn a_long_sub_list_collapses_the_older_entries_into_a_tally() {
586 // 9 subs: 8 completed + 1 running. Detail keeps the newest two; the
587 // tally reports the stage's whole ratio, so 8 of 9 are done.
588 let tl = timeline_with_subs(9);
589 let rows = visible_rows(&tl.stages, 12);
590 assert_eq!(
591 labels(&rows),
592 vec!["backend", "8/9 done", "migration 7", "migration 8"],
593 "older sub-items must collapse into one tally, not occupy a row each"
594 );
595 }
596
597 #[test]
598 fn the_running_sub_item_survives_summarization() {
599 // The whole point of the display: whatever is happening right now
600 // must be on screen.
601 let tl = timeline_with_subs(20);
602 let rows = visible_rows(&tl.stages, 12);
603 let running = rows
604 .iter()
605 .filter_map(|r| match &r.kind {
606 RowKind::Sub(s) if s.done.is_none() => Some(s.label.clone()),
607 _ => None,
608 })
609 .collect::<Vec<_>>();
610 assert_eq!(running, vec!["migration 19"]);
611 }
612
613 #[test]
614 fn overflow_keeps_the_newest_rows_not_the_oldest() {
615 // The defect this function exists to fix: every platform used to
616 // iterate oldest-first and stop at the cap, so the newest rows —
617 // including the running one — were the ones dropped.
618 let mut tl = StageTimeline::new();
619 for stage in ["prep", "backend", "host", "cef_init", "paint"] {
620 tl.apply_tick(vec![begin(stage)]);
621 tl.apply_tick(vec![]);
622 }
623 let rows = visible_rows(&tl.stages, 3);
624 assert_eq!(labels(&rows), vec!["host", "cef_init", "paint"]);
625 }
626
627 #[test]
628 fn overflow_never_orphans_a_sub_row_from_its_stage() {
629 // ReAgent P1 / codex P2 on PR #3268: trimming the flat row list by
630 // count alone could cut inside a stage block, dropping the stage row
631 // while keeping its indented children — which then render dangling
632 // under whatever stage happened to precede them. Linux's 4-line
633 // budget hits this with an ordinary prep -> backend(migrations) ->
634 // host sequence.
635 let mut tl = timeline_with_subs(5); // "backend" + 5 subs
636 tl.apply_tick(vec![begin("host")]);
637 tl.apply_tick(vec![]);
638 let rows = visible_rows(&tl.stages, 4);
639
640 assert!(
641 matches!(rows[0].kind, RowKind::Stage(_)),
642 "the first visible row must be a stage row, never an orphaned child"
643 );
644 // Every indented row is preceded, somewhere above, by a stage row.
645 let mut seen_stage = false;
646 for row in &rows {
647 match row.kind {
648 RowKind::Stage(_) => seen_stage = true,
649 _ => assert!(seen_stage, "an indented row appeared before any stage row"),
650 }
651 }
652 }
653
654 #[test]
655 fn a_partially_fitting_stage_keeps_its_header_over_its_oldest_child() {
656 // When only part of a block fits, the stage row is mandatory and the
657 // rows given up are its oldest (the tally first, then older detail).
658 let tl = timeline_with_subs(5);
659 let rows = visible_rows(&tl.stages, 2);
660 assert_eq!(labels(&rows), vec!["backend", "migration 4"]);
661 }
662
663 #[test]
664 fn a_zero_budget_yields_no_rows_rather_than_panicking() {
665 let tl = timeline_with_subs(5);
666 assert!(visible_rows(&tl.stages, 0).is_empty());
667 }
668
669 #[test]
670 fn hold_duration_shortens_for_a_very_fast_start() {
671 std::env::remove_var("AGENTMUX_SPLASH_HOLD_MS");
672 assert_eq!(hold_duration(100), Duration::from_millis(1000));
673 assert_eq!(hold_duration(5000), Duration::from_millis(2000));
674 }
675}