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