Skip to main content

tix/
app.rs

1use std::cell::{Cell, RefCell};
2use std::io;
3use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use crossterm::event::{
7    self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind,
8    KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
9};
10use crossterm::execute;
11use crossterm::terminal::{
12    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
13};
14use ratatui::Terminal;
15use ratatui::backend::CrosstermBackend;
16use ratatui::layout::{Position, Rect};
17use ratatui::style::{Color, Modifier, Style};
18use ratatui::text::{Line, Span};
19use ratatui::widgets::{Block, Borders, Clear, Paragraph};
20
21use crate::assignee::{git_identity, resolve_me};
22use crate::commands::{self, mv};
23use crate::config::{BoardConfiguration, Config};
24use crate::error::{Result, TixError};
25use crate::keybindings::{EffectiveBindings, InputContext};
26use crate::model::{Issue, Priority, Status, today};
27use crate::planning::Planner;
28use crate::search::{self, Corpus, FilterField};
29use crate::storage::{IssueSelector, Store};
30
31use crate::editor;
32use crate::ui::board::{self, BoardState, HitTarget};
33use crate::ui::commandbar;
34use crate::ui::context_menu::{ContextMenu, MenuEvent, MenuItem, MenuItemKind};
35use crate::ui::date_picker::{DatePicker, DatePickerEvent, DatePickerHit};
36use crate::ui::detail::{self, DetailState};
37use crate::ui::dialog::{DialogEvent, RolloverDialog};
38use crate::ui::form::{self, FormState};
39use crate::ui::pickers::{DropdownState, PickerEvent, PickerItem, PickerMode, SelectionModel};
40use crate::ui::planning::{
41    PlanningForm, PlanningKind, PlanningModal, PlanningPrompt, PlanningRow, PlanningSegment,
42};
43use crate::ui::settings::{SettingsModal, SettingsPrompt};
44use crate::ui::tabs::BoardTabs;
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum Action {
48    None,
49    Quit,
50    OpenEditor(String),
51    OpenAgent(String),
52    /// Switch to a named board by tab index (mouse click on a tab, D5/D6).
53    SwitchBoard(usize),
54    /// Confirm-delete dialog Yes/No button clicked; maps to the `y` /
55    /// any-other-key branch of [`App::handle_confirm_delete_key`] (D6).
56    ConfirmDeleteYes,
57    ConfirmDeleteNo,
58    /// A filter-modal criterion row was clicked; jump focus there (D6).
59    FilterModalFocus(FilterField),
60    /// A settings-modal row was clicked; select it and run the Enter action.
61    SettingsRow(usize),
62    /// A date-picker day cell or control was clicked; maps to the equivalent
63    /// key branch (D6): day → select that date, Prev/Next → page the month,
64    /// Submit → Enter.
65    DatePickerHit(DatePickerHit),
66    /// The help overlay was clicked; close it like the `?`/`q`/Esc branch.
67    HelpClose,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum Mode {
72    Board,
73    /// The batch modal (WP07). The modal's state is in [`App::bulk`]; the mode says
74    /// only that it is open, exactly as `Mode::FilterModal` does.
75    BulkModal,
76    /// The triage inbox (WP06, T029). A modal over the board like settings and
77    /// the batch modal, so the board, its tabs and its card cursor are all
78    /// still there underneath and unchanged when it closes.
79    ///
80    /// The inbox's own cursor and selection live in [`App::triage`] rather than
81    /// in [`BoardState`], which is what makes "the normal board/detail
82    /// selection is unchanged when the inbox is not active" a property of
83    /// which struct each cursor is in rather than something a test has to
84    /// remember to restore.
85    TriageInbox,
86    RolloverDialog,
87    Detail,
88    Form,
89    ConfirmDelete,
90    MoveIntoDone {
91        target_col: usize,
92        buffer: String,
93    },
94    /// The blocked-move acknowledgement prompt (WP04, FR-004).
95    ///
96    /// Reached instead of applying a move toward the board's completion column
97    /// when the issue has open blockers. `warning` is the **shared** text from
98    /// `commands::blocked_note`, so the TUI says exactly what the CLI says.
99    ConfirmBlockedMove {
100        target_col: usize,
101        warning: String,
102    },
103    /// The reasoned-override prompt (WP05, FR-002, contract §3.3).
104    ///
105    /// Reached instead of applying a move that leaves a **terminal** category
106    /// when the board declares a `category_map`. `reason` is the **shared**
107    /// diagnostic from the service, so the TUI says exactly what the CLI says —
108    /// parity includes the message, not only the accept/refuse decision.
109    ///
110    /// This prompt is the whole reason WP05 exists on this path. Until it, a
111    /// terminal-exit transition was refused by the seam with a message naming
112    /// `tix move … --reason`, and the TUI had **no way to supply one** — the flag
113    /// is a CLI spelling and the TUI has no flags. So a user who moved an issue to
114    /// `done` in the TUI could not move it back, in the TUI, at all.
115    TransitionReason {
116        target_col: usize,
117        /// The refusal being overridden, rendered verbatim.
118        reason: String,
119        /// What the user has typed so far.
120        buffer: String,
121    },
122    Help {
123        return_to: Box<Mode>,
124    },
125    /// Issue context menu opened by right-clicking a card (D5); `return_to` is
126    /// the mode it was opened from (the board). Activation runs the exact
127    /// action the equivalent key binding would.
128    ContextMenu {
129        return_to: Box<Mode>,
130    },
131    Filter {
132        buffer: String,
133    },
134    FilterModal,
135    Settings,
136    PlanningManagement,
137    PlanningForm,
138    /// Calendar date picker popup; `return_to` is the modal it overlays.
139    DatePicker {
140        return_to: Box<Mode>,
141    },
142    Comment {
143        buffer: String,
144    },
145    Asset {
146        buffer: String,
147    },
148    EditLintWarning {
149        filename: String,
150        message: String,
151    },
152    /// Create a relationship from the detail view (#1359), in two steps.
153    ///
154    /// `Step::Target` asks for the other issue as a **selector** — the same string
155    /// `tix link <from> <to>` takes, so `#4`, `4`, or a title fragment all work and
156    /// an unresolvable one produces the CLI's own error rather than a TUI-specific
157    /// one. `Step::Kind` then asks for the kind, and it is **required**: C-001 forbids
158    /// an untypeable relationship, so there is no default to fall back to and an
159    /// empty or unrecognised answer is a refusal, not a guess.
160    LinkCreate {
161        buffer: String,
162        step: LinkStep,
163        /// The target resolved by the `Target` step, carried forward to `Kind`.
164        ///
165        /// Carried rather than re-resolved: the first step already applied the CLI's
166        /// selector rules and reported its errors, and re-reading the buffer's
167        /// meaning a second time would be a second place for the two to disagree. A
168        /// `Kind` step with no target cannot happen — the mode is only entered in
169        /// that state with one.
170        target: Option<u32>,
171    },
172    /// Re-kind or retarget the relationship under the detail pane's cursor.
173    ///
174    /// Carries the target it was opened with so the kind step can submit an edit
175    /// rather than a create: `tix link edit <id>` is an edit precisely because the
176    /// stable id survives a re-kind, and a create-then-remove pair would lose it.
177    LinkEdit {
178        index: usize,
179        buffer: String,
180        step: LinkStep,
181        /// The new target, when the user supplied one. `None` means **keep the
182        /// existing target** and change only the kind — the same as `tix link edit
183        /// <id> --type …` with no `--to`.
184        target: Option<u32>,
185        /// The new kind, when the user supplied one. `None` likewise means keep.
186        kind: Option<crate::relationships::RelationKind>,
187    },
188    /// Confirm removing the relationship under the cursor.
189    ///
190    /// A relationship is stored in exactly one issue's file, so removing it is a
191    /// write to another issue from the one on screen. It gets a confirmation for the
192    /// same reason `ConfirmDelete` does, and the confirmation names the edge.
193    ConfirmRemoveRelationship {
194        index: usize,
195    },
196}
197
198/// Which half of a two-step relationship prompt is being answered.
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
200pub enum LinkStep {
201    /// The other issue, as a selector.
202    Target,
203    /// The relationship kind. **Required** (C-001).
204    Kind,
205}
206
207/// A rectangular click target recorded by a widget during drawing (D3), so the
208/// same frame's pointer position can be resolved to an [`Action`] without the
209/// widget knowing anything about mouse handling.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub struct HitRegion {
212    pub rect: Rect,
213    pub action: Action,
214}
215
216/// Origin of a mouse press (D4): which column and (when the press landed on a
217/// card) which card it started on, plus a timestamp used for the double-click
218/// window. Kept by [`MouseState::last_down`] until a second press on the same
219/// card, a drag, or the next distinct target replaces it.
220#[derive(Debug)]
221struct MouseDown {
222    col: usize,
223    card: Option<usize>,
224    at: Instant,
225}
226
227/// An in-flight drag (D4): the pressed card's origin, so an `Up` can compare
228/// the drop target against it and no-op for same-column / non-card drops.
229#[derive(Debug)]
230struct Drag {
231    col: usize,
232    card: Option<usize>,
233}
234
235/// Mouse interaction state driven by `handle_board_mouse` (D4). A `Down`
236/// records the press origin, `Drag` frames promote it into an active drag (no
237/// board mutation while dragging), and the resolving `Up` decides between a
238/// plain click / double-click / drag-drop.
239#[derive(Debug)]
240pub struct MouseState {
241    last_down: Option<MouseDown>,
242    drag: Option<Drag>,
243    pub double_click_window: Duration,
244}
245
246impl Default for MouseState {
247    fn default() -> Self {
248        MouseState {
249            last_down: None,
250            drag: None,
251            double_click_window: Duration::from_millis(500),
252        }
253    }
254}
255
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257enum FormPickerField {
258    Labels,
259    Assignee,
260    Cycles,
261    Milestones,
262    Reviewer,
263    /// The parent issue (WP06 / T026). Single-select, per C-001: one parent,
264    /// never a list, so this is `DropdownState::single` below and never `multi`.
265    Parent,
266}
267
268#[derive(Debug, Clone)]
269struct FormPicker {
270    field: FormPickerField,
271    state: DropdownState,
272}
273
274/// Rows a mouse-wheel notch scrolls, in every surface that scrolls.
275///
276/// One constant rather than a number per handler: the detail pane and the keybindings
277/// overlay (and anything else that scrolls later) move by the same amount, or the
278/// wheel means something different depending on where the pointer happens to be.
279const WHEEL_ROWS: u16 = 5;
280
281/// How much of the keybindings overlay fits, and how far it can scroll (tix #1441).
282///
283/// Recorded by the overlay's own draw: its *handlers* need what only the renderer
284/// knows, which is why the two numbers travel back through a `Cell` rather than being
285/// recomputed from the key that was pressed.
286#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
287struct HelpMetrics {
288    /// Binding rows the overlay can show at once.
289    visible: u16,
290    /// The largest `help_scroll` that still shows the last row.
291    max_scroll: u16,
292}
293
294/// Independent toggles are what a TUI session's state *is* — the mode lives in `Mode`,
295/// and the flags beside it (archived shown, help line open, one-shot exit) are not
296/// states a machine would model, they are switches the user throws. Four is not a
297/// design smell here; a struct with a mode enum and a handful of toggles is the shape.
298#[allow(clippy::struct_excessive_bools)]
299pub struct App {
300    pub mode: Mode,
301    pub board: BoardState,
302    pub detail: Option<DetailState>,
303    pub form: Option<FormState>,
304    pub confirm_delete_target: Option<String>,
305    pub store: Store,
306    pub status_msg: Option<String>,
307    pub quit: bool,
308    pub bindings: EffectiveBindings,
309    /// The active named board configuration index into `Config::configs()`
310    /// (session state only — never persisted). `rebuild_board`/`apply_filter`
311    /// must not reset it.
312    pub active_config: usize,
313    /// Persistent tabs bar of all named boards (DM-01M2MH8J01DZHV3JMT76XX38KX).
314    pub tabs: BoardTabs,
315    filter: Option<String>,
316    corpus: Corpus,
317    /// Whether the board is showing **archived** issues too (tix #1492).
318    ///
319    /// Off by default, and that default is the point: the board and the corpus are
320    /// built from [`crate::storage::Store::list`], which drops a dot-prefixed file
321    /// from the directory entry without opening it, so a repository with thousands of
322    /// archived issues costs nothing to open. Toggling this on is the "reload them on
323    /// demand" half — `refresh_corpus` re-reads the issue set with the archived files
324    /// included and rebuilds the board around it.
325    pub show_archived: bool,
326    /// How many rows the keybindings overlay is scrolled down (tix #1441).
327    ///
328    /// Reset when the overlay opens, so `?` always starts at the top: a help screen
329    /// that reappears halfway down, from a scroll three sessions ago, would hide the
330    /// first bindings from exactly the person who needed them.
331    help_scroll: u16,
332    /// The keybindings overlay's `/` query, and whether the input line has the
333    /// keyboard (tix #1496).
334    ///
335    /// Cleared when the overlay opens, like the scroll offset and for the same reason:
336    /// a help screen that reappeared filtered by a query from ten minutes ago would
337    /// hide the binding the reader opened it to find.
338    help_query: String,
339    help_searching: bool,
340    /// The overlay's last-rendered geometry, recorded by its own draw (tix #1441).
341    ///
342    /// A `Cell` for the same reason `frame_hit_regions` is a `RefCell`: the renderer
343    /// takes `&App`, and it is the only thing that knows how many rows fit.
344    help_metrics: std::cell::Cell<HelpMetrics>,
345    /// Cards one screen of the active board column holds, as of the last draw
346    /// (tix #1442 follow-up).
347    ///
348    /// Reported by the board renderer, which is the only thing that knows the column's
349    /// height and each card's rendered height; a `Cell` for the same reason
350    /// `help_metrics` is one — the renderer holds `&App`.
351    board_page: std::cell::Cell<usize>,
352    pub filter_modal: Option<crate::ui::filter_modal::FilterModal>,
353    /// Settings modal (board/column configuration via `s`).
354    pub settings: Option<SettingsModal>,
355    /// Planning modal (cycle/milestone management via `m`).
356    pub planning: Option<PlanningModal>,
357    /// Create/edit form for a planning item.
358    pub planning_form: Option<PlanningForm>,
359    /// Open calendar date picker (overlaid on the form that opened it).
360    pub date_picker: Option<DatePicker>,
361    /// Committed structured filter (kept when the modal is closed). The live
362    /// in-progress criteria come from `filter_modal` while it is open.
363    structured: search::StructuredFilter,
364    rollover_dialog: Option<RolloverDialog>,
365    form_picker: Option<FormPicker>,
366    /// One-shot render (`tix render`): suppresses the keyboard hint bar.
367    one_shot: bool,
368    /// Click targets recorded by widgets during the current draw; cleared at
369    /// the start of every draw and resolved by [`App::resolve_region`] when a
370    /// pointer event arrives (D3). `RefCell` lets the &-borrowing render pass
371    /// record regions while `handle_mouse` resolves them later (D6).
372    frame_hit_regions: RefCell<Vec<HitRegion>>,
373    /// Mouse click/drag state (D4).
374    mouse: MouseState,
375    /// Open issue context menu (D5): `Some` exactly while `mode` is
376    /// [`Mode::ContextMenu`].
377    context_menu: Option<ContextMenu>,
378    /// The batch modal (WP07), `Some` exactly while `mode` is
379    /// [`Mode::BulkModal`].
380    pub bulk: Option<crate::ui::bulk::BulkModal>,
381    /// The triage inbox (WP06), `Some` exactly while `mode` is
382    /// [`Mode::TriageInbox`]. Its cursor and selection are its own — see the
383    /// note on that mode.
384    pub triage: Option<crate::ui::triage::TriageState>,
385    /// Column currently shown as the drag-drop target while a card is held.
386    drag_preview_col: Option<usize>,
387    /// The terminal size of the most recent draw, remembered so pointer
388    /// coordinates can be mapped onto the board render area (D3).
389    frame_size: Cell<Rect>,
390}
391
392impl App {
393    /// Record a disclosure raised by a write, alongside whatever confirmation the
394    /// action already produced.
395    ///
396    /// Cycle-4 F4. The event loop drains at the *top* of the loop body, so a
397    /// disclosure raised by `handle_key` at the bottom would overwrite that
398    /// action's confirmation message before any frame ever drew it — the user
399    /// moved an issue and never saw "Moved #1 to in progress". Composing is the
400    /// fix: both facts are true, and both are worth showing.
401    ///
402    /// The disclosure is appended rather than prepended so the confirmation reads
403    /// first, which is the order the user caused them in. The existing message
404    /// is left untouched, so this cannot swallow anything either.
405    pub fn note_disclosure(&mut self, disclosure: String) {
406        // Disclosure first, then the confirmation. Order alone does **not** make
407        // it fit: the status area is a single unwrapped row, so at 80 columns a
408        // disclosure plus a long filename is cut regardless of order. Cycle 6
409        // measured this and found the claim previously recorded here — that
410        // reordering made the actionable tail survive — to be false at 80, 100
411        // and 120 columns.
412        //
413        // What actually helps is that the disclosure now *leads with the action*
414        // ("remove `links:` by hand to silence"), so the instruction is the part
415        // that survives a long filename, plus an ellipsis at the render site so a
416        // cut is visible rather than silent. The full text remains in
417        // `tix lint`, which is the durable channel for it.
418        self.status_msg = Some(match self.status_msg.take() {
419            Some(existing) if !existing.is_empty() => format!("{disclosure} — {existing}"),
420            _ => disclosure,
421        });
422    }
423
424    pub fn new(config: &Config) -> Result<Self> {
425        let store = Store::open(config)?;
426        let tabs = BoardTabs::new(config.configs().map(|(name, _)| name.to_string()).collect());
427        let board = BoardState::build(&store, config.default_config());
428        let corpus = Corpus::build(&store)?;
429        let planner = Planner::from_config(config.clone(), std::path::PathBuf::from("."));
430        let rollover_plan = planner.rollover_plan(&store, &today());
431        let rollover_dialog = if rollover_plan.is_empty() {
432            None
433        } else {
434            Some(RolloverDialog::new(
435                rollover_plan
436                    .into_iter()
437                    .map(|p| (p.from_cycle, p.to_cycle, p.issues.len()))
438                    .collect(),
439            ))
440        };
441        Ok(App {
442            mode: if rollover_dialog.is_some() {
443                Mode::RolloverDialog
444            } else {
445                Mode::Board
446            },
447            board,
448            // The default board is the active set; archived issues are read only
449            // when the toggle asks for them (tix #1492).
450            show_archived: false,
451            help_scroll: 0,
452            help_query: String::new(),
453            help_searching: false,
454            help_metrics: std::cell::Cell::new(HelpMetrics::default()),
455            board_page: std::cell::Cell::new(0),
456            detail: None,
457            form: None,
458            confirm_delete_target: None,
459            store,
460            status_msg: None,
461            quit: false,
462            bindings: config.effective_keybindings()?,
463            active_config: 0,
464            tabs,
465            filter: None,
466            corpus,
467            filter_modal: None,
468            settings: None,
469            planning: None,
470            planning_form: None,
471            date_picker: None,
472            structured: search::StructuredFilter::default(),
473            rollover_dialog,
474            form_picker: None,
475            one_shot: false,
476            frame_hit_regions: RefCell::new(Vec::new()),
477            mouse: MouseState::default(),
478            context_menu: None,
479            bulk: None,
480            triage: None,
481            drag_preview_col: None,
482            frame_size: Cell::new(Rect::new(0, 0, 0, 0)),
483        })
484    }
485
486    /// The active `BoardConfiguration` indexed by [`App::active_config`].
487    fn active_board_config(&self) -> &BoardConfiguration {
488        self.store
489            .config
490            .configs()
491            .nth(self.active_config)
492            .map(|(_, board)| board)
493            .unwrap_or_else(|| self.store.config.default_config())
494    }
495
496    /// The category resolution for the board the TUI is currently showing.
497    ///
498    /// WP05. Derived from [`Self::active_board_config`], so it is the resolution of
499    /// the board on screen rather than of whichever board is `default` — the TUI can
500    /// display any configured board, and a card showing another board's categories is
501    /// a wrong answer rather than a missing one.
502    fn workflow_resolution(&self) -> crate::workflow::CategoryResolution {
503        let name = self
504            .store
505            .config
506            .configs()
507            .nth(self.active_config)
508            .map(|(name, _)| name)
509            .unwrap_or("default");
510        crate::workflow::resolution(&self.store.config, self.active_board_config(), name)
511    }
512
513    /// The category label and color hint for `issue`, as the detail view shows them.
514    ///
515    /// WP05 T023. Resolved through the service against the board on screen, and
516    /// derived rather than stored — an issue's category changes when its column
517    /// changes or when the board's `category_map` is edited, and a value captured
518    /// when the detail was opened would be stale for exactly as long as the view
519    /// stayed open.
520    fn category_of_issue(
521        &self,
522        issue: &crate::model::Issue,
523    ) -> Option<(String, Option<crate::workflow::ColorHint>)> {
524        let resolution = self.workflow_resolution();
525        crate::workflow::category_of(issue, &resolution)
526            .map(|c| (c.label().to_string(), c.color_hint()))
527    }
528
529    pub fn handle_key(&mut self, key: KeyEvent) -> Result<Action> {
530        if key.kind != KeyEventKind::Press {
531            return Ok(Action::None);
532        }
533        let context = self.input_context();
534        let key = self
535            .bindings
536            .action(context, key)
537            .map(|action| self.bindings.canonical_event(context, action))
538            .unwrap_or_else(|| {
539                if Self::allows_text_fallback(context) {
540                    key
541                } else {
542                    KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)
543                }
544            });
545        self.handle_resolved_key(context, key)
546    }
547
548    fn allows_text_fallback(context: InputContext) -> bool {
549        matches!(
550            context,
551            InputContext::Form
552                | InputContext::FormPicker
553                | InputContext::Filter
554                | InputContext::FilterModal
555                // #1496: the overlay's `/` line takes free text. Without this, every
556                // letter arrives as `F(12)` — the rewrite happens before the handler sees
557                // the key — and the search line would be untypeable. The *list* mode is
558                // deliberately not listed here: it has no text entry, so an unbound key
559                // there is a no-op either way.
560                | InputContext::HelpSearch
561                | InputContext::PlanningForm
562                | InputContext::Comment
563                | InputContext::Asset
564                | InputContext::MoveIntoDone
565                | InputContext::LinkPrompt
566                | InputContext::SettingsPrompt
567                | InputContext::BulkModal
568                | InputContext::Triage
569        )
570    }
571
572    fn input_context(&self) -> InputContext {
573        match &self.mode {
574            Mode::Board => InputContext::Board,
575            Mode::RolloverDialog => InputContext::Rollover,
576            Mode::Detail => InputContext::Detail,
577            Mode::Form if self.form_picker.is_some() => InputContext::FormPicker,
578            Mode::Form => InputContext::Form,
579            Mode::ConfirmDelete => InputContext::ConfirmDelete,
580            Mode::MoveIntoDone { .. } => InputContext::MoveIntoDone,
581            Mode::TransitionReason { .. } => InputContext::MoveIntoDone,
582            Mode::ConfirmBlockedMove { .. } => InputContext::ConfirmBlockedMove,
583            Mode::Help { .. } if self.help_searching => InputContext::HelpSearch,
584            Mode::Help { .. } => InputContext::Help,
585            Mode::ContextMenu { .. } => InputContext::ContextMenu,
586            Mode::Filter { .. } => InputContext::Filter,
587            Mode::FilterModal => InputContext::FilterModal,
588            Mode::Settings
589                if self
590                    .settings
591                    .as_ref()
592                    .is_some_and(|modal| modal.prompt.is_some()) =>
593            {
594                InputContext::SettingsPrompt
595            }
596            Mode::Settings => InputContext::Settings,
597            Mode::PlanningManagement
598                if self
599                    .planning
600                    .as_ref()
601                    .is_some_and(|modal| modal.prompt.is_some()) =>
602            {
603                InputContext::PlanningConfirm
604            }
605            Mode::PlanningManagement => InputContext::PlanningManagement,
606            Mode::PlanningForm => InputContext::PlanningForm,
607            Mode::DatePicker { .. } => InputContext::DatePicker,
608            Mode::Comment { .. } => InputContext::Comment,
609            Mode::Asset { .. } => InputContext::Asset,
610            Mode::EditLintWarning { .. } => InputContext::EditWarning,
611            Mode::LinkCreate { .. } | Mode::LinkEdit { .. } => InputContext::LinkPrompt,
612            Mode::ConfirmRemoveRelationship { .. } => InputContext::ConfirmDelete,
613            Mode::BulkModal => InputContext::BulkModal,
614            Mode::TriageInbox => InputContext::Triage,
615        }
616    }
617
618    /// The batch modal's keys (WP07 T032/T033).
619    ///
620    /// Every branch here either changes the modal's **own** state or calls a shared
621    /// service. There is no `IssuePatch` built here and no `store.update` here: the
622    /// modal holds `PatchFlags` and the plan/apply live in `bulk::plan`/`bulk::apply`,
623    /// which is what "reject TUI-local patch construction or direct writes" asks for.
624    ///
625    /// `p` previews, `y` **arms** and then `y` again confirms, and `esc` cancels from
626    /// any state. Two `y` presses because "I saw it" and "I approved it" are two
627    /// decisions, and one `y` conflates them.
628    /// Open the batch modal, optionally marking the card under the cursor first.
629    ///
630    /// `mark` is what makes `space` on a card the first press rather than two. It
631    /// marks **only if** the selection is not already refused — a duplicate or a
632    /// full selection is reported on the modal's note, not silently swallowed, so
633    /// the second `space` on the same card tells the user why nothing changed.
634    ///
635    /// The board's `(active_col, selected)` cursor is never written here, so opening
636    /// and cancelling the modal restores the board exactly (T031).
637    fn open_bulk_modal(&mut self, action: crate::ui::bulk::BatchAction, mark: bool) {
638        if mark {
639            let number = self.board.get_selected_card().map(|c| c.number);
640            let mut modal = self
641                .bulk
642                .take()
643                .unwrap_or_else(|| crate::ui::bulk::BulkModal::new(action));
644            if let Some(number) = number {
645                if let Err(problem) = modal.selection.toggle(number) {
646                    modal.note = Some(problem.to_string());
647                }
648            }
649            self.bulk = Some(modal);
650        } else if self.bulk.is_none() {
651            self.bulk = Some(crate::ui::bulk::BulkModal::new(action));
652        }
653        self.mode = Mode::BulkModal;
654    }
655
656    /// Open the triage inbox (`t`).
657    ///
658    /// A refusal — no `[triage]` section in this project, or an I/O failure —
659    /// is reported on the status line and **nothing else changes**: a project
660    /// that does not use triage leaves the board exactly where it was, which is
661    /// T029's "navigation is unchanged when the tab is not active" from the
662    /// other side.
663    fn open_triage_inbox(&mut self) -> Result<Action> {
664        match crate::ui::triage::TriageState::open(&self.store) {
665            Ok(state) => {
666                self.triage = Some(state);
667                self.mode = Mode::TriageInbox;
668            }
669            Err(e) => self.status_msg = Some(e.to_string()),
670        }
671        Ok(Action::None)
672    }
673
674    /// The inbox's keys (WP06, T029–T032).
675    ///
676    /// Two layers, exactly as the batch modal has one: an open decision modal
677    /// takes the key first, and the list has it otherwise. Every branch here
678    /// either moves a cursor that belongs to *this* view or calls a shared
679    /// service — no `IssuePatch`, no `store.update`, and no status compared
680    /// against a literal. Cancellation drops the modal and changes nothing else;
681    /// the queue is re-read only after an apply has actually run.
682    fn handle_triage_key(&mut self, key: KeyEvent) -> Result<Action> {
683        use crate::ui::triage::ModalState;
684
685        let Some(mut state) = self.triage.take() else {
686            self.mode = Mode::Board;
687            return Ok(Action::None);
688        };
689
690        let editing = state.modal.as_ref().is_some_and(|m| m.editing);
691        let armed = state.modal.as_ref().is_some_and(|m| m.is_armed());
692        let previewed = matches!(
693            state.modal.as_ref().map(|m| &m.state),
694            Some(ModalState::Preview(_))
695        );
696        let mut closed = false;
697        let mut wrote = false;
698
699        if state.modal.is_some() {
700            match key.code {
701                // `esc` while a field is being typed reverts the draft rather
702                // than closing — the footer says "esc revert", and a revert
703                // that closed the modal would take the row it reverted with it.
704                KeyCode::Esc if editing => {
705                    if let Some(modal) = state.modal.as_mut() {
706                        modal.abort_edit();
707                    }
708                }
709                // Cancels from every state: the plan and the draft go, the
710                // committed fields stay, and no file moved (T032). The report
711                // goes with the user when it is a refusal — a refusal nobody
712                // can re-read after pressing `esc` is not a report.
713                KeyCode::Esc => {
714                    let carried = match state.modal.as_ref().map(|m| &m.state) {
715                        Some(ModalState::Done(text)) | Some(ModalState::Refused(text)) => {
716                            Some(text.lines().next().unwrap_or(text.as_str()).to_string())
717                        }
718                        _ => None,
719                    };
720                    state.close_modal();
721                    if carried.is_some() {
722                        state.note = carried;
723                    }
724                }
725                KeyCode::Enter if editing => {
726                    if let Some(modal) = state.modal.as_mut() {
727                        modal.commit_edit();
728                    }
729                }
730                KeyCode::Enter => {
731                    if let Some(modal) = state.modal.as_mut() {
732                        modal.begin_edit();
733                    }
734                }
735                KeyCode::Backspace if editing => {
736                    if let Some(modal) = state.modal.as_mut() {
737                        modal.pop_char();
738                    }
739                }
740                KeyCode::Char(c) if editing => {
741                    if let Some(modal) = state.modal.as_mut() {
742                        modal.push_char(c);
743                    }
744                }
745                // The cursor does not move while a field is being typed:
746                // `commit_edit` writes the draft to the row the cursor is on,
747                // so a cursor that could travel mid-edit would file half a
748                // reason under the wrong label.
749                KeyCode::Tab | KeyCode::Down | KeyCode::Char('j') if !editing => {
750                    if let Some(modal) = state.modal.as_mut() {
751                        modal.move_cursor(1);
752                    }
753                }
754                KeyCode::BackTab | KeyCode::Up | KeyCode::Char('k') if !editing => {
755                    if let Some(modal) = state.modal.as_mut() {
756                        modal.move_cursor(-1);
757                    }
758                }
759                // The target row, when the decision has one — the same `t` the
760                // batch modal gives its pair target.
761                KeyCode::Char('t') if !editing => {
762                    if let Some(modal) = state.modal.as_mut() {
763                        let wanted = if modal.kind == crate::triage::DecisionKind::Accept {
764                            crate::ui::triage::DecisionField::Target
765                        } else {
766                            crate::ui::triage::DecisionField::DuplicateOf
767                        };
768                        if let Some(index) = modal.fields.iter().position(|f| *f == wanted) {
769                            modal.cursor = index;
770                        }
771                    }
772                }
773                // Cycle the verb. The selection and the already-typed fields
774                // survive it, for the reason the batch modal's `a` keeps the
775                // selection: a user who marked ten does not re-mark them to
776                // change their mind about what to do with them.
777                KeyCode::Char('a') => {
778                    if let Some(modal) = state.modal.as_mut() {
779                        modal.cycle_kind();
780                    }
781                }
782                KeyCode::Char('p') => state.preview(&self.store)?,
783                KeyCode::Char('y') => {
784                    if armed {
785                        state.confirm(&self.store)?;
786                        wrote = state.modal_finished();
787                    } else if previewed {
788                        state.confirm(&self.store)?;
789                    } else {
790                        state.note = Some("press `p` to preview first".to_string());
791                    }
792                }
793                _ => {}
794            }
795            if wrote {
796                // The plan ran, so the counts behind the modal are stale. Re-read
797                // them now; the board underneath is refreshed when the inbox
798                // closes.
799                state.refresh(&self.store)?;
800            }
801        } else {
802            match key.code {
803                KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => closed = true,
804                KeyCode::Up | KeyCode::Char('k') => state.move_cursor(-1),
805                KeyCode::Down | KeyCode::Char('j') => state.move_cursor(1),
806                KeyCode::Char(' ') => state.toggle_current(),
807                KeyCode::Char('X') => state.clear_selection(),
808                // Oldest first, the TUI's `--age`. Toggling re-reads the queue
809                // rather than re-sorting a snapshot, so the ages cannot be
810                // computed against two different `today`s.
811                KeyCode::Char('A') => {
812                    state.by_age = !state.by_age;
813                    state.refresh(&self.store)?;
814                }
815                KeyCode::Char('a') => {
816                    state.open_single(crate::triage::DecisionKind::Accept);
817                }
818                KeyCode::Char('d') => {
819                    state.open_single(crate::triage::DecisionKind::Decline);
820                }
821                KeyCode::Char('u') => {
822                    state.open_single(crate::triage::DecisionKind::Duplicate);
823                }
824                KeyCode::Char('f') => {
825                    state.open_single(crate::triage::DecisionKind::Defer);
826                }
827                // The batch modal over everything marked; `a` inside it picks
828                // the decision, exactly as the batch modal's `a` picks its
829                // action.
830                KeyCode::Char('p') => {
831                    state.open_batch(crate::triage::DecisionKind::Accept);
832                }
833                _ => {}
834            }
835        }
836
837        if closed {
838            // The note goes to the status line rather than being dropped with
839            // the view — the batch modal's handler does the same thing for the
840            // same reason.
841            let note = state.note.clone();
842            self.triage = None;
843            self.mode = Mode::Board;
844            self.rebuild_board();
845            if note.is_some() {
846                self.status_msg = note;
847            }
848            return Ok(Action::None);
849        }
850
851        self.triage = Some(state);
852        self.mode = Mode::TriageInbox;
853        Ok(Action::None)
854    }
855
856    fn handle_bulk_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
857        use crate::ui::bulk::{BulkFocus, BulkState};
858        let Some(mut modal) = self.bulk.take() else {
859            self.mode = Mode::Board;
860            return Ok(Action::None);
861        };
862        // The board's cards, in board order — what the user can see, which is what
863        // they can have marked.
864        let issues = crate::ui::bulk::visible_issues(&self.board, &self.store)?;
865        let result = if modal.focus == BulkFocus::Edit {
866            // Editing: every key is text. No command is reachable here, and that is
867            // the point — one channel cannot be both a command set and a text field.
868            match key.code {
869                KeyCode::Esc => {
870                    modal.abort_edit();
871                    Ok(())
872                }
873                KeyCode::Enter => modal.commit_edit(),
874                KeyCode::Backspace => {
875                    modal.draft.pop();
876                    Ok(())
877                }
878                KeyCode::Char(c) => {
879                    modal.draft.push(c);
880                    Ok(())
881                }
882                _ => Ok(()),
883            }
884        } else {
885            match key.code {
886                KeyCode::Esc => {
887                    // Closes the modal, as every other modal here does on `esc`. The
888                    // selection goes with it: `esc` is a cancel, and a cancel that
889                    // leaves a half-built selection behind has not cancelled.
890                    self.bulk = None;
891                    self.mode = Mode::Board;
892                    self.rebuild_board();
893                    return Ok(Action::None);
894                }
895                KeyCode::Tab | KeyCode::Down => {
896                    modal.move_cursor(1);
897                    Ok(())
898                }
899                KeyCode::BackTab | KeyCode::Up => {
900                    modal.move_cursor(-1);
901                    Ok(())
902                }
903                KeyCode::Enter => {
904                    if modal.row_names().is_empty() {
905                        // An archive has no rows, so `enter` previews rather than
906                        // entering nothing.
907                        modal.preview(&self.store, &issues)
908                    } else {
909                        modal.begin_edit();
910                        Ok(())
911                    }
912                }
913                KeyCode::Char('p') => modal.preview(&self.store, &issues),
914                KeyCode::Char('y') => {
915                    if modal.is_armed() {
916                        modal.confirm(&self.store)
917                    } else {
918                        modal.arm();
919                        Ok(())
920                    }
921                }
922                // The card under the **board** cursor, which is visible underneath
923                // the modal. `X` empties the whole selection; `space` and `x` act on
924                // one card. The board arms are the same three keys, so there is one
925                // vocabulary and not two.
926                KeyCode::Char('X') => {
927                    modal.selection.clear();
928                    modal.mark_stale();
929                    Ok(())
930                }
931                KeyCode::Char(' ') | KeyCode::Char('x') => {
932                    match self.board.get_selected_card().map(|c| c.number) {
933                        None => Ok(()),
934                        Some(card) => {
935                            let outcome = if key.code == KeyCode::Char(' ') {
936                                modal.selection.toggle(card).map(|_| true)
937                            } else if modal.selection.remove(card) {
938                                Ok(true)
939                            } else {
940                                Err(crate::ui::bulk::SelectionProblem::AlreadySelected)
941                            };
942                            match outcome {
943                                Ok(_) => {
944                                    modal.mark_stale();
945                                    Ok(())
946                                }
947                                Err(problem) => {
948                                    modal.note = Some(if key.code == KeyCode::Char('x') {
949                                        // Unmarking something not marked is the
950                                        // *desired* end state, so it reports rather
951                                        // than errors.
952                                        format!("#{card} was not selected")
953                                    } else {
954                                        problem.to_string()
955                                    });
956                                    Ok(())
957                                }
958                            }
959                        }
960                    }
961                }
962                KeyCode::Char('a') => {
963                    // Cycle the action. The selection and the flags survive it: a user
964                    // who marked ten cards does not re-mark them to change their mind
965                    // about the verb.
966                    modal.action = modal.action.next();
967                    modal.state = BulkState::Selecting;
968                    modal.plan_stale = true;
969                    modal.cursor = 0;
970                    Ok(())
971                }
972                KeyCode::Char('t') => {
973                    // A pair's target defaults to the card the user is standing on,
974                    // which is the other end of a link nine times in ten. It is a
975                    // **default**, not a commitment: the `target` row is editable.
976                    if let Some(card) = self.board.get_selected_card() {
977                        modal.target = Some(card.number.to_string());
978                        modal.mark_stale();
979                    }
980                    Ok(())
981                }
982                KeyCode::Char('c') => {
983                    // `--clear` on the row under the cursor: the **`clear` row** is
984                    // the one that carries the flag, never the cursor's own row. On
985                    // the `clear` row it re-applies what is already named
986                    // (idempotent); on any other row it names that row. `set_row`
987                    // validates, so a non-clearable field (`status`) is a refusal
988                    // rather than a flag the apply would ignore.
989                    //
990                    // The first version of this arm passed `modal.cursor` where the
991                    // clear row's index belongs, so `c` on `priority` stored the
992                    // word "priority" as the priority, left `flags.clear` empty, and
993                    // made the next preview refuse with `invalid priority
994                    // 'priority'`. It was the only key of this keymap with no test.
995                    let names = modal.row_names();
996                    match names.iter().position(|row| *row == "clear") {
997                        None => {
998                            // A pair has a target and a kind, an archive has no rows
999                            // at all: there is no field to empty, and inventing one
1000                            // would offer a flag the apply cannot carry.
1001                            modal.note = Some(format!(
1002                                "a {} has no clearable fields — nothing to clear",
1003                                modal.action.as_str()
1004                            ));
1005                            Ok(())
1006                        }
1007                        Some(clear_row) => {
1008                            let name = modal.row_name(modal.cursor);
1009                            if name == "clear" {
1010                                let current = modal.row_value(clear_row);
1011                                if current == "—" {
1012                                    // Nothing is named, so there is nothing to
1013                                    // re-apply. The em dash the renderer shows for an
1014                                    // absent value is not a field name and must not
1015                                    // reach `set_row` as one.
1016                                    modal.note = Some(
1017                                        "no field is named — press c on the field you \
1018                                         want to empty"
1019                                            .to_string(),
1020                                    );
1021                                    Ok(())
1022                                } else {
1023                                    modal.set_row(clear_row, current)
1024                                }
1025                            } else {
1026                                modal.set_row(clear_row, name.to_string())
1027                            }
1028                        }
1029                    }
1030                }
1031                _ => Ok(()),
1032            }
1033        };
1034        if let Err(e) = &result {
1035            modal.note = Some(e.to_string());
1036        }
1037        // The modal closes on a **completed** apply, and on nothing else. A refusal
1038        // stays on screen with its reasons, because a refusal the user cannot read is
1039        // not a report — and WP06 already froze the rule that a refusal is a completed
1040        // command that reports non-zero, not an `Err`.
1041        let finished = matches!(modal.state, BulkState::Done(..)) && result.is_ok();
1042        self.bulk = Some(modal);
1043        if finished {
1044            // The modal is about to be dropped, so its note would be dropped with it.
1045            // The board's status line is where a result has to land.
1046            if let Some(note) = self.bulk.as_ref().and_then(|m| m.note.clone()) {
1047                self.status_msg = Some(note);
1048            }
1049            self.mode = Mode::Board;
1050            self.rebuild_board();
1051        } else {
1052            self.mode = Mode::BulkModal;
1053        }
1054        // …and the same rule one level up. `run` propagates an `Err` out of
1055        // `handle_key` with `?`, so returning one here ends the session and takes the
1056        // note this function just recorded with it. Every other dispatch in this file
1057        // reports a user's refusal through `status_msg` and returns `Ok`; this one
1058        // used to return it, so `c` on the `status` row — or, with no `c` involved at
1059        // all, `enter` on the `clear` row with `title` typed in — printed
1060        // `cannot clear …` and quit the TUI. `visible_issues` above still returns
1061        // `Err`: that is an I/O failure behind the keyboard, not a request the user
1062        // made and was refused.
1063        //
1064        // Read by `finished` above, and deliberately not consumed: a refusal is a
1065        // completed command that reports.
1066        let _ = result;
1067        Ok(Action::None)
1068    }
1069
1070    fn handle_resolved_key(&mut self, _context: InputContext, key: KeyEvent) -> Result<Action> {
1071        match self.mode.clone() {
1072            Mode::Board => self.handle_board_key(key),
1073            Mode::RolloverDialog => self.handle_rollover_dialog_key(key),
1074            Mode::Detail => self.handle_detail_key(key),
1075            Mode::Form => self.handle_form_key(key),
1076            Mode::ConfirmDelete => self.handle_confirm_delete_key(key),
1077            Mode::MoveIntoDone { target_col, .. } => {
1078                self.handle_move_into_done_key(key, target_col)
1079            }
1080            Mode::TransitionReason { target_col, .. } => {
1081                self.handle_transition_reason_key(key, target_col)
1082            }
1083            Mode::ConfirmBlockedMove {
1084                target_col,
1085                warning,
1086            } => self.handle_confirm_blocked_move_key(key, target_col, &warning),
1087            Mode::Help { .. } => self.handle_help_key(key),
1088            Mode::ContextMenu { .. } => self.handle_context_menu_key(key),
1089            Mode::Filter { .. } => self.handle_filter_key(key),
1090            Mode::FilterModal => self.handle_filter_modal_key(key),
1091            Mode::BulkModal => self.handle_bulk_modal_key(key),
1092            Mode::TriageInbox => self.handle_triage_key(key),
1093            Mode::Settings => self.handle_settings_key(key),
1094            Mode::PlanningManagement => self.handle_planning_key(key),
1095            Mode::PlanningForm => self.handle_planning_form_key(key),
1096            Mode::DatePicker { .. } => self.handle_date_picker_key(key),
1097            Mode::Comment { .. } => self.handle_comment_key(key),
1098            Mode::Asset { .. } => self.handle_asset_key(key),
1099            Mode::EditLintWarning { .. } => self.handle_edit_lint_warning_key(key),
1100            Mode::LinkCreate { .. } => self.handle_link_prompt_key(key),
1101            Mode::LinkEdit { .. } => self.handle_link_prompt_key(key),
1102            Mode::ConfirmRemoveRelationship { .. } => self.handle_remove_relationship_key(key),
1103        }
1104    }
1105
1106    /// Route a [`MouseEvent`] into the active mode's pointer handler (D2),
1107    /// mirroring [`App::handle_key`]: one dispatch trunk per mode, so mouse and
1108    /// keyboard share the same single-mode mental model. Modes that declare no
1109    /// mouse behavior fall through to a no-op. Per-mode handlers land with the
1110    /// click/hover/dialog tasks.
1111    pub fn handle_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1112        let mut action = match self.mode.clone() {
1113            Mode::Board => self.handle_board_mouse(mouse),
1114            Mode::Detail => self.handle_detail_mouse(mouse),
1115            Mode::ConfirmDelete => self.handle_confirm_delete_mouse(mouse),
1116            Mode::Help { .. } => self.handle_help_mouse(mouse),
1117            Mode::ContextMenu { .. } => self.handle_context_menu_mouse(mouse),
1118            Mode::FilterModal => self.handle_filter_modal_mouse(mouse),
1119            Mode::Settings => self.handle_settings_mouse(mouse),
1120            Mode::DatePicker { .. } => self.handle_date_picker_mouse(mouse),
1121            _ => Ok(Action::None),
1122        }?;
1123        if action == Action::None
1124            && matches!(mouse.kind, MouseEventKind::Down(_))
1125            && !matches!(self.mode, Mode::ContextMenu { .. })
1126        {
1127            // Fall back to widget-recorded hit regions (D3/D6): a click on a
1128            // region a widget declared during draw runs its associated action,
1129            // regardless of which mode did not handle the event. The context
1130            // menu owns every click while it is open (inside = item, outside =
1131            // dismiss), so underlying board regions must not resolve through
1132            // the fallback.
1133            if let Some(region_action) = self.resolve_region(mouse.column, mouse.row) {
1134                action = region_action;
1135            }
1136        }
1137        Ok(action)
1138    }
1139
1140    /// Record a click target bounding `rect` for the current draw (D3).
1141    /// Widgets call this while rendering; `handle_mouse` resolves the region
1142    /// under a pointer via [`App::resolve_region`]. Regions are cleared at the
1143    /// start of every frame, so stale targets from previous frames never fire.
1144    pub fn record_region(&self, rect: Rect, action: Action) {
1145        self.frame_hit_regions
1146            .borrow_mut()
1147            .push(HitRegion { rect, action });
1148    }
1149
1150    /// The innermost (last-recorded) region under `(x, y)`, if any (D3).
1151    fn resolve_region(&self, x: u16, y: u16) -> Option<Action> {
1152        self.frame_hit_regions
1153            .borrow()
1154            .iter()
1155            .rev()
1156            .find(|region| region.rect.contains(Position::new(x, y)))
1157            .map(|region| region.action.clone())
1158    }
1159
1160    // The pointer handlers below are filled in by their mouse tasks
1161    // (dialog click targets 5.x); until then they are honest stubs that
1162    // return a no-op so the dispatch trunk stays live.
1163
1164    /// Board-mode pointer handling (D4): `Down` selects a card/column and
1165    /// records the press origin, `Drag` frames promote an active drag without
1166    /// touching the board, and a resolving `Up` decides between a plain click,
1167    /// a double-click (opens the card's detail) and a drag-move to another
1168    /// real column. Right-click opens the context menu (task 4.2); scroll
1169    /// wheel behavior lands with task 6.1.
1170    fn handle_board_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1171        match mouse.kind {
1172            MouseEventKind::Down(MouseButton::Left) => {
1173                self.drag_preview_col = None;
1174                let hit = self.board_hit_at(mouse.column, mouse.row);
1175                self.mouse_down_left(hit)
1176            }
1177            MouseEventKind::Down(MouseButton::Right) => {
1178                self.drag_preview_col = None;
1179                let hit = self.board_hit_at(mouse.column, mouse.row);
1180                self.open_context_menu(hit)
1181            }
1182            MouseEventKind::Up(MouseButton::Left) => {
1183                self.drag_preview_col = None;
1184                self.mouse_up_left(mouse.column, mouse.row)
1185            }
1186            MouseEventKind::Drag(_) => {
1187                self.mouse_drag();
1188                self.update_drag_preview(mouse.column, mouse.row);
1189                Ok(Action::None)
1190            }
1191            MouseEventKind::ScrollUp => {
1192                self.board.move_up();
1193                Ok(Action::None)
1194            }
1195            MouseEventKind::ScrollDown => {
1196                self.board.move_down();
1197                Ok(Action::None)
1198            }
1199            MouseEventKind::Moved => {
1200                self.drag_preview_col = None;
1201                if let Some(tab) = self.tab_hover_at(mouse.column, mouse.row) {
1202                    self.tabs.hovered = Some(tab);
1203                    self.board.hover = None;
1204                } else {
1205                    self.tabs.hovered = None;
1206                    self.mouse_hover(mouse.column, mouse.row);
1207                }
1208                Ok(Action::None)
1209            }
1210            _ => Ok(Action::None),
1211        }
1212    }
1213
1214    /// Track which tab, if any, the pointer is hovering. This mirrors the tab
1215    /// bar geometry so the hover highlight stays aligned with the rendered row.
1216    fn tab_hover_at(&self, x: u16, y: u16) -> Option<usize> {
1217        if !self.tabs.needs_bar() {
1218            return None;
1219        }
1220        let area = self.frame_size.get();
1221        if y < area.y || y >= area.y + TAB_HEIGHT {
1222            return None;
1223        }
1224        let tabs_area = Rect {
1225            y: area.y,
1226            height: TAB_HEIGHT,
1227            ..area
1228        };
1229        (0..self.tabs.len()).find(|&index| {
1230            self.tabs
1231                .tab_rect(index, tabs_area)
1232                .is_some_and(|rect| rect.contains(Position::new(x, y)))
1233        })
1234    }
1235
1236    /// Track the pointer for the hover highlight (D8): `Moved` records the
1237    /// card under the pointer on `board.hover` (never touching the selection)
1238    /// and clears it when the pointer is not over a card — empty column space,
1239    /// the board chrome, or outside the board.
1240    fn mouse_hover(&mut self, x: u16, y: u16) {
1241        self.board.hover = match self.board_hit_at(x, y) {
1242            Some(HitTarget::Card { col, row }) => Some((col, row)),
1243            _ => None,
1244        };
1245    }
1246
1247    /// Update the current drag preview target while a card is held down.
1248    fn update_drag_preview(&mut self, x: u16, y: u16) {
1249        match self.board_hit_at(x, y) {
1250            Some(HitTarget::Card { col, row }) => {
1251                self.drag_preview_col = Some(col);
1252                self.board.hover = Some((col, row));
1253            }
1254            Some(HitTarget::Column { col }) if col < self.board.real_cols() => {
1255                self.drag_preview_col = Some(col);
1256                self.board.hover = None;
1257            }
1258            _ => {
1259                self.drag_preview_col = None;
1260                self.board.hover = None;
1261            }
1262        }
1263    }
1264
1265    /// Board-detail mouse handling: only the wheel changes scroll offset.
1266    fn handle_detail_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1267        match mouse.kind {
1268            MouseEventKind::ScrollUp => {
1269                if let Some(d) = self.detail.as_mut() {
1270                    d.scroll_by(-i32::from(WHEEL_ROWS));
1271                }
1272            }
1273            MouseEventKind::ScrollDown => {
1274                if let Some(d) = self.detail.as_mut() {
1275                    d.scroll_by(i32::from(WHEEL_ROWS));
1276                }
1277            }
1278            _ => {}
1279        }
1280        Ok(Action::None)
1281    }
1282
1283    /// The rect the board widget draws into within the frame `area`, mirroring
1284    /// `render_board`: tabs bar and hover line on top, filter row (when
1285    /// active), board, then the hint bar at the bottom. `None` when the frame
1286    /// is too small to contain a board at all.
1287    fn board_mouse_area(&self, area: Rect) -> Option<Rect> {
1288        let tab_h: u16 = if self.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
1289        let filter_h: u16 = if self.filter.is_some() || self.structured.has_active_criteria() {
1290            FILTER_HEIGHT
1291        } else {
1292            0
1293        };
1294        let hint_h: u16 = if self.one_shot { 0 } else { HINT_HEIGHT };
1295        if area.height <= hint_h + tab_h + HOVER_HEIGHT + filter_h {
1296            return None;
1297        }
1298        Some(Rect {
1299            y: area.y + tab_h + HOVER_HEIGHT + filter_h,
1300            height: area.height - tab_h - HOVER_HEIGHT - filter_h - hint_h,
1301            ..area
1302        })
1303    }
1304
1305    /// Resolve a terminal pointer against the board using the geometry of the
1306    /// most recent draw (D3), or `None` outside the board / when unframed.
1307    fn board_hit_at(&self, x: u16, y: u16) -> Option<HitTarget> {
1308        let frame = self.frame_size.get();
1309        let board_area = self.board_mouse_area(frame)?;
1310        self.board.hit_test(
1311            board_area,
1312            board::BoardDisplay::from(self.active_board_config()),
1313            self.status_msg.is_some(),
1314            x,
1315            y,
1316        )
1317    }
1318
1319    /// Move the cursor to the card or column the pointer resolved to (D4:
1320    /// selection changes only on `Down`).
1321    fn select_at(&mut self, hit: HitTarget) {
1322        match hit {
1323            HitTarget::Card { col, row } => {
1324                self.board.active_col = col;
1325                self.board.selected = row;
1326            }
1327            HitTarget::Column { col } => {
1328                self.board.active_col = col;
1329                self.board.clamp_selection();
1330            }
1331        }
1332    }
1333
1334    fn mouse_down_left(&mut self, hit: Option<HitTarget>) -> Result<Action> {
1335        let Some(target) = hit else {
1336            return Ok(Action::None);
1337        };
1338        self.select_at(target);
1339        let (col, card) = match target {
1340            HitTarget::Card { col, row } => (col, Some(row)),
1341            HitTarget::Column { col } => (col, None),
1342        };
1343
1344        let double_click = match target {
1345            HitTarget::Card { col, row } => match self.mouse.last_down.as_ref() {
1346                // A second press on the same card within the window is a
1347                // double-click; the press consuming it re-records the origin.
1348                Some(prev) => {
1349                    prev.at.elapsed() < self.mouse.double_click_window
1350                        && prev.col == col
1351                        && prev.card == Some(row)
1352                }
1353                None => false,
1354            },
1355            HitTarget::Column { .. } => false,
1356        };
1357
1358        self.mouse.last_down = Some(MouseDown {
1359            col,
1360            card,
1361            at: Instant::now(),
1362        });
1363
1364        if double_click {
1365            return self.open_detail_for(target);
1366        }
1367        Ok(Action::None)
1368    }
1369
1370    /// Open the detail view for the card at the hit target (mirrors the board
1371    /// `Enter` branch) — the resolving half of a double-click.
1372    fn open_detail_for(&mut self, hit: HitTarget) -> Result<Action> {
1373        if let HitTarget::Card { col, row } = hit {
1374            if let Some(card) = self.board.column_cards(col).get(row).cloned() {
1375                let issue = self.store.get(IssueSelector::Filename(card.filename))?;
1376                // Service-resolved, so the pane shows the relationships that
1377                // touch this issue rather than the ones it happens to own.
1378                let relationships = self.store.relationships_for(issue.number)?;
1379                // One graph for the chain and the blocked note, so the two rows
1380                // cannot describe different snapshots of the same issue.
1381                let (all, done_column) = self.store.graph_source()?;
1382                let graph = commands::graph_over(&all, &done_column);
1383                let number = issue.number;
1384                // WP05: the resolved category, for the detail row (T023). Taken
1385                // before `issue` moves into the struct.
1386                let category = self.category_of_issue(&issue);
1387                self.detail = Some(DetailState {
1388                    issue,
1389                    category,
1390                    relationships,
1391                    chain: graph.dependency_chain(number),
1392                    blocked: commands::blocked_note(&graph.blocked_state(number)),
1393                    // From the **same** `all` snapshot the graph was built over, so
1394                    // the hierarchy rows and the relationship rows cannot describe
1395                    // different states of the same issue.
1396                    hierarchy: commands::parent::facts_over(&all, &done_column, number),
1397                    scroll_offset: 0,
1398                    viewport: std::cell::Cell::new(0),
1399                    max_scroll: std::cell::Cell::new(0),
1400                    rel_cursor: 0,
1401                });
1402                self.mode = Mode::Detail;
1403            }
1404        }
1405        Ok(Action::None)
1406    }
1407
1408    /// Right-click on a card opens the issue context menu (D5, Spec: 6). The
1409    /// clicked card is selected first so every activated item — which runs the
1410    /// exact path its key binding would — operates on the expected issue. The
1411    /// menu is a widget overlay; activation/dismissal happen in
1412    /// [`App::handle_context_menu_key`] / [`App::handle_context_menu_mouse`].
1413    fn open_context_menu(&mut self, hit: Option<HitTarget>) -> Result<Action> {
1414        let target = match hit {
1415            Some(target @ HitTarget::Card { .. }) => target,
1416            // Right-click without a card target is a no-op.
1417            _ => return Ok(Action::None),
1418        };
1419        self.select_at(target);
1420        let mut items = vec![
1421            MenuItem {
1422                kind: MenuItemKind::OpenDetail,
1423                label: "Open detail".to_string(),
1424            },
1425            MenuItem {
1426                kind: MenuItemKind::EditInEditor,
1427                label: "Edit in editor".to_string(),
1428            },
1429        ];
1430        for col in 0..self.board.real_cols() {
1431            if col != self.board.active_col {
1432                let name = self.board.column_name(col);
1433                items.push(MenuItem {
1434                    kind: MenuItemKind::MoveTo { column: col },
1435                    label: format!("Move to {name}"),
1436                });
1437            }
1438        }
1439        items.push(MenuItem {
1440            kind: MenuItemKind::Delete,
1441            label: "Delete".to_string(),
1442        });
1443        items.push(MenuItem {
1444            kind: MenuItemKind::RefreshBoard,
1445            label: "Refresh board".to_string(),
1446        });
1447        self.context_menu = Some(ContextMenu::new(items));
1448        self.mode = Mode::ContextMenu {
1449            return_to: Box::new(Mode::Board),
1450        };
1451        Ok(Action::None)
1452    }
1453
1454    /// Restore `return_to` and drop the open menu (mirrors `handle_help_key`).
1455    fn close_context_menu(&mut self) {
1456        if let Mode::ContextMenu { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
1457            self.mode = *return_to;
1458        }
1459        self.context_menu = None;
1460    }
1461
1462    /// Keyboard routing for the open context menu (D5): Up/Down move the
1463    /// selection, Enter activates the highlighted item (via the shared action
1464    /// path), Esc dismisses. Mirrors the Help overlay's single-trunk routing.
1465    fn handle_context_menu_key(&mut self, key: KeyEvent) -> Result<Action> {
1466        let Some(menu) = self.context_menu.as_mut() else {
1467            self.close_context_menu();
1468            return Ok(Action::None);
1469        };
1470        match menu.handle_key(key.code) {
1471            MenuEvent::None => Ok(Action::None),
1472            MenuEvent::Dismissed => {
1473                self.close_context_menu();
1474                Ok(Action::None)
1475            }
1476            MenuEvent::Selected(index) => {
1477                let kind = menu.item(index).map(|item| item.kind.clone());
1478                let Some(kind) = kind else {
1479                    return Ok(Action::None);
1480                };
1481                self.activate_context_item(kind)
1482            }
1483        }
1484    }
1485
1486    /// Pointer routing for the open context menu (D5): a `Down` on an item row
1487    /// activates it; a `Down` outside the popup dismisses; anything else is
1488    /// ignored. Because this handler owns every click, it bypasses the D3
1489    /// region fallback (`handle_mouse` skips it while this mode is active).
1490    fn handle_context_menu_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1491        match mouse.kind {
1492            MouseEventKind::ScrollUp => {
1493                if let Some(menu) = self.context_menu.as_mut() {
1494                    let _ = menu.handle_key(KeyCode::Up);
1495                }
1496                Ok(Action::None)
1497            }
1498            MouseEventKind::ScrollDown => {
1499                if let Some(menu) = self.context_menu.as_mut() {
1500                    let _ = menu.handle_key(KeyCode::Down);
1501                }
1502                Ok(Action::None)
1503            }
1504            MouseEventKind::Down(_) => {
1505                let Some(menu) = self.context_menu.take() else {
1506                    self.close_context_menu();
1507                    return Ok(Action::None);
1508                };
1509                let bounds = menu.bounds(self.frame_size.get());
1510                let in_popup = mouse.column >= bounds.x
1511                    && mouse.column < bounds.x + bounds.width
1512                    && mouse.row >= bounds.y
1513                    && mouse.row < bounds.y + bounds.height;
1514                if !in_popup {
1515                    self.close_context_menu();
1516                    return Ok(Action::None);
1517                }
1518                // Item rows sit between the popup's borders (top border at `bounds.y`,
1519                // content starts one row down; bottom border excluded from the hit).
1520                let row_hit = mouse.row.checked_sub(bounds.y + 1);
1521                let index = match row_hit {
1522                    Some(offset) => usize::from(offset),
1523                    None => return Ok(Action::None),
1524                };
1525                let kind = menu.item(index).map(|item| item.kind.clone());
1526                self.context_menu = Some(menu);
1527                match kind {
1528                    Some(kind) => self.activate_context_item(kind),
1529                    None => Ok(Action::None),
1530                }
1531            }
1532            _ => Ok(Action::None),
1533        }
1534    }
1535
1536    /// Run the action a context menu item represents — the exact path the
1537    /// equivalent key binding would take (Spec: 6). The menu is closed first,
1538    /// restoring `return_to`, so the shared board/detail/handler state is
1539    /// current when the action runs.
1540    fn activate_context_item(&mut self, kind: MenuItemKind) -> Result<Action> {
1541        self.close_context_menu();
1542        match kind {
1543            MenuItemKind::OpenDetail => {
1544                let hit = HitTarget::Card {
1545                    col: self.board.active_col,
1546                    row: self.board.selected,
1547                };
1548                self.open_detail_for(hit)
1549            }
1550            MenuItemKind::EditInEditor => {
1551                if let Some(card) = self.board.get_selected_card() {
1552                    return Ok(Action::OpenEditor(card.filename.clone()));
1553                }
1554                Ok(Action::None)
1555            }
1556            MenuItemKind::MoveTo { column } => self.move_selected_to(column),
1557            MenuItemKind::Delete => {
1558                if let Some(card) = self.board.get_selected_card().cloned() {
1559                    self.confirm_delete_target = Some(card.filename);
1560                    self.mode = Mode::ConfirmDelete;
1561                }
1562                Ok(Action::None)
1563            }
1564            MenuItemKind::RefreshBoard => {
1565                self.rebuild_board();
1566                self.status_msg = Some("Refreshed".to_string());
1567                Ok(Action::None)
1568            }
1569        }
1570    }
1571
1572    /// A press followed by `Drag` frames promotes the recorded press into an
1573    /// active drag (D4). The board is not mutated during drag frames; the
1574    /// eventual `Up` decides the outcome. Dragging requires a card origin —
1575    /// grabbing empty column space never moves anything.
1576    fn mouse_drag(&mut self) {
1577        if self.mouse.drag.is_none() {
1578            if let Some(down) = &self.mouse.last_down {
1579                if down.card.is_some() {
1580                    self.mouse.drag = Some(Drag {
1581                        col: down.col,
1582                        card: down.card,
1583                    });
1584                }
1585            }
1586        }
1587    }
1588
1589    /// Resolve a left-button release (D4): an active drag becomes a drop —
1590    /// moved to a different real column via `move_selected_to`, no-op on the
1591    /// source column, the mismatched column, or outside the board — while a
1592    /// plain click keeps the press recorded so a fast second press on the same
1593    /// card registers as a double-click.
1594    fn mouse_up_left(&mut self, x: u16, y: u16) -> Result<Action> {
1595        self.board.hover = None;
1596        if let Some(drag) = self.mouse.drag.take() {
1597            self.mouse.last_down = None;
1598            if drag.card.is_none() {
1599                return Ok(Action::None);
1600            }
1601            let target = match self.board_hit_at(x, y) {
1602                Some(target) => target,
1603                None => return Ok(Action::None),
1604            };
1605            match target {
1606                HitTarget::Card { col, row } if col == drag.col => {
1607                    if Some(row) == drag.card {
1608                        return Ok(Action::None);
1609                    }
1610                    return self.reorder_selected_within_column(col, drag.card.unwrap(), row);
1611                }
1612                HitTarget::Column { col } if col == drag.col => {
1613                    return self.reorder_selected_within_column(
1614                        col,
1615                        drag.card.unwrap(),
1616                        self.board.column_cards(col).len().saturating_sub(1),
1617                    );
1618                }
1619                HitTarget::Card { col, .. } | HitTarget::Column { col }
1620                    if col < self.board.real_cols() && col != drag.col =>
1621                {
1622                    return self.move_selected_to(col);
1623                }
1624                _ => return Ok(Action::None),
1625            }
1626        }
1627        Ok(Action::None)
1628    }
1629
1630    /// Persist the cards whose positions differ from the automatic ordering so
1631    /// a drag-reordered column survives rebuilds/restarts without storing a
1632    /// full column snapshot.
1633    fn persist_card_order(&mut self) -> bool {
1634        let board_name = self.active_board_name().to_string();
1635        let mut order = Vec::new();
1636        let active_config = self.active_board_config().clone();
1637        order.extend(
1638            self.board
1639                .columns
1640                .iter()
1641                .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
1642        );
1643        order.extend(
1644            self.board
1645                .hidden
1646                .iter()
1647                .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
1648        );
1649        order.extend(board::sparse_card_order(
1650            &active_config,
1651            &self.board.mismatch.cards,
1652        ));
1653        let Some(board_config) = self.store.config.kanban.configurations.get_mut(&board_name)
1654        else {
1655            return false;
1656        };
1657        board_config.card_order = order;
1658        self.persist_config()
1659    }
1660
1661    fn reorder_selected_within_column(
1662        &mut self,
1663        col: usize,
1664        from_row: usize,
1665        to_row: usize,
1666    ) -> Result<Action> {
1667        let (filename, number, column_name) = {
1668            let Some(column) = self.board.columns.get_mut(col) else {
1669                return Ok(Action::None);
1670            };
1671            if from_row >= column.cards.len() || to_row >= column.cards.len() || from_row == to_row
1672            {
1673                return Ok(Action::None);
1674            }
1675            let card = column.cards.remove(from_row);
1676            let filename = card.filename.clone();
1677            let number = card.number;
1678            let insert_at = to_row;
1679            column.cards.insert(insert_at, card);
1680            (filename, number, column.name.clone())
1681        };
1682
1683        if !self.persist_card_order() {
1684            return Ok(Action::None);
1685        }
1686        self.rebuild_board();
1687        self.select_card_by_filename(&filename);
1688        self.status_msg = Some(format!("Reordered #{} in {}", number, column_name));
1689        Ok(Action::None)
1690    }
1691
1692    /// Move the selected card within the active column by `delta` slots.
1693    fn move_selected_within_column(&mut self, delta: isize) -> Result<Action> {
1694        let col = self.board.active_col;
1695        let column_name = if col < self.board.columns.len() {
1696            self.board.columns[col].name.clone()
1697        } else if col == self.board.columns.len() && self.board.show_mismatch {
1698            self.board.mismatch.name.clone()
1699        } else {
1700            return Ok(Action::None);
1701        };
1702        let (filename, number) = {
1703            let cards = if col < self.board.columns.len() {
1704                &mut self.board.columns[col].cards
1705            } else {
1706                &mut self.board.mismatch.cards
1707            };
1708            let len = cards.len();
1709            if len < 2 {
1710                return Ok(Action::None);
1711            }
1712            let from_row = self.board.selected;
1713            if from_row >= len {
1714                return Ok(Action::None);
1715            }
1716            let to_row = if delta < 0 {
1717                from_row.saturating_sub(delta.unsigned_abs())
1718            } else {
1719                usize::min(from_row + delta as usize, len - 1)
1720            };
1721            if to_row == from_row {
1722                return Ok(Action::None);
1723            }
1724            let card = cards.remove(from_row);
1725            let filename = card.filename.clone();
1726            let number = card.number;
1727            let insert_at = to_row;
1728            cards.insert(insert_at, card);
1729            (filename, number)
1730        };
1731        if !self.persist_card_order() {
1732            return Ok(Action::None);
1733        }
1734        self.rebuild_board();
1735        self.select_card_by_filename(&filename);
1736        self.status_msg = Some(format!(
1737            "Moved #{} {} in {}",
1738            number,
1739            if delta < 0 { "up" } else { "down" },
1740            column_name
1741        ));
1742        Ok(Action::None)
1743    }
1744
1745    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1746    fn handle_confirm_delete_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1747        Ok(Action::None)
1748    }
1749
1750    #[allow(clippy::unnecessary_wraps)]
1751    fn handle_help_mouse(&mut self, mouse: MouseEvent) -> Result<Action> {
1752        // tix #1441: the wheel scrolls the overlay, like the board and the detail
1753        // pane already did. It used to resolve to a click-anywhere-closes region,
1754        // which is still true for clicks — a wheel event is not a click.
1755        match mouse.kind {
1756            MouseEventKind::ScrollUp => self.scroll_help(-i32::from(WHEEL_ROWS)),
1757            MouseEventKind::ScrollDown => self.scroll_help(i32::from(WHEEL_ROWS)),
1758            _ => {}
1759        }
1760        Ok(Action::None)
1761    }
1762
1763    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1764    fn handle_filter_modal_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1765        Ok(Action::None)
1766    }
1767
1768    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1769    fn handle_settings_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1770        Ok(Action::None)
1771    }
1772
1773    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
1774    fn handle_date_picker_mouse(&mut self, _mouse: MouseEvent) -> Result<Action> {
1775        Ok(Action::None)
1776    }
1777
1778    fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
1779        if key
1780            .modifiers
1781            .contains(crossterm::event::KeyModifiers::CONTROL)
1782        {
1783            match key.code {
1784                KeyCode::Up => return self.move_selected_within_column(-1),
1785                KeyCode::Down => return self.move_selected_within_column(1),
1786                _ => {}
1787            }
1788        }
1789        match key.code {
1790            KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
1791                self.quit = true;
1792                Ok(Action::Quit)
1793            }
1794            KeyCode::Tab => {
1795                self.tabs.next();
1796                self.active_config = self.tabs.selected;
1797                self.switch_config();
1798                Ok(Action::None)
1799            }
1800            KeyCode::BackTab => {
1801                self.tabs.previous();
1802                self.active_config = self.tabs.selected;
1803                self.switch_config();
1804                Ok(Action::None)
1805            }
1806            KeyCode::Left | KeyCode::Char('h') => {
1807                self.board.move_left();
1808                Ok(Action::None)
1809            }
1810            KeyCode::Right | KeyCode::Char('l') => {
1811                self.board.move_right();
1812                Ok(Action::None)
1813            }
1814            KeyCode::Up | KeyCode::Char('k') => {
1815                self.board.move_up();
1816                Ok(Action::None)
1817            }
1818            KeyCode::Down | KeyCode::Char('j') => {
1819                self.board.move_down();
1820                Ok(Action::None)
1821            }
1822            KeyCode::Enter => {
1823                if let Some(card) = self.board.get_selected_card().cloned() {
1824                    let issue = self.store.get(IssueSelector::Filename(card.filename))?;
1825                    let relationships = self.store.relationships_for(issue.number)?;
1826                    let (all, done_column) = self.store.graph_source()?;
1827                    let graph = commands::graph_over(&all, &done_column);
1828                    let number = issue.number;
1829                    let category = self.category_of_issue(&issue);
1830                    self.detail = Some(DetailState {
1831                        issue,
1832                        category,
1833                        relationships,
1834                        chain: graph.dependency_chain(number),
1835                        blocked: commands::blocked_note(&graph.blocked_state(number)),
1836                        // Same snapshot as the graph above — see the other
1837                        // detail-opening path.
1838                        hierarchy: commands::parent::facts_over(&all, &done_column, number),
1839                        scroll_offset: 0,
1840                        viewport: std::cell::Cell::new(0),
1841                        max_scroll: std::cell::Cell::new(0),
1842                        rel_cursor: 0,
1843                    });
1844                    self.mode = Mode::Detail;
1845                }
1846                Ok(Action::None)
1847            }
1848            KeyCode::Char('e') => {
1849                if let Some(card) = self.board.get_selected_card() {
1850                    Ok(Action::OpenEditor(card.filename.clone()))
1851                } else {
1852                    Ok(Action::None)
1853                }
1854            }
1855            KeyCode::Char('A') => {
1856                if let Some(card) = self.board.get_selected_card() {
1857                    Ok(Action::OpenAgent(card.filename.clone()))
1858                } else {
1859                    Ok(Action::None)
1860                }
1861            }
1862            KeyCode::Char('c') => {
1863                self.toggle_column_visibility();
1864                Ok(Action::None)
1865            }
1866            KeyCode::Char('f') => {
1867                self.open_filter_modal();
1868                Ok(Action::None)
1869            }
1870            // WP07 T031: `space` marks a card and `x` unmarks it. The **board**
1871            // owns these, not the modal, because the selection is made by standing
1872            // on cards — a modal that swallowed the board's keys would break every
1873            // existing binding, which T031 forbids. Marking also opens the modal on
1874            // the first mark, so the first `space` is not silent: the user sees
1875            // "1 selected" without having to find the `B` key.
1876            KeyCode::Char(' ') => {
1877                self.open_bulk_modal(crate::ui::bulk::BatchAction::Edit, true);
1878                Ok(Action::None)
1879            }
1880            KeyCode::Char('x') | KeyCode::Char('X') => {
1881                if self.bulk.is_some() {
1882                    let Some(card) = self.board.get_selected_card().map(|c| c.number) else {
1883                        return Ok(Action::None);
1884                    };
1885                    if let Some(modal) = self.bulk.as_mut() {
1886                        if !modal.selection.remove(card) {
1887                            modal.note = Some(format!("#{card} was not selected"));
1888                        }
1889                    }
1890                } else {
1891                    self.open_bulk_modal(crate::ui::bulk::BatchAction::Edit, true);
1892                }
1893                Ok(Action::None)
1894            }
1895            KeyCode::Char('B') => {
1896                let action = self
1897                    .bulk
1898                    .as_ref()
1899                    .map(|m| m.action)
1900                    .unwrap_or(crate::ui::bulk::BatchAction::Edit);
1901                self.open_bulk_modal(action, false);
1902                Ok(Action::None)
1903            }
1904            KeyCode::Char('<') => {
1905                let real_cols = self.board.real_cols();
1906                if real_cols > 0 {
1907                    let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
1908                    {
1909                        real_cols - 1
1910                    } else {
1911                        self.board.active_col - 1
1912                    };
1913                    self.move_selected_to(target)
1914                } else {
1915                    Ok(Action::None)
1916                }
1917            }
1918            KeyCode::Char('>') => {
1919                let real_cols = self.board.real_cols();
1920                if real_cols > 0 {
1921                    let target = if self.board.active_col >= real_cols {
1922                        0
1923                    } else {
1924                        (self.board.active_col + 1) % real_cols
1925                    };
1926                    self.move_selected_to(target)
1927                } else {
1928                    Ok(Action::None)
1929                }
1930            }
1931            KeyCode::Char('n') => {
1932                self.open_new_issue_form();
1933                Ok(Action::None)
1934            }
1935            KeyCode::Char('C') => {
1936                // #1484. The source is resolved through the same selector path the
1937                // CLI uses (`view::get`, by the card's filename) rather than read
1938                // off the card: a `Card` is the *display* projection, and it
1939                // carries no estimate, reviewer, start date or parent — a clone
1940                // that silently dropped those would be a partial copy wearing a
1941                // whole one's name.
1942                if let Some(card) = self.board.get_selected_card().cloned() {
1943                    match crate::commands::view::get(&self.store, &card.filename) {
1944                        Ok(issue) => {
1945                            self.status_msg = Some(format!("Cloning #{}", issue.number));
1946                            self.form = Some(FormState::from_issue(&issue));
1947                            self.mode = Mode::Form;
1948                        }
1949                        Err(err) => self.status_msg = Some(format!("Cannot clone: {err}")),
1950                    }
1951                } else {
1952                    self.status_msg = Some("No issue selected to clone".to_string());
1953                }
1954                Ok(Action::None)
1955            }
1956            // #1492: show the archived issues too, or hide them again. Off by
1957            // default and *reloaded* rather than filtered on demand: the archived
1958            // files are not in the corpus until this is on, which is what keeps
1959            // opening a board in a repository full of them cheap.
1960            KeyCode::Char('a') => {
1961                self.show_archived = !self.show_archived;
1962                self.refresh_corpus()?;
1963                self.status_msg = Some(
1964                    if self.show_archived {
1965                        "Showing archived issues"
1966                    } else {
1967                        "Hiding archived issues"
1968                    }
1969                    .to_string(),
1970                );
1971                Ok(Action::None)
1972            }
1973            // tix #1442 (follow-up): a page of cards. The column scrolls itself to keep
1974            // the selection visible — the `List` does that — so a page step is a
1975            // selection jump by one screen, measured by the renderer.
1976            //
1977            // These keys were **unbound** on the board until now, and an unbound key in
1978            // a non-text context is rewritten to `F(12)` before the handler sees it, so
1979            // `PgUp`/`PgDn` did nothing at all while the wheel next to them worked. A
1980            // key that silently disappears is worse than one that is not advertised.
1981            KeyCode::PageDown => {
1982                self.move_selection_by_page(1);
1983                Ok(Action::None)
1984            }
1985            KeyCode::PageUp => {
1986                self.move_selection_by_page(-1);
1987                Ok(Action::None)
1988            }
1989            KeyCode::Char('d') => {
1990                if let Some(card) = self.board.get_selected_card().cloned() {
1991                    self.confirm_delete_target = Some(card.filename);
1992                    self.mode = Mode::ConfirmDelete;
1993                }
1994                Ok(Action::None)
1995            }
1996            KeyCode::Char('r') => {
1997                self.rebuild_board();
1998                self.status_msg = Some("Refreshed".to_string());
1999                Ok(Action::None)
2000            }
2001            KeyCode::Char('s') => {
2002                self.open_settings(None);
2003                Ok(Action::None)
2004            }
2005            // WP06, T029. `t` was unbound, so this adds a key rather than
2006            // changing what an existing one means — the escalation this
2007            // mission is told to avoid is an *unrelated* navigation change,
2008            // and this one is the triage surface's own entry point.
2009            KeyCode::Char('t') => self.open_triage_inbox(),
2010            KeyCode::Char('m') | KeyCode::Char('M') => {
2011                self.open_planning_modal();
2012                Ok(Action::None)
2013            }
2014            KeyCode::Char('b') => {
2015                self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
2016                Ok(Action::None)
2017            }
2018            KeyCode::Char('?') => {
2019                self.open_help(Mode::Board);
2020                Ok(Action::None)
2021            }
2022            KeyCode::Char('/') => {
2023                let buffer = self.filter.clone().unwrap_or_default();
2024                self.mode = Mode::Filter { buffer };
2025                Ok(Action::None)
2026            }
2027            _ => Ok(Action::None),
2028        }
2029    }
2030
2031    /// The single entry point for a TUI move, with no reason yet.
2032    ///
2033    /// WP05 T021: all three TUI move paths — the board's `<`/`>` keys, the detail
2034    /// view's move action, and the context menu's — now come through here, so the
2035    /// enumeration NFR-002 is checked against is three call sites rather than three
2036    /// hand-copied sequences that can drift.
2037    ///
2038    /// The order is deliberate and load-bearing:
2039    ///
2040    /// 1. **Blocked-move guard.** A move that will need an acknowledgement must not
2041    ///    first ask the user for a reason or a commit hash.
2042    /// 2. **Transition policy.** A terminal-exit refusal raises the reason prompt.
2043    /// 3. **Commit prompt**, only if the move actually reaches completion.
2044    /// 4. **The seam.**
2045    fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
2046        self.move_selected_to_impl(target_col, None, false)
2047    }
2048
2049    /// [`Self::move_selected_to`], carrying a reason and/or a prior acknowledgement.
2050    ///
2051    /// Called with `(None, false)` to start a move, `(Some(reason), false)` from the
2052    /// reason prompt's accept path, and `(None, true)` from the blocked-move
2053    /// confirmation. The call re-runs the guard and the policy check rather than
2054    /// resuming mid-sequence, so the three entry points cannot drift apart.
2055    ///
2056    /// **Why `acknowledged` has to be threaded rather than the prompt resuming.**
2057    /// The acknowledgement exists to suppress the guard's refusal. Re-entering with
2058    /// `false` would raise the confirmation dialog again and loop forever, so the
2059    /// flag is the only thing that distinguishes "the user has not decided" from
2060    /// "the user has decided".
2061    fn move_selected_to_impl(
2062        &mut self,
2063        target_col: usize,
2064        override_reason: Option<String>,
2065        acknowledged: bool,
2066    ) -> Result<Action> {
2067        let card = match self.board.get_selected_card().cloned() {
2068            Some(c) => c,
2069            None => return Ok(Action::None),
2070        };
2071
2072        let target_name = self.board.column_name(target_col).to_string();
2073        // WP05: completion is the **service's** decision, not a column-name
2074        // comparison (C-004). This is the same class of defect WP04 fixed in the
2075        // CLI: on a board whose `category_map` puts `completed` somewhere other than
2076        // its `done_column`, comparing names asks the wrong question, and the TUI
2077        // would skip the commit prompt for a move the seam records as reaching
2078        // completion.
2079        let (_, resolution) = crate::workflow::active_resolution(&self.store.config);
2080        let is_done_target = resolution
2081            .resolve(&target_name)
2082            .is_some_and(crate::workflow::WorkflowCategory::is_complete);
2083        let current_col_name = self.board.column_name(self.board.active_col);
2084        let was_done = resolution
2085            .resolve(current_col_name)
2086            .is_some_and(crate::workflow::WorkflowCategory::is_complete);
2087
2088        // The shared guard runs **here**, before the commit prompt, so a move that
2089        // will need an acknowledgement does not first ask for a commit hash.
2090        //
2091        // This call is mandatory, not an optimisation: writing `issue.status`
2092        // directly below would let the TUI move blocked work to done with no
2093        // warning, no confirmation, and no History entry, while the CLI refused
2094        // the same move. That divergence is the parity defect
2095        // `contracts/surface-parity.md` §3 exists to prevent.
2096        let issue = self
2097            .store
2098            .get(IssueSelector::Filename(card.filename.clone()))?;
2099
2100        // WP05 T021 step 3: when the policy refuses and no reason has been supplied,
2101        // raise the **shared** diagnostic as the prompt's text. Parity includes the
2102        // message, not only the accept/refuse decision
2103        // (`contracts/surface-parity.md` §2), and reusing the service's own string is
2104        // what guarantees that without a second copy to reword.
2105        if override_reason.is_none()
2106            && let crate::workflow::TransitionOutcome::Rejected(diagnostic) =
2107                crate::workflow::validate_transition(
2108                    &issue,
2109                    &target_name,
2110                    &resolution,
2111                    &self.store.config,
2112                    None,
2113                )
2114        {
2115            self.mode = Mode::TransitionReason {
2116                target_col,
2117                reason: diagnostic.to_string(),
2118                buffer: String::new(),
2119            };
2120            return Ok(Action::None);
2121        }
2122
2123        let guard = mv::blocked_move_guard(&self.store.config, &self.store, &issue, &target_name)?;
2124        if guard.needs_acknowledgement() && !acknowledged {
2125            self.mode = Mode::ConfirmBlockedMove {
2126                target_col,
2127                warning: guard.warning().unwrap_or_default(),
2128            };
2129            return Ok(Action::None);
2130        }
2131
2132        if is_done_target && card.commit.is_none() {
2133            self.mode = Mode::MoveIntoDone {
2134                target_col,
2135                buffer: String::new(),
2136            };
2137            return Ok(Action::None);
2138        }
2139
2140        // Through the shared path, so the move's rules — including any
2141        // acknowledgement event — are identical on every surface. `was_done`
2142        // clearing the commit stays in the command layer, which owns it.
2143        //
2144        // **This call is mandatory, not an optimisation.** `Status` is a tuple struct
2145        // with a public field (research R7), so writing `issue.status` here would
2146        // compile, would be shorter, and would silently skip the transition policy
2147        // and the `TransitionDecision` — the TUI would then accept a move the CLI
2148        // refuses. The comment is here so the next reader does not "simplify" it.
2149        let commit = if was_done { None } else { card.commit.clone() };
2150        mv::apply_guarded(
2151            &self.store.config,
2152            &self.store,
2153            &card.filename,
2154            &target_name,
2155            commit.as_deref(),
2156            override_reason.as_deref(),
2157            acknowledged,
2158        )?;
2159
2160        self.rebuild_board();
2161        self.board.select_column(target_col);
2162        self.status_msg = Some(format!("Moved #{} to {target_name}", card.number));
2163        Ok(Action::None)
2164    }
2165
2166    /// The reasoned-override prompt's key handler.
2167    ///
2168    /// Enter accepts a **non-blank** reason; Esc cancels. A blank reason is refused
2169    /// rather than accepted, because a whitespace-only string is not a reason and the
2170    /// service trims it away — accepting it here would show the user a move that
2171    /// then fails at the seam, having already closed the prompt.
2172    ///
2173    /// Cancelling returns to the board with **nothing** written: no file, no
2174    /// `updated` bump, no History entry. The move was never applied, so there is
2175    /// nothing to undo.
2176    fn handle_transition_reason_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
2177        match key.code {
2178            KeyCode::Enter => {
2179                let reason = match &self.mode {
2180                    Mode::TransitionReason { buffer, .. } => buffer.clone(),
2181                    _ => String::new(),
2182                };
2183                if reason.trim().is_empty() {
2184                    self.status_msg = Some(
2185                        "A reason is required: the decision is recorded in the issue's \
2186                         history and has to say why"
2187                            .to_string(),
2188                    );
2189                    return Ok(Action::None);
2190                }
2191                // Re-enter the shared move with the reason, so the guard, the policy
2192                // check, the commit prompt and the write all run once more in the
2193                // same order as an unprompted move.
2194                self.mode = Mode::Board;
2195                self.move_selected_to_impl(target_col, Some(reason), false)
2196            }
2197            KeyCode::Esc => {
2198                self.mode = Mode::Board;
2199                Ok(Action::None)
2200            }
2201            KeyCode::Char(c) => {
2202                if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
2203                    buffer.push(c);
2204                }
2205                Ok(Action::None)
2206            }
2207            KeyCode::Backspace => {
2208                if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
2209                    buffer.pop();
2210                }
2211                Ok(Action::None)
2212            }
2213            _ => Ok(Action::None),
2214        }
2215    }
2216
2217    fn handle_detail_key(&mut self, key: KeyEvent) -> Result<Action> {
2218        match key.code {
2219            KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
2220                self.mode = Mode::Board;
2221                self.detail = None;
2222                Ok(Action::None)
2223            }
2224            KeyCode::Char('e') => {
2225                if let Some(ref d) = self.detail {
2226                    let filename = d.issue.filename.clone();
2227                    Ok(Action::OpenEditor(filename))
2228                } else {
2229                    Ok(Action::None)
2230                }
2231            }
2232            KeyCode::PageUp => {
2233                if let Some(d) = self.detail.as_mut() {
2234                    let page = d.page();
2235                    d.scroll_by(-i32::from(page));
2236                }
2237                Ok(Action::None)
2238            }
2239            KeyCode::PageDown => {
2240                if let Some(d) = self.detail.as_mut() {
2241                    let page = d.page();
2242                    d.scroll_by(i32::from(page));
2243                }
2244                Ok(Action::None)
2245            }
2246            KeyCode::Char('?') => {
2247                self.open_help(Mode::Detail);
2248                Ok(Action::None)
2249            }
2250            KeyCode::Char('c') => {
2251                self.mode = Mode::Comment {
2252                    buffer: String::new(),
2253                };
2254                Ok(Action::None)
2255            }
2256            KeyCode::Char('a') => {
2257                self.mode = Mode::Asset {
2258                    buffer: String::new(),
2259                };
2260                Ok(Action::None)
2261            }
2262            // #1359: the three mutating relationship operations, plus the cursor
2263            // naming which edge `E` and `d` act on.
2264            KeyCode::Char('n') => {
2265                self.mode = Mode::LinkCreate {
2266                    buffer: String::new(),
2267                    step: LinkStep::Target,
2268                    target: None,
2269                };
2270                Ok(Action::None)
2271            }
2272            KeyCode::Char('E') => {
2273                // Only meaningful with an edge under the cursor; with none selected
2274                // the key says so rather than doing nothing, because a silently
2275                // ignored key is indistinguishable from a missing binding.
2276                match self.selected_relationship_id() {
2277                    Some(id) => {
2278                        let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
2279                        self.mode = Mode::LinkEdit {
2280                            index,
2281                            buffer: String::new(),
2282                            step: LinkStep::Target,
2283                            target: None,
2284                            kind: None,
2285                        };
2286                        self.status_msg = Some(format!("Re-kind {id}"));
2287                    }
2288                    None => {
2289                        self.status_msg =
2290                            Some("no relationship selected — press j or k first".to_string())
2291                    }
2292                }
2293                Ok(Action::None)
2294            }
2295            KeyCode::Char('d') => {
2296                match self.selected_relationship().cloned() {
2297                    Some(rel) => {
2298                        let line = crate::commands::link::relationship_line(&rel);
2299                        let index = self.detail.as_ref().map(|d| d.rel_cursor).unwrap_or(0);
2300                        self.status_msg = Some(format!("Remove {line}? (y to confirm)"));
2301                        self.mode = Mode::ConfirmRemoveRelationship { index };
2302                    }
2303                    None => {
2304                        self.status_msg =
2305                            Some("no relationship selected — press j or k first".to_string())
2306                    }
2307                }
2308                Ok(Action::None)
2309            }
2310            KeyCode::Char('j') | KeyCode::Down => {
2311                self.move_rel_cursor(1);
2312                Ok(Action::None)
2313            }
2314            KeyCode::Char('k') | KeyCode::Up => {
2315                self.move_rel_cursor(-1);
2316                Ok(Action::None)
2317            }
2318            _ => Ok(Action::None),
2319        }
2320    }
2321
2322    fn handle_form_key(&mut self, key: KeyEvent) -> Result<Action> {
2323        if self.form_picker.is_some() {
2324            return self.handle_form_picker_key(key);
2325        }
2326        let form = match self.form.as_mut() {
2327            Some(f) => f,
2328            None => return Ok(Action::None),
2329        };
2330        match key.code {
2331            KeyCode::Esc => {
2332                self.form = None;
2333                self.mode = Mode::Board;
2334                Ok(Action::None)
2335            }
2336            KeyCode::Enter => {
2337                if form.is_date_focus() && !form.focused_date_is_complete() {
2338                    self.open_date_picker(Mode::Form);
2339                    return Ok(Action::None);
2340                }
2341                // A parent is chosen, never typed, so an **empty** Parent row has
2342                // nothing to submit and Enter means "choose". This is the same rule
2343                // the date fields follow one branch above — Enter opens the picker
2344                // only while the value is missing, and submits once it is there.
2345                //
2346                // Not "always open the picker", which was the first attempt: that
2347                // makes Parent a field the form can never be submitted from, because
2348                // Enter there never submits and the only way out is to tab away. A
2349                // chosen field that traps the submit key is worse than a plain text
2350                // field.
2351                if form.focus.is_parent() && form.parent.trim().is_empty() {
2352                    self.open_form_picker();
2353                    return Ok(Action::None);
2354                }
2355                if form.title.trim().is_empty() {
2356                    self.status_msg = Some("Title is required".to_string());
2357                    return Ok(Action::None);
2358                }
2359                let start_date = if form.start_date.trim().is_empty() {
2360                    None
2361                } else {
2362                    Some(form.start_date.trim().to_string())
2363                };
2364                let due_date = if form.due_date.trim().is_empty() {
2365                    None
2366                } else {
2367                    Some(form.due_date.trim().to_string())
2368                };
2369                if let Some(start) = start_date.as_deref()
2370                    && !crate::model::validate_date(start)
2371                {
2372                    self.status_msg = Some("Start date must be YYYY-MM-DD".to_string());
2373                    return Ok(Action::None);
2374                }
2375                if let Some(due) = due_date.as_deref()
2376                    && !crate::model::validate_date(due)
2377                {
2378                    self.status_msg = Some("Due date must be YYYY-MM-DD".to_string());
2379                    return Ok(Action::None);
2380                }
2381                if let (Some(start), Some(due)) = (start_date.as_deref(), due_date.as_deref())
2382                    && due < start
2383                {
2384                    self.status_msg = Some("Due date must be >= start date".to_string());
2385                    return Ok(Action::None);
2386                }
2387                let title = form.title.trim().to_string();
2388                let status_str = if form.status.trim().is_empty() {
2389                    self.store
2390                        .config
2391                        .default_config()
2392                        .columns
2393                        .first()
2394                        .cloned()
2395                        .unwrap_or_else(|| "todo".to_string())
2396                } else {
2397                    form.status.trim().to_string()
2398                };
2399                let priority: Priority = form.priority.parse().unwrap_or_default();
2400                let identity = git_identity();
2401                let assignee = if form.assignee.trim().is_empty() {
2402                    None
2403                } else {
2404                    resolve_me(form.assignee.trim(), identity.as_ref())
2405                };
2406                let labels: Vec<String> = form
2407                    .labels
2408                    .split(',')
2409                    .map(|s| s.trim().to_string())
2410                    .filter(|s| !s.is_empty())
2411                    .collect();
2412                let estimate = if form.estimate.trim().is_empty() {
2413                    None
2414                } else {
2415                    Some(form.estimate.trim().to_string())
2416                };
2417                let cycles = {
2418                    let values: Vec<String> = form
2419                        .cycles
2420                        .split(',')
2421                        .map(str::trim)
2422                        .filter(|s| !s.is_empty())
2423                        .map(str::to_string)
2424                        .collect();
2425                    (!values.is_empty()).then_some(values)
2426                };
2427                let milestones = {
2428                    let values: Vec<String> = form
2429                        .milestones
2430                        .split(',')
2431                        .map(str::trim)
2432                        .filter(|s| !s.is_empty())
2433                        .map(str::to_string)
2434                        .collect();
2435                    (!values.is_empty()).then_some(values)
2436                };
2437                let reviewer = if form.reviewer.trim().is_empty() {
2438                    None
2439                } else {
2440                    Some(form.reviewer.trim().to_string())
2441                };
2442
2443                // The parent is resolved and validated through the hierarchy
2444                // service **before** anything is written, exactly as
2445                // `commands::create` does it — same resolution, same
2446                // `validate_new_parent`, so a bad parent creates no file, no
2447                // number, and no History entry. The service's own diagnostic is
2448                // what reaches `status_msg`, so the TUI says what the CLI says
2449                // rather than a second wording of the same rule.
2450                //
2451                // `validate_new_parent`, not `set_parent`: the child does not exist
2452                // yet, so it cannot be its own parent and cannot close a cycle. A
2453                // self-parent and a cycle are both *unrepresentable* here, not merely
2454                // rejected.
2455                //
2456                // **This call is not test-covered, and the reason is worth stating
2457                // rather than leaving implied.** The picker's options come from the
2458                // store, so every value the user can choose is a live issue; and
2459                // `unknown_parent` needs a selector that *resolves* to a number the
2460                // index does not hold. `resolve_issue` and `hierarchy_index` each
2461                // re-read the store, so that state exists only for a delete landing
2462                // between the two reads — a window no single-threaded test can enter,
2463                // since deleting first makes `resolve_issue` fail instead (which is
2464                // the refusal `t027_a_rejected_parent_keeps_the_form_open...` does
2465                // assert). Reverting the line leaves the suite green, so this is
2466                // defence in depth against a concurrent delete, kept because the CLI
2467                // has the identical line and the charter's parity boundary is about
2468                // what both surfaces *do*, not about which of them is covered.
2469                //
2470                // A rejection returns to the form with the field untouched, so the
2471                // user keeps everything they typed and can pick again.
2472                let parent_number = match form.parent.trim() {
2473                    "" => None,
2474                    raw => match self.store.resolve_issue(raw).and_then(|target| {
2475                        let (index, _) = self.store.hierarchy_index()?;
2476                        crate::hierarchy::validate_new_parent(&index, target.number)?;
2477                        Ok(target.number)
2478                    }) {
2479                        Ok(number) => Some(number),
2480                        Err(e) => {
2481                            self.status_msg = Some(e.to_string());
2482                            return Ok(Action::None);
2483                        }
2484                    },
2485                };
2486
2487                let mut issue = Issue {
2488                    number: 0,
2489                    title,
2490                    status: Status::new(status_str),
2491                    labels,
2492                    priority,
2493                    assignee,
2494                    estimate,
2495                    commit: None,
2496                    gitlab_id: None,
2497                    linear_id: None,
2498                    mission: None,
2499                    task: None,
2500                    relationships: vec![],
2501                    files: vec![],
2502                    cycles,
2503                    milestones,
2504                    reviewer,
2505                    start_date,
2506                    due_date,
2507                    created: today(),
2508                    updated: today(),
2509                    filename: String::new(),
2510                    body: String::new(),
2511                    history: crate::model::History::new(),
2512                    has_retired_links_key: false,
2513                    parent: parent_number,
2514                    extra_frontmatter: std::collections::BTreeMap::new(),
2515                    archived: false,
2516                    entered_at: None,
2517                    source: None,
2518                };
2519                // Exactly one History entry, naming the parent (C-004). Appended to
2520                // the in-memory issue so `store.create` writes it with the first
2521                // content — an entry that only appeared on a later edit would
2522                // describe a create that did not happen this way. This is the step
2523                // the TUI's hand-built `Issue` was silently skipping (tix #1385).
2524                if let Some(number) = parent_number {
2525                    issue.history.append_now(
2526                        &crate::storage::actor_identity(),
2527                        crate::model::HistoryEventKind::ParentSet,
2528                        &crate::hierarchy::change_detail(None, Some(number)),
2529                    );
2530                }
2531                // The entry stamp, through the **same** rule the CLI's create core
2532                // uses: without it this form was the one create path that entered the
2533                // inbox without recording when, so the queue reported an `unknown` age
2534                // for an issue the TUI had just created. (The form still builds its own
2535                // `Issue` rather than calling `commands::create::create` — see tix #1385
2536                // for the parent/History line that cost once already; what must not be
2537                // duplicated is the *rule*, and it no longer is.) Applied **before** the
2538                // write, so the file and the stamp cannot disagree.
2539                crate::triage::record_entry_if_inbox(&self.store.config, &mut issue, "new")?;
2540                self.store.create(&mut issue, "", None)?;
2541                // `issue.filename` is the store-relative path (including the
2542                // assignee segment); the board's cards key on that, so select
2543                // with it — the basename would never match and the wrong card
2544                // would stay selected.
2545                let filename = issue.filename.clone();
2546                let basename = filename.rsplit('/').next().unwrap_or(&filename).to_string();
2547                self.status_msg = Some(format!("Created {basename}"));
2548                self.form = None;
2549                self.mode = Mode::Board;
2550                self.refresh_corpus()?;
2551                self.select_card_by_filename(&filename);
2552                Ok(Action::None)
2553            }
2554            KeyCode::Tab | KeyCode::Down => {
2555                form.move_focus_next();
2556                Ok(Action::None)
2557            }
2558            KeyCode::BackTab | KeyCode::Up => {
2559                form.move_focus_prev();
2560                Ok(Action::None)
2561            }
2562            KeyCode::F(2) => {
2563                if form.is_date_focus() {
2564                    self.open_date_picker(Mode::Form);
2565                } else {
2566                    self.open_form_picker();
2567                }
2568                Ok(Action::None)
2569            }
2570            KeyCode::Char(c) => {
2571                form.insert_char(c);
2572                Ok(Action::None)
2573            }
2574            KeyCode::Backspace => {
2575                form.delete_char();
2576                Ok(Action::None)
2577            }
2578            _ => Ok(Action::None),
2579        }
2580    }
2581
2582    /// Open the calendar picker, seeded from the focused date field of the
2583    /// modal identified by `return_to`.
2584    fn open_date_picker(&mut self, return_to: Mode) {
2585        let current = match return_to {
2586            Mode::Form => self.form.as_ref().map(|f| f.focused_value().to_string()),
2587            Mode::PlanningForm => self
2588                .planning_form
2589                .as_ref()
2590                .map(|f| f.focused_value().to_string()),
2591            Mode::FilterModal => self.filter_modal.as_ref().map(|m| m.focused_value()),
2592            _ => None,
2593        };
2594        self.date_picker = Some(DatePicker::new(current.as_deref().unwrap_or("")));
2595        self.mode = Mode::DatePicker {
2596            return_to: Box::new(return_to),
2597        };
2598    }
2599
2600    fn handle_date_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
2601        let Some(picker) = self.date_picker.as_mut() else {
2602            self.mode = Mode::Board;
2603            return Ok(Action::None);
2604        };
2605        match picker.handle_key(key.code) {
2606            DatePickerEvent::None => {}
2607            DatePickerEvent::Cancelled => self.close_date_picker(),
2608            DatePickerEvent::Selected(value) => {
2609                self.apply_date_selection(&value);
2610                self.close_date_picker();
2611            }
2612            DatePickerEvent::Cleared => {
2613                self.apply_date_selection("");
2614                self.close_date_picker();
2615            }
2616        }
2617        Ok(Action::None)
2618    }
2619
2620    /// Close the picker and restore the modal that opened it.
2621    fn close_date_picker(&mut self) {
2622        let return_to = match std::mem::replace(&mut self.mode, Mode::Board) {
2623            Mode::DatePicker { return_to } => *return_to,
2624            _ => Mode::Board,
2625        };
2626        self.date_picker = None;
2627        self.mode = return_to;
2628    }
2629
2630    /// Write the picked `value` (empty clears) into the modal the picker was
2631    /// opened from.
2632    fn apply_date_selection(&mut self, value: &str) {
2633        let return_to = match &self.mode {
2634            Mode::DatePicker { return_to } => (**return_to).clone(),
2635            _ => return,
2636        };
2637        match return_to {
2638            Mode::Form => {
2639                if let Some(form) = self.form.as_mut() {
2640                    form.set_focused_date(value);
2641                }
2642            }
2643            Mode::PlanningForm => {
2644                if let Some(form) = self.planning_form.as_mut() {
2645                    form.set_focused_date(value);
2646                }
2647            }
2648            Mode::FilterModal => {
2649                if let Some(modal) = self.filter_modal.as_mut() {
2650                    modal.set_focused_date(value);
2651                }
2652                self.rebuild_board();
2653            }
2654            _ => {}
2655        }
2656    }
2657
2658    fn handle_form_picker_key(&mut self, key: KeyEvent) -> Result<Action> {
2659        if matches!(key.code, KeyCode::Esc | KeyCode::F(2)) {
2660            self.form_picker = None;
2661            return Ok(Action::None);
2662        }
2663        let event = match self.form_picker.as_mut() {
2664            Some(picker) => picker.state.handle_key(key.code),
2665            None => return Ok(Action::None),
2666        };
2667        match event {
2668            PickerEvent::None => {}
2669            PickerEvent::Cancelled => {
2670                self.form_picker = None;
2671            }
2672            PickerEvent::Selected(ids) => {
2673                self.apply_picker_selection(ids);
2674            }
2675            PickerEvent::Created(value) => {
2676                self.apply_picker_created(value)?;
2677            }
2678        }
2679        Ok(Action::None)
2680    }
2681
2682    fn open_form_picker(&mut self) {
2683        let Some(form) = self.form.as_ref() else {
2684            return;
2685        };
2686        let field = match form.focus {
2687            form::FormFocus::Labels => FormPickerField::Labels,
2688            form::FormFocus::Assignee => FormPickerField::Assignee,
2689            form::FormFocus::Cycles => FormPickerField::Cycles,
2690            form::FormFocus::Milestones => FormPickerField::Milestones,
2691            form::FormFocus::Reviewer => FormPickerField::Reviewer,
2692            form::FormFocus::Parent => FormPickerField::Parent,
2693            _ => return,
2694        };
2695        let options = self.picker_options(field);
2696        let mut state = match field {
2697            FormPickerField::Labels | FormPickerField::Cycles | FormPickerField::Milestones => {
2698                DropdownState::multi(options)
2699            }
2700            FormPickerField::Assignee | FormPickerField::Reviewer | FormPickerField::Parent => {
2701                DropdownState::single(options)
2702            }
2703        };
2704        self.seed_picker_selection(field, &mut state);
2705        self.form_picker = Some(FormPicker { field, state });
2706    }
2707
2708    fn picker_options(&self, field: FormPickerField) -> Vec<PickerItem> {
2709        match field {
2710            FormPickerField::Cycles => {
2711                let root = self.project_root();
2712                let planner = Planner::from_config(self.store.config.clone(), root);
2713                planner
2714                    .cycle_names()
2715                    .into_iter()
2716                    .map(|name| PickerItem {
2717                        id: name.clone(),
2718                        label: name,
2719                    })
2720                    .collect()
2721            }
2722            FormPickerField::Milestones => {
2723                let root = self.project_root();
2724                let planner = Planner::from_config(self.store.config.clone(), root);
2725                planner
2726                    .milestone_names()
2727                    .unwrap_or_default()
2728                    .into_iter()
2729                    .map(|name| PickerItem {
2730                        id: name.clone(),
2731                        label: name,
2732                    })
2733                    .collect()
2734            }
2735            FormPickerField::Labels => {
2736                let mut out = std::collections::BTreeSet::new();
2737                for issue in self.store.list().unwrap_or_default() {
2738                    for label in issue.labels {
2739                        if !label.trim().is_empty() {
2740                            out.insert(label);
2741                        }
2742                    }
2743                }
2744                out.into_iter()
2745                    .map(|label| PickerItem {
2746                        id: label.clone(),
2747                        label,
2748                    })
2749                    .collect()
2750            }
2751            FormPickerField::Parent => {
2752                // Every issue in the store is a candidate parent.
2753                //
2754                // T026's note asks for the issue itself to be excluded from the
2755                // list, as "the first line of defense against a self-parent". The
2756                // form is **create-only** — `Mode::Form` has one entry point and it
2757                // always builds a fresh `FormState` — so there is no issue-under-edit
2758                // to exclude: the child does not exist yet and cannot be among its
2759                // own ancestors. The list is therefore unfiltered here, and the
2760                // defense is `hierarchy::validate_new_parent`, which runs at submit.
2761                // (Tracked as tix #1384: once an edit form exists, this arm must
2762                // exclude the edited issue, and `set_parent` becomes the write.)
2763                self.store
2764                    .list()
2765                    .unwrap_or_default()
2766                    .into_iter()
2767                    .map(|issue| PickerItem {
2768                        id: issue.number.to_string(),
2769                        label: format!("#{} {}", issue.number, issue.title),
2770                    })
2771                    .collect()
2772            }
2773            FormPickerField::Assignee | FormPickerField::Reviewer => {
2774                let mut out = std::collections::BTreeSet::new();
2775                for team in self.store.config.teams.values() {
2776                    for member in &team.members {
2777                        if !member.trim().is_empty() {
2778                            out.insert(member.trim().to_string());
2779                        }
2780                    }
2781                }
2782                if let Some(id) = git_identity() {
2783                    out.insert(id.to_string());
2784                }
2785                if let Some(form) = self.form.as_ref() {
2786                    let current = match field {
2787                        FormPickerField::Assignee => form.assignee.trim(),
2788                        FormPickerField::Reviewer => form.reviewer.trim(),
2789                        _ => "",
2790                    };
2791                    if !current.is_empty() {
2792                        out.insert(current.to_string());
2793                    }
2794                }
2795                out.into_iter()
2796                    .map(|member| PickerItem {
2797                        id: member.clone(),
2798                        label: member,
2799                    })
2800                    .collect()
2801            }
2802        }
2803    }
2804
2805    fn seed_picker_selection(&self, field: FormPickerField, picker: &mut DropdownState) {
2806        let Some(form) = self.form.as_ref() else {
2807            return;
2808        };
2809        let values: Vec<String> = match field {
2810            FormPickerField::Labels => form
2811                .labels
2812                .split(',')
2813                .map(str::trim)
2814                .filter(|s| !s.is_empty())
2815                .map(str::to_string)
2816                .collect(),
2817            FormPickerField::Cycles => form
2818                .cycles
2819                .split(',')
2820                .map(str::trim)
2821                .filter(|s| !s.is_empty())
2822                .map(str::to_string)
2823                .collect(),
2824            FormPickerField::Milestones => form
2825                .milestones
2826                .split(',')
2827                .map(str::trim)
2828                .filter(|s| !s.is_empty())
2829                .map(str::to_string)
2830                .collect(),
2831            FormPickerField::Assignee => form
2832                .assignee
2833                .trim()
2834                .split(',')
2835                .map(str::trim)
2836                .filter(|s| !s.is_empty())
2837                .map(str::to_string)
2838                .take(1)
2839                .collect(),
2840            FormPickerField::Reviewer => form
2841                .reviewer
2842                .trim()
2843                .split(',')
2844                .map(str::trim)
2845                .filter(|s| !s.is_empty())
2846                .map(str::to_string)
2847                .take(1)
2848                .collect(),
2849            // Already a resolved number, so no comma-splitting: there is exactly
2850            // one parent, and splitting it would corrupt a value that is legal.
2851            FormPickerField::Parent => (!form.parent.trim().is_empty())
2852                .then(|| form.parent.trim().to_string())
2853                .into_iter()
2854                .collect(),
2855        };
2856        match &mut picker.selection {
2857            SelectionModel::Single(current) => {
2858                *current = values.first().cloned();
2859            }
2860            SelectionModel::Multi(current) => {
2861                current.clear();
2862                current.extend(values);
2863            }
2864        }
2865    }
2866
2867    fn apply_picker_selection(&mut self, values: Vec<String>) {
2868        let Some(form) = self.form.as_mut() else {
2869            return;
2870        };
2871        let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
2872            return;
2873        };
2874        match field {
2875            FormPickerField::Labels => form.labels = values.join(","),
2876            FormPickerField::Assignee => {
2877                form.assignee = values.first().cloned().unwrap_or_default()
2878            }
2879            FormPickerField::Cycles => form.cycles = values.join(","),
2880            FormPickerField::Milestones => form.milestones = values.join(","),
2881            FormPickerField::Reviewer => {
2882                form.reviewer = values.first().cloned().unwrap_or_default()
2883            }
2884            // Single-select: at most one value, and no selection clears the field,
2885            // which is the same "empty means none" the CLI's `--parent ""` means.
2886            FormPickerField::Parent => form.parent = values.first().cloned().unwrap_or_default(),
2887        }
2888    }
2889
2890    fn apply_picker_created(&mut self, value: String) -> Result<()> {
2891        let value = value.trim().to_string();
2892        if value.is_empty() {
2893            return Ok(());
2894        }
2895        let Some(field) = self.form_picker.as_ref().map(|p| p.field) else {
2896            return Ok(());
2897        };
2898        match field {
2899            FormPickerField::Cycles => {
2900                let root = self.project_root();
2901                let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
2902                match planner.add_cycle(Some(value.clone())) {
2903                    Ok(_) => {
2904                        self.reload_config_from(root.as_path())?;
2905                    }
2906                    Err(e) => {
2907                        self.status_msg = Some(e.to_string());
2908                        return Ok(());
2909                    }
2910                }
2911            }
2912            FormPickerField::Milestones => {
2913                let root = self.project_root();
2914                let planner = Planner::from_config(self.store.config.clone(), root.clone());
2915                match planner.create_milestone_file(&value, None, None) {
2916                    Ok(_) => {
2917                        self.reload_config_from(root.as_path())?;
2918                    }
2919                    Err(e) => {
2920                        self.status_msg = Some(e.to_string());
2921                        return Ok(());
2922                    }
2923                }
2924            }
2925            FormPickerField::Assignee | FormPickerField::Reviewer => {
2926                let root = self.project_root();
2927                let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
2928                let team = self
2929                    .store
2930                    .config
2931                    .planning
2932                    .default_team
2933                    .clone()
2934                    .unwrap_or_else(|| "default".to_string());
2935                match planner.add_team_member(&team, &value) {
2936                    Ok(()) => {
2937                        self.reload_config_from(root.as_path())?;
2938                    }
2939                    Err(e) => {
2940                        self.status_msg = Some(e.to_string());
2941                        return Ok(());
2942                    }
2943                }
2944            }
2945            // `+` is inline-create: it makes a *new* cycle, milestone, or team
2946            // member, and appends it to the list. A parent is not something you
2947            // make from inside another issue's form — it is an issue that already
2948            // exists — so the keystroke does nothing here, exactly as it does for
2949            // Labels. Creating an empty issue to stand in for a parent would be
2950            // worse than nothing: it would write a file the user never asked for.
2951            FormPickerField::Labels | FormPickerField::Parent => {}
2952        }
2953
2954        let next_options = self.picker_options(field);
2955        if let Some(picker) = self.form_picker.as_mut() {
2956            picker.state.options = next_options;
2957            picker.state.filter.clear();
2958            picker.state.cursor = 0;
2959            picker.state.mode = PickerMode::Idle;
2960            picker.state.create_buffer.clear();
2961            match &mut picker.state.selection {
2962                SelectionModel::Single(current) => *current = Some(value.clone()),
2963                SelectionModel::Multi(current) => {
2964                    current.insert(value.clone());
2965                }
2966            }
2967            let values = match &picker.state.selection {
2968                SelectionModel::Single(current) => current.iter().cloned().collect(),
2969                SelectionModel::Multi(current) => current.iter().cloned().collect(),
2970            };
2971            self.apply_picker_selection(values);
2972        }
2973        Ok(())
2974    }
2975
2976    fn project_root(&self) -> PathBuf {
2977        Config::project_root(&self.store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
2978    }
2979
2980    fn reload_config_from(&mut self, root: &std::path::Path) -> Result<()> {
2981        let cfg = Config::load_with_warning(&root.join("tix.toml"), |_| {})?;
2982        self.bindings = cfg.effective_keybindings()?;
2983        self.store.config = cfg;
2984        Ok(())
2985    }
2986
2987    fn handle_confirm_delete_key(&mut self, key: KeyEvent) -> Result<Action> {
2988        match key.code {
2989            KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm_delete_yes(),
2990            _ => self.confirm_delete_no(),
2991        }
2992    }
2993
2994    /// Yes branch of the confirm-delete dialog — shared by the `y` key, the
2995    /// `Action::ConfirmDeleteYes` mouse hit (D6), and the run loop.
2996    fn confirm_delete_yes(&mut self) -> Result<Action> {
2997        if let Some(filename) = self.confirm_delete_target.take() {
2998            self.store.delete(&filename)?;
2999            self.status_msg = Some(format!("Deleted {filename}"));
3000        }
3001        self.mode = Mode::Board;
3002        self.rebuild_board();
3003        Ok(Action::None)
3004    }
3005
3006    /// Cancel branch of the confirm-delete dialog — shared by every other key,
3007    /// `Action::ConfirmDeleteNo`, and the run loop.
3008    fn confirm_delete_no(&mut self) -> Result<Action> {
3009        self.confirm_delete_target = None;
3010        self.mode = Mode::Board;
3011        Ok(Action::None)
3012    }
3013
3014    /// The blocked-move acknowledgement prompt (WP04, FR-004).
3015    ///
3016    /// Both branches go through [`mv::apply_guarded`], so the TUI's accept/reject
3017    /// decision, its diagnostic, and its History event are the *same code* the
3018    /// CLI runs. An unconfirmed move is not applied at all — the cancel branch
3019    /// changes nothing and returns to the board.
3020    fn handle_confirm_blocked_move_key(
3021        &mut self,
3022        key: KeyEvent,
3023        target_col: usize,
3024        warning: &str,
3025    ) -> Result<Action> {
3026        let confirmed = matches!(
3027            key.code,
3028            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter
3029        );
3030        if !confirmed {
3031            // Anything else cancels. Leaving the move unapplied is the safe
3032            // default: a prompt that guesses on an unrecognised key is a prompt
3033            // that can close blocked work by accident.
3034            self.mode = Mode::Board;
3035            self.status_msg = Some(format!("Move cancelled; {warning}"));
3036            return Ok(Action::None);
3037        }
3038
3039        // WP05: the confirmation **defers** to the shared move rather than
3040        // re-implementing it. This branch used to carry its own copy of the
3041        // completion check, the commit-prompt decision, and the `apply_guarded`
3042        // call — a third place to keep in step with the other two move paths, and
3043        // the one WP04's C-004 work had to be duplicated into by hand.
3044        //
3045        // `acknowledged = true` is the whole point of the re-entry: it is what
3046        // suppresses the guard's refusal, so re-entering with `false` would raise
3047        // this same dialog again.
3048        self.mode = Mode::Board;
3049        self.move_selected_to_impl(target_col, None, true)
3050    }
3051
3052    fn handle_move_into_done_key(&mut self, key: KeyEvent, target_col: usize) -> Result<Action> {
3053        match key.code {
3054            KeyCode::Enter => {
3055                let buffer = match &self.mode {
3056                    Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
3057                    _ => unreachable!(),
3058                };
3059
3060                let card = match self.board.get_selected_card().cloned() {
3061                    Some(c) => c,
3062                    None => {
3063                        self.mode = Mode::Board;
3064                        return Ok(Action::None);
3065                    }
3066                };
3067
3068                let target_name = self.board.column_name(target_col).to_string();
3069                let trimmed = buffer.trim();
3070                // Through the shared path, not a local `issue.status =` write — the
3071                // TUI must not write History itself, or the audit trail would be
3072                // surface-specific.
3073                //
3074                // **This is the one move site that does not re-enter
3075                // `move_selected_to_impl`,** and the reason is that it carries a
3076                // value the card does not hold: the hash the user just typed. Re-
3077                // entering would see `card.commit.is_none()` again and raise the very
3078                // prompt the user has just answered. So the enumeration NFR-002 is
3079                // checked against is *two* move writers — this one and
3080                // `move_selected_to_impl` — plus the create form, which is a column
3081                // check and not a transition.
3082                let moved = mv::apply_guarded(
3083                    &self.store.config,
3084                    &self.store,
3085                    &card.filename,
3086                    &target_name,
3087                    if trimmed.is_empty() {
3088                        None
3089                    } else {
3090                        Some(trimmed)
3091                    },
3092                    /* override_reason */ None,
3093                    true,
3094                )?;
3095
3096                self.rebuild_board();
3097                self.board.select_column(target_col);
3098                // Derived from the move's own outcome, not from how this prompt
3099                // was reached, so a move that was confirmed as blocked says so
3100                // here too rather than reporting itself as an ordinary move.
3101                let ack = if moved.acknowledged {
3102                    ", blocked move acknowledged"
3103                } else {
3104                    ""
3105                };
3106                self.status_msg = Some(match trimmed {
3107                    "" => format!(
3108                        "Moved #{} to {target_name} (no commit recorded{ack})",
3109                        moved.issue.number
3110                    ),
3111                    _ => format!(
3112                        "Moved #{} to {target_name} (commit: {}{ack})",
3113                        moved.issue.number, trimmed
3114                    ),
3115                });
3116                self.mode = Mode::Board;
3117                Ok(Action::None)
3118            }
3119            KeyCode::Esc => {
3120                self.mode = Mode::Board;
3121                Ok(Action::None)
3122            }
3123            KeyCode::Char(c) => {
3124                if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
3125                    buffer.push(c);
3126                }
3127                Ok(Action::None)
3128            }
3129            KeyCode::Backspace => {
3130                if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
3131                    buffer.pop();
3132                }
3133                Ok(Action::None)
3134            }
3135            _ => Ok(Action::None),
3136        }
3137    }
3138
3139    fn handle_help_key(&mut self, key: KeyEvent) -> Result<Action> {
3140        // tix #1496: while the `/` line has the keyboard, every key belongs to the
3141        // query. The list underneath filters as you type, so Enter only *commits* the
3142        // input (the query stays and the list keeps its filter) and Esc clears it — the
3143        // same bargain `/` makes on the board, so one gesture does not mean two things
3144        // in the same tool. Closing the overlay is one Esc away in both states.
3145        if self.help_searching {
3146            match key.code {
3147                KeyCode::Enter => self.help_searching = false,
3148                KeyCode::Esc => {
3149                    self.help_query.clear();
3150                    self.help_searching = false;
3151                }
3152                KeyCode::Backspace => {
3153                    self.help_query.pop();
3154                }
3155                KeyCode::Char(c) => self.help_query.push(c),
3156                _ => {}
3157            }
3158            // A narrower list starts at the top: keeping an offset from the wider list
3159            // would scroll the matches away before the reader saw them.
3160            self.help_scroll = 0;
3161            return Ok(Action::None);
3162        }
3163        match key.code {
3164            KeyCode::Char('?') | KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
3165                self.close_help();
3166            }
3167            // Not a toggle: `/` starts the input line, and the query it leaves behind is
3168            // what filters the list. Reopening it with the query still there is how you
3169            // extend a search, and Esc inside it is how you clear one.
3170            KeyCode::Char('/') => self.help_searching = true,
3171            // tix #1441/#1442: the overlay scrolls — one row for the cursor keys its
3172            // context already binds (`next`/`previous`, which until now the help
3173            // advertised and this handler ignored), one page for PgUp/PgDn, one wheel
3174            // notch for the wheel. The same three steps the detail pane uses, because
3175            // "scroll" meaning something different per surface is how a keyboard stops
3176            // being learnable.
3177            KeyCode::Down | KeyCode::Char('j') => self.scroll_help(1),
3178            KeyCode::Up | KeyCode::Char('k') => self.scroll_help(-1),
3179            KeyCode::PageDown => self.scroll_help(i32::from(self.help_page())),
3180            KeyCode::PageUp => self.scroll_help(-i32::from(self.help_page())),
3181            _ => {}
3182        }
3183        Ok(Action::None)
3184    }
3185
3186    /// Rows one page step moves in the overlay: what fits, less a row of overlap.
3187    fn help_page(&self) -> u16 {
3188        self.help_metrics.get().visible.saturating_sub(1).max(1)
3189    }
3190
3191    /// Open the keybindings overlay, always at the top and unfiltered (tix #1441).
3192    ///
3193    /// A help screen that reappeared halfway down — from a scroll the user made some
3194    /// minutes ago — would hide the first bindings from exactly the person who opened
3195    /// it to look them up. The same goes for a `/` query left over from last time
3196    /// (tix #1496).
3197    fn open_help(&mut self, return_to: Mode) {
3198        self.help_scroll = 0;
3199        self.help_query.clear();
3200        self.help_searching = false;
3201        self.mode = Mode::Help {
3202            return_to: Box::new(return_to),
3203        };
3204    }
3205
3206    /// Scroll the overlay by `rows` (negative scrolls up), clamped to the list.
3207    ///
3208    /// The clamp is what makes the last row reachable *and* keeps the list from being
3209    /// scrolled into emptiness — a bound the old truncating overlay did not need
3210    /// because it could not move at all.
3211    fn scroll_help(&mut self, rows: i32) {
3212        let max = i32::from(self.help_metrics.get().max_scroll);
3213        let next = i32::from(self.help_scroll) + rows;
3214        self.help_scroll = next.clamp(0, max) as u16;
3215    }
3216
3217    fn close_help(&mut self) {
3218        if let Mode::Help { return_to } = std::mem::replace(&mut self.mode, Mode::Board) {
3219            self.mode = *return_to;
3220        }
3221    }
3222
3223    fn handle_filter_key(&mut self, key: KeyEvent) -> Result<Action> {
3224        match key.code {
3225            KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
3226                self.filter = None;
3227                self.board = BoardState::build(&self.store, self.active_board_config());
3228                self.status_msg = None;
3229                self.mode = Mode::Board;
3230            }
3231            KeyCode::Enter => {
3232                self.mode = Mode::Board;
3233            }
3234            KeyCode::Char(c) => {
3235                let mut next = match &self.mode {
3236                    Mode::Filter { buffer } => buffer.clone(),
3237                    _ => String::new(),
3238                };
3239                next.push(c);
3240                self.mode = Mode::Filter {
3241                    buffer: next.clone(),
3242                };
3243                self.apply_filter(&next);
3244            }
3245            KeyCode::Backspace => {
3246                let mut next = match &self.mode {
3247                    Mode::Filter { buffer } => buffer.clone(),
3248                    _ => String::new(),
3249                };
3250                next.pop();
3251                self.mode = Mode::Filter {
3252                    buffer: next.clone(),
3253                };
3254                self.apply_filter(&next);
3255            }
3256            _ => {}
3257        }
3258        Ok(Action::None)
3259    }
3260
3261    /// The structured filter to apply right now: the live in-progress criteria
3262    /// while the modal is open, otherwise the committed one.
3263    fn active_structured_filter(&self) -> &search::StructuredFilter {
3264        self.filter_modal
3265            .as_ref()
3266            .map(|m| m.active_filter())
3267            .unwrap_or(&self.structured)
3268    }
3269
3270    /// Open the structured filter modal seeded from the committed filter.
3271    fn open_filter_modal(&mut self) {
3272        self.filter_modal = Some(crate::ui::filter_modal::FilterModal::with_filter(
3273            self.structured.clone(),
3274        ));
3275        self.mode = Mode::FilterModal;
3276    }
3277
3278    /// FilterModal keys: Tab/Shift+Tab navigate criterion rows; `r` resets all
3279    /// criteria (FR-014); Esc cancels; Enter normalizes + commits. Free-text
3280    /// typing edits the focused field and re-renders live (FR-013). All
3281    /// criteria run over the corpus in the same single pass as the query
3282    /// (NFR-001) via [`BoardState::build_filtered`].
3283    fn handle_filter_modal_key(&mut self, key: KeyEvent) -> Result<Action> {
3284        let Some(modal) = self.filter_modal.as_mut() else {
3285            self.mode = Mode::Board;
3286            return Ok(Action::None);
3287        };
3288        match key.code {
3289            KeyCode::Esc => {
3290                self.filter_modal = None;
3291                self.mode = Mode::Board;
3292                self.rebuild_board();
3293            }
3294            KeyCode::Tab | KeyCode::Down => modal.focus_next(),
3295            KeyCode::BackTab | KeyCode::Up => modal.focus_prev(),
3296            KeyCode::Char('r') | KeyCode::Char('R') => modal.clear(),
3297            KeyCode::Enter => {
3298                if modal.is_date_focus() && !modal.focused_date_is_complete() {
3299                    self.open_date_picker(Mode::FilterModal);
3300                } else {
3301                    let mut filter = modal.active_filter().clone();
3302                    filter.normalize();
3303                    self.structured = filter;
3304                    self.filter_modal = None;
3305                    self.mode = Mode::Board;
3306                    self.rebuild_board();
3307                }
3308            }
3309            KeyCode::Backspace => {
3310                modal.backspace();
3311                self.rebuild_board();
3312            }
3313            KeyCode::Char(c) => {
3314                modal.type_char(c);
3315                self.rebuild_board();
3316            }
3317            KeyCode::F(2) => {
3318                if modal.is_date_focus() {
3319                    self.open_date_picker(Mode::FilterModal);
3320                }
3321            }
3322            _ => {}
3323        }
3324        Ok(Action::None)
3325    }
3326
3327    fn handle_comment_key(&mut self, key: KeyEvent) -> Result<Action> {
3328        match key.code {
3329            KeyCode::Esc => {
3330                self.mode = Mode::Detail;
3331                Ok(Action::None)
3332            }
3333            KeyCode::Enter => self.submit_comment(),
3334            KeyCode::Char(c) => {
3335                if let Mode::Comment { ref mut buffer } = self.mode {
3336                    buffer.push(c);
3337                }
3338                Ok(Action::None)
3339            }
3340            KeyCode::Backspace => {
3341                if let Mode::Comment { ref mut buffer } = self.mode {
3342                    buffer.pop();
3343                }
3344                Ok(Action::None)
3345            }
3346            _ => Ok(Action::None),
3347        }
3348    }
3349
3350    /// Move the board selection by one screen of cards (tix #1442 follow-up).
3351    ///
3352    /// A no-op before the first draw (nothing has reported a page yet) and in an empty
3353    /// column (the renderer reports zero, because there is nothing to page).
3354    fn move_selection_by_page(&mut self, direction: i32) {
3355        let page = self.board_page.get();
3356        if page == 0 {
3357            return;
3358        }
3359        let step = i32::try_from(page).unwrap_or(i32::MAX);
3360        self.board.move_selection_by(step * direction);
3361    }
3362
3363    /// Open the create-issue form — `n` on the board, and where `tix new` starts
3364    /// the session (#1398).
3365    ///
3366    /// Seeds the first configured column and the git identity (FR-028, best effort:
3367    /// empty when git is absent, never an error). One method rather than two copies,
3368    /// because the key and the command must produce the *same* form — two entry
3369    /// points into one dialog that default differently is how a bug is born.
3370    pub fn open_new_issue_form(&mut self) {
3371        let default_status = self
3372            .store
3373            .config
3374            .default_config()
3375            .columns
3376            .first()
3377            .cloned()
3378            .unwrap_or_else(|| "todo".to_string());
3379        let default_assignee = crate::assignee::git_identity()
3380            .map(|id| id.to_string())
3381            .unwrap_or_default();
3382        self.form = Some(FormState::with_default_assignee(
3383            &default_status,
3384            &default_assignee,
3385        ));
3386        self.mode = Mode::Form;
3387    }
3388
3389    /// Open the settings modal (`s`), optionally seeding an inline prompt
3390    /// (e.g. `b` starts a new-board prompt).
3391    fn open_settings(&mut self, prompt: Option<SettingsPrompt>) {
3392        self.settings = Some(SettingsModal {
3393            selected: 0,
3394            prompt,
3395        });
3396        self.mode = Mode::Settings;
3397    }
3398
3399    /// Settings modal keys: `j/k`/arrows move the column cursor; Space/Enter
3400    /// toggles column visibility; `a` adds a column, `n` creates a board, `x`
3401    /// deletes the current board (y/N confirm); Tab switches board; Inline
3402    /// prompts accept text + Enter, Esc cancels. Every mutation persists to
3403    /// `tix.toml` and rebuilds the board.
3404    fn handle_settings_key(&mut self, key: KeyEvent) -> Result<Action> {
3405        if self.settings.is_none() {
3406            self.mode = Mode::Board;
3407            return Ok(Action::None);
3408        }
3409        let prompt_active = self.settings.as_ref().and_then(|m| m.prompt.clone());
3410        let Some(p) = prompt_active else {
3411            return self.handle_settings_browse(key);
3412        };
3413        match key.code {
3414            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3415                if let Some(ref mut m) = self.settings {
3416                    m.prompt = None;
3417                }
3418                Ok(Action::None)
3419            }
3420            KeyCode::Enter | KeyCode::Char('y') | KeyCode::Char('Y') => {
3421                match p {
3422                    SettingsPrompt::AddColumn(name) => self.settings_commit_add_column(name),
3423                    SettingsPrompt::NewBoard(name) => self.settings_commit_new_board(name),
3424                    SettingsPrompt::ConfirmDeleteBoard(name) => {
3425                        self.settings_commit_delete_board(name)
3426                    }
3427                }
3428                Ok(Action::None)
3429            }
3430            KeyCode::Char(c) => {
3431                if let Some(ref mut m) = self.settings
3432                    && let Some(SettingsPrompt::AddColumn(ref mut b))
3433                    | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
3434                {
3435                    b.push(c);
3436                }
3437                Ok(Action::None)
3438            }
3439            KeyCode::Backspace => {
3440                if let Some(ref mut m) = self.settings
3441                    && let Some(SettingsPrompt::AddColumn(ref mut b))
3442                    | Some(SettingsPrompt::NewBoard(ref mut b)) = m.prompt
3443                {
3444                    b.pop();
3445                }
3446                Ok(Action::None)
3447            }
3448            _ => Ok(Action::None),
3449        }
3450    }
3451
3452    /// Browsing state: no inline prompt is active.
3453    fn handle_settings_browse(&mut self, key: KeyEvent) -> Result<Action> {
3454        match key.code {
3455            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3456                self.settings = None;
3457                self.mode = Mode::Board;
3458                self.rebuild_board();
3459                Ok(Action::None)
3460            }
3461            KeyCode::Up | KeyCode::Char('k') => {
3462                let count =
3463                    self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
3464                if let Some(ref mut m) = self.settings {
3465                    m.move_selected(-1, count);
3466                }
3467                Ok(Action::None)
3468            }
3469            KeyCode::Down | KeyCode::Char('j') => {
3470                let count =
3471                    self.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
3472                if let Some(ref mut m) = self.settings {
3473                    m.move_selected(1, count);
3474                }
3475                Ok(Action::None)
3476            }
3477            KeyCode::Tab => {
3478                self.settings_next_board();
3479                Ok(Action::None)
3480            }
3481            KeyCode::BackTab => {
3482                self.settings_prev_board();
3483                Ok(Action::None)
3484            }
3485            KeyCode::Char(' ') | KeyCode::Enter => self.settings_toggle_selected(),
3486            KeyCode::Char('a') => {
3487                if let Some(ref mut m) = self.settings {
3488                    m.prompt = Some(SettingsPrompt::AddColumn(String::new()));
3489                }
3490                Ok(Action::None)
3491            }
3492            KeyCode::Char('n') => {
3493                if let Some(ref mut m) = self.settings {
3494                    m.prompt = Some(SettingsPrompt::NewBoard(String::new()));
3495                }
3496                Ok(Action::None)
3497            }
3498            KeyCode::Char('x') | KeyCode::Char('X') => {
3499                if self.store.config.configs().count() <= 1 {
3500                    self.status_msg = Some("Cannot delete the last board".to_string());
3501                    return Ok(Action::None);
3502                }
3503                let name = self.active_board_name().to_string();
3504                if let Some(ref mut m) = self.settings {
3505                    m.prompt = Some(SettingsPrompt::ConfirmDeleteBoard(name));
3506                }
3507                Ok(Action::None)
3508            }
3509            _ => Ok(Action::None),
3510        }
3511    }
3512
3513    fn settings_next_board(&mut self) {
3514        self.tabs.next();
3515        self.active_config = self.tabs.selected;
3516        self.switch_config();
3517        if let Some(ref mut m) = self.settings {
3518            m.selected = 0;
3519        }
3520    }
3521
3522    fn settings_prev_board(&mut self) {
3523        self.tabs.previous();
3524        self.active_config = self.tabs.selected;
3525        self.switch_config();
3526        if let Some(ref mut m) = self.settings {
3527            m.selected = 0;
3528        }
3529    }
3530
3531    fn open_planning_modal(&mut self) {
3532        let modal = self.planning.get_or_insert_with(PlanningModal::new);
3533        modal.prompt = None;
3534        modal.selected = 0;
3535        self.mode = Mode::PlanningManagement;
3536    }
3537
3538    /// The rows the planning modal renders, read straight from the registry:
3539    /// cycles from `cycle_names()` + `cycle()`, milestones from
3540    /// `list_milestones()`, referencing issue counts from `store.list()`
3541    /// (same computation as `tix cycle list` / `milestone list`).
3542    fn planning_rows(&self) -> (Vec<PlanningRow>, Vec<PlanningRow>) {
3543        let root = self.project_root();
3544        let planner = Planner::from_config(self.store.config.clone(), root);
3545        let issues = self.store.list().unwrap_or_default();
3546
3547        let mut cycles = Vec::new();
3548        for name in planner.cycle_names() {
3549            let cycle = planner.cycle(&name);
3550            let issues = issues
3551                .iter()
3552                .filter(|issue| {
3553                    issue
3554                        .cycles
3555                        .as_ref()
3556                        .is_some_and(|cs| cs.iter().any(|c| c == &name))
3557                })
3558                .count();
3559            cycles.push(PlanningRow {
3560                name,
3561                description: cycle
3562                    .and_then(|c| c.description.clone())
3563                    .unwrap_or_default(),
3564                date_a: cycle.and_then(|c| c.start_date.clone()).unwrap_or_default(),
3565                date_b: Some(cycle.and_then(|c| c.due_date.clone()).unwrap_or_default()),
3566                issues,
3567            });
3568        }
3569
3570        let mut milestones = Vec::new();
3571        if let Ok(list) = planner.list_milestones() {
3572            for ms in list {
3573                let issues = issues
3574                    .iter()
3575                    .filter(|issue| {
3576                        issue
3577                            .milestones
3578                            .as_ref()
3579                            .is_some_and(|ml| ml.iter().any(|x| x == &ms.name))
3580                    })
3581                    .count();
3582                milestones.push(PlanningRow {
3583                    name: ms.name,
3584                    description: ms.description,
3585                    date_a: ms.target_date,
3586                    date_b: None,
3587                    issues,
3588                });
3589            }
3590        }
3591        (cycles, milestones)
3592    }
3593
3594    /// The number of rows in the modal's active segment (for cursor clamping).
3595    fn planning_rows_len(&self) -> usize {
3596        let (cycles, milestones) = self.planning_rows();
3597        match self.planning.as_ref().map(|m| m.segment) {
3598            Some(PlanningSegment::Cycles) => cycles.len(),
3599            Some(PlanningSegment::Milestones) => milestones.len(),
3600            None => 0,
3601        }
3602    }
3603
3604    /// Planning modal keys: browse the list (`j/k`/arrows, Tab segment switch,
3605    /// `n` create, `e` edit, `x` delete confirm); a `ConfirmDelete` prompt
3606    /// accepts `y`/Enter or cancels on `n`/Esc.
3607    fn handle_planning_key(&mut self, key: KeyEvent) -> Result<Action> {
3608        if self.planning.is_none() {
3609            self.mode = Mode::Board;
3610            return Ok(Action::None);
3611        }
3612        if let Some(prompt) = self.planning.as_ref().and_then(|m| m.prompt.clone()) {
3613            return self.handle_planning_confirm_key(key, prompt);
3614        }
3615        match key.code {
3616            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3617                self.planning = None;
3618                self.mode = Mode::Board;
3619                Ok(Action::None)
3620            }
3621            KeyCode::Up | KeyCode::Char('k') => {
3622                let len = self.planning_rows_len();
3623                if let Some(ref mut m) = self.planning {
3624                    m.move_selected(-1, len);
3625                }
3626                Ok(Action::None)
3627            }
3628            KeyCode::Down | KeyCode::Char('j') => {
3629                let len = self.planning_rows_len();
3630                if let Some(ref mut m) = self.planning {
3631                    m.move_selected(1, len);
3632                }
3633                Ok(Action::None)
3634            }
3635            KeyCode::Tab | KeyCode::BackTab => {
3636                if let Some(ref mut m) = self.planning {
3637                    m.switch_segment();
3638                }
3639                Ok(Action::None)
3640            }
3641            KeyCode::Char('n') => {
3642                self.planning_open_create();
3643                Ok(Action::None)
3644            }
3645            KeyCode::Char('e') => self.planning_open_edit(),
3646            KeyCode::Char('x') | KeyCode::Char('X') => self.planning_confirm_delete(),
3647            _ => Ok(Action::None),
3648        }
3649    }
3650
3651    /// Keys while a delete confirmation prompt is up inside the modal.
3652    fn handle_planning_confirm_key(
3653        &mut self,
3654        key: KeyEvent,
3655        prompt: PlanningPrompt,
3656    ) -> Result<Action> {
3657        let PlanningPrompt::ConfirmDelete { name, .. } = prompt;
3658        match key.code {
3659            KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
3660                match self.planning_delete(&name) {
3661                    Ok(()) => {
3662                        self.status_msg = Some(format!("Deleted {name}"));
3663                    }
3664                    Err(e) => {
3665                        self.status_msg = Some(format!("Cannot delete {name}: {e}"));
3666                    }
3667                }
3668                if let Some(ref mut m) = self.planning {
3669                    m.prompt = None;
3670                }
3671                Ok(Action::None)
3672            }
3673            _ => {
3674                if let Some(ref mut m) = self.planning {
3675                    m.prompt = None;
3676                }
3677                Ok(Action::None)
3678            }
3679        }
3680    }
3681
3682    /// `n`: open a blank create form for the active segment.
3683    fn planning_open_create(&mut self) {
3684        let kind = match self.planning.as_ref().map(|m| m.segment) {
3685            Some(PlanningSegment::Milestones) => PlanningKind::Milestone,
3686            _ => PlanningKind::Cycle,
3687        };
3688        self.planning_form = Some(PlanningForm::new(kind));
3689        self.mode = Mode::PlanningForm;
3690    }
3691
3692    /// `e`: open an edit form pre-filled from the selected item.
3693    fn planning_open_edit(&mut self) -> Result<Action> {
3694        let Some(modal) = self.planning.as_ref() else {
3695            return Ok(Action::None);
3696        };
3697        let root = self.project_root();
3698        let planner = Planner::from_config(self.store.config.clone(), root);
3699        let form = match modal.segment {
3700            PlanningSegment::Cycles => {
3701                let name = match planner.cycle_names().into_iter().nth(modal.selected) {
3702                    Some(name) => name,
3703                    None => return Ok(Action::None),
3704                };
3705                let Some(cycle) = planner.cycle(&name) else {
3706                    return Ok(Action::None);
3707                };
3708                PlanningForm::from_cycle(&name, cycle)
3709            }
3710            PlanningSegment::Milestones => {
3711                let list = planner.list_milestones().unwrap_or_default();
3712                let Some(ms) = list.into_iter().nth(modal.selected) else {
3713                    return Ok(Action::None);
3714                };
3715                PlanningForm::from_milestone(&ms)
3716            }
3717        };
3718        self.planning_form = Some(form);
3719        self.mode = Mode::PlanningForm;
3720        Ok(Action::None)
3721    }
3722
3723    /// `x`: compute the delete impact for the selected item and raise the
3724    /// inline confirm prompt (spec: reference count shown, issues untouched).
3725    fn planning_confirm_delete(&mut self) -> Result<Action> {
3726        let Some(modal) = self.planning.as_ref() else {
3727            return Ok(Action::None);
3728        };
3729        if modal.prompt.is_some() {
3730            return Ok(Action::None);
3731        }
3732        let root = self.project_root();
3733        let planner = Planner::from_config(self.store.config.clone(), root);
3734        let (name, referencing) = match modal.segment {
3735            PlanningSegment::Cycles => {
3736                let Some(name) = planner.cycle_names().into_iter().nth(modal.selected) else {
3737                    return Ok(Action::None);
3738                };
3739                let referencing = planner
3740                    .delete_impact(&self.store, &name)
3741                    .map(|i| i.referencing_issues.len())
3742                    .unwrap_or(0);
3743                (name, referencing)
3744            }
3745            PlanningSegment::Milestones => {
3746                let list = planner.list_milestones().unwrap_or_default();
3747                let Some(name) = list.into_iter().nth(modal.selected).map(|m| m.name) else {
3748                    return Ok(Action::None);
3749                };
3750                let referencing = planner
3751                    .milestone_delete_impact(&self.store, &name)
3752                    .map(|i| i.referencing_issues.len())
3753                    .unwrap_or(0);
3754                (name, referencing)
3755            }
3756        };
3757        if let Some(ref mut m) = self.planning {
3758            m.prompt = Some(PlanningPrompt::ConfirmDelete { name, referencing });
3759        }
3760        Ok(Action::None)
3761    }
3762
3763    /// Confirm-delete an item, leaving referencing issues unchanged. Cycle
3764    /// deletion mutates `tix.toml`, so the store config is reloaded for
3765    /// pickers built from it.
3766    fn planning_delete(&mut self, name: &str) -> Result<()> {
3767        let root = self.project_root();
3768        let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
3769        match self.planning.as_ref().map(|m| m.segment) {
3770            Some(PlanningSegment::Cycles) => {
3771                planner.delete_cycle(name)?;
3772                self.reload_config_from(&root)?;
3773            }
3774            Some(PlanningSegment::Milestones) => {
3775                planner.delete_milestone(name)?;
3776            }
3777            None => {}
3778        }
3779        self.refresh_corpus()?;
3780        Ok(())
3781    }
3782
3783    /// Planning form keys. Cancel closes the form back to the modal; the
3784    /// remaining keys (field navigation, typing, Enter/apply) live in
3785    /// `handle_planning_form_edit_key`.
3786    fn handle_planning_form_key(&mut self, key: KeyEvent) -> Result<Action> {
3787        if self.planning_form.is_none() {
3788            self.mode = Mode::PlanningManagement;
3789            return Ok(Action::None);
3790        }
3791        match key.code {
3792            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('Q') => {
3793                self.planning_form = None;
3794                self.mode = Mode::PlanningManagement;
3795                Ok(Action::None)
3796            }
3797            _ => self.handle_planning_form_edit_key(key),
3798        }
3799    }
3800
3801    /// Form field navigation and editing, plus Enter to apply (task groups
3802    /// 3/4 fill in the mutations).
3803    fn handle_planning_form_edit_key(&mut self, key: KeyEvent) -> Result<Action> {
3804        if self.planning_form.is_none() {
3805            return Ok(Action::None);
3806        }
3807        match key.code {
3808            KeyCode::Enter => {
3809                if self
3810                    .planning_form
3811                    .as_ref()
3812                    .is_some_and(|f| f.focused_is_date() && !f.focused_date_is_complete())
3813                {
3814                    self.open_date_picker(Mode::PlanningForm);
3815                    Ok(Action::None)
3816                } else {
3817                    self.planning_form_apply()
3818                }
3819            }
3820            KeyCode::Tab | KeyCode::Down => {
3821                if let Some(ref mut f) = self.planning_form {
3822                    f.focus_next();
3823                }
3824                Ok(Action::None)
3825            }
3826            KeyCode::BackTab | KeyCode::Up => {
3827                if let Some(ref mut f) = self.planning_form {
3828                    f.focus_prev();
3829                }
3830                Ok(Action::None)
3831            }
3832            KeyCode::Backspace => {
3833                if let Some(ref mut f) = self.planning_form {
3834                    f.delete_char();
3835                }
3836                Ok(Action::None)
3837            }
3838            KeyCode::Char(c) => {
3839                if let Some(ref mut f) = self.planning_form {
3840                    f.insert_char(c);
3841                }
3842                Ok(Action::None)
3843            }
3844            KeyCode::F(2) => {
3845                if self
3846                    .planning_form
3847                    .as_ref()
3848                    .is_some_and(|f| f.focused_is_date())
3849                {
3850                    self.open_date_picker(Mode::PlanningForm);
3851                }
3852                Ok(Action::None)
3853            }
3854            _ => Ok(Action::None),
3855        }
3856    }
3857
3858    /// Apply the open form (create or edit). Filled in by task group 3
3859    /// (create) and 4 (edit/rename cascades).
3860    fn planning_form_apply(&mut self) -> Result<Action> {
3861        let Some(form) = self.planning_form.clone() else {
3862            self.mode = Mode::PlanningManagement;
3863            return Ok(Action::None);
3864        };
3865        let result = self.planning_form_commit(&form);
3866        match result {
3867            Ok(()) => {
3868                self.planning_form = None;
3869                self.mode = Mode::PlanningManagement;
3870                Ok(Action::None)
3871            }
3872            Err(e) => {
3873                self.status_msg = Some(format!("Cannot save {}: {e}", form.kind.noun()));
3874                Ok(Action::None)
3875            }
3876        }
3877    }
3878
3879    fn planning_form_commit(&mut self, form: &PlanningForm) -> Result<()> {
3880        let root = self.project_root();
3881        let mut planner = Planner::from_config(self.store.config.clone(), root.clone());
3882        let name = form.name().trim().to_string();
3883        if name.is_empty() {
3884            return Err(TixError::InvalidInput("a name is required".to_string()));
3885        }
3886        match form.kind {
3887            PlanningKind::Cycle => {
3888                if let Some(old) = form.editing.as_deref() {
3889                    if old != name {
3890                        planner.rename_cycle_cascade(&self.store, old, &name)?;
3891                    }
3892                    planner.set_cycle_fields(
3893                        &name,
3894                        Some(form.description()),
3895                        Some(form.date_a()),
3896                        Some(form.date_b()),
3897                    )?;
3898                } else {
3899                    planner.add_cycle(Some(name.clone()))?;
3900                    planner.set_cycle_fields(
3901                        &name,
3902                        Some(form.description()),
3903                        Some(form.date_a()),
3904                        Some(form.date_b()),
3905                    )?;
3906                }
3907                self.reload_config_from(&root)?;
3908            }
3909            PlanningKind::Milestone => {
3910                let target = if form.date_a().trim().is_empty() {
3911                    None
3912                } else {
3913                    Some(form.date_a())
3914                };
3915                if let Some(old) = form.editing.as_deref() {
3916                    if old != name {
3917                        let old_milestone = planner.milestone(old)?;
3918                        planner.rename_milestone_cascade(&self.store, &old_milestone, &name)?;
3919                    }
3920                    planner.update_milestone(&name, target, Some(form.description()))?;
3921                } else {
3922                    planner.create_milestone_file(&name, target, Some(form.description()))?;
3923                }
3924            }
3925        }
3926        self.refresh_corpus()?;
3927        Ok(())
3928    }
3929
3930    fn settings_toggle_selected(&mut self) -> Result<Action> {
3931        let col_count = self.active_board_config().columns.len();
3932        let selected = self.settings.as_ref().map(|m| m.selected).unwrap_or(0);
3933        if selected >= col_count {
3934            return self.settings_toggle_display_row(selected - col_count);
3935        }
3936        let col = self
3937            .settings
3938            .as_ref()
3939            .and_then(|m| self.active_board_config().columns.get(m.selected))
3940            .cloned();
3941        let Some(col) = col else {
3942            return Ok(Action::None);
3943        };
3944        let board_name = self.active_board_name().to_string();
3945        let now_hidden = {
3946            let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
3947            match board_config {
3948                Some(bc) => {
3949                    if let Some(pos) = bc.hidden_columns.iter().position(|h| h == &col) {
3950                        bc.hidden_columns.remove(pos);
3951                        false
3952                    } else {
3953                        bc.hidden_columns.push(col.clone());
3954                        true
3955                    }
3956                }
3957                None => return Ok(Action::None),
3958            }
3959        };
3960        if !self.persist_config() {
3961            return Ok(Action::None);
3962        }
3963        self.rebuild_board();
3964        let last = self.active_board_config().columns.len().saturating_sub(1);
3965        if let Some(ref mut m) = self.settings
3966            && m.selected > last
3967        {
3968            m.selected = last;
3969        }
3970        self.status_msg = Some(match now_hidden {
3971            true => format!("'{col}' hidden"),
3972            false => format!("'{col}' shown"),
3973        });
3974        Ok(Action::None)
3975    }
3976
3977    /// Toggle/cycle one of the display rows (after the column checkboxes).
3978    /// Row 0 flips title wrapping, row 1 cycles the line limit through 1-5,
3979    /// row 2 cycles the ordering; every change persists and rebuilds the board.
3980    fn settings_toggle_display_row(&mut self, row: usize) -> Result<Action> {
3981        let board_name = self.active_board_name().to_string();
3982        let action_msg = {
3983            let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
3984            let Some(board_config) = board_config else {
3985                return Ok(Action::None);
3986            };
3987            match row {
3988                0 => {
3989                    board_config.wrap_titles = !board_config.wrap_titles;
3990                    if board_config.wrap_titles {
3991                        "Wrap titles: on".to_string()
3992                    } else {
3993                        "Wrap titles: off".to_string()
3994                    }
3995                }
3996                1 => {
3997                    board_config.title_lines = board_config.title_lines % 5 + 1;
3998                    format!("Title lines: {}", board_config.title_lines)
3999                }
4000                2 => {
4001                    board_config.order_by = board_config.order_by.next();
4002                    format!("Order by: {}", board_config.order_by.as_str())
4003                }
4004                _ => return Ok(Action::None),
4005            }
4006        };
4007        if !self.persist_config() {
4008            return Ok(Action::None);
4009        }
4010        self.rebuild_board();
4011        self.status_msg = Some(action_msg);
4012        Ok(Action::None)
4013    }
4014
4015    fn settings_commit_add_column(&mut self, name: String) {
4016        let name = name.trim().to_string();
4017        if name.is_empty() {
4018            self.status_msg = Some("Column name is required".to_string());
4019            return;
4020        }
4021        let board_name = self.active_board_name().to_string();
4022        let board_config = self.store.config.kanban.configurations.get_mut(&board_name);
4023        let Some(board_config) = board_config else {
4024            self.status_msg = Some("board not found".to_string());
4025            return;
4026        };
4027        if board_config.columns.iter().any(|c| c == &name) {
4028            self.status_msg = Some(format!("Column '{name}' already exists"));
4029            return;
4030        }
4031        board_config.columns.push(name.clone());
4032        if !self.persist_config() {
4033            return;
4034        }
4035        self.rebuild_board();
4036        let last = self.active_board_config().columns.len().saturating_sub(1);
4037        if let Some(ref mut m) = self.settings {
4038            m.prompt = None;
4039            m.selected = last;
4040        }
4041        self.status_msg = Some(format!("Added column '{name}'"));
4042    }
4043
4044    fn settings_commit_new_board(&mut self, name: String) {
4045        let name = name.trim().to_string();
4046        if name.is_empty() {
4047            self.status_msg = Some("Board name is required".to_string());
4048            return;
4049        }
4050        if self.store.config.kanban.configurations.contains_key(&name) {
4051            self.status_msg = Some(format!("Board '{name}' already exists"));
4052            return;
4053        }
4054        self.store
4055            .config
4056            .kanban
4057            .configurations
4058            .insert(name.clone(), crate::config::BoardConfiguration::default());
4059        if !self.persist_config() {
4060            return;
4061        }
4062        self.reload_tabs();
4063        let idx = self
4064            .store
4065            .config
4066            .configs()
4067            .position(|(n, _)| n == name.as_str())
4068            .unwrap_or(0);
4069        self.active_config = idx;
4070        self.tabs.selected = idx;
4071        self.switch_config();
4072        if let Some(ref mut m) = self.settings {
4073            m.prompt = None;
4074            m.selected = 0;
4075        }
4076        self.status_msg = Some(format!("Added board '{name}'"));
4077    }
4078
4079    fn settings_commit_delete_board(&mut self, name: String) {
4080        if self.store.config.kanban.configurations.len() <= 1 {
4081            self.status_msg = Some("Cannot delete the last board".to_string());
4082            return;
4083        }
4084        let idx = self.active_config;
4085        let removed = self.store.config.kanban.configurations.shift_remove(&name);
4086        if removed.is_none() {
4087            self.status_msg = Some(format!("Board '{name}' not found"));
4088            return;
4089        }
4090        if !self.persist_config() {
4091            return;
4092        }
4093        self.reload_tabs();
4094        let count = self.store.config.kanban.configurations.len();
4095        let target = idx.min(count.saturating_sub(1));
4096        self.active_config = target;
4097        self.tabs.selected = target;
4098        self.switch_config();
4099        if let Some(ref mut m) = self.settings {
4100            m.prompt = None;
4101            m.selected = 0;
4102        }
4103        self.status_msg = Some(format!("Deleted board '{name}'"));
4104    }
4105
4106    fn persist_config(&mut self) -> bool {
4107        match self.store.config_path() {
4108            Ok(path) => match self.store.config.save(&path) {
4109                Ok(()) => true,
4110                Err(e) => {
4111                    self.status_msg = Some(format!("settings save: {e}"));
4112                    false
4113                }
4114            },
4115            Err(e) => {
4116                self.status_msg = Some(format!("settings path: {e}"));
4117                false
4118            }
4119        }
4120    }
4121
4122    fn reload_tabs(&mut self) {
4123        let names: Vec<String> = self
4124            .store
4125            .config
4126            .configs()
4127            .map(|(name, _)| name.to_string())
4128            .collect();
4129        self.tabs = BoardTabs::new(names);
4130    }
4131
4132    fn active_board_name(&self) -> &str {
4133        self.store
4134            .config
4135            .configs()
4136            .nth(self.active_config)
4137            .map(|(name, _)| name)
4138            .unwrap_or("default")
4139    }
4140
4141    fn submit_comment(&mut self) -> Result<Action> {
4142        let buffer = match &self.mode {
4143            Mode::Comment { buffer } => buffer.clone(),
4144            _ => return Ok(Action::None),
4145        };
4146        let text = buffer.trim().to_string();
4147        if text.is_empty() {
4148            self.status_msg = Some("Comment discarded (empty)".to_string());
4149            self.mode = Mode::Detail;
4150            return Ok(Action::None);
4151        }
4152        let filename = match self.detail.as_ref() {
4153            Some(d) => d.issue.filename.clone(),
4154            None => {
4155                self.mode = Mode::Detail;
4156                return Ok(Action::None);
4157            }
4158        };
4159        let author = crate::assignee::git_identity()
4160            .map(|id| id.to_string())
4161            .unwrap_or_else(|| "anonymous".to_string());
4162        let mut issue = self.store.get(IssueSelector::Filename(filename.clone()))?;
4163        issue.add_comment(&author, &text);
4164        self.store.update(&filename, &issue)?;
4165        if let Some(ref mut d) = self.detail {
4166            d.issue = issue;
4167            d.scroll_offset = 0;
4168        }
4169        self.refresh_corpus()?;
4170        self.status_msg = Some(format!("Commented by {author}"));
4171        self.mode = Mode::Detail;
4172        Ok(Action::None)
4173    }
4174
4175    /// The relationship under the detail pane's cursor, if any.
4176    fn selected_relationship(&self) -> Option<&crate::relationships::Relationship> {
4177        self.detail
4178            .as_ref()
4179            .and_then(|d| d.relationships.get(d.rel_cursor))
4180    }
4181
4182    /// The **stable id** of the selected relationship, which is what `tix link edit`
4183    /// and `tix link remove` name.
4184    ///
4185    /// Taken from the id rather than from `(from, to)`, because the id survives a
4186    /// re-kind: `2 related 1` and `1 blocks 2` are different edges that can share an
4187    /// endpoint pair, and addressing one by its endpoints would be ambiguous.
4188    fn selected_relationship_id(&self) -> Option<String> {
4189        self.selected_relationship().map(|r| r.id.clone())
4190    }
4191
4192    /// Move the relationship cursor by `delta`, clamped to the list.
4193    fn move_rel_cursor(&mut self, delta: isize) {
4194        let Some(d) = self.detail.as_mut() else {
4195            return;
4196        };
4197        if d.relationships.is_empty() {
4198            return;
4199        }
4200        let last = d.relationships.len() as isize - 1;
4201        let next = (d.rel_cursor as isize + delta).clamp(0, last);
4202        d.rel_cursor = next as usize;
4203    }
4204
4205    /// The two-step create/edit relationship prompt (#1359).
4206    ///
4207    /// Shares one handler for `LinkCreate` and `LinkEdit` because the two differ only
4208    /// in what they do at the end: a create needs both a target and a kind, an edit
4209    /// keeps the target it already has unless the user changes it. Sharing it means
4210    /// the two cannot drift in how they read a selector or report a bad kind — which
4211    /// is the class of bug `surface-parity.md` §2 is about.
4212    fn handle_link_prompt_key(&mut self, key: KeyEvent) -> Result<Action> {
4213        match key.code {
4214            KeyCode::Esc => {
4215                // Cancel the whole flow, not just this step: `Esc` in step 2 returns
4216                // to the detail pane rather than to step 1, because a two-step
4217                // cancel that needs two presses is a trap.
4218                self.mode = Mode::Detail;
4219                self.status_msg = Some("relationship: cancelled".to_string());
4220                Ok(Action::None)
4221            }
4222            KeyCode::Enter => {
4223                let prompt = match self.mode.clone() {
4224                    Mode::LinkCreate { buffer, step, .. } => Some((buffer, step)),
4225                    Mode::LinkEdit { buffer, step, .. } => Some((buffer, step)),
4226                    _ => None,
4227                };
4228                match prompt {
4229                    Some((buffer, step)) => self.submit_link_prompt(buffer, step),
4230                    None => Ok(Action::None),
4231                }
4232            }
4233            KeyCode::Backspace => {
4234                if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
4235                    buffer.pop();
4236                } else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
4237                    buffer.pop();
4238                }
4239                Ok(Action::None)
4240            }
4241            KeyCode::Char(c) => {
4242                if let Mode::LinkCreate { ref mut buffer, .. } = self.mode {
4243                    buffer.push(c);
4244                } else if let Mode::LinkEdit { ref mut buffer, .. } = self.mode {
4245                    buffer.push(c);
4246                }
4247                Ok(Action::None)
4248            }
4249            _ => Ok(Action::None),
4250        }
4251    }
4252
4253    /// One prompt step: resolve a target, or apply the kind.
4254    fn submit_link_prompt(&mut self, buffer: String, step: LinkStep) -> Result<Action> {
4255        let trimmed = buffer.trim().to_string();
4256        let subject = self.detail.as_ref().map(|d| d.issue.number).unwrap_or(0);
4257        let (is_edit, index) = match &self.mode {
4258            Mode::LinkEdit { index, .. } => (true, *index),
4259            _ => (false, 0),
4260        };
4261
4262        match step {
4263            LinkStep::Target => {
4264                if trimmed.is_empty() {
4265                    self.status_msg = Some("relationship: cancelled (no issue given)".into());
4266                    self.mode = Mode::Detail;
4267                    return Ok(Action::None);
4268                }
4269                // **The CLI's own resolver**, so `#4`, `4` and a title fragment all
4270                // work and an unresolvable selector produces the CLI's message. A
4271                // TUI-specific resolver would be a second place for the two surfaces
4272                // to disagree about what a selector means.
4273                let selector = match crate::commands::parse_selector(&self.store, &trimmed) {
4274                    Ok(selector) => selector,
4275                    Err(e) => {
4276                        self.status_msg = Some(e.to_string());
4277                        return Ok(Action::None);
4278                    }
4279                };
4280                let target = match self.store.get(selector) {
4281                    Ok(issue) => issue.number,
4282                    Err(e) => {
4283                        self.status_msg = Some(e.to_string());
4284                        return Ok(Action::None);
4285                    }
4286                };
4287                if target == subject {
4288                    // The service rejects a self-link; saying so here is friendlier
4289                    // and the service's check still stands as the guarantee.
4290                    self.status_msg =
4291                        Some("relationship: an issue cannot be related to itself".into());
4292                    return Ok(Action::None);
4293                }
4294                self.mode = if is_edit {
4295                    Mode::LinkEdit {
4296                        index,
4297                        buffer: String::new(),
4298                        step: LinkStep::Kind,
4299                        target: Some(target),
4300                        kind: None,
4301                    }
4302                } else {
4303                    Mode::LinkCreate {
4304                        buffer: String::new(),
4305                        step: LinkStep::Kind,
4306                        target: Some(target),
4307                    }
4308                };
4309                Ok(Action::None)
4310            }
4311            LinkStep::Kind => {
4312                // **Required** (C-001). An untypeable relationship must never be
4313                // creatable by omission, so there is no default kind and an empty
4314                // answer is a refusal carrying the CLI's own message — which names
4315                // the valid set rather than just failing.
4316                let kind = match crate::commands::link::parse_kind(&trimmed) {
4317                    Ok(kind) => kind,
4318                    Err(e) => {
4319                        self.status_msg = Some(e.to_string());
4320                        return Ok(Action::None);
4321                    }
4322                };
4323                if is_edit {
4324                    self.apply_relationship_edit(kind)
4325                } else {
4326                    self.apply_relationship_create(subject, kind)
4327                }
4328            }
4329        }
4330    }
4331
4332    /// `tix link <subject> <target> --type <kind>`, through the service.
4333    ///
4334    /// The **shared** message (`link::link_message`), so the TUI reports the same
4335    /// sentence as the CLI and MCP for the same edge.
4336    fn apply_relationship_create(
4337        &mut self,
4338        subject: u32,
4339        kind: crate::relationships::RelationKind,
4340    ) -> Result<Action> {
4341        let Some(target) = (match &self.mode {
4342            Mode::LinkCreate { target, .. } => *target,
4343            _ => None,
4344        }) else {
4345            // Unreachable: `Kind` is only entered from a resolved `Target` step. Left
4346            // as a refusal rather than an `expect` so a request handler never panics.
4347            self.status_msg = Some("relationship: no target resolved".into());
4348            self.mode = Mode::Detail;
4349            return Ok(Action::None);
4350        };
4351        // `Store::link` takes the same **selector strings** the CLI takes, so the
4352        // numbers resolved in the prompt go through the one resolver rather than a
4353        // second path that happens to agree today.
4354        match self
4355            .store
4356            .link(&subject.to_string(), &target.to_string(), kind)
4357        {
4358            Ok(outcome) => {
4359                self.status_msg = Some(crate::commands::link::link_message(&outcome));
4360                self.mode = Mode::Detail;
4361                self.refresh_detail()?;
4362            }
4363            Err(e) => {
4364                self.status_msg = Some(e.to_string());
4365                self.mode = Mode::Detail;
4366            }
4367        }
4368        Ok(Action::None)
4369    }
4370
4371    /// `tix link edit <id> --to <target> --type <kind>`, through the service.
4372    ///
4373    /// Reached by the **stable id**, not by an endpoint pair: `2 related 1` and
4374    /// `1 blocks 2` can share a pair, so an endpoint-addressed edit would be
4375    /// ambiguous. The id survives a re-kind, which is the whole reason an edit
4376    /// exists rather than a remove-then-create pair.
4377    fn apply_relationship_edit(
4378        &mut self,
4379        kind: crate::relationships::RelationKind,
4380    ) -> Result<Action> {
4381        // The index is read from the mode rather than passed in, so there is one
4382        // source for "which edge" instead of a parameter and a field that can
4383        // disagree. (It did disagree in the first draft: the parameter was carried
4384        // and never used, and the field was read instead.)
4385        let (id, to) = match &self.mode {
4386            Mode::LinkEdit { index, target, .. } => (
4387                self.detail
4388                    .as_ref()
4389                    .and_then(|d| d.relationships.get(*index))
4390                    .map(|r| r.id.clone()),
4391                *target,
4392            ),
4393            _ => (None, None),
4394        };
4395        let Some(id) = id else {
4396            self.status_msg = Some("relationship: nothing selected to edit".into());
4397            self.mode = Mode::Detail;
4398            return Ok(Action::None);
4399        };
4400        match self.store.edit_relation(&id, to, Some(kind)) {
4401            Ok(outcome) => {
4402                self.status_msg = Some(crate::commands::link::edit_message(&outcome));
4403                self.mode = Mode::Detail;
4404                self.refresh_detail()?;
4405            }
4406            Err(e) => {
4407                self.status_msg = Some(e.to_string());
4408                self.mode = Mode::Detail;
4409            }
4410        }
4411        Ok(Action::None)
4412    }
4413
4414    fn handle_remove_relationship_key(&mut self, key: KeyEvent) -> Result<Action> {
4415        match key.code {
4416            KeyCode::Char('y') | KeyCode::Char('Y') => {
4417                let index = match &self.mode {
4418                    Mode::ConfirmRemoveRelationship { index } => *index,
4419                    _ => return Ok(Action::None),
4420                };
4421                let id = match self
4422                    .detail
4423                    .as_ref()
4424                    .and_then(|d| d.relationships.get(index))
4425                {
4426                    Some(rel) => rel.id.clone(),
4427                    None => {
4428                        self.mode = Mode::Detail;
4429                        return Ok(Action::None);
4430                    }
4431                };
4432                match self.store.remove_relation(&id) {
4433                    Ok(outcome) => {
4434                        // **The shared wording** — `link::remove_message` — so the TUI
4435                        // and the CLI cannot drift on what a removal reports.
4436                        self.status_msg = Some(crate::commands::link::remove_message(&outcome));
4437                        self.mode = Mode::Detail;
4438                        self.refresh_detail()?;
4439                    }
4440                    Err(e) => {
4441                        self.status_msg = Some(e.to_string());
4442                        self.mode = Mode::Detail;
4443                    }
4444                }
4445                Ok(Action::None)
4446            }
4447            _ => {
4448                self.mode = Mode::Detail;
4449                self.status_msg = Some("relationship: removal cancelled".to_string());
4450                Ok(Action::None)
4451            }
4452        }
4453    }
4454
4455    fn handle_asset_key(&mut self, key: KeyEvent) -> Result<Action> {
4456        match key.code {
4457            KeyCode::Esc => {
4458                self.mode = Mode::Detail;
4459                Ok(Action::None)
4460            }
4461            KeyCode::Enter => self.submit_asset(),
4462            KeyCode::Char(c) => {
4463                if let Mode::Asset { ref mut buffer } = self.mode {
4464                    buffer.push(c);
4465                }
4466                Ok(Action::None)
4467            }
4468            KeyCode::Backspace => {
4469                if let Mode::Asset { ref mut buffer } = self.mode {
4470                    buffer.pop();
4471                }
4472                Ok(Action::None)
4473            }
4474            _ => Ok(Action::None),
4475        }
4476    }
4477
4478    fn submit_asset(&mut self) -> Result<Action> {
4479        let buffer = match &self.mode {
4480            Mode::Asset { buffer } => buffer.clone(),
4481            _ => return Ok(Action::None),
4482        };
4483        let path = std::path::PathBuf::from(buffer.trim());
4484        let filename = match self.detail.as_ref() {
4485            Some(d) => d.issue.filename.clone(),
4486            None => {
4487                self.mode = Mode::Detail;
4488                return Ok(Action::None);
4489            }
4490        };
4491        match self.store.add_asset(&filename, &path) {
4492            Ok(added) => {
4493                if let Ok(issue) = self.store.get(IssueSelector::Filename(filename))
4494                    && let Some(ref mut d) = self.detail
4495                {
4496                    d.issue = issue;
4497                    d.scroll_offset = 0;
4498                }
4499                self.status_msg = Some(format!("Asset added: {}", added.link));
4500                self.refresh_corpus()?;
4501                self.mode = Mode::Detail;
4502                Ok(Action::None)
4503            }
4504            Err(e) => {
4505                self.status_msg = Some(format!("asset: {e}"));
4506                Ok(Action::None)
4507            }
4508        }
4509    }
4510
4511    /// Toggle column visibility on the active column (FR-005/FR-006/NFR-004/
4512    /// SC-003). Updates the in-memory config and persists atomically.
4513    fn toggle_column_visibility(&mut self) {
4514        let col_name = self.board.active_column_name().to_string();
4515        let config_name = self.tabs.names[self.active_config].clone();
4516        let board_config = self
4517            .store
4518            .config
4519            .kanban
4520            .configurations
4521            .get_mut(&config_name);
4522        let board_config = match board_config {
4523            Some(bc) => bc,
4524            None => {
4525                self.status_msg = Some("toggle: config not found".into());
4526                return;
4527            }
4528        };
4529        let now_hidden = if let Some(pos) = board_config
4530            .hidden_columns
4531            .iter()
4532            .position(|h| h == &col_name)
4533        {
4534            board_config.hidden_columns.remove(pos);
4535            false
4536        } else {
4537            board_config.hidden_columns.push(col_name.clone());
4538            true
4539        };
4540        match self.store.config_path() {
4541            Ok(path) => {
4542                if let Err(e) = self.store.config.save(&path) {
4543                    self.status_msg = Some(format!("toggle save: {e}"));
4544                    return;
4545                }
4546            }
4547            Err(e) => {
4548                self.status_msg = Some(format!("toggle path: {e}"));
4549                return;
4550            }
4551        }
4552        self.rebuild_board();
4553        // Keep the cursor logically on the toggled column so a second `c`
4554        // press re-shows it: visible index when shown, hidden-region index
4555        // while hidden. Navigation keys still skip hidden columns (T014).
4556        let visible = self.board.columns.len();
4557        self.board.active_col = if now_hidden {
4558            visible
4559                + self
4560                    .board
4561                    .hidden
4562                    .iter()
4563                    .position(|c| c.name == col_name)
4564                    .unwrap_or_default()
4565        } else {
4566            self.board
4567                .columns
4568                .iter()
4569                .position(|c| c.name == col_name)
4570                .unwrap_or_default()
4571        };
4572        self.board.clamp_selection();
4573        self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
4574    }
4575
4576    /// Switch the active board configuration (Tab/BackTab). Rebuilds the
4577    /// board through the same path as `rebuild_board` so filters re-apply to
4578    /// the new config's visible columns, resetting `active_col` to the first
4579    /// visible column while keeping the selected card if it is still visible.
4580    /// Switch to the named board at tab index `i` — the exact Tab/BackTab
4581    /// path (set `selected`, then `active_config`, then rebuild via
4582    /// [`App::switch_config`]). Used by the mouse-hit `Action::SwitchBoard`
4583    /// and the run loop; tabs need a single shared transition.
4584    fn switch_board(&mut self, index: usize) {
4585        if index < self.tabs.len() {
4586            self.tabs.selected = index;
4587            self.tabs.hovered = None;
4588            self.active_config = index;
4589            self.switch_config();
4590        }
4591    }
4592
4593    fn switch_config(&mut self) {
4594        let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
4595        self.rebuild_board();
4596        self.board.active_col = 0;
4597        if let Some(filename) = selected_filename
4598            && self
4599                .board
4600                .column_cards(0)
4601                .iter()
4602                .any(|c| c.filename == filename)
4603        {
4604            self.board.selected = self
4605                .board
4606                .column_cards(0)
4607                .iter()
4608                .position(|c| c.filename == filename)
4609                .unwrap_or(0);
4610        } else {
4611            self.board.selected = 0;
4612        }
4613        self.board.clamp_selection();
4614        self.status_msg = Some("Switched board".to_string());
4615    }
4616
4617    /// Re-read the store and rebuild the board while preserving the current
4618    /// filter, active column, and selection.
4619    fn rebuild_board(&mut self) {
4620        let active_col = self.board.active_col;
4621        let selected = self.board.selected;
4622        let board_config = self.active_board_config().clone();
4623        let active_filter = self.active_structured_filter().clone();
4624        let q = self.filter.clone().unwrap_or_default();
4625        if !q.is_empty() || active_filter.has_active_criteria() {
4626            self.board = BoardState::build_filtered(
4627                &board_config,
4628                &self.store.config,
4629                &self.corpus,
4630                &q,
4631                &active_filter,
4632                &self.workflow_resolution(),
4633            );
4634        } else {
4635            self.board =
4636                BoardState::build_with_archived(&self.store, &board_config, self.show_archived);
4637        }
4638        self.board.active_col = active_col;
4639        self.board.selected = selected;
4640        self.board.clamp_selection();
4641    }
4642
4643    fn select_card_by_filename(&mut self, filename: &str) {
4644        let mut columns: Vec<&[crate::ui::board::Card]> = self
4645            .board
4646            .columns
4647            .iter()
4648            .map(|c| c.cards.as_slice())
4649            .collect();
4650        if self.board.show_mismatch {
4651            columns.push(self.board.mismatch.cards.as_slice());
4652        }
4653        if let Some((col_idx, card_idx)) =
4654            columns.iter().enumerate().find_map(|(col_idx, cards)| {
4655                cards
4656                    .iter()
4657                    .position(|card| card.filename == filename)
4658                    .map(|card_idx| (col_idx, card_idx))
4659            })
4660        {
4661            self.board.active_col = col_idx;
4662            self.board.selected = card_idx;
4663        }
4664    }
4665
4666    /// Live filter: update the applied `filter` and re-render the board from
4667    /// the corpus. An empty buffer clears the query (FR-013: an active
4668    /// structured filter alone still filters).
4669    fn apply_filter(&mut self, buffer: &str) {
4670        if buffer.trim().is_empty() {
4671            self.filter = None;
4672        } else {
4673            self.filter = Some(buffer.trim().to_string());
4674        }
4675        let board_config = self.active_board_config().clone();
4676        let active_filter = self.active_structured_filter().clone();
4677        let q = self.filter.clone().unwrap_or_default();
4678        if !q.is_empty() || active_filter.has_active_criteria() {
4679            self.board = BoardState::build_filtered(
4680                &board_config,
4681                &self.store.config,
4682                &self.corpus,
4683                &q,
4684                &active_filter,
4685                &self.workflow_resolution(),
4686            );
4687            self.status_msg = if self.board.is_empty() {
4688                Some("No matches for current filter".to_string())
4689            } else {
4690                None
4691            };
4692        } else {
4693            self.board = BoardState::build(&self.store, &board_config);
4694            self.status_msg = None;
4695        }
4696    }
4697
4698    /// Re-read the corpus after comment/asset writes so the active filter
4699    /// stays fresh, then rebuilds the board.
4700    /// Re-read the open detail pane from the service after a write.
4701    ///
4702    /// **Everything** the pane shows is re-derived, not just the issue: the
4703    /// relationships come from `Store::relationships_for` and the chain and blocked
4704    /// note from a fresh graph. A pane that refreshed only `issue` would keep showing
4705    /// the edge list and blocked state from *before* the mutation, which is the
4706    /// failure that makes a TUI feel like it is lying to you.
4707    ///
4708    /// The relationship cursor is clamped rather than reset, so editing the third of
4709    /// five relationships leaves it on the third — which after a re-kind is the same
4710    /// edge, since the list is ordered by `(kind, target, from)` and a re-kind can
4711    /// move it. Clamping is the honest minimum: it never points past the end.
4712    fn refresh_detail(&mut self) -> Result<()> {
4713        let Some(detail) = self.detail.as_ref() else {
4714            return Ok(());
4715        };
4716        let number = detail.issue.number;
4717        let filename = detail.issue.filename.clone();
4718        let Some(mut next) = self.store.get(IssueSelector::Filename(filename)).ok() else {
4719            return Ok(());
4720        };
4721        let relationships = self.store.relationships_for(number)?;
4722        let (all, done_column) = self.store.graph_source()?;
4723        let graph = commands::graph_over(&all, &done_column);
4724        let cursor = detail.rel_cursor;
4725        if let Some(d) = self.detail.as_mut() {
4726            d.issue = std::mem::replace(&mut next, d.issue.clone());
4727            d.relationships = relationships;
4728            d.chain = graph.dependency_chain(number);
4729            d.blocked = commands::blocked_note(&graph.blocked_state(number));
4730            d.rel_cursor = cursor;
4731            d.clamp_rel_cursor();
4732        }
4733        self.refresh_corpus()
4734    }
4735
4736    fn refresh_corpus(&mut self) -> Result<()> {
4737        let c = Corpus::build_with(&self.store, self.show_archived)?;
4738        self.corpus = c;
4739        self.rebuild_board();
4740        Ok(())
4741    }
4742
4743    /// Re-read issues from disk and rebuild the board, so edits made outside
4744    /// `tix` appear without a manual keypress (issue #10, driven by
4745    /// [`crate::watcher::FsWatcher`]). A read error (e.g. a partially written
4746    /// file) leaves the current board in place.
4747    pub fn refresh_from_disk(&mut self) -> Result<()> {
4748        self.refresh_corpus()
4749    }
4750
4751    pub fn after_editor(&mut self, filename: &str) {
4752        match self
4753            .store
4754            .get(IssueSelector::Filename(filename.to_string()))
4755        {
4756            Ok(mut issue) => {
4757                issue.bump_updated();
4758                // FR-031: when the editor assigned the issue, physically move
4759                // it into the new assignee segment before writing content.
4760                // An unassigned edit keeps the current file location.
4761                let location = match issue.assignee {
4762                    Some(ref a) => match self.store.reassign(filename, Some(a)) {
4763                        Ok(moved) => moved.to_string_lossy().to_string(),
4764                        Err(e) => {
4765                            self.status_msg = Some(format!("editor: {e}"));
4766                            return;
4767                        }
4768                    },
4769                    None => filename.to_string(),
4770                };
4771                if let Err(e) = self.store.update(&location, &issue) {
4772                    self.status_msg = Some(format!("editor: {e}"));
4773                } else {
4774                    if let Some(ref mut d) = self.detail
4775                        && (d.issue.filename == filename || d.issue.filename == location)
4776                    {
4777                        d.issue = issue;
4778                        d.scroll_offset = 0;
4779                    }
4780                    let _ = self.refresh_corpus();
4781                    if let Err(e) = self.warn_if_linted_issue_invalid(&location) {
4782                        self.status_msg = Some(format!("editor: {e}"));
4783                    } else {
4784                        self.status_msg = Some("Edited".to_string());
4785                    }
4786                }
4787            }
4788            Err(e) => {
4789                self.status_msg = Some(format!("editor: {e}"));
4790            }
4791        }
4792    }
4793
4794    fn warn_if_linted_issue_invalid(&mut self, filename: &str) -> Result<()> {
4795        let findings =
4796            crate::commands::lint::collect_file(&self.store.config, &self.store, filename)?;
4797        if let Some(finding) = findings.into_iter().next() {
4798            let message = format!("{}: {}", finding.kind, finding.message);
4799            self.mode = Mode::EditLintWarning {
4800                filename: filename.to_string(),
4801                message,
4802            };
4803        }
4804        Ok(())
4805    }
4806
4807    fn handle_edit_lint_warning_key(&mut self, key: KeyEvent) -> Result<Action> {
4808        let (filename, _) = match &self.mode {
4809            Mode::EditLintWarning { filename, message } => (filename.clone(), message.clone()),
4810            _ => return Ok(Action::None),
4811        };
4812        match key.code {
4813            KeyCode::Char('r') | KeyCode::Char('R') => {
4814                self.mode = Mode::Board;
4815                Ok(Action::OpenEditor(filename))
4816            }
4817            KeyCode::Char('i') | KeyCode::Char('I') | KeyCode::Enter | KeyCode::Esc => {
4818                self.mode = Mode::Board;
4819                Ok(Action::None)
4820            }
4821            _ => Ok(Action::None),
4822        }
4823    }
4824
4825    fn handle_rollover_dialog_key(&mut self, key: KeyEvent) -> Result<Action> {
4826        let Some(dialog) = self.rollover_dialog.as_mut() else {
4827            self.mode = Mode::Board;
4828            return Ok(Action::None);
4829        };
4830        match dialog.handle_key(key.code) {
4831            DialogEvent::ConfirmDo => {
4832                crate::commands::rollover::run(&self.store.config, &self.store, None, true)?;
4833                self.rebuild_board();
4834                self.status_msg = Some("Rollover applied".to_string());
4835                self.rollover_dialog = None;
4836                self.mode = Mode::Board;
4837            }
4838            DialogEvent::ConfirmIgnore | DialogEvent::Dismiss => {
4839                self.rollover_dialog = None;
4840                self.mode = Mode::Board;
4841            }
4842            DialogEvent::None => {}
4843        }
4844        Ok(Action::None)
4845    }
4846}
4847
4848const HINT_HEIGHT: u16 = 1;
4849/// Two rows: one for the tab labels, one for the tab bar's bottom border
4850/// (`BoardTabs` renders a `Block` with `Borders::BOTTOM`, which consumes the
4851/// last row). With height 1 the labels would never render.
4852const TAB_HEIGHT: u16 = 2;
4853/// Single-row active-filter indicator shown directly beneath the tab bar
4854/// (board-filtering FR-01); zero rows when no filter is active (D-05).
4855const FILTER_HEIGHT: u16 = 1;
4856/// Full-width row directly beneath the tab bar showing the currently hovered
4857/// issue's title (hovered-issue title line FR).
4858const HOVER_HEIGHT: u16 = 1;
4859const RENDER_FALLBACK_WIDTH: u16 = 80;
4860const RENDER_FALLBACK_HEIGHT: u16 = 24;
4861/// Spare rows kept blank below a one-shot render so the board fits on screen
4862/// instead of consuming the full terminal height.
4863const RENDER_BOTTOM_MARGIN: u16 = 4;
4864
4865pub fn render(app: &App, f: &mut ratatui::Frame, area: Rect) {
4866    app.frame_size.set(area);
4867    app.frame_hit_regions.borrow_mut().clear();
4868    match &app.mode {
4869        Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
4870        Mode::BulkModal => {
4871            // The board renders underneath, as it does under every other modal, so
4872            // the user can see the cards they marked and the columns they are in.
4873            render_board(app, f, area, commandbar::MODE_BOARD);
4874            if let Some(modal) = app.bulk.as_ref() {
4875                crate::ui::bulk::render(f, area, modal);
4876            }
4877        }
4878        Mode::TriageInbox => {
4879            // The board renders underneath, as it does under every other modal,
4880            // so the inbox sits on the cards whose queue it lists and closing it
4881            // needs no redraw of its own (T029).
4882            render_board(app, f, area, commandbar::MODE_BOARD);
4883            if let Some(state) = app.triage.as_ref() {
4884                crate::ui::triage::render(f, area, state);
4885            }
4886        }
4887        Mode::RolloverDialog => {
4888            render_board(app, f, area, commandbar::MODE_BOARD);
4889            if let Some(dialog) = app.rollover_dialog.as_ref() {
4890                let rect = crate::ui::modal::modal_rect(area);
4891                f.render_widget(Clear, rect);
4892                let mut lines = Vec::new();
4893                lines.push(Line::from(Span::styled(
4894                    "Rollover needed",
4895                    Style::default()
4896                        .fg(Color::Yellow)
4897                        .add_modifier(Modifier::BOLD),
4898                )));
4899                for (from, to, count) in &dialog.plans {
4900                    lines.push(Line::from(format!(
4901                        "{count} issue(s): \"{from}\" -> \"{to}\""
4902                    )));
4903                }
4904                lines.push(Line::from(""));
4905                lines.push(Line::from(format!(
4906                    "{}: Do rollover | {}: Toggle choice | {}: Ignore",
4907                    app.bindings.label(
4908                        InputContext::Rollover,
4909                        crate::keybindings::TuiAction::Submit
4910                    ),
4911                    app.bindings.label(
4912                        InputContext::Rollover,
4913                        crate::keybindings::TuiAction::Previous
4914                    ),
4915                    app.bindings.label(
4916                        InputContext::Rollover,
4917                        crate::keybindings::TuiAction::Cancel
4918                    ),
4919                )));
4920                lines.push(Line::from(match dialog.choice {
4921                    crate::ui::dialog::DialogChoice::DoRollover => "> Do rollover | Ignore",
4922                    crate::ui::dialog::DialogChoice::Ignore => "Do rollover | > Ignore",
4923                }));
4924                let para = Paragraph::new(lines).block(
4925                    Block::default()
4926                        .borders(Borders::ALL)
4927                        .title("Rollover needed")
4928                        .style(Style::default().fg(Color::Yellow)),
4929                );
4930                f.render_widget(para, rect);
4931            }
4932        }
4933        Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
4934        Mode::Form => {
4935            if let Some(fr) = &app.form {
4936                render_board(app, f, area, commandbar::MODE_BOARD);
4937                let rect = crate::ui::modal::modal_rect(area);
4938                f.render_widget(Clear, rect);
4939                form::render_with_bindings(fr, f, rect, &app.bindings, InputContext::Form);
4940                if let Some(picker) = &app.form_picker {
4941                    render_form_picker(picker, f, area, &app.bindings);
4942                } else {
4943                    // With nothing drawn over the form, the form owns the caret
4944                    // (#1486). Under a picker the overlay owns it instead, and a
4945                    // caret on the row behind it would point at the wrong surface.
4946                    form::place_caret(fr, f, rect);
4947                }
4948            }
4949        }
4950        Mode::ConfirmDelete => {
4951            if area.height < 2 {
4952                return;
4953            }
4954            board::render(
4955                &app.board,
4956                f,
4957                area,
4958                &None,
4959                app.drag_preview_col,
4960                board::BoardDisplay::from(app.active_board_config()),
4961            );
4962            let title = app.confirm_delete_target.as_deref().unwrap_or("?");
4963            let prompt_area = confirm_delete_prompt(area);
4964            let prompt = Paragraph::new(vec![
4965                Line::from(Span::styled(
4966                    format!("Delete '{title}'? [y/N]"),
4967                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
4968                )),
4969                Line::from(""),
4970                Line::from("  [Yes]   [No]  "),
4971            ])
4972            .block(
4973                Block::default()
4974                    .borders(Borders::ALL)
4975                    .title("Confirm")
4976                    .style(Style::default().fg(Color::Red)),
4977            );
4978            f.render_widget(prompt, prompt_area);
4979            // Record the Yes / No button cells as click regions (D6): the run
4980            // loop maps them to the same `y` / cancel branches as the keys.
4981            let (yes, no) = confirm_delete_buttons(prompt_area);
4982            app.record_region(yes, Action::ConfirmDeleteYes);
4983            app.record_region(no, Action::ConfirmDeleteNo);
4984        }
4985        Mode::ConfirmBlockedMove { warning, .. } => {
4986            if area.height < 3 {
4987                return;
4988            }
4989            board::render(
4990                &app.board,
4991                f,
4992                area,
4993                &None,
4994                app.drag_preview_col,
4995                board::BoardDisplay::from(app.active_board_config()),
4996            );
4997            let prompt_area = Rect {
4998                y: area.y + area.height - 2,
4999                height: 2,
5000                ..area
5001            };
5002            // The warning is the shared text, so this prompt and the CLI's
5003            // `tix move` refusal cannot describe the same situation differently.
5004            let prompt = Paragraph::new(vec![
5005                Line::from(Span::styled(
5006                    format!("{warning} — move anyway? [y/N]"),
5007                    Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5008                )),
5009                Line::from("  The decision is recorded in the issue's History."),
5010            ])
5011            .block(
5012                Block::default()
5013                    .borders(Borders::ALL)
5014                    .title("Blocked move")
5015                    .style(Style::default().fg(Color::Red)),
5016            );
5017            f.render_widget(prompt, prompt_area);
5018        }
5019        Mode::MoveIntoDone { buffer, .. } => {
5020            if area.height < 3 {
5021                return;
5022            }
5023            let prompt_area = Rect {
5024                y: area.y + area.height - 1,
5025                height: 1,
5026                ..area
5027            };
5028            let block_area = Rect {
5029                height: area.height - 1,
5030                ..area
5031            };
5032            board::render(
5033                &app.board,
5034                f,
5035                block_area,
5036                &app.status_msg,
5037                app.drag_preview_col,
5038                board::BoardDisplay::from(app.active_board_config()),
5039            );
5040            let prompt_text = format!("Commit hash: {buffer}_");
5041            let prompt = Paragraph::new(Line::from(Span::styled(
5042                prompt_text,
5043                Style::default().fg(Color::Yellow),
5044            )));
5045            f.render_widget(prompt, prompt_area);
5046        }
5047        // WP05: the reasoned-override prompt. `reason` is the **shared** service
5048        // diagnostic, rendered verbatim, so this prompt and the CLI's refusal
5049        // describe the same situation in the same words
5050        // (`contracts/surface-parity.md` §2 — parity includes the message).
5051        Mode::TransitionReason { buffer, reason, .. } => {
5052            if area.height < 3 {
5053                return;
5054            }
5055            let block_area = Rect {
5056                height: area.height - 1,
5057                ..area
5058            };
5059            board::render(
5060                &app.board,
5061                f,
5062                block_area,
5063                &app.status_msg,
5064                app.drag_preview_col,
5065                board::BoardDisplay::from(app.active_board_config()),
5066            );
5067            let prompt_area = Rect {
5068                y: area.y + area.height - 1,
5069                height: 1,
5070                ..area
5071            };
5072            let prompt = Paragraph::new(Line::from(Span::styled(
5073                format!("Reason (required): {buffer}_"),
5074                Style::default().fg(Color::Yellow),
5075            )));
5076            f.render_widget(prompt, prompt_area);
5077            // The refusal is shown above the board rather than in the one-line
5078            // prompt, because it is a full sentence with a backtick-quoted column
5079            // and a command spelling, and truncating it would leave the user with a
5080            // rule and no route.
5081            if area.height >= 5 {
5082                let notice = Rect {
5083                    y: area.y,
5084                    height: 3,
5085                    ..area
5086                };
5087                let text = Paragraph::new(vec![
5088                    Line::from(Span::styled(
5089                        "Blocked by the transition policy",
5090                        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
5091                    )),
5092                    Line::from(reason.clone()),
5093                ])
5094                .block(
5095                    Block::default()
5096                        .borders(Borders::ALL)
5097                        .title("Override required"),
5098                );
5099                f.render_widget(text, notice);
5100            }
5101        }
5102        Mode::Help { return_to } => render_help(app, f, area, return_to),
5103        Mode::ContextMenu { return_to } => render_context_menu(app, f, area, return_to),
5104        Mode::Filter { buffer } => render_board_input(app, f, area, "Filter", buffer),
5105        Mode::FilterModal => {
5106            render_board(app, f, area, commandbar::MODE_BOARD);
5107            if let Some(m) = app.filter_modal.as_ref() {
5108                m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
5109                // Record a hit region per criterion row; the run loop jumps
5110                // focus there (D6) exactly as Tab/arrow cycling would place it.
5111                let rect = crate::ui::modal::modal_rect(area);
5112                for (row, field) in FilterField::ALL.iter().enumerate() {
5113                    let hit = Rect {
5114                        x: rect.x + 1,
5115                        y: rect.y + 1 + row as u16,
5116                        width: rect.width.saturating_sub(2),
5117                        height: 1,
5118                    };
5119                    app.record_region(hit, Action::FilterModalFocus(*field));
5120                }
5121            }
5122        }
5123        Mode::Settings => {
5124            render_board(app, f, area, commandbar::MODE_BOARD);
5125            if let Some(m) = app.settings.as_ref() {
5126                let board = app.active_board_config();
5127                m.render_with_bindings(
5128                    f,
5129                    area,
5130                    app.active_board_name(),
5131                    board,
5132                    &app.bindings,
5133                    InputContext::Settings,
5134                );
5135                // Record a hit region per row; the run loop selects the row and
5136                // runs the same toggle the Enter key would (D6).
5137                let rect = crate::ui::modal::modal_rect(area);
5138                let total = board.columns.len() + crate::ui::settings::DISPLAY_ROWS;
5139                for row in 0..total {
5140                    let hit = Rect {
5141                        x: rect.x + 1,
5142                        y: rect.y + 1 + row as u16,
5143                        width: rect.width.saturating_sub(2),
5144                        height: 1,
5145                    };
5146                    app.record_region(hit, Action::SettingsRow(row));
5147                }
5148            }
5149        }
5150        Mode::PlanningManagement => {
5151            render_board(app, f, area, commandbar::MODE_BOARD);
5152            if let Some(m) = app.planning.as_ref() {
5153                let (cycles, milestones) = app.planning_rows();
5154                m.render_with_bindings(
5155                    f,
5156                    area,
5157                    &cycles,
5158                    &milestones,
5159                    &app.bindings,
5160                    InputContext::PlanningManagement,
5161                );
5162            }
5163        }
5164        Mode::PlanningForm => {
5165            render_board(app, f, area, commandbar::MODE_BOARD);
5166            if let Some(fm) = app.planning_form.as_ref() {
5167                fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
5168            }
5169        }
5170        Mode::DatePicker { return_to } => {
5171            render_board(app, f, area, commandbar::MODE_BOARD);
5172            match return_to.as_ref() {
5173                Mode::Form => {
5174                    if let Some(fr) = app.form.as_ref() {
5175                        let rect = crate::ui::modal::modal_rect(area);
5176                        f.render_widget(Clear, rect);
5177                        form::render_with_bindings(fr, f, rect, &app.bindings, InputContext::Form);
5178                    }
5179                }
5180                Mode::PlanningForm => {
5181                    if let Some(fm) = app.planning_form.as_ref() {
5182                        fm.render_with_bindings(f, area, &app.bindings, InputContext::PlanningForm);
5183                    }
5184                }
5185                Mode::FilterModal => {
5186                    if let Some(m) = app.filter_modal.as_ref() {
5187                        m.render_with_bindings(f, area, &app.bindings, InputContext::FilterModal);
5188                    }
5189                }
5190                _ => {}
5191            }
5192            if let Some(picker) = app.date_picker.as_ref() {
5193                picker.render_with_bindings(f, area, &app.bindings, InputContext::DatePicker);
5194                // Record the calendar grid and chrome so pointer clicks route
5195                // through the same key branches as keyboard input (D6).
5196                let rect = picker.popup_rect(area);
5197                let days_y = rect.y + 3;
5198                let weeks = rect.height.saturating_sub(6);
5199                for row in 0..weeks {
5200                    for col in 0..7u16 {
5201                        let x = rect.x + 1 + col * 3;
5202                        let y = days_y + row;
5203                        if let Some(date) = picker.date_at(area, x + 1, y) {
5204                            let hit = Rect {
5205                                x,
5206                                y,
5207                                width: 3,
5208                                height: 1,
5209                            };
5210                            app.record_region(
5211                                hit,
5212                                Action::DatePickerHit(DatePickerHit::Day(
5213                                    crate::ui::date_picker::format_date(date),
5214                                )),
5215                            );
5216                        }
5217                    }
5218                }
5219                let header_y = rect.y + 1;
5220                let header_w = rect.width.saturating_sub(2);
5221                let split = header_w / 2;
5222                if split > 0 {
5223                    app.record_region(
5224                        Rect {
5225                            x: rect.x + 1,
5226                            y: header_y,
5227                            width: split,
5228                            height: 1,
5229                        },
5230                        Action::DatePickerHit(DatePickerHit::PrevMonth),
5231                    );
5232                    app.record_region(
5233                        Rect {
5234                            x: rect.x + 1 + split,
5235                            y: header_y,
5236                            width: header_w - split,
5237                            height: 1,
5238                        },
5239                        Action::DatePickerHit(DatePickerHit::NextMonth),
5240                    );
5241                }
5242                app.record_region(
5243                    Rect {
5244                        x: rect.x,
5245                        y: rect.y + rect.height.saturating_sub(1),
5246                        width: rect.width,
5247                        height: 1,
5248                    },
5249                    Action::DatePickerHit(DatePickerHit::Submit),
5250                );
5251            }
5252        }
5253        Mode::Comment { buffer } => render_detail_input(app, f, area, "Comment", buffer),
5254        Mode::Asset { buffer } => render_detail_input(app, f, area, "Asset", buffer),
5255        Mode::EditLintWarning { filename, message } => {
5256            render_edit_lint_warning(app, f, area, filename, message)
5257        }
5258        // #1359. The prompt is labelled by which step it is asking, so "enter
5259        // something" is never the whole instruction — and the kind step says it is
5260        // **required**, which is the one thing a user needs to know before typing.
5261        Mode::LinkCreate { buffer, step, .. } | Mode::LinkEdit { buffer, step, .. } => {
5262            let label = match (step, matches!(app.mode, Mode::LinkEdit { .. })) {
5263                (LinkStep::Target, false) => "Link to issue",
5264                (LinkStep::Target, true) => "Re-target to issue (blank keeps)",
5265                (LinkStep::Kind, false) => "Kind (required)",
5266                (LinkStep::Kind, true) => "Kind (required)",
5267            };
5268            render_detail_input(app, f, area, label, buffer)
5269        }
5270        Mode::ConfirmRemoveRelationship { .. } => {
5271            if let Some(ref d) = app.detail {
5272                detail::render(d, f, area);
5273            }
5274            render_input_line(
5275                f,
5276                Rect {
5277                    y: area.y + area.height.saturating_sub(1),
5278                    height: 1,
5279                    ..area
5280                },
5281                "Remove relationship (y to confirm)",
5282                "",
5283            );
5284        }
5285    }
5286}
5287
5288fn render_form_picker(
5289    picker: &FormPicker,
5290    f: &mut ratatui::Frame,
5291    area: Rect,
5292    bindings: &EffectiveBindings,
5293) {
5294    let rect = crate::ui::modal::modal_rect(area);
5295    let picker_rect = Rect::new(
5296        rect.x.saturating_add(2),
5297        rect.y.saturating_add(2),
5298        rect.width.saturating_sub(4).max(10),
5299        rect.height.saturating_sub(4).max(5),
5300    );
5301    f.render_widget(Clear, picker_rect);
5302
5303    let mut lines = Vec::new();
5304    lines.push(Line::from(format!("Filter: {}", picker.state.filter)));
5305    lines.push(Line::from(""));
5306
5307    if picker.state.mode == PickerMode::InlineCreate {
5308        lines.push(Line::from(Span::styled(
5309            format!("Create: {}_", picker.state.create_buffer),
5310            Style::default().fg(Color::Yellow),
5311        )));
5312    } else {
5313        let visible = picker.state.visible_indices();
5314        for (pos, idx) in visible.into_iter().take(8).enumerate() {
5315            let item = &picker.state.options[idx];
5316            let selected = match &picker.state.selection {
5317                SelectionModel::Single(current) => current.as_deref() == Some(item.id.as_str()),
5318                SelectionModel::Multi(current) => current.contains(&item.id),
5319            };
5320            let cursor = if pos == picker.state.cursor { ">" } else { " " };
5321            let mark = if selected { "[x]" } else { "[ ]" };
5322            lines.push(Line::from(format!("{cursor} {mark} {}", item.label)));
5323        }
5324    }
5325
5326    lines.push(Line::from(""));
5327    lines.push(Line::from(Span::styled(
5328        format!(
5329            "{}/{} close | {} select | {} create",
5330            bindings.label(
5331                InputContext::FormPicker,
5332                crate::keybindings::TuiAction::OpenPicker
5333            ),
5334            bindings.label(
5335                InputContext::FormPicker,
5336                crate::keybindings::TuiAction::Cancel
5337            ),
5338            bindings.label(
5339                InputContext::FormPicker,
5340                crate::keybindings::TuiAction::Submit
5341            ),
5342            bindings.label(
5343                InputContext::FormPicker,
5344                crate::keybindings::TuiAction::Create
5345            ),
5346        ),
5347        Style::default().fg(Color::DarkGray),
5348    )));
5349
5350    let title = match picker.field {
5351        FormPickerField::Labels => "Labels picker",
5352        FormPickerField::Assignee => "Assignee picker",
5353        FormPickerField::Cycles => "Cycles picker",
5354        FormPickerField::Milestones => "Milestones picker",
5355        FormPickerField::Reviewer => "Reviewer picker",
5356        FormPickerField::Parent => "Parent picker",
5357    };
5358    let para = Paragraph::new(lines).block(Block::default().borders(Borders::ALL).title(title));
5359    f.render_widget(para, picker_rect);
5360}
5361
5362fn render_edit_lint_warning(
5363    app: &App,
5364    f: &mut ratatui::Frame,
5365    area: Rect,
5366    filename: &str,
5367    message: &str,
5368) {
5369    render_board(app, f, area, commandbar::MODE_BOARD);
5370    let rect = crate::ui::modal::modal_rect(area);
5371    f.render_widget(Clear, rect);
5372    let lines = vec![
5373        Line::from(Span::styled("Lint failed", Style::default().fg(Color::Red))),
5374        Line::from(Span::raw(filename.to_string())),
5375        Line::from(Span::styled(
5376            message.to_string(),
5377            Style::default().fg(Color::Yellow),
5378        )),
5379        Line::from(""),
5380        Line::from(Span::styled(
5381            format!(
5382                "{}: re-edit · {}: ignore",
5383                app.bindings.label(
5384                    InputContext::EditWarning,
5385                    crate::keybindings::TuiAction::Reedit
5386                ),
5387                app.bindings.label(
5388                    InputContext::EditWarning,
5389                    crate::keybindings::TuiAction::Ignore
5390                ),
5391            ),
5392            Style::default().fg(Color::DarkGray),
5393        )),
5394    ];
5395    let para = Paragraph::new(lines).block(
5396        Block::default()
5397            .borders(Borders::ALL)
5398            .title("Edit Warning")
5399            .style(Style::default().fg(Color::Red)),
5400    );
5401    f.render_widget(para, rect);
5402}
5403
5404/// Full-width row that always shows the title of the currently hovered
5405/// (selected) issue — `#<number> <title>` — or a dim placeholder when no card
5406/// is selected. Renders regardless of filter state. Sits directly beneath the
5407/// tab bar, or at the very top when a single board makes the bar redundant.
5408fn render_hover_line(app: &App, f: &mut ratatui::Frame, area: Rect) {
5409    let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
5410    let hover_area = Rect {
5411        y: area.y + tab_h,
5412        height: HOVER_HEIGHT,
5413        ..area
5414    };
5415    let line = match app.board.get_selected_card() {
5416        Some(card) => Line::from(vec![
5417            Span::styled(
5418                format!("#{}", card.number),
5419                Style::default().fg(Color::Cyan),
5420            ),
5421            Span::raw(" "),
5422            Span::styled(card.title.clone(), Style::default()),
5423        ]),
5424        None => Line::from(Span::styled("—", Style::default().fg(Color::DarkGray))),
5425    };
5426    f.render_widget(Paragraph::new(line), hover_area);
5427}
5428
5429/// Board view: a persistent tabs bar at the top (omitted when only one board
5430/// is configured), the hovered-issue title line beneath it, then the board
5431/// (with its own status line), and the hint bar at the very bottom
5432/// (FR-009/FR-010).
5433fn render_board(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
5434    let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
5435    let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
5436        FILTER_HEIGHT
5437    } else {
5438        0
5439    };
5440    let hint_h: u16 = if app.one_shot { 0 } else { HINT_HEIGHT };
5441    if area.height <= hint_h + tab_h + HOVER_HEIGHT + filter_h {
5442        return;
5443    }
5444    let hint_area = Rect {
5445        y: area.y + area.height - hint_h,
5446        height: hint_h,
5447        ..area
5448    };
5449    let tabs_area = Rect {
5450        y: area.y,
5451        height: tab_h,
5452        ..area
5453    };
5454    let filter_area = Rect {
5455        y: area.y + tab_h + HOVER_HEIGHT,
5456        height: filter_h,
5457        ..area
5458    };
5459    let content = Rect {
5460        y: area.y + tab_h + HOVER_HEIGHT + filter_h,
5461        height: area.height - tab_h - HOVER_HEIGHT - filter_h - hint_h,
5462        ..area
5463    };
5464    if tab_h > 0 {
5465        app.tabs.render(f, tabs_area);
5466        for index in 0..app.tabs.len() {
5467            if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
5468                app.record_region(rect, Action::SwitchBoard(index));
5469            }
5470        }
5471    }
5472    render_hover_line(app, f, area);
5473    if filter_h > 0 {
5474        filter_row(app).render(f, filter_area);
5475    }
5476    let page_cards = board::render(
5477        &app.board,
5478        f,
5479        content,
5480        &app.status_msg,
5481        app.drag_preview_col,
5482        board::BoardDisplay::from(app.active_board_config()),
5483    );
5484    // The renderer reports what one screen of the active column holds; the page keys
5485    // read it back. Set through `&App` because the field is a `Cell` — the same reason
5486    // the help overlay's geometry is one (tix #1442 follow-up).
5487    app.board_page.set(page_cards);
5488    if hint_h > 0 {
5489        render_hint(f, hint_area, &app.bindings, tag);
5490    }
5491}
5492
5493/// Detail view: detail content, optional status line, then the hint bar.
5494fn render_detail(app: &App, f: &mut ratatui::Frame, area: Rect, tag: &str) {
5495    let status_h: u16 = if app.status_msg.is_some() { 1 } else { 0 };
5496    if area.height <= HINT_HEIGHT + status_h {
5497        return;
5498    }
5499    let hint_area = Rect {
5500        y: area.y + area.height - HINT_HEIGHT,
5501        height: HINT_HEIGHT,
5502        ..area
5503    };
5504    let content = Rect {
5505        height: area.height - status_h - HINT_HEIGHT,
5506        ..area
5507    };
5508    if let Some(ref d) = app.detail {
5509        detail::render(d, f, content);
5510    }
5511    if let Some(msg) = &app.status_msg {
5512        let status_area = Rect {
5513            y: area.y + area.height - status_h - HINT_HEIGHT,
5514            height: status_h,
5515            ..area
5516        };
5517        let para = Paragraph::new(Line::from(Span::raw(msg.clone())))
5518            .style(Style::default().fg(Color::Yellow));
5519        f.render_widget(para, status_area);
5520    }
5521    render_hint(f, hint_area, &app.bindings, tag);
5522}
5523
5524/// Board-underneath input modes (filter): the tab bar is omitted for a single
5525/// board; otherwise the layout is tab bar, hovered-issue title line, optional
5526/// filter row, board, and a one-line prompt at the bottom.
5527fn render_board_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
5528    let tab_h: u16 = if app.tabs.needs_bar() { TAB_HEIGHT } else { 0 };
5529    if area.height < tab_h + HOVER_HEIGHT + 1 {
5530        return;
5531    }
5532    let filter_h: u16 = if app.filter.is_some() || app.structured.has_active_criteria() {
5533        FILTER_HEIGHT
5534    } else {
5535        0
5536    };
5537    let input_area = Rect {
5538        y: area.y + area.height - 1,
5539        height: 1,
5540        ..area
5541    };
5542    let tabs_area = Rect {
5543        y: area.y,
5544        height: tab_h,
5545        ..area
5546    };
5547    let filter_area = Rect {
5548        y: area.y + tab_h + HOVER_HEIGHT,
5549        height: filter_h,
5550        ..area
5551    };
5552    let content = Rect {
5553        y: area.y + tab_h + HOVER_HEIGHT + filter_h,
5554        height: area
5555            .height
5556            .saturating_sub(tab_h + HOVER_HEIGHT + filter_h + 1),
5557        ..area
5558    };
5559    if tab_h > 0 {
5560        app.tabs.render(f, tabs_area);
5561        for index in 0..app.tabs.len() {
5562            if let Some(rect) = app.tabs.tab_rect(index, tabs_area) {
5563                app.record_region(rect, Action::SwitchBoard(index));
5564            }
5565        }
5566    }
5567    render_hover_line(app, f, area);
5568    if filter_h > 0 {
5569        filter_row(app).render(f, filter_area);
5570    }
5571    let page_cards = board::render(
5572        &app.board,
5573        f,
5574        content,
5575        &app.status_msg,
5576        app.drag_preview_col,
5577        board::BoardDisplay::from(app.active_board_config()),
5578    );
5579    // The renderer reports what one screen of the active column holds; the page keys
5580    // read it back. Set through `&App` because the field is a `Cell` — the same reason
5581    // the help overlay's geometry is one (tix #1442 follow-up).
5582    app.board_page.set(page_cards);
5583    render_input_line(f, input_area, label, buffer);
5584}
5585
5586/// Detail-underneath input modes (comment, asset): detail content + prompt.
5587fn render_detail_input(app: &App, f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
5588    if area.height < 2 {
5589        return;
5590    }
5591    let input_area = Rect {
5592        y: area.y + area.height - 1,
5593        height: 1,
5594        ..area
5595    };
5596    let content = Rect {
5597        height: area.height - 1,
5598        ..area
5599    };
5600    if let Some(ref d) = app.detail {
5601        detail::render(d, f, content);
5602    }
5603    render_input_line(f, input_area, label, buffer);
5604}
5605
5606fn render_input_line(f: &mut ratatui::Frame, area: Rect, label: &str, buffer: &str) {
5607    let text = format!("{label}: {buffer}_");
5608    let prompt = Paragraph::new(Line::from(Span::styled(
5609        text,
5610        Style::default().fg(Color::Yellow),
5611    )));
5612    f.render_widget(prompt, area);
5613}
5614
5615fn filter_row(app: &App) -> crate::ui::filter_row::FilterRow {
5616    crate::ui::filter_row::FilterRow {
5617        query: app.filter.clone(),
5618        structured: app.structured.clone(),
5619    }
5620}
5621
5622fn render_hint(f: &mut ratatui::Frame, area: Rect, bindings: &EffectiveBindings, tag: &str) {
5623    let context = if tag == commandbar::MODE_DETAIL {
5624        InputContext::Detail
5625    } else {
5626        InputContext::Board
5627    };
5628    let bar = commandbar::hint_bar_for(bindings, context);
5629    let para = Paragraph::new(Line::from(Span::styled(
5630        bar,
5631        Style::default().fg(Color::Gray),
5632    )));
5633    f.render_widget(para, area);
5634}
5635
5636/// Help as a centered modal (FR-010): the screen the user came from stays
5637/// visible underneath (rendered per `return_to`), and the command list floats
5638/// above it in a bordered box. `?`/`q`/Esc close it (see `handle_help_key`).
5639fn render_help(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
5640    match return_to {
5641        Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
5642        Mode::Detail => render_detail(app, f, area, commandbar::MODE_DETAIL),
5643        _ => render_board(app, f, area, commandbar::MODE_BOARD),
5644    }
5645
5646    let help_entries = commandbar::help_overlay(&app.bindings);
5647    // tix #1496: `/` filters the list, best match first. `fuzzy_score` is the same
5648    // subsequence scorer `tix search` and the board's `/` use, so "clon" finds
5649    // `clone issue` and "nwiss" finds it too — one notion of "fuzzy" in the tool, not
5650    // a second one that behaves almost the same.
5651    let query = app.help_query.trim().to_string();
5652    // Contiguous matches first; the fuzzy scorer is the **fallback**, not the first
5653    // pass. A bare subsequence is far too permissive over a hundred short rows —
5654    // `clone` is a subsequence of `Esc / q / ? cancel context_menu`, so a pure fuzzy
5655    // filter would keep most of the list and rank the binding you asked for somewhere
5656    // inside it. The literal search is what actually narrows; when it finds nothing
5657    // (`nwiss` has no literal home) the scorer takes over, which is the case that makes
5658    // the search *fuzzy* rather than a second substring filter.
5659    let mut fuzzy_fallback = false;
5660    let matches: Vec<(String, String, String)> = if query.is_empty() {
5661        help_entries.clone()
5662    } else {
5663        let needle = query.to_lowercase();
5664        let haystack = |entry: &(String, String, String)| {
5665            format!("{} {} {}", entry.0, entry.1, entry.2).to_lowercase()
5666        };
5667        let literal: Vec<(String, String, String)> = help_entries
5668            .iter()
5669            .filter(|entry| haystack(entry).contains(&needle))
5670            .cloned()
5671            .collect();
5672        if literal.is_empty() {
5673            fuzzy_fallback = true;
5674            // Best first; `sort_by` is stable, so equal scores keep the declared order —
5675            // which is the order the overlay is normally read in.
5676            let mut scored: Vec<(u32, (String, String, String))> = help_entries
5677                .iter()
5678                .filter_map(|entry| {
5679                    crate::search::fuzzy_score(&query, &haystack(entry))
5680                        .map(|score| (score, entry.clone()))
5681                })
5682                .collect();
5683            scored.sort_by(|a, b| b.0.cmp(&a.0));
5684            scored.into_iter().map(|(_, entry)| entry).collect()
5685        } else {
5686            literal
5687        }
5688    };
5689    let total = help_entries.len();
5690    // How many entry rows fit, once the border and this line are accounted for.
5691    // Computed **before** the rows so the notice can be counted against the same
5692    // budget rather than pushed off the bottom itself.
5693    let frame_rows = area.height.saturating_sub(2);
5694    let visible = (matches.len() as u16).min(frame_rows.saturating_sub(1));
5695    let hidden = matches.len().saturating_sub(visible as usize);
5696    // tix #1441: record what only the renderer knows, so the handlers can clamp a
5697    // step without re-deriving the frame.
5698    app.help_metrics.set(HelpMetrics {
5699        visible,
5700        max_scroll: u16::try_from(hidden).unwrap_or(u16::MAX),
5701    });
5702    let offset = usize::from(app.help_scroll).min(hidden);
5703    let mut rows: Vec<Line<'static>> = matches
5704        .iter()
5705        .skip(offset)
5706        .take(visible as usize)
5707        .map(|(key, desc, scope)| {
5708            Line::from(vec![
5709                Span::styled(format!("{key:<20}"), Style::default().fg(Color::Cyan)),
5710                Span::styled(format!("{desc:<28}"), Style::default().fg(Color::White)),
5711                Span::styled(scope.clone(), Style::default().fg(Color::DarkGray)),
5712            ])
5713        })
5714        .collect();
5715    // Returns the column the terminal caret belongs at, when the input line is up.
5716    let caret = if app.help_searching {
5717        let mut spans = vec![
5718            Span::styled("/", Style::default().fg(Color::Yellow)),
5719            Span::styled(app.help_query.clone(), Style::default().fg(Color::White)),
5720        ];
5721        // The count belongs on this line too: while typing is exactly when the reader
5722        // wants to know whether the filter found anything, and the rows below say it
5723        // only by being short. It sits after the caret, dim, so it reads as status
5724        // rather than as more input.
5725        if !query.is_empty() {
5726            spans.push(Span::styled(
5727                format!(
5728                    "  — {} of {total} bindings{}",
5729                    matches.len(),
5730                    if fuzzy_fallback { " (fuzzy)" } else { "" },
5731                ),
5732                Style::default().fg(Color::DarkGray),
5733            ));
5734        }
5735        rows.push(Line::from(spans));
5736        Some(Line::from(format!("/{}", app.help_query)).width())
5737    } else if !query.is_empty() {
5738        // What the filter did, and where in the *matches* the reader is — the same two
5739        // facts the unfiltered line carries, because a filtered list that does not say
5740        // how much it is hiding reads as the whole list.
5741        // Says which search answered, so a fallback that matched rows the reader did not
5742        // type is explained rather than mysterious.
5743        let mode = if fuzzy_fallback { " (fuzzy)" } else { "" };
5744        rows.push(Line::from(Span::styled(
5745            match hidden {
5746                0 => format!(
5747                    "match \"{query}\" — {} of {total} bindings{mode}",
5748                    matches.len()
5749                ),
5750                _ => format!(
5751                    "match \"{query}\" — {} of {total} bindings, rows {}-{}{mode}",
5752                    matches.len(),
5753                    offset + 1,
5754                    offset + visible as usize,
5755                ),
5756            },
5757            Style::default().fg(Color::DarkGray),
5758        )));
5759        None
5760    } else if hidden > 0 {
5761        // **Say what is missing — and where you are.** The overlay used to be
5762        // silently truncated at the frame height, so a short terminal showed a
5763        // complete-looking list that was quietly missing bindings, and a binding
5764        // absent from the help is a binding nobody can find. Now that it scrolls, the
5765        // same line has to answer "is there more?" *and* "am I at the top?", or a
5766        // scrolled reader cannot tell the difference between the first page and the
5767        // last.
5768        //
5769        // One row of the budget, so it stays short enough not to wrap.
5770        rows.push(Line::from(Span::styled(
5771            format!(
5772                "rows {}-{} of {} — {} above, {} below",
5773                offset + 1,
5774                offset + visible as usize,
5775                matches.len(),
5776                offset,
5777                hidden - offset,
5778            ),
5779            Style::default().fg(Color::DarkGray),
5780        )));
5781        None
5782    } else {
5783        None
5784    };
5785    let block = Block::default()
5786        .borders(Borders::ALL)
5787        .title("Help [? close]")
5788        .style(Style::default().fg(Color::Cyan));
5789    let para = Paragraph::new(rows).block(block);
5790    // The match rows plus the one line that carries the input prompt, the filter
5791    // summary or the scroll position — whichever of those is showing.
5792    let notice_rows = u16::from(caret.is_some() || !query.is_empty() || hidden > 0);
5793    let drawn_rows = visible + notice_rows;
5794
5795    let modal_w = 60.min(area.width).max(30);
5796    // Sized to what is actually drawn, so a filter that narrows the list to three
5797    // bindings gives a three-row box rather than leaving a tall empty one.
5798    let modal_h = (drawn_rows + 2).min(area.height).max(4);
5799    let modal_x = area.x + (area.width.saturating_sub(modal_w)) / 2;
5800    let modal_y = area.y + (area.height.saturating_sub(modal_h)) / 2;
5801    let modal_area = Rect::new(modal_x, modal_y, modal_w, modal_h);
5802
5803    if area.height >= modal_h && area.width >= modal_w {
5804        f.render_widget(Clear, modal_area);
5805        f.render_widget(para, modal_area);
5806    }
5807    // The input line has the keyboard, so the caret goes where the next character will
5808    // land — the same rule the create form follows (tix #1486), and the reason the
5809    // prompt is drawn as a row rather than only described in the footer.
5810    if let Some(column) = caret
5811        && area.height >= modal_h
5812        && area.width >= modal_w
5813    {
5814        let inner = Block::default().borders(Borders::ALL).inner(modal_area);
5815        let row = inner.y + visible.min(inner.height.saturating_sub(1));
5816        let x = inner.x
5817            + u16::try_from(column)
5818                .unwrap_or(u16::MAX)
5819                .min(inner.width.saturating_sub(1));
5820        f.set_cursor_position(ratatui::layout::Position::new(x, row));
5821    }
5822    // Any click on the help screen closes it, so the whole overlay resolves to
5823    // the same branch as `?`/`q`/Esc.
5824    app.record_region(area, Action::HelpClose);
5825}
5826
5827/// The centered prompt box of the confirm-delete dialog, sized to the frame.
5828/// Shared by `render` (drawing) and `confirm_delete_buttons` (hit regions) so
5829/// the pointer geometry cannot drift from what is drawn.
5830fn confirm_delete_prompt(area: Rect) -> Rect {
5831    let prompt_w = 40.min(area.width);
5832    let prompt_h = 4;
5833    let prompt_x = area.x + (area.width.saturating_sub(prompt_w)) / 2;
5834    let prompt_y = area.y + (area.height.saturating_sub(prompt_h)) / 2;
5835    Rect::new(prompt_x, prompt_y, prompt_w, prompt_h)
5836}
5837
5838/// The matched cell rectangles of the Yes and No buttons inside a
5839/// confirm-delete prompt, matching the `"  [Yes]   [No]  "` row's columns.
5840/// `(yes, no)`.
5841fn confirm_delete_buttons(prompt: Rect) -> (Rect, Rect) {
5842    let yes = Rect::new(prompt.x + 3, prompt.y + 3, 5, 1);
5843    let no = Rect::new(prompt.x + 10, prompt.y + 3, 4, 1);
5844    (yes, no)
5845}
5846
5847/// The issue context menu as an overlay (D5): the board stays visible
5848/// underneath and the menu floats at a clamped popup position. See
5849/// [`App::handle_context_menu_key`] for routing.
5850fn render_context_menu(app: &App, f: &mut ratatui::Frame, area: Rect, return_to: &Mode) {
5851    match return_to {
5852        Mode::Board => render_board(app, f, area, commandbar::MODE_BOARD),
5853        _ => render_board(app, f, area, commandbar::MODE_BOARD),
5854    }
5855    if let Some(menu) = app.context_menu.as_ref() {
5856        menu.render(f, area);
5857    }
5858}
5859
5860/// Drain disclosures raised by a TUI action into the status line.
5861///
5862/// Extracted from the event loop so the wiring is reachable from a test. Cycle 5
5863/// deleted the inlined loop and all 1012 tests stayed green: the unit test for
5864/// `note_disclosure` calls it directly, so it could not fail for the regression it
5865/// is named for. `run` needs a real terminal, so this function is the only seam
5866/// available. The call site inside `run` is still not covered; it says so.
5867pub fn drain_disclosures(app: &mut App) {
5868    for disclosure in crate::storage::take_disclosures() {
5869        app.note_disclosure(disclosure);
5870    }
5871}
5872
5873pub fn run(config: &Config) -> Result<()> {
5874    run_session(config, false)
5875}
5876
5877/// `tix new` (#1398): the same session, opened on the create-issue form.
5878///
5879/// The form wins over the pending-rollover dialog an expired cycle would normally
5880/// raise at startup. That is deliberate: `tix new` was asked to file an issue, the
5881/// rollover is a prompt rather than a task, and it is still pending — the next
5882/// session and the one-line hint every other command prints both raise it again.
5883pub fn run_new_issue(config: &Config) -> Result<()> {
5884    run_session(config, true)
5885}
5886
5887fn run_session(config: &Config, new_issue_form: bool) -> Result<()> {
5888    let mut app = App::new(config)?;
5889    if new_issue_form {
5890        app.open_new_issue_form();
5891    }
5892    let mut watcher = crate::watcher::FsWatcher::new(app.store.issues_dir.clone());
5893    enable_raw_mode().map_err(io_err)?;
5894    execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
5895    mouse_capture_on(io::stdout()).map_err(io_err)?;
5896    let guard = RestoreGuard;
5897    let backend = CrosstermBackend::new(io::stdout());
5898    let mut terminal = Terminal::new(backend).map_err(io_err)?;
5899
5900    let result = loop {
5901        // A write performed by any TUI action can raise a disclosure; surface
5902        // it rather than letting it sit in the queue for the rest of the session.
5903        //
5904        // Uncovered: `run` needs a real terminal, so no test reaches this line.
5905        // `drain_disclosures` itself is tested; this call site is not, and saying
5906        // so here is the point — a reader must not infer that deleting it would be
5907        // caught. Cycle-6 F5: the function's own comment used to promise a
5908        // statement here that was never written.
5909        drain_disclosures(&mut app);
5910        terminal
5911            .draw(|f| render(&app, f, f.area()))
5912            .map_err(io_err)?;
5913
5914        if event::poll(Duration::from_millis(200)).map_err(io_err)? {
5915            let action = match event::read().map_err(io_err)? {
5916                Event::Key(key) => app.handle_key(key)?,
5917                Event::Mouse(mouse) => app.handle_mouse(mouse)?,
5918                // Resize (and any unknown event) is a no-op: the loop redraws
5919                // on every 200ms tick anyway (D2).
5920                _ => Action::None,
5921            };
5922            match action {
5923                Action::None => {}
5924                Action::Quit => break Ok(()),
5925                Action::SwitchBoard(index) => {
5926                    app.switch_board(index);
5927                }
5928                Action::ConfirmDeleteYes => {
5929                    let _ = app.confirm_delete_yes();
5930                }
5931                Action::ConfirmDeleteNo => {
5932                    let _ = app.confirm_delete_no();
5933                }
5934                Action::FilterModalFocus(field) => {
5935                    if let Some(modal) = app.filter_modal.as_mut() {
5936                        modal.focus_field(field);
5937                    }
5938                }
5939                Action::SettingsRow(row) => {
5940                    let count =
5941                        app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
5942                    if let Some(modal) = app.settings.as_mut() {
5943                        modal.move_selected_to(row, count);
5944                    }
5945                    let _ = app.settings_toggle_selected();
5946                }
5947                Action::DatePickerHit(hit) => match hit {
5948                    DatePickerHit::Day(date) => {
5949                        if let Some(picker) = app.date_picker.as_mut() {
5950                            picker.select_date_str(&date);
5951                        }
5952                    }
5953                    DatePickerHit::PrevMonth => {
5954                        if let Some(picker) = app.date_picker.as_mut() {
5955                            picker.shift_month(-1);
5956                        }
5957                    }
5958                    DatePickerHit::NextMonth => {
5959                        if let Some(picker) = app.date_picker.as_mut() {
5960                            picker.shift_month(1);
5961                        }
5962                    }
5963                    DatePickerHit::Submit => {
5964                        let _ = app.handle_date_picker_key(KeyEvent::new(
5965                            KeyCode::Enter,
5966                            crossterm::event::KeyModifiers::NONE,
5967                        ));
5968                    }
5969                },
5970                Action::HelpClose => {
5971                    app.close_help();
5972                }
5973                Action::OpenEditor(filename) => {
5974                    drop(terminal);
5975                    let _ = disable_raw_mode();
5976                    let _ = mouse_capture_off(io::stdout());
5977                    let _ = execute!(io::stdout(), LeaveAlternateScreen);
5978
5979                    let edit_result = (|| -> Result<()> {
5980                        let name = editor::resolve_editor(app.store.config.editor.as_deref())?;
5981                        let original = app.store.get(IssueSelector::Filename(filename.clone()))?;
5982                        let temp = std::env::temp_dir().join(format!(
5983                            "tix-edit-{}-{}.md",
5984                            std::process::id(),
5985                            original.number
5986                        ));
5987                        std::fs::write(&temp, original.to_file_contents()).map_err(TixError::Io)?;
5988                        editor::run_editor(&name, &temp, &app.store.config)?;
5989                        let edited = std::fs::read_to_string(&temp).map_err(TixError::Io)?;
5990                        let mut parsed = Issue::parse(&edited)?;
5991                        let _ = std::fs::remove_file(&temp);
5992                        if parsed.title.trim().is_empty() {
5993                            app.mode = Mode::EditLintWarning {
5994                                filename: filename.clone(),
5995                                message: "title is required".to_string(),
5996                            };
5997                            app.status_msg = Some("editor: title is required".to_string());
5998                            return Ok(());
5999                        }
6000                        parsed.bump_updated();
6001                        let location = match parsed.assignee {
6002                            Some(ref a) => match app.store.reassign(&original.filename, Some(a)) {
6003                                Ok(moved) => moved.to_string_lossy().to_string(),
6004                                Err(e) => {
6005                                    app.status_msg = Some(format!("editor: {e}"));
6006                                    return Ok(());
6007                                }
6008                            },
6009                            None => original.filename.clone(),
6010                        };
6011                        app.store.update(&location, &parsed)?;
6012                        if let Some(ref mut d) = app.detail
6013                            && (d.issue.filename == original.filename
6014                                || d.issue.filename == location)
6015                        {
6016                            d.issue = parsed;
6017                            d.scroll_offset = 0;
6018                        }
6019                        app.refresh_corpus()?;
6020                        app.status_msg = Some("Edited".to_string());
6021                        Ok(())
6022                    })();
6023                    if let Err(e) = edit_result {
6024                        app.status_msg = Some(format!("editor: {e}"));
6025                    }
6026
6027                    enable_raw_mode().map_err(io_err)?;
6028                    execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
6029                    mouse_capture_on(io::stdout()).map_err(io_err)?;
6030                    let backend = CrosstermBackend::new(io::stdout());
6031                    terminal = Terminal::new(backend).map_err(io_err)?;
6032                }
6033                Action::OpenAgent(filename) => {
6034                    drop(terminal);
6035                    let _ = disable_raw_mode();
6036                    let _ = mouse_capture_off(io::stdout());
6037                    let _ = execute!(io::stdout(), LeaveAlternateScreen);
6038
6039                    let edit_result = (|| -> Result<()> {
6040                        let agent = editor::resolve_agent(app.store.config.agent.as_deref())?;
6041                        let issue = app.store.get(IssueSelector::Filename(filename.clone()))?;
6042                        let temp = std::env::temp_dir().join(format!(
6043                            "tix-agent-{}-{}.md",
6044                            std::process::id(),
6045                            issue.number
6046                        ));
6047                        std::fs::write(&temp, issue.body.as_bytes()).map_err(io_err)?;
6048                        let result = std::process::Command::new(&agent)
6049                            .arg(&temp)
6050                            .status()
6051                            .map_err(|e| {
6052                                crate::error::TixError::Editor(format!(
6053                                    "failed to launch {agent}: {e}"
6054                                ))
6055                            });
6056                        let _ = std::fs::remove_file(&temp);
6057                        result?;
6058                        Ok(())
6059                    })();
6060                    if let Err(e) = edit_result {
6061                        app.status_msg = Some(format!("agent: {e}"));
6062                    }
6063
6064                    enable_raw_mode().map_err(io_err)?;
6065                    execute!(io::stdout(), EnterAlternateScreen).map_err(io_err)?;
6066                    mouse_capture_on(io::stdout()).map_err(io_err)?;
6067                    let backend = CrosstermBackend::new(io::stdout());
6068                    terminal = Terminal::new(backend).map_err(io_err)?;
6069                }
6070            }
6071        }
6072
6073        // Issue #10: reflect edits made outside `tix` (another editor, `tix`
6074        // CLI, git checkout) by rebuilding the board when the issues tree
6075        // changes. A transient read error keeps the current board.
6076        if watcher.poll()
6077            && let Err(e) = app.refresh_from_disk()
6078        {
6079            app.status_msg = Some(format!("watch: {e}"));
6080        }
6081    };
6082    drop(guard);
6083    result
6084}
6085
6086/// Render the board once using startup state and return the plain-text frame.
6087pub fn render_once(config: &Config) -> Result<String> {
6088    let size = resolve_render_size(crossterm::terminal::size().ok());
6089    render_once_at(config, size.0, size.1)
6090}
6091
6092fn resolve_render_size(measured: Option<(u16, u16)>) -> (u16, u16) {
6093    let (width, height) = measured.unwrap_or((RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT));
6094    (width.max(1), height.max(1))
6095}
6096
6097fn render_once_at(config: &Config, width: u16, height: u16) -> Result<String> {
6098    let render_h = height.max(1).saturating_sub(RENDER_BOTTOM_MARGIN).max(1);
6099    let mut app = App::new(config)?;
6100    app.mode = Mode::Board;
6101    app.rollover_dialog = None;
6102    app.one_shot = true;
6103    let cells = render_to_cells(&app, width, render_h)?;
6104    Ok(cells_to_text(&cells, width, render_h))
6105}
6106
6107fn render_to_cells(app: &App, width: u16, height: u16) -> Result<Vec<String>> {
6108    let backend = ratatui::backend::TestBackend::new(width.max(1), height.max(1));
6109    let mut terminal = ratatui::Terminal::new(backend).map_err(io_err)?;
6110    terminal
6111        .draw(|f| render(app, f, f.area()))
6112        .map_err(io_err)?;
6113    let buffer = terminal.backend().buffer();
6114    Ok(buffer
6115        .content
6116        .iter()
6117        .map(|cell| cell.symbol().to_string())
6118        .collect())
6119}
6120
6121fn cells_to_text(cells: &[String], width: u16, height: u16) -> String {
6122    let mut lines = Vec::new();
6123    for row in 0..height as usize {
6124        let start = row * width as usize;
6125        let end = start + width as usize;
6126        let line = cells[start..end].concat();
6127        lines.push(line.trim_end().to_string());
6128    }
6129    while matches!(lines.last(), Some(last) if last.is_empty()) {
6130        let _ = lines.pop();
6131    }
6132    if lines.is_empty() {
6133        String::new()
6134    } else {
6135        format!("{}\n", lines.join("\n"))
6136    }
6137}
6138
6139/// Enable terminal mouse capture on `stream` (D1): one command pair shared by
6140/// the startup, editor/agent shell-out re-entry, and restore paths so capture
6141/// toggling can never drift between call sites.
6142fn mouse_capture_on(mut stream: io::Stdout) -> Result<()> {
6143    execute!(stream, EnableMouseCapture).map_err(io_err)
6144}
6145
6146/// Disable terminal mouse capture on `stream` (D1): mirror of
6147/// [`mouse_capture_on`]. Must run while the alternate screen is still active,
6148/// so the terminal leaves a clean state on quit/error.
6149fn mouse_capture_off(mut stream: io::Stdout) -> Result<()> {
6150    execute!(stream, DisableMouseCapture).map_err(io_err)
6151}
6152
6153struct RestoreGuard;
6154
6155impl Drop for RestoreGuard {
6156    fn drop(&mut self) {
6157        let _ = disable_raw_mode();
6158        let _ = mouse_capture_off(io::stdout());
6159        let _ = execute!(io::stdout(), LeaveAlternateScreen);
6160    }
6161}
6162
6163fn io_err<E: std::fmt::Display>(e: E) -> TixError {
6164    TixError::Io(io::Error::other(e.to_string()))
6165}
6166
6167#[cfg(test)]
6168mod tests {
6169    use super::*;
6170    use std::fs;
6171    use tempfile::TempDir;
6172
6173    fn make_app() -> (TempDir, App) {
6174        let tmp = TempDir::new().unwrap();
6175        let dir = tmp.path().join("issues");
6176        fs::create_dir_all(&dir).unwrap();
6177        let config_path = tmp.path().join("tix.toml");
6178        let config = Config {
6179            issues_dir: dir.clone(),
6180            ..Config::default()
6181        };
6182        config.save(&config_path).unwrap();
6183        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
6184        let app = App::new(&config).unwrap();
6185        (tmp, app)
6186    }
6187
6188    fn write_issue(
6189        dir: &std::path::Path,
6190        name: &str,
6191        number: u32,
6192        title: &str,
6193        status: &str,
6194        commit: Option<&str>,
6195    ) {
6196        let commit_line = match commit {
6197            Some(h) => format!("commit: \"{h}\""),
6198            None => String::new(),
6199        };
6200        let contents = format!(
6201            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n{commit_line}\n---\n\nBody text."
6202        );
6203        fs::write(dir.join(name), contents).unwrap();
6204    }
6205
6206    fn press(code: KeyCode) -> KeyEvent {
6207        KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
6208    }
6209
6210    fn press_with_modifiers(code: KeyCode, modifiers: crossterm::event::KeyModifiers) -> KeyEvent {
6211        KeyEvent::new(code, modifiers)
6212    }
6213
6214    /// A temp store seeded with two cycles (one referencing a stored issue), a
6215    /// milestone file, and one issue referencing both. Cycles carry
6216    /// far-future dates so no rollover dialog steals the initial mode.
6217    fn make_app_with_planning() -> (TempDir, App) {
6218        let tmp = TempDir::new().unwrap();
6219        let dir = tmp.path().join("issues");
6220        fs::create_dir_all(&dir).unwrap();
6221        let mut config = Config {
6222            issues_dir: dir.clone(),
6223            ..Config::default()
6224        };
6225        config.cycles.insert(
6226            "Cycle 1".into(),
6227            crate::config::PlanningCycle {
6228                description: Some("kickoff".into()),
6229                start_date: Some("2026-10-01".into()),
6230                due_date: Some("2999-12-31".into()),
6231            },
6232        );
6233        config.cycles.insert(
6234            "Cycle 2".into(),
6235            crate::config::PlanningCycle {
6236                description: Some("next".into()),
6237                start_date: Some("2999-01-01".into()),
6238                due_date: Some("2999-01-15".into()),
6239            },
6240        );
6241        let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\nmilestones: [\"GA\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
6242        fs::create_dir_all(dir.join("alice")).unwrap();
6243        fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
6244        let milestones_dir = tmp.path().join("docs/milestones");
6245        fs::create_dir_all(&milestones_dir).unwrap();
6246        fs::write(
6247            milestones_dir.join("2026-01-01-ga.md"),
6248            "---\nname: \"GA\"\ntarget_date: \"2026-12-31\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nship it\n",
6249        )
6250        .unwrap();
6251        let config_path = tmp.path().join("tix.toml");
6252        config.save(&config_path).unwrap();
6253        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
6254        let app = App::new(&config).unwrap();
6255        (tmp, app)
6256    }
6257
6258    fn make_app_with_rollover_dialog() -> (TempDir, App) {
6259        let tmp = TempDir::new().unwrap();
6260        let dir = tmp.path().join("issues");
6261        fs::create_dir_all(&dir).unwrap();
6262        let mut config = Config {
6263            issues_dir: dir.clone(),
6264            ..Config::default()
6265        };
6266        config.cycles.clear();
6267        config.cycles.insert(
6268            "Cycle 1".into(),
6269            crate::config::PlanningCycle {
6270                description: Some("expired".into()),
6271                start_date: Some("2000-01-01".into()),
6272                due_date: Some("2000-01-02".into()),
6273            },
6274        );
6275        config.cycles.insert(
6276            "Cycle 2".into(),
6277            crate::config::PlanningCycle {
6278                description: Some("next".into()),
6279                start_date: Some("2999-01-01".into()),
6280                due_date: Some("2999-01-15".into()),
6281            },
6282        );
6283        let issue = "---\nnumber: 1\ntitle: \"A\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncycles: [\"Cycle 1\"]\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
6284        fs::create_dir_all(dir.join("alice")).unwrap();
6285        fs::write(dir.join("alice/2026-01-01-a.md"), issue).unwrap();
6286        let config_path = tmp.path().join("tix.toml");
6287        config.save(&config_path).unwrap();
6288        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
6289        let app = App::new(&config).unwrap();
6290        (tmp, app)
6291    }
6292
6293    #[test]
6294    fn quit_on_q() {
6295        let (_tmp, mut app) = make_app();
6296        let action = app.handle_key(press(KeyCode::Char('q'))).unwrap();
6297        assert!(app.quit);
6298        assert!(matches!(action, Action::Quit));
6299    }
6300
6301    #[test]
6302    fn m_opens_planning_modal_from_board() {
6303        let (_tmp, mut app) = make_app();
6304        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6305        assert!(
6306            matches!(app.mode, Mode::PlanningManagement),
6307            "m flips board mode to planning management"
6308        );
6309        assert!(app.planning.is_some(), "planning modal state is opened");
6310    }
6311
6312    #[test]
6313    fn q_and_esc_close_planning_modal() {
6314        let (_tmp, mut app) = make_app();
6315        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6316        let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
6317        assert!(matches!(app.mode, Mode::Board));
6318        assert!(app.planning.is_none());
6319
6320        let (_tmp, mut app) = make_app();
6321        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6322        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6323        assert!(matches!(app.mode, Mode::Board), "Esc closes the modal");
6324        assert!(app.planning.is_none());
6325    }
6326
6327    #[test]
6328    fn esc_cancels_planning_form_back_to_modal() {
6329        let (_tmp, mut app) = make_app();
6330        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6331        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6332        assert!(matches!(app.mode, Mode::PlanningForm));
6333        assert!(app.planning_form.is_some());
6334        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6335        assert!(
6336            matches!(app.mode, Mode::PlanningManagement),
6337            "Esc in the form returns to the modal"
6338        );
6339        assert!(app.planning_form.is_none());
6340        assert!(app.planning.is_some(), "the modal stays open underneath");
6341    }
6342
6343    #[test]
6344    fn planning_browse_navigates_and_switches_segments() {
6345        let (_tmp, mut app) = make_app_with_planning();
6346        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6347
6348        let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
6349        assert_eq!(app.planning.as_ref().unwrap().selected, 1);
6350        let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
6351        assert_eq!(
6352            app.planning.as_ref().unwrap().selected,
6353            1,
6354            "cursor clamps at the last cycle"
6355        );
6356        let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
6357        assert_eq!(app.planning.as_ref().unwrap().selected, 0);
6358
6359        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6360        let modal = app.planning.as_ref().unwrap();
6361        assert_eq!(
6362            modal.segment,
6363            crate::ui::planning::PlanningSegment::Milestones,
6364            "Tab switches to the milestones segment"
6365        );
6366        assert_eq!(modal.selected, 0, "segment switch resets the row cursor");
6367        let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
6368        assert_eq!(
6369            app.planning.as_ref().unwrap().selected,
6370            0,
6371            "a single milestone clamps the cursor"
6372        );
6373
6374        let _ = app.handle_key(press(KeyCode::BackTab)).unwrap();
6375        assert_eq!(
6376            app.planning.as_ref().unwrap().segment,
6377            crate::ui::planning::PlanningSegment::Cycles,
6378            "BackTab switches back to the cycles segment"
6379        );
6380
6381        let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
6382        assert!(
6383            matches!(app.mode, Mode::Board),
6384            "q cancels out of the modal"
6385        );
6386        assert!(app.planning.is_none());
6387    }
6388
6389    #[test]
6390    fn planning_n_opens_create_form_for_active_segment() {
6391        let (_tmp, mut app) = make_app_with_planning();
6392        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6393        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6394        assert!(matches!(app.mode, Mode::PlanningForm));
6395        assert_eq!(
6396            app.planning_form.as_ref().unwrap().kind,
6397            crate::ui::planning::PlanningKind::Cycle,
6398            "n on the cycles segment opens a cycle form"
6399        );
6400
6401        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6402        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6403        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6404        assert_eq!(
6405            app.planning_form.as_ref().unwrap().kind,
6406            crate::ui::planning::PlanningKind::Milestone,
6407            "n on the milestones segment opens a milestone form"
6408        );
6409    }
6410
6411    #[test]
6412    fn planning_e_opens_edit_form_prefilled() {
6413        let (_tmp, mut app) = make_app_with_planning();
6414        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6415        let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6416        let form = app.planning_form.as_ref().unwrap().clone();
6417        assert_eq!(form.kind, crate::ui::planning::PlanningKind::Cycle);
6418        assert_eq!(form.name(), "Cycle 1");
6419        assert_eq!(form.editing.as_deref(), Some("Cycle 1"));
6420        assert_eq!(form.description(), "kickoff");
6421        assert_eq!(form.date_a(), "2026-10-01");
6422
6423        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6424        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6425        let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6426        let form = app.planning_form.as_ref().unwrap().clone();
6427        assert_eq!(form.kind, crate::ui::planning::PlanningKind::Milestone);
6428        assert_eq!(form.name(), "GA");
6429        assert_eq!(form.editing.as_deref(), Some("GA"));
6430        assert_eq!(form.date_a(), "2026-12-31");
6431    }
6432
6433    #[test]
6434    fn planning_x_shows_referencing_count_and_cancel_keeps_item() {
6435        let (_tmp, mut app) = make_app_with_planning();
6436        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6437        let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6438        match &app.planning.as_ref().unwrap().prompt {
6439            Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
6440                assert_eq!(name, "Cycle 1");
6441                assert_eq!(*referencing, 1, "one issue references Cycle 1");
6442            }
6443            other => panic!("x raises a confirm prompt, got {other:?}"),
6444        }
6445
6446        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6447        assert!(
6448            app.planning.as_ref().unwrap().prompt.is_none(),
6449            "n dismisses the prompt"
6450        );
6451        assert!(
6452            matches!(app.mode, Mode::PlanningManagement),
6453            "cancelling stays in the modal"
6454        );
6455        assert_eq!(
6456            app.planning_rows_len(),
6457            2,
6458            "the cancelled delete leaves the cycle in the registry"
6459        );
6460    }
6461
6462    #[test]
6463    /// #1307: a double-width character must not defeat the cut marker.
6464    ///
6465    /// The previous truncation took `chars().take(budget)` — scalar values, not
6466    /// display cells — so a CJK filename produced a string up to twice as wide as
6467    /// the status area. ratatui's `Paragraph` then hard-cut it at the right edge
6468    /// and discarded the ellipsis appended at the end of the overflowed string,
6469    /// which is the silent cut the truncation was added to prevent.
6470    ///
6471    /// Driven through the real renderer rather than the helper, because the bug
6472    /// lived in the interaction between the two: the helper "worked" and the
6473    /// widget threw the marker away.
6474    fn the_cut_marker_survives_a_double_width_filename() {
6475        let (_tmp, mut app) = make_app();
6476        app.note_disclosure(
6477            "remove `links:` by hand to silence \u{2014} \
6478             alice/2026-09-14-\u{65e5}\u{672c}\u{8a9e}\u{306e}\u{3068}\u{3066}\u{3082}\u{3044}\u{3044}\u{30d5}\u{30a1}\u{30a4}\u{30eb}\u{540d}_tests.md \
6479             (retired by C-001; never read as typed data, no migration)"
6480                .to_string(),
6481        );
6482        // 60 is included: the review noted the existing test omitted it, and
6483        // omitted widths are where this class of bug hides.
6484        for width in [60u16, 80, 100, 120, 200] {
6485            let cells = render_to_cells(&app, width, 40).unwrap();
6486            let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
6487            let status = rendered
6488                .iter()
6489                .find(|l| l.contains("remove `links:`"))
6490                .unwrap_or_else(|| panic!("width {width}: no status row: {rendered:?}"));
6491            assert!(
6492                status.contains("remove `links:` by hand"),
6493                "width {width}: the action must stay visible, got {status:?}"
6494            );
6495            if width < 200 {
6496                assert!(
6497                    status.contains('\u{2026}'),
6498                    "width {width}: a cut must be marked, not silent, got {status:?}"
6499                );
6500            }
6501        }
6502    }
6503
6504    /// #1307: the rendered row must never exceed the area, in cells.
6505    ///
6506    /// This is the invariant the widget silently broke: a string handed to a
6507    /// one-row area that is wider than the area gets hard-cut, and whatever sat
6508    /// at the end of it — the marker — is lost.
6509    #[test]
6510    #[allow(clippy::unicode_not_nfc)] // the CJK literals are deliberately un-normalised
6511    fn a_truncated_status_row_never_exceeds_its_width_in_cells() {
6512        use unicode_width::UnicodeWidthStr;
6513        for msg in [
6514            "remove `links:` by hand to silence \u{2014} plain-ascii-file.md",
6515            "remove `links:` by hand to silence \u{2014} \u{65e5}\u{672c}\u{8a9e}.md",
6516            "short",
6517            "",
6518        ] {
6519            for budget in [1usize, 2, 3, 5, 10, 20] {
6520                let text = crate::ui::board::truncate_to_cells(msg, budget);
6521                assert!(
6522                    text.width() <= budget,
6523                    "budget {budget}, msg {msg:?}: produced {text:?} at width {}",
6524                    text.width()
6525                );
6526                if msg.width() > budget {
6527                    assert!(
6528                        text.ends_with('\u{2026}'),
6529                        "budget {budget}, msg {msg:?}: a cut must be marked, got {text:?}"
6530                    );
6531                }
6532            }
6533        }
6534    }
6535
6536    #[test]
6537    fn the_retired_key_warning_stays_actionable_at_every_terminal_width() {
6538        // Cycle-5 Major 6 claimed reordering the composed message was enough; cycle
6539        // 6 drove the real renderer and found the actionable instruction still
6540        // off-screen at 80, 100 and 120 columns, because the status area is one
6541        // unwrapped row and a long filename fills it whatever the order. This
6542        // pins what actually makes it work: the disclosure leads with the action,
6543        // and the render marks the cut.
6544        let (_tmp, mut app) = make_app();
6545        app.note_disclosure(
6546            "remove `links:` by hand to silence \u{2014} \
6547             alice/2026-09-14-add-the-ability-to-hide-and-show-kanban-columns.md \
6548             (retired by C-001; never read as typed data, no migration)"
6549                .to_string(),
6550        );
6551        for width in [80u16, 100, 120] {
6552            let cells = render_to_cells(&app, width, 40).unwrap();
6553            let rendered: Vec<String> = (0..40).map(|y| flush_line(&cells, width, y)).collect();
6554            let status = rendered
6555                .iter()
6556                .find(|l| l.contains("remove `links:`"))
6557                .unwrap_or_else(|| panic!("width {width}: no status row rendered: {rendered:?}"));
6558            assert!(
6559                status.contains("remove `links:` by hand to silence"),
6560                "width {width}: the action must be visible, got {status:?}"
6561            );
6562            assert!(
6563                status.contains('\u{2026}'),
6564                "width {width}: a cut is marked rather than silent, got {status:?}"
6565            );
6566        }
6567    }
6568
6569    #[test]
6570    fn planning_renders_rows_with_fields_and_counts() {
6571        let (_tmp, mut app) = make_app_with_planning();
6572        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6573        let cells = render_to_cells(&app, 120, 40).unwrap();
6574        let lines: Vec<String> = (0..40).map(|y| flush_line(&cells, 120, y)).collect();
6575
6576        assert!(
6577            lines
6578                .iter()
6579                .any(|l| l.contains("Cycle 1 | kickoff | 2026-10-01 | 2999-12-31 | 1 issue")),
6580            "cycle rows show fields and count: {lines:?}"
6581        );
6582        assert!(
6583            lines
6584                .iter()
6585                .any(|l| l.contains("Cycle 2 | next | 2999-01-01 | 2999-01-15 | 0 issues")),
6586            "unreferenced cycles render a zero count"
6587        );
6588
6589        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6590        let cells = render_to_cells(&app, 120, 40).unwrap();
6591        let lines: Vec<String> = (0..40).map(|y| flush_line(&cells, 120, y)).collect();
6592        assert!(
6593            lines
6594                .iter()
6595                .any(|l| l.contains("GA | ship it | 2026-12-31 | 1 issue")),
6596            "milestone rows show fields and count: {lines:?}"
6597        );
6598    }
6599
6600    #[test]
6601    fn planning_form_creates_cycle_with_blank_fields_rejected() {
6602        let (_tmp, mut app) = make_app_with_planning();
6603        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6604        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6605
6606        // Blank name is rejected; the form stays open with an error status.
6607        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6608        assert!(
6609            matches!(app.mode, Mode::PlanningForm),
6610            "blank-name submit keeps the form open"
6611        );
6612        let status = app.status_msg.as_deref().unwrap_or("");
6613        assert!(
6614            status.contains("a name is required"),
6615            "blank names surface an error status: {status:?}"
6616        );
6617        assert_eq!(app.planning_rows_len(), 2, "nothing was created");
6618
6619        // Type a name and submit.
6620        for c in "Cycle 9".chars() {
6621            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6622        }
6623        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6624        assert!(
6625            matches!(app.mode, Mode::PlanningManagement),
6626            "a valid create returns to the modal"
6627        );
6628        assert!(app.planning_form.is_none());
6629        assert!(
6630            app.store.config.cycles.contains_key("Cycle 9"),
6631            "the new cycle is persisted to tix.toml and reloaded into the store"
6632        );
6633        let (cycles, _) = app.planning_rows();
6634        assert!(
6635            cycles.iter().any(|r| r.name == "Cycle 9"),
6636            "the modal re-query shows the new cycle"
6637        );
6638    }
6639
6640    #[test]
6641    fn planning_form_creates_milestone_and_refreshes_pickers() {
6642        let (tmp, mut app) = make_app_with_planning();
6643        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6644        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6645        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6646        for c in "Beta".chars() {
6647            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6648        }
6649        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6650        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6651        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
6652        assert!(matches!(app.mode, Mode::DatePicker { .. }));
6653        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6654        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6655
6656        let milestones_dir = tmp.path().join("docs/milestones");
6657        let created = milestones_dir
6658            .read_dir()
6659            .unwrap()
6660            .filter_map(|e| e.ok())
6661            .filter(|e| e.file_name().to_string_lossy().contains("beta.md"))
6662            .count();
6663        assert_eq!(created, 1, "a milestone registry file is written");
6664
6665        let (_, milestones) = app.planning_rows();
6666        assert!(
6667            milestones
6668                .iter()
6669                .any(|r| { r.name == "Beta" && r.date_a == today() }),
6670            "the modal re-query shows the milestone with its target date"
6671        );
6672
6673        let options = app.picker_options(FormPickerField::Milestones);
6674        assert!(
6675            options.iter().any(|o| o.id == "Beta"),
6676            "a freshly built picker lists the new milestone (no restart)"
6677        );
6678    }
6679
6680    #[test]
6681    fn planning_form_date_row_masked_typing_and_picker() {
6682        let (_tmp, mut app) = make_app_with_planning();
6683        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6684        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6685        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6686        for c in "Gamma".chars() {
6687            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6688        }
6689        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6690        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6691
6692        // Enter on an empty date row opens the picker.
6693        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6694        assert!(matches!(app.mode, Mode::DatePicker { .. }));
6695        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6696        assert!(matches!(app.mode, Mode::PlanningForm));
6697
6698        // Typing digits edits the date in place with the mask.
6699        for c in "20261130".chars() {
6700            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6701        }
6702        assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
6703
6704        // Backspace removes the last digit, then typing restores it.
6705        let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6706        assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-3");
6707        let _ = app.handle_key(press(KeyCode::Char('0'))).unwrap();
6708        assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
6709
6710        // F2 opens the picker seeded from the field.
6711        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
6712        assert!(matches!(app.mode, Mode::DatePicker { .. }));
6713        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6714        assert_eq!(app.planning_form.as_ref().unwrap().date_a(), "2026-11-30");
6715    }
6716
6717    #[test]
6718    fn planning_form_creates_cycle_visible_to_pickers() {
6719        let (_tmp, mut app) = make_app_with_planning();
6720        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6721        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6722        for c in "Cycle 9".chars() {
6723            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6724        }
6725        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6726
6727        let options = app.picker_options(crate::app::FormPickerField::Cycles);
6728        assert!(
6729            options.iter().any(|o| o.id == "Cycle 9"),
6730            "a freshly built cycle picker lists the new cycle"
6731        );
6732    }
6733
6734    #[test]
6735    fn planning_form_edits_cycle_fields() {
6736        let (_tmp, mut app) = make_app_with_planning();
6737        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6738        let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6739
6740        // Focus the description field and rewrite it.
6741        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6742        for _ in 0..7 {
6743            let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6744        }
6745        for c in "sprint".chars() {
6746            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6747        }
6748        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6749
6750        let cycle = &app.store.config.cycles["Cycle 1"];
6751        assert_eq!(cycle.description.as_deref(), Some("sprint"));
6752        assert_eq!(cycle.start_date.as_deref(), Some("2026-10-01"));
6753        assert_eq!(cycle.due_date.as_deref(), Some("2999-12-31"));
6754        assert!(
6755            app.store.config.cycles.contains_key("Cycle 1")
6756                && !app.store.config.cycles.contains_key("sprint"),
6757            "field edits never rename the cycle"
6758        );
6759    }
6760
6761    #[test]
6762    fn planning_form_renames_cycle_cascade_rewrites_issues_and_refreshes() {
6763        let (tmp, mut app) = make_app_with_planning();
6764        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6765        let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6766        for _ in 0..7 {
6767            let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6768        }
6769        for c in "Cycle One".chars() {
6770            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6771        }
6772        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6773
6774        assert!(
6775            !app.store.config.cycles.contains_key("Cycle 1"),
6776            "old name gone"
6777        );
6778        assert!(
6779            app.store.config.cycles.contains_key("Cycle One"),
6780            "new name persisted"
6781        );
6782
6783        let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6784        assert_eq!(issue.cycles.unwrap(), vec!["Cycle One".to_string()]);
6785
6786        let (cycles, _) = app.planning_rows();
6787        assert!(
6788            cycles.iter().any(|r| r.name == "Cycle One"),
6789            "the modal re-query shows the renamed cycle"
6790        );
6791
6792        let options = app.picker_options(crate::app::FormPickerField::Cycles);
6793        assert!(options.iter().any(|o| o.id == "Cycle One"));
6794        assert!(!options.iter().any(|o| o.id == "Cycle 1"));
6795
6796        // The corpus is rebuilt from the rewritten files: the renamed cycle
6797        // matches a board query and the stale spelling no longer does.
6798        app.apply_filter("cycle:One");
6799        let hit: usize = (0..app.board.total_cols())
6800            .map(|c| app.board.column_cards(c).len())
6801            .sum();
6802        assert_eq!(hit, 1, "the board corpus picks up the renamed cycle");
6803        let raw = std::fs::read_to_string(tmp.path().join("issues/alice/2026-01-01-a.md")).unwrap();
6804        assert!(
6805            raw.contains("Cycle One"),
6806            "issue file carries the new spelling"
6807        );
6808        assert!(!raw.contains("Cycle 1"), "stale spelling fully rewritten");
6809    }
6810
6811    #[test]
6812    fn planning_form_duplicate_name_surfaces_error_keeps_form() {
6813        let (_tmp, mut app) = make_app_with_planning();
6814        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6815        let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6816        for _ in 0..7 {
6817            let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6818        }
6819        for c in "Cycle 2".chars() {
6820            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6821        }
6822        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6823
6824        assert!(matches!(app.mode, Mode::PlanningForm));
6825        let status = app.status_msg.as_deref().unwrap_or("");
6826        assert!(
6827            status.contains("already exists"),
6828            "duplicate names surface an error status: {status:?}"
6829        );
6830        assert!(app.store.config.cycles.contains_key("Cycle 1"));
6831        assert!(app.store.config.cycles.contains_key("Cycle 2"));
6832        assert_eq!(
6833            app.store.config.cycles.len(),
6834            2,
6835            "the rejected rename changed nothing"
6836        );
6837    }
6838
6839    #[test]
6840    fn planning_form_renames_milestone_cascade_edge_to_edge() {
6841        let (tmp, mut app) = make_app_with_planning();
6842        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6843        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6844        let _ = app.handle_key(press(KeyCode::Char('e'))).unwrap();
6845        for _ in 0..2 {
6846            let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6847        }
6848        for c in "GAX".chars() {
6849            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6850        }
6851        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6852        for _ in 0..7 {
6853            let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
6854        }
6855        for c in "beta".chars() {
6856            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
6857        }
6858        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6859        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
6860        assert!(matches!(app.mode, Mode::DatePicker { .. }));
6861        let _ = app.handle_key(press(KeyCode::PageDown)).unwrap();
6862        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6863        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6864
6865        let milestones_dir = tmp.path().join("docs/milestones");
6866        let files: Vec<String> = milestones_dir
6867            .read_dir()
6868            .unwrap()
6869            .filter_map(|e| e.ok())
6870            .map(|e| e.file_name().to_string_lossy().to_string())
6871            .collect();
6872        assert!(
6873            files.iter().any(|f| f.ends_with("-gax.md")),
6874            "the milestone registry file is renamed: {files:?}"
6875        );
6876        assert!(
6877            !files.iter().any(|f| f.ends_with("-ga.md")),
6878            "the old registry file is gone: {files:?}"
6879        );
6880
6881        let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6882        assert_eq!(issue.milestones.unwrap(), vec!["GAX".to_string()]);
6883
6884        let planner = Planner::load_from(&tmp.path().join("tix.toml")).unwrap();
6885        let ms = planner.milestone("GAX").unwrap();
6886        assert_eq!(ms.target_date.as_str(), "2027-01-31");
6887        assert!(ms.description.contains("beta"), "description was edited");
6888    }
6889
6890    #[test]
6891    fn planning_delete_confirm_removes_cycle_and_refreshes_grouping() {
6892        let (_tmp, mut app) = make_app_with_planning();
6893        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6894        let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6895        match &app.planning.as_ref().unwrap().prompt {
6896            Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
6897                assert_eq!(name, "Cycle 1");
6898                assert_eq!(*referencing, 1, "the confirm prompt reports the count");
6899            }
6900            other => panic!("x raises a confirm prompt, got {other:?}"),
6901        }
6902
6903        // Cancelling with Esc keeps everything.
6904        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6905        assert!(
6906            app.store.config.cycles.contains_key("Cycle 1"),
6907            "cancelling keeps the item"
6908        );
6909        let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6910        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6911
6912        // Confirming deletes the cycle but leaves referencing issues unchanged.
6913        assert!(!app.store.config.cycles.contains_key("Cycle 1"));
6914        assert!(app.store.config.cycles.contains_key("Cycle 2"));
6915        let (cycles, _) = app.planning_rows();
6916        assert!(
6917            cycles.iter().all(|r| r.name != "Cycle 1"),
6918            "the modal re-query drops the deleted cycle"
6919        );
6920        let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6921        assert_eq!(
6922            issue.cycles.unwrap(),
6923            vec!["Cycle 1".to_string()],
6924            "references preserved"
6925        );
6926
6927        let options = app.picker_options(crate::app::FormPickerField::Cycles);
6928        assert!(
6929            !options.iter().any(|o| o.id == "Cycle 1"),
6930            "no stale picker entry"
6931        );
6932        app.apply_filter("cycle:Cycle");
6933        let hit: usize = (0..app.board.total_cols())
6934            .map(|c| app.board.column_cards(c).len())
6935            .sum();
6936        assert_eq!(hit, 1, "the referencing issue stays grouped on the board");
6937    }
6938
6939    #[test]
6940    fn planning_delete_confirm_removes_milestone_file_preserves_issues() {
6941        let (tmp, mut app) = make_app_with_planning();
6942        let _ = app.handle_key(press(KeyCode::Char('m'))).unwrap();
6943        let _ = app.handle_key(press(KeyCode::Tab)).unwrap();
6944        let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6945        match &app.planning.as_ref().unwrap().prompt {
6946            Some(crate::ui::planning::PlanningPrompt::ConfirmDelete { name, referencing }) => {
6947                assert_eq!(name, "GA");
6948                assert_eq!(*referencing, 1);
6949            }
6950            other => panic!("x raises a confirm prompt, got {other:?}"),
6951        }
6952        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
6953        let milestones_dir = tmp.path().join("docs/milestones");
6954        assert!(
6955            milestones_dir.join("2026-01-01-ga.md").exists(),
6956            "cancel keeps the milestone file"
6957        );
6958
6959        let _ = app.handle_key(press(KeyCode::Char('x'))).unwrap();
6960        let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
6961        assert!(
6962            !milestones_dir.join("2026-01-01-ga.md").exists(),
6963            "confirm removes the registry file"
6964        );
6965        let (_, milestones) = app.planning_rows();
6966        assert!(milestones.is_empty(), "the milestone segment is now empty");
6967        let issue = app.store.get(IssueSelector::Number(1)).unwrap();
6968        assert_eq!(issue.milestones.unwrap(), vec!["GA".to_string()]);
6969        let options = app.picker_options(FormPickerField::Milestones);
6970        assert!(
6971            !options.iter().any(|o| o.id == "GA"),
6972            "no stale picker entry"
6973        );
6974    }
6975
6976    #[test]
6977    fn startup_shows_rollover_dialog_when_plan_exists() {
6978        let (_tmp, app) = make_app_with_rollover_dialog();
6979        assert!(matches!(app.mode, Mode::RolloverDialog));
6980        assert!(app.rollover_dialog.is_some());
6981    }
6982
6983    #[test]
6984    fn rollover_dialog_enter_applies_rollover() {
6985        let (_tmp, mut app) = make_app_with_rollover_dialog();
6986        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
6987        assert!(matches!(app.mode, Mode::Board));
6988        let issue = app.store.list().unwrap().into_iter().next().unwrap();
6989        let cycles = issue.cycles.unwrap_or_default();
6990        assert!(cycles.iter().any(|c| c == "Cycle 2"));
6991        assert!(!cycles.iter().any(|c| c == "Cycle 1"));
6992    }
6993
6994    #[test]
6995    fn quit_on_esc() {
6996        let (_tmp, mut app) = make_app();
6997        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
6998        assert!(app.quit);
6999    }
7000
7001    #[test]
7002    fn cursor_clamp_empty_column() {
7003        let (_tmp, mut app) = make_app();
7004        app.board.move_left();
7005        assert_eq!(app.board.active_col, app.board.total_cols() - 1);
7006        app.board.move_right();
7007        assert_eq!(app.board.active_col, 0);
7008    }
7009
7010    #[test]
7011    fn cursor_clamp_single_card() {
7012        let (tmp, mut app) = make_app();
7013        let dir = tmp.path().join("issues");
7014        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7015        let config = app.store.config.clone();
7016        app.board = BoardState::build(&app.store, config.default_config());
7017        assert_eq!(app.board.column_cards(0).len(), 1);
7018        app.board.move_down();
7019        assert_eq!(app.board.selected, 0);
7020        app.board.move_up();
7021        assert_eq!(app.board.selected, 0);
7022    }
7023
7024    #[test]
7025    fn wrap_around_columns() {
7026        let (_tmp, mut app) = make_app();
7027        let total = app.board.total_cols();
7028        for _ in 0..total + 2 {
7029            app.board.move_right();
7030        }
7031        assert_eq!(app.board.active_col, 2 % total);
7032        for _ in 0..total + 2 {
7033            app.board.move_left();
7034        }
7035    }
7036
7037    #[test]
7038    fn refresh_from_disk_picks_up_new_file() {
7039        let (tmp, mut app) = make_app();
7040        let dir = tmp.path().join("issues");
7041        assert_eq!(app.board.column_cards(0).len(), 0);
7042        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7043        app.refresh_from_disk().unwrap();
7044        assert_eq!(app.board.column_cards(0).len(), 1);
7045    }
7046
7047    #[test]
7048    fn refresh_from_disk_reflects_external_status_move() {
7049        let (tmp, mut app) = make_app();
7050        let dir = tmp.path().join("issues");
7051        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7052        app.refresh_from_disk().unwrap();
7053        assert_eq!(app.board.column_cards(0).len(), 1);
7054
7055        write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
7056        app.refresh_from_disk().unwrap();
7057        assert_eq!(app.board.column_cards(0).len(), 0);
7058        assert_eq!(app.board.column_cards(3).len(), 1);
7059    }
7060
7061    #[test]
7062    fn refresh_from_disk_keeps_board_on_bad_file() {
7063        let (tmp, mut app) = make_app();
7064        let dir = tmp.path().join("issues");
7065        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7066        app.refresh_from_disk().unwrap();
7067        assert_eq!(app.board.column_cards(0).len(), 1);
7068
7069        fs::write(dir.join("2026-01-01-bad.md"), "not frontmatter").unwrap();
7070        assert!(app.refresh_from_disk().is_err());
7071        assert_eq!(
7072            app.board.column_cards(0).len(),
7073            1,
7074            "a malformed file must not wipe the board"
7075        );
7076    }
7077
7078    #[test]
7079    fn enter_opens_detail() {
7080        let (tmp, mut app) = make_app();
7081        let dir = tmp.path().join("issues");
7082        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7083        let config = app.store.config.clone();
7084        app.board = BoardState::build(&app.store, config.default_config());
7085        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7086        assert!(matches!(app.mode, Mode::Detail));
7087        assert!(app.detail.is_some());
7088    }
7089
7090    #[test]
7091    fn n_opens_form() {
7092        let (_tmp, mut app) = make_app();
7093        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7094        assert!(matches!(app.mode, Mode::Form));
7095        assert!(app.form.is_some());
7096    }
7097
7098    fn filter_modal_focus(app: &App) -> crate::search::FilterField {
7099        app.filter_modal
7100            .as_ref()
7101            .expect("filter modal open")
7102            .focus()
7103    }
7104
7105    #[test]
7106    fn filter_modal_arrows_navigate_rows() {
7107        let (_tmp, mut app) = make_app();
7108        let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7109        assert!(matches!(app.mode, Mode::FilterModal));
7110        use crate::search::FilterField::*;
7111        assert_eq!(filter_modal_focus(&app), Assignee);
7112        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7113        assert_eq!(filter_modal_focus(&app), Label);
7114        let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7115        assert_eq!(filter_modal_focus(&app), Assignee);
7116        // `FilterField::ALL` has **ten** rows since `Parent` was appended (WP06
7117        // T024), so a full lap is ten presses. Hard-coded rather than derived from
7118        // `ALL.len()` on purpose: a test that reads the length it is testing cannot
7119        // notice the length changing, which is exactly what happened here — the
7120        // loop was `0..9` and the wrap assertion then failed.
7121        for _ in 0..crate::search::FilterField::ALL.len() {
7122            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7123        }
7124        assert_eq!(filter_modal_focus(&app), Assignee, "Down wraps forward");
7125        let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7126        assert_eq!(
7127            filter_modal_focus(&app),
7128            Category,
7129            "Up wraps backward, to the last row — which is now `Category` (WP08 \
7130             appended it after `Parent`)"
7131        );
7132    }
7133
7134    #[test]
7135    fn filter_modal_arrow_press_keeps_criterion_value() {
7136        let (_tmp, mut app) = make_app();
7137        let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7138        for c in "alice".chars() {
7139            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7140        }
7141        let before = app
7142            .filter_modal
7143            .as_ref()
7144            .unwrap()
7145            .active_filter()
7146            .assignee
7147            .clone();
7148        assert_eq!(before.as_deref(), Some("alice"));
7149        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7150        let after = app
7151            .filter_modal
7152            .as_ref()
7153            .unwrap()
7154            .active_filter()
7155            .assignee
7156            .clone();
7157        assert_eq!(after, before, "arrow press must not alter the value");
7158        use crate::search::FilterField::*;
7159        assert_eq!(filter_modal_focus(&app), Label);
7160    }
7161
7162    #[test]
7163    fn filter_modal_j_and_k_type_their_letters() {
7164        let (_tmp, mut app) = make_app();
7165        let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7166        let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
7167        let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
7168        let modal = app.filter_modal.as_ref().unwrap();
7169        assert_eq!(
7170            modal.active_filter().assignee.as_deref(),
7171            Some("jk"),
7172            "j and k must type into the focused criterion, not navigate"
7173        );
7174        use crate::search::FilterField::*;
7175        assert_eq!(modal.focus(), Assignee);
7176    }
7177
7178    #[test]
7179    fn filter_modal_date_field_masked_typing_and_picker() {
7180        let (_tmp, mut app) = make_app();
7181        let _ = app.handle_key(press(KeyCode::Char('f'))).unwrap();
7182        use crate::search::FilterField::*;
7183        for _ in 0..7 {
7184            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7185        }
7186        assert_eq!(filter_modal_focus(&app), StartDate);
7187
7188        // Enter on an empty date criterion opens the picker.
7189        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7190        assert!(matches!(app.mode, Mode::DatePicker { .. }));
7191        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7192        assert!(matches!(app.mode, Mode::FilterModal));
7193
7194        // Typing digits edits the criterion in place with the mask.
7195        for c in "20260910".chars() {
7196            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7197        }
7198        assert_eq!(
7199            app.filter_modal
7200                .as_ref()
7201                .unwrap()
7202                .active_filter()
7203                .start_date
7204                .as_deref(),
7205            Some("2026-09-10")
7206        );
7207
7208        // Enter on a complete date commits the filter.
7209        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7210        assert!(matches!(app.mode, Mode::Board));
7211        assert_eq!(app.structured.start_date.as_deref(), Some("2026-09-10"));
7212    }
7213
7214    #[test]
7215    fn d_opens_confirm_delete() {
7216        let (tmp, mut app) = make_app();
7217        let dir = tmp.path().join("issues");
7218        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7219        let config = app.store.config.clone();
7220        app.board = BoardState::build(&app.store, config.default_config());
7221        let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
7222        assert!(matches!(app.mode, Mode::ConfirmDelete));
7223        assert!(app.confirm_delete_target.is_some());
7224    }
7225
7226    #[test]
7227    fn move_to_done_triggers_commit_prompt() {
7228        let (tmp, mut app) = make_app();
7229        let dir = tmp.path().join("issues");
7230        // issue sits in "in review" (0-indexed column 2), target is "done" (3)
7231        write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
7232        let config = app.store.config.clone();
7233        app.board = BoardState::build(&app.store, config.default_config());
7234        app.board.select_column(2);
7235        app.board.selected = 0;
7236
7237        let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7238        // Moving to done without a commit opens the hash prompt
7239        assert!(
7240            matches!(
7241                &app.mode,
7242                Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()
7243            ),
7244            "expected MoveIntoDone, got {:?}",
7245            app.mode
7246        );
7247
7248        // Esc cancels the prompt and keeps the status unchanged
7249        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7250        assert!(matches!(app.mode, Mode::Board));
7251        let issue = app
7252            .store
7253            .get(crate::storage::IssueSelector::Filename(
7254                "2026-01-01-a.md".into(),
7255            ))
7256            .unwrap();
7257        assert_eq!(issue.status.display(), "in review");
7258        assert!(issue.commit.is_none());
7259    }
7260
7261    #[test]
7262    fn move_into_done_with_commit_applies() {
7263        let (tmp, mut app) = make_app();
7264        let dir = tmp.path().join("issues");
7265        write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
7266        let config = app.store.config.clone();
7267        app.board = BoardState::build(&app.store, config.default_config());
7268        app.board.select_column(2);
7269        app.board.selected = 0;
7270
7271        let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7272        assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
7273
7274        for c in "abc123".chars() {
7275            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7276        }
7277        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7278
7279        assert!(matches!(app.mode, Mode::Board));
7280        let issue = app
7281            .store
7282            .get(crate::storage::IssueSelector::Filename(
7283                "2026-01-01-a.md".into(),
7284            ))
7285            .unwrap();
7286        assert_eq!(issue.status.display(), "done");
7287        assert_eq!(issue.commit.as_deref(), Some("abc123"));
7288    }
7289
7290    #[test]
7291    fn move_into_done_with_empty_commit_moves_without_hash() {
7292        let (tmp, mut app) = make_app();
7293        let dir = tmp.path().join("issues");
7294        write_issue(&dir, "2026-01-01-a.md", 1, "A", "in review", None);
7295        let config = app.store.config.clone();
7296        app.board = BoardState::build(&app.store, config.default_config());
7297        app.board.select_column(2);
7298        app.board.selected = 0;
7299
7300        let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7301        assert!(matches!(&app.mode, Mode::MoveIntoDone { .. }));
7302        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7303
7304        assert!(matches!(app.mode, Mode::Board));
7305        let issue = app
7306            .store
7307            .get(crate::storage::IssueSelector::Filename(
7308                "2026-01-01-a.md".into(),
7309            ))
7310            .unwrap();
7311        assert_eq!(issue.status.display(), "done");
7312        assert!(
7313            issue.commit.is_none(),
7314            "no commit recorded when moving to done without a hash"
7315        );
7316    }
7317
7318    #[test]
7319    fn move_out_of_done_clears_commit() {
7320        let (tmp, mut app) = make_app();
7321        let dir = tmp.path().join("issues");
7322        write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", Some("abc123"));
7323        let config = app.store.config.clone();
7324        app.board = BoardState::build(&app.store, config.default_config());
7325        app.board.select_column(3);
7326        app.board.selected = 0;
7327
7328        let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
7329        assert!(matches!(app.mode, Mode::Board));
7330        let issue = app
7331            .store
7332            .get(crate::storage::IssueSelector::Filename(
7333                "2026-01-01-a.md".into(),
7334            ))
7335            .unwrap();
7336        assert_eq!(issue.status.display(), "in review");
7337        assert!(issue.commit.is_none());
7338    }
7339
7340    #[test]
7341    fn move_right_wraps_across_real_columns_skipping_mismatch() {
7342        let (tmp, mut app) = make_app();
7343        let dir = tmp.path().join("issues");
7344        write_issue(&dir, "2026-01-01-a.md", 1, "A", "done", None);
7345        let config = app.store.config.clone();
7346        app.board = BoardState::build(&app.store, config.default_config());
7347        app.board.select_column(3); // done (last real column)
7348        app.board.selected = 0;
7349        let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
7350        assert_eq!(app.board.active_col, 0);
7351        let issue = app
7352            .store
7353            .get(crate::storage::IssueSelector::Filename(
7354                "2026-01-01-a.md".into(),
7355            ))
7356            .unwrap();
7357        assert_eq!(issue.status.display(), "todo");
7358    }
7359
7360    #[test]
7361    fn move_left_wraps_across_real_columns_skipping_mismatch() {
7362        let (tmp, mut app) = make_app();
7363        let dir = tmp.path().join("issues");
7364        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", Some("abc"));
7365        let config = app.store.config.clone();
7366        app.board = BoardState::build(&app.store, config.default_config());
7367        app.board.select_column(0);
7368        app.board.selected = 0;
7369        let _ = app.handle_key(press(KeyCode::Char('<'))).unwrap();
7370        assert_eq!(app.board.active_col, 3);
7371        let issue = app
7372            .store
7373            .get(crate::storage::IssueSelector::Filename(
7374                "2026-01-01-a.md".into(),
7375            ))
7376            .unwrap();
7377        assert_eq!(issue.status.display(), "done");
7378    }
7379
7380    #[test]
7381    fn move_left_right_board() {
7382        let (_tmp, mut app) = make_app();
7383        let initial = app.board.active_col;
7384        let _ = app.handle_key(press(KeyCode::Char('l'))).unwrap();
7385        assert_eq!(app.board.active_col, initial + 1);
7386        let _ = app.handle_key(press(KeyCode::Char('h'))).unwrap();
7387        assert_eq!(app.board.active_col, initial);
7388    }
7389
7390    #[test]
7391    fn refresh_rebuilds_board() {
7392        let (tmp, mut app) = make_app();
7393        let dir = tmp.path().join("issues");
7394        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7395        let _ = app.handle_key(press(KeyCode::Char('r'))).unwrap();
7396        assert_eq!(app.board.column_cards(0).len(), 1);
7397        assert_eq!(app.status_msg, Some("Refreshed".to_string()));
7398    }
7399
7400    #[test]
7401    fn detail_esc_back_to_board() {
7402        let (tmp, mut app) = make_app();
7403        let dir = tmp.path().join("issues");
7404        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7405        let config = app.store.config.clone();
7406        app.board = BoardState::build(&app.store, config.default_config());
7407        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7408        assert!(matches!(app.mode, Mode::Detail));
7409        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7410        assert!(matches!(app.mode, Mode::Board));
7411        assert!(app.detail.is_none());
7412    }
7413
7414    #[test]
7415    fn confirm_delete_y_removes_issue() {
7416        let (tmp, mut app) = make_app();
7417        let dir = tmp.path().join("issues");
7418        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7419        let config = app.store.config.clone();
7420        app.board = BoardState::build(&app.store, config.default_config());
7421        let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
7422        assert!(matches!(app.mode, Mode::ConfirmDelete));
7423        let _ = app.handle_key(press(KeyCode::Char('y'))).unwrap();
7424        assert!(matches!(app.mode, Mode::Board));
7425        let config = app.store.config.clone();
7426        app.board = BoardState::build(&app.store, config.default_config());
7427        assert_eq!(app.board.column_cards(0).len(), 0);
7428    }
7429
7430    #[test]
7431    fn confirm_delete_n_cancels() {
7432        let (tmp, mut app) = make_app();
7433        let dir = tmp.path().join("issues");
7434        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
7435        let config = app.store.config.clone();
7436        app.board = BoardState::build(&app.store, config.default_config());
7437        let _ = app.handle_key(press(KeyCode::Char('d'))).unwrap();
7438        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7439        assert!(matches!(app.mode, Mode::Board));
7440        assert!(app.confirm_delete_target.is_none());
7441        let config = app.store.config.clone();
7442        app.board = BoardState::build(&app.store, config.default_config());
7443        assert_eq!(app.board.column_cards(0).len(), 1);
7444    }
7445
7446    #[test]
7447    fn form_submit_creates_issue() {
7448        let (_tmp, mut app) = make_app();
7449        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7450        if let Mode::Form = &app.mode {
7451            // Type title
7452            for c in "My Issue".chars() {
7453                let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7454            }
7455            // Submit
7456            let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7457        }
7458        assert!(matches!(app.mode, Mode::Board));
7459        assert!(
7460            app.status_msg
7461                .as_deref()
7462                .is_some_and(|m| m.starts_with("Created ")),
7463            "got {:?}",
7464            app.status_msg
7465        );
7466    }
7467
7468    #[test]
7469    fn form_submit_selects_created_issue() {
7470        let (_tmp, mut app) = make_app();
7471        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7472        if let Mode::Form = &app.mode {
7473            for c in "Selected Issue".chars() {
7474                let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7475            }
7476            let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7477        }
7478        assert!(matches!(app.mode, Mode::Board));
7479        let card = app.board.get_selected_card().expect("selected card");
7480        assert_eq!(card.title, "Selected Issue");
7481        assert_eq!(app.board.active_col, 0);
7482    }
7483
7484    #[test]
7485    fn form_submit_selects_created_issue_in_assignee_segment() {
7486        let (tmp, mut app) = make_app();
7487        let dir = tmp.path().join("issues");
7488        write_issue(&dir, "2026-01-01-a.md", 1, "Old", "todo", None);
7489        let config = app.store.config.clone();
7490        app.board = BoardState::build(&app.store, config.default_config());
7491
7492        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7493        if let Mode::Form = &app.mode {
7494            for c in "New".chars() {
7495                let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7496            }
7497        }
7498        app.form.as_mut().unwrap().assignee = "alice".to_string();
7499        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7500
7501        assert!(matches!(app.mode, Mode::Board));
7502        let card = app.board.get_selected_card().expect("selected card");
7503        assert_eq!(
7504            card.title, "New",
7505            "the just-created issue is selected, not the previously selected one"
7506        );
7507        assert!(card.filename.starts_with("alice/"), "got {}", card.filename);
7508    }
7509
7510    #[test]
7511    fn editor_return_warns_on_invalid_issue() {
7512        let (_tmp, mut app) = make_app();
7513        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7514        if let Mode::Form = &app.mode {
7515            for c in "Lint Warn".chars() {
7516                let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7517            }
7518            let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7519        }
7520        let issue = app
7521            .board
7522            .get_selected_card()
7523            .cloned()
7524            .expect("selected issue");
7525        let path = app.store.issues_dir.join(&issue.filename);
7526        let mut text = std::fs::read_to_string(&path).unwrap();
7527        // Rewrite whatever the form actually wrote, rather than assuming it is
7528        // `medium`. The form seeds `priority: String::new()`, so the previous
7529        // `replace("priority: medium", …)` was a no-op, the issue was never made
7530        // invalid, and the test asserted a warning that had nothing to warn
7531        // about. It passed for the wrong reason and would have kept passing after
7532        // the form changed again.
7533        let before = text.clone();
7534        text = text
7535            .lines()
7536            .map(|l| {
7537                if l.starts_with("priority:") {
7538                    "priority: strange"
7539                } else {
7540                    l
7541                }
7542            })
7543            .collect::<Vec<_>>()
7544            .join("\n");
7545        assert_ne!(
7546            text, before,
7547            "the fixture must actually change the priority"
7548        );
7549        std::fs::write(&path, text).unwrap();
7550
7551        app.after_editor(&issue.filename);
7552
7553        assert!(matches!(app.mode, Mode::EditLintWarning { .. }));
7554    }
7555
7556    #[test]
7557    fn form_empty_title_rejected() {
7558        let (_tmp, mut app) = make_app();
7559        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7560        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7561        assert!(matches!(app.mode, Mode::Form));
7562        assert_eq!(app.status_msg.as_deref(), Some("Title is required"));
7563    }
7564
7565    #[test]
7566    fn form_esc_cancels() {
7567        let (_tmp, mut app) = make_app();
7568        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7569        assert!(matches!(app.mode, Mode::Form));
7570        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7571        assert!(matches!(app.mode, Mode::Board));
7572    }
7573
7574    fn form_focus(app: &App) -> crate::ui::form::FormFocus {
7575        app.form.as_ref().expect("form open").focus
7576    }
7577
7578    #[test]
7579    fn form_arrows_navigate_fields() {
7580        let (_tmp, mut app) = make_app();
7581        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7582        use crate::ui::form::FormFocus::*;
7583        assert_eq!(form_focus(&app), Title);
7584        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7585        assert_eq!(form_focus(&app), Status);
7586        let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7587        assert_eq!(form_focus(&app), Title);
7588        for _ in 0..crate::ui::form::FormFocus::RING.len() {
7589            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7590        }
7591        assert_eq!(form_focus(&app), Title, "Down wraps forward");
7592        let _ = app.handle_key(press(KeyCode::Up)).unwrap();
7593        assert_eq!(
7594            form_focus(&app),
7595            *crate::ui::form::FormFocus::RING
7596                .last()
7597                .expect("RING is non-empty"),
7598            "Up wraps backward, from the last field in the ring"
7599        );
7600    }
7601
7602    #[test]
7603    fn form_j_and_k_type_their_letters() {
7604        let (_tmp, mut app) = make_app();
7605        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7606        let _ = app.handle_key(press(KeyCode::Char('j'))).unwrap();
7607        let _ = app.handle_key(press(KeyCode::Char('k'))).unwrap();
7608        let form = app.form.as_ref().unwrap();
7609        assert_eq!(form.title, "jk", "j and k must type, not navigate");
7610        assert_eq!(form.focus, crate::ui::form::FormFocus::Title);
7611    }
7612
7613    #[test]
7614    fn form_arrow_navigated_status_creates_in_that_column() {
7615        let (_tmp, mut app) = make_app();
7616        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7617        for c in "Arrow Status".chars() {
7618            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7619        }
7620        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7621        let prefilled = app.store.config.default_config().columns[0].len();
7622        for _ in 0..prefilled {
7623            let _ = app.handle_key(press(KeyCode::Backspace)).unwrap();
7624        }
7625        for c in "in progress".chars() {
7626            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7627        }
7628        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7629        assert!(matches!(app.mode, Mode::Board));
7630        let names: Vec<String> = (0..app.board.total_cols())
7631            .map(|i| app.board.column_name(i).to_string())
7632            .collect();
7633        let idx = names
7634            .iter()
7635            .position(|n| n == "in progress")
7636            .expect("in progress column exists");
7637        assert_eq!(
7638            app.board.column_cards(idx).len(),
7639            1,
7640            "arrow-navigated status must land the issue in 'in progress'"
7641        );
7642    }
7643
7644    #[test]
7645    fn form_invalid_date_order_is_rejected() {
7646        let (_tmp, mut app) = make_app();
7647        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7648        for c in "Date check".chars() {
7649            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7650        }
7651        for _ in 0..9 {
7652            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7653        }
7654        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7655        assert!(matches!(app.mode, Mode::DatePicker { .. }));
7656        let _ = app.handle_key(press(KeyCode::PageDown)).unwrap();
7657        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7658        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7659        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7660        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7661        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7662        assert!(matches!(app.mode, Mode::Form));
7663        assert_eq!(
7664            app.status_msg.as_deref(),
7665            Some("Due date must be >= start date")
7666        );
7667    }
7668
7669    #[test]
7670    fn form_persists_planning_fields() {
7671        let (_tmp, mut app) = make_app();
7672        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7673        for c in "Planning fields".chars() {
7674            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7675        }
7676        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7677        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7678        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7679        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7680        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7681        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7682        for c in "Cycle 1,Cycle 2".chars() {
7683            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7684        }
7685        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7686        for c in "Backlog".chars() {
7687            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7688        }
7689        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7690        for c in "alice".chars() {
7691            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7692        }
7693        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7694        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7695        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7696        let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7697        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7698        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7699        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7700        assert!(matches!(app.mode, Mode::Board));
7701        let issues = app.store.list().unwrap();
7702        assert_eq!(issues.len(), 1);
7703        let issue = &issues[0];
7704        assert_eq!(
7705            issue.cycles.as_deref(),
7706            Some(&["Cycle 1".to_string(), "Cycle 2".to_string()][..])
7707        );
7708        assert_eq!(
7709            issue.milestones.as_deref(),
7710            Some(&["Backlog".to_string()][..])
7711        );
7712        assert_eq!(issue.reviewer.as_deref(), Some("alice"));
7713        assert_eq!(issue.start_date.as_deref(), Some(today().as_str()));
7714        assert_eq!(issue.due_date.as_deref(), Some(today().as_str()));
7715    }
7716
7717    #[test]
7718    fn form_date_field_masked_typing_and_picker() {
7719        let (_tmp, mut app) = make_app();
7720        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7721        for c in "Dated".chars() {
7722            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7723        }
7724        for _ in 0..9 {
7725            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7726        }
7727        assert!(app.form.as_ref().unwrap().is_date_focus());
7728
7729        // Enter on an empty date field opens the picker.
7730        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7731        assert!(matches!(app.mode, Mode::DatePicker { .. }));
7732        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7733        assert!(matches!(app.mode, Mode::Form));
7734
7735        // Typing digits edits the date in place with the mask.
7736        for c in "20260910".chars() {
7737            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7738        }
7739        assert_eq!(app.form.as_ref().unwrap().start_date, "2026-09-10");
7740
7741        // Enter on a complete date submits the form.
7742        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7743        assert!(matches!(app.mode, Mode::Board));
7744        let issues = app.store.list().unwrap();
7745        let issue = issues
7746            .iter()
7747            .find(|i| i.title == "Dated")
7748            .expect("the form created the issue");
7749        assert_eq!(issue.start_date.as_deref(), Some("2026-09-10"));
7750    }
7751
7752    #[test]
7753    fn form_date_picker_select_and_clear() {
7754        let (_tmp, mut app) = make_app();
7755        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7756        for c in "Dated".chars() {
7757            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7758        }
7759        for _ in 0..9 {
7760            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7761        }
7762
7763        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7764        assert!(matches!(app.mode, Mode::DatePicker { .. }));
7765        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7766        assert!(matches!(app.mode, Mode::Form));
7767        assert_eq!(app.form.as_ref().unwrap().start_date, today());
7768
7769        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7770        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
7771        assert_eq!(
7772            app.form.as_ref().unwrap().start_date,
7773            today(),
7774            "Esc leaves the date unchanged"
7775        );
7776
7777        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7778        let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
7779        assert_eq!(
7780            app.form.as_ref().unwrap().start_date,
7781            "",
7782            "c clears the date field"
7783        );
7784    }
7785
7786    /// Render the date picker once and return the center point of the day cell
7787    /// matching `target` (`YYYY-MM-DD`).
7788    fn drawn_date_picker_day(app: &App, target: &str) -> (u16, u16) {
7789        use ratatui::backend::TestBackend;
7790
7791        let backend = TestBackend::new(app.frame_size.get().width, app.frame_size.get().height);
7792        let mut terminal = ratatui::Terminal::new(backend).unwrap();
7793        terminal
7794            .draw(|f| crate::app::render(app, f, f.area()))
7795            .unwrap();
7796
7797        let picker = app.date_picker.as_ref().expect("picker open");
7798        let rect = picker.popup_rect(app.frame_size.get());
7799        let days_y = rect.y + 3;
7800        let weeks = rect.height.saturating_sub(6);
7801        for row in 0..weeks {
7802            for col in 0..7u16 {
7803                let x = rect.x + 1 + col * 3 + 1;
7804                let y = days_y + row;
7805                if picker
7806                    .date_at(app.frame_size.get(), x, y)
7807                    .map(crate::ui::date_picker::format_date)
7808                    .as_deref()
7809                    == Some(target)
7810                {
7811                    return (x, y);
7812                }
7813            }
7814        }
7815        panic!("no day cell found for {target}");
7816    }
7817
7818    #[test]
7819    fn date_picker_day_click_changes_selected_date() {
7820        let (_tmp, mut app) = make_app();
7821        app.frame_size.set(Rect::new(0, 0, 80, 24));
7822        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7823        for _ in 0..9 {
7824            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7825        }
7826        app.form.as_mut().unwrap().start_date = "2026-03-15".into();
7827        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7828        assert!(matches!(app.mode, Mode::DatePicker { .. }));
7829
7830        let (x, y) = drawn_date_picker_day(&app, "2026-03-16");
7831        let action = app.handle_mouse(down_left(x, y)).unwrap();
7832        assert_eq!(
7833            action,
7834            Action::DatePickerHit(DatePickerHit::Day("2026-03-16".into()))
7835        );
7836        if let Action::DatePickerHit(DatePickerHit::Day(date)) = action {
7837            app.date_picker.as_mut().unwrap().select_date_str(&date);
7838        }
7839        assert_eq!(app.date_picker.as_ref().unwrap().selected(), "2026-03-16");
7840    }
7841
7842    #[test]
7843    fn date_picker_ok_click_submits_selected_date() {
7844        let (_tmp, mut app) = make_app();
7845        app.frame_size.set(Rect::new(0, 0, 80, 24));
7846        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7847        for _ in 0..9 {
7848            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7849        }
7850        app.form.as_mut().unwrap().start_date = "2026-03-15".into();
7851        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7852
7853        let (x, y) = drawn_date_picker_day(&app, "2026-03-16");
7854        let _ = app.handle_mouse(down_left(x, y)).unwrap();
7855        app.date_picker
7856            .as_mut()
7857            .unwrap()
7858            .select_date_str("2026-03-16");
7859
7860        let picker = app.date_picker.as_ref().unwrap();
7861        let rect = picker.popup_rect(app.frame_size.get());
7862        let ok_x = rect.x + rect.width / 2;
7863        let ok_y = rect.y + rect.height.saturating_sub(1);
7864        let action = app.handle_mouse(down_left(ok_x, ok_y)).unwrap();
7865        assert_eq!(action, Action::DatePickerHit(DatePickerHit::Submit));
7866
7867        let _ = app.handle_date_picker_key(press(KeyCode::Enter)).unwrap();
7868        assert!(matches!(app.mode, Mode::Form));
7869        assert_eq!(app.form.as_ref().unwrap().start_date, "2026-03-16");
7870    }
7871
7872    #[test]
7873    fn form_non_date_field_still_opens_list_picker() {
7874        let (_tmp, mut app) = make_app();
7875        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7876        for _ in 0..6 {
7877            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7878        }
7879        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7880        assert!(
7881            app.form_picker.is_some(),
7882            "cycles field opens the list picker"
7883        );
7884        assert!(!matches!(app.mode, Mode::DatePicker { .. }));
7885    }
7886
7887    #[test]
7888    fn form_picker_inline_create_cycle_persists_and_populates_field() {
7889        let (_tmp, mut app) = make_app();
7890        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7891        for c in "Picker cycle".chars() {
7892            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7893        }
7894        for _ in 0..6 {
7895            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7896        }
7897
7898        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7899        assert!(app.form_picker.is_some(), "picker should open on cycles");
7900        let _ = app.handle_key(press(KeyCode::Char('+'))).unwrap();
7901        for c in "Cycle 42".chars() {
7902            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7903        }
7904        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7905
7906        let form = app.form.as_ref().unwrap();
7907        assert!(
7908            form.cycles.split(',').any(|c| c.trim() == "Cycle 42"),
7909            "created cycle should populate form field, got {:?}",
7910            form.cycles
7911        );
7912        assert!(
7913            app.store.config.cycles.contains_key("Cycle 42"),
7914            "new cycle should be persisted in config"
7915        );
7916
7917        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7918        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7919        assert!(matches!(app.mode, Mode::Board));
7920        let issue = app.store.list().unwrap().into_iter().next().unwrap();
7921        assert!(
7922            issue
7923                .cycles
7924                .unwrap_or_default()
7925                .iter()
7926                .any(|c| c == "Cycle 42")
7927        );
7928    }
7929
7930    #[test]
7931    fn form_picker_selects_existing_label() {
7932        let (tmp, mut app) = make_app();
7933        let dir = tmp.path().join("issues");
7934        let seed = "---\nnumber: 1\ntitle: \"Seed\"\nstatus: \"todo\"\nlabels: [\"bug\"]\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text.";
7935        fs::write(dir.join("2026-01-01-seed.md"), seed).unwrap();
7936        app.rebuild_board();
7937
7938        let _ = app.handle_key(press(KeyCode::Char('n'))).unwrap();
7939        for c in "Picker labels".chars() {
7940            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
7941        }
7942        for _ in 0..3 {
7943            let _ = app.handle_key(press(KeyCode::Down)).unwrap();
7944        }
7945        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7946        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7947        let _ = app.handle_key(press(KeyCode::F(2))).unwrap();
7948        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
7949
7950        assert!(matches!(app.mode, Mode::Board));
7951        let issues = app.store.list().unwrap();
7952        let created = issues
7953            .into_iter()
7954            .find(|i| i.title == "Picker labels")
7955            .expect("created issue");
7956        assert!(created.labels.iter().any(|l| l == "bug"));
7957    }
7958
7959    #[test]
7960    fn key_release_ignored() {
7961        let (_tmp, mut app) = make_app();
7962        let key = KeyEvent {
7963            code: KeyCode::Char('q'),
7964            modifiers: crossterm::event::KeyModifiers::NONE,
7965            kind: KeyEventKind::Release,
7966            state: crossterm::event::KeyEventState::NONE,
7967        };
7968        let _ = app.handle_key(key).unwrap();
7969        assert!(!app.quit);
7970    }
7971
7972    #[test]
7973    fn handle_mouse_in_mode_without_mouse_support_is_noop() {
7974        let (_tmp, mut app) = make_app();
7975        app.mode = Mode::Filter {
7976            buffer: "query".into(),
7977        };
7978        let before = app.mode.clone();
7979        let ev = MouseEvent {
7980            kind: crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left),
7981            column: 1,
7982            row: 1,
7983            modifiers: crossterm::event::KeyModifiers::NONE,
7984        };
7985        let action = app.handle_mouse(ev).unwrap();
7986        assert_eq!(action, Action::None);
7987        assert_eq!(app.mode, before);
7988    }
7989
7990    #[test]
7991    fn frame_hit_regions_are_empty_until_recorded_and_resolve_to_actions() {
7992        let (_tmp, app) = make_app();
7993        assert!(
7994            app.frame_hit_regions.borrow().is_empty(),
7995            "no regions before a draw"
7996        );
7997
7998        let rect_a = Rect::new(0, 0, 10, 5);
7999        let rect_b = Rect::new(2, 1, 2, 2);
8000        app.record_region(rect_a, Action::OpenEditor("a.md".into()));
8001        app.record_region(rect_b, Action::Quit);
8002        assert_eq!(app.frame_hit_regions.borrow().len(), 2);
8003
8004        // Recorded rects resolve to their action...
8005        assert_eq!(
8006            app.resolve_region(0, 0),
8007            Some(Action::OpenEditor("a.md".into()))
8008        );
8009        // ...innermost (last-recorded) regions win on overlap...
8010        assert_eq!(app.resolve_region(3, 2), Some(Action::Quit));
8011        // ...and positions outside every region resolve to nothing.
8012        assert_eq!(app.resolve_region(11, 2), None);
8013        assert_eq!(app.resolve_region(3, 6), None);
8014    }
8015
8016    #[test]
8017    fn draw_clears_regions_so_stale_targets_never_fire() {
8018        use ratatui::backend::TestBackend;
8019        let (_tmp, app) = make_app();
8020        app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
8021        app.record_region(Rect::new(0, 0, 10, 5), Action::Quit);
8022        let mut terminal = Terminal::new(TestBackend::new(80, 40)).unwrap();
8023        terminal.draw(|f| render(&app, f, f.area())).unwrap();
8024        assert!(
8025            app.frame_hit_regions.borrow().is_empty(),
8026            "a draw clears regions recorded before it"
8027        );
8028    }
8029
8030    // ------------------------------------------------------------------
8031    // Mouse board selection / click / drag (task 3.1)
8032    // ------------------------------------------------------------------
8033
8034    /// A board with `n` cards in the first column, for the page-step tests.
8035    fn make_app_with_cards(n: u32) -> (TempDir, App) {
8036        let (tmp, mut app) = make_app();
8037        let dir = app.store.config.issues_dir.clone();
8038        for i in 1..=n {
8039            write_issue(
8040                &dir,
8041                &format!("card-{i:02}.md"),
8042                i,
8043                &format!("Card {i:02}"),
8044                "todo",
8045                None,
8046            );
8047        }
8048        app.rebuild_board();
8049        (tmp, app)
8050    }
8051
8052    /// Four-column board (todo / in progress / in review / done) seeded with
8053    /// one short title per column, framed at 80x40 so every card renders on a
8054    /// single line. `double_click_window` is padded so tests never race the
8055    /// wall clock.
8056    fn make_mouse_app() -> (TempDir, App) {
8057        let (tmp, mut app) = make_app();
8058        app.mouse.double_click_window = Duration::from_secs(60);
8059        let dir = app.store.config.issues_dir.clone();
8060        write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
8061        write_issue(&dir, "b.md", 2, "Beta", "todo", None);
8062        write_issue(&dir, "c.md", 3, "Gamma", "in progress", None);
8063        write_issue(&dir, "d.md", 4, "Delta", "in review", None);
8064        write_issue(&dir, "e.md", 5, "Epsilon", "done", Some("abc123"));
8065        app.rebuild_board();
8066        app.frame_size.set(Rect::new(0, 0, 80, 40));
8067        (tmp, app)
8068    }
8069
8070    fn make_sparse_order_mouse_app() -> (TempDir, App) {
8071        let (tmp, mut app) = make_app();
8072        app.mouse.double_click_window = Duration::from_secs(60);
8073        let dir = app.store.config.issues_dir.clone();
8074        write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
8075        write_issue(&dir, "b.md", 2, "Beta", "todo", None);
8076        write_issue(&dir, "c.md", 3, "Gamma", "todo", None);
8077        write_issue(&dir, "d.md", 4, "Delta", "todo", None);
8078        app.rebuild_board();
8079        app.frame_size.set(Rect::new(0, 0, 80, 40));
8080        (tmp, app)
8081    }
8082
8083    /// The pointer fixtures, plus one **blocked** card: #1 blocks #2, so dragging
8084    /// or menu-moving #2 into `done` has to raise the acknowledgement prompt.
8085    ///
8086    /// WP04's TUI coverage went through the keyboard `>` binding only. A drag-drop
8087    /// and a context-menu item are two more doors into `move_selected_to`, and a
8088    /// door with no test is a door nobody checked.
8089    fn make_blocked_mouse_app() -> (TempDir, App) {
8090        let (tmp, mut app) = make_mouse_app();
8091        app.store
8092            .link("1", "2", crate::relationships::RelationKind::Blocks)
8093            .unwrap();
8094        app.rebuild_board();
8095        (tmp, app)
8096    }
8097
8098    /// The index of the `done` column, resolved from the board rather than
8099    /// hard-coded, so a column-order change does not silently retarget the test.
8100    fn done_col(app: &App) -> usize {
8101        let name = app
8102            .store
8103            .config
8104            .default_config()
8105            .effective_done_column()
8106            .expect("the fixture board has a done column");
8107        (0..app.board.total_cols())
8108            .find(|c| app.board.column_name(*c) == name)
8109            .unwrap_or_else(|| panic!("no column named {name}"))
8110    }
8111
8112    fn down_left(x: u16, y: u16) -> MouseEvent {
8113        MouseEvent {
8114            kind: MouseEventKind::Down(MouseButton::Left),
8115            column: x,
8116            row: y,
8117            modifiers: crossterm::event::KeyModifiers::NONE,
8118        }
8119    }
8120
8121    fn down_right(x: u16, y: u16) -> MouseEvent {
8122        MouseEvent {
8123            kind: MouseEventKind::Down(MouseButton::Right),
8124            column: x,
8125            row: y,
8126            modifiers: crossterm::event::KeyModifiers::NONE,
8127        }
8128    }
8129
8130    fn up_left(x: u16, y: u16) -> MouseEvent {
8131        MouseEvent {
8132            kind: MouseEventKind::Up(MouseButton::Left),
8133            column: x,
8134            row: y,
8135            modifiers: crossterm::event::KeyModifiers::NONE,
8136        }
8137    }
8138
8139    fn drag_event(x: u16, y: u16) -> MouseEvent {
8140        MouseEvent {
8141            kind: MouseEventKind::Drag(MouseButton::Left),
8142            column: x,
8143            row: y,
8144            modifiers: crossterm::event::KeyModifiers::NONE,
8145        }
8146    }
8147
8148    /// A pointer position on `row`-th card of column `col`, using the shared
8149    /// board geometry so tests stay in lock-step with `hit_test`.
8150    fn card_cell(app: &App, col: usize, row: usize) -> (u16, u16) {
8151        let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
8152        let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
8153        let mut y = rect.y + 1;
8154        for before in 0..row {
8155            y += crate::ui::widgets::card_line_count(
8156                &app.board.columns[col].cards[before],
8157                rect.width as usize,
8158                true,
8159                3,
8160            );
8161        }
8162        (rect.x + 2, y)
8163    }
8164
8165    /// A pointer position on empty space inside column `col` (below its cards).
8166    fn column_cell(app: &App, col: usize) -> (u16, u16) {
8167        let board_area = app.board_mouse_area(app.frame_size.get()).unwrap();
8168        let rect = board::compute_column_rects(board_area, app.board.total_cols())[col];
8169        (rect.x + 2, rect.y + rect.height - 2)
8170    }
8171
8172    /// Dragging a blocked card into `done` is a **move**, so it asks before it
8173    /// writes — the same prompt the `>` binding raises.
8174    ///
8175    /// WP04 covered the keyboard path only. A drag-drop is a second door into
8176    /// `move_selected_to`, and the review flagged that no test drove it.
8177    #[test]
8178    fn a_drag_of_a_blocked_card_into_done_asks_before_it_writes() {
8179        let (_tmp, mut app) = make_blocked_mouse_app();
8180        let (sx, sy) = card_cell(&app, 0, 1); // #2 Beta, blocked by #1
8181        let (tx, ty) = column_cell(&app, done_col(&app));
8182
8183        app.handle_mouse(down_left(sx, sy)).unwrap();
8184        app.handle_mouse(drag_event(tx, ty)).unwrap();
8185        app.handle_mouse(up_left(tx, ty)).unwrap();
8186
8187        assert!(
8188            matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
8189            "a drag into done must raise the acknowledgement prompt, got {:?}",
8190            app.mode
8191        );
8192        // Cancelling leaves the card where it was.
8193        app.handle_key(press(KeyCode::Esc)).unwrap();
8194        assert!(
8195            app.board.column_cards(0).iter().any(|c| c.number == 2),
8196            "a cancelled drag must not move the card"
8197        );
8198    }
8199
8200    /// The same drag, confirmed: the prompt **defers** to the commit prompt
8201    /// rather than applying with no commit, so a confirmed blocked move is
8202    /// recorded exactly like a clear one.
8203    #[test]
8204    fn a_confirmed_blocked_drag_into_done_records_its_commit() {
8205        let (_tmp, mut app) = make_blocked_mouse_app();
8206        let dir = app.store.config.issues_dir.clone();
8207        let (sx, sy) = card_cell(&app, 0, 1);
8208        let (tx, ty) = column_cell(&app, done_col(&app));
8209
8210        app.handle_mouse(down_left(sx, sy)).unwrap();
8211        app.handle_mouse(drag_event(tx, ty)).unwrap();
8212        app.handle_mouse(up_left(tx, ty)).unwrap();
8213        app.handle_key(press(KeyCode::Char('y'))).unwrap();
8214
8215        assert!(
8216            matches!(app.mode, Mode::MoveIntoDone { .. }),
8217            "confirming must still ask for the commit hash, got {:?}",
8218            app.mode
8219        );
8220        for c in "deadbee".chars() {
8221            app.handle_key(press(KeyCode::Char(c))).unwrap();
8222        }
8223        app.handle_key(press(KeyCode::Enter)).unwrap();
8224
8225        let file = fs::read_to_string(dir.join("b.md")).unwrap();
8226        assert!(file.contains("commit: deadbee"), "commit recorded:\n{file}");
8227        assert!(
8228            file.contains("blocked_move_acknowledged"),
8229            "and the acknowledgement is in the same record:\n{file}"
8230        );
8231    }
8232
8233    /// The context menu's `Move to <column>` is a third door into
8234    /// `move_selected_to`, and it was the only one with no test at all.
8235    #[test]
8236    fn a_context_menu_move_of_a_blocked_card_asks_before_it_writes() {
8237        let (_tmp, mut app) = make_blocked_mouse_app();
8238        let (sx, sy) = card_cell(&app, 0, 1); // #2 Beta, blocked by #1
8239        let target = done_col(&app);
8240
8241        app.handle_mouse(down_right(sx, sy)).unwrap();
8242        assert!(
8243            matches!(app.mode, Mode::ContextMenu { .. }),
8244            "right-click opens the menu, got {:?}",
8245            app.mode
8246        );
8247        // Navigate to `Move to <done>` by index, not by counting Down presses.
8248        let index = app
8249            .context_menu
8250            .as_ref()
8251            .unwrap()
8252            .items()
8253            .iter()
8254            .position(|i| i.kind == MenuItemKind::MoveTo { column: target })
8255            .expect("the menu offers a move into the done column");
8256        for _ in 0..index {
8257            app.handle_key(press(KeyCode::Down)).unwrap();
8258        }
8259        app.handle_key(press(KeyCode::Enter)).unwrap();
8260
8261        assert!(
8262            matches!(app.mode, Mode::ConfirmBlockedMove { .. }),
8263            "a menu move into done must raise the acknowledgement prompt, got {:?}",
8264            app.mode
8265        );
8266        app.handle_key(press(KeyCode::Esc)).unwrap();
8267        assert!(
8268            app.board.column_cards(0).iter().any(|c| c.number == 2),
8269            "a cancelled menu move must not move the card"
8270        );
8271    }
8272
8273    #[test]
8274    fn mouse_click_selects_card() {
8275        let (_tmp, mut app) = make_mouse_app();
8276        let (x, y) = card_cell(&app, 0, 1);
8277        let action = app.handle_mouse(down_left(x, y)).unwrap();
8278        assert_eq!(action, Action::None);
8279        assert_eq!((app.board.active_col, app.board.selected), (0, 1));
8280        assert_eq!(
8281            app.board.get_selected_card().map(|c| c.number),
8282            Some(2),
8283            "second todo card selected"
8284        );
8285    }
8286
8287    #[test]
8288    fn mouse_click_on_empty_column_space_selects_column() {
8289        let (_tmp, mut app) = make_mouse_app();
8290        let (x, y) = column_cell(&app, 1);
8291        let action = app.handle_mouse(down_left(x, y)).unwrap();
8292        assert_eq!(action, Action::None);
8293        assert_eq!(app.board.active_col, 1);
8294    }
8295
8296    #[test]
8297    fn mouse_double_click_opens_detail() {
8298        let (_tmp, mut app) = make_mouse_app();
8299        let (x, y) = card_cell(&app, 0, 0);
8300        app.handle_mouse(down_left(x, y)).unwrap();
8301        app.handle_mouse(up_left(x, y)).unwrap();
8302        app.handle_mouse(down_left(x, y)).unwrap();
8303        assert!(matches!(app.mode, Mode::Detail));
8304        assert_eq!(
8305            app.detail.as_ref().map(|d| d.issue.number),
8306            Some(1),
8307            "double-clicked card detail is open"
8308        );
8309    }
8310
8311    #[test]
8312    fn mouse_single_click_does_not_open_detail() {
8313        let (_tmp, mut app) = make_mouse_app();
8314        let (x, y) = card_cell(&app, 0, 0);
8315        app.handle_mouse(down_left(x, y)).unwrap();
8316        app.handle_mouse(up_left(x, y)).unwrap();
8317        assert!(matches!(app.mode, Mode::Board));
8318        assert!(app.detail.is_none());
8319    }
8320
8321    #[test]
8322    fn mouse_drag_moves_issue_to_another_real_column() {
8323        let (_tmp, mut app) = make_mouse_app();
8324        let (sx, sy) = card_cell(&app, 0, 1);
8325        let target = column_cell(&app, 1);
8326        app.handle_mouse(down_left(sx, sy)).unwrap();
8327        app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8328        let action = app.handle_mouse(up_left(target.0, target.1)).unwrap();
8329        assert_eq!(action, Action::None);
8330        assert_eq!(app.board.active_col, 1);
8331        assert!(
8332            app.board.columns[1].cards.iter().any(|c| c.number == 2),
8333            "Beta landed in 'in progress'"
8334        );
8335        assert!(
8336            app.board.columns[0].cards.iter().all(|c| c.number != 2),
8337            "Beta left 'todo'"
8338        );
8339        assert_eq!(app.status_msg.as_deref(), Some("Moved #2 to in progress"));
8340    }
8341
8342    #[test]
8343    fn mouse_drag_updates_preview_column_while_held() {
8344        let (_tmp, mut app) = make_mouse_app();
8345        let (sx, sy) = card_cell(&app, 0, 1);
8346        let target = column_cell(&app, 1);
8347        app.handle_mouse(down_left(sx, sy)).unwrap();
8348        app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8349        assert_eq!(app.drag_preview_col, Some(1));
8350        assert_eq!(app.board.hover, None);
8351        app.handle_mouse(up_left(target.0, target.1)).unwrap();
8352        assert_eq!(app.drag_preview_col, None);
8353    }
8354
8355    #[test]
8356    fn ctrl_up_and_down_reorder_the_selected_card() {
8357        let (_tmp, mut app) = make_mouse_app();
8358        app.board.active_col = 0;
8359        app.board.selected = 1;
8360        let up = app
8361            .handle_key(press_with_modifiers(
8362                KeyCode::Up,
8363                crossterm::event::KeyModifiers::CONTROL,
8364            ))
8365            .unwrap();
8366        assert_eq!(up, Action::None);
8367        assert_eq!(
8368            app.board
8369                .column_cards(0)
8370                .iter()
8371                .map(|c| c.title.as_str())
8372                .collect::<Vec<_>>(),
8373            ["Beta", "Alpha"],
8374            "ctrl-up moves the selected card one slot earlier"
8375        );
8376        assert_eq!(app.board.selected, 0);
8377        let down = app
8378            .handle_key(press_with_modifiers(
8379                KeyCode::Down,
8380                crossterm::event::KeyModifiers::CONTROL,
8381            ))
8382            .unwrap();
8383        assert_eq!(down, Action::None);
8384        assert_eq!(
8385            app.board
8386                .column_cards(0)
8387                .iter()
8388                .map(|c| c.title.as_str())
8389                .collect::<Vec<_>>(),
8390            ["Alpha", "Beta"],
8391            "ctrl-down restores the original order"
8392        );
8393    }
8394
8395    #[test]
8396    fn mouse_drop_on_source_column_is_noop() {
8397        let (_tmp, mut app) = make_mouse_app();
8398        let (sx, sy) = card_cell(&app, 0, 1);
8399        let target = column_cell(&app, 0);
8400        app.handle_mouse(down_left(sx, sy)).unwrap();
8401        app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8402        app.handle_mouse(up_left(target.0, target.1)).unwrap();
8403        assert!(
8404            app.board.columns[0].cards.iter().any(|c| c.number == 2),
8405            "Beta stayed in 'todo'"
8406        );
8407    }
8408
8409    #[test]
8410    fn mouse_drag_reorders_cards_within_same_column_and_persists_order() {
8411        let (_tmp, mut app) = make_mouse_app();
8412        let (sx, sy) = card_cell(&app, 0, 1);
8413        let (tx, ty) = card_cell(&app, 0, 0);
8414        app.handle_mouse(down_left(sx, sy)).unwrap();
8415        app.handle_mouse(drag_event(tx, ty)).unwrap();
8416        let action = app.handle_mouse(up_left(tx, ty)).unwrap();
8417        assert_eq!(action, Action::None);
8418        assert_eq!(
8419            app.board
8420                .column_cards(0)
8421                .iter()
8422                .map(|c| c.title.as_str())
8423                .collect::<Vec<_>>(),
8424            ["Beta", "Alpha"],
8425            "dropping on another card in the same column reorders it"
8426        );
8427        let board_name = app.active_board_name().to_string();
8428        let board_cfg = app
8429            .store
8430            .config
8431            .kanban
8432            .configurations
8433            .get(&board_name)
8434            .unwrap();
8435        assert_eq!(
8436            board_cfg.card_order,
8437            vec!["b.md".to_string(), "a.md".to_string()],
8438            "same-column drag persists the custom order in config"
8439        );
8440    }
8441
8442    #[test]
8443    fn mouse_drag_persists_only_cards_that_differ_from_default_order() {
8444        let (_tmp, mut app) = make_sparse_order_mouse_app();
8445        let (sx, sy) = card_cell(&app, 0, 3);
8446        let (tx, ty) = card_cell(&app, 0, 1);
8447        app.handle_mouse(down_left(sx, sy)).unwrap();
8448        app.handle_mouse(drag_event(tx, ty)).unwrap();
8449        app.handle_mouse(up_left(tx, ty)).unwrap();
8450        assert_eq!(
8451            app.board
8452                .column_cards(0)
8453                .iter()
8454                .map(|c| c.title.as_str())
8455                .collect::<Vec<_>>(),
8456            ["Alpha", "Delta", "Beta", "Gamma"],
8457            "dragging the last card into the middle updates the visible order"
8458        );
8459        let board_name = app.active_board_name().to_string();
8460        let board_cfg = app
8461            .store
8462            .config
8463            .kanban
8464            .configurations
8465            .get(&board_name)
8466            .unwrap();
8467        assert_eq!(
8468            board_cfg.card_order,
8469            vec!["d.md".to_string(), "b.md".to_string(), "c.md".to_string()],
8470            "only the cards whose positions changed are persisted"
8471        );
8472    }
8473
8474    #[test]
8475    fn mouse_drop_on_no_column_area_is_noop() {
8476        let (_tmp, mut app) = make_mouse_app();
8477        let (sx, sy) = card_cell(&app, 0, 0);
8478        app.handle_mouse(down_left(sx, sy)).unwrap();
8479        let outside = (sx, 0u16); // the hover line, above the board area
8480        app.handle_mouse(drag_event(outside.0, outside.1)).unwrap();
8481        app.handle_mouse(up_left(outside.0, outside.1)).unwrap();
8482        assert!(
8483            app.board.columns[0].cards.iter().any(|c| c.number == 1),
8484            "Alpha stayed in 'todo'"
8485        );
8486        assert_eq!(app.status_msg, None);
8487    }
8488
8489    #[test]
8490    fn mouse_drop_on_mismatch_column_is_noop() {
8491        let (_tmp, mut app) = make_app();
8492        app.mouse.double_click_window = Duration::from_secs(60);
8493        let dir = app.store.config.issues_dir.clone();
8494        write_issue(&dir, "x.md", 1, "Alpha", "todo", None);
8495        write_issue(&dir, "w.md", 9, "Oddball", "weird", None);
8496        app.rebuild_board();
8497        app.board.show_mismatch = true;
8498        app.frame_size.set(Rect::new(0, 0, 80, 40));
8499
8500        let (sx, sy) = card_cell(&app, 0, 0);
8501        let mismatch_col = app.board.columns.len();
8502        let target = column_cell(&app, mismatch_col);
8503        app.handle_mouse(down_left(sx, sy)).unwrap();
8504        app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8505        app.handle_mouse(up_left(target.0, target.1)).unwrap();
8506        assert!(
8507            app.board.columns[0].cards.iter().any(|c| c.number == 1),
8508            "Alpha stayed in 'todo' (mismatched column is not a move target)"
8509        );
8510    }
8511
8512    #[test]
8513    fn mouse_drag_into_done_without_commit_opens_prompt() {
8514        let (_tmp, mut app) = make_mouse_app();
8515        let (sx, sy) = card_cell(&app, 0, 0);
8516        let target = column_cell(&app, 3); // "done"
8517        app.handle_mouse(down_left(sx, sy)).unwrap();
8518        app.handle_mouse(drag_event(target.0, target.1)).unwrap();
8519        app.handle_mouse(up_left(target.0, target.1)).unwrap();
8520        assert!(
8521            matches!(&app.mode, Mode::MoveIntoDone { target_col: 3, buffer } if buffer.is_empty()),
8522            "drag into done without a commit opens the MoveIntoDone prompt, got {:?}",
8523            app.mode
8524        );
8525    }
8526
8527    fn moved_to(x: u16, y: u16) -> MouseEvent {
8528        MouseEvent {
8529            kind: MouseEventKind::Moved,
8530            column: x,
8531            row: y,
8532            modifiers: crossterm::event::KeyModifiers::NONE,
8533        }
8534    }
8535
8536    fn scroll_up(x: u16, y: u16) -> MouseEvent {
8537        MouseEvent {
8538            kind: MouseEventKind::ScrollUp,
8539            column: x,
8540            row: y,
8541            modifiers: crossterm::event::KeyModifiers::NONE,
8542        }
8543    }
8544
8545    fn scroll_down(x: u16, y: u16) -> MouseEvent {
8546        MouseEvent {
8547            kind: MouseEventKind::ScrollDown,
8548            column: x,
8549            row: y,
8550            modifiers: crossterm::event::KeyModifiers::NONE,
8551        }
8552    }
8553
8554    #[test]
8555    fn mouse_hover_updates_without_selection_change() {
8556        let (_tmp, mut app) = make_mouse_app();
8557        let (x, y) = card_cell(&app, 0, 1);
8558        let before = (app.board.active_col, app.board.selected);
8559        let action = app.handle_mouse(moved_to(x, y)).unwrap();
8560        assert_eq!(action, Action::None);
8561        assert_eq!(app.board.hover, Some((0, 1)));
8562        assert_eq!((app.board.active_col, app.board.selected), before);
8563    }
8564
8565    #[test]
8566    fn mouse_hover_cleared_on_up_or_pointer_leaving_board() {
8567        let (_tmp, mut app) = make_mouse_app();
8568        let (x, y) = card_cell(&app, 0, 0);
8569        app.handle_mouse(moved_to(x, y)).unwrap();
8570        assert_eq!(app.board.hover, Some((0, 0)));
8571
8572        // Leaving the board clears the hover.
8573        app.handle_mouse(moved_to(x, 0)).unwrap();
8574        assert_eq!(app.board.hover, None);
8575
8576        // Hovering a card again then releasing the button also clears it.
8577        app.handle_mouse(moved_to(x, y)).unwrap();
8578        assert!(app.board.hover.is_some());
8579        app.handle_mouse(up_left(x, y)).unwrap();
8580        assert_eq!(app.board.hover, None);
8581    }
8582
8583    #[test]
8584    fn mouse_scroll_moves_board_selection_within_active_column() {
8585        let (_tmp, mut app) = make_mouse_app();
8586        let (x, y) = card_cell(&app, 0, 1);
8587        app.handle_mouse(down_left(x, y)).unwrap();
8588        assert_eq!(app.board.selected, 1);
8589
8590        let action = app.handle_mouse(scroll_up(x, y)).unwrap();
8591        assert_eq!(action, Action::None);
8592        assert_eq!(app.board.selected, 0);
8593
8594        let action = app.handle_mouse(scroll_down(x, y)).unwrap();
8595        assert_eq!(action, Action::None);
8596        assert_eq!(app.board.selected, 1);
8597    }
8598
8599    #[test]
8600    fn mouse_scroll_scrolls_detail_and_context_menu() {
8601        let (_tmp, mut app) = make_mouse_app();
8602        let (x, y) = card_cell(&app, 0, 0);
8603
8604        app.handle_mouse(down_left(x, y)).unwrap();
8605        app.handle_mouse(up_left(x, y)).unwrap();
8606        app.handle_mouse(down_left(x, y)).unwrap();
8607        assert!(matches!(app.mode, Mode::Detail));
8608        // The pane's geometry is normally recorded by its own draw; a test that drives
8609        // the handler supplies it, which is also what makes the clamping visible.
8610        if let Some(d) = app.detail.as_mut() {
8611            d.viewport.set(20);
8612            d.max_scroll.set(8);
8613        }
8614        let action = app.handle_mouse(scroll_down(x, y)).unwrap();
8615        assert_eq!(action, Action::None);
8616        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 5);
8617        // One more notch would be 10, past the last page: it stops at 8 rather than
8618        // scrolling the pane into emptiness (tix #1442).
8619        app.handle_mouse(scroll_down(x, y)).unwrap();
8620        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 8);
8621        let action = app.handle_mouse(scroll_up(x, y)).unwrap();
8622        assert_eq!(action, Action::None);
8623        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 3);
8624        for _ in 0..5 {
8625            app.handle_mouse(scroll_up(x, y)).unwrap();
8626        }
8627        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 0);
8628
8629        let (_tmp, mut app) = make_mouse_app();
8630        let (x, y) = card_cell(&app, 0, 0);
8631        app.handle_mouse(down_right(x, y)).unwrap();
8632        assert!(matches!(app.mode, Mode::ContextMenu { .. }));
8633        let action = app.handle_mouse(scroll_down(x, y)).unwrap();
8634        assert_eq!(action, Action::None);
8635        assert_eq!(app.context_menu.as_ref().unwrap().selected(), Some(1));
8636        let action = app.handle_mouse(scroll_up(x, y)).unwrap();
8637        assert_eq!(action, Action::None);
8638        assert_eq!(app.context_menu.as_ref().unwrap().selected(), Some(0));
8639    }
8640
8641    // ------------------------------------------------------------------
8642    // #1398 / #1441 / #1442 — the new-issue entry point, and scrolling
8643    // ------------------------------------------------------------------
8644
8645    /// `tix new` starts the session on the create form (#1398). This is the state
8646    /// `app::run_new_issue` opens, and it must be the *same* form `n` produces — both
8647    /// go through [`App::open_new_issue_form`], and this pins the outcome so a future
8648    /// second implementation of "prefill the form" shows up as a difference.
8649    #[test]
8650    fn new_issue_form_is_the_form_the_board_key_opens() {
8651        let (_tmp, mut by_key) = make_app();
8652        by_key.mode = Mode::Board;
8653        let _ = by_key.handle_key(press(KeyCode::Char('n'))).unwrap();
8654
8655        let (_tmp2, mut by_command) = make_app();
8656        by_command.open_new_issue_form();
8657
8658        assert!(matches!(by_command.mode, Mode::Form));
8659        assert!(matches!(by_key.mode, Mode::Form));
8660        let key_form = by_key.form.as_ref().expect("n opens a form");
8661        let cmd_form = by_command.form.as_ref().expect("the command opens a form");
8662        assert_eq!(cmd_form.title, key_form.title);
8663        assert_eq!(cmd_form.status, key_form.status);
8664        assert_eq!(cmd_form.assignee, key_form.assignee);
8665        assert_eq!(cmd_form.focus, key_form.focus);
8666    }
8667
8668    /// The pane's page keys move a **page**, not a handful of lines, and stop at the
8669    /// end of the content instead of scrolling past it (#1442).
8670    #[test]
8671    fn detail_page_keys_scroll_one_page_and_stop_at_the_end() {
8672        let (_tmp, mut app) = make_mouse_app();
8673        let (x, y) = card_cell(&app, 0, 0);
8674        app.handle_mouse(down_left(x, y)).unwrap();
8675        app.handle_mouse(up_left(x, y)).unwrap();
8676        app.handle_mouse(down_left(x, y)).unwrap();
8677        assert!(matches!(app.mode, Mode::Detail));
8678
8679        if let Some(d) = app.detail.as_mut() {
8680            d.viewport.set(20);
8681            d.max_scroll.set(45);
8682        }
8683        // A page keeps one row of the previous page, so the reader has an anchor.
8684        let page = app.detail.as_ref().unwrap().page();
8685        assert_eq!(page, 19);
8686
8687        app.handle_key(press(KeyCode::PageDown)).unwrap();
8688        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 19);
8689        app.handle_key(press(KeyCode::PageDown)).unwrap();
8690        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 38);
8691        // 38 + 19 = 57 > 45, so this one lands on the last page rather than past it.
8692        app.handle_key(press(KeyCode::PageDown)).unwrap();
8693        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 45);
8694        app.handle_key(press(KeyCode::PageUp)).unwrap();
8695        assert_eq!(app.detail.as_ref().unwrap().scroll_offset, 26);
8696    }
8697
8698    /// The keybindings overlay scrolls — one row, one page, one wheel notch — and
8699    /// clamps at both ends (#1441, #1442).
8700    #[test]
8701    fn help_overlay_scrolls_by_row_page_and_wheel() {
8702        use ratatui::Terminal;
8703        use ratatui::backend::TestBackend;
8704
8705        let (_tmp, mut app) = make_app();
8706        app.open_help(Mode::Board);
8707        // The overlay records its geometry while it draws, so it has to draw once
8708        // before its handlers know how far they can go — which is also what a real
8709        // session does before it reads a key.
8710        let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
8711        terminal.draw(|f| render(&app, f, f.area())).unwrap();
8712        let metrics = app.help_metrics.get();
8713        assert!(
8714            metrics.max_scroll > 0,
8715            "the test frame is meant to hide rows: {metrics:?}"
8716        );
8717
8718        app.handle_key(press(KeyCode::Char('j'))).unwrap();
8719        assert_eq!(app.help_scroll, 1, "one row down");
8720        app.handle_key(press(KeyCode::Char('k'))).unwrap();
8721        assert_eq!(app.help_scroll, 0, "and back");
8722
8723        app.handle_key(press(KeyCode::PageDown)).unwrap();
8724        assert_eq!(app.help_scroll, app.help_page(), "one page down");
8725        app.handle_key(press(KeyCode::PageUp)).unwrap();
8726        assert_eq!(app.help_scroll, 0, "and back");
8727
8728        app.handle_mouse(scroll_down(1, 1)).unwrap();
8729        assert_eq!(app.help_scroll, WHEEL_ROWS, "one wheel notch");
8730        app.handle_mouse(scroll_up(1, 1)).unwrap();
8731        assert_eq!(app.help_scroll, 0);
8732
8733        for _ in 0..50 {
8734            app.handle_key(press(KeyCode::PageDown)).unwrap();
8735        }
8736        assert_eq!(
8737            app.help_scroll, metrics.max_scroll,
8738            "the last row is reachable"
8739        );
8740        for _ in 0..50 {
8741            app.handle_key(press(KeyCode::PageUp)).unwrap();
8742        }
8743        assert_eq!(app.help_scroll, 0, "and the first row stays the first");
8744    }
8745
8746    /// Scrolling the overlay moves the **rendered** rows, and reopening starts at the
8747    /// top again (#1441).
8748    #[test]
8749    fn help_overlay_scrolls_its_rows_and_reopens_at_the_top() {
8750        use ratatui::Terminal;
8751        use ratatui::backend::TestBackend;
8752
8753        let (_tmp, mut app) = make_app();
8754        let entries = crate::ui::commandbar::help_overlay(&app.bindings);
8755        assert!(
8756            entries.len() > 12,
8757            "the overlay must be longer than the frame"
8758        );
8759
8760        let draw = |app: &App| {
8761            let mut terminal = Terminal::new(TestBackend::new(80, 12)).unwrap();
8762            terminal.draw(|f| render(app, f, f.area())).unwrap();
8763            let buf = terminal.backend().buffer();
8764            let mut text = String::new();
8765            for y in 0..buf.area.height {
8766                for x in 0..buf.area.width {
8767                    text.push_str(buf[(x, y)].symbol());
8768                }
8769                text.push('\n');
8770            }
8771            text
8772        };
8773
8774        app.open_help(Mode::Board);
8775        let first = draw(&app);
8776        assert!(
8777            first.contains(&entries[0].0),
8778            "the top of the list is on screen: {first}"
8779        );
8780
8781        app.handle_key(press(KeyCode::PageDown)).unwrap();
8782        let second = draw(&app);
8783        assert_ne!(
8784            second, first,
8785            "scrolling a page must change what is drawn, not just a counter"
8786        );
8787        let page = app.help_page() as usize;
8788        assert!(
8789            second.contains(&entries[page].0),
8790            "the row one page down is now visible: {second}"
8791        );
8792        assert!(
8793            !second.contains(&entries[0].0),
8794            "and the first page scrolled away"
8795        );
8796
8797        // Reopening resets: a help screen that reappeared halfway down would hide the
8798        // first bindings from the person who opened it to find them.
8799        app.close_help();
8800        app.open_help(Mode::Board);
8801        assert_eq!(app.help_scroll, 0);
8802        assert!(draw(&app).contains(&entries[0].0));
8803    }
8804
8805    /// `PgUp`/`PgDn` move the board selection by one screen of cards — and they move
8806    /// it **at all**, which they did not before (tix #1442 follow-up): the keys were
8807    /// unbound on the board, and an unbound key in a non-text context is rewritten to
8808    /// `F(12)` before the handler sees it, so the key did nothing while the wheel next
8809    /// to it worked.
8810    #[test]
8811    fn board_page_keys_move_the_selection_by_a_screen() {
8812        use ratatui::Terminal;
8813        use ratatui::backend::TestBackend;
8814
8815        let (_tmp, mut app) = make_app_with_cards(30);
8816        // The page size is reported by the draw, exactly as in a session.
8817        let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
8818        terminal.draw(|f| render(&app, f, f.area())).unwrap();
8819        let page = app.board_page.get();
8820        assert!(
8821            page > 1,
8822            "a 20-row column must hold more than one card, got {page}"
8823        );
8824
8825        assert_eq!(app.board.selected, 0);
8826        app.handle_key(press(KeyCode::PageDown)).unwrap();
8827        assert_eq!(app.board.selected, page, "one screen down");
8828        app.handle_key(press(KeyCode::PageDown)).unwrap();
8829        assert_eq!(app.board.selected, page * 2, "two screens down");
8830        app.handle_key(press(KeyCode::PageUp)).unwrap();
8831        assert_eq!(app.board.selected, page, "and back one");
8832
8833        for _ in 0..20 {
8834            app.handle_key(press(KeyCode::PageDown)).unwrap();
8835        }
8836        assert_eq!(
8837            app.board.selected, 29,
8838            "paging past the end lands on the last card"
8839        );
8840        for _ in 0..20 {
8841            app.handle_key(press(KeyCode::PageUp)).unwrap();
8842        }
8843        assert_eq!(app.board.selected, 0, "and paging up lands on the first");
8844    }
8845
8846    /// An empty column has nothing to page, and the key says nothing rather than
8847    /// moving a selection that is not there.
8848    #[test]
8849    fn board_page_keys_do_nothing_in_an_empty_column() {
8850        use ratatui::Terminal;
8851        use ratatui::backend::TestBackend;
8852
8853        let (_tmp, mut app) = make_app();
8854        let mut terminal = Terminal::new(TestBackend::new(70, 20)).unwrap();
8855        terminal.draw(|f| render(&app, f, f.area())).unwrap();
8856
8857        assert_eq!(app.board_page.get(), 0, "nothing to page");
8858        app.handle_key(press(KeyCode::PageDown)).unwrap();
8859        assert_eq!(app.board.selected, 0);
8860        assert!(matches!(app.mode, Mode::Board));
8861    }
8862
8863    /// The overlay's rendered text at `w`x`h`.
8864    ///
8865    /// For the tests that care about what is *on screen* rather than what the state
8866    /// says: a filter that changes a counter but not a row would pass a state test.
8867    fn draw_help(app: &App, w: u16, h: u16) -> String {
8868        use ratatui::Terminal;
8869        use ratatui::backend::TestBackend;
8870
8871        let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
8872        terminal.draw(|f| render(app, f, f.area())).unwrap();
8873        let buf = terminal.backend().buffer();
8874        let mut text = String::new();
8875        for y in 0..buf.area.height {
8876            for x in 0..buf.area.width {
8877                text.push_str(buf[(x, y)].symbol());
8878            }
8879            text.push('\n');
8880        }
8881        text
8882    }
8883
8884    /// tix #1496: `/` searches the keybindings. The query filters the list as it is
8885    /// typed, in the same fuzzy sense `tix search` uses, and the overlay says how much
8886    /// it is hiding.
8887    #[test]
8888    fn help_overlay_search_filters_the_list() {
8889        let (_tmp, mut app) = make_app();
8890        let entries = crate::ui::commandbar::help_overlay(&app.bindings);
8891        let first_key = entries[0].0.clone();
8892
8893        app.open_help(Mode::Board);
8894        assert!(
8895            draw_help(&app, 80, 24).contains(&first_key),
8896            "the unfiltered list starts at the top"
8897        );
8898
8899        app.handle_key(press(KeyCode::Char('/'))).unwrap();
8900        assert!(app.help_searching, "the input line has the keyboard");
8901        for c in "clone".chars() {
8902            app.handle_key(press(KeyCode::Char(c))).unwrap();
8903        }
8904        assert_eq!(app.help_query, "clone");
8905
8906        let filtered = draw_help(&app, 80, 24);
8907        assert!(
8908            filtered.contains("clone issue"),
8909            "the match is listed: {filtered}"
8910        );
8911        assert!(
8912            filtered.contains("of") && filtered.contains("bindings"),
8913            "and the summary says how many rows the filter kept: {filtered}"
8914        );
8915        assert!(
8916            !filtered.contains("open detail"),
8917            "an unrelated binding is filtered out: {filtered}"
8918        );
8919
8920        // Enter commits the search: the input closes, the filter stays.
8921        app.handle_key(press(KeyCode::Enter)).unwrap();
8922        assert!(!app.help_searching);
8923        assert_eq!(app.help_query, "clone");
8924        assert!(draw_help(&app, 80, 24).contains("clone issue"));
8925
8926        // Backspace while the input is open widens it again.
8927        app.handle_key(press(KeyCode::Char('/'))).unwrap();
8928        app.handle_key(press(KeyCode::Backspace)).unwrap();
8929        app.handle_key(press(KeyCode::Backspace)).unwrap();
8930        assert_eq!(app.help_query, "clo");
8931        assert!(
8932            draw_help(&app, 80, 24).contains("clone issue"),
8933            "still a match"
8934        );
8935
8936        // Esc **inside** the input clears the query and does not close the overlay —
8937        // the same bargain `/` makes on the board.
8938        app.handle_key(press(KeyCode::Esc)).unwrap();
8939        assert!(!app.help_searching);
8940        assert!(app.help_query.is_empty());
8941        assert!(
8942            matches!(app.mode, Mode::Help { .. }),
8943            "clearing a search is not closing the help"
8944        );
8945        assert!(
8946            draw_help(&app, 80, 24).contains(&first_key),
8947            "the whole list is back"
8948        );
8949
8950        // And Esc outside it closes, as it always did.
8951        app.handle_key(press(KeyCode::Esc)).unwrap();
8952        assert!(!matches!(app.mode, Mode::Help { .. }));
8953    }
8954
8955    /// The search is *fuzzy*, not a substring test: `nwiss` finds `new issue`, which is
8956    /// the whole point of asking for a fuzzy search rather than a second `/`.
8957    #[test]
8958    fn help_overlay_search_is_fuzzy() {
8959        let (_tmp, mut app) = make_app();
8960        app.open_help(Mode::Board);
8961        app.handle_key(press(KeyCode::Char('/'))).unwrap();
8962        for c in "nwiss".chars() {
8963            app.handle_key(press(KeyCode::Char(c))).unwrap();
8964        }
8965        let text = draw_help(&app, 80, 24);
8966        assert!(
8967            text.contains("new issue"),
8968            "a non-contiguous query still matches: {text}"
8969        );
8970    }
8971
8972    /// Reopening the overlay starts from a clean slate: a query left over from last
8973    /// time would hide the binding the reader opened the help to find.
8974    #[test]
8975    fn help_overlay_search_resets_when_reopened() {
8976        let (_tmp, mut app) = make_app();
8977        app.open_help(Mode::Board);
8978        app.handle_key(press(KeyCode::Char('/'))).unwrap();
8979        app.handle_key(press(KeyCode::Char('x'))).unwrap();
8980        app.handle_key(press(KeyCode::Enter)).unwrap();
8981        assert_eq!(app.help_query, "x");
8982
8983        app.close_help();
8984        app.open_help(Mode::Board);
8985        assert!(app.help_query.is_empty(), "the query did not survive");
8986        assert!(!app.help_searching);
8987    }
8988
8989    /// While the input line is up, the caret sits at the end of what has been typed —
8990    /// the text has to land somewhere visible.
8991    #[test]
8992    fn help_overlay_search_puts_the_caret_on_the_input_line() {
8993        use ratatui::Terminal;
8994        use ratatui::backend::TestBackend;
8995
8996        let (_tmp, mut app) = make_app();
8997        app.open_help(Mode::Board);
8998        app.handle_key(press(KeyCode::Char('/'))).unwrap();
8999        for c in "clone".chars() {
9000            app.handle_key(press(KeyCode::Char(c))).unwrap();
9001        }
9002
9003        let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
9004        terminal.draw(|f| render(&app, f, f.area())).unwrap();
9005        let cursor = terminal.backend().cursor_position();
9006
9007        // Find the row the prompt is drawn on, and the column just past its text: the
9008        // caret must be exactly there, which is the claim — not a re-derivation of the
9009        // modal's geometry.
9010        let buf = terminal.backend().buffer();
9011        let mut found = None;
9012        for y in 0..buf.area.height {
9013            let mut row = String::new();
9014            for x in 0..buf.area.width {
9015                row.push_str(buf[(x, y)].symbol());
9016            }
9017            // `find` returns a *byte* index and the box borders are three bytes each, so
9018            // the column is the number of characters before the match, not the offset.
9019            if let Some(byte) = row.find("/clone") {
9020                let column = row[..byte].chars().count() as u16 + "/clone".chars().count() as u16;
9021                found = Some((y, column));
9022            }
9023        }
9024        let (row, column) = found.expect("the prompt is drawn");
9025        assert_eq!(
9026            cursor,
9027            ratatui::layout::Position::new(column, row),
9028            "the prompt row was: {:?}",
9029            {
9030                let mut text = String::new();
9031                for x in 0..buf.area.width {
9032                    text.push_str(buf[(x, row)].symbol());
9033                }
9034                text
9035            }
9036        );
9037    }
9038
9039    /// The search line is **free text**: `q` and `?` are characters there, not the keys
9040    /// that close the overlay. They *are* bound to cancel in the list mode, and
9041    /// `App::handle_key` rewrites a bound key to its action's canonical chord before the
9042    /// handler sees it — so without a context of its own, `q` would arrive as `Esc` and
9043    /// no query could contain one.
9044    #[test]
9045    fn help_overlay_search_accepts_the_keys_that_close_the_overlay() {
9046        let (_tmp, mut app) = make_app();
9047        app.open_help(Mode::Board);
9048        app.handle_key(press(KeyCode::Char('/'))).unwrap();
9049        for c in "quit?".chars() {
9050            app.handle_key(press(KeyCode::Char(c))).unwrap();
9051        }
9052        assert_eq!(app.help_query, "quit?");
9053        assert!(app.help_searching, "still typing");
9054        assert!(
9055            matches!(app.mode, Mode::Help { .. }),
9056            "neither `q` nor `?` closed the overlay"
9057        );
9058
9059        // Committing it and pressing `q` in the *list* mode still closes, as before.
9060        app.handle_key(press(KeyCode::Enter)).unwrap();
9061        app.handle_key(press(KeyCode::Char('q'))).unwrap();
9062        assert!(!matches!(app.mode, Mode::Help { .. }));
9063    }
9064
9065    // ------------------------------------------------------------------
9066    // WP05 — help overlay, `/` filter, comment and asset entry
9067    // ------------------------------------------------------------------
9068
9069    fn open_detail(app: &mut App) {
9070        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9071        assert!(matches!(app.mode, Mode::Detail));
9072        assert!(app.detail.is_some());
9073    }
9074
9075    #[test]
9076    fn help_opens_from_board_and_closes() {
9077        let (_tmp, mut app) = make_app();
9078        let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9079        assert!(
9080            matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Board),
9081            "got {:?}",
9082            app.mode
9083        );
9084        let _ = app.handle_key(press(KeyCode::Char('q'))).unwrap();
9085        assert!(matches!(app.mode, Mode::Board));
9086    }
9087
9088    #[test]
9089    fn help_from_detail_returns_to_detail() {
9090        let (tmp, mut app) = make_app();
9091        let dir = tmp.path().join("issues");
9092        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9093        let config = app.store.config.clone();
9094        app.board = BoardState::build(&app.store, config.default_config());
9095        open_detail(&mut app);
9096        let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9097        assert!(
9098            matches!(&app.mode, Mode::Help { return_to } if **return_to == Mode::Detail),
9099            "got {:?}",
9100            app.mode
9101        );
9102        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9103        assert!(matches!(app.mode, Mode::Detail));
9104    }
9105
9106    #[test]
9107    fn help_overlay_click_closes_via_recorded_region() {
9108        let (_tmp, mut app) = make_app();
9109        let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9110        assert!(matches!(app.mode, Mode::Help { .. }));
9111
9112        let _ = draw_board(&app, 80, 24);
9113
9114        let action = app.handle_mouse(down_left(0, 0)).unwrap();
9115        assert_eq!(action, Action::HelpClose);
9116        app.close_help();
9117
9118        assert!(matches!(app.mode, Mode::Board));
9119    }
9120
9121    #[test]
9122    fn help_overlay_lists_mouse_bindings() {
9123        let (_tmp, mut app) = make_app();
9124        let _ = app.handle_key(press(KeyCode::Char('?'))).unwrap();
9125        let cells = draw_board(&app, 80, 24);
9126        let text: String = cells.concat();
9127        assert!(text.contains("click"), "got {text:?}");
9128        assert!(text.contains("double-click"), "got {text:?}");
9129        assert!(text.contains("right-click"), "got {text:?}");
9130        assert!(text.contains("drag"), "got {text:?}");
9131    }
9132
9133    #[test]
9134    fn filter_narrows_board_live() {
9135        let (tmp, mut app) = make_app();
9136        let dir = tmp.path().join("issues");
9137        write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9138        write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
9139        let config = app.store.config.clone();
9140        app.board = BoardState::build(&app.store, config.default_config());
9141        app.refresh_corpus().unwrap();
9142        assert_eq!(app.board.column_cards(0).len(), 2);
9143
9144        let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9145        assert!(matches!(app.mode, Mode::Filter { .. }));
9146        for c in "arget".chars() {
9147            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9148        }
9149        assert_eq!(app.filter, Some("arget".into()));
9150        assert_eq!(app.board.column_cards(0).len(), 1);
9151        assert_eq!(app.board.column_cards(0)[0].title, "Target");
9152        assert!(matches!(app.mode, Mode::Filter { .. }));
9153    }
9154
9155    #[test]
9156    fn filter_esc_clears_and_returns_to_board() {
9157        let (tmp, mut app) = make_app();
9158        let dir = tmp.path().join("issues");
9159        write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9160        let config = app.store.config.clone();
9161        app.board = BoardState::build(&app.store, config.default_config());
9162        app.refresh_corpus().unwrap();
9163        let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9164        for c in "zzw".chars() {
9165            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9166        }
9167        assert_eq!(app.board.column_cards(0).len(), 0);
9168        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9169        assert!(matches!(app.mode, Mode::Board));
9170        assert_eq!(app.filter, None);
9171        assert_eq!(app.board.column_cards(0).len(), 1);
9172    }
9173
9174    #[test]
9175    fn filter_enter_commits_and_stays_filtered() {
9176        let (tmp, mut app) = make_app();
9177        let dir = tmp.path().join("issues");
9178        write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9179        write_issue(&dir, "2026-01-01-b.md", 2, "Other", "todo", None);
9180        let config = app.store.config.clone();
9181        app.board = BoardState::build(&app.store, config.default_config());
9182        app.refresh_corpus().unwrap();
9183        let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9184        for c in "arget".chars() {
9185            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9186        }
9187        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9188        assert!(matches!(app.mode, Mode::Board));
9189        assert_eq!(app.filter, Some("arget".into()));
9190        assert_eq!(app.board.column_cards(0).len(), 1);
9191    }
9192
9193    #[test]
9194    fn filter_zero_match_reports_status() {
9195        let (tmp, mut app) = make_app();
9196        let dir = tmp.path().join("issues");
9197        write_issue(&dir, "2026-01-01-a.md", 1, "Target", "todo", None);
9198        let config = app.store.config.clone();
9199        app.board = BoardState::build(&app.store, config.default_config());
9200        app.refresh_corpus().unwrap();
9201        let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9202        for c in "zzw".chars() {
9203            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9204        }
9205        assert_eq!(app.board.column_cards(0).len(), 0);
9206        assert!(
9207            app.status_msg
9208                .as_deref()
9209                .is_some_and(|m| m.contains("No matches")),
9210            "got {:?}",
9211            app.status_msg
9212        );
9213    }
9214
9215    #[test]
9216    fn filter_zero_match_number_reports_status() {
9217        let (tmp, mut app) = make_app();
9218        let dir = tmp.path().join("issues");
9219        write_issue(&dir, "2026-01-01-a.md", 7, "Target", "todo", None);
9220        // Issue 7's title contains the digit sequence "999"? No: a miss still
9221        // must surface the no-matches status because the bare number gate
9222        // bypasses fuzzy text matching.
9223        let config = app.store.config.clone();
9224        app.board = BoardState::build(&app.store, config.default_config());
9225        app.refresh_corpus().unwrap();
9226        let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9227        for c in "999".chars() {
9228            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9229        }
9230        assert_eq!(app.filter, Some("999".into()));
9231        assert_eq!(app.board.column_cards(0).len(), 0);
9232        assert!(
9233            app.status_msg
9234                .as_deref()
9235                .is_some_and(|m| m.contains("No matches")),
9236            "got {:?}",
9237            app.status_msg
9238        );
9239    }
9240
9241    #[test]
9242    fn comment_round_trip_via_detail() {
9243        let (tmp, mut app) = make_app();
9244        let dir = tmp.path().join("issues");
9245        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9246        let config = app.store.config.clone();
9247        app.board = BoardState::build(&app.store, config.default_config());
9248        open_detail(&mut app);
9249
9250        let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9251        assert!(matches!(app.mode, Mode::Comment { .. }));
9252        for c in "hello world".chars() {
9253            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9254        }
9255        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9256        assert!(matches!(app.mode, Mode::Detail));
9257
9258        let issue = app
9259            .store
9260            .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9261            .unwrap();
9262        let comments = issue.comments();
9263        assert_eq!(comments.len(), 1);
9264        assert_eq!(comments[0].text.trim(), "hello world");
9265    }
9266
9267    #[test]
9268    fn comment_esc_cancels() {
9269        let (tmp, mut app) = make_app();
9270        let dir = tmp.path().join("issues");
9271        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9272        let config = app.store.config.clone();
9273        app.board = BoardState::build(&app.store, config.default_config());
9274        open_detail(&mut app);
9275        let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9276        for c in "partial".chars() {
9277            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9278        }
9279        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9280        assert!(matches!(app.mode, Mode::Detail));
9281        let issue = app
9282            .store
9283            .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9284            .unwrap();
9285        assert!(issue.comments().is_empty());
9286    }
9287
9288    #[test]
9289    fn comment_empty_discarded() {
9290        let (tmp, mut app) = make_app();
9291        let dir = tmp.path().join("issues");
9292        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9293        let config = app.store.config.clone();
9294        app.board = BoardState::build(&app.store, config.default_config());
9295        open_detail(&mut app);
9296        let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9297        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9298        assert!(matches!(app.mode, Mode::Detail));
9299        let issue = app
9300            .store
9301            .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9302            .unwrap();
9303        assert!(issue.comments().is_empty());
9304        assert!(
9305            app.status_msg
9306                .as_deref()
9307                .is_some_and(|m| m.contains("empty"))
9308        );
9309    }
9310
9311    #[test]
9312    fn f4_a_disclosure_is_composed_with_the_confirmation_not_substituted() {
9313        // Cycle-4 F4. The event loop drains at the top of the loop body, so a
9314        // disclosure raised by `handle_key` at the bottom overwrote that action's
9315        // confirmation before any frame drew it. Reproduced with a real keypress:
9316        // on a `links:`-bearing issue, "Moved #1 to in progress" was assigned and
9317        // then never rendered.
9318        //
9319        // The loop itself needs a real terminal, so it is not covered here; this
9320        // asserts the composition rule the loop delegates to, and the drain
9321        // topology is covered end-to-end in `tests/mcp.rs` for `main` and the
9322        // MCP server.
9323        let (_tmp, mut app) = make_app();
9324        app.status_msg = Some("Moved #1 to in progress".to_string());
9325        app.note_disclosure("legacy-1.md: this write removed the retired `links:` key.".into());
9326        let status = app.status_msg.as_deref().unwrap();
9327        assert!(
9328            status.contains("Moved #1 to in progress"),
9329            "the confirmation must survive: {status}"
9330        );
9331        assert!(
9332            status.contains("retired `links:` key"),
9333            "and the disclosure must be shown: {status}"
9334        );
9335        // Disclosure first, because the actionable instruction is at the *end* of
9336        // a ~160-character string and the status area is a single unwrapped row.
9337        // At 80 columns the tail is what gets cut.
9338        let disclosure_at = status.find("retired").expect("disclosure is present");
9339        let confirmation_at = status.find("Moved #1").expect("confirmation is present");
9340        assert!(
9341            disclosure_at < confirmation_at,
9342            "the disclosure leads, so its actionable tail survives truncation: {status}"
9343        );
9344
9345        // With no prior message the disclosure is shown on its own.
9346        let (_tmp2, mut app2) = make_app();
9347        app2.note_disclosure("alone".into());
9348        assert_eq!(app2.status_msg.as_deref(), Some("alone"));
9349    }
9350
9351    #[test]
9352    fn f4_the_loop_drain_moves_a_raised_disclosure_into_the_status_line() {
9353        // Cycle-5 Major 4. The previous test called `App::note_disclosure`
9354        // directly, so deleting the drain from the event loop left every test
9355        // green — the unit test could not fail for the regression it was named
9356        // for. This goes through `drain_disclosures`, the function the loop
9357        // itself calls, so emptying that function fails here.
9358        let (_tmp, mut app) = make_app();
9359        app.status_msg = Some("Moved #1 to in progress".to_string());
9360
9361        crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
9362        assert_eq!(
9363            crate::storage::take_disclosures().len(),
9364            1,
9365            "precondition: a disclosure is queued and nothing has drained it"
9366        );
9367        // Re-raise it: the line above consumed the queue.
9368        crate::storage::retired_key_disclosure("alice/2026-01-01-x.md");
9369
9370        super::drain_disclosures(&mut app);
9371        let status = app.status_msg.as_deref().unwrap_or_default();
9372        assert!(
9373            status.contains("Moved #1 to in progress"),
9374            "the confirmation survives: {status:?}"
9375        );
9376        assert!(
9377            status.contains("retired"),
9378            "and the loop's drain moved the disclosure in: {status:?}"
9379        );
9380        assert!(
9381            crate::storage::take_disclosures().is_empty(),
9382            "the queue is empty, so the next frame does not repeat it"
9383        );
9384    }
9385
9386    /// Every printable ASCII character can be typed into a free-text prompt.
9387    ///
9388    /// All four of `Comment`, `Asset`, `Filter` and `MoveIntoDone` read arbitrary
9389    /// text into a buffer — a comment body, a **filesystem path**, a filter string, a
9390    /// commit message. `q` used to be bound to `cancel` in all four, copied in from
9391    /// the list-navigation contexts. That is not a style wart: the binding resolves
9392    /// to the action's canonical event (`esc`), so pressing `q` **left the mode**,
9393    /// and every character typed after it was then applied as a command in the mode
9394    /// underneath.
9395    ///
9396    /// For `Asset` the input is a path, so this is reachable from ordinary use: attach
9397    /// an asset from `~/Pictures/q-report.png` and the prompt closes on the `q`, and
9398    /// whatever you type next is executed as detail-view commands. This was found by
9399    /// the `asset_round_trip_via_detail` flake (#1305): that test types the source
9400    /// path through `handle_key`, and the path came from a `tempfile` directory whose
9401    /// six random characters are `[A-Za-z0-9]`, so about one run in forty hit a `q` —
9402    /// and one failure reported `status = "Commented by …"` with no asset written.
9403    ///
9404    /// The test is deliberately **deterministic**: it enumerates the printable ASCII
9405    /// range rather than depending on a random name, so it can never miss the bug it
9406    /// exists to prevent. `esc` is the cancel key, which is already this codebase's
9407    /// convention for text entry (`FilterModal` and `ConfirmDelete` bind `esc` only).
9408    #[test]
9409    fn every_printable_character_is_typeable_in_a_free_text_prompt() {
9410        let (_tmp, app) = make_app();
9411        let free_text = [
9412            InputContext::Comment,
9413            InputContext::Asset,
9414            InputContext::Filter,
9415            InputContext::MoveIntoDone,
9416        ];
9417
9418        // `q` and `Q` are the two that used to be stolen. Asserted by name as well
9419        // as by the sweep below, so a future reader does not have to diff the binding
9420        // table to see what this test is about.
9421        for context in free_text {
9422            for ch in ['q', 'Q'] {
9423                let event = KeyEvent::new(KeyCode::Char(ch), crossterm::event::KeyModifiers::NONE);
9424                assert_ne!(
9425                    app.bindings.action(context, event),
9426                    Some(crate::keybindings::TuiAction::Cancel),
9427                    "{context:?} must be able to type `{ch}`: it is bound to cancel, so \
9428                     pressing it leaves the mode and the rest of the input is applied as \
9429                     commands underneath"
9430                );
9431            }
9432        }
9433
9434        // The sweep: no printable ASCII character may resolve to a binding at all in a
9435        // free-text context. `Esc` and `Enter` are control keys rather than
9436        // characters, so they are out of scope here — `Enter` submits, which is right.
9437        for context in free_text {
9438            let mut stolen: Vec<char> = Vec::new();
9439            for code in 0x20u8..0x7f {
9440                let ch = code as char;
9441                let event = KeyEvent::new(KeyCode::Char(ch), crossterm::event::KeyModifiers::NONE);
9442                if app.bindings.action(context, event).is_some() {
9443                    stolen.push(ch);
9444                }
9445            }
9446            assert!(
9447                stolen.is_empty(),
9448                "{context:?} steals these printable characters, so they cannot be \
9449                 typed: {stolen:?}"
9450            );
9451        }
9452
9453        // `esc` still cancels — the fix moved `q` out, it did not remove the escape
9454        // hatch. Asserted on the real handler, not just the binding table.
9455        let (tmp, mut app) = make_app();
9456        let dir = tmp.path().join("issues");
9457        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9458        app.board = BoardState::build(&app.store, app.store.config.default_config());
9459        open_detail(&mut app);
9460        let _ = app.handle_key(press(KeyCode::Char('c'))).unwrap();
9461        assert!(
9462            matches!(app.mode, Mode::Comment { .. }),
9463            "enter the comment prompt"
9464        );
9465        for c in "has a q in it".chars() {
9466            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9467        }
9468        if let Mode::Comment { ref buffer, .. } = app.mode {
9469            assert_eq!(
9470                buffer, "has a q in it",
9471                "every character reached the buffer"
9472            );
9473        } else {
9474            panic!("the prompt must still be open, got {:?}", app.mode);
9475        }
9476        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9477        assert!(
9478            !matches!(app.mode, Mode::Comment { .. }),
9479            "esc must still cancel, got {:?}",
9480            app.mode
9481        );
9482    }
9483
9484    #[test]
9485    fn asset_round_trip_via_detail() {
9486        let (tmp, mut app) = make_app();
9487        let dir = tmp.path().join("issues");
9488        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9489        let config = app.store.config.clone();
9490        app.board = BoardState::build(&app.store, config.default_config());
9491        open_detail(&mut app);
9492
9493        let asset = tmp.path().join("pic.png");
9494        fs::write(&asset, b"fake-png").unwrap();
9495
9496        let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
9497        assert!(matches!(app.mode, Mode::Asset { .. }));
9498        for c in asset.to_string_lossy().chars() {
9499            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9500        }
9501        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9502        assert!(matches!(app.mode, Mode::Detail));
9503
9504        assert!(
9505            dir.join("a-assets").join("pic.png").is_file(),
9506            "asset must be copied into the issue asset dir"
9507        );
9508        let issue = app
9509            .store
9510            .get(IssueSelector::Filename("2026-01-01-a.md".into()))
9511            .unwrap();
9512        assert!(
9513            issue.body.contains("pic.png"),
9514            "body must reference the asset, got {:?}",
9515            issue.body
9516        );
9517    }
9518
9519    #[test]
9520    fn asset_error_stays_in_mode() {
9521        let (tmp, mut app) = make_app();
9522        let dir = tmp.path().join("issues");
9523        write_issue(&dir, "2026-01-01-a.md", 1, "A", "todo", None);
9524        let config = app.store.config.clone();
9525        app.board = BoardState::build(&app.store, config.default_config());
9526        open_detail(&mut app);
9527        let _ = app.handle_key(press(KeyCode::Char('a'))).unwrap();
9528        for c in "/no/such/file.png".chars() {
9529            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9530        }
9531        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
9532        assert!(
9533            matches!(app.mode, Mode::Asset { .. }),
9534            "asset error must stay in entry mode, got {:?}",
9535            app.mode
9536        );
9537        assert!(app.status_msg.is_some());
9538    }
9539
9540    fn draw_board(app: &App, w: u16, h: u16) -> Vec<String> {
9541        render_to_cells(app, w, h).unwrap()
9542    }
9543
9544    fn flush_line(cells: &[String], width: u16, row: u16) -> String {
9545        let start = (row as usize) * (width as usize);
9546        cells[start..start + width as usize].concat()
9547    }
9548
9549    #[test]
9550    fn render_board_unfiltered_has_no_filter_row() {
9551        let (_tmp, app) = make_app();
9552        let cells = draw_board(&app, 80, 24);
9553        assert!(
9554            !cells.iter().any(|s| s.contains("Filters:")),
9555            "no filter row expected"
9556        );
9557    }
9558
9559    #[test]
9560    fn render_board_filtered_shows_filter_row_beneath_tabs() {
9561        let (_tmp, mut app) = make_app();
9562        app.filter = Some("55".into());
9563        app.board = BoardState::build(&app.store, app.store.config.default_config());
9564        let cells = draw_board(&app, 80, 24);
9565        // Single board: no tab bar. Row 0 = hovered-issue title line; row 1 =
9566        // the filter indicator.
9567        assert!(
9568            flush_line(&cells, 80, 1).contains("Filters: #55"),
9569            "row 1 should read the filter, got {}",
9570            flush_line(&cells, 80, 1)
9571        );
9572    }
9573
9574    #[test]
9575    fn render_board_structured_filter_shows_row() {
9576        let (_tmp, mut app) = make_app();
9577        app.filter = None;
9578        app.structured = search::StructuredFilter {
9579            status: Some("todo".into()),
9580            ..Default::default()
9581        };
9582        app.board = BoardState::build(&app.store, app.store.config.default_config());
9583        let cells = draw_board(&app, 80, 24);
9584        assert!(
9585            flush_line(&cells, 80, 1).contains("Filters:"),
9586            "got {}",
9587            flush_line(&cells, 80, 1)
9588        );
9589    }
9590
9591    #[test]
9592    fn render_board_filter_input_draws_row_and_prompt() {
9593        let (_tmp, mut app) = make_app();
9594        app.mode = Mode::Filter {
9595            buffer: "55".to_string(),
9596        };
9597        app.filter = Some("55".into());
9598        app.board = BoardState::build(&app.store, app.store.config.default_config());
9599        let cells = draw_board(&app, 80, 24);
9600        assert!(
9601            flush_line(&cells, 80, 1).contains("Filters: #55"),
9602            "row 1 should read the filter while typing"
9603        );
9604        assert!(
9605            flush_line(&cells, 80, 23).contains("Filter: 55"),
9606            "bottom row should hold the input prompt"
9607        );
9608    }
9609
9610    #[test]
9611    fn render_board_tiny_terminal_filtered_does_not_panic() {
9612        let (_tmp, mut app) = make_app();
9613        app.filter = Some("55".into());
9614        app.board = BoardState::build(&app.store, app.store.config.default_config());
9615        app.mode = Mode::Filter {
9616            buffer: "55".to_string(),
9617        };
9618        for h in [2_u16, 3, 4, 5] {
9619            let _ = draw_board(&app, 80, h);
9620        }
9621    }
9622
9623    #[test]
9624    fn filter_esc_clears_row_and_resolves_to_board() {
9625        let (tmp, mut app) = make_app();
9626        let dir = tmp.path().join("issues");
9627        write_issue(&dir, "2026-01-01-a.md", 55, "Target", "todo", None);
9628        let config = app.store.config.clone();
9629        app.board = BoardState::build(&app.store, config.default_config());
9630        app.refresh_corpus().unwrap();
9631        let _ = app.handle_key(press(KeyCode::Char('/'))).unwrap();
9632        for c in "55".chars() {
9633            let _ = app.handle_key(press(KeyCode::Char(c))).unwrap();
9634        }
9635        let cells = draw_board(&app, 80, 24);
9636        assert!(
9637            cells.concat().contains("Filters:"),
9638            "filtered board must show the row; filter={:?} cells_row1={}",
9639            app.filter,
9640            flush_line(&cells, 80, 1)
9641        );
9642        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
9643        assert!(matches!(app.mode, Mode::Board));
9644        assert_eq!(app.filter, None);
9645        let after = draw_board(&app, 80, 24).concat();
9646        assert!(
9647            !after.contains("Filters:"),
9648            "esc cleared the filter, row must disappear, got {after:?}"
9649        );
9650    }
9651
9652    #[test]
9653    fn render_once_uses_default_board_configuration() {
9654        let tmp = TempDir::new().unwrap();
9655        let dir = tmp.path().join("issues");
9656        fs::create_dir_all(&dir).unwrap();
9657
9658        let config_path = tmp.path().join("tix.toml");
9659        let config = Config {
9660            issues_dir: dir.clone(),
9661            ..Config::default()
9662        };
9663        config.save(&config_path).unwrap();
9664        let mut config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9665        config.kanban.configurations.clear();
9666        config.kanban.configurations.insert(
9667            "release".to_string(),
9668            crate::config::BoardConfiguration {
9669                columns: vec!["todo".to_string(), "done".to_string()],
9670                done_column: Some("done".to_string()),
9671                category_map: indexmap::IndexMap::new(),
9672                hidden_columns: vec![],
9673                show_mismatch: false,
9674                wrap_titles: true,
9675                title_lines: 2,
9676                card_order: vec![],
9677                order_by: crate::config::OrderBy::CreatedAtDesc,
9678            },
9679        );
9680        config.kanban.configurations.insert(
9681            "default".to_string(),
9682            crate::config::BoardConfiguration {
9683                columns: vec!["backlog".to_string(), "done".to_string()],
9684                done_column: Some("done".to_string()),
9685                category_map: indexmap::IndexMap::new(),
9686                hidden_columns: vec![],
9687                show_mismatch: false,
9688                wrap_titles: true,
9689                title_lines: 2,
9690                card_order: vec![],
9691                order_by: crate::config::OrderBy::CreatedAtDesc,
9692            },
9693        );
9694        config.save(&config_path).unwrap();
9695        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9696
9697        write_issue(&dir, "2026-01-01-a.md", 1, "A", "backlog", None);
9698        let rendered = render_once_at(&config, 80, 24).unwrap();
9699        assert!(
9700            rendered.contains("backlog"),
9701            "rendered board should include default board columns, got:\n{rendered}"
9702        );
9703    }
9704
9705    #[test]
9706    fn render_once_tiny_terminal_is_stable() {
9707        let (tmp, _app) = make_app();
9708        let config_path = tmp.path().join("tix.toml");
9709        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9710        write_issue(
9711            &tmp.path().join("issues"),
9712            "2026-01-01-a.md",
9713            1,
9714            "Alpha",
9715            "todo",
9716            None,
9717        );
9718        let first = render_once_at(&config, 20, 4).unwrap();
9719        let second = render_once_at(&config, 20, 4).unwrap();
9720        assert_eq!(first, second, "tiny render should be deterministic");
9721    }
9722
9723    #[test]
9724    fn render_once_omits_keyboard_hints() {
9725        let (tmp, _app) = make_app();
9726        let config_path = tmp.path().join("tix.toml");
9727        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9728        write_issue(
9729            &tmp.path().join("issues"),
9730            "2026-01-01-a.md",
9731            1,
9732            "Alpha",
9733            "todo",
9734            None,
9735        );
9736        let rendered = render_once_at(&config, 80, 24).unwrap();
9737        for hint in [
9738            "j/k nav",
9739            "n new",
9740            "? help",
9741            "h/l column",
9742            "enter open",
9743            "d delete",
9744        ] {
9745            assert!(
9746                !rendered.contains(hint),
9747                "one-shot output must not contain the keyboard hint {hint:?}:\n{rendered}"
9748            );
9749        }
9750    }
9751
9752    #[test]
9753    fn render_once_reserves_bottom_margin_lines() {
9754        let (tmp, _app) = make_app();
9755        let config_path = tmp.path().join("tix.toml");
9756        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9757        write_issue(
9758            &tmp.path().join("issues"),
9759            "2026-01-01-a.md",
9760            1,
9761            "Alpha",
9762            "todo",
9763            None,
9764        );
9765        let height = 24u16;
9766        let rendered = render_once_at(&config, 80, height).unwrap();
9767        let line_count = rendered.lines().count();
9768        assert!(
9769            line_count <= (height - RENDER_BOTTOM_MARGIN) as usize,
9770            "one-shot render must leave {RENDER_BOTTOM_MARGIN} spare lines, \
9771             got {line_count} lines for a {height}-row terminal:\n{rendered}"
9772        );
9773        assert!(
9774            rendered.lines().any(|l| l.chars().count() == 80),
9775            "one-shot render should still span the full width:\n{rendered}"
9776        );
9777    }
9778
9779    #[test]
9780    fn resolve_render_size_falls_back_for_non_tty() {
9781        assert_eq!(
9782            resolve_render_size(None),
9783            (RENDER_FALLBACK_WIDTH, RENDER_FALLBACK_HEIGHT)
9784        );
9785        assert_eq!(resolve_render_size(Some((0, 0))), (1, 1));
9786    }
9787
9788    // --- Right-click context menu (D5, Spec: 6) ---
9789
9790    /// A two-board config (`default` + `release`) so the tab bar renders.
9791    fn make_multi_board_app() -> (TempDir, App) {
9792        let tmp = TempDir::new().unwrap();
9793        let dir = tmp.path().join("issues");
9794        fs::create_dir_all(&dir).unwrap();
9795        let config_path = tmp.path().join("tix.toml");
9796        let mut config = Config {
9797            issues_dir: dir.clone(),
9798            ..Config::default()
9799        };
9800        config.kanban.configurations.insert(
9801            "release".into(),
9802            crate::config::BoardConfiguration::default(),
9803        );
9804        config.save(&config_path).unwrap();
9805        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
9806        let app = App::new(&config).unwrap();
9807        app.frame_size.set(Rect::new(0, 0, 80, 24));
9808        (tmp, app)
9809    }
9810
9811    #[test]
9812    fn tab_click_switches_board_via_recorded_region() {
9813        use ratatui::Terminal;
9814        use ratatui::backend::TestBackend;
9815
9816        let (_tmp, mut app) = make_multi_board_app();
9817        assert!(app.tabs.needs_bar());
9818        assert_eq!(app.active_config, 0);
9819
9820        let backend = TestBackend::new(80, 24);
9821        let mut terminal = Terminal::new(backend).unwrap();
9822        terminal
9823            .draw(|f| crate::app::render(&app, f, f.area()))
9824            .unwrap();
9825
9826        // The tab bar is the top TAB_HEIGHT rows; click into the second tab.
9827        let tabs_area = Rect::new(0, 0, 80, 2);
9828        let second = app.tabs.tab_rect(1, tabs_area).unwrap();
9829        let action = app.handle_mouse(down_left(second.x + 1, 1)).unwrap();
9830        assert_eq!(
9831            action,
9832            Action::SwitchBoard(1),
9833            "tab click resolves the region"
9834        );
9835
9836        // Replay the run loop's consumption of the action (the loop arm calls
9837        // the same switch_board, so the transition is shared).
9838        if let Action::SwitchBoard(index) = action {
9839            app.switch_board(index);
9840        }
9841        assert_eq!(app.active_config, 1);
9842        assert_eq!(app.tabs.selected, 1);
9843        assert_eq!(app.status_msg.as_deref(), Some("Switched board"));
9844    }
9845
9846    #[test]
9847    fn tab_hover_updates_hover_state_without_switching_board() {
9848        let (_tmp, mut app) = make_multi_board_app();
9849        let tabs_area = Rect::new(0, 0, 80, 2);
9850        let second = app.tabs.tab_rect(1, tabs_area).unwrap();
9851
9852        let action = app.handle_mouse(moved_to(second.x + 1, 0)).unwrap();
9853        assert_eq!(action, Action::None);
9854        assert_eq!(app.active_config, 0);
9855        assert_eq!(app.tabs.selected, 0);
9856        assert_eq!(app.tabs.hovered, Some(1));
9857        assert_eq!(app.board.hover, None);
9858    }
9859
9860    // --- Confirm-delete dialog click targets (D6, Spec: 8) ---
9861
9862    /// Render the app once so recorded hit regions are populated, then return
9863    /// the Yes / No button cell coordinates for the drawn prompt.
9864    fn drawn_confirm_buttons(app: &App) -> ((u16, u16), (u16, u16)) {
9865        let backend = ratatui::backend::TestBackend::new(
9866            app.frame_size.get().width,
9867            app.frame_size.get().height,
9868        );
9869        let mut terminal = ratatui::Terminal::new(backend).unwrap();
9870        terminal
9871            .draw(|f| crate::app::render(app, f, f.area()))
9872            .unwrap();
9873        let prompt = confirm_delete_prompt(app.frame_size.get());
9874        let (yes, no) = confirm_delete_buttons(prompt);
9875        ((yes.x + 2, yes.y), (no.x + 1, no.y))
9876    }
9877
9878    #[test]
9879    fn confirm_delete_yes_click_deletes_issue() {
9880        let (_tmp, mut app) = make_app();
9881        let dir = app.store.config.issues_dir.clone();
9882        write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
9883        app.rebuild_board();
9884        app.frame_size.set(Rect::new(0, 0, 80, 24));
9885        app.confirm_delete_target = Some("a.md".into());
9886        app.mode = Mode::ConfirmDelete;
9887
9888        let ((yes_x, yes_y), _) = drawn_confirm_buttons(&app);
9889        let action = app.handle_mouse(down_left(yes_x, yes_y)).unwrap();
9890        assert_eq!(action, Action::ConfirmDeleteYes);
9891
9892        let _ = app.confirm_delete_yes();
9893        assert_eq!(app.mode, Mode::Board);
9894        assert_eq!(app.confirm_delete_target, None);
9895        assert_eq!(
9896            app.status_msg.as_deref(),
9897            Some("Deleted a.md"),
9898            "Yes click deletes the issue (same branch as the y key)"
9899        );
9900        assert!(
9901            !dir.join("a.md").exists(),
9902            "the target file is actually removed"
9903        );
9904    }
9905
9906    #[test]
9907    fn confirm_delete_no_click_cancels() {
9908        let (_tmp, mut app) = make_app();
9909        let dir = app.store.config.issues_dir.clone();
9910        write_issue(&dir, "a.md", 1, "Alpha", "todo", None);
9911        app.rebuild_board();
9912        app.frame_size.set(Rect::new(0, 0, 80, 24));
9913        app.confirm_delete_target = Some("a.md".into());
9914        app.mode = Mode::ConfirmDelete;
9915
9916        let (_, (no_x, no_y)) = drawn_confirm_buttons(&app);
9917        let action = app.handle_mouse(down_left(no_x, no_y)).unwrap();
9918        assert_eq!(action, Action::ConfirmDeleteNo);
9919
9920        let _ = app.confirm_delete_no();
9921        assert_eq!(app.mode, Mode::Board);
9922        assert_eq!(app.confirm_delete_target, None);
9923        assert!(
9924            dir.join("a.md").exists(),
9925            "No click cancels: the issue is left in place (same branch as Esc)"
9926        );
9927    }
9928
9929    // --- Filter-modal row clicks (D6, Spec: 8) ---
9930
9931    #[test]
9932    fn filter_modal_row_click_moves_focus_to_that_criterion() {
9933        let (_tmp, mut app) = make_app();
9934        app.frame_size.set(Rect::new(0, 0, 80, 24));
9935        app.filter_modal = Some(crate::ui::filter_modal::FilterModal::new());
9936        app.mode = Mode::FilterModal;
9937
9938        let backend = ratatui::backend::TestBackend::new(80, 24);
9939        let mut terminal = ratatui::Terminal::new(backend).unwrap();
9940        terminal
9941            .draw(|f| crate::app::render(&app, f, f.area()))
9942            .unwrap();
9943
9944        // Rows mirror FilterField::ALL in order; click the Status row (index 2).
9945        let rect = crate::ui::modal::modal_rect(Rect::new(0, 0, 80, 24));
9946        let status_row = rect.y + 1 + 2;
9947        let status_field = FilterField::ALL[2];
9948        assert_eq!(status_field, FilterField::Status);
9949
9950        assert_ne!(
9951            app.filter_modal.as_ref().unwrap().focus(),
9952            FilterField::Status
9953        );
9954        let action = app.handle_mouse(down_left(rect.x + 2, status_row)).unwrap();
9955        assert_eq!(
9956            action,
9957            Action::FilterModalFocus(FilterField::Status),
9958            "row click resolves the criterion region"
9959        );
9960        if let Action::FilterModalFocus(field) = action {
9961            app.filter_modal.as_mut().unwrap().focus_field(field);
9962        }
9963        assert_eq!(
9964            app.filter_modal.as_ref().unwrap().focus(),
9965            FilterField::Status,
9966            "clicked row becomes the focused criterion"
9967        );
9968    }
9969
9970    // --- Settings-modal row clicks (D6, Spec: 8) ---
9971
9972    fn drawn_settings_row(app: &App, row: usize) -> (u16, u16) {
9973        let backend = ratatui::backend::TestBackend::new(
9974            app.frame_size.get().width,
9975            app.frame_size.get().height,
9976        );
9977        let mut terminal = ratatui::Terminal::new(backend).unwrap();
9978        terminal
9979            .draw(|f| crate::app::render(app, f, f.area()))
9980            .unwrap();
9981        let rect = crate::ui::modal::modal_rect(app.frame_size.get());
9982        (rect.x + 2, rect.y + 1 + row as u16)
9983    }
9984
9985    #[test]
9986    fn settings_row_click_selects_and_toggles_like_enter() {
9987        let (_tmp, mut app) = make_app();
9988        app.frame_size.set(Rect::new(0, 0, 80, 24));
9989        let board_name = app.active_board_name().to_string();
9990        let col = "in progress".to_string();
9991        app.settings = Some(crate::ui::settings::SettingsModal::new());
9992        app.mode = Mode::Settings;
9993
9994        // Row 1 is the "in progress" column row (columns in order, then the
9995        // DISPLAY_ROWS display rows). Clicking it must select it and run the
9996        // same toggle Enter would: hide the column.
9997        let (x, y) = drawn_settings_row(&app, 1);
9998        let action = app.handle_mouse(down_left(x, y)).unwrap();
9999        assert_eq!(action, Action::SettingsRow(1), "settings row resolves");
10000
10001        if let Action::SettingsRow(row) = action {
10002            let count = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
10003            app.settings.as_mut().unwrap().move_selected_to(row, count);
10004            let _ = app.settings_toggle_selected();
10005        }
10006        assert_eq!(app.settings.as_ref().unwrap().selected, 1);
10007        assert!(
10008            app.store
10009                .config
10010                .kanban
10011                .configurations
10012                .get(&board_name)
10013                .unwrap()
10014                .hidden_columns
10015                .contains(&col),
10016            "clicking the column row runs the Enter toggle (hides the column)"
10017        );
10018    }
10019
10020    #[test]
10021    fn settings_display_row_click_mirrors_entering_browse() {
10022        let (_tmp, mut app) = make_app();
10023        app.frame_size.set(Rect::new(0, 0, 80, 24));
10024        let board_name = app.active_board_name().to_string();
10025        app.settings = Some(crate::ui::settings::SettingsModal::new());
10026        app.mode = Mode::Settings;
10027
10028        // The first display row sits right after the board's columns (index 4).
10029        let display_row = app.active_board_config().columns.len();
10030        let (x, y) = drawn_settings_row(&app, display_row);
10031        let action = app.handle_mouse(down_left(x, y)).unwrap();
10032        assert_eq!(action, Action::SettingsRow(display_row));
10033
10034        if let Action::SettingsRow(row) = action {
10035            let count = app.active_board_config().columns.len() + crate::ui::settings::DISPLAY_ROWS;
10036            app.settings.as_mut().unwrap().move_selected_to(row, count);
10037            let _ = app.settings_toggle_selected();
10038        }
10039        assert_eq!(app.settings.as_ref().unwrap().selected, display_row);
10040        assert_eq!(
10041            app.status_msg.as_deref(),
10042            Some("Wrap titles: off"),
10043            "display row click runs the same edit as Enter on that row"
10044        );
10045        assert!(
10046            !app.store
10047                .config
10048                .kanban
10049                .configurations
10050                .get(&board_name)
10051                .unwrap()
10052                .wrap_titles
10053        );
10054    }
10055
10056    /// The menu items offered for a card in the given column.
10057    fn menu_kinds(app: &App) -> Vec<MenuItemKind> {
10058        app.context_menu
10059            .as_ref()
10060            .expect("menu open")
10061            .items()
10062            .iter()
10063            .map(|item| item.kind.clone())
10064            .collect()
10065    }
10066
10067    #[test]
10068    fn right_click_card_opens_menu_with_all_actions() {
10069        let (_tmp, mut app) = make_mouse_app();
10070        let (x, y) = card_cell(&app, 0, 1);
10071        app.handle_mouse(down_right(x, y)).unwrap();
10072
10073        assert_eq!((app.board.active_col, app.board.selected), (0, 1));
10074        let Some(menu) = app.context_menu.as_ref() else {
10075            panic!("menu not open");
10076        };
10077        assert_eq!(menu.len(), 7, "open, edit, 3 moves, delete, refresh");
10078        let kinds = menu_kinds(&app);
10079        assert_eq!(kinds[0], MenuItemKind::OpenDetail);
10080        assert_eq!(kinds[1], MenuItemKind::EditInEditor);
10081        assert!(matches!(kinds[2], MenuItemKind::MoveTo { column: 1 }));
10082        assert!(matches!(kinds[3], MenuItemKind::MoveTo { column: 2 }));
10083        assert!(matches!(kinds[4], MenuItemKind::MoveTo { column: 3 }));
10084        assert_eq!(kinds[5], MenuItemKind::Delete);
10085        assert_eq!(kinds[6], MenuItemKind::RefreshBoard);
10086        assert!(
10087            menu.item(2).unwrap().label.contains("in progress"),
10088            "move entries are labeled with the target column"
10089        );
10090        assert!(
10091            !kinds
10092                .iter()
10093                .any(|k| matches!(k, MenuItemKind::MoveTo { column: 0 })),
10094            "current column is not offered as a move target"
10095        );
10096        assert!(matches!(app.mode, Mode::ContextMenu { .. }));
10097    }
10098
10099    #[test]
10100    fn right_click_empty_column_space_is_a_noop() {
10101        let (_tmp, mut app) = make_mouse_app();
10102        let (x, y) = column_cell(&app, 1);
10103        app.handle_mouse(down_right(x, y)).unwrap();
10104        assert!(
10105            !matches!(app.mode, Mode::ContextMenu { .. }),
10106            "no menu on empty column space"
10107        );
10108        assert!(app.context_menu.is_none());
10109    }
10110
10111    #[test]
10112    fn context_menu_open_detail_matches_enter_key() {
10113        let (_tmp, mut app) = make_mouse_app();
10114        let (x, y) = card_cell(&app, 0, 1);
10115        let issue_before = app.board.column_cards(0)[1].number;
10116        app.handle_mouse(down_right(x, y)).unwrap();
10117        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10118
10119        assert!(!matches!(app.mode, Mode::ContextMenu { .. }));
10120        assert!(app.context_menu.is_none());
10121        let Some(detail) = &app.detail else {
10122            panic!("menu Open detail should open the detail view");
10123        };
10124        assert_eq!(detail.issue.number, issue_before);
10125
10126        let (_tmp, mut app) = make_mouse_app();
10127        let (x, y) = card_cell(&app, 0, 1);
10128        let _ = app.handle_mouse(down_left(x, y)).unwrap();
10129        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10130        assert_eq!(
10131            app.detail.as_ref().map(|d| d.issue.number),
10132            Some(issue_before),
10133            "menu Open detail and board Enter open the same issue"
10134        );
10135    }
10136
10137    #[test]
10138    fn context_menu_edit_in_editor_matches_e_key() {
10139        let (_tmp, mut app) = make_mouse_app();
10140        let (x, y) = card_cell(&app, 0, 1);
10141        app.handle_mouse(down_right(x, y)).unwrap();
10142        app.handle_key(press(KeyCode::Down)).unwrap(); // select "Edit in editor"
10143        let action = app.handle_key(press(KeyCode::Enter)).unwrap();
10144
10145        let selected = app.board.get_selected_card().cloned().unwrap().filename;
10146        assert_eq!(
10147            action,
10148            Action::OpenEditor(selected.clone()),
10149            "menu Edit in editor returns OpenEditor for the selected card"
10150        );
10151
10152        let (_tmp, mut app) = make_mouse_app();
10153        let (x, y) = card_cell(&app, 0, 1);
10154        let _ = app.handle_mouse(down_left(x, y)).unwrap();
10155        let key_action = app.handle_key(press(KeyCode::Char('e'))).unwrap();
10156        let key_filename = match app.board.get_selected_card() {
10157            Some(card) => card.filename.clone(),
10158            None => String::new(),
10159        };
10160        assert_eq!(
10161            key_action,
10162            Action::OpenEditor(key_filename),
10163            "board 'e' returns OpenEditor for the same card"
10164        );
10165    }
10166
10167    #[test]
10168    fn context_menu_move_to_matches_cardinal_move_key() {
10169        // Menu MoveTo { column: 2 } on the first card must produce the same
10170        // file state as '>' twice (0 -> 1 -> 2) on a matching board.
10171        fn moved_via_menu() -> Status {
10172            let (_tmp, mut app) = make_mouse_app();
10173            let (x, y) = card_cell(&app, 0, 0);
10174            app.handle_mouse(down_right(x, y)).unwrap();
10175            // Select the "Move to in review" (column 2) entry: items are
10176            // [Open detail, Edit in editor, Move to in progress (1),
10177            //  Move to in review (2), Move to done (3), Delete, Refresh].
10178            app.handle_key(press(KeyCode::Down)).unwrap();
10179            app.handle_key(press(KeyCode::Down)).unwrap();
10180            app.handle_key(press(KeyCode::Down)).unwrap();
10181            app.handle_key(press(KeyCode::Enter)).unwrap();
10182            app.store
10183                .get(IssueSelector::Filename("a.md".to_string()))
10184                .unwrap()
10185                .status
10186        }
10187
10188        fn moved_via_keys() -> Status {
10189            let (_tmp, mut app) = make_mouse_app();
10190            let (x, y) = card_cell(&app, 0, 0);
10191            let _ = app.handle_mouse(down_left(x, y)).unwrap();
10192            let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
10193            let _ = app.handle_key(press(KeyCode::Char('>'))).unwrap();
10194            app.store
10195                .get(IssueSelector::Filename("a.md".to_string()))
10196                .unwrap()
10197                .status
10198        }
10199
10200        assert_eq!(moved_via_menu(), moved_via_keys());
10201    }
10202
10203    #[test]
10204    fn context_menu_delete_sets_up_confirm() {
10205        let (_tmp, mut app) = make_mouse_app();
10206        let (x, y) = card_cell(&app, 0, 1);
10207        app.handle_mouse(down_right(x, y)).unwrap();
10208        // Items: [Open detail, Edit, 3x Move, Delete, Refresh] -> Delete is 5.
10209        app.handle_key(press(KeyCode::Down)).unwrap();
10210        app.handle_key(press(KeyCode::Down)).unwrap();
10211        app.handle_key(press(KeyCode::Down)).unwrap();
10212        app.handle_key(press(KeyCode::Down)).unwrap();
10213        app.handle_key(press(KeyCode::Down)).unwrap();
10214        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10215
10216        assert_eq!(app.mode, Mode::ConfirmDelete);
10217        assert_eq!(
10218            app.confirm_delete_target.as_deref(),
10219            Some("b.md"),
10220            "delete targets the right-clicked card"
10221        );
10222        assert!(!matches!(&app.mode, Mode::ContextMenu { .. }));
10223    }
10224
10225    #[test]
10226    fn context_menu_refresh_rebuilds_board() {
10227        let (_tmp, mut app) = make_mouse_app();
10228        let (x, y) = card_cell(&app, 0, 0);
10229        app.handle_mouse(down_right(x, y)).unwrap();
10230        // Refresh Board is the last item (index 6).
10231        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10232        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10233        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10234        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10235        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10236        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10237        app.handle_key(press(KeyCode::Char('j'))).unwrap();
10238        let _ = app.handle_key(press(KeyCode::Enter)).unwrap();
10239
10240        assert_eq!(app.mode, Mode::Board);
10241        assert_eq!(app.status_msg.as_deref(), Some("Refreshed"));
10242        assert!(app.context_menu.is_none());
10243    }
10244
10245    #[test]
10246    fn context_menu_esc_closes_without_side_effects() {
10247        let (_tmp, mut app) = make_mouse_app();
10248        let (card_x, card_y) = card_cell(&app, 0, 1);
10249        app.handle_mouse(down_right(card_x, card_y)).unwrap();
10250        let status_before = app
10251            .store
10252            .get(IssueSelector::Filename("b.md".to_string()))
10253            .unwrap()
10254            .status
10255            .clone();
10256
10257        let _ = app.handle_key(press(KeyCode::Esc)).unwrap();
10258
10259        assert_eq!(app.mode, Mode::Board);
10260        assert!(app.context_menu.is_none());
10261        assert_eq!((app.board.active_col, app.board.selected), (0, 1));
10262        assert!(app.confirm_delete_target.is_none());
10263        assert_eq!(
10264            app.store
10265                .get(IssueSelector::Filename("b.md".to_string()))
10266                .unwrap()
10267                .status,
10268            status_before,
10269            "Esc closes the menu without mutating any issue"
10270        );
10271    }
10272
10273    #[test]
10274    fn context_menu_outside_click_closes_menu() {
10275        let (_tmp, mut app) = make_mouse_app();
10276        let (card_x, card_y) = card_cell(&app, 0, 1);
10277        app.handle_mouse(down_right(card_x, card_y)).unwrap();
10278
10279        // Click far below the board area (outside the popup).
10280        let action = app.handle_mouse(down_left(40, 39)).unwrap();
10281        assert_eq!(action, Action::None);
10282        assert!(app.context_menu.is_none());
10283        assert_eq!(app.mode, Mode::Board);
10284        assert!(
10285            app.mouse.drag.is_none(),
10286            "the dismissing click must not start a drag"
10287        );
10288    }
10289
10290    #[test]
10291    fn context_menu_item_click_activates() {
10292        let (_tmp, mut app) = make_mouse_app();
10293        let (card_x, card_y) = card_cell(&app, 0, 1);
10294        app.handle_mouse(down_right(card_x, card_y)).unwrap();
10295        let menu = app.context_menu.as_ref().unwrap();
10296        let bounds = menu.bounds(app.frame_size.get());
10297        let item_x = bounds.x + 2;
10298        let item_y = bounds.y + 1 + 5;
10299        let _ = app.handle_mouse(down_left(item_x, item_y)).unwrap();
10300
10301        assert_eq!(app.mode, Mode::ConfirmDelete);
10302        assert_eq!(app.confirm_delete_target.as_deref(), Some("b.md"));
10303        assert!(app.context_menu.is_none());
10304    }
10305
10306    #[test]
10307    fn custom_board_binding_replaces_default() {
10308        let tmp = TempDir::new().unwrap();
10309        let dir = tmp.path().join("issues");
10310        fs::create_dir_all(&dir).unwrap();
10311        let mut config = Config {
10312            issues_dir: dir,
10313            ..Config::default()
10314        };
10315        config.keybindings.board.new_issue = Some(vec!["ctrl+n".into()]);
10316        let mut app = App::new(&config).unwrap();
10317        app.handle_key(press(KeyCode::Char('n'))).unwrap();
10318        assert!(matches!(app.mode, Mode::Board));
10319        app.handle_key(press_with_modifiers(
10320            KeyCode::Char('n'),
10321            crossterm::event::KeyModifiers::CONTROL,
10322        ))
10323        .unwrap();
10324        assert!(matches!(app.mode, Mode::Form));
10325    }
10326}