Skip to main content

wt/tui/view/
mod.rs

1//! TUI rendering (spec §10): the list pane, detail pane, status bar, and modal
2//! overlays. Rendering is a pure function of [`App`] state into a ratatui
3//! [`Frame`], so it is testable with a `TestBackend`. Color comes from the
4//! resolved [`Theme`] (spec §11); when color is disabled the styles collapse to
5//! the monochrome look (dim/bold/reversed only).
6
7use ratatui::Frame;
8use ratatui::layout::{Constraint, Layout, Margin, Rect};
9use ratatui::style::{Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{
12    Block, Clear, HighlightSpacing, List, ListItem, ListState, Paragraph, Scrollbar,
13    ScrollbarOrientation, ScrollbarState, Wrap,
14};
15
16use crate::agent::{AgentModel, Effort};
17use crate::keys::KeyAction;
18use crate::model::{MergeState, PrState, SortKey, SortSpec, Worktree};
19use crate::output::render::branch_display;
20use crate::time::{now_unix, parse_iso8601, relative};
21use crate::tui::app::{
22    App, BusyState, CheckoutState, ComposeField, CreateState, CreateStep, InitSubmodulesState,
23    Mode, Pane, PrComposeState, PrPickerState, StaleBaseState,
24};
25use crate::tui::glyphs::Glyphs;
26use crate::tui::hints::{self, Hint};
27use crate::tui::options::OptionList;
28use crate::tui::theme::Theme;
29
30mod detail;
31mod list;
32mod modals;
33
34/// Renders the whole TUI for the current state.
35pub fn render(app: &App, frame: &mut Frame) {
36    let area = frame.area();
37    let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(area);
38    let (main, status) = (rows[0], rows[1]);
39
40    if app.show_sidebar && app.detail_visible() {
41        let cols = Layout::horizontal([Constraint::Length(app.sidebar_width), Constraint::Min(20)])
42            .split(main);
43        list::render_list(app, frame, cols[0]);
44        detail::render_detail(app, frame, cols[1]);
45    } else if app.show_sidebar {
46        list::render_list(app, frame, main);
47    } else {
48        detail::render_detail(app, frame, main);
49    }
50    render_status_bar(app, frame, status);
51
52    match &app.mode {
53        Mode::Help => modals::render_help(app, frame, area),
54        Mode::Create(state) => modals::render_create(app, state, frame, area),
55        Mode::PrPicker(state) => modals::render_pr_picker(app, state, frame, area),
56        Mode::PrCompose(state) => modals::render_pr_compose(app, state, frame, area),
57        Mode::Checkout(state) => modals::render_checkout(app, state, frame, area),
58        Mode::ConfirmRemove(index) => modals::render_confirm(app, *index, frame, area),
59        Mode::ConfirmCreate(index) => modals::render_confirm_create(app, *index, frame, area),
60        Mode::ConfirmDeleteBranch { index, force } => {
61            modals::render_confirm_delete_branch(app, *index, *force, frame, area)
62        }
63        Mode::ConfirmStaleBase(state) => modals::render_confirm_stale_base(app, state, frame, area),
64        Mode::ConfirmInitSubmodules(state) => {
65            modals::render_confirm_init_submodules(app, state, frame, area)
66        }
67        _ => {}
68    }
69
70    // The busy-spinner overlay (issue #46) is drawn last so it sits on top of
71    // whatever mode triggered the action (e.g. the checkout picker).
72    if let Some(busy) = &app.busy {
73        render_busy(app, busy, frame, area);
74    }
75}
76
77/// Renders the centered busy-spinner overlay shown while a shell-based action
78/// runs on a background task (issue #46): an animated spinner frame followed by
79/// the action label, e.g. `⠹ Removing feat/foo…`.
80fn render_busy(app: &App, busy: &BusyState, frame: &mut Frame, area: Rect) {
81    let theme = Theme::with_palette(app.color, app.palette);
82    let glyphs = Glyphs::new(app.nerd_fonts);
83    let line = Line::from(vec![
84        Span::styled(
85            glyphs.spinner_frame(busy.frame).to_string(),
86            theme.spinner(),
87        ),
88        Span::raw(" "),
89        Span::styled(format!("{}…", busy.label), theme.label()),
90    ]);
91    // Size to the label (spinner + spacing + ellipsis + side padding); `centered`
92    // clamps to the available area.
93    let width = (busy.label.chars().count() as u16 + 8).clamp(20, area.width);
94    let rect = centered(area, width, 3);
95    frame.render_widget(Clear, rect);
96    frame.render_widget(
97        Paragraph::new(line)
98            .block(Block::bordered().title(Span::styled("working", theme.title(true)))),
99        rect,
100    );
101}
102
103/// The ahead/behind cell as spans: green `↑N`, red `↓M`, or the absent marker
104/// (spinner while loading).
105fn ahead_behind_spans(
106    worktree: &Worktree,
107    theme: &Theme,
108    loaded: bool,
109    glyphs: &Glyphs,
110) -> Vec<Span<'static>> {
111    if !loaded {
112        return vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())];
113    }
114    match (worktree.ahead, worktree.behind) {
115        (Some(ahead), Some(behind)) => vec![
116            Span::styled(format!("↑{ahead}"), theme.ahead(ahead)),
117            Span::raw(" "),
118            Span::styled(format!("↓{behind}"), theme.behind(behind)),
119        ],
120        _ => vec![Span::styled(glyphs.absent().to_string(), theme.absent())],
121    }
122}
123
124/// The commit cell as spans: orange hash, plain subject, dim relative time.
125fn commit_spans(
126    worktree: &Worktree,
127    theme: &Theme,
128    loaded: bool,
129    glyphs: &Glyphs,
130    now: i64,
131) -> Vec<Span<'static>> {
132    match (&worktree.commit, loaded) {
133        (_, false) => vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())],
134        (Some(c), true) => {
135            let rel = parse_iso8601(&c.timestamp)
136                .map(|u| relative(now, u))
137                .unwrap_or_default();
138            vec![
139                Span::styled(c.hash.clone(), theme.commit_hash()),
140                Span::raw(" "),
141                Span::raw(c.subject.clone()),
142                Span::raw(" "),
143                Span::styled(format!("({rel})"), theme.time()),
144            ]
145        }
146        // Loaded, present, but no commit read: failed fetch → absent marker.
147        (None, true) if !worktree.is_missing => {
148            vec![Span::styled(glyphs.absent().to_string(), theme.absent())]
149        }
150        (None, true) => Vec::new(),
151    }
152}
153
154/// The PR cell as a span, colored by PR state (spinner while loading).
155fn pr_spans(
156    worktree: &Worktree,
157    theme: &Theme,
158    loaded: bool,
159    glyphs: &Glyphs,
160) -> Vec<Span<'static>> {
161    match (&worktree.pr, loaded) {
162        (_, false) => vec![Span::styled(glyphs.spinner().to_string(), theme.spinner())],
163        (Some(pr), true) => vec![Span::styled(
164            format!("#{} ({})", pr.number, pr.state.as_str()),
165            theme.pr_state(pr.state),
166        )],
167        (None, true) => Vec::new(),
168    }
169}
170
171/// The dirty label span for the detail pane, colored by state.
172fn dirty_label_span(worktree: &Worktree, theme: &Theme) -> Span<'static> {
173    match (worktree.dirty, worktree.has_untracked) {
174        (Some(true), _) => Span::styled("modified", theme.dirty()),
175        (_, Some(true)) => Span::styled("untracked", theme.untracked()),
176        (Some(false), _) => Span::styled("clean", theme.hint_label()),
177        _ => Span::raw(""),
178    }
179}
180
181/// The single-line note describing a worktree's merge / unpushed state, shared by
182/// the confirm dialog and the detail pane. The reassuring/informative states
183/// (merged, upstream-gone) are always returned; the alarming "unpushed work"
184/// states (no-upstream-local, and a real ahead count when tracked) are returned
185/// only when `include_warnings` is set — i.e. in the destructive confirm flow —
186/// so the passive detail pane stays calm. Returns `None` when there is nothing to
187/// say (or the row is not yet loaded).
188fn merge_state_note(
189    worktree: &Worktree,
190    theme: &Theme,
191    include_warnings: bool,
192) -> Option<Line<'static>> {
193    match &worktree.merge_state {
194        Some(MergeState::Merged { into: Some(base) }) => Some(Line::from(Span::styled(
195            format!("(merged into {base} — safe to delete)"),
196            theme.success(),
197        ))),
198        Some(MergeState::Merged { into: None }) => Some(Line::from(Span::styled(
199            "(merged via PR — safe to delete)",
200            theme.success(),
201        ))),
202        Some(MergeState::UpstreamGone) => Some(Line::from(Span::styled(
203            "(upstream branch deleted — likely merged)",
204            theme.label(),
205        ))),
206        Some(MergeState::NoUpstreamLocal) if include_warnings => Some(Line::from(Span::styled(
207            "(no upstream — local-only, unpushed work)",
208            theme.warning(),
209        ))),
210        Some(MergeState::Tracked) | None if include_warnings => match worktree.ahead {
211            Some(ahead) if ahead > 0 => Some(Line::from(Span::styled(
212                format!("({ahead} unpushed commit(s))"),
213                theme.warning(),
214            ))),
215            _ => None,
216        },
217        _ => None,
218    }
219}
220
221/// Renders the bottom status/help bar.
222fn render_status_bar(app: &App, frame: &mut Frame, area: Rect) {
223    let theme = Theme::with_palette(app.color, app.palette);
224    let mut spans = vec![Span::styled(
225        format!(" {} ", mode_label(&app.mode)),
226        theme.mode_chip(&app.mode),
227    )];
228    if !app.filter.is_empty() {
229        spans.push(Span::raw(" "));
230        spans.push(Span::styled(format!("/{}", app.filter), theme.accent()));
231    }
232    spans.push(Span::raw("  "));
233    if let Some(message) = &app.status_message {
234        spans.push(Span::styled(message.clone(), theme.status(app.status_kind)));
235    } else {
236        for (i, (key, label)) in mode_hints(app).into_iter().enumerate() {
237            if i > 0 {
238                spans.push(Span::raw("  "));
239            }
240            spans.push(Span::styled(key, theme.hint_key()));
241            spans.push(Span::raw(" "));
242            spans.push(Span::styled(label, theme.hint_label()));
243        }
244    }
245    frame.render_widget(Paragraph::new(Line::from(spans)), area);
246}
247
248/// The short mode name shown in the status-bar chip.
249fn mode_label(mode: &Mode) -> &'static str {
250    match mode {
251        Mode::List => "LIST",
252        Mode::Filter => "FILTER",
253        Mode::Create(_) => "CREATE",
254        Mode::PrPicker(_) => "PR",
255        Mode::PrCompose(_) => "COMPOSE",
256        Mode::Checkout(_) => "CHECKOUT",
257        Mode::ConfirmRemove(_) => "REMOVE",
258        Mode::ConfirmCreate(_) => "CREATE",
259        Mode::ConfirmDeleteBranch { .. } => "DELETE",
260        Mode::ConfirmStaleBase(_) => "CREATE",
261        Mode::ConfirmInitSubmodules(_) => "SUBMODULES",
262        Mode::Help => "HELP",
263    }
264}
265
266/// The curated subset of rebindable actions shown in the List-mode status bar
267/// (the full reference lives in the help overlay). Their key text comes from the
268/// live [`Keymap`](crate::keys::Keymap) and their labels from
269/// [`KeyAction::label`], so the bar can never drift from the bindings (issue #39).
270const LIST_BAR: [KeyAction; 8] = [
271    KeyAction::Switch,
272    KeyAction::New,
273    KeyAction::Remove,
274    KeyAction::PrCheckout,
275    KeyAction::Checkout,
276    KeyAction::Filter,
277    KeyAction::Help,
278    KeyAction::Quit,
279];
280
281/// The right-side key hints for the current mode (spec §10 bottom bar), as
282/// `(key, description)` pairs so the keys can be colored. List-mode hints derive
283/// from the keymap; modal hints come from the shared [`hints`] tables.
284fn mode_hints(app: &App) -> Vec<(String, String)> {
285    match &app.mode {
286        Mode::List => LIST_BAR
287            .iter()
288            .filter_map(|&action| {
289                app.keymap
290                    .display_for(action)
291                    .map(|keys| (keys, action.label().to_string()))
292            })
293            .collect(),
294        Mode::Filter => hint_pairs(hints::filter_hints()),
295        Mode::Create(_) => hint_pairs(hints::create_hints()),
296        Mode::PrPicker(_) => hint_pairs(hints::pr_picker_hints()),
297        Mode::PrCompose(_) => hint_pairs(hints::compose_edit_hints()),
298        Mode::Checkout(_) => hint_pairs(hints::checkout_hints()),
299        Mode::ConfirmRemove(_) => hint_pairs(hints::confirm_hints()),
300        Mode::ConfirmCreate(_) => hint_pairs(hints::confirm_create_hints()),
301        Mode::ConfirmDeleteBranch { .. } => hint_pairs(hints::confirm_delete_branch_hints()),
302        Mode::ConfirmStaleBase(_) => hint_pairs(hints::confirm_stale_base_hints()),
303        Mode::ConfirmInitSubmodules(_) => hint_pairs(hints::confirm_init_submodules_hints()),
304        Mode::Help => hint_pairs(hints::help_hints()),
305    }
306}
307
308/// Converts a static hint table into owned `(key, label)` pairs for the bar.
309fn hint_pairs(table: &[Hint]) -> Vec<(String, String)> {
310    table
311        .iter()
312        .map(|h| (h.key.to_string(), h.label.to_string()))
313        .collect()
314}
315
316/// Centers a popup `width`×`height` within `area`.
317fn centered(area: Rect, width: u16, height: u16) -> Rect {
318    let w = width.min(area.width);
319    let h = height.min(area.height);
320    Rect {
321        x: area.x + (area.width.saturating_sub(w)) / 2,
322        y: area.y + (area.height.saturating_sub(h)) / 2,
323        width: w,
324        height: h,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::keys::KeyChord;
332    use crate::tui::app::testutil::{app, wt};
333    use crate::tui::app::{
334        ComposeField, CreateState, PrComposeState, PrItem, PrPickerState, StatusKind,
335    };
336    use crossterm::event::KeyCode;
337    use ratatui::Terminal;
338    use ratatui::backend::TestBackend;
339    use ratatui::buffer::Buffer;
340    use ratatui::style::Color;
341
342    /// Renders the app to a TestBackend and returns the buffer as text.
343    fn render_to_text(app: &App, w: u16, h: u16) -> String {
344        buffer_text(&render_to_buffer(app, w, h))
345    }
346
347    /// Renders the app to a TestBackend and returns the raw (styled) buffer.
348    fn render_to_buffer(app: &App, w: u16, h: u16) -> Buffer {
349        let backend = TestBackend::new(w, h);
350        let mut terminal = Terminal::new(backend).unwrap();
351        terminal.draw(|f| render(app, f)).unwrap();
352        terminal.backend().buffer().clone()
353    }
354
355    /// The foreground color of the first cell rendering `symbol`.
356    fn cell_fg(buffer: &Buffer, symbol: &str) -> Color {
357        let area = buffer.area;
358        for y in 0..area.height {
359            for x in 0..area.width {
360                if buffer[(x, y)].symbol() == symbol {
361                    return buffer[(x, y)].fg;
362                }
363            }
364        }
365        panic!("symbol {symbol:?} not found");
366    }
367
368    fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
369        let area = buffer.area;
370        let mut out = String::new();
371        for y in 0..area.height {
372            for x in 0..area.width {
373                out.push_str(buffer[(x, y)].symbol());
374            }
375            out.push('\n');
376        }
377        out
378    }
379
380    #[test]
381    fn renders_list_and_detail() {
382        let a = app(&[("main", true), ("feature/x", false)]);
383        let text = render_to_text(&a, 100, 20);
384        assert!(text.contains("worktrees"));
385        assert!(text.contains("detail"));
386        assert!(text.contains("main"));
387        assert!(text.contains("feature/x"));
388        // The current worktree shows the '*' marker.
389        assert!(text.contains('*'));
390    }
391
392    #[test]
393    fn list_shows_branch_rows_with_marker_and_count() {
394        use crate::tui::app::testutil::branch_row;
395        let mut a = app(&[("main", true)]);
396        let mut br = branch_row("feature/lonely");
397        br.ahead = Some(3);
398        br.behind = Some(1);
399        a.worktrees.push(br);
400        a.apply_filter(String::new());
401        a.mark_loaded(a.worktrees[1].path.clone());
402        let text = render_to_text(&a, 100, 20);
403        assert!(text.contains("feature/lonely"));
404        assert!(text.contains('○')); // the worktree-less marker
405        assert!(text.contains("↑3"));
406        assert!(text.contains("↓1"));
407        // The title tallies branch rows separately from worktrees.
408        assert!(text.contains("branches"));
409    }
410
411    #[test]
412    fn detail_pane_for_branch_row_is_pathless() {
413        use crate::tui::app::testutil::branch_row;
414        let mut a = app(&[("main", true)]);
415        let mut br = branch_row("topic");
416        br.ahead = Some(2);
417        br.behind = Some(0);
418        br.base_ref = Some("main".into());
419        a.worktrees.push(br);
420        a.apply_filter(String::new());
421        a.mark_loaded(a.worktrees[1].path.clone());
422        a.selected = 1; // select the branch row
423        let text = render_to_text(&a, 100, 20);
424        assert!(text.contains("no worktree"));
425        assert!(text.contains("vs base"));
426        assert!(text.contains("base:"));
427        // The virtual path key is never surfaced to the user.
428        assert!(!text.contains("branch://"));
429    }
430
431    #[test]
432    fn confirm_create_dialog_renders() {
433        use crate::tui::app::testutil::branch_row;
434        let mut a = app(&[("main", true)]);
435        let mut br = branch_row("topic");
436        br.base_ref = Some("main".into());
437        br.ahead = Some(2);
438        br.behind = Some(0);
439        a.worktrees.push(br);
440        a.apply_filter(String::new());
441        a.mark_loaded(a.worktrees[1].path.clone());
442        a.mode = Mode::ConfirmCreate(1);
443        let text = render_to_text(&a, 100, 30);
444        assert!(text.contains("create worktree"));
445        assert!(text.contains("topic"));
446        assert!(text.contains("switch into it"));
447        assert!(text.contains("[y/N]"));
448    }
449
450    #[test]
451    fn renders_confirm_init_submodules_modal() {
452        let mut a = app(&[("main", true)]);
453        a.mode = Mode::ConfirmInitSubmodules(InitSubmodulesState {
454            dir: std::path::PathBuf::from("/wt/feature"),
455            branch: "feature".into(),
456            count: 3,
457        });
458        let text = render_to_text(&a, 100, 30);
459        assert!(text.contains("initialize submodules"));
460        assert!(text.contains("feature"));
461        assert!(text.contains("3 uninitialized"));
462        // Default-yes prompt (capital Y).
463        assert!(text.contains("[Y/n]"));
464    }
465
466    #[test]
467    fn narrow_terminal_hides_detail() {
468        let a = app(&[("main", true)]);
469        let mut a = a;
470        a.size = (50, 20);
471        let text = render_to_text(&a, 50, 20);
472        assert!(text.contains("worktrees"));
473        assert!(!text.contains("detail")); // detail hidden < 60 cols
474    }
475
476    #[test]
477    fn pending_rows_show_spinner() {
478        let mut a = app(&[("main", true)]);
479        a.mark_loading(); // nothing loaded -> spinners
480        let text = render_to_text(&a, 100, 20);
481        assert!(text.contains('…'));
482    }
483
484    #[test]
485    fn loaded_no_upstream_shows_absent_marker() {
486        // A loaded worktree with no upstream shows the ahead/behind "–".
487        let a = app(&[("main", true)]);
488        let text = render_to_text(&a, 100, 20);
489        assert!(text.contains('–'));
490    }
491
492    #[test]
493    fn help_overlay_renders() {
494        let mut a = app(&[("main", true)]);
495        a.mode = Mode::Help;
496        let text = render_to_text(&a, 100, 40);
497        assert!(text.contains("help"));
498        assert!(text.contains("navigate"));
499        assert!(text.contains("quit"));
500        // Regression for #39: checkout (`c`) was bound but undocumented.
501        assert!(text.contains("checkout"));
502    }
503
504    #[test]
505    fn help_overlay_documents_every_action() {
506        // The help overlay is generated from the keymap, so every action must
507        // appear with its label — the structural guard against hint drift (#39).
508        let mut a = app(&[("main", true)]);
509        a.mode = Mode::Help;
510        let text = render_to_text(&a, 100, 40);
511        for action in KeyAction::ALL {
512            assert!(
513                text.contains(action.label()),
514                "help overlay missing label for {action:?}: {:?}",
515                action.label()
516            );
517        }
518    }
519
520    #[test]
521    fn list_bar_includes_checkout() {
522        // The List status bar derives from the keymap; checkout must show with
523        // its default `c` binding (the visible half of the #39 fix).
524        let a = app(&[("main", true)]);
525        let text = render_to_text(&a, 100, 30);
526        assert!(text.contains("checkout"));
527        assert!(text.contains(" c "));
528    }
529
530    #[test]
531    fn list_bar_follows_rebind() {
532        // Rebinding an action flows through to the hint: the bar is sourced from
533        // the live keymap, not a hardcoded string.
534        let mut a = app(&[("main", true)]);
535        a.keymap
536            .rebind(KeyAction::Checkout, KeyChord::key(KeyCode::Char('x')));
537        let text = render_to_text(&a, 100, 30);
538        assert!(text.contains(" x "));
539    }
540
541    #[test]
542    fn create_overlay_shows_fields_and_error() {
543        let mut a = app(&[("main", true)]);
544        a.mode = Mode::Create(CreateState {
545            branch: "feat".into(),
546            error: Some("branch name is required".into()),
547            ..Default::default()
548        });
549        let text = render_to_text(&a, 100, 30);
550        assert!(text.contains("new worktree"));
551        assert!(text.contains("feat"));
552        assert!(text.contains("required"));
553    }
554
555    #[test]
556    fn create_overlay_shows_open_branch_options() {
557        use crate::tui::options::OptionList;
558        let mut a = app(&[("main", true)]);
559        let mut options = OptionList::new(vec![
560            "main".into(),
561            "origin/main".into(),
562            "origin/dev".into(),
563        ]);
564        options.open();
565        a.mode = Mode::Create(CreateState {
566            options,
567            ..Default::default()
568        });
569        let text = render_to_text(&a, 100, 30);
570        // The dropdown lists the existing branches and marks the cursor row.
571        assert!(text.contains("origin/main"));
572        assert!(text.contains("origin/dev"));
573        assert!(text.contains('▌')); // selection bar on the highlighted row
574        assert!(text.contains("options")); // status hint mentions ↑/↓ options
575    }
576
577    #[test]
578    fn checkout_overlay_renders_branches_and_target() {
579        use crate::tui::app::CheckoutState;
580        use crate::tui::options::OptionList;
581        let mut a = app(&[("main", true), ("feature/x", false)]);
582        let mut options = OptionList::new(vec!["main".into(), "feature/x".into()]);
583        options.open();
584        a.mode = Mode::Checkout(CheckoutState {
585            worktree_index: 0,
586            query: "feat".into(),
587            options,
588            ..Default::default()
589        });
590        let text = render_to_text(&a, 100, 30);
591        assert!(text.contains("checkout branch"));
592        assert!(text.contains("feature/x"));
593        assert!(text.contains("branches")); // the hint row
594    }
595
596    #[test]
597    fn pr_compose_model_field_shows_options_dropdown() {
598        let mut a = app(&[("main", true)]);
599        a.mode = Mode::PrCompose(PrComposeState {
600            field: ComposeField::Model,
601            branch: "feat".into(),
602            trunk: "main".into(),
603            action_label: "create".into(),
604            ..Default::default()
605        });
606        let text = render_to_text(&a, 100, 30);
607        // Every model option is listed, the active field marked with `>`.
608        assert!(text.contains("Opus 4.8"));
609        assert!(text.contains("Sonnet 4.6"));
610        assert!(text.contains("Haiku 4.5"));
611        assert!(text.contains("> model:"));
612    }
613
614    #[test]
615    fn pr_compose_effort_field_shows_options_dropdown() {
616        let mut a = app(&[("main", true)]);
617        a.mode = Mode::PrCompose(PrComposeState {
618            field: ComposeField::Effort,
619            branch: "feat".into(),
620            trunk: "main".into(),
621            action_label: "create".into(),
622            ..Default::default()
623        });
624        let text = render_to_text(&a, 100, 30);
625        assert!(text.contains("low"));
626        assert!(text.contains("medium"));
627        assert!(text.contains("high"));
628    }
629
630    #[test]
631    fn pr_compose_overlay_shows_header_fields_and_hints() {
632        let mut a = app(&[("main", true)]);
633        a.mode = Mode::PrCompose(PrComposeState {
634            field: ComposeField::Body,
635            title: "Add login".into(),
636            body: "Summary line".into(),
637            draft: true,
638            branch: "feat/login".into(),
639            trunk: "main".into(),
640            action_label: "create".into(),
641            error: Some("boom".into()),
642            ..Default::default()
643        });
644        let text = render_to_text(&a, 100, 30);
645        assert!(text.contains("open pull request"));
646        assert!(text.contains("feat/login"));
647        assert!(text.contains("Add login"));
648        assert!(text.contains("Summary line"));
649        assert!(text.contains("[create]"));
650        assert!(text.contains("draft [x]"));
651        assert!(text.contains("boom"));
652        assert!(text.contains("Ctrl-S"));
653        // The AI-fill controls and the selected model/effort are shown.
654        assert!(text.contains("Ctrl-A"));
655        assert!(text.contains("model:"));
656        assert!(text.contains("Sonnet 4.6")); // the default model label
657        assert!(text.contains("effort:"));
658    }
659
660    #[test]
661    fn pr_compose_shows_selected_model_and_effort() {
662        let mut a = app(&[("main", true)]);
663        a.mode = Mode::PrCompose(PrComposeState {
664            title: "T".into(),
665            branch: "feat".into(),
666            trunk: "main".into(),
667            action_label: "create".into(),
668            model: crate::agent::AgentModel::Opus,
669            effort: crate::agent::Effort::High,
670            ..Default::default()
671        });
672        let text = render_to_text(&a, 100, 30);
673        assert!(text.contains("Opus 4.8"));
674        assert!(text.contains("high"));
675    }
676
677    #[test]
678    fn pr_compose_submitting_shows_status() {
679        let mut a = app(&[("main", true)]);
680        a.mode = Mode::PrCompose(PrComposeState {
681            title: "T".into(),
682            branch: "feat".into(),
683            trunk: "main".into(),
684            action_label: "update #5".into(),
685            submitting: true,
686            ..Default::default()
687        });
688        let text = render_to_text(&a, 100, 30);
689        assert!(text.contains("working"));
690        assert!(text.contains("[update #5]"));
691    }
692
693    #[test]
694    fn pr_picker_states() {
695        let mut a = app(&[("main", true)]);
696        a.mode = Mode::PrPicker(PrPickerState {
697            loading: true,
698            ..Default::default()
699        });
700        assert!(render_to_text(&a, 100, 30).contains("loading"));
701
702        a.mode = Mode::PrPicker(PrPickerState {
703            loading: false,
704            prs: vec![
705                PrItem {
706                    number: 42,
707                    title: "Add login".into(),
708                    author: "alice".into(),
709                    state: "open".into(),
710                    created_at: "2020-01-01T00:00:00Z".into(),
711                },
712                // An unparseable timestamp renders an empty age without panicking.
713                PrItem {
714                    number: 7,
715                    title: "No date".into(),
716                    author: "bob".into(),
717                    state: "open".into(),
718                    created_at: String::new(),
719                },
720            ],
721            ..Default::default()
722        });
723        let text = render_to_text(&a, 100, 30);
724        assert!(text.contains("#42"));
725        assert!(text.contains("Add login"));
726        // The age column renders a relative time; the fixed far-past date is
727        // always years before "now", so the unit is deterministically years.
728        assert!(text.contains("ago"));
729
730        a.mode = Mode::PrPicker(PrPickerState {
731            error: Some("gh unavailable".into()),
732            ..Default::default()
733        });
734        assert!(render_to_text(&a, 100, 30).contains("gh auth login"));
735    }
736
737    #[test]
738    fn confirm_remove_overlay_shows_safety() {
739        let mut dirty = wt("topic", false);
740        dirty.dirty = Some(true);
741        let mut a = app(&[("main", true)]);
742        a.worktrees.push(dirty);
743        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
744        let text = render_to_text(&a, 100, 30);
745        assert!(text.contains("confirm remove"));
746        assert!(text.contains("data may be lost"));
747        assert!(text.contains("[y/N]"));
748    }
749
750    #[test]
751    fn confirm_remove_flags_no_upstream_as_unpushed() {
752        // A clean, local-only branch (no upstream, not merged) is still flagged as
753        // unpushed work, matching the remove guard (spec §10/§12).
754        let mut clean = wt("topic", false);
755        clean.dirty = Some(false);
756        clean.has_untracked = Some(false);
757        clean.ahead = None; // no upstream
758        clean.merge_state = Some(MergeState::NoUpstreamLocal);
759        let mut a = app(&[("main", true)]);
760        a.worktrees.push(clean);
761        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
762        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
763        let text = render_to_text(&a, 100, 30);
764        assert!(text.contains("no upstream"));
765        assert!(text.contains("local-only"));
766        assert!(!text.contains("data may be lost")); // not dirty
767    }
768
769    #[test]
770    fn confirm_remove_merged_into_base_is_safe() {
771        // A branch merged into its base: reassuring note, no unpushed alarm.
772        let mut w = wt("feature/done", false);
773        w.dirty = Some(false);
774        w.has_untracked = Some(false);
775        w.ahead = None;
776        w.merge_state = Some(MergeState::Merged {
777            into: Some("main".into()),
778        });
779        let mut a = app(&[("main", true)]);
780        a.worktrees.push(w);
781        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
782        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
783        let text = render_to_text(&a, 100, 30);
784        assert!(text.contains("merged into main"));
785        assert!(text.contains("safe to delete"));
786        // The alarming unpushed warning is suppressed (the branch header may
787        // still note the absence of an upstream — that is not the warning).
788        assert!(!text.contains("unpushed"));
789    }
790
791    #[test]
792    fn confirm_remove_merged_via_pr_is_safe() {
793        // A squash/rebase PR merge (ancestry can't prove it) → "merged via PR".
794        let mut w = wt("feature/squashed", false);
795        w.dirty = Some(false);
796        w.has_untracked = Some(false);
797        w.ahead = None;
798        w.merge_state = Some(MergeState::Merged { into: None });
799        let mut a = app(&[("main", true)]);
800        a.worktrees.push(w);
801        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
802        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
803        let text = render_to_text(&a, 100, 30);
804        assert!(text.contains("merged via PR"));
805        assert!(!text.contains("unpushed"));
806    }
807
808    #[test]
809    fn confirm_remove_upstream_gone_is_soft() {
810        // Upstream configured but gone: softened "likely merged", not an alarm.
811        let mut w = wt("feature/pushed", false);
812        w.dirty = Some(false);
813        w.has_untracked = Some(false);
814        w.ahead = None;
815        w.merge_state = Some(MergeState::UpstreamGone);
816        let mut a = app(&[("main", true)]);
817        a.worktrees.push(w);
818        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
819        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
820        let text = render_to_text(&a, 100, 30);
821        assert!(text.contains("upstream branch deleted"));
822        assert!(text.contains("likely merged"));
823        assert!(!text.contains("unpushed work"));
824    }
825
826    #[test]
827    fn confirm_remove_merged_but_dirty_still_warns() {
828        // Mergedness is orthogonal to dirtiness: a merged-but-dirty tree shows
829        // both the reassuring merge note AND the data-loss warning.
830        let mut w = wt("feature/dirty-merged", false);
831        w.dirty = Some(true);
832        w.ahead = None;
833        w.merge_state = Some(MergeState::Merged {
834            into: Some("main".into()),
835        });
836        let mut a = app(&[("main", true)]);
837        a.worktrees.push(w);
838        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
839        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
840        let text = render_to_text(&a, 100, 30);
841        assert!(text.contains("safe to delete"));
842        assert!(text.contains("data may be lost"));
843    }
844
845    #[test]
846    fn confirm_remove_tracked_ahead_still_warns() {
847        // A tracked branch with real unpushed commits keeps the unpushed warning.
848        let mut w = wt("feature/ahead", false);
849        w.dirty = Some(false);
850        w.ahead = Some(2);
851        w.upstream = Some("origin/feature/ahead".into());
852        w.merge_state = Some(MergeState::Tracked);
853        let mut a = app(&[("main", true)]);
854        a.worktrees.push(w);
855        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
856        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
857        let text = render_to_text(&a, 100, 30);
858        assert!(text.contains("2 unpushed commit(s)"));
859    }
860
861    #[test]
862    fn confirm_remove_honors_remove_untracked_blocks() {
863        // Untracked-only is NOT dirty by default (remove.untracked_blocks=false),
864        // so the dialog must not claim data loss — even though show_untracked is on.
865        let mut wt_un = wt("topic", false);
866        wt_un.dirty = Some(false);
867        wt_un.has_untracked = Some(true);
868        wt_un.ahead = Some(0);
869        let mut a = app(&[("main", true)]);
870        assert!(a.show_untracked && !a.remove_untracked_blocks);
871        a.worktrees.push(wt_un);
872        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
873        assert!(!render_to_text(&a, 100, 30).contains("data may be lost"));
874    }
875
876    #[test]
877    fn confirm_remove_shows_glanceable_context() {
878        // A clean, fully-merged branch: the dialog surfaces upstream, base, the
879        // tip commit, ahead/behind, and the PR — and raises no data-loss alarm.
880        use crate::model::{Commit, Pr, PrState};
881        let mut w = wt("feature/login", false);
882        w.dirty = Some(false);
883        w.has_untracked = Some(false);
884        w.ahead = Some(0);
885        w.behind = Some(0);
886        w.upstream = Some("origin/feature/login".into());
887        w.base_ref = Some("main".into());
888        w.commit = Some(Commit {
889            hash: "abc1234".into(),
890            subject: "Add login page".into(),
891            author: "Alice".into(),
892            timestamp: "2024-01-15T10:30:00Z".into(),
893        });
894        w.pr = Some(Pr {
895            number: 42,
896            state: PrState::Merged,
897            title: "Add login page".into(),
898        });
899        let mut a = app(&[("main", true)]);
900        a.worktrees.push(w);
901        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
902        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
903        let text = render_to_text(&a, 100, 30);
904        assert!(text.contains("origin/feature/login"));
905        assert!(text.contains("base:"));
906        assert!(text.contains("abc1234"));
907        assert!(text.contains("Add login page"));
908        assert!(text.contains("#42 (merged)"));
909        assert!(text.contains("↑0"));
910        assert!(!text.contains("data may be lost"));
911        assert!(text.contains("[y/N]"));
912    }
913
914    #[test]
915    fn confirm_remove_layers_warnings_over_context() {
916        // Dirty + ahead + an open PR: the neutral context lines AND both safety
917        // warnings appear together (the ahead/unpushed overlap is intentional).
918        use crate::model::{Commit, Pr, PrState};
919        let mut w = wt("feature/x", false);
920        w.dirty = Some(true);
921        w.ahead = Some(2);
922        w.behind = Some(0);
923        w.upstream = Some("origin/feature/x".into());
924        w.commit = Some(Commit {
925            hash: "def5678".into(),
926            subject: "WIP work".into(),
927            author: "Bob".into(),
928            timestamp: "2024-02-20T08:00:00Z".into(),
929        });
930        w.pr = Some(Pr {
931            number: 7,
932            state: PrState::Open,
933            title: "Feature x".into(),
934        });
935        let mut a = app(&[("main", true)]);
936        a.worktrees.push(w);
937        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
938        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
939        let text = render_to_text(&a, 100, 30);
940        assert!(text.contains("def5678"));
941        assert!(text.contains("#7 (open)"));
942        assert!(text.contains("data may be lost"));
943        assert!(text.contains("2 unpushed commit(s)"));
944    }
945
946    #[test]
947    fn confirm_remove_missing_skips_status_lines() {
948        // A missing worktree has no working tree to read: the dialog shows only
949        // the deletion marker, never the commit context (even if a tip commit
950        // happens to be recorded on the row).
951        use crate::model::Commit;
952        let mut w = wt("feature/gone", false);
953        w.is_missing = true;
954        w.base_ref = Some("main".into());
955        w.commit = Some(Commit {
956            hash: "ccc9999".into(),
957            subject: "Gone branch tip".into(),
958            author: "Dan".into(),
959            timestamp: "2024-04-01T00:00:00Z".into(),
960        });
961        let mut a = app(&[("main", true)]);
962        a.worktrees.push(w);
963        a.mark_loaded(a.worktrees[a.worktrees.len() - 1].path.clone());
964        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
965        let text = render_to_text(&a, 100, 30);
966        assert!(text.contains("directory already deleted"));
967        assert!(!text.contains("ccc9999"));
968        assert!(!text.contains("Gone branch tip"));
969    }
970
971    #[test]
972    fn confirm_remove_shows_spinner_until_loaded() {
973        // Before the row's async fields load, the dialog shows a spinner and must
974        // not leak commit content (matching the detail pane).
975        use crate::model::Commit;
976        let mut w = wt("feature/loading", false);
977        w.commit = Some(Commit {
978            hash: "aaa0000".into(),
979            subject: "Secret subject".into(),
980            author: "Carol".into(),
981            timestamp: "2024-03-01T00:00:00Z".into(),
982        });
983        let mut a = app(&[("main", true)]);
984        a.worktrees.push(w);
985        a.mark_loading(); // pushed row is unloaded anyway, but be explicit
986        a.mode = Mode::ConfirmRemove(a.worktrees.len() - 1);
987        let text = render_to_text(&a, 100, 30);
988        // The dialog rendered (its branch is shown) but the commit is withheld.
989        assert!(text.contains("feature/loading"));
990        assert!(!text.contains("Secret subject"));
991        assert!(!text.contains("aaa0000"));
992    }
993
994    #[test]
995    fn failed_dirty_read_shows_absent_marker_not_blank() {
996        // A loaded, present worktree whose dirty state is unknown renders "–"
997        // (not a blank that would read as clean).
998        let mut unknown = wt("topic", false);
999        unknown.dirty = None; // status read failed
1000        unknown.ahead = Some(0);
1001        unknown.behind = Some(0); // so ahead/behind is not the only "–"
1002        let mut a = app(&[("main", true)]);
1003        a.worktrees.push(unknown);
1004        let text = render_to_text(&a, 100, 20);
1005        assert!(text.contains('–'));
1006    }
1007
1008    #[test]
1009    fn status_bar_hints_are_per_mode() {
1010        let mut a = app(&[("main", true)]);
1011        a.mode = Mode::PrPicker(PrPickerState {
1012            loading: false,
1013            ..Default::default()
1014        });
1015        // The PR-picker overlay is empty here, so the bottom bar hint shows.
1016        assert!(render_to_text(&a, 100, 30).contains("checkout"));
1017    }
1018
1019    #[test]
1020    fn detail_pane_shows_recent_commits_and_pr_url() {
1021        use crate::model::{Commit, Pr, PrState};
1022        let mut a = app(&[("main", true)]);
1023        let c = |hash: &str, subject: &str| Commit {
1024            hash: hash.into(),
1025            subject: subject.into(),
1026            author: "x".into(),
1027            timestamp: "2024-01-15T10:30:00Z".into(),
1028        };
1029        a.worktrees[0].recent_commits = vec![c("aaaaaaa", "newest"), c("bbbbbbb", "older")];
1030        a.worktrees[0].pr = Some(Pr {
1031            number: 42,
1032            state: PrState::Open,
1033            title: "Add login".into(),
1034        });
1035        a.worktrees[0].pr_url = Some("https://github.com/o/r/pull/42".into());
1036        let text = render_to_text(&a, 100, 30);
1037        assert!(text.contains("commits:"));
1038        assert!(text.contains("newest"));
1039        assert!(text.contains("older"));
1040        assert!(text.contains("pull/42"));
1041    }
1042
1043    #[test]
1044    fn detail_pane_shows_merged_note() {
1045        // The detail pane mirrors the reassuring merge note (but not the
1046        // destructive-flow "unpushed work" warnings).
1047        let mut a = app(&[("main", true)]);
1048        a.worktrees[0].merge_state = Some(MergeState::Merged {
1049            into: Some("main".into()),
1050        });
1051        a.mark_loaded(a.worktrees[0].path.clone());
1052        let text = render_to_text(&a, 100, 30);
1053        assert!(text.contains("merged into main"));
1054    }
1055
1056    #[test]
1057    fn detail_pane_omits_no_upstream_warning() {
1058        // The passive detail pane stays calm: the no-upstream-local warning is
1059        // confined to the destructive confirm flow.
1060        let mut a = app(&[("main", true)]);
1061        a.worktrees[0].merge_state = Some(MergeState::NoUpstreamLocal);
1062        a.mark_loaded(a.worktrees[0].path.clone());
1063        let text = render_to_text(&a, 100, 30);
1064        assert!(!text.contains("local-only"));
1065    }
1066
1067    #[test]
1068    fn status_bar_shows_mode_and_filter() {
1069        let mut a = app(&[("main", true)]);
1070        a.filter = "feat".into();
1071        a.mode = Mode::Filter;
1072        let text = render_to_text(&a, 100, 20);
1073        assert!(text.contains("FILTER"));
1074        assert!(text.contains("/feat"));
1075    }
1076
1077    #[test]
1078    fn list_markers_are_colored_and_gate_on_color_flag() {
1079        let mut a = app(&[("main", true)]);
1080        a.worktrees[0].ahead = Some(1);
1081        a.worktrees[0].behind = Some(2);
1082        a.worktrees[0].dirty = Some(true);
1083        // Colored: status/dirty/ahead/behind cells carry a foreground color.
1084        let buf = render_to_buffer(&a, 100, 20);
1085        assert_ne!(cell_fg(&buf, "*"), Color::Reset); // current marker (green)
1086        assert_ne!(cell_fg(&buf, "M"), Color::Reset); // dirty (yellow)
1087        assert_ne!(cell_fg(&buf, "↑"), Color::Reset); // ahead (green)
1088        assert_ne!(cell_fg(&buf, "↓"), Color::Reset); // behind (red)
1089        assert_ne!(cell_fg(&buf, "↑"), cell_fg(&buf, "↓")); // distinct hues
1090        // Monochrome: the same cells fall back to the default foreground.
1091        a.color = false;
1092        let mono = render_to_buffer(&a, 100, 20);
1093        assert_eq!(cell_fg(&mono, "*"), Color::Reset);
1094        assert_eq!(cell_fg(&mono, "M"), Color::Reset);
1095        assert_eq!(cell_fg(&mono, "↑"), Color::Reset);
1096    }
1097
1098    #[test]
1099    fn custom_palette_recolors_cells() {
1100        let mut a = app(&[("main", true)]);
1101        // Overriding the "current" (green) slot recolors the current marker,
1102        // proving the resolved palette threads through rendering.
1103        a.palette.green = Color::Rgb(1, 2, 3);
1104        let buf = render_to_buffer(&a, 100, 20);
1105        assert_eq!(cell_fg(&buf, "*"), Color::Rgb(1, 2, 3));
1106    }
1107
1108    #[test]
1109    fn pr_state_cell_is_colored() {
1110        use crate::model::{Pr, PrState};
1111        let mut a = app(&[("main", true)]);
1112        a.worktrees[0].pr = Some(Pr {
1113            number: 7,
1114            state: PrState::Open,
1115            title: "t".into(),
1116        });
1117        let buf = render_to_buffer(&a, 120, 20);
1118        // The PR cell '#' is colored by state when color is on.
1119        assert_ne!(cell_fg(&buf, "#"), Color::Reset);
1120    }
1121
1122    #[test]
1123    fn focused_pane_border_differs_from_unfocused() {
1124        let mut a = app(&[("main", true)]);
1125        a.focus = Pane::List;
1126        let list_focused = render_to_buffer(&a, 100, 20);
1127        a.focus = Pane::Detail;
1128        let detail_focused = render_to_buffer(&a, 100, 20);
1129        // (0,0) is the list pane's top-left border corner.
1130        assert_ne!(list_focused[(0, 0)].fg, detail_focused[(0, 0)].fg);
1131    }
1132
1133    #[test]
1134    fn list_title_shows_count_and_sort() {
1135        let a = app(&[("main", true), ("feature/x", false)]);
1136        let text = render_to_text(&a, 100, 20);
1137        assert!(text.contains("(2)"));
1138        assert!(text.contains("branch ↑"));
1139    }
1140
1141    #[test]
1142    fn filtered_title_shows_visible_over_total() {
1143        let mut a = app(&[("alpha", true), ("beta", false)]);
1144        a.filter_push('a');
1145        a.filter_push('l');
1146        a.filter_push('p'); // matches only "alpha"
1147        assert_eq!(a.visible.len(), 1);
1148        let text = render_to_text(&a, 100, 20);
1149        assert!(text.contains("(1/2)"));
1150    }
1151
1152    #[test]
1153    fn empty_filter_shows_no_matches_hint() {
1154        let mut a = app(&[("alpha", true)]);
1155        a.filter_push('z');
1156        a.filter_push('z');
1157        a.filter_push('z');
1158        assert!(a.visible.is_empty());
1159        let text = render_to_text(&a, 100, 20);
1160        assert!(text.contains("no matches for /zzz"));
1161    }
1162
1163    #[test]
1164    fn detail_scrollbar_appears_when_content_overflows() {
1165        use crate::model::Commit;
1166        let mut a = app(&[("main", true)]);
1167        a.worktrees[0].recent_commits = (0..40)
1168            .map(|i| Commit {
1169                hash: format!("h{i:05}"),
1170                subject: "s".into(),
1171                author: "a".into(),
1172                timestamp: "2024-01-15T10:30:00Z".into(),
1173            })
1174            .collect();
1175        // A short pane forces overflow; the scrollbar thumb glyph appears.
1176        let text = render_to_text(&a, 100, 12);
1177        assert!(text.contains('█'));
1178    }
1179
1180    #[test]
1181    fn status_message_colored_by_kind() {
1182        let mut a = app(&[("main", true)]);
1183        a.set_status("ZEBRA", StatusKind::Success);
1184        let ok = render_to_buffer(&a, 100, 20);
1185        a.set_status("ZEBRA", StatusKind::Error);
1186        let err = render_to_buffer(&a, 100, 20);
1187        a.set_status("ZEBRA", StatusKind::Info);
1188        let info = render_to_buffer(&a, 100, 20);
1189        // 'Z' only appears in the status message, so it locates the cell.
1190        assert_ne!(cell_fg(&ok, "Z"), Color::Reset); // success colored
1191        assert_ne!(cell_fg(&err, "Z"), Color::Reset); // error colored
1192        assert_eq!(cell_fg(&info, "Z"), Color::Reset); // info uncolored
1193        assert_ne!(cell_fg(&ok, "Z"), cell_fg(&err, "Z")); // success != error
1194    }
1195
1196    #[test]
1197    fn busy_overlay_renders_label_and_spinner() {
1198        let mut a = app(&[("main", true)]);
1199        a.begin_busy("Removing feat/foo");
1200        let text = render_to_text(&a, 100, 20);
1201        assert!(text.contains("working"));
1202        assert!(text.contains("Removing feat/foo"));
1203        assert!(text.contains('…'));
1204        // The first ASCII spinner frame is shown.
1205        let frame0 = Glyphs::new(false).spinner_frame(0);
1206        assert!(text.contains(frame0));
1207    }
1208
1209    #[test]
1210    fn busy_overlay_animates_with_frame() {
1211        let mut a = app(&[("main", true)]);
1212        a.begin_busy("Working");
1213        let glyphs = Glyphs::new(false);
1214        let at0 = render_to_text(&a, 100, 20);
1215        a.tick_busy();
1216        let at1 = render_to_text(&a, 100, 20);
1217        // The frame index flows into the rendered glyph; frame 1 differs from 0.
1218        assert_ne!(glyphs.spinner_frame(0), glyphs.spinner_frame(1));
1219        assert!(at1.contains(glyphs.spinner_frame(1)));
1220        assert_ne!(at0, at1);
1221    }
1222
1223    #[test]
1224    fn busy_overlay_sits_over_mode() {
1225        let mut a = app(&[("main", true)]);
1226        a.mode = crate::tui::app::Mode::ConfirmRemove(0);
1227        a.begin_busy("Removing main");
1228        let text = render_to_text(&a, 100, 20);
1229        // The overlay is drawn last, over the confirm dialog.
1230        assert!(text.contains("working"));
1231        assert!(text.contains("Removing main"));
1232    }
1233}