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