Skip to main content

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}