Skip to main content

tix/
keybindings.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
5use serde::de::Error as _;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8use crate::error::{Result, TixError};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
11pub enum InputContext {
12    Board,
13    /// The batch modal (WP07). Its own context so a key means one thing there and
14    /// is not borrowed from the board's — `space` marks a card in a batch and is not
15    /// a board action, and `y` confirms in a batch and is not a board action either.
16    BulkModal,
17    /// The triage inbox and its decision modal (WP06, T029/T031).
18    ///
19    /// Its own context for the reason `BulkModal`'s doc gives:
20    /// `a`/`d`/`u`/`f` decide here and would be board actions there, and the
21    /// inbox is keyboard-driven all the way down. It carries **free text** (a
22    /// decline reason, a duplicate target), so `cancel` is `esc` and *only*
23    /// `esc` — `q` stays a typeable letter for the same reason `LinkPrompt`
24    /// keeps it.
25    Triage,
26    /// The F3 Cycle mode (tui-cycle-mode).
27    ///
28    /// Its own context for the reason `Triage`'s doc gives: `m` opens planning
29    /// management here and would be a board action there, `j`/`k` walk the cycle
30    /// rows, and the mode carries no free text, so `q`/`esc` may close it.
31    Cycle,
32    /// The F5 Milestones mode.
33    ///
34    /// Its own context for the reason `Cycle`'s doc gives: `m` opens planning
35    /// management here, `j`/`k` walk the rows, and it carries no free text, so
36    /// `q`/`esc` may close it.
37    Milestones,
38    /// The F6 Repos mode (tix #1529).
39    ///
40    /// Its own context for the reason `Cycle`'s doc gives: `j`/`k` walk the
41    /// repository rows, `Enter` runs tix in the selected one, and it carries no
42    /// free text, so `q`/`esc` may close it.
43    Repos,
44    Rollover,
45    Detail,
46    Form,
47    FormPicker,
48    ConfirmDelete,
49    MoveIntoDone,
50    /// The blocked-move acknowledgement prompt (WP04). Its own context so the
51    /// command bar can advertise `y`/`esc` and nothing else.
52    ConfirmBlockedMove,
53    Help,
54    /// The keybindings overlay's `/` input line (tix #1496).
55    ///
56    /// Its own context for the reason [`InputContext::Filter`] is one: it reads **free
57    /// text**. Keeping the list mode's `q`/`?` bound to cancel here would rewrite them
58    /// to `esc` before the handler saw them, and a query could never contain a `q` —
59    /// the same trap the free-text modes below document.
60    HelpSearch,
61    ContextMenu,
62    /// The detail pane's `related_issues` picker (#1513).
63    ///
64    /// Its own context so the picker's `Enter` selects a related entry and does
65    /// not borrow any detail-pane key. It carries no free text, so `cancel` is
66    /// `Esc`; an unbound letter is a no-op rather than a filter character.
67    RelatedPicker,
68    Filter,
69    FilterModal,
70    Settings,
71    SettingsPrompt,
72    PlanningManagement,
73    PlanningConfirm,
74    PlanningForm,
75    DatePicker,
76    Comment,
77    Asset,
78    /// The editor round trip's repair prompt (tix #1506).
79    ///
80    /// Its own context: every key here is a decision about a file the editor just
81    /// produced — apply the proposed fix, declare the key, reopen the editor on
82    /// the retained copy, acknowledge and keep it, or discard it behind a second
83    /// confirmation. It is the successor to #1506's `edit_warning` context, which
84    /// the same prompt grew out of; there is deliberately only one such prompt.
85    EditRepair,
86    /// The edit-conflict prompt (tix #1510).
87    ///
88    /// Its own context for the reason [`InputContext::EditRepair`] has one: it is
89    /// a modal decision with three distinct outcomes, and one of them — *compare* —
90    /// reopens the editor, which no board key does. It carries no free text, so a
91    /// letter could close it — but only `Esc` (bound to `ignore`) does, because the
92    /// prompt closes through a decision and nothing else. `q` stays unbound.
93    EditConflict,
94    /// The two-step create/edit relationship prompt (#1359).
95    ///
96    /// Free text, so `cancel` is `Esc` and **only** `Esc` — see the note on the
97    /// `Asset`/`Comment`/`Filter` group for why binding `q` here would be a real
98    /// defect rather than a preference: a title fragment containing `q` is ordinary.
99    LinkPrompt,
100    /// The top-level mode shell (tui-mode-navigation).
101    ///
102    /// It binds only the five mode switches and the navigation toggle. It is
103    /// consulted **after** the active top-level context (Board, Triage,
104    /// PlanningManagement, Settings, Form) declines the pressed key, so a
105    /// context that binds the key locally always wins — which is what keeps
106    /// `F2` as `open_picker` inside a form and the `F(12)` sentinel untouched
107    /// for a truly unbound key.
108    Shell,
109}
110
111impl InputContext {
112    pub const ALL: &'static [Self] = &[
113        Self::Board,
114        Self::Rollover,
115        Self::Detail,
116        Self::Form,
117        Self::FormPicker,
118        Self::ConfirmDelete,
119        Self::MoveIntoDone,
120        Self::ConfirmBlockedMove,
121        Self::Help,
122        Self::HelpSearch,
123        Self::ContextMenu,
124        // Walked like every other context so the overlay's completeness guard
125        // sees the related picker's keys too (#1513).
126        Self::RelatedPicker,
127        Self::Filter,
128        Self::FilterModal,
129        Self::BulkModal,
130        Self::Triage,
131        Self::Cycle,
132        Self::Milestones,
133        // Walked like every other context so the overlay's guard sees the Repos
134        // mode's keys too (tix #1529).
135        Self::Repos,
136        Self::Settings,
137        Self::SettingsPrompt,
138        Self::PlanningManagement,
139        Self::PlanningConfirm,
140        Self::PlanningForm,
141        Self::DatePicker,
142        Self::Comment,
143        Self::Asset,
144        Self::EditRepair,
145        // Walked like every other context so the overlay's completeness guard
146        // sees the conflict prompt's keys too (tix #1510).
147        Self::EditConflict,
148        // Free text like the others, and missing from this list until the overlay's
149        // completeness guard needed it: a context the guard does not walk is a context
150        // whose keys can go undocumented.
151        Self::LinkPrompt,
152        // The mode shell's switches, walked like every other context so the
153        // completeness guard can see them.
154        Self::Shell,
155    ];
156
157    pub const fn as_str(self) -> &'static str {
158        match self {
159            Self::Board => "board",
160            Self::Rollover => "rollover",
161            Self::Detail => "detail",
162            Self::Form => "form",
163            Self::FormPicker => "form_picker",
164            Self::ConfirmDelete => "confirm_delete",
165            Self::ConfirmBlockedMove => "confirm_blocked_move",
166            Self::MoveIntoDone => "move_into_done",
167            Self::Help => "help",
168            Self::HelpSearch => "help_search",
169            Self::ContextMenu => "context_menu",
170            Self::RelatedPicker => "related_picker",
171            Self::Filter => "filter",
172            Self::FilterModal => "filter_modal",
173            Self::BulkModal => "bulk_modal",
174            Self::Settings => "settings",
175            Self::SettingsPrompt => "settings_prompt",
176            Self::PlanningManagement => "planning_management",
177            Self::PlanningConfirm => "planning_confirm",
178            Self::PlanningForm => "planning_form",
179            Self::DatePicker => "date_picker",
180            Self::Comment => "comment",
181            Self::Asset => "asset",
182            Self::EditRepair => "edit_repair",
183            Self::EditConflict => "edit_conflict",
184            Self::LinkPrompt => "link_prompt",
185            Self::Triage => "triage",
186            Self::Cycle => "cycle",
187            Self::Milestones => "milestones",
188            Self::Repos => "repos",
189            Self::Shell => "shell",
190        }
191    }
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "snake_case")]
196pub enum TuiAction {
197    MoveUp,
198    MoveDown,
199    MoveLeft,
200    MoveRight,
201    NextBoard,
202    PreviousBoard,
203    Open,
204    Edit,
205    Agent,
206    /// Open the selected issue in a read-only viewer (tix #1507).
207    ///
208    /// `v` was free on both the board and the detail pane. Read-only by
209    /// construction: the viewer runs on the issue file and never writes back.
210    View,
211    ToggleColumn,
212    FilterModal,
213    BulkModal,
214    /// Open the triage inbox (WP06, T029). Its own action rather than a chord
215    /// bolted onto another one, so `t` can be rebound from `keybindings.board`
216    /// like every other board key and the help overlay can name it.
217    Triage,
218    Mark,
219    Unmark,
220    MovePrevious,
221    MoveNext,
222    NewIssue,
223    /// Duplicate the selected issue into the create-issue form, prefilled from it
224    /// (tix #1484).
225    ///
226    /// `C`, the capital of `c` (toggle column): a pair that reads together without
227    /// changing what any existing key means, which is the same rule `B`/`A` follow.
228    CloneIssue,
229    /// Show the archived issues on the board, or hide them again (tix #1492).
230    ///
231    /// `a` was unbound on the board. The toggle is a *reload*, not a display
232    /// filter: the archived files are not read until it is on.
233    ToggleArchived,
234    /// Show each issue's sub-issues beneath it on the board, or hide them again
235    /// (tix #1399).
236    ///
237    /// `u` was unbound on the board. Unlike the archive toggle this is display-only:
238    /// the children are already in the board state, so flipping it **reorders** the
239    /// column (one level deep) rather than re-reading the store.
240    ToggleChildren,
241    Delete,
242    Refresh,
243    Settings,
244    Planning,
245    NewBoard,
246    Help,
247    Quit,
248    TextFilter,
249    ReorderUp,
250    ReorderDown,
251    Close,
252    ScrollUp,
253    ScrollDown,
254    Comment,
255    Asset,
256    /// Open the create-relationship prompt from the detail view (#1359).
257    NewRelationship,
258    /// Re-kind or retarget the relationship under the detail pane's cursor.
259    EditRelationship,
260    /// Remove the relationship under the cursor, after confirmation.
261    RemoveRelationship,
262    /// Set, change or clear the open issue's parent from the detail pane
263    /// (tix #1384).
264    ///
265    /// `P` was free in the detail pane. The prompt routes its write through the
266    /// shared hierarchy service (`Store::set_parent`/`clear_parent`), which is
267    /// the same seam `tix edit --parent` uses, so the TUI gains the
268    /// set/change/clear parity for `parent` that the create-only form could not
269    /// express.
270    EditParent,
271    /// Move the detail pane's relationship cursor.
272    NextRelationship,
273    PreviousRelationship,
274    /// Open the picker listing the detail pane's `related_issues` entries
275    /// (tix #1513).
276    ///
277    /// `r` was free in the detail pane — the board's `r` is `refresh`, a
278    /// different context, so the two never collide.
279    RelatedPicker,
280    /// Follow the detail pane's selected `related_issues` entry (tix #1513).
281    ///
282    /// `o` was free in the detail pane and reads as "open".
283    FollowRelated,
284    /// Pop the detail pane's navigation stack and restore the previous issue
285    /// (tix #1513).
286    ///
287    /// `Backspace` was free in the detail pane and reads as "back".
288    NavBack,
289    Submit,
290    Cancel,
291    Next,
292    Previous,
293    OpenPicker,
294    Reset,
295    Toggle,
296    AddColumn,
297    NewPlanningItem,
298    EditPlanningItem,
299    DeletePlanningItem,
300    Confirm,
301    Create,
302    PreviousYear,
303    NextYear,
304    PreviousMonth,
305    NextMonth,
306    Reedit,
307    Ignore,
308    /// Declare the repair prompt's undeclared frontmatter key in `tix.toml`
309    /// (tix #1506).
310    ///
311    /// Its own action, not `confirm`: `confirm` applies whatever fix the prompt
312    /// proposes, which *happens* to be the declaration for an unknown key, while
313    /// `declare_key` always means "write it into `[custom_attributes]`" and says
314    /// so when there is no key to declare.
315    DeclareKey,
316    /// Discard the repair prompt's retained copy, after a second confirmation
317    /// (tix #1506).
318    ///
319    /// The **only** action that removes the editor's copy. The first press arms
320    /// the prompt; a second press is the decision. `Esc` deliberately
321    /// acknowledges and keeps the copy instead, so a stray key cannot destroy a
322    /// user's edit.
323    Discard,
324    /// Switch the top-level shell to the Board mode (F1 by default).
325    SwitchToBoard,
326    /// Switch the top-level shell to the Triage mode (F2 by default).
327    SwitchToTriage,
328    /// Switch the top-level shell to the Cycle mode (F4 by default).
329    SwitchToCycle,
330    /// Switch the top-level shell to the Milestones mode (F5 by default).
331    SwitchToMilestones,
332    /// Switch the top-level shell to the Repos mode (F6 by default, tix #1529).
333    ///
334    /// F6 was free in the shell and sits directly after the milestone switch, so
335    /// the repository list reads as the last of the managed registries.
336    SwitchToRepos,
337    /// Switch the top-level shell to the New Issue mode (F1 by default).
338    SwitchToNewIssue,
339    /// Switch the top-level shell to the Config mode (F12 by default).
340    SwitchToConfig,
341    /// Cycle the vertical navigation display state `Off`/`Small`/`Default`.
342    ToggleNavigation,
343}
344
345impl TuiAction {
346    pub const fn as_str(self) -> &'static str {
347        match self {
348            Self::MoveUp => "move_up",
349            Self::MoveDown => "move_down",
350            Self::MoveLeft => "move_left",
351            Self::MoveRight => "move_right",
352            Self::NextBoard => "next_board",
353            Self::PreviousBoard => "previous_board",
354            Self::Open => "open",
355            Self::Edit => "edit",
356            Self::Agent => "agent",
357            Self::View => "view",
358            Self::ToggleColumn => "toggle_column",
359            Self::FilterModal => "filter_modal",
360            Self::BulkModal => "bulk_modal",
361            Self::Triage => "triage",
362            Self::Mark => "mark",
363            Self::Unmark => "unmark",
364            Self::MovePrevious => "move_previous",
365            Self::MoveNext => "move_next",
366            Self::NewIssue => "new_issue",
367            Self::CloneIssue => "clone_issue",
368            Self::ToggleArchived => "toggle_archived",
369            Self::ToggleChildren => "toggle_children",
370            Self::Delete => "delete",
371            Self::Refresh => "refresh",
372            Self::Settings => "settings",
373            Self::Planning => "planning",
374            Self::NewBoard => "new_board",
375            Self::Help => "help",
376            Self::Quit => "quit",
377            Self::TextFilter => "text_filter",
378            Self::ReorderUp => "reorder_up",
379            Self::ReorderDown => "reorder_down",
380            Self::Close => "close",
381            Self::ScrollUp => "scroll_up",
382            Self::ScrollDown => "scroll_down",
383            Self::Comment => "comment",
384            Self::Asset => "asset",
385            Self::NewRelationship => "new_relationship",
386            Self::EditRelationship => "edit_relationship",
387            Self::RemoveRelationship => "remove_relationship",
388            Self::EditParent => "edit_parent",
389            Self::NextRelationship => "next_relationship",
390            Self::PreviousRelationship => "previous_relationship",
391            Self::RelatedPicker => "related_picker",
392            Self::FollowRelated => "follow_related",
393            Self::NavBack => "nav_back",
394            Self::Submit => "submit",
395            Self::Cancel => "cancel",
396            Self::Next => "next",
397            Self::Previous => "previous",
398            Self::OpenPicker => "open_picker",
399            Self::Reset => "reset",
400            Self::Toggle => "toggle",
401            Self::AddColumn => "add_column",
402            Self::NewPlanningItem => "new_planning_item",
403            Self::EditPlanningItem => "edit_planning_item",
404            Self::DeletePlanningItem => "delete_planning_item",
405            Self::Confirm => "confirm",
406            Self::Create => "create",
407            Self::PreviousYear => "previous_year",
408            Self::NextYear => "next_year",
409            Self::PreviousMonth => "previous_month",
410            Self::NextMonth => "next_month",
411            Self::Reedit => "reedit",
412            Self::Ignore => "ignore",
413            Self::DeclareKey => "declare_key",
414            Self::Discard => "discard",
415            Self::SwitchToBoard => "switch_to_board",
416            Self::SwitchToTriage => "switch_to_triage",
417            Self::SwitchToCycle => "switch_to_cycle",
418            Self::SwitchToMilestones => "switch_to_milestones",
419            Self::SwitchToRepos => "switch_to_repos",
420            Self::SwitchToNewIssue => "switch_to_new_issue",
421            Self::SwitchToConfig => "switch_to_config",
422            Self::ToggleNavigation => "toggle_navigation",
423        }
424    }
425
426    pub const fn description(self) -> &'static str {
427        match self {
428            Self::MoveUp => "move up",
429            Self::MoveDown => "move down",
430            Self::MoveLeft => "previous column",
431            Self::MoveRight => "next column",
432            Self::NextBoard => "next board",
433            Self::PreviousBoard => "previous board",
434            Self::Open => "open detail",
435            Self::Edit => "edit",
436            Self::Agent => "open agent",
437            Self::View => "view issue",
438            Self::ToggleColumn => "toggle column",
439            Self::FilterModal => "filter criteria",
440            Self::BulkModal => "batch select",
441            Self::Triage => "triage inbox",
442            Self::Mark => "mark for batch",
443            Self::Unmark => "unmark for batch",
444            Self::MovePrevious => "move to previous column",
445            Self::MoveNext => "move to next column",
446            Self::NewIssue => "new issue",
447            Self::CloneIssue => "clone issue",
448            Self::ToggleArchived => "show archived issues",
449            Self::ToggleChildren => "toggle sub-issues",
450            Self::Delete => "delete",
451            Self::Refresh => "refresh",
452            Self::Settings => "settings",
453            Self::Planning => "planning",
454            Self::NewBoard => "new board",
455            Self::Help => "help",
456            Self::Quit => "quit",
457            Self::TextFilter => "search",
458            Self::ReorderUp => "reorder up",
459            Self::ReorderDown => "reorder down",
460            Self::Close => "close",
461            Self::ScrollUp => "scroll up one page",
462            Self::ScrollDown => "scroll down one page",
463            Self::Comment => "comment",
464            Self::Asset => "asset",
465            Self::NewRelationship => "new relationship",
466            Self::EditRelationship => "edit relationship",
467            Self::RemoveRelationship => "remove relationship",
468            Self::EditParent => "set parent",
469            Self::NextRelationship => "next relationship",
470            Self::PreviousRelationship => "previous relationship",
471            Self::RelatedPicker => "related issues",
472            Self::FollowRelated => "follow related issue",
473            Self::NavBack => "back",
474            Self::Submit => "submit",
475            Self::Cancel => "cancel",
476            Self::Next => "next",
477            Self::Previous => "previous",
478            Self::OpenPicker => "open picker",
479            Self::Reset => "reset",
480            Self::Toggle => "toggle",
481            Self::AddColumn => "add column",
482            Self::NewPlanningItem => "new",
483            Self::EditPlanningItem => "edit",
484            Self::DeletePlanningItem => "delete",
485            Self::Confirm => "confirm",
486            Self::Create => "create",
487            Self::PreviousYear => "previous year",
488            Self::NextYear => "next year",
489            Self::PreviousMonth => "previous month",
490            Self::NextMonth => "next month",
491            Self::Reedit => "re-edit",
492            Self::Ignore => "ignore",
493            Self::DeclareKey => "declare the key in tix.toml",
494            Self::Discard => "discard the edit",
495            Self::SwitchToBoard => "board mode",
496            Self::SwitchToTriage => "triage mode",
497            Self::SwitchToCycle => "cycle mode",
498            Self::SwitchToMilestones => "milestones mode",
499            Self::SwitchToRepos => "repos mode",
500            Self::SwitchToNewIssue => "new issue mode",
501            Self::SwitchToConfig => "config mode",
502            Self::ToggleNavigation => "toggle navigation",
503        }
504    }
505
506    /// The wording this action gets in `context`'s help.
507    ///
508    /// `move_left`/`move_right` walk a **column** on the board, the Config
509    /// mode's two **panes** (rail ⇄ body), and a **group** in the Cycle
510    /// listing; `submit` steps into the body and then activates the row there.
511    /// The action — and the key — is the same event, so only the noun the help
512    /// shows changes. Every other action keeps its single description.
513    pub const fn description_in(self, context: InputContext) -> &'static str {
514        match (context, self) {
515            (InputContext::Cycle, Self::MoveLeft) => "previous group",
516            (InputContext::Cycle, Self::MoveRight) => "next group",
517            (InputContext::Settings, Self::MoveLeft) => "previous pane",
518            (InputContext::Settings, Self::MoveRight) => "next pane",
519            (InputContext::Settings, Self::Submit) => "step into the body / activate row",
520            // #1506. Every key in the repair prompt is a decision about the file
521            // the editor just produced, and "confirm"/"ignore" say nothing about
522            // which one — the overlay has to spell out each.
523            (InputContext::EditRepair, Self::Confirm) => "apply the proposed fix",
524            (InputContext::EditRepair, Self::DeclareKey) => "declare the key in tix.toml",
525            (InputContext::EditRepair, Self::Reedit) => "reopen the editor on the retained copy",
526            (InputContext::EditRepair, Self::Ignore) => "acknowledge (keeps the copy)",
527            (InputContext::EditRepair, Self::Discard) => "discard the edit (asks again)",
528            // #1510. The conflict prompt reuses the edit warning's three actions:
529            // each is the same *kind* of decision, so what the key means here is
530            // what the wording has to spell out.
531            (InputContext::EditConflict, Self::Confirm) => "overwrite the file with this edit",
532            (InputContext::EditConflict, Self::Ignore) => "keep the on-disk version",
533            (InputContext::EditConflict, Self::Reedit) => "compare in the editor",
534            _ => self.description(),
535        }
536    }
537}
538
539pub type Binding = Vec<String>;
540
541macro_rules! partial_bindings {
542    ($($field:ident),+ $(,)?) => {
543        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
544        #[serde(default, deny_unknown_fields)]
545        pub struct ActionBindings {
546            $(
547                #[serde(skip_serializing_if = "Option::is_none")]
548                pub $field: Option<Binding>,
549            )+
550        }
551    };
552}
553
554partial_bindings!(
555    move_up,
556    move_down,
557    move_left,
558    move_right,
559    next_board,
560    previous_board,
561    open,
562    edit,
563    agent,
564    view,
565    toggle_column,
566    filter_modal,
567    bulk_modal,
568    triage,
569    mark,
570    unmark,
571    move_previous,
572    move_next,
573    new_issue,
574    clone_issue,
575    toggle_archived,
576    toggle_children,
577    delete,
578    refresh,
579    settings,
580    planning,
581    new_board,
582    help,
583    quit,
584    text_filter,
585    reorder_up,
586    reorder_down,
587    close,
588    scroll_up,
589    scroll_down,
590    comment,
591    asset,
592    edit_parent,
593    submit,
594    cancel,
595    next,
596    previous,
597    open_picker,
598    reset,
599    toggle,
600    add_column,
601    new_planning_item,
602    edit_planning_item,
603    delete_planning_item,
604    confirm,
605    create,
606    previous_year,
607    next_year,
608    previous_month,
609    next_month,
610    reedit,
611    ignore,
612    declare_key,
613    discard,
614    switch_to_board,
615    switch_to_triage,
616    switch_to_cycle,
617    switch_to_milestones,
618    switch_to_repos,
619    switch_to_new_issue,
620    switch_to_config,
621    toggle_navigation,
622);
623
624macro_rules! keybindings_config {
625    ($($field:ident => $context:ident),+ $(,)?) => {
626        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
627        #[serde(default, deny_unknown_fields)]
628        pub struct KeyBindings {
629            $(
630                #[serde(skip_serializing_if = "ActionBindings::is_empty")]
631                pub $field: ActionBindings,
632            )+
633        }
634
635        impl KeyBindings {
636            pub fn is_empty(&self) -> bool {
637                self == &Self::default()
638            }
639
640            fn overrides(&self, context: InputContext) -> &ActionBindings {
641                match context {
642                    $(InputContext::$context => &self.$field,)+
643                }
644            }
645        }
646    };
647}
648
649keybindings_config!(
650    board => Board,
651    rollover => Rollover,
652    detail => Detail,
653    form => Form,
654    form_picker => FormPicker,
655    confirm_delete => ConfirmDelete,
656    confirm_blocked_move => ConfirmBlockedMove,
657    help_search => HelpSearch,
658    move_into_done => MoveIntoDone,
659    help => Help,
660    context_menu => ContextMenu,
661    // #1513: the related-issues picker's own section, so its keys are rebindable
662    // through `[keybindings.related_picker]` like every other context's.
663    related_picker => RelatedPicker,
664    filter => Filter,
665    filter_modal => FilterModal,
666    bulk_modal => BulkModal,
667    triage => Triage,
668    cycle => Cycle,
669    milestones => Milestones,
670    // tix #1529: the F6 Repos mode's own section, so its keys are rebindable
671    // through `[keybindings.repos]` like every other context's.
672    repos => Repos,
673    settings => Settings,
674    settings_prompt => SettingsPrompt,
675    planning_management => PlanningManagement,
676    planning_confirm => PlanningConfirm,
677    planning_form => PlanningForm,
678    date_picker => DatePicker,
679    comment => Comment,
680    asset => Asset,
681    edit_repair => EditRepair,
682    edit_conflict => EditConflict,
683    link_prompt => LinkPrompt,
684    shell => Shell,
685);
686
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
688pub struct KeyChord {
689    pub code: KeyCode,
690    pub modifiers: KeyModifiers,
691}
692
693impl KeyChord {
694    pub fn parse(value: &str) -> std::result::Result<Self, String> {
695        let value = value.trim();
696        if value.is_empty() {
697            return Err("key chord must not be empty".into());
698        }
699        if value.chars().count() == 1 {
700            let ch = value.chars().next().expect("value has one character");
701            if ch.is_control() {
702                return Err(format!("unsupported key name `{value}`"));
703            }
704            let modifiers = if ch.is_ascii_uppercase() {
705                KeyModifiers::SHIFT
706            } else {
707                KeyModifiers::NONE
708            };
709            return Ok(Self {
710                code: KeyCode::Char(ch),
711                modifiers,
712            });
713        }
714        let mut parts = value.split('+');
715        let key = parts.next_back().ok_or("missing key name")?.trim();
716        let mut modifiers = KeyModifiers::NONE;
717        for part in parts {
718            match part.trim().to_ascii_lowercase().as_str() {
719                "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
720                "alt" | "option" => modifiers |= KeyModifiers::ALT,
721                "shift" => modifiers |= KeyModifiers::SHIFT,
722                "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
723                other => return Err(format!("unsupported modifier `{other}`")),
724            }
725        }
726        if key.is_empty() {
727            return Err("missing key name".into());
728        }
729        let mut code = match key.to_ascii_lowercase().as_str() {
730            "backtab" => return Err("use shift+tab instead of `backtab`".into()),
731            "shift+tab" => KeyCode::BackTab,
732            "backspace" => KeyCode::Backspace,
733            "tab" => KeyCode::Tab,
734            "enter" | "return" => KeyCode::Enter,
735            "esc" | "escape" => KeyCode::Esc,
736            "space" => KeyCode::Char(' '),
737            "left" => KeyCode::Left,
738            "right" => KeyCode::Right,
739            "up" => KeyCode::Up,
740            "down" => KeyCode::Down,
741            "home" => KeyCode::Home,
742            "end" => KeyCode::End,
743            "pageup" | "pgup" => KeyCode::PageUp,
744            "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
745            "delete" | "del" => KeyCode::Delete,
746            "insert" | "ins" => KeyCode::Insert,
747            _ => {
748                if let Some(number) = key
749                    .to_ascii_lowercase()
750                    .strip_prefix('f')
751                    .and_then(|n| n.parse::<u8>().ok())
752                {
753                    if (1..=12).contains(&number) {
754                        KeyCode::F(number)
755                    } else {
756                        return Err(format!("unsupported function key `{key}`"));
757                    }
758                } else {
759                    let mut chars = key.chars();
760                    let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
761                        return Err(format!("unsupported key name `{key}`"));
762                    };
763                    if ch.is_control() {
764                        return Err(format!("unsupported key name `{key}`"));
765                    }
766                    if ch.is_ascii_uppercase() {
767                        modifiers |= KeyModifiers::SHIFT;
768                    }
769                    KeyCode::Char(ch)
770                }
771            }
772        };
773        if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
774            code = KeyCode::BackTab;
775            modifiers.remove(KeyModifiers::SHIFT);
776        }
777        if code == KeyCode::BackTab {
778            modifiers |= KeyModifiers::SHIFT;
779        }
780        Ok(Self { code, modifiers })
781    }
782
783    pub fn matches(&self, event: KeyEvent) -> bool {
784        if self.code != event.code {
785            return false;
786        }
787        if self.modifiers.is_empty() {
788            return event.modifiers.is_empty();
789        }
790        if self.code == KeyCode::BackTab {
791            return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
792        }
793        if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
794            let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
795            let normalized_expected = if shift_expected {
796                expected.to_ascii_lowercase()
797            } else {
798                expected
799            };
800            let shift_actual =
801                actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
802            let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
803            let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
804            return expected_other == actual_other
805                && normalized_expected == actual.to_ascii_lowercase()
806                && shift_expected == shift_actual;
807        }
808        event.modifiers == self.modifiers
809    }
810
811    pub fn display(&self) -> String {
812        let mut out = String::new();
813        if self.modifiers.contains(KeyModifiers::CONTROL) {
814            out.push_str("Ctrl+");
815        }
816        if self.modifiers.contains(KeyModifiers::ALT) {
817            out.push_str("Alt+");
818        }
819        if self.modifiers.contains(KeyModifiers::SUPER) {
820            out.push_str("Super+");
821        }
822        if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
823            out.push_str("Shift+");
824        }
825        out.push_str(&match self.code {
826            KeyCode::BackTab => "Shift+Tab".into(),
827            KeyCode::Char(' ') => "Space".into(),
828            KeyCode::Char(ch) => ch.to_string(),
829            KeyCode::Enter => "Enter".into(),
830            KeyCode::Esc => "Esc".into(),
831            KeyCode::Left => "Left".into(),
832            KeyCode::Right => "Right".into(),
833            KeyCode::Up => "Up".into(),
834            KeyCode::Down => "Down".into(),
835            KeyCode::PageUp => "PgUp".into(),
836            KeyCode::PageDown => "PgDn".into(),
837            KeyCode::Delete => "Delete".into(),
838            KeyCode::Insert => "Insert".into(),
839            KeyCode::Home => "Home".into(),
840            KeyCode::End => "End".into(),
841            KeyCode::F(n) => format!("F{n}"),
842            other => format!("{other:?}"),
843        });
844        out
845    }
846}
847
848impl Serialize for KeyChord {
849    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
850    where
851        S: Serializer,
852    {
853        serializer.serialize_str(&self.display())
854    }
855}
856
857impl<'de> Deserialize<'de> for KeyChord {
858    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
859    where
860        D: Deserializer<'de>,
861    {
862        let value = String::deserialize(deserializer)?;
863        Self::parse(&value).map_err(D::Error::custom)
864    }
865}
866
867impl fmt::Display for KeyChord {
868    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
869        formatter.write_str(&self.display())
870    }
871}
872
873#[derive(Debug, Clone, Default, PartialEq, Eq)]
874pub struct EffectiveBindings {
875    modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
876}
877
878fn chord(value: &str) -> KeyChord {
879    KeyChord::parse(value).expect("built-in key chord must parse")
880}
881
882fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
883    map.insert(action, values.iter().map(|value| chord(value)).collect());
884}
885
886impl KeyBindings {
887    pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
888        let mut effective = EffectiveBindings::default();
889        for context in InputContext::ALL {
890            let mut mode = default_mode(*context);
891            overlay(&mut mode, overrides.overrides(*context), *context)?;
892            effective.modes.insert(*context, mode);
893        }
894        Ok(effective)
895    }
896}
897
898impl EffectiveBindings {
899    pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
900        self.modes
901            .get(&context)?
902            .iter()
903            .find_map(|(action, chords)| {
904                chords
905                    .iter()
906                    .any(|key| key.matches(event))
907                    .then_some(*action)
908            })
909    }
910
911    /// Every action this context binds, sorted by name so the order is stable.
912    ///
913    /// For the help overlay's completeness guard: that overlay's per-context action
914    /// list is written by hand, and a binding it does not name is a key that works and
915    /// that nobody can discover.
916    pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
917        let mut actions: Vec<TuiAction> = self
918            .modes
919            .get(&context)
920            .map(|mode| mode.keys().copied().collect())
921            .unwrap_or_default();
922        actions.sort_by_key(|action| action.as_str());
923        actions
924    }
925
926    pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
927        self.modes
928            .get(&context)
929            .and_then(|mode| mode.get(&action))
930            .map(Vec::as_slice)
931            .unwrap_or_default()
932    }
933
934    pub fn label(&self, context: InputContext, action: TuiAction) -> String {
935        let labels: Vec<String> = self
936            .chords(context, action)
937            .iter()
938            .map(ToString::to_string)
939            .collect();
940        if labels.is_empty() {
941            "—".into()
942        } else {
943            labels.join(" / ")
944        }
945    }
946
947    pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
948        let (code, modifiers) = match (context, action) {
949            (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
950            (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
951            (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
952            (InputContext::PlanningManagement, TuiAction::Next) => {
953                (KeyCode::Tab, KeyModifiers::NONE)
954            }
955            (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
956            (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
957            (InputContext::DatePicker, TuiAction::PreviousYear) => {
958                (KeyCode::Char('['), KeyModifiers::NONE)
959            }
960            (InputContext::DatePicker, TuiAction::NextYear) => {
961                (KeyCode::Char(']'), KeyModifiers::NONE)
962            }
963            (InputContext::DatePicker, TuiAction::PreviousMonth) => {
964                (KeyCode::PageUp, KeyModifiers::NONE)
965            }
966            (InputContext::DatePicker, TuiAction::NextMonth) => {
967                (KeyCode::PageDown, KeyModifiers::NONE)
968            }
969            (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
970            (InputContext::Settings, TuiAction::NewBoard) => {
971                (KeyCode::Char('n'), KeyModifiers::NONE)
972            }
973            (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
974            // #1441/#1442: `scroll_up`/`scroll_down` are **page** steps and must
975            // canonicalize to themselves. The generic arms below fold them into
976            // Up/Down, which is what made the detail pane's `PgUp`/`PgDn` arms dead
977            // code: the key arrived canonicalized as `Down` and moved the
978            // relationship cursor instead of scrolling, so a bound key did something
979            // other than what it was bound to. These arms sit above the generics
980            // because the match is top-down.
981            (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
982            (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
983            (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
984            (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
985            (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
986            (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
987            (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
988            (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
989            (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
990                (KeyCode::Enter, KeyModifiers::NONE)
991            }
992            (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
993            (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
994            (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
995            (_, TuiAction::ToggleChildren) => (KeyCode::Char('u'), KeyModifiers::NONE),
996            (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
997                (KeyCode::Char('e'), KeyModifiers::NONE)
998            }
999            (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
1000            (_, TuiAction::View) => (KeyCode::Char('v'), KeyModifiers::NONE),
1001            (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
1002                (KeyCode::Char('c'), KeyModifiers::NONE)
1003            }
1004            (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
1005            (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
1006            (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
1007            (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
1008            (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
1009            (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
1010            (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
1011            (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
1012            (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
1013            (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
1014                (KeyCode::Char('r'), KeyModifiers::NONE)
1015            }
1016            (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
1017            (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
1018            (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
1019            (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
1020            (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
1021            (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
1022            (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
1023            (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
1024            (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
1025            (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
1026            // #1359. `e` is the editor in the detail view, so re-kind is `E` — the
1027            // same relationship the CLI's `tix link edit <id>` changes.
1028            (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
1029            (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
1030            (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
1031            // #1384: hand the handler the key the footer advertises, not the
1032            // catch-all `Esc`.
1033            (_, TuiAction::EditParent) => (KeyCode::Char('P'), KeyModifiers::NONE),
1034            (_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
1035            (_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
1036            // #1513. The three related-navigation actions hand the handler the
1037            // key they advertise — without these arms the generic catch-all
1038            // would fold them into `Esc`, and `o` on a related entry would close
1039            // the pane instead of following the link.
1040            (_, TuiAction::RelatedPicker) => (KeyCode::Char('r'), KeyModifiers::NONE),
1041            (_, TuiAction::FollowRelated) => (KeyCode::Char('o'), KeyModifiers::NONE),
1042            (_, TuiAction::NavBack) => (KeyCode::Backspace, KeyModifiers::NONE),
1043            (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
1044            // The shell's mode switches. `F(2)` is also `open_picker`'s canonical
1045            // event — they are different actions in different contexts, which is
1046            // exactly why mode switching is scoped to the shell context and a form's
1047            // `F2` keeps its picker meaning.
1048            (_, TuiAction::SwitchToBoard) => (KeyCode::F(3), KeyModifiers::NONE),
1049            (_, TuiAction::SwitchToTriage) => (KeyCode::F(2), KeyModifiers::NONE),
1050            (_, TuiAction::SwitchToCycle) => (KeyCode::F(4), KeyModifiers::NONE),
1051            (_, TuiAction::SwitchToMilestones) => (KeyCode::F(5), KeyModifiers::NONE),
1052            (_, TuiAction::SwitchToRepos) => (KeyCode::F(6), KeyModifiers::NONE),
1053            (_, TuiAction::SwitchToNewIssue) => (KeyCode::F(1), KeyModifiers::NONE),
1054            (_, TuiAction::SwitchToConfig) => (KeyCode::F(12), KeyModifiers::NONE),
1055            (_, TuiAction::ToggleNavigation) => (KeyCode::Char('n'), KeyModifiers::CONTROL),
1056            (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
1057            (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
1058            // #1506. The repair prompt's own two decisions canonicalize to the
1059            // keys the prompt advertises, so `handle_edit_repair_key` reads a
1060            // canonical `d`/`x` however the user reached the action.
1061            (_, TuiAction::DeclareKey) => (KeyCode::Char('d'), KeyModifiers::NONE),
1062            (_, TuiAction::Discard) => (KeyCode::Char('x'), KeyModifiers::NONE),
1063            (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
1064            _ => (KeyCode::Esc, KeyModifiers::NONE),
1065        };
1066        KeyEvent::new(code, modifiers)
1067    }
1068}
1069
1070fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
1071    let mut map = HashMap::new();
1072    for (action, keys) in values {
1073        let action = match *action {
1074            "move_up" => TuiAction::MoveUp,
1075            "move_down" => TuiAction::MoveDown,
1076            "move_left" => TuiAction::MoveLeft,
1077            "move_right" => TuiAction::MoveRight,
1078            "next_board" => TuiAction::NextBoard,
1079            "previous_board" => TuiAction::PreviousBoard,
1080            "open" => TuiAction::Open,
1081            "edit" => TuiAction::Edit,
1082            "agent" => TuiAction::Agent,
1083            "view" => TuiAction::View,
1084            "toggle_column" => TuiAction::ToggleColumn,
1085            "filter_modal" => TuiAction::FilterModal,
1086            "bulk_modal" => TuiAction::BulkModal,
1087            "triage" => TuiAction::Triage,
1088            "mark" => TuiAction::Mark,
1089            "unmark" => TuiAction::Unmark,
1090            "move_previous" => TuiAction::MovePrevious,
1091            "move_next" => TuiAction::MoveNext,
1092            "new_issue" => TuiAction::NewIssue,
1093            "clone_issue" => TuiAction::CloneIssue,
1094            "toggle_archived" => TuiAction::ToggleArchived,
1095            "toggle_children" => TuiAction::ToggleChildren,
1096            "delete" => TuiAction::Delete,
1097            "refresh" => TuiAction::Refresh,
1098            "settings" => TuiAction::Settings,
1099            "planning" => TuiAction::Planning,
1100            "new_board" => TuiAction::NewBoard,
1101            "help" => TuiAction::Help,
1102            "quit" => TuiAction::Quit,
1103            "text_filter" => TuiAction::TextFilter,
1104            "reorder_up" => TuiAction::ReorderUp,
1105            "reorder_down" => TuiAction::ReorderDown,
1106            "close" => TuiAction::Close,
1107            "scroll_up" => TuiAction::ScrollUp,
1108            "scroll_down" => TuiAction::ScrollDown,
1109            "comment" => TuiAction::Comment,
1110            "asset" => TuiAction::Asset,
1111            "new_relationship" => TuiAction::NewRelationship,
1112            "edit_relationship" => TuiAction::EditRelationship,
1113            "remove_relationship" => TuiAction::RemoveRelationship,
1114            "edit_parent" => TuiAction::EditParent,
1115            "next_relationship" => TuiAction::NextRelationship,
1116            "previous_relationship" => TuiAction::PreviousRelationship,
1117            "related_picker" => TuiAction::RelatedPicker,
1118            "follow_related" => TuiAction::FollowRelated,
1119            "nav_back" => TuiAction::NavBack,
1120            "submit" => TuiAction::Submit,
1121            "cancel" => TuiAction::Cancel,
1122            "next" => TuiAction::Next,
1123            "previous" => TuiAction::Previous,
1124            "open_picker" => TuiAction::OpenPicker,
1125            "reset" => TuiAction::Reset,
1126            "toggle" => TuiAction::Toggle,
1127            "add_column" => TuiAction::AddColumn,
1128            "new_planning_item" => TuiAction::NewPlanningItem,
1129            "edit_planning_item" => TuiAction::EditPlanningItem,
1130            "delete_planning_item" => TuiAction::DeletePlanningItem,
1131            "confirm" => TuiAction::Confirm,
1132            "create" => TuiAction::Create,
1133            "previous_year" => TuiAction::PreviousYear,
1134            "next_year" => TuiAction::NextYear,
1135            "previous_month" => TuiAction::PreviousMonth,
1136            "next_month" => TuiAction::NextMonth,
1137            "reedit" => TuiAction::Reedit,
1138            "ignore" => TuiAction::Ignore,
1139            "declare_key" => TuiAction::DeclareKey,
1140            "discard" => TuiAction::Discard,
1141            "switch_to_board" => TuiAction::SwitchToBoard,
1142            "switch_to_triage" => TuiAction::SwitchToTriage,
1143            "switch_to_cycle" => TuiAction::SwitchToCycle,
1144            "switch_to_milestones" => TuiAction::SwitchToMilestones,
1145            "switch_to_repos" => TuiAction::SwitchToRepos,
1146            "switch_to_new_issue" => TuiAction::SwitchToNewIssue,
1147            "switch_to_config" => TuiAction::SwitchToConfig,
1148            "toggle_navigation" => TuiAction::ToggleNavigation,
1149            _ => unreachable!(),
1150        };
1151        set(&mut map, action, keys);
1152    }
1153    map
1154}
1155
1156fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
1157    match context {
1158        InputContext::Board => defaults(&[
1159            ("move_up", &["up", "k"]),
1160            ("move_down", &["down", "j"]),
1161            // tix #1442 (follow-up): a screen of cards. They have to be *bound* for the
1162            // board to see them at all: `App::handle_key` routes an unbound key in a
1163            // non-text context to `F(12)` rather than passing it through, which is why
1164            // `PgUp`/`PgDn` did nothing here while the wheel next to them worked. They
1165            // canonicalize to `PageUp`/`PageDown`, so the handler can tell a page from
1166            // a row.
1167            ("scroll_up", &["pgup"]),
1168            ("scroll_down", &["pgdn"]),
1169            ("move_left", &["left", "h"]),
1170            ("move_right", &["right", "l"]),
1171            ("next_board", &["tab"]),
1172            ("previous_board", &["shift+tab"]),
1173            ("open", &["enter"]),
1174            ("edit", &["e"]),
1175            // #1507: read the selected card in the resolved viewer, read-only.
1176            ("view", &["v"]),
1177            ("agent", &["A"]),
1178            ("toggle_column", &["c"]),
1179            ("filter_modal", &["f"]),
1180            ("bulk_modal", &["B"]),
1181            // WP06: the triage inbox. `t` was unbound, so this adds a key
1182            // rather than changing what any existing one means.
1183            ("triage", &["t"]),
1184            // The chord is spelled `space`, not a literal blank: `KeyChord::parse`
1185            // trims its input, so a bare `" "` is the empty chord and every
1186            // built-in binding panics at startup.
1187            ("mark", &["space"]),
1188            ("unmark", &["x"]),
1189            ("move_previous", &["<"]),
1190            ("move_next", &[">"]),
1191            ("new_issue", &["n"]),
1192            // #1484: the capital of `c` (toggle column), not a new letter — the
1193            // board's free letters are its navigation, and `C` keeps "clone" beside
1194            // the toggle it is the second half of.
1195            ("clone_issue", &["C"]),
1196            // #1492: `a` was free on the board, and "archived" is what it means.
1197            ("toggle_archived", &["a"]),
1198            // #1399: `u` was free on the board; "sub-issues" is what it shows.
1199            ("toggle_children", &["u"]),
1200            ("delete", &["d"]),
1201            ("refresh", &["r"]),
1202            ("settings", &["s"]),
1203            ("planning", &["m", "M"]),
1204            ("new_board", &["b"]),
1205            ("help", &["?"]),
1206            ("quit", &["q", "Q", "esc"]),
1207            ("text_filter", &["/"]),
1208            ("reorder_up", &["ctrl+up"]),
1209            ("reorder_down", &["ctrl+down"]),
1210        ]),
1211        InputContext::Rollover => defaults(&[
1212            ("submit", &["enter"]),
1213            ("previous", &["left", "right", "tab"]),
1214            ("cancel", &["esc"]),
1215        ]),
1216        InputContext::Detail => defaults(&[
1217            ("close", &["q", "Q", "esc"]),
1218            ("edit", &["e"]),
1219            // #1507: view the open issue in the resolved read-only viewer.
1220            ("view", &["v"]),
1221            ("scroll_up", &["pgup"]),
1222            ("scroll_down", &["pgdn"]),
1223            ("help", &["?"]),
1224            ("comment", &["c"]),
1225            ("asset", &["a"]),
1226            // #1359: the three mutating relationship operations, plus the cursor
1227            // that names which edge `E` and `d` act on. `e` stays the editor, so
1228            // re-kind is `E`.
1229            ("new_relationship", &["n"]),
1230            ("edit_relationship", &["E"]),
1231            ("remove_relationship", &["d"]),
1232            // #1384: `P` was free in the detail pane. Set/change/clear the open
1233            // issue's parent through the shared hierarchy service, which is the
1234            // TUI half of `tix edit --parent` that the create-only form could
1235            // not express.
1236            ("edit_parent", &["P"]),
1237            ("next_relationship", &["j", "down"]),
1238            ("previous_relationship", &["k", "up"]),
1239            // #1513: the related-issues surface. `r` lists them, `o` follows the
1240            // selected one, `Backspace` returns to the previous pane. `j`/`k`
1241            // keep moving the **relationship** cursor; the picker is the
1242            // selection surface for related entries.
1243            ("related_picker", &["r"]),
1244            ("follow_related", &["o"]),
1245            ("nav_back", &["backspace"]),
1246        ]),
1247        InputContext::RelatedPicker => defaults(&[
1248            ("cancel", &["esc"]),
1249            ("next", &["down"]),
1250            ("previous", &["up"]),
1251            ("submit", &["enter"]),
1252        ]),
1253        InputContext::Form | InputContext::PlanningForm => defaults(&[
1254            ("cancel", &["esc"]),
1255            ("submit", &["enter"]),
1256            ("next", &["tab", "down"]),
1257            ("previous", &["shift+tab", "up"]),
1258            ("open_picker", &["f2"]),
1259        ]),
1260        InputContext::FormPicker => defaults(&[
1261            ("cancel", &["esc", "f2"]),
1262            ("next", &["up"]),
1263            ("previous", &["down"]),
1264            ("submit", &["enter"]),
1265            ("create", &["+", "insert"]),
1266        ]),
1267        InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
1268        // `n` cancels as well as `esc`: a yes/no prompt whose only "no" is a
1269        // non-printing key is a trap for anyone who does not know it.
1270        InputContext::ConfirmBlockedMove => defaults(&[
1271            ("confirm", &["y", "Y", "enter"]),
1272            ("cancel", &["n", "N", "esc"]),
1273        ]),
1274        // Free-text entry. `cancel` is **`esc` only**, and that is load-bearing
1275        // rather than a style choice.
1276        //
1277        // All four of these read arbitrary text into a buffer — a commit message, a
1278        // comment body, a **filesystem path**, a filter string. Binding `q` to cancel
1279        // means the letter `q` cannot be typed, and it is worse than that: the
1280        // binding resolves to the action's canonical event (`esc`), so `q` does not
1281        // merely fail to type, it **leaves the mode**, and every character after it
1282        // is then interpreted as a command in the mode underneath.
1283        //
1284        // This was found by a test flake (#1305), not by inspection. The asset
1285        // round-trip test types the source path through `handle_key`, and the path
1286        // came from a `tempfile` temp directory whose six random characters are drawn
1287        // from `[A-Za-z0-9]`. Roughly one run in forty produced a name containing a
1288        // `q`; the prompt closed mid-path and the rest of the string was applied as
1289        // Detail-mode keys — which is how a run came to report
1290        // `status = "Commented by …"` and no asset at all. A user attaching an asset
1291        // from a path containing `q` gets the same thing, and it silently creates a
1292        // comment on the issue.
1293        //
1294        // The test was at fault for depending on a random name, but the behaviour is
1295        // a real defect, so the binding moves rather than the fixture. `esc` remains
1296        // the cancel key, which is already this codebase's convention for text entry
1297        // — `FilterModal` and `ConfirmDelete` both bind `esc` and nothing else.
1298        //
1299        // `q`-to-close is kept where there is no text to type: `Help`,
1300        // `ContextMenu`, `Settings`, `PlanningManagement`, `DatePicker`, and the
1301        // yes/no `ConfirmBlockedMove` keep it.
1302        InputContext::MoveIntoDone
1303        | InputContext::Comment
1304        | InputContext::Asset
1305        | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1306        // The overlay and the context menu are *not* the same surface, and sharing a
1307        // context made them one: a binding added for the overlay (`pgup`/`pgdn`, tix
1308        // #1441) would silently become a context-menu key too. They share a key set
1309        // today only by coincidence, so they get their own arms.
1310        InputContext::Help => defaults(&[
1311            ("cancel", &["esc", "q", "?"]),
1312            ("next", &["down", "j"]),
1313            ("previous", &["up", "k"]),
1314            ("submit", &["enter"]),
1315            // #1496: `/` filters the list. Bound rather than handled as a raw key so it
1316            // is rebindable like everything else — and so the overlay's own rows name
1317            // it, since a search nobody knows about is a search nobody uses.
1318            ("text_filter", &["/"]),
1319            // #1441/#1442: one page at a time. These have to be *bound* for the
1320            // overlay to see them at all — `App::handle_key` routes an unbound key in
1321            // a non-text context to `F(12)` rather than passing it through — and they
1322            // canonicalize to themselves so the handler can tell a page from a row.
1323            ("scroll_up", &["pgup"]),
1324            ("scroll_down", &["pgdn"]),
1325        ]),
1326        InputContext::ContextMenu => defaults(&[
1327            ("cancel", &["esc", "q", "?"]),
1328            ("next", &["down", "j"]),
1329            ("previous", &["up", "k"]),
1330            ("submit", &["enter"]),
1331        ]),
1332        // The overlay's `/` input (tix #1496): free text, so `cancel` is `Esc` and only
1333        // `Esc`. `q` and `?` stay ordinary characters here — binding them to cancel
1334        // would rewrite them to `esc` inside `App::handle_key`, before the handler saw
1335        // them, and a query could never contain a `q`.
1336        InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1337        InputContext::BulkModal => defaults(&[
1338            ("cancel", &["esc"]),
1339            ("next", &["tab", "down"]),
1340            ("previous", &["shift+tab", "up"]),
1341            ("submit", &["enter"]),
1342        ]),
1343        // WP06. Five bindings and nothing else, because every remaining inbox
1344        // key (`q`, `j`, `k`, `a`, `d`, `u`, `f`, `p`, `y`, `?`) has to arrive
1345        // **raw**: a decline reason containing `q` and a duplicate target
1346        // containing `a` are ordinary, and `allows_text_fallback` is what keeps
1347        // them letters instead of turning them into a close or an accept.
1348        InputContext::Triage => defaults(&[
1349            ("cancel", &["esc"]),
1350            ("submit", &["enter"]),
1351            ("move_up", &["up"]),
1352            ("move_down", &["down"]),
1353            ("toggle", &["space"]),
1354        ]),
1355        // The F3 Cycle mode (tui-cycle-mode). `m` opens the planning-management
1356        // surface for the same reason the board binds it: it is where cycles and
1357        // milestones are created, renamed, and deleted. Nothing here collides with
1358        // the surface's own keys, so opening it from this mode is unambiguous.
1359        InputContext::Cycle => defaults(&[
1360            ("close", &["q", "Q", "esc"]),
1361            ("move_up", &["up", "k"]),
1362            ("move_down", &["down", "j"]),
1363            // The listing's horizontal axis: `h`/`l` and the arrows land on the
1364            // first entry of the previous / next rendered group.
1365            ("move_left", &["left", "h"]),
1366            ("move_right", &["right", "l"]),
1367            ("planning", &["m"]),
1368            ("help", &["?"]),
1369        ]),
1370        // The F5 Milestones mode. `m`, `j`/`k`, `q`/`esc` mirror the Cycle
1371        // listing: it is the same kind of registry view. The planning commands
1372        // (`n`/`e`/`x`, `Enter` to edit) are bound here too, so the milestone
1373        // registry is editable from its own mode and not only from F12.
1374        InputContext::Milestones => defaults(&[
1375            ("close", &["q", "Q", "esc"]),
1376            ("move_up", &["up", "k"]),
1377            ("move_down", &["down", "j"]),
1378            ("submit", &["enter"]),
1379            ("new_planning_item", &["n"]),
1380            ("edit_planning_item", &["e"]),
1381            ("delete_planning_item", &["x", "X"]),
1382            ("planning", &["m"]),
1383            ("help", &["?"]),
1384        ]),
1385        // The F6 Repos mode (tix #1529). `j`/`k` walk the list, `Enter` runs tix
1386        // in the selected repository, and `q`/`esc` leave. No free text, so the
1387        // letters are safe as close keys.
1388        InputContext::Repos => defaults(&[
1389            ("close", &["q", "Q", "esc"]),
1390            ("move_up", &["up", "k"]),
1391            ("move_down", &["down", "j"]),
1392            ("submit", &["enter"]),
1393            ("help", &["?"]),
1394        ]),
1395        InputContext::FilterModal => defaults(&[
1396            ("cancel", &["esc"]),
1397            ("next", &["tab", "down"]),
1398            ("previous", &["shift+tab", "up"]),
1399            ("reset", &["r", "R"]),
1400            ("submit", &["enter"]),
1401            ("open_picker", &["f2"]),
1402        ]),
1403        // The F12 Config mode's sections (tui-config-mode). The dashboards
1404        // section edits the active board, so `tab`/`shift+tab` switch boards
1405        // (`next_board`/`previous_board`, whose canonical events are Tab and
1406        // BackTab). `left`/`right` move the pane focus between the section rail
1407        // and the body, and `up`/`down` move inside the focused pane — the
1408        // section when the rail is focused, the row when the body is. `n`/`e`/`x`
1409        // are the new/rename/delete keys in both the dashboards section and the
1410        // cycles/milestones sections — the section decides what they act on.
1411        //
1412        // `enter` is `submit`, not a second `toggle` chord: it steps from the
1413        // rail into the body and then activates the row, while `space` keeps
1414        // toggling wherever the cursor is. Both bound to `toggle` would
1415        // canonicalize to the same event and there would be no Enter behaviour
1416        // to implement.
1417        InputContext::Settings => defaults(&[
1418            ("close", &["q", "Q", "esc"]),
1419            ("next_board", &["tab", "]"]),
1420            ("previous_board", &["shift+tab", "["]),
1421            ("move_left", &["left", "h"]),
1422            ("move_right", &["right", "l"]),
1423            ("move_up", &["up", "k"]),
1424            ("move_down", &["down", "j"]),
1425            ("toggle", &["space"]),
1426            ("submit", &["enter"]),
1427            ("add_column", &["a"]),
1428            ("new_board", &["n"]),
1429            ("edit_planning_item", &["e"]),
1430            ("delete", &["x", "X"]),
1431        ]),
1432        InputContext::SettingsPrompt => defaults(&[
1433            ("cancel", &["esc", "q", "Q"]),
1434            ("submit", &["enter", "y", "Y"]),
1435        ]),
1436        InputContext::PlanningManagement => defaults(&[
1437            ("close", &["q", "Q", "esc"]),
1438            ("move_up", &["up", "k"]),
1439            ("move_down", &["down", "j"]),
1440            // The segments (Cycles / Milestones) are a horizontal axis, so the
1441            // horizontal movement keys switch them — the same rule the Config
1442            // rail's vertical section list deliberately does not follow: there
1443            // `h`/`l` are the pane axis and `j`/`k` walk the sections.
1444            ("move_left", &["left", "h"]),
1445            ("move_right", &["right", "l"]),
1446            ("next", &["tab", "shift+tab"]),
1447            ("new_planning_item", &["n"]),
1448            ("edit_planning_item", &["e"]),
1449            ("delete_planning_item", &["x", "X"]),
1450        ]),
1451        InputContext::PlanningConfirm => {
1452            defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1453        }
1454        InputContext::DatePicker => defaults(&[
1455            ("cancel", &["esc", "q"]),
1456            ("submit", &["enter"]),
1457            ("move_up", &["up", "k"]),
1458            ("move_down", &["down", "j"]),
1459            ("move_left", &["left", "h"]),
1460            ("move_right", &["right", "l"]),
1461            ("previous_month", &["pgup"]),
1462            ("next_month", &["pgdn"]),
1463            ("previous_year", &["["]),
1464            ("next_year", &["]"]),
1465            ("reset", &["delete", "c", "C"]),
1466        ]),
1467        // #1506/#1510. The repair prompt the editor round trip raises when the
1468        // saved file has a problem: apply the proposed fix, declare the key,
1469        // reopen the editor on the retained copy, acknowledge, or discard.
1470        // `confirm` keeps `a`/`y` — the chord #1506's warning advertised — and
1471        // every other key is a decision, never a stray exit. `discard` is `x`
1472        // and requires a second press; `Esc` acknowledges and **keeps** the copy.
1473        InputContext::EditRepair => defaults(&[
1474            ("confirm", &["a", "y"]),
1475            ("declare_key", &["d", "D"]),
1476            ("reedit", &["r", "R"]),
1477            ("ignore", &["i", "I", "enter", "esc"]),
1478            ("discard", &["x", "X"]),
1479        ]),
1480        // #1510. The conflict prompt: overwrite, abandon, compare. The chords are
1481        // the ones the handler's arms key on — `handle_key` rewrites a bound key to
1482        // its action's canonical event, which is `y` for `confirm`, `Enter` for
1483        // `ignore`, and `r` for `reedit` — so every chord listed here arrives as
1484        // that canonical code. `Esc` is bound to `ignore`, which is what makes it
1485        // the safe default the prompt promises. `q` is deliberately *not* bound:
1486        // the prompt closes only through a decision it names.
1487        InputContext::EditConflict => defaults(&[
1488            ("confirm", &["o", "O", "y", "Y"]),
1489            ("ignore", &["a", "A", "esc"]),
1490            ("reedit", &["c", "C", "r", "R"]),
1491        ]),
1492        // The mode shell (tui-mode-navigation). Five switches and a display-state
1493        // toggle, nothing else: every other key belongs to the active mode, and this
1494        // context is consulted only when that mode declines the key.
1495        InputContext::Shell => defaults(&[
1496            ("switch_to_new_issue", &["f1"]),
1497            ("switch_to_triage", &["f2"]),
1498            ("switch_to_board", &["f3"]),
1499            ("switch_to_cycle", &["f4"]),
1500            ("switch_to_milestones", &["f5"]),
1501            // #1529: F6 was free in the shell; the repo list follows the
1502            // milestone registry.
1503            ("switch_to_repos", &["f6"]),
1504            ("switch_to_config", &["f12"]),
1505            ("toggle_navigation", &["ctrl+n"]),
1506        ]),
1507        // #1359. Free text, so `q` must be typeable: a title fragment or a path
1508        // containing `q` is unremarkable, and binding it to cancel closes the prompt
1509        // and applies the rest of the input as commands underneath. See the note on
1510        // the `Asset`/`Comment`/`Filter` group for the full reasoning.
1511        InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1512    }
1513}
1514
1515macro_rules! overlay_fields {
1516    ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
1517        $(
1518            if let Some(values) = &$overrides.$field {
1519                let mut chords = Vec::with_capacity(values.len());
1520                for value in values {
1521                    chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
1522                        "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
1523                    )))?);
1524                }
1525                if chords.is_empty() {
1526                    return Err(TixError::Config(format!(
1527                        "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
1528                    )));
1529                }
1530                $map.insert(TuiAction::$action, chords);
1531            }
1532        )+
1533    };
1534}
1535
1536fn overlay(
1537    map: &mut HashMap<TuiAction, Vec<KeyChord>>,
1538    overrides: &ActionBindings,
1539    context: InputContext,
1540) -> Result<()> {
1541    overlay_fields!(map, overrides, context,
1542        move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
1543        next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
1544        agent => Agent, view => View, toggle_column => ToggleColumn, filter_modal => FilterModal,
1545        bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
1546        move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
1547        clone_issue => CloneIssue,
1548        toggle_archived => ToggleArchived,
1549        toggle_children => ToggleChildren,
1550        delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
1551        new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
1552        reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
1553        comment => Comment, asset => Asset, edit_parent => EditParent,
1554        submit => Submit, cancel => Cancel, next => Next,
1555        previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
1556        add_column => AddColumn, new_planning_item => NewPlanningItem,
1557        edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
1558        confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
1559        previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
1560        declare_key => DeclareKey, discard => Discard,
1561        switch_to_board => SwitchToBoard, switch_to_triage => SwitchToTriage,
1562        switch_to_cycle => SwitchToCycle, switch_to_milestones => SwitchToMilestones,
1563        switch_to_repos => SwitchToRepos,
1564        switch_to_new_issue => SwitchToNewIssue,
1565        switch_to_config => SwitchToConfig, toggle_navigation => ToggleNavigation,
1566    );
1567    let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
1568    for (action, chords) in map {
1569        for key in chords {
1570            if let Some(previous) = seen.insert(*key, *action) {
1571                return Err(TixError::Config(format!(
1572                    "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
1573                    context.as_str(),
1574                    key,
1575                    previous.as_str(),
1576                    action.as_str()
1577                )));
1578            }
1579        }
1580    }
1581    Ok(())
1582}
1583
1584impl ActionBindings {
1585    pub fn is_empty(&self) -> bool {
1586        self == &Self::default()
1587    }
1588}
1589
1590#[cfg(test)]
1591mod tests {
1592    use super::*;
1593
1594    #[test]
1595    fn parses_and_normalizes_documented_chords() {
1596        assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
1597        assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
1598        assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
1599        assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
1600        assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
1601        assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
1602        assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
1603        assert!(KeyChord::parse("ctrl+wat").is_err());
1604        assert!(KeyChord::parse("f99").is_err());
1605    }
1606
1607    #[test]
1608    fn defaults_validate_and_cross_mode_reuse_is_allowed() {
1609        let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1610        assert!(
1611            defaults
1612                .action(
1613                    InputContext::Board,
1614                    KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
1615                )
1616                .is_some()
1617        );
1618        let mut overrides = KeyBindings::default();
1619        overrides.board.open = Some(vec!["f4".into()]);
1620        overrides.detail.close = Some(vec!["f4".into()]);
1621        assert!(KeyBindings::resolve(&overrides).is_ok());
1622    }
1623
1624    #[test]
1625    fn board_and_detail_bind_v_to_view() {
1626        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1627        for context in [InputContext::Board, InputContext::Detail] {
1628            let event = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
1629            assert_eq!(
1630                bindings.action(context, event),
1631                Some(TuiAction::View),
1632                "{context:?} must bind `v` to view"
1633            );
1634            assert_eq!(
1635                bindings.canonical_event(context, TuiAction::View),
1636                event,
1637                "`view` must canonicalize to `v` so the handler sees it as typed"
1638            );
1639        }
1640    }
1641
1642    /// The conflict prompt's three decisions (#1510): every choice is reachable by
1643    /// two chords, and each pair resolves to an action whose canonical event is the
1644    /// code the handler's arms read (`y`, `Enter`, `r`) — the indirection that makes
1645    /// `Esc` abandon and `c` compare without a chord name in the logic.
1646    #[test]
1647    fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
1648        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1649        let cases = [
1650            (KeyCode::Char('o'), TuiAction::Confirm),
1651            (KeyCode::Char('y'), TuiAction::Confirm),
1652            (KeyCode::Char('a'), TuiAction::Ignore),
1653            (KeyCode::Esc, TuiAction::Ignore),
1654            (KeyCode::Char('c'), TuiAction::Reedit),
1655            (KeyCode::Char('r'), TuiAction::Reedit),
1656        ];
1657        for (code, action) in cases {
1658            assert_eq!(
1659                bindings.action(
1660                    InputContext::EditConflict,
1661                    KeyEvent::new(code, KeyModifiers::NONE)
1662                ),
1663                Some(action),
1664                "{code:?} must be {action:?} in the conflict prompt"
1665            );
1666        }
1667        // …and the canonical events are what `handle_edit_conflict_key` matches on,
1668        // so the mapping is asserted rather than assumed.
1669        for (action, expected) in [
1670            (TuiAction::Confirm, KeyCode::Char('y')),
1671            (TuiAction::Ignore, KeyCode::Enter),
1672            (TuiAction::Reedit, KeyCode::Char('r')),
1673        ] {
1674            assert_eq!(
1675                bindings.canonical_event(InputContext::EditConflict, action),
1676                KeyEvent::new(expected, KeyModifiers::NONE),
1677                "{action:?} must hand the handler `{expected:?}`"
1678            );
1679        }
1680        // A key nobody bound does nothing: `q` must not close the prompt.
1681        assert_eq!(
1682            bindings.action(
1683                InputContext::EditConflict,
1684                KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
1685            ),
1686            None
1687        );
1688    }
1689
1690    #[test]
1691    fn conflicts_report_mode_and_actions() {
1692        let mut overrides = KeyBindings::default();
1693        overrides.board.open = Some(vec!["ctrl+n".into()]);
1694        overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
1695        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
1696        assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
1697    }
1698
1699    #[test]
1700    fn shell_defaults_resolve_mode_switches_and_toggle() {
1701        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1702        // F1 New Issue, F2 Triage, F3 Board, F4 Cycles, F5 Milestones,
1703        // F6 Repos (tix #1529), F12 Config.
1704        let cases = [
1705            (KeyCode::F(1), TuiAction::SwitchToNewIssue),
1706            (KeyCode::F(2), TuiAction::SwitchToTriage),
1707            (KeyCode::F(3), TuiAction::SwitchToBoard),
1708            (KeyCode::F(4), TuiAction::SwitchToCycle),
1709            (KeyCode::F(5), TuiAction::SwitchToMilestones),
1710            (KeyCode::F(6), TuiAction::SwitchToRepos),
1711            (KeyCode::F(12), TuiAction::SwitchToConfig),
1712        ];
1713        for (code, action) in cases {
1714            assert_eq!(
1715                bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
1716                Some(action),
1717                "{code:?} must switch modes in the shell"
1718            );
1719            assert_eq!(
1720                bindings.canonical_event(InputContext::Shell, action),
1721                KeyEvent::new(code, KeyModifiers::NONE),
1722                "{action:?} must canonicalize to its default chord"
1723            );
1724        }
1725        assert_eq!(
1726            bindings.action(
1727                InputContext::Shell,
1728                KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
1729            ),
1730            Some(TuiAction::ToggleNavigation)
1731        );
1732        // F7..F11 are reserved: the shell context binds nothing there. (F6 now
1733        // switches to the Repos mode, tix #1529.)
1734        for number in 7..=11 {
1735            assert_eq!(
1736                bindings.action(
1737                    InputContext::Shell,
1738                    KeyEvent::new(KeyCode::F(number), KeyModifiers::NONE)
1739                ),
1740                None,
1741                "F{number} must stay unbound in the shell"
1742            );
1743        }
1744    }
1745
1746    #[test]
1747    fn shell_conflict_reports_both_actions_and_mode() {
1748        let mut overrides = KeyBindings::default();
1749        overrides.shell.switch_to_board = Some(vec!["f2".into()]);
1750        overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
1751        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
1752        assert!(
1753            error.contains("shell")
1754                && error.contains("switch_to_board")
1755                && error.contains("switch_to_triage"),
1756            "got {error:?}"
1757        );
1758    }
1759}