Skip to main content

tix/app/
board_input.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl App {
5    /// Persist the cards whose positions differ from the automatic ordering so
6    /// a drag-reordered column survives rebuilds/restarts without storing a
7    /// full column snapshot.
8    pub(in crate::app) fn persist_card_order(&mut self) -> bool {
9        let board_name = self.active_board_name().to_string();
10        let mut order = Vec::new();
11        let active_config = self.active_board_config().clone();
12        order.extend(
13            self.board
14                .columns
15                .iter()
16                .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
17        );
18        order.extend(
19            self.board
20                .hidden
21                .iter()
22                .flat_map(|col| board::sparse_card_order(&active_config, &col.cards)),
23        );
24        order.extend(board::sparse_card_order(
25            &active_config,
26            &self.board.mismatch.cards,
27        ));
28        let Some(board_config) = self.store.config.kanban.configurations.get_mut(&board_name)
29        else {
30            return false;
31        };
32        board_config.card_order = order;
33        self.persist_config()
34    }
35
36    pub(in crate::app) fn reorder_selected_within_column(
37        &mut self,
38        col: usize,
39        from_row: usize,
40        to_row: usize,
41    ) -> Result<Action> {
42        let (filename, number, column_name) = {
43            let Some(column) = self.board.columns.get_mut(col) else {
44                return Ok(Action::None);
45            };
46            if from_row >= column.cards.len() || to_row >= column.cards.len() || from_row == to_row
47            {
48                return Ok(Action::None);
49            }
50            let card = column.cards.remove(from_row);
51            let filename = card.filename.clone();
52            let number = card.number;
53            let insert_at = to_row;
54            column.cards.insert(insert_at, card);
55            (filename, number, column.name.clone())
56        };
57
58        if !self.persist_card_order() {
59            return Ok(Action::None);
60        }
61        self.rebuild_board();
62        self.select_card_by_filename(&filename);
63        self.status_msg = Some(format!("Reordered #{} in {}", number, column_name));
64        Ok(Action::None)
65    }
66
67    /// Move the selected card within the active column by `delta` slots.
68    pub(in crate::app) fn move_selected_within_column(&mut self, delta: isize) -> Result<Action> {
69        let col = self.board.active_col;
70        let column_name = if col < self.board.columns.len() {
71            self.board.columns[col].name.clone()
72        } else if col == self.board.columns.len() && self.board.show_mismatch {
73            self.board.mismatch.name.clone()
74        } else {
75            return Ok(Action::None);
76        };
77        let (filename, number) = {
78            let cards = if col < self.board.columns.len() {
79                &mut self.board.columns[col].cards
80            } else {
81                &mut self.board.mismatch.cards
82            };
83            let len = cards.len();
84            if len < 2 {
85                return Ok(Action::None);
86            }
87            let from_row = self.board.selected;
88            if from_row >= len {
89                return Ok(Action::None);
90            }
91            let to_row = if delta < 0 {
92                from_row.saturating_sub(delta.unsigned_abs())
93            } else {
94                usize::min(from_row + delta as usize, len - 1)
95            };
96            if to_row == from_row {
97                return Ok(Action::None);
98            }
99            let card = cards.remove(from_row);
100            let filename = card.filename.clone();
101            let number = card.number;
102            let insert_at = to_row;
103            cards.insert(insert_at, card);
104            (filename, number)
105        };
106        if !self.persist_card_order() {
107            return Ok(Action::None);
108        }
109        self.rebuild_board();
110        self.select_card_by_filename(&filename);
111        self.status_msg = Some(format!(
112            "Moved #{} {} in {}",
113            number,
114            if delta < 0 { "up" } else { "down" },
115            column_name
116        ));
117        Ok(Action::None)
118    }
119
120    /// Open the action menu for the card the board is displaying, or for the
121    /// selected card (tix #1744).
122    ///
123    /// When exactly one card is displayed, `Enter` targets **it** wherever the
124    /// cursor happens to be — a `/` search that narrows the board to a single
125    /// issue lands the cursor on an empty column, and requiring a navigation step
126    /// before `Enter` does anything is the bug this fixes. With zero or several
127    /// cards the cursor still decides, exactly as before.
128    pub(in crate::app) fn open_card_action_menu(&mut self) -> Result<Action> {
129        let (col, row) = self
130            .board
131            .sole_visible_card()
132            .unwrap_or((self.board.active_col, self.board.selected));
133        self.mouse.last_down = None;
134        self.open_action_menu(Some(HitTarget::Card { col, row }), false)
135    }
136
137    #[allow(clippy::too_many_lines)]
138    pub(in crate::app) fn handle_board_key(&mut self, key: KeyEvent) -> Result<Action> {
139        if key
140            .modifiers
141            .contains(crossterm::event::KeyModifiers::CONTROL)
142        {
143            match key.code {
144                KeyCode::Up => return self.move_selected_within_column(-1),
145                KeyCode::Down => return self.move_selected_within_column(1),
146                _ => {}
147            }
148        }
149        match key.code {
150            KeyCode::Char('q') | KeyCode::Char('Q') | KeyCode::Esc => {
151                self.quit = true;
152                Ok(Action::Quit)
153            }
154            KeyCode::Tab => {
155                self.tabs.next();
156                self.active_config = self.tabs.selected;
157                self.switch_config();
158                Ok(Action::None)
159            }
160            KeyCode::BackTab => {
161                self.tabs.previous();
162                self.active_config = self.tabs.selected;
163                self.switch_config();
164                Ok(Action::None)
165            }
166            KeyCode::Left | KeyCode::Char('h') => {
167                self.board.move_left();
168                Ok(Action::None)
169            }
170            KeyCode::Right | KeyCode::Char('l') => {
171                self.board.move_right();
172                Ok(Action::None)
173            }
174            KeyCode::Up | KeyCode::Char('k') => {
175                self.board.move_up();
176                Ok(Action::None)
177            }
178            KeyCode::Down | KeyCode::Char('j') => {
179                self.board.move_down();
180                Ok(Action::None)
181            }
182            KeyCode::Enter => self.open_card_action_menu(),
183            KeyCode::Char('e') => {
184                if let Some(card) = self.board.get_selected_card() {
185                    Ok(Action::OpenEditor(card.filename.clone()))
186                } else {
187                    Ok(Action::None)
188                }
189            }
190            // The mentions view (OpenSpec change `issue-mentions`). `i` is the
191            // canonical event of `TuiAction::OpenMentions`, so a rebound chord
192            // arrives here as `i`; the indicator row reads the chord back out of the
193            // effective bindings and advertises whichever one is in force.
194            KeyCode::Char('i') => {
195                self.open_mentions();
196                Ok(Action::None)
197            }
198            // The board's read-only viewer (#1507) is the **shifted** letter: the
199            // plain `v` opens the saved-views modal (WP05) in the arm below, and
200            // the Detail context keeps `v` for the viewer. See the board defaults
201            // in `keybindings.rs` for why `v` went to saved views.
202            KeyCode::Char('V') => {
203                if let Some(card) = self.board.get_selected_card() {
204                    Ok(Action::OpenViewer(card.filename.clone()))
205                } else {
206                    Ok(Action::None)
207                }
208            }
209            KeyCode::Char('A') => {
210                if let Some(card) = self.board.get_selected_card() {
211                    Ok(Action::OpenAgent(card.filename.clone()))
212                } else {
213                    Ok(Action::None)
214                }
215            }
216            KeyCode::Char('c') => {
217                self.toggle_column_visibility();
218                Ok(Action::None)
219            }
220            KeyCode::Char('f') => {
221                self.open_filter_modal();
222                Ok(Action::None)
223            }
224            // WP07 T031: `space` marks a card, `x` unmarks it, `X` empties the
225            // selection. The **board** owns these, not the modal, because the
226            // selection is made by standing on cards; a modal that swallowed the
227            // board's keys would break every existing binding, which T031 forbids.
228            //
229            // Marking deliberately does **not** open the modal: the first `space`
230            // used to raise the batch-edit modal, which made every later mark a
231            // keystroke inside a surface the user did not ask for. The status line
232            // carries the running count instead, and `B` opens the modal when the
233            // user actually wants to act on the set.
234            KeyCode::Char(' ') => {
235                self.toggle_mark_selected();
236                Ok(Action::None)
237            }
238            KeyCode::Char('x') => {
239                self.unmark_selected_card();
240                Ok(Action::None)
241            }
242            KeyCode::Char('X') => {
243                self.clear_marks();
244                Ok(Action::None)
245            }
246            KeyCode::Char('B') => {
247                let action = self
248                    .bulk
249                    .as_ref()
250                    .map(|m| m.action)
251                    .unwrap_or(crate::ui::bulk::BatchAction::Edit);
252                self.open_bulk_modal(action);
253                Ok(Action::None)
254            }
255            KeyCode::Char('<') => {
256                let real_cols = self.board.real_cols();
257                if real_cols > 0 {
258                    let target = if self.board.active_col == 0 || self.board.active_col >= real_cols
259                    {
260                        real_cols - 1
261                    } else {
262                        self.board.active_col - 1
263                    };
264                    self.move_selected_to(target)
265                } else {
266                    Ok(Action::None)
267                }
268            }
269            KeyCode::Char('>') => {
270                let real_cols = self.board.real_cols();
271                if real_cols > 0 {
272                    let target = if self.board.active_col >= real_cols {
273                        0
274                    } else {
275                        (self.board.active_col + 1) % real_cols
276                    };
277                    self.move_selected_to(target)
278                } else {
279                    Ok(Action::None)
280                }
281            }
282            KeyCode::Char('n') => {
283                self.open_new_issue_form();
284                Ok(Action::None)
285            }
286            KeyCode::Char('C') => {
287                self.clone_selected_into_form();
288                Ok(Action::None)
289            }
290            // #1492: show the archived issues too, or hide them again. Off by
291            // default and *reloaded* rather than filtered on demand: the archived
292            // files are not in the corpus until this is on, which is what keeps
293            // opening a board in a repository full of them cheap.
294            KeyCode::Char('a') => {
295                self.show_archived = !self.show_archived;
296                self.refresh_corpus()?;
297                self.status_msg = Some(
298                    if self.show_archived {
299                        "Showing archived issues"
300                    } else {
301                        "Hiding archived issues"
302                    }
303                    .to_string(),
304                );
305                Ok(Action::None)
306            }
307            // #1399: show each issue's sub-issues beneath it, or hide them again.
308            // Display-only: the children are already in the board state, so this
309            // reorders the columns (one level deep) on a rebuild rather than
310            // re-reading the store — the archive toggle's reload would be wasted work.
311            KeyCode::Char('u') => {
312                self.show_children = !self.show_children;
313                self.rebuild_board();
314                self.status_msg = Some(
315                    if self.show_children {
316                        "Showing sub-issues"
317                    } else {
318                        "Hiding sub-issues"
319                    }
320                    .to_string(),
321                );
322                Ok(Action::None)
323            }
324            // tix #1442 (follow-up): a page of cards. The column scrolls itself to keep
325            // the selection visible — the `List` does that — so a page step is a
326            // selection jump by one screen, measured by the renderer.
327            //
328            // These keys were **unbound** on the board until now, and an unbound key in
329            // a non-text context is rewritten to `F(12)` before the handler sees it, so
330            // `PgUp`/`PgDn` did nothing at all while the wheel next to them worked. A
331            // key that silently disappears is worse than one that is not advertised.
332            KeyCode::PageDown => {
333                self.move_selection_by_page(1);
334                Ok(Action::None)
335            }
336            KeyCode::PageUp => {
337                self.move_selection_by_page(-1);
338                Ok(Action::None)
339            }
340            KeyCode::Char('d') => {
341                if let Some(card) = self.board.get_selected_card().cloned() {
342                    self.confirm_delete_target = Some(card.filename);
343                    self.mode = Mode::ConfirmDelete;
344                }
345                Ok(Action::None)
346            }
347            KeyCode::Char('r') => {
348                self.refresh_corpus()?;
349                self.status_msg = Some("Refreshed".to_string());
350                Ok(Action::None)
351            }
352            KeyCode::Char('s') => {
353                self.open_settings(None);
354                Ok(Action::None)
355            }
356            // WP06, T029. `t` was unbound, so this adds a key rather than
357            // changing what an existing one means — the escalation this
358            // mission is told to avoid is an *unrelated* navigation change,
359            // and this one is the triage surface's own entry point.
360            KeyCode::Char('t') => self.open_triage_inbox(),
361            // WP05. `v` was unbound, so this adds a key rather than changing what
362            // an existing one means (T024), and it is rebound through
363            // `keybindings.board.views` like every other board key.
364            KeyCode::Char('v') => {
365                self.open_views();
366                Ok(Action::None)
367            }
368            KeyCode::Char('m') | KeyCode::Char('M') => {
369                self.open_planning_modal();
370                Ok(Action::None)
371            }
372            KeyCode::Char('b') => {
373                self.open_settings(Some(SettingsPrompt::NewBoard(String::new())));
374                Ok(Action::None)
375            }
376            KeyCode::Char('?') => {
377                self.open_help(Mode::Board);
378                Ok(Action::None)
379            }
380            KeyCode::Char('/') => {
381                let buffer = self.filter.clone().unwrap_or_default();
382                self.mode = Mode::Filter { buffer };
383                Ok(Action::None)
384            }
385            _ => Ok(Action::None),
386        }
387    }
388
389    /// Duplicate the selected issue into the create-issue form, prefilled
390    /// (tix #1484). The source is resolved through the same selector path the CLI
391    /// uses, so fields the card projection does not carry are copied too.
392    pub(in crate::app) fn clone_selected_into_form(&mut self) {
393        let Some(card) = self.board.get_selected_card().cloned() else {
394            self.status_msg = Some("No issue selected to clone".to_string());
395            return;
396        };
397        match crate::commands::view::get(&self.store, &card.filename) {
398            Ok(issue) => {
399                self.status_msg = Some(format!("Cloning #{}", issue.number));
400                let entry = self.new_issue_target_status(NewIssueTarget::Triage);
401                let mut form = FormState::from_issue(&issue);
402                if issue.status.to_string().trim() == entry {
403                    form.target = NewIssueTarget::Triage;
404                }
405                self.enter_form(form);
406            }
407            Err(err) => self.status_msg = Some(format!("Cannot clone: {err}")),
408        }
409    }
410
411    /// The single entry point for a visible-column move (`<`/`>`, drag-and-drop).
412    pub(in crate::app) fn move_selected_to(&mut self, target_col: usize) -> Result<Action> {
413        self.start_move(MoveTarget::Visible(target_col))
414    }
415
416    /// Start a move to a named configured column (the action dialog's path).
417    ///
418    /// When the card under the cursor is part of a multi-selection, the move
419    /// covers **every** marked issue; otherwise it is the single card. The batch
420    /// keeps the selection afterwards, so a user can move a set and then apply
421    /// the next action to the same set.
422    pub(in crate::app) fn start_move(&mut self, target: MoveTarget) -> Result<Action> {
423        let Some(card) = self.board.get_selected_card().cloned() else {
424            return Ok(Action::None);
425        };
426        let target_name = target.name(&self.board);
427        let numbers = self.move_numbers(card.number);
428        self.pending_move = Some(PendingMove {
429            numbers,
430            target,
431            target_name,
432            reason: None,
433            acknowledged: false,
434            commit: None,
435        });
436        self.advance_move()
437    }
438
439    /// The issues a move started on `anchor` should cover: the whole marked set
440    /// when the anchor is marked and the set has more than one issue, otherwise
441    /// just the anchor.
442    fn move_numbers(&self, anchor: u32) -> Vec<u32> {
443        match &self.bulk {
444            Some(modal) if modal.selection.len() > 1 && modal.selection.contains(anchor) => {
445                modal.selection.numbers().to_vec()
446            }
447            _ => vec![anchor],
448        }
449    }
450
451    /// Run the shared move with the answers collected so far.
452    ///
453    /// The order is the documented one and matches `commands::mv::run`: the
454    /// blocked-move guard first (an acknowledgement must not be asked for after
455    /// a reason or a commit hash), then the transition policy, then the WIP
456    /// policy, then the commit prompt. Every prompt stores its answer in
457    /// [`App::pending_move`] and calls back here, so no prompt discards another
458    /// prompt's answer.
459    ///
460    /// A batch asks each question **once** and applies the answer to the whole
461    /// set: all items move to the same column, so they share that column's limit
462    /// and one reason/acknowledgement/commit is the same decision for each. The
463    /// per-item outcomes are collected and reported together; a refused item
464    /// never rolls back the ones already applied.
465    #[allow(clippy::too_many_lines)]
466    pub(in crate::app) fn advance_move(&mut self) -> Result<Action> {
467        use crate::wip::WipOutcome;
468        use crate::workflow::{TransitionOutcome, WorkflowCategory};
469
470        let Some(pending) = self.pending_move.clone() else {
471            return Ok(Action::None);
472        };
473        if pending.numbers.is_empty() {
474            self.finish_move();
475            return Ok(Action::None);
476        }
477
478        let target_name = pending.target_name.clone();
479        let (_, resolution) = crate::workflow::active_resolution(&self.store.config);
480        let is_done_target = resolution
481            .resolve(&target_name)
482            .is_some_and(WorkflowCategory::is_complete);
483
484        // Resolve the batch once. A number that no longer resolves is dropped and
485        // reported; the rest still move, which is the same partial-batch rule the
486        // CLI and the batch modal use.
487        let mut issues = Vec::with_capacity(pending.numbers.len());
488        let mut missing = 0usize;
489        for number in &pending.numbers {
490            match self.store.get(IssueSelector::Number(*number)) {
491                Ok(issue) => issues.push(issue),
492                Err(_) => missing += 1,
493            }
494        }
495        if issues.is_empty() {
496            self.finish_move();
497            self.status_msg = Some("Move: none of the selected issues still exist".to_string());
498            return Ok(Action::None);
499        }
500
501        // 1. Blocked-move guard. One acknowledgement covers the batch; the
502        //    decision is recorded per issue by the shared apply.
503        if !pending.acknowledged {
504            let mut blocked = Vec::new();
505            for issue in &issues {
506                let guard =
507                    mv::blocked_move_guard(&self.store.config, &self.store, issue, &target_name)?;
508                if guard.needs_acknowledgement() {
509                    blocked.push(guard.warning().unwrap_or_default());
510                }
511            }
512            if let Some(first) = blocked.first() {
513                let warning = if issues.len() > 1 {
514                    format!(
515                        "{} of {} selected issues are blocked. {first}",
516                        blocked.len(),
517                        issues.len()
518                    )
519                } else {
520                    first.clone()
521                };
522                self.mode = Mode::ConfirmBlockedMove { warning };
523                return Ok(Action::None);
524            }
525        }
526
527        // 2. Transition policy. One reason covers the batch.
528        if pending.reason.is_none() {
529            for issue in &issues {
530                if let TransitionOutcome::Rejected(diagnostic) =
531                    crate::workflow::validate_transition(
532                        issue,
533                        &target_name,
534                        &resolution,
535                        &self.store.config,
536                        None,
537                    )
538                {
539                    self.mode = Mode::TransitionReason {
540                        reason: diagnostic.to_string(),
541                        buffer: String::new(),
542                    };
543                    return Ok(Action::None);
544                }
545            }
546        }
547
548        // 3. WIP policy. Every item targets one column, so one override reason
549        //    authorizes the same limit; asking once is the same decision.
550        if self.pending_wip_override.is_none() {
551            for issue in &issues {
552                if let Some(decision) =
553                    self.wip_decision(issue.number, &target_name, pending.reason.as_deref(), None)
554                    && decision.outcome == WipOutcome::Block
555                {
556                    self.wip_override = Some(crate::ui::wip_override::WipOverridePrompt {
557                        number: issue.number,
558                        count: issues.len(),
559                        target_move: pending.target.clone(),
560                        target: target_name.clone(),
561                        decision,
562                    });
563                    self.mode = Mode::WipOverride {
564                        buffer: String::new(),
565                    };
566                    return Ok(Action::None);
567                }
568            }
569        }
570
571        // 4. Commit prompt. Answered once for the batch; an empty answer records
572        //    no hash on the items that reach completion.
573        if is_done_target && pending.commit.is_none() {
574            let needs_commit = issues.iter().any(|issue| {
575                let was_done = resolution
576                    .resolve(&issue.status.to_string())
577                    .is_some_and(WorkflowCategory::is_complete);
578                !was_done && issue.commit.is_none()
579            });
580            if needs_commit {
581                self.mode = Mode::MoveIntoDone {
582                    buffer: String::new(),
583                };
584                return Ok(Action::None);
585            }
586        }
587
588        // 5. Apply each item with the collected answers.
589        let mut moved = Vec::new();
590        let mut refusals: Vec<(u32, String)> = Vec::new();
591        let mut wip_note: Option<String> = None;
592        for issue in &issues {
593            let was_done = resolution
594                .resolve(&issue.status.to_string())
595                .is_some_and(WorkflowCategory::is_complete);
596            let commit = if was_done {
597                None
598            } else {
599                match &pending.commit {
600                    Some(answered) => answered.clone(),
601                    None => issue.commit.clone(),
602                }
603            };
604
605            let (wip_event, item_note) = match self.wip_decision(
606                issue.number,
607                &target_name,
608                pending.reason.as_deref(),
609                self.pending_wip_override.as_ref(),
610            ) {
611                Some(decision) => match decision.outcome {
612                    WipOutcome::Allow => (None, None),
613                    WipOutcome::Warn => (None, Some(crate::wip::render_decision(&decision))),
614                    WipOutcome::Override => (decision.history.clone(), None),
615                    WipOutcome::Rejected
616                        if decision.diagnostics.first().is_some_and(|diagnostic| {
617                            diagnostic.kind == crate::wip::WipDiagnosticKind::RejectedTransition
618                        }) =>
619                    {
620                        (None, None)
621                    }
622                    WipOutcome::Block | WipOutcome::Rejected => {
623                        refusals.push((issue.number, crate::wip::render_decision(&decision)));
624                        continue;
625                    }
626                },
627                None => (None, None),
628            };
629            if wip_note.is_none() {
630                wip_note = item_note;
631            }
632
633            match mv::apply_guarded(
634                &self.store.config,
635                &self.store,
636                &issue.filename,
637                &target_name,
638                commit.as_deref(),
639                pending.reason.as_deref(),
640                pending.acknowledged,
641                wip_event.as_ref(),
642            ) {
643                Ok(outcome) => {
644                    moved.push((issue.number, outcome.acknowledged));
645                    // The next item's WIP decision counts the column occupancy
646                    // **after** this write. Without the refresh, a batch could
647                    // authorize two items against the same free slot.
648                    self.refresh_corpus()?;
649                }
650                Err(error) => refusals.push((issue.number, error.to_string())),
651            }
652        }
653
654        self.finish_move();
655        self.refresh_corpus()?;
656        if let Some(index) = pending.target.visible_index() {
657            self.board.select_column(index);
658        }
659
660        if pending.numbers.len() == 1 && refusals.is_empty() && missing == 0 {
661            let (number, acknowledged) = moved.first().copied().unwrap_or((0, false));
662            let ack = if acknowledged {
663                ", blocked move acknowledged"
664            } else {
665                ""
666            };
667            let commit_note = match &pending.commit {
668                Some(Some(hash)) => format!(" (commit: {hash}{ack})"),
669                Some(None) => format!(" (no commit recorded{ack})"),
670                None => String::new(),
671            };
672            self.status_msg = Some(match wip_note {
673                Some(note) => format!("{note}\nMoved #{number} to {target_name}{commit_note}"),
674                None => format!("Moved #{number} to {target_name}{commit_note}"),
675            });
676        } else {
677            let mut message = format!(
678                "Moved {} of {} to {target_name}",
679                moved.len(),
680                pending.numbers.len()
681            );
682            if let Some((number, reason)) = refusals.first() {
683                let head = reason.lines().next().unwrap_or_default();
684                message.push_str(&format!("; {} refused (#{number}: {head})", refusals.len()));
685            }
686            if missing > 0 {
687                message.push_str(&format!("; {missing} missing"));
688            }
689            self.status_msg = Some(message);
690        }
691        Ok(Action::None)
692    }
693
694    /// Drop a move that is finished or cancelled, and the WIP authorization it
695    /// may have carried.
696    pub(in crate::app) fn finish_move(&mut self) {
697        self.pending_move = None;
698        self.pending_wip_override = None;
699    }
700
701    /// The reasoned-override prompt's key handler.
702    ///
703    /// Enter accepts a **non-blank** reason; Esc cancels. A blank reason is refused
704    /// rather than accepted, because a whitespace-only string is not a reason and the
705    /// service trims it away — accepting it here would show the user a move that
706    /// then fails at the seam, having already closed the prompt.
707    ///
708    /// Cancelling returns to the board with **nothing** written: no file, no
709    /// `updated` bump, no History entry. The move was never applied, so there is
710    /// nothing to undo.
711    pub(in crate::app) fn handle_transition_reason_key(&mut self, key: KeyEvent) -> Result<Action> {
712        match key.code {
713            KeyCode::Enter => {
714                let reason = match &self.mode {
715                    Mode::TransitionReason { buffer, .. } => buffer.clone(),
716                    _ => String::new(),
717                };
718                if reason.trim().is_empty() {
719                    self.status_msg = Some(
720                        "A reason is required: the decision is recorded in the issue's \
721                         history and has to say why"
722                            .to_string(),
723                    );
724                    return Ok(Action::None);
725                }
726                // Store the reason and resume the shared move: the guard, the
727                // policy check, the commit prompt and the write run again, and the
728                // answers collected by the other prompts are still there.
729                if let Some(pending) = self.pending_move.as_mut() {
730                    pending.reason = Some(reason);
731                }
732                self.mode = Mode::Board;
733                self.advance_move()
734            }
735            KeyCode::Esc => {
736                self.finish_move();
737                self.mode = Mode::Board;
738                Ok(Action::None)
739            }
740            KeyCode::Char(c) => {
741                if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
742                    buffer.push(c);
743                }
744                Ok(Action::None)
745            }
746            KeyCode::Backspace => {
747                if let Mode::TransitionReason { ref mut buffer, .. } = self.mode {
748                    buffer.pop();
749                }
750                Ok(Action::None)
751            }
752            _ => Ok(Action::None),
753        }
754    }
755
756    /// The optional commit prompt for a move into a completion column.
757    ///
758    /// The answer is stored on [`App::pending_move`] and the shared move resumes;
759    /// it no longer writes on its own, which is what dropped the transition reason
760    /// and hard-coded the acknowledgement before.
761    pub(in crate::app) fn handle_move_into_done_key(&mut self, key: KeyEvent) -> Result<Action> {
762        match key.code {
763            KeyCode::Enter => {
764                let buffer = match &self.mode {
765                    Mode::MoveIntoDone { buffer, .. } => buffer.clone(),
766                    _ => String::new(),
767                };
768                let trimmed = buffer.trim();
769                if let Some(pending) = self.pending_move.as_mut() {
770                    pending.commit = Some(if trimmed.is_empty() {
771                        None
772                    } else {
773                        Some(trimmed.to_string())
774                    });
775                }
776                self.mode = Mode::Board;
777                self.advance_move()
778            }
779            KeyCode::Esc => {
780                // The move is abandoned, so the override it may have been authorized
781                // with is abandoned with it: leaving it pending would authorize the
782                // *next* move to that column.
783                self.finish_move();
784                self.mode = Mode::Board;
785                Ok(Action::None)
786            }
787            KeyCode::Char(c) => {
788                if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
789                    buffer.push(c);
790                }
791                Ok(Action::None)
792            }
793            KeyCode::Backspace => {
794                if let Mode::MoveIntoDone { ref mut buffer, .. } = self.mode {
795                    buffer.pop();
796                }
797                Ok(Action::None)
798            }
799            _ => Ok(Action::None),
800        }
801    }
802
803    /// Move the board selection by one screen of cards (tix #1442 follow-up).
804    ///
805    /// A no-op before the first draw (nothing has reported a page yet) and in an empty
806    /// column (the renderer reports zero, because there is nothing to page).
807    pub(in crate::app) fn move_selection_by_page(&mut self, direction: i32) {
808        let page = self.board_page.get();
809        if page == 0 {
810            return;
811        }
812        let step = i32::try_from(page).unwrap_or(i32::MAX);
813        self.board.move_selection_by(step * direction);
814    }
815
816    /// Toggle column visibility on the active column (FR-005/FR-006/NFR-004/
817    /// SC-003). Updates the in-memory config and persists atomically.
818    pub(in crate::app) fn toggle_column_visibility(&mut self) {
819        let col_name = self.board.active_column_name().to_string();
820        let config_name = self.tabs.names[self.active_config].clone();
821        let board_config = self
822            .store
823            .config
824            .kanban
825            .configurations
826            .get_mut(&config_name);
827        let board_config = match board_config {
828            Some(bc) => bc,
829            None => {
830                self.status_msg = Some("toggle: config not found".into());
831                return;
832            }
833        };
834        let now_hidden = if let Some(pos) = board_config
835            .hidden_columns
836            .iter()
837            .position(|h| h == &col_name)
838        {
839            board_config.hidden_columns.remove(pos);
840            false
841        } else {
842            board_config.hidden_columns.push(col_name.clone());
843            true
844        };
845        match self.store.config_path() {
846            Ok(path) => {
847                if let Err(e) = self.store.config.save(&path) {
848                    self.status_msg = Some(format!("toggle save: {e}"));
849                    return;
850                }
851            }
852            Err(e) => {
853                self.status_msg = Some(format!("toggle path: {e}"));
854                return;
855            }
856        }
857        // The user changed visibility by hand, so the applied view's visibility
858        // snapshot stops being the live board's visibility (WP05, T025/C-006) —
859        // dropped **before** the rebuild, because a rebuild that re-applied the
860        // snapshot would make the toggle look like it did nothing. The **marker**
861        // stays: the board is still associated with that view, and the divergence
862        // report now names the difference instead of hiding it.
863        if let Some(session) = self.view_session.as_mut() {
864            session.visibility = false;
865        }
866        self.rebuild_board();
867        // Keep the cursor logically on the toggled column so a second `c`
868        // press re-shows it: visible index when shown, hidden-region index
869        // while hidden. Navigation keys still skip hidden columns (T014).
870        let visible = self.board.columns.len();
871        self.board.active_col = if now_hidden {
872            visible
873                + self
874                    .board
875                    .hidden
876                    .iter()
877                    .position(|c| c.name == col_name)
878                    .unwrap_or_default()
879        } else {
880            self.board
881                .columns
882                .iter()
883                .position(|c| c.name == col_name)
884                .unwrap_or_default()
885        };
886        self.board.clamp_selection();
887        self.status_msg = Some(format!("Toggled '{col_name}' visibility"));
888    }
889
890    /// Switch the active board configuration (Tab/BackTab). Rebuilds the
891    /// board through the same path as `rebuild_board` so filters re-apply to
892    /// the new config's visible columns, resetting `active_col` to the first
893    /// visible column while keeping the selected card if it is still visible.
894    /// Switch to the named board at tab index `i` — the exact Tab/BackTab
895    /// path (set `selected`, then `active_config`, then rebuild via
896    /// [`App::switch_config`]). Used by the mouse-hit `Action::SwitchBoard`
897    /// and the run loop; tabs need a single shared transition.
898    pub(in crate::app) fn switch_board(&mut self, index: usize) {
899        if index < self.tabs.len() {
900            self.tabs.selected = index;
901            self.tabs.hovered = None;
902            self.active_config = index;
903            self.switch_config();
904        }
905    }
906
907    pub(in crate::app) fn switch_config(&mut self) {
908        let selected_filename = self.board.get_selected_card().map(|c| c.filename.clone());
909        self.rebuild_board();
910        self.board.active_col = 0;
911        if let Some(filename) = selected_filename
912            && self
913                .board
914                .column_cards(0)
915                .iter()
916                .any(|c| c.filename == filename)
917        {
918            self.board.selected = self
919                .board
920                .column_cards(0)
921                .iter()
922                .position(|c| c.filename == filename)
923                .unwrap_or(0);
924        } else {
925            self.board.selected = 0;
926        }
927        self.board.clamp_selection();
928        self.status_msg = Some("Switched board".to_string());
929    }
930
931    /// Activate a rail **branch**: switch the shell to the branch's target,
932    /// resolved by the same paths the mode's own controls use.
933    ///
934    /// One method per [`Action`] arm, so a branch click and a branch-only
935    /// keyboard activation (there is none today; branches are pointer-only per
936    /// the open-question-3 decision) share a single transition.
937    pub fn activate_branch(&mut self, action: &Action) -> Result<Action> {
938        match action {
939            Action::SelectBoard(name) => self.select_board_branch(name),
940            Action::SelectCycle(name) => self.select_cycle_branch(name),
941            Action::SelectMilestone(name) => self.select_milestone_branch(name),
942            Action::NewFromTemplate(name) => self.new_from_template(name),
943            _ => Ok(Action::None),
944        }
945    }
946
947    /// Switch the active board to the named configuration and show the Board
948    /// mode (open question 2: the branch switches the mode too, so the result of
949    /// the switch is what the user sees rather than a board changing off-screen).
950    pub(in crate::app) fn select_board_branch(&mut self, name: &str) -> Result<Action> {
951        let index = self
952            .store
953            .config
954            .configs()
955            .position(|(candidate, _)| candidate == name);
956        let Some(index) = index else {
957            self.status_msg = Some(format!("Board '{name}' not found"));
958            return Ok(Action::None);
959        };
960        self.switch_top_mode(TopMode::Board)?;
961        self.switch_board(index);
962        Ok(Action::None)
963    }
964
965    /// Enter the Cycle mode with its cursor on the named cycle.
966    ///
967    /// The index is a position in the mode's **rendered** flat order, the same
968    /// space [`crate::ui::cycle::flat_entries`] and every other cursor write
969    /// use; a position in the config's insertion order would land on a
970    /// different cycle whenever the two disagree, which is most of the time.
971    pub(in crate::app) fn select_cycle_branch(&mut self, name: &str) -> Result<Action> {
972        self.switch_top_mode(TopMode::Cycle)?;
973        let groups = self.cycle_groups();
974        let index = crate::ui::cycle::flat_entries(&groups)
975            .iter()
976            .position(|entry| entry.name == name);
977        if let (Some(index), Some(state)) = (index, self.cycle_state.as_mut()) {
978            state.selected = index;
979        }
980        Ok(Action::None)
981    }
982
983    /// Enter the Milestones mode with its cursor on the named milestone.
984    pub(in crate::app) fn select_milestone_branch(&mut self, name: &str) -> Result<Action> {
985        self.switch_top_mode(TopMode::Milestones)?;
986        let (_cycles, milestones) = self.planning_rows();
987        let index = milestones
988            .iter()
989            .position(|row| row.name == name)
990            .unwrap_or(0);
991        if let Some(modal) = self.planning.as_mut() {
992            modal.segment = PlanningSegment::Milestones;
993            modal.selected = index;
994            modal.prompt = None;
995        }
996        Ok(Action::None)
997    }
998
999    /// Open the New Issue form seeded from the named `[templates.<name>]`
1000    /// record, entering the New Issue mode first (the form is that mode's
1001    /// content).
1002    pub(in crate::app) fn new_from_template(&mut self, name: &str) -> Result<Action> {
1003        self.switch_top_mode(TopMode::NewIssue)?;
1004        self.select_template(name);
1005        Ok(Action::None)
1006    }
1007}