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    /// The `F7` Stats page (mission `project-issue-health-status-01M3C8HX`, WP06;
45    /// the **Time** tab added by `per-issue-time-tracking-01M3C9A4`, WP05).
46    ///
47    /// Its own context for the reason `Repos`'s doc gives: `Tab`/`Shift+Tab` switch
48    /// between the page's two tabs, `j`/`k` walk the active tab's rows, `Enter` opens
49    /// the selected row's issue, and the Time tab's `a`/`e`/`x`/`f`/`s`/`h` act on its
50    /// entries and its scope. It carries no free text, so `q`/`esc` may close it. The
51    /// mutations are the Time tab's forms, which are routed through the shared time
52    /// service.
53    Stats,
54    /// The F8 Schedule page (tix #1593, mission `recurring-issues-01M3C7G7`).
55    ///
56    /// Its own context for the reason `Cycle`'s doc gives: `j`/`k` walk the series
57    /// rows, `g`/`p`/`r`/`x`/`s`/`d`/`i` act on the selected series, and it carries
58    /// no free text, so `q`/`esc` may close it.
59    Schedule,
60    /// The F11 Library page (tix #1593).
61    ///
62    /// Its own context for the reason `Cycle`'s doc gives: `Tab` switches between
63    /// its two registries rather than moving a board field, `Enter` applies the
64    /// selected view, and `j`/`k` walk rows. It carries no free text, so
65    /// `q`/`esc` may close it.
66    Library,
67    /// The F10 **Portfolio** page (WP06, tix #1593).
68    ///
69    /// Its own context for the reason `Cycle`'s doc gives: `Tab`/`Shift+Tab` switch
70    /// between the Projects and Epics registries, `j`/`k` walk the active tab's rows,
71    /// `h`/`l` walk the selected epic's members on the Epics tab, `Enter` opens the
72    /// selected member issue's detail directly (FR-004), and `q`/`esc` may close it
73    /// because it carries no free text.
74    Portfolio,
75    Rollover,
76    Detail,
77    Form,
78    FormPicker,
79    ConfirmDelete,
80    MoveIntoDone,
81    /// The blocked-move acknowledgement prompt (WP04). Its own context so the
82    /// command bar can advertise `y`/`esc` and nothing else.
83    ConfirmBlockedMove,
84    Help,
85    /// The keybindings overlay's `/` input line (tix #1496).
86    ///
87    /// Its own context for the reason [`InputContext::Filter`] is one: it reads **free
88    /// text**. Keeping the list mode's `q`/`?` bound to cancel here would rewrite them
89    /// to `esc` before the handler saw them, and a query could never contain a `q` —
90    /// the same trap the free-text modes below document.
91    HelpSearch,
92    ContextMenu,
93    /// The detail pane's `related_issues` picker (#1513).
94    ///
95    /// Its own context so the picker's `Enter` selects a related entry and does
96    /// not borrow any detail-pane key. It carries no free text, so `cancel` is
97    /// `Esc`; an unbound letter is a no-op rather than a filter character.
98    RelatedPicker,
99    Filter,
100    FilterModal,
101    Settings,
102    SettingsPrompt,
103    PlanningManagement,
104    PlanningConfirm,
105    PlanningForm,
106    DatePicker,
107    Comment,
108    Asset,
109    /// The editor round trip's repair prompt (tix #1506).
110    ///
111    /// Its own context: every key here is a decision about a file the editor just
112    /// produced — apply the proposed fix, declare the key, reopen the editor on
113    /// the retained copy, acknowledge and keep it, or discard it behind a second
114    /// confirmation. It is the successor to #1506's `edit_warning` context, which
115    /// the same prompt grew out of; there is deliberately only one such prompt.
116    EditRepair,
117    /// The edit-conflict prompt (tix #1510).
118    ///
119    /// Its own context for the reason [`InputContext::EditRepair`] has one: it is
120    /// a modal decision with three distinct outcomes, and one of them — *compare* —
121    /// reopens the editor, which no board key does. It carries no free text, so a
122    /// letter could close it — but only `Esc` (bound to `ignore`) does, because the
123    /// prompt closes through a decision and nothing else. `q` stays unbound.
124    EditConflict,
125    /// The two-step create/edit relationship prompt (#1359).
126    ///
127    /// Free text, so `cancel` is `Esc` and **only** `Esc` — see the note on the
128    /// `Asset`/`Comment`/`Filter` group for why binding `q` here would be a real
129    /// defect rather than a preference: a title fragment containing `q` is ordinary.
130    LinkPrompt,
131    /// The top-level mode shell (tui-mode-navigation).
132    ///
133    /// It binds only the five mode switches and the navigation toggle. It is
134    /// consulted **after** the active top-level context (Board, Triage,
135    /// PlanningManagement, Settings, Form) declines the pressed key, so a
136    /// context that binds the key locally always wins — which is what keeps
137    /// `F2` as `open_picker` inside a form and the `F(12)` sentinel untouched
138    /// for a truly unbound key.
139    Shell,
140    /// The saved-views modal (WP05, T024–T027).
141    ///
142    /// Its own context for the reason `Settings` has one: `n`, `e`, `u`, `d`, and
143    /// `x` mean "new view", "rename", "update from live", "duplicate", and
144    /// "delete view" in this modal and something else on the board. Nothing in the
145    /// list is typeable, so ordinary letters are free to be commands.
146    Views,
147    /// The saved-views modal's inline name prompt (WP05, T027).
148    ///
149    /// A separate context because it carries **free text**: a view named `dense`
150    /// must be a name and not a "duplicate" command, and a name containing `q`
151    /// must not close the prompt. Only `submit` and `cancel` are bound, which is
152    /// what leaves every other key a letter — the same arrangement, for the same
153    /// reason, as `SettingsPrompt`.
154    ViewsPrompt,
155}
156
157impl InputContext {
158    pub const ALL: &'static [Self] = &[
159        Self::Board,
160        Self::Rollover,
161        Self::Detail,
162        Self::Form,
163        Self::FormPicker,
164        Self::ConfirmDelete,
165        Self::MoveIntoDone,
166        Self::ConfirmBlockedMove,
167        Self::Help,
168        Self::HelpSearch,
169        Self::ContextMenu,
170        // Walked like every other context so the overlay's completeness guard
171        // sees the related picker's keys too (#1513).
172        Self::RelatedPicker,
173        Self::Filter,
174        Self::FilterModal,
175        Self::BulkModal,
176        Self::Triage,
177        Self::Cycle,
178        Self::Milestones,
179        // Walked like every other context so the overlay's guard sees the Repos
180        // mode's keys too (tix #1529).
181        Self::Repos,
182        // Walked like every other context so the guard sees the Stats page's keys
183        // too (mission `project-issue-health-status-01M3C8HX`).
184        Self::Stats,
185        // Walked like every other context so the guard sees the Schedule page's
186        // keys too (tix #1593).
187        Self::Schedule,
188        // Walked like every other context so the overlay's guard sees the
189        // Library page's keys too (tix #1593).
190        Self::Library,
191        // Walked like every other context so the guard sees the Portfolio page's
192        // keys too (WP06). A context the guard does not walk is a context whose
193        // keys can go undocumented.
194        Self::Portfolio,
195        Self::Settings,
196        Self::SettingsPrompt,
197        Self::PlanningManagement,
198        Self::PlanningConfirm,
199        Self::PlanningForm,
200        Self::DatePicker,
201        Self::Comment,
202        Self::Asset,
203        Self::EditRepair,
204        // Walked like every other context so the overlay's completeness guard
205        // sees the conflict prompt's keys too (tix #1510).
206        Self::EditConflict,
207        // Free text like the others, and missing from this list until the overlay's
208        // completeness guard needed it: a context the guard does not walk is a context
209        // whose keys can go undocumented.
210        Self::LinkPrompt,
211        // The mode shell's switches, walked like every other context so the
212        // completeness guard can see them.
213        Self::Shell,
214        Self::Views,
215        Self::ViewsPrompt,
216    ];
217
218    pub const fn as_str(self) -> &'static str {
219        match self {
220            Self::Board => "board",
221            Self::Rollover => "rollover",
222            Self::Detail => "detail",
223            Self::Form => "form",
224            Self::FormPicker => "form_picker",
225            Self::ConfirmDelete => "confirm_delete",
226            Self::ConfirmBlockedMove => "confirm_blocked_move",
227            Self::MoveIntoDone => "move_into_done",
228            Self::Help => "help",
229            Self::HelpSearch => "help_search",
230            Self::ContextMenu => "context_menu",
231            Self::RelatedPicker => "related_picker",
232            Self::Filter => "filter",
233            Self::FilterModal => "filter_modal",
234            Self::BulkModal => "bulk_modal",
235            Self::Settings => "settings",
236            Self::SettingsPrompt => "settings_prompt",
237            Self::PlanningManagement => "planning_management",
238            Self::PlanningConfirm => "planning_confirm",
239            Self::PlanningForm => "planning_form",
240            Self::DatePicker => "date_picker",
241            Self::Comment => "comment",
242            Self::Asset => "asset",
243            Self::EditRepair => "edit_repair",
244            Self::EditConflict => "edit_conflict",
245            Self::LinkPrompt => "link_prompt",
246            Self::Views => "views",
247            Self::ViewsPrompt => "views_prompt",
248            Self::Triage => "triage",
249            Self::Cycle => "cycle",
250            Self::Milestones => "milestones",
251            Self::Repos => "repos",
252            Self::Stats => "stats",
253            Self::Schedule => "schedule",
254            Self::Library => "library",
255            Self::Portfolio => "portfolio",
256            Self::Shell => "shell",
257        }
258    }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
262#[serde(rename_all = "snake_case")]
263pub enum TuiAction {
264    MoveUp,
265    MoveDown,
266    MoveLeft,
267    MoveRight,
268    NextBoard,
269    PreviousBoard,
270    Open,
271    Edit,
272    Agent,
273    /// Open the selected issue in a read-only viewer (tix #1507).
274    ///
275    /// `v` was free on both the board and the detail pane. Read-only by
276    /// construction: the viewer runs on the issue file and never writes back.
277    View,
278    ToggleColumn,
279    FilterModal,
280    BulkModal,
281    /// Open the triage inbox (WP06, T029). Its own action rather than a chord
282    /// bolted onto another one, so `t` can be rebound from `keybindings.board`
283    /// like every other board key and the help overlay can name it.
284    Triage,
285    /// Open the mentions view (OpenSpec change `issue-mentions`): every pending
286    /// `@mention` the local user holds, across issues.
287    ///
288    /// Its own action rather than a chord bolted onto another one, for the reason
289    /// `Triage`'s is: the chord is a rebindable board key, the help overlay can name
290    /// it, and the bottom indicator row reads it back out of the effective bindings
291    /// so the row cannot advertise a key the user has rebound away.
292    ///
293    /// The default is `i`, which was free on the board — `t` is the triage inbox, `v`
294    /// the saved views, `f` the filter, and the board's letters are its navigation.
295    OpenMentions,
296    /// Open the saved-views modal (WP05, T024). Its own action for the reason
297    /// `Triage`'s is: `v` is a rebindable board key and the help overlay names it.
298    Views,
299    /// Apply the view under the cursor (WP05, T025).
300    ApplyView,
301    /// Create a view from the live plan (WP05, T027).
302    NewView,
303    /// Rename the view under the cursor (WP05, T027).
304    RenameView,
305    /// Replace the view under the cursor's plan with the live plan (WP05, T027).
306    UpdateView,
307    /// Duplicate the view under the cursor under a fresh stable id (WP05, T027).
308    DuplicateView,
309    /// Delete the view under the cursor's **definition**, after confirmation
310    /// (WP05, T027).
311    DeleteView,
312    Mark,
313    Unmark,
314    MovePrevious,
315    MoveNext,
316    NewIssue,
317    /// Duplicate the selected issue into the create-issue form, prefilled from it
318    /// (tix #1484).
319    ///
320    /// `C`, the capital of `c` (toggle column): a pair that reads together without
321    /// changing what any existing key means, which is the same rule `B`/`A` follow.
322    CloneIssue,
323    /// Show the archived issues on the board, or hide them again (tix #1492).
324    ///
325    /// `a` was unbound on the board. The toggle is a *reload*, not a display
326    /// filter: the archived files are not read until it is on.
327    ToggleArchived,
328    /// Show each issue's sub-issues beneath it on the board, or hide them again
329    /// (tix #1399).
330    ///
331    /// `u` was unbound on the board. Unlike the archive toggle this is display-only:
332    /// the children are already in the board state, so flipping it **reorders** the
333    /// column (one level deep) rather than re-reading the store.
334    ToggleChildren,
335    Delete,
336    Refresh,
337    Settings,
338    Planning,
339    NewBoard,
340    Help,
341    Quit,
342    TextFilter,
343    ReorderUp,
344    ReorderDown,
345    Close,
346    ScrollUp,
347    ScrollDown,
348    Comment,
349    Asset,
350    /// Open the create-relationship prompt from the detail view (#1359).
351    NewRelationship,
352    /// Re-kind or retarget the relationship under the detail pane's cursor.
353    EditRelationship,
354    /// Remove the relationship under the cursor, after confirmation.
355    RemoveRelationship,
356    /// Set, change or clear the open issue's parent from the detail pane
357    /// (tix #1384).
358    ///
359    /// `P` was free in the detail pane. The prompt routes its write through the
360    /// shared hierarchy service (`Store::set_parent`/`clear_parent`), which is
361    /// the same seam `tix edit --parent` uses, so the TUI gains the
362    /// set/change/clear parity for `parent` that the create-only form could not
363    /// express.
364    EditParent,
365    /// Move the detail pane's relationship cursor.
366    NextRelationship,
367    PreviousRelationship,
368    /// Open the picker listing the detail pane's `related_issues` entries
369    /// (tix #1513).
370    ///
371    /// `r` was free in the detail pane — the board's `r` is `refresh`, a
372    /// different context, so the two never collide.
373    RelatedPicker,
374    /// Follow the detail pane's selected `related_issues` entry (tix #1513).
375    ///
376    /// `o` was free in the detail pane and reads as "open".
377    FollowRelated,
378    /// Pop the detail pane's navigation stack and restore the previous issue
379    /// (tix #1513).
380    ///
381    /// `Backspace` was free in the detail pane and reads as "back".
382    NavBack,
383    Submit,
384    Cancel,
385    Next,
386    Previous,
387    OpenPicker,
388    Reset,
389    Toggle,
390    AddColumn,
391    /// Move the Config mode's selected board column one position earlier
392    /// (`shift+k` by default, tui-config-mode).
393    MoveColumnUp,
394    /// Move the Config mode's selected board column one position later
395    /// (`shift+j` by default, tui-config-mode).
396    MoveColumnDown,
397    NewPlanningItem,
398    EditPlanningItem,
399    DeletePlanningItem,
400    Confirm,
401    Create,
402    PreviousYear,
403    NextYear,
404    PreviousMonth,
405    NextMonth,
406    Reedit,
407    Ignore,
408    /// Declare the repair prompt's undeclared frontmatter key in `tix.toml`
409    /// (tix #1506).
410    ///
411    /// Its own action, not `confirm`: `confirm` applies whatever fix the prompt
412    /// proposes, which *happens* to be the declaration for an unknown key, while
413    /// `declare_key` always means "write it into `[custom_attributes]`" and says
414    /// so when there is no key to declare.
415    DeclareKey,
416    /// Discard the repair prompt's retained copy, after a second confirmation
417    /// (tix #1506).
418    ///
419    /// The **only** action that removes the editor's copy. The first press arms
420    /// the prompt; a second press is the decision. `Esc` deliberately
421    /// acknowledges and keeps the copy instead, so a stray key cannot destroy a
422    /// user's edit.
423    Discard,
424    /// Switch the top-level shell to the Board mode (F1 by default).
425    SwitchToBoard,
426    /// Switch the top-level shell to the Triage mode (F2 by default).
427    SwitchToTriage,
428    /// Switch the top-level shell to the Cycle mode (F4 by default).
429    SwitchToCycle,
430    /// Switch the top-level shell to the Milestones mode (F5 by default).
431    SwitchToMilestones,
432    /// Switch the top-level shell to the Repos mode (F6 by default, tix #1529).
433    ///
434    /// F6 was free in the shell and sits directly after the milestone switch, so
435    /// the repository list reads as the last of the managed registries.
436    SwitchToRepos,
437    /// Switch the top-level shell to the Stats page (F7 by default).
438    ///
439    /// F7 was reserved in the shell until this page existed; the Stats page takes it,
440    /// and its first tab is Health (mission `project-issue-health-status-01M3C8HX`).
441    SwitchToStats,
442    /// Open the health form for the issue in the detail pane.
443    ///
444    /// One action, two layers: the form's own field chooses whether the value is the
445    /// issue's `current` state or an `override` layered over its derived one, so a
446    /// user does not have to learn two key paths that do the same thing with one
447    /// field different.
448    HealthForm,
449    /// Open the add-entry form on the `F7` Stats page's **Time** tab (mission
450    /// `per-issue-time-tracking-01M3C9A4`, WP05 T025).
451    ///
452    /// One of six keys the Time tab contributes to [`InputContext::Stats`]. The key
453    /// opens a form and writes nothing; the form's `Enter` is the only path that
454    /// reaches the shared time service, so a stray key cannot record an entry.
455    TimeAdd,
456    /// Open the correction form for the Time tab's selected entry (WP05 T025).
457    TimeEdit,
458    /// Open the deletion form for the Time tab's selected entry (WP05 T025).
459    ///
460    /// A deletion removes the entry from the issue's current projection; the form asks
461    /// for a reason and a confirmation, and History keeps the prior value (C-003,
462    /// C-004).
463    TimeDelete,
464    /// Edit the Time tab's scope: issue, assignee, period (WP05 T026).
465    ///
466    /// It edits the **shared** `TimeFilter` the CLI's `--issue/--assignee/--from/--to`
467    /// build, so a filter has one grammar and one validator.
468    TimeFilter,
469    /// Cycle the Time tab's breakdown dimension: all, issue, assignee, date
470    /// (WP05 T026).
471    TimeBreakdown,
472    /// Toggle the Time tab's audit view (WP05 T027): what the current projection no
473    /// longer holds but History still attributes.
474    TimeHistory,
475    /// Switch to the F8 Schedule page (tix #1593).
476    SwitchToSchedule,
477    /// Generate the selected series' due occurrence (tix #1593).
478    SeriesGenerate,
479    /// Pause the selected series.
480    SeriesPause,
481    /// Resume the selected series.
482    SeriesResume,
483    /// End the selected series.
484    SeriesEnd,
485    /// Record the selected series' due date as skipped.
486    SeriesSkip,
487    /// Move the selected series' candidate past its due date.
488    SeriesDefer,
489    /// Stop offering the selected series' due date.
490    SeriesIgnore,
491    /// Define a new recurring series (tix #1593).
492    ///
493    /// The Series tab's `n`: it opens the page's create form, whose save reaches the
494    /// same `tix recurring create` service the CLI runs, with the same rule and anchor
495    /// parsing and the same validation order.
496    SeriesNew,
497    /// Switch to the F11 Library page (tix #1593).
498    SwitchToLibrary,
499    /// Switch to the F10 **Portfolio** page (WP06).
500    ///
501    /// F10 was reserved in the shell until this page existed; the Portfolio page's
502    /// Projects tab takes it.
503    SwitchToPortfolio,
504    /// Switch the top-level shell to the New Issue mode (F1 by default).
505    SwitchToNewIssue,
506    /// Switch the top-level shell to the Config mode (F12 by default).
507    SwitchToConfig,
508    /// Cycle the vertical navigation display state `Off`/`Small`/`Default`.
509    ToggleNavigation,
510}
511
512impl TuiAction {
513    pub const fn as_str(self) -> &'static str {
514        match self {
515            Self::MoveUp => "move_up",
516            Self::MoveDown => "move_down",
517            Self::MoveLeft => "move_left",
518            Self::MoveRight => "move_right",
519            Self::NextBoard => "next_board",
520            Self::PreviousBoard => "previous_board",
521            Self::Open => "open",
522            Self::Edit => "edit",
523            Self::Agent => "agent",
524            Self::View => "view",
525            Self::ToggleColumn => "toggle_column",
526            Self::OpenMentions => "open_mentions",
527            Self::FilterModal => "filter_modal",
528            Self::BulkModal => "bulk_modal",
529            Self::Triage => "triage",
530            Self::Views => "views",
531            Self::ApplyView => "apply_view",
532            Self::NewView => "new_view",
533            Self::RenameView => "rename_view",
534            Self::UpdateView => "update_view",
535            Self::DuplicateView => "duplicate_view",
536            Self::DeleteView => "delete_view",
537            Self::Mark => "mark",
538            Self::Unmark => "unmark",
539            Self::MovePrevious => "move_previous",
540            Self::MoveNext => "move_next",
541            Self::NewIssue => "new_issue",
542            Self::CloneIssue => "clone_issue",
543            Self::ToggleArchived => "toggle_archived",
544            Self::ToggleChildren => "toggle_children",
545            Self::Delete => "delete",
546            Self::Refresh => "refresh",
547            Self::Settings => "settings",
548            Self::Planning => "planning",
549            Self::NewBoard => "new_board",
550            Self::Help => "help",
551            Self::Quit => "quit",
552            Self::TextFilter => "text_filter",
553            Self::ReorderUp => "reorder_up",
554            Self::ReorderDown => "reorder_down",
555            Self::Close => "close",
556            Self::ScrollUp => "scroll_up",
557            Self::ScrollDown => "scroll_down",
558            Self::Comment => "comment",
559            Self::Asset => "asset",
560            Self::NewRelationship => "new_relationship",
561            Self::EditRelationship => "edit_relationship",
562            Self::RemoveRelationship => "remove_relationship",
563            Self::EditParent => "edit_parent",
564            Self::NextRelationship => "next_relationship",
565            Self::PreviousRelationship => "previous_relationship",
566            Self::RelatedPicker => "related_picker",
567            Self::FollowRelated => "follow_related",
568            Self::NavBack => "nav_back",
569            Self::Submit => "submit",
570            Self::Cancel => "cancel",
571            Self::Next => "next",
572            Self::Previous => "previous",
573            Self::OpenPicker => "open_picker",
574            Self::Reset => "reset",
575            Self::Toggle => "toggle",
576            Self::AddColumn => "add_column",
577            Self::MoveColumnUp => "move_column_up",
578            Self::MoveColumnDown => "move_column_down",
579            Self::NewPlanningItem => "new_planning_item",
580            Self::EditPlanningItem => "edit_planning_item",
581            Self::DeletePlanningItem => "delete_planning_item",
582            Self::Confirm => "confirm",
583            Self::Create => "create",
584            Self::PreviousYear => "previous_year",
585            Self::NextYear => "next_year",
586            Self::PreviousMonth => "previous_month",
587            Self::NextMonth => "next_month",
588            Self::Reedit => "reedit",
589            Self::Ignore => "ignore",
590            Self::DeclareKey => "declare_key",
591            Self::Discard => "discard",
592            Self::SwitchToBoard => "switch_to_board",
593            Self::SwitchToTriage => "switch_to_triage",
594            Self::SwitchToCycle => "switch_to_cycle",
595            Self::SwitchToMilestones => "switch_to_milestones",
596            Self::SwitchToRepos => "switch_to_repos",
597            Self::SwitchToStats => "switch_to_stats",
598            Self::HealthForm => "health_form",
599            Self::TimeAdd => "time_add",
600            Self::TimeEdit => "time_edit",
601            Self::TimeDelete => "time_delete",
602            Self::TimeFilter => "time_filter",
603            Self::TimeBreakdown => "time_breakdown",
604            Self::TimeHistory => "time_history",
605            Self::SwitchToSchedule => "switch_to_schedule",
606            Self::SeriesGenerate => "series_generate",
607            Self::SeriesPause => "series_pause",
608            Self::SeriesResume => "series_resume",
609            Self::SeriesEnd => "series_end",
610            Self::SeriesSkip => "series_skip",
611            Self::SeriesDefer => "series_defer",
612            Self::SeriesIgnore => "series_ignore",
613            Self::SeriesNew => "series_new",
614            Self::SwitchToLibrary => "switch_to_library",
615            Self::SwitchToPortfolio => "switch_to_portfolio",
616            Self::SwitchToNewIssue => "switch_to_new_issue",
617            Self::SwitchToConfig => "switch_to_config",
618            Self::ToggleNavigation => "toggle_navigation",
619        }
620    }
621
622    pub const fn description(self) -> &'static str {
623        match self {
624            Self::MoveUp => "move up",
625            Self::MoveDown => "move down",
626            Self::MoveLeft => "previous column",
627            Self::MoveRight => "next column",
628            Self::NextBoard => "next board",
629            Self::PreviousBoard => "previous board",
630            Self::Open => "open detail",
631            Self::Edit => "edit",
632            Self::Agent => "open agent",
633            Self::View => "view issue",
634            Self::ToggleColumn => "toggle column",
635            Self::FilterModal => "filter criteria",
636            Self::BulkModal => "batch select",
637            Self::Triage => "triage inbox",
638            Self::OpenMentions => "mentions",
639            Self::Views => "saved views",
640            Self::ApplyView => "apply view",
641            Self::NewView => "new view",
642            Self::RenameView => "rename view",
643            Self::UpdateView => "update view from live",
644            Self::DuplicateView => "duplicate view",
645            Self::DeleteView => "delete view",
646            Self::Mark => "mark for batch",
647            Self::Unmark => "unmark for batch",
648            Self::MovePrevious => "move to previous column",
649            Self::MoveNext => "move to next column",
650            Self::NewIssue => "new issue",
651            Self::CloneIssue => "clone issue",
652            Self::ToggleArchived => "show archived issues",
653            Self::ToggleChildren => "toggle sub-issues",
654            Self::Delete => "delete",
655            Self::Refresh => "refresh",
656            Self::Settings => "settings",
657            Self::Planning => "planning",
658            Self::NewBoard => "new board",
659            Self::Help => "help",
660            Self::Quit => "quit",
661            Self::TextFilter => "search",
662            Self::ReorderUp => "reorder up",
663            Self::ReorderDown => "reorder down",
664            Self::Close => "close",
665            Self::ScrollUp => "scroll up one page",
666            Self::ScrollDown => "scroll down one page",
667            Self::Comment => "comment",
668            Self::Asset => "asset",
669            Self::NewRelationship => "new relationship",
670            Self::EditRelationship => "edit relationship",
671            Self::RemoveRelationship => "remove relationship",
672            Self::EditParent => "set parent",
673            Self::NextRelationship => "next relationship",
674            Self::PreviousRelationship => "previous relationship",
675            Self::RelatedPicker => "related issues",
676            Self::FollowRelated => "follow related issue",
677            Self::NavBack => "back",
678            Self::Submit => "submit",
679            Self::Cancel => "cancel",
680            Self::Next => "next",
681            Self::Previous => "previous",
682            Self::OpenPicker => "open picker",
683            Self::Reset => "reset",
684            Self::Toggle => "toggle",
685            Self::AddColumn => "add column",
686            Self::MoveColumnUp => "move column up",
687            Self::MoveColumnDown => "move column down",
688            Self::NewPlanningItem => "new",
689            Self::EditPlanningItem => "edit",
690            Self::DeletePlanningItem => "delete",
691            Self::Confirm => "confirm",
692            Self::Create => "create",
693            Self::PreviousYear => "previous year",
694            Self::NextYear => "next year",
695            Self::PreviousMonth => "previous month",
696            Self::NextMonth => "next month",
697            Self::Reedit => "re-edit",
698            Self::Ignore => "ignore",
699            Self::DeclareKey => "declare the key in tix.toml",
700            Self::Discard => "discard the edit",
701            Self::SwitchToBoard => "board mode",
702            Self::SwitchToTriage => "triage mode",
703            Self::SwitchToCycle => "cycle mode",
704            Self::SwitchToMilestones => "milestones mode",
705            Self::SwitchToRepos => "repos mode",
706            Self::SwitchToStats => "the Stats page",
707            Self::HealthForm => "record this issue's health",
708            Self::TimeAdd => "add a time entry",
709            Self::TimeEdit => "correct the selected entry",
710            Self::TimeDelete => "delete the selected entry",
711            Self::TimeFilter => "edit the time filter",
712            Self::TimeBreakdown => "cycle the breakdown",
713            Self::TimeHistory => "show the audit view",
714            Self::SwitchToSchedule => "schedule mode",
715            Self::SeriesGenerate => "generate the due occurrence",
716            Self::SeriesPause => "pause the series",
717            Self::SeriesResume => "resume the series",
718            Self::SeriesEnd => "end the series",
719            Self::SeriesSkip => "skip the due date",
720            Self::SeriesDefer => "defer the due date",
721            Self::SeriesIgnore => "ignore the due date",
722            Self::SeriesNew => "create a new series",
723            Self::SwitchToLibrary => "library mode",
724            Self::SwitchToPortfolio => "portfolio mode",
725            Self::SwitchToNewIssue => "new issue mode",
726            Self::SwitchToConfig => "config mode",
727            Self::ToggleNavigation => "toggle navigation",
728        }
729    }
730
731    /// The wording this action gets in `context`'s help.
732    ///
733    /// `move_left`/`move_right` walk a **column** on the board, the Config
734    /// mode's two **panes** (rail ⇄ body), and a **group** in the Cycle
735    /// listing; `submit` steps into the body and then activates the row there.
736    /// The action — and the key — is the same event, so only the noun the help
737    /// shows changes. Every other action keeps its single description.
738    pub const fn description_in(self, context: InputContext) -> &'static str {
739        match (context, self) {
740            (InputContext::Cycle, Self::MoveLeft) => "previous group",
741            (InputContext::Cycle, Self::MoveRight) => "next group",
742            // tix #1593: on the Library page the two directions step between its
743            // registries, not between rows.
744            (InputContext::Library, Self::Previous) => "previous tab",
745            (InputContext::Library, Self::Next) => "next tab",
746            (InputContext::Library, Self::Submit) => "apply the selected view",
747            // WP05: both of the Stats page's tabs open the selected row's issue, so the
748            // one description is honest for both — the Health tab has no other meaning
749            // for `Enter`, and the Time tab's row names an entry that belongs to one.
750            (InputContext::Stats, Self::Submit) => "open the row's issue",
751            (InputContext::Settings, Self::MoveLeft) => "previous pane",
752            (InputContext::Settings, Self::MoveRight) => "next pane",
753            (InputContext::Settings, Self::Submit) => "step into the body / activate row",
754            // The filter modal applies ad-hoc: a pick takes effect at once and
755            // closing keeps it, so both exits mean "apply and close" and the generic
756            // "cancel" would read as a discard that no longer exists.
757            (InputContext::FilterModal, Self::OpenPicker) => "open the focused row's picker",
758            (InputContext::FilterModal, Self::Submit) => "apply and close",
759            (InputContext::FilterModal, Self::Cancel) => "apply and close",
760            // #1506. Every key in the repair prompt is a decision about the file
761            // the editor just produced, and "confirm"/"ignore" say nothing about
762            // which one — the overlay has to spell out each.
763            (InputContext::EditRepair, Self::Confirm) => "apply the proposed fix",
764            (InputContext::EditRepair, Self::DeclareKey) => "declare the key in tix.toml",
765            (InputContext::EditRepair, Self::Reedit) => "reopen the editor on the retained copy",
766            (InputContext::EditRepair, Self::Ignore) => "acknowledge (keeps the copy)",
767            (InputContext::EditRepair, Self::Discard) => "discard the edit (asks again)",
768            // #1510. The conflict prompt reuses the edit warning's three actions:
769            // each is the same *kind* of decision, so what the key means here is
770            // what the wording has to spell out.
771            (InputContext::EditConflict, Self::Confirm) => "overwrite the file with this edit",
772            (InputContext::EditConflict, Self::Ignore) => "keep the on-disk version",
773            (InputContext::EditConflict, Self::Reedit) => "compare in the editor",
774            _ => self.description(),
775        }
776    }
777}
778
779pub type Binding = Vec<String>;
780
781macro_rules! partial_bindings {
782    ($($field:ident),+ $(,)?) => {
783        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
784        #[serde(default, deny_unknown_fields)]
785        pub struct ActionBindings {
786            $(
787                #[serde(skip_serializing_if = "Option::is_none")]
788                pub $field: Option<Binding>,
789            )+
790        }
791    };
792}
793
794partial_bindings!(
795    // The mentions view (OpenSpec change `issue-mentions`): a rebindable board
796    // action, so `[keybindings.board] open_mentions = [...]` works like every other
797    // board key and the bottom indicator row can read back the chord in force.
798    open_mentions,
799    move_up,
800    move_down,
801    move_left,
802    move_right,
803    next_board,
804    previous_board,
805    open,
806    edit,
807    agent,
808    view,
809    toggle_column,
810    filter_modal,
811    bulk_modal,
812    triage,
813    views,
814    apply_view,
815    new_view,
816    rename_view,
817    update_view,
818    duplicate_view,
819    delete_view,
820    mark,
821    unmark,
822    move_previous,
823    move_next,
824    new_issue,
825    clone_issue,
826    toggle_archived,
827    toggle_children,
828    delete,
829    refresh,
830    settings,
831    planning,
832    new_board,
833    help,
834    quit,
835    text_filter,
836    reorder_up,
837    reorder_down,
838    close,
839    scroll_up,
840    scroll_down,
841    comment,
842    asset,
843    edit_parent,
844    submit,
845    cancel,
846    next,
847    previous,
848    open_picker,
849    reset,
850    toggle,
851    add_column,
852    move_column_up,
853    move_column_down,
854    new_planning_item,
855    edit_planning_item,
856    delete_planning_item,
857    confirm,
858    create,
859    previous_year,
860    next_year,
861    previous_month,
862    next_month,
863    reedit,
864    ignore,
865    declare_key,
866    discard,
867    switch_to_board,
868    switch_to_triage,
869    switch_to_cycle,
870    switch_to_milestones,
871    switch_to_repos,
872    switch_to_stats,
873    health_form,
874    // Mission `per-issue-time-tracking-01M3C9A4` (WP05): the Time tab's six keys, one
875    // action each so every key is rebindable through `[keybindings.stats]`, appears in
876    // the help overlay, and has its own canonical event the handler can read.
877    time_add,
878    time_edit,
879    time_delete,
880    time_filter,
881    time_breakdown,
882    time_history,
883    // tix #1593.
884    switch_to_schedule,
885    // tix #1593: the F8 Schedule page's seven keyed actions, one action each so
886    // every key is rebindable, appears in the help overlay, and has its own
887    // canonical event the handler can read.
888    series_generate,
889    series_pause,
890    series_resume,
891    series_end,
892    series_skip,
893    series_defer,
894    series_ignore,
895    series_new,
896    // tix #1593.
897    switch_to_library,
898    switch_to_portfolio,
899    switch_to_new_issue,
900    switch_to_config,
901    toggle_navigation,
902);
903
904macro_rules! keybindings_config {
905    ($($field:ident => $context:ident),+ $(,)?) => {
906        #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
907        #[serde(default, deny_unknown_fields)]
908        pub struct KeyBindings {
909            $(
910                #[serde(skip_serializing_if = "ActionBindings::is_empty")]
911                pub $field: ActionBindings,
912            )+
913        }
914
915        impl KeyBindings {
916            pub fn is_empty(&self) -> bool {
917                self == &Self::default()
918            }
919
920            fn overrides(&self, context: InputContext) -> &ActionBindings {
921                match context {
922                    $(InputContext::$context => &self.$field,)+
923                }
924            }
925        }
926    };
927}
928
929keybindings_config!(
930    board => Board,
931    rollover => Rollover,
932    detail => Detail,
933    form => Form,
934    form_picker => FormPicker,
935    confirm_delete => ConfirmDelete,
936    confirm_blocked_move => ConfirmBlockedMove,
937    help_search => HelpSearch,
938    move_into_done => MoveIntoDone,
939    help => Help,
940    context_menu => ContextMenu,
941    // #1513: the related-issues picker's own section, so its keys are rebindable
942    // through `[keybindings.related_picker]` like every other context's.
943    related_picker => RelatedPicker,
944    filter => Filter,
945    filter_modal => FilterModal,
946    bulk_modal => BulkModal,
947    triage => Triage,
948    cycle => Cycle,
949    milestones => Milestones,
950    // tix #1529: the F6 Repos mode's own section, so its keys are rebindable
951    // through `[keybindings.repos]` like every other context's.
952    repos => Repos,
953    // Mission `project-issue-health-status-01M3C8HX`: the F7 Stats page's own
954    // section, so its keys are rebindable through `[keybindings.stats]`.
955    stats => Stats,
956    // tix #1593: the F8 Schedule page's own section, so its keys are rebindable
957    // through `[keybindings.schedule]` like every other context's.
958    schedule => Schedule,
959    // tix #1593: the F11 Library page's own section, so its keys are rebindable
960    // through `[keybindings.library]` like every other context's.
961    library => Library,
962    // WP06: the F10 Portfolio page's own section, so its keys are rebindable
963    // through `[keybindings.portfolio]` like every other context's.
964    portfolio => Portfolio,
965    settings => Settings,
966    settings_prompt => SettingsPrompt,
967    planning_management => PlanningManagement,
968    planning_confirm => PlanningConfirm,
969    planning_form => PlanningForm,
970    date_picker => DatePicker,
971    comment => Comment,
972    asset => Asset,
973    edit_repair => EditRepair,
974    edit_conflict => EditConflict,
975    link_prompt => LinkPrompt,
976    shell => Shell,
977    views => Views,
978    views_prompt => ViewsPrompt,
979);
980
981#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
982pub struct KeyChord {
983    pub code: KeyCode,
984    pub modifiers: KeyModifiers,
985}
986
987impl KeyChord {
988    pub fn parse(value: &str) -> std::result::Result<Self, String> {
989        let value = value.trim();
990        if value.is_empty() {
991            return Err("key chord must not be empty".into());
992        }
993        if value.chars().count() == 1 {
994            let ch = value.chars().next().expect("value has one character");
995            if ch.is_control() {
996                return Err(format!("unsupported key name `{value}`"));
997            }
998            let modifiers = if ch.is_ascii_uppercase() {
999                KeyModifiers::SHIFT
1000            } else {
1001                KeyModifiers::NONE
1002            };
1003            return Ok(Self {
1004                code: KeyCode::Char(ch),
1005                modifiers,
1006            });
1007        }
1008        let mut parts = value.split('+');
1009        let key = parts.next_back().ok_or("missing key name")?.trim();
1010        let mut modifiers = KeyModifiers::NONE;
1011        for part in parts {
1012            match part.trim().to_ascii_lowercase().as_str() {
1013                "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
1014                "alt" | "option" => modifiers |= KeyModifiers::ALT,
1015                "shift" => modifiers |= KeyModifiers::SHIFT,
1016                "super" | "cmd" | "command" => modifiers |= KeyModifiers::SUPER,
1017                other => return Err(format!("unsupported modifier `{other}`")),
1018            }
1019        }
1020        if key.is_empty() {
1021            return Err("missing key name".into());
1022        }
1023        let mut code = match key.to_ascii_lowercase().as_str() {
1024            "backtab" => return Err("use shift+tab instead of `backtab`".into()),
1025            "shift+tab" => KeyCode::BackTab,
1026            "backspace" => KeyCode::Backspace,
1027            "tab" => KeyCode::Tab,
1028            "enter" | "return" => KeyCode::Enter,
1029            "esc" | "escape" => KeyCode::Esc,
1030            "space" => KeyCode::Char(' '),
1031            "left" => KeyCode::Left,
1032            "right" => KeyCode::Right,
1033            "up" => KeyCode::Up,
1034            "down" => KeyCode::Down,
1035            "home" => KeyCode::Home,
1036            "end" => KeyCode::End,
1037            "pageup" | "pgup" => KeyCode::PageUp,
1038            "pagedown" | "pgdn" | "pgdown" => KeyCode::PageDown,
1039            "delete" | "del" => KeyCode::Delete,
1040            "insert" | "ins" => KeyCode::Insert,
1041            _ => {
1042                if let Some(number) = key
1043                    .to_ascii_lowercase()
1044                    .strip_prefix('f')
1045                    .and_then(|n| n.parse::<u8>().ok())
1046                {
1047                    if (1..=12).contains(&number) {
1048                        KeyCode::F(number)
1049                    } else {
1050                        return Err(format!("unsupported function key `{key}`"));
1051                    }
1052                } else {
1053                    let mut chars = key.chars();
1054                    let Some(ch) = chars.next().filter(|_| chars.next().is_none()) else {
1055                        return Err(format!("unsupported key name `{key}`"));
1056                    };
1057                    if ch.is_control() {
1058                        return Err(format!("unsupported key name `{key}`"));
1059                    }
1060                    if ch.is_ascii_uppercase() {
1061                        modifiers |= KeyModifiers::SHIFT;
1062                    }
1063                    KeyCode::Char(ch)
1064                }
1065            }
1066        };
1067        if code == KeyCode::Tab && modifiers.contains(KeyModifiers::SHIFT) {
1068            code = KeyCode::BackTab;
1069            modifiers.remove(KeyModifiers::SHIFT);
1070        }
1071        if code == KeyCode::BackTab {
1072            modifiers |= KeyModifiers::SHIFT;
1073        }
1074        Ok(Self { code, modifiers })
1075    }
1076
1077    pub fn matches(&self, event: KeyEvent) -> bool {
1078        if self.code != event.code {
1079            return false;
1080        }
1081        if self.modifiers.is_empty() {
1082            return event.modifiers.is_empty();
1083        }
1084        if self.code == KeyCode::BackTab {
1085            return event.modifiers.contains(KeyModifiers::SHIFT) || event.code == KeyCode::BackTab;
1086        }
1087        if let (KeyCode::Char(expected), KeyCode::Char(actual)) = (self.code, event.code) {
1088            let shift_expected = self.modifiers.contains(KeyModifiers::SHIFT);
1089            let normalized_expected = if shift_expected {
1090                expected.to_ascii_lowercase()
1091            } else {
1092                expected
1093            };
1094            let shift_actual =
1095                actual.is_uppercase() || event.modifiers.contains(KeyModifiers::SHIFT);
1096            let expected_other = self.modifiers.difference(KeyModifiers::SHIFT);
1097            let actual_other = event.modifiers.difference(KeyModifiers::SHIFT);
1098            return expected_other == actual_other
1099                && normalized_expected == actual.to_ascii_lowercase()
1100                && shift_expected == shift_actual;
1101        }
1102        event.modifiers == self.modifiers
1103    }
1104
1105    pub fn display(&self) -> String {
1106        let mut out = String::new();
1107        if self.modifiers.contains(KeyModifiers::CONTROL) {
1108            out.push_str("Ctrl+");
1109        }
1110        if self.modifiers.contains(KeyModifiers::ALT) {
1111            out.push_str("Alt+");
1112        }
1113        if self.modifiers.contains(KeyModifiers::SUPER) {
1114            out.push_str("Super+");
1115        }
1116        if self.modifiers.contains(KeyModifiers::SHIFT) && self.code != KeyCode::BackTab {
1117            out.push_str("Shift+");
1118        }
1119        out.push_str(&match self.code {
1120            KeyCode::BackTab => "Shift+Tab".into(),
1121            KeyCode::Char(' ') => "Space".into(),
1122            KeyCode::Char(ch) => ch.to_string(),
1123            KeyCode::Enter => "Enter".into(),
1124            KeyCode::Esc => "Esc".into(),
1125            KeyCode::Left => "Left".into(),
1126            KeyCode::Right => "Right".into(),
1127            KeyCode::Up => "Up".into(),
1128            KeyCode::Down => "Down".into(),
1129            KeyCode::PageUp => "PgUp".into(),
1130            KeyCode::PageDown => "PgDn".into(),
1131            KeyCode::Delete => "Delete".into(),
1132            KeyCode::Insert => "Insert".into(),
1133            KeyCode::Home => "Home".into(),
1134            KeyCode::End => "End".into(),
1135            KeyCode::F(n) => format!("F{n}"),
1136            other => format!("{other:?}"),
1137        });
1138        out
1139    }
1140}
1141
1142impl Serialize for KeyChord {
1143    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
1144    where
1145        S: Serializer,
1146    {
1147        serializer.serialize_str(&self.display())
1148    }
1149}
1150
1151impl<'de> Deserialize<'de> for KeyChord {
1152    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
1153    where
1154        D: Deserializer<'de>,
1155    {
1156        let value = String::deserialize(deserializer)?;
1157        Self::parse(&value).map_err(D::Error::custom)
1158    }
1159}
1160
1161impl fmt::Display for KeyChord {
1162    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1163        formatter.write_str(&self.display())
1164    }
1165}
1166
1167#[derive(Debug, Clone, Default, PartialEq, Eq)]
1168pub struct EffectiveBindings {
1169    modes: HashMap<InputContext, HashMap<TuiAction, Vec<KeyChord>>>,
1170}
1171
1172fn chord(value: &str) -> KeyChord {
1173    KeyChord::parse(value).expect("built-in key chord must parse")
1174}
1175
1176fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
1177    map.insert(action, values.iter().map(|value| chord(value)).collect());
1178}
1179
1180impl KeyBindings {
1181    pub fn resolve(overrides: &KeyBindings) -> Result<EffectiveBindings> {
1182        let mut effective = EffectiveBindings::default();
1183        for context in InputContext::ALL {
1184            let mut mode = default_mode(*context);
1185            overlay(&mut mode, overrides.overrides(*context), *context)?;
1186            effective.modes.insert(*context, mode);
1187        }
1188        Ok(effective)
1189    }
1190}
1191
1192impl EffectiveBindings {
1193    pub fn action(&self, context: InputContext, event: KeyEvent) -> Option<TuiAction> {
1194        self.modes
1195            .get(&context)?
1196            .iter()
1197            .find_map(|(action, chords)| {
1198                chords
1199                    .iter()
1200                    .any(|key| key.matches(event))
1201                    .then_some(*action)
1202            })
1203    }
1204
1205    /// Every action this context binds, sorted by name so the order is stable.
1206    ///
1207    /// For the help overlay's completeness guard: that overlay's per-context action
1208    /// list is written by hand, and a binding it does not name is a key that works and
1209    /// that nobody can discover.
1210    pub fn bound_actions(&self, context: InputContext) -> Vec<TuiAction> {
1211        let mut actions: Vec<TuiAction> = self
1212            .modes
1213            .get(&context)
1214            .map(|mode| mode.keys().copied().collect())
1215            .unwrap_or_default();
1216        actions.sort_by_key(|action| action.as_str());
1217        actions
1218    }
1219
1220    pub fn chords(&self, context: InputContext, action: TuiAction) -> &[KeyChord] {
1221        self.modes
1222            .get(&context)
1223            .and_then(|mode| mode.get(&action))
1224            .map(Vec::as_slice)
1225            .unwrap_or_default()
1226    }
1227
1228    pub fn label(&self, context: InputContext, action: TuiAction) -> String {
1229        let labels: Vec<String> = self
1230            .chords(context, action)
1231            .iter()
1232            .map(ToString::to_string)
1233            .collect();
1234        if labels.is_empty() {
1235            "—".into()
1236        } else {
1237            labels.join(" / ")
1238        }
1239    }
1240
1241    pub fn canonical_event(&self, context: InputContext, action: TuiAction) -> KeyEvent {
1242        let (code, modifiers) = match (context, action) {
1243            (_, TuiAction::ReorderUp) => (KeyCode::Up, KeyModifiers::CONTROL),
1244            (_, TuiAction::ReorderDown) => (KeyCode::Down, KeyModifiers::CONTROL),
1245            (InputContext::Rollover, TuiAction::Previous) => (KeyCode::Tab, KeyModifiers::NONE),
1246            (InputContext::PlanningManagement, TuiAction::Next) => {
1247                (KeyCode::Tab, KeyModifiers::NONE)
1248            }
1249            (InputContext::FormPicker, TuiAction::Next) => (KeyCode::Up, KeyModifiers::NONE),
1250            (InputContext::FormPicker, TuiAction::Previous) => (KeyCode::Down, KeyModifiers::NONE),
1251            (InputContext::DatePicker, TuiAction::PreviousYear) => {
1252                (KeyCode::Char('['), KeyModifiers::NONE)
1253            }
1254            (InputContext::DatePicker, TuiAction::NextYear) => {
1255                (KeyCode::Char(']'), KeyModifiers::NONE)
1256            }
1257            (InputContext::DatePicker, TuiAction::PreviousMonth) => {
1258                (KeyCode::PageUp, KeyModifiers::NONE)
1259            }
1260            (InputContext::DatePicker, TuiAction::NextMonth) => {
1261                (KeyCode::PageDown, KeyModifiers::NONE)
1262            }
1263            (InputContext::DatePicker, TuiAction::Reset) => (KeyCode::Delete, KeyModifiers::NONE),
1264            // tix #1593: on the Library page `next`/`previous` step between the
1265            // two registries, so they must arrive as Tab/BackTab — the generic
1266            // arms below fold them into Down/Up, which the page's own `j`/`k`
1267            // already own and which would make the tab keys indistinguishable
1268            // from the row keys.
1269            (InputContext::Library, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1270            (InputContext::Library, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1271            // WP06: the Portfolio page reads two registries too, so its tab keys
1272            // must arrive as Tab/BackTab for the same reason — the generic arms
1273            // would fold them into Up/Down and make them indistinguishable from the
1274            // row keys.
1275            (InputContext::Portfolio, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1276            (InputContext::Portfolio, TuiAction::Previous) => {
1277                (KeyCode::BackTab, KeyModifiers::NONE)
1278            }
1279            // WP05: the Stats page's two tabs switch on Tab/BackTab for the same
1280            // reason — the generic arms would fold them into Up/Down and make the tab
1281            // keys indistinguishable from the row keys, so `Tab` would stop switching.
1282            (InputContext::Stats, TuiAction::Next) => (KeyCode::Tab, KeyModifiers::NONE),
1283            (InputContext::Stats, TuiAction::Previous) => (KeyCode::BackTab, KeyModifiers::NONE),
1284            (InputContext::Settings, TuiAction::NewBoard) => {
1285                (KeyCode::Char('n'), KeyModifiers::NONE)
1286            }
1287            (InputContext::Settings, TuiAction::Delete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1288            // #1441/#1442: `scroll_up`/`scroll_down` are **page** steps and must
1289            // canonicalize to themselves. The generic arms below fold them into
1290            // Up/Down, which is what made the detail pane's `PgUp`/`PgDn` arms dead
1291            // code: the key arrived canonicalized as `Down` and moved the
1292            // relationship cursor instead of scrolling, so a bound key did something
1293            // other than what it was bound to. These arms sit above the generics
1294            // because the match is top-down.
1295            (_, TuiAction::ScrollUp) => (KeyCode::PageUp, KeyModifiers::NONE),
1296            (_, TuiAction::ScrollDown) => (KeyCode::PageDown, KeyModifiers::NONE),
1297            (_, TuiAction::MoveUp | TuiAction::Previous) => (KeyCode::Up, KeyModifiers::NONE),
1298            (_, TuiAction::MoveDown | TuiAction::Next) => (KeyCode::Down, KeyModifiers::NONE),
1299            (_, TuiAction::MoveLeft) => (KeyCode::Left, KeyModifiers::NONE),
1300            (_, TuiAction::MoveRight) => (KeyCode::Right, KeyModifiers::NONE),
1301            (_, TuiAction::PreviousBoard) => (KeyCode::BackTab, KeyModifiers::NONE),
1302            (_, TuiAction::NextBoard) => (KeyCode::Tab, KeyModifiers::NONE),
1303            (_, TuiAction::Submit | TuiAction::Open | TuiAction::Ignore) => {
1304                (KeyCode::Enter, KeyModifiers::NONE)
1305            }
1306            (_, TuiAction::NewIssue) => (KeyCode::Char('n'), KeyModifiers::NONE),
1307            (_, TuiAction::CloneIssue) => (KeyCode::Char('C'), KeyModifiers::NONE),
1308            (_, TuiAction::ToggleArchived) => (KeyCode::Char('a'), KeyModifiers::NONE),
1309            (_, TuiAction::ToggleChildren) => (KeyCode::Char('u'), KeyModifiers::NONE),
1310            (_, TuiAction::Edit | TuiAction::EditPlanningItem) => {
1311                (KeyCode::Char('e'), KeyModifiers::NONE)
1312            }
1313            (_, TuiAction::Agent) => (KeyCode::Char('A'), KeyModifiers::NONE),
1314            // The board's `view` action is `V` (#1507): plain `v` there is the
1315            // saved-views modal, so the synthesized event must be the shifted
1316            // letter the board's own handler arm reads.
1317            (InputContext::Board, TuiAction::View) => (KeyCode::Char('V'), KeyModifiers::NONE),
1318            (_, TuiAction::View) => (KeyCode::Char('v'), KeyModifiers::NONE),
1319            (_, TuiAction::ToggleColumn | TuiAction::Comment) => {
1320                (KeyCode::Char('c'), KeyModifiers::NONE)
1321            }
1322            (_, TuiAction::FilterModal) => (KeyCode::Char('f'), KeyModifiers::NONE),
1323            (_, TuiAction::BulkModal) => (KeyCode::Char('B'), KeyModifiers::NONE),
1324            (_, TuiAction::Triage) => (KeyCode::Char('t'), KeyModifiers::NONE),
1325            // The mentions view's canonical event is the default chord the indicator
1326            // row advertises, so the board's own handler reads `i` whatever the user
1327            // rebound `open_mentions` to.
1328            (_, TuiAction::OpenMentions) => (KeyCode::Char('i'), KeyModifiers::NONE),
1329            // WP05. Each view action's canonical event is the key the modal's own
1330            // footer advertises, so a click region can synthesize exactly the key
1331            // the handler expects.
1332            (_, TuiAction::Views) => (KeyCode::Char('v'), KeyModifiers::NONE),
1333            (_, TuiAction::ApplyView) => (KeyCode::Enter, KeyModifiers::NONE),
1334            (_, TuiAction::NewView) => (KeyCode::Char('n'), KeyModifiers::NONE),
1335            (_, TuiAction::RenameView) => (KeyCode::Char('e'), KeyModifiers::NONE),
1336            (_, TuiAction::UpdateView) => (KeyCode::Char('u'), KeyModifiers::NONE),
1337            (_, TuiAction::DuplicateView) => (KeyCode::Char('d'), KeyModifiers::NONE),
1338            (_, TuiAction::DeleteView) => (KeyCode::Char('x'), KeyModifiers::NONE),
1339            (_, TuiAction::Mark) => (KeyCode::Char(' '), KeyModifiers::NONE),
1340            (_, TuiAction::Unmark) => (KeyCode::Char('x'), KeyModifiers::NONE),
1341            (_, TuiAction::MovePrevious) => (KeyCode::Char('<'), KeyModifiers::NONE),
1342            (_, TuiAction::MoveNext) => (KeyCode::Char('>'), KeyModifiers::NONE),
1343            (_, TuiAction::Delete) => (KeyCode::Char('d'), KeyModifiers::NONE),
1344            (_, TuiAction::DeletePlanningItem) => (KeyCode::Char('x'), KeyModifiers::NONE),
1345            (_, TuiAction::Refresh | TuiAction::Reset | TuiAction::Reedit) => {
1346                (KeyCode::Char('r'), KeyModifiers::NONE)
1347            }
1348            (_, TuiAction::Settings) => (KeyCode::Char('s'), KeyModifiers::NONE),
1349            (_, TuiAction::Planning) => (KeyCode::Char('m'), KeyModifiers::NONE),
1350            (_, TuiAction::NewPlanningItem) => (KeyCode::Char('n'), KeyModifiers::NONE),
1351            (_, TuiAction::NewBoard) => (KeyCode::Char('b'), KeyModifiers::NONE),
1352            (_, TuiAction::AddColumn) => (KeyCode::Char('a'), KeyModifiers::NONE),
1353            (_, TuiAction::MoveColumnUp) => (KeyCode::Char('K'), KeyModifiers::SHIFT),
1354            (_, TuiAction::MoveColumnDown) => (KeyCode::Char('J'), KeyModifiers::SHIFT),
1355            (_, TuiAction::Help) => (KeyCode::Char('?'), KeyModifiers::NONE),
1356            (_, TuiAction::Quit) => (KeyCode::Char('q'), KeyModifiers::NONE),
1357            (_, TuiAction::TextFilter) => (KeyCode::Char('/'), KeyModifiers::NONE),
1358            (_, TuiAction::Close | TuiAction::Cancel) => (KeyCode::Esc, KeyModifiers::NONE),
1359            (_, TuiAction::Asset) => (KeyCode::Char('a'), KeyModifiers::NONE),
1360            // #1359. `e` is the editor in the detail view, so re-kind is `E` — the
1361            // same relationship the CLI's `tix link edit <id>` changes.
1362            (_, TuiAction::NewRelationship) => (KeyCode::Char('n'), KeyModifiers::NONE),
1363            (_, TuiAction::EditRelationship) => (KeyCode::Char('E'), KeyModifiers::SHIFT),
1364            (_, TuiAction::RemoveRelationship) => (KeyCode::Char('d'), KeyModifiers::NONE),
1365            // #1384: hand the handler the key the footer advertises, not the
1366            // catch-all `Esc`.
1367            (_, TuiAction::EditParent) => (KeyCode::Char('P'), KeyModifiers::NONE),
1368            (_, TuiAction::NextRelationship) => (KeyCode::Char('j'), KeyModifiers::NONE),
1369            (_, TuiAction::PreviousRelationship) => (KeyCode::Char('k'), KeyModifiers::NONE),
1370            // #1513. The three related-navigation actions hand the handler the
1371            // key they advertise — without these arms the generic catch-all
1372            // would fold them into `Esc`, and `o` on a related entry would close
1373            // the pane instead of following the link.
1374            (_, TuiAction::RelatedPicker) => (KeyCode::Char('r'), KeyModifiers::NONE),
1375            (_, TuiAction::FollowRelated) => (KeyCode::Char('o'), KeyModifiers::NONE),
1376            (_, TuiAction::NavBack) => (KeyCode::Backspace, KeyModifiers::NONE),
1377            (_, TuiAction::OpenPicker) => (KeyCode::F(2), KeyModifiers::NONE),
1378            // The shell's mode switches. `F(2)` is also `open_picker`'s canonical
1379            // event — they are different actions in different contexts, which is
1380            // exactly why mode switching is scoped to the shell context and a form's
1381            // `F2` keeps its picker meaning.
1382            (_, TuiAction::SwitchToBoard) => (KeyCode::F(3), KeyModifiers::NONE),
1383            (_, TuiAction::SwitchToTriage) => (KeyCode::F(2), KeyModifiers::NONE),
1384            (_, TuiAction::SwitchToCycle) => (KeyCode::F(4), KeyModifiers::NONE),
1385            (_, TuiAction::SwitchToMilestones) => (KeyCode::F(5), KeyModifiers::NONE),
1386            (_, TuiAction::SwitchToRepos) => (KeyCode::F(6), KeyModifiers::NONE),
1387            // Mission `project-issue-health-status-01M3C8HX` (WP06): F7 was reserved
1388            // in the shell until its page existed; the Stats page takes it.
1389            (_, TuiAction::SwitchToStats) => (KeyCode::F(7), KeyModifiers::NONE),
1390            // The health form's key. `handle_detail_key` reads raw keys, so its
1391            // canonical event must be that key: without this the default (`Esc`) is
1392            // what the handler sees, and the key meant to open the form would close
1393            // the pane instead.
1394            (_, TuiAction::HealthForm) => (KeyCode::Char('H'), KeyModifiers::NONE),
1395            // WP05: the Time tab's commands canonicalize to their own letters, so the
1396            // handler reads one event per action however the user rebound it. Without
1397            // these the catch-all would fold all six into `Esc` — closing the page
1398            // instead of acting.
1399            (_, TuiAction::TimeAdd) => (KeyCode::Char('a'), KeyModifiers::NONE),
1400            (_, TuiAction::TimeEdit) => (KeyCode::Char('e'), KeyModifiers::NONE),
1401            (_, TuiAction::TimeDelete) => (KeyCode::Char('x'), KeyModifiers::NONE),
1402            (_, TuiAction::TimeFilter) => (KeyCode::Char('f'), KeyModifiers::NONE),
1403            (_, TuiAction::TimeBreakdown) => (KeyCode::Char('s'), KeyModifiers::NONE),
1404            (_, TuiAction::TimeHistory) => (KeyCode::Char('h'), KeyModifiers::NONE),
1405            // tix #1593: F8 was free in the shell; the Schedule page takes it.
1406            (_, TuiAction::SwitchToSchedule) => (KeyCode::F(8), KeyModifiers::NONE),
1407            // The Schedule page's actions canonicalize to their own letters: the
1408            // handler reads the canonical event, so each action needs one.
1409            (_, TuiAction::SeriesGenerate) => (KeyCode::Char('g'), KeyModifiers::NONE),
1410            (_, TuiAction::SeriesPause) => (KeyCode::Char('p'), KeyModifiers::NONE),
1411            (_, TuiAction::SeriesResume) => (KeyCode::Char('r'), KeyModifiers::NONE),
1412            (_, TuiAction::SeriesEnd) => (KeyCode::Char('x'), KeyModifiers::NONE),
1413            (_, TuiAction::SeriesSkip) => (KeyCode::Char('s'), KeyModifiers::NONE),
1414            (_, TuiAction::SeriesDefer) => (KeyCode::Char('d'), KeyModifiers::NONE),
1415            (_, TuiAction::SeriesIgnore) => (KeyCode::Char('i'), KeyModifiers::NONE),
1416            // The Series tab's create key: the handler opens the form when it reads
1417            // this canonical event, so a rebind still lands there.
1418            (_, TuiAction::SeriesNew) => (KeyCode::Char('n'), KeyModifiers::NONE),
1419            // tix #1593: F11 was free in the shell; the Library page follows the
1420            // repos list.
1421            (_, TuiAction::SwitchToLibrary) => (KeyCode::F(11), KeyModifiers::NONE),
1422            // WP06: F10 was reserved in the shell; the Portfolio page takes it.
1423            (_, TuiAction::SwitchToPortfolio) => (KeyCode::F(10), KeyModifiers::NONE),
1424            (_, TuiAction::SwitchToNewIssue) => (KeyCode::F(1), KeyModifiers::NONE),
1425            (_, TuiAction::SwitchToConfig) => (KeyCode::F(12), KeyModifiers::NONE),
1426            (_, TuiAction::ToggleNavigation) => (KeyCode::Char('n'), KeyModifiers::CONTROL),
1427            (_, TuiAction::Toggle) => (KeyCode::Char(' '), KeyModifiers::NONE),
1428            (_, TuiAction::Confirm) => (KeyCode::Char('y'), KeyModifiers::NONE),
1429            // #1506. The repair prompt's own two decisions canonicalize to the
1430            // keys the prompt advertises, so `handle_edit_repair_key` reads a
1431            // canonical `d`/`x` however the user reached the action.
1432            (_, TuiAction::DeclareKey) => (KeyCode::Char('d'), KeyModifiers::NONE),
1433            (_, TuiAction::Discard) => (KeyCode::Char('x'), KeyModifiers::NONE),
1434            (_, TuiAction::Create) => (KeyCode::Char('+'), KeyModifiers::NONE),
1435            _ => (KeyCode::Esc, KeyModifiers::NONE),
1436        };
1437        KeyEvent::new(code, modifiers)
1438    }
1439}
1440
1441fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
1442    let mut map = HashMap::new();
1443    for (action, keys) in values {
1444        let action = match *action {
1445            "move_up" => TuiAction::MoveUp,
1446            "move_down" => TuiAction::MoveDown,
1447            "move_left" => TuiAction::MoveLeft,
1448            "move_right" => TuiAction::MoveRight,
1449            "next_board" => TuiAction::NextBoard,
1450            "previous_board" => TuiAction::PreviousBoard,
1451            "open" => TuiAction::Open,
1452            "edit" => TuiAction::Edit,
1453            "agent" => TuiAction::Agent,
1454            "view" => TuiAction::View,
1455            "toggle_column" => TuiAction::ToggleColumn,
1456            "filter_modal" => TuiAction::FilterModal,
1457            "bulk_modal" => TuiAction::BulkModal,
1458            "triage" => TuiAction::Triage,
1459            "open_mentions" => TuiAction::OpenMentions,
1460            "views" => TuiAction::Views,
1461            "apply_view" => TuiAction::ApplyView,
1462            "new_view" => TuiAction::NewView,
1463            "rename_view" => TuiAction::RenameView,
1464            "update_view" => TuiAction::UpdateView,
1465            "duplicate_view" => TuiAction::DuplicateView,
1466            "delete_view" => TuiAction::DeleteView,
1467            "mark" => TuiAction::Mark,
1468            "unmark" => TuiAction::Unmark,
1469            "move_previous" => TuiAction::MovePrevious,
1470            "move_next" => TuiAction::MoveNext,
1471            "new_issue" => TuiAction::NewIssue,
1472            "clone_issue" => TuiAction::CloneIssue,
1473            "toggle_archived" => TuiAction::ToggleArchived,
1474            "toggle_children" => TuiAction::ToggleChildren,
1475            "delete" => TuiAction::Delete,
1476            "refresh" => TuiAction::Refresh,
1477            "settings" => TuiAction::Settings,
1478            "planning" => TuiAction::Planning,
1479            "new_board" => TuiAction::NewBoard,
1480            "help" => TuiAction::Help,
1481            "quit" => TuiAction::Quit,
1482            "text_filter" => TuiAction::TextFilter,
1483            "reorder_up" => TuiAction::ReorderUp,
1484            "reorder_down" => TuiAction::ReorderDown,
1485            "close" => TuiAction::Close,
1486            "scroll_up" => TuiAction::ScrollUp,
1487            "scroll_down" => TuiAction::ScrollDown,
1488            "comment" => TuiAction::Comment,
1489            "asset" => TuiAction::Asset,
1490            "new_relationship" => TuiAction::NewRelationship,
1491            "edit_relationship" => TuiAction::EditRelationship,
1492            "remove_relationship" => TuiAction::RemoveRelationship,
1493            "edit_parent" => TuiAction::EditParent,
1494            "next_relationship" => TuiAction::NextRelationship,
1495            "previous_relationship" => TuiAction::PreviousRelationship,
1496            "related_picker" => TuiAction::RelatedPicker,
1497            "follow_related" => TuiAction::FollowRelated,
1498            "nav_back" => TuiAction::NavBack,
1499            "submit" => TuiAction::Submit,
1500            "cancel" => TuiAction::Cancel,
1501            "next" => TuiAction::Next,
1502            "previous" => TuiAction::Previous,
1503            "open_picker" => TuiAction::OpenPicker,
1504            "reset" => TuiAction::Reset,
1505            "toggle" => TuiAction::Toggle,
1506            "add_column" => TuiAction::AddColumn,
1507            "move_column_up" => TuiAction::MoveColumnUp,
1508            "move_column_down" => TuiAction::MoveColumnDown,
1509            "new_planning_item" => TuiAction::NewPlanningItem,
1510            "edit_planning_item" => TuiAction::EditPlanningItem,
1511            "delete_planning_item" => TuiAction::DeletePlanningItem,
1512            "confirm" => TuiAction::Confirm,
1513            "create" => TuiAction::Create,
1514            "previous_year" => TuiAction::PreviousYear,
1515            "next_year" => TuiAction::NextYear,
1516            "previous_month" => TuiAction::PreviousMonth,
1517            "next_month" => TuiAction::NextMonth,
1518            "reedit" => TuiAction::Reedit,
1519            "ignore" => TuiAction::Ignore,
1520            "declare_key" => TuiAction::DeclareKey,
1521            "discard" => TuiAction::Discard,
1522            "switch_to_board" => TuiAction::SwitchToBoard,
1523            "switch_to_triage" => TuiAction::SwitchToTriage,
1524            "switch_to_cycle" => TuiAction::SwitchToCycle,
1525            "switch_to_milestones" => TuiAction::SwitchToMilestones,
1526            "switch_to_repos" => TuiAction::SwitchToRepos,
1527            "switch_to_stats" => TuiAction::SwitchToStats,
1528            "health_form" => TuiAction::HealthForm,
1529            "time_add" => TuiAction::TimeAdd,
1530            "time_edit" => TuiAction::TimeEdit,
1531            "time_delete" => TuiAction::TimeDelete,
1532            "time_filter" => TuiAction::TimeFilter,
1533            "time_breakdown" => TuiAction::TimeBreakdown,
1534            "time_history" => TuiAction::TimeHistory,
1535            "switch_to_schedule" => TuiAction::SwitchToSchedule,
1536            "series_generate" => TuiAction::SeriesGenerate,
1537            "series_pause" => TuiAction::SeriesPause,
1538            "series_resume" => TuiAction::SeriesResume,
1539            "series_end" => TuiAction::SeriesEnd,
1540            "series_skip" => TuiAction::SeriesSkip,
1541            "series_defer" => TuiAction::SeriesDefer,
1542            "series_ignore" => TuiAction::SeriesIgnore,
1543            "series_new" => TuiAction::SeriesNew,
1544            "switch_to_library" => TuiAction::SwitchToLibrary,
1545            "switch_to_portfolio" => TuiAction::SwitchToPortfolio,
1546            "switch_to_new_issue" => TuiAction::SwitchToNewIssue,
1547            "switch_to_config" => TuiAction::SwitchToConfig,
1548            "toggle_navigation" => TuiAction::ToggleNavigation,
1549            _ => unreachable!(),
1550        };
1551        set(&mut map, action, keys);
1552    }
1553    map
1554}
1555
1556fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
1557    match context {
1558        InputContext::Board => defaults(&[
1559            ("move_up", &["up", "k"]),
1560            ("move_down", &["down", "j"]),
1561            // tix #1442 (follow-up): a screen of cards. They have to be *bound* for the
1562            // board to see them at all: `App::handle_key` routes an unbound key in a
1563            // non-text context to `F(12)` rather than passing it through, which is why
1564            // `PgUp`/`PgDn` did nothing here while the wheel next to them worked. They
1565            // canonicalize to `PageUp`/`PageDown`, so the handler can tell a page from
1566            // a row.
1567            ("scroll_up", &["pgup"]),
1568            ("scroll_down", &["pgdn"]),
1569            ("move_left", &["left", "h"]),
1570            ("move_right", &["right", "l"]),
1571            ("next_board", &["tab"]),
1572            ("previous_board", &["shift+tab"]),
1573            ("open", &["enter"]),
1574            ("edit", &["e"]),
1575            // #1507: read the selected card in the resolved viewer, read-only.
1576            // The viewer keeps `v` in the **Detail** context; on the board `v` is
1577            // the saved-views modal (WP05 — 11 tests, and documented in
1578            // `README.md`, `docs/key-bindings.md` and `docs/saved-views.md`), so
1579            // the board's viewer chord is the shifted letter, matching the board's
1580            // `A`/`B`/`C`/`J`/`K` family.
1581            ("view", &["V"]),
1582            ("agent", &["A"]),
1583            ("toggle_column", &["c"]),
1584            ("filter_modal", &["f"]),
1585            ("bulk_modal", &["B"]),
1586            // WP06: the triage inbox. `t` was unbound, so this adds a key
1587            // rather than changing what any existing one means.
1588            ("triage", &["t"]),
1589            // The mentions view: `i` was unbound on the board, and the bottom
1590            // indicator row spells the chord it is bound to.
1591            ("open_mentions", &["i"]),
1592            // WP05: the saved-views modal. `v` was unbound, for the same reason.
1593            ("views", &["v"]),
1594            // The chord is spelled `space`, not a literal blank: `KeyChord::parse`
1595            // trims its input, so a bare `" "` is the empty chord and every
1596            // built-in binding panics at startup.
1597            ("mark", &["space"]),
1598            ("unmark", &["x"]),
1599            ("move_previous", &["<"]),
1600            ("move_next", &[">"]),
1601            ("new_issue", &["n"]),
1602            // #1484: the capital of `c` (toggle column), not a new letter — the
1603            // board's free letters are its navigation, and `C` keeps "clone" beside
1604            // the toggle it is the second half of.
1605            ("clone_issue", &["C"]),
1606            // #1492: `a` was free on the board, and "archived" is what it means.
1607            ("toggle_archived", &["a"]),
1608            // #1399: `u` was free on the board; "sub-issues" is what it shows.
1609            ("toggle_children", &["u"]),
1610            ("delete", &["d"]),
1611            ("refresh", &["r"]),
1612            ("settings", &["s"]),
1613            ("planning", &["m", "M"]),
1614            ("new_board", &["b"]),
1615            ("help", &["?"]),
1616            ("quit", &["q", "Q", "esc"]),
1617            ("text_filter", &["/"]),
1618            // #1586: `Shift+K`/`Shift+Up` and `Shift+J`/`Shift+Down` reorder the
1619            // selected card exactly as `Ctrl+Up`/`Ctrl+Down` do — the same
1620            // move the mouse drag performs. The shifted letters are free on the
1621            // board (plain `k`/`j` are the cursor), so this adds keys without
1622            // changing what any existing one means.
1623            ("reorder_up", &["ctrl+up", "shift+up", "K"]),
1624            ("reorder_down", &["ctrl+down", "shift+down", "J"]),
1625        ]),
1626        InputContext::Rollover => defaults(&[
1627            ("submit", &["enter"]),
1628            ("previous", &["left", "right", "tab"]),
1629            ("cancel", &["esc"]),
1630        ]),
1631        InputContext::Detail => defaults(&[
1632            ("close", &["q", "Q", "esc"]),
1633            ("edit", &["e"]),
1634            // #1507: view the open issue in the resolved read-only viewer.
1635            ("view", &["v"]),
1636            ("scroll_up", &["pgup"]),
1637            ("scroll_down", &["pgdn"]),
1638            ("help", &["?"]),
1639            ("comment", &["c"]),
1640            ("asset", &["a"]),
1641            // #1359: the three mutating relationship operations, plus the cursor
1642            // that names which edge `E` and `d` act on. `e` stays the editor, so
1643            // re-kind is `E`.
1644            ("new_relationship", &["n"]),
1645            ("edit_relationship", &["E"]),
1646            ("remove_relationship", &["d"]),
1647            // #1384: `P` was free in the detail pane. Set/change/clear the open
1648            // issue's parent through the shared hierarchy service, which is the
1649            // TUI half of `tix edit --parent` that the create-only form could
1650            // not express.
1651            ("edit_parent", &["P"]),
1652            ("next_relationship", &["j", "down"]),
1653            ("previous_relationship", &["k", "up"]),
1654            // #1513: the related-issues surface. `r` lists them, `o` follows the
1655            // selected one, `Backspace` returns to the previous pane. `j`/`k`
1656            // keep moving the **relationship** cursor; the picker is the
1657            // selection surface for related entries.
1658            ("related_picker", &["r"]),
1659            ("follow_related", &["o"]),
1660            ("nav_back", &["backspace"]), // Mission `project-issue-health-status-01M3C8HX` (WP06): the health form, on
1661            // the issue the pane is showing.
1662            ("health_form", &["H"]),
1663        ]),
1664        InputContext::RelatedPicker => defaults(&[
1665            ("cancel", &["esc"]),
1666            ("next", &["down"]),
1667            ("previous", &["up"]),
1668            ("submit", &["enter"]),
1669        ]),
1670        InputContext::Form | InputContext::PlanningForm => defaults(&[
1671            ("cancel", &["esc"]),
1672            ("submit", &["enter"]),
1673            ("next", &["tab", "down"]),
1674            ("previous", &["shift+tab", "up"]),
1675            ("open_picker", &["f2"]),
1676        ]),
1677        InputContext::FormPicker => defaults(&[
1678            ("cancel", &["esc", "f2"]),
1679            ("next", &["up"]),
1680            ("previous", &["down"]),
1681            ("submit", &["enter"]),
1682            ("create", &["+", "insert"]),
1683        ]),
1684        InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
1685        // `n` cancels as well as `esc`: a yes/no prompt whose only "no" is a
1686        // non-printing key is a trap for anyone who does not know it.
1687        InputContext::ConfirmBlockedMove => defaults(&[
1688            ("confirm", &["y", "Y", "enter"]),
1689            ("cancel", &["n", "N", "esc"]),
1690        ]),
1691        // Free-text entry. `cancel` is **`esc` only**, and that is load-bearing
1692        // rather than a style choice.
1693        //
1694        // All four of these read arbitrary text into a buffer — a commit message, a
1695        // comment body, a **filesystem path**, a filter string. Binding `q` to cancel
1696        // means the letter `q` cannot be typed, and it is worse than that: the
1697        // binding resolves to the action's canonical event (`esc`), so `q` does not
1698        // merely fail to type, it **leaves the mode**, and every character after it
1699        // is then interpreted as a command in the mode underneath.
1700        //
1701        // This was found by a test flake (#1305), not by inspection. The asset
1702        // round-trip test types the source path through `handle_key`, and the path
1703        // came from a `tempfile` temp directory whose six random characters are drawn
1704        // from `[A-Za-z0-9]`. Roughly one run in forty produced a name containing a
1705        // `q`; the prompt closed mid-path and the rest of the string was applied as
1706        // Detail-mode keys — which is how a run came to report
1707        // `status = "Commented by …"` and no asset at all. A user attaching an asset
1708        // from a path containing `q` gets the same thing, and it silently creates a
1709        // comment on the issue.
1710        //
1711        // The test was at fault for depending on a random name, but the behaviour is
1712        // a real defect, so the binding moves rather than the fixture. `esc` remains
1713        // the cancel key, which is already this codebase's convention for text entry
1714        // — `FilterModal` and `ConfirmDelete` both bind `esc` and nothing else.
1715        //
1716        // `q`-to-close is kept where there is no text to type: `Help`,
1717        // `ContextMenu`, `Settings`, `PlanningManagement`, `DatePicker`, and the
1718        // yes/no `ConfirmBlockedMove` keep it.
1719        InputContext::MoveIntoDone
1720        | InputContext::Comment
1721        | InputContext::Asset
1722        | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1723        // The overlay and the context menu are *not* the same surface, and sharing a
1724        // context made them one: a binding added for the overlay (`pgup`/`pgdn`, tix
1725        // #1441) would silently become a context-menu key too. They share a key set
1726        // today only by coincidence, so they get their own arms.
1727        InputContext::Help => defaults(&[
1728            ("cancel", &["esc", "q", "?"]),
1729            ("next", &["down", "j"]),
1730            ("previous", &["up", "k"]),
1731            ("submit", &["enter"]),
1732            // #1496: `/` filters the list. Bound rather than handled as a raw key so it
1733            // is rebindable like everything else — and so the overlay's own rows name
1734            // it, since a search nobody knows about is a search nobody uses.
1735            ("text_filter", &["/"]),
1736            // #1441/#1442: one page at a time. These have to be *bound* for the
1737            // overlay to see them at all — `App::handle_key` routes an unbound key in
1738            // a non-text context to `F(12)` rather than passing it through — and they
1739            // canonicalize to themselves so the handler can tell a page from a row.
1740            ("scroll_up", &["pgup"]),
1741            ("scroll_down", &["pgdn"]),
1742        ]),
1743        InputContext::ContextMenu => defaults(&[
1744            ("cancel", &["esc", "q", "?"]),
1745            ("next", &["down", "j"]),
1746            ("previous", &["up", "k"]),
1747            ("submit", &["enter"]),
1748        ]),
1749        // The overlay's `/` input (tix #1496): free text, so `cancel` is `Esc` and only
1750        // `Esc`. `q` and `?` stay ordinary characters here — binding them to cancel
1751        // would rewrite them to `esc` inside `App::handle_key`, before the handler saw
1752        // them, and a query could never contain a `q`.
1753        InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1754        InputContext::BulkModal => defaults(&[
1755            ("cancel", &["esc"]),
1756            ("next", &["tab", "down"]),
1757            ("previous", &["shift+tab", "up"]),
1758            ("submit", &["enter"]),
1759        ]),
1760        // WP06. Five bindings and nothing else, because every remaining inbox
1761        // key (`q`, `j`, `k`, `a`, `d`, `u`, `f`, `p`, `y`, `?`) has to arrive
1762        // **raw**: a decline reason containing `q` and a duplicate target
1763        // containing `a` are ordinary, and `allows_text_fallback` is what keeps
1764        // them letters instead of turning them into a close or an accept.
1765        InputContext::Triage => defaults(&[
1766            ("cancel", &["esc"]),
1767            ("submit", &["enter"]),
1768            ("move_up", &["up"]),
1769            ("move_down", &["down"]),
1770            ("toggle", &["space"]),
1771        ]),
1772        // The F3 Cycle mode (tui-cycle-mode). `m` opens the planning-management
1773        // surface for the same reason the board binds it: it is where cycles and
1774        // milestones are created, renamed, and deleted. Nothing here collides with
1775        // the surface's own keys, so opening it from this mode is unambiguous.
1776        InputContext::Cycle => defaults(&[
1777            ("close", &["q", "Q", "esc"]),
1778            ("move_up", &["up", "k"]),
1779            ("move_down", &["down", "j"]),
1780            // The listing's horizontal axis: `h`/`l` and the arrows land on the
1781            // first entry of the previous / next rendered group.
1782            ("move_left", &["left", "h"]),
1783            ("move_right", &["right", "l"]),
1784            ("planning", &["m"]),
1785            ("help", &["?"]),
1786        ]),
1787        // The F5 Milestones mode. `m`, `j`/`k`, `q`/`esc` mirror the Cycle
1788        // listing: it is the same kind of registry view. The planning commands
1789        // (`n`/`e`/`x`, `Enter` to edit) are bound here too, so the milestone
1790        // registry is editable from its own mode and not only from F12.
1791        InputContext::Milestones => defaults(&[
1792            ("close", &["q", "Q", "esc"]),
1793            ("move_up", &["up", "k"]),
1794            ("move_down", &["down", "j"]),
1795            ("submit", &["enter"]),
1796            ("new_planning_item", &["n"]),
1797            ("edit_planning_item", &["e"]),
1798            ("delete_planning_item", &["x", "X"]),
1799            ("planning", &["m"]),
1800            ("help", &["?"]),
1801        ]),
1802        // The F6 Repos mode (tix #1529). `j`/`k` walk the list, `Enter` runs tix
1803        // in the selected repository, and `q`/`esc` leave. No free text, so the
1804        // letters are safe as close keys.
1805        // The F7 Stats page: its two tabs and each tab's own keys.
1806        //
1807        // `Tab`/`Shift+Tab` switch between the Health and Time tabs; `Enter` opens the
1808        // selected Health row's issue's detail (where the health form lives);
1809        // `j`/`k` walk the active tab's rows; and `q`/`esc` leave. No free text, so the
1810        // letters are safe as close and command keys.
1811        //
1812        // The Time tab's six commands are all **inert without `Enter`**: `a`/`e`/`x`
1813        // open a form, `f` opens the filter editor, and `s`/`h` change only what the
1814        // tab is showing — so no key on the page writes anything by itself (WP05).
1815        InputContext::Stats => defaults(&[
1816            ("close", &["q", "Q", "esc"]),
1817            ("move_up", &["up", "k"]),
1818            ("move_down", &["down", "j"]),
1819            ("next", &["tab"]),
1820            ("previous", &["shift+tab"]),
1821            ("submit", &["enter"]),
1822            ("time_add", &["a"]),
1823            ("time_edit", &["e"]),
1824            ("time_delete", &["x"]),
1825            ("time_filter", &["f"]),
1826            ("time_breakdown", &["s"]),
1827            ("time_history", &["h"]),
1828            ("help", &["?"]),
1829        ]),
1830        InputContext::Repos => defaults(&[
1831            ("close", &["q", "Q", "esc"]),
1832            ("move_up", &["up", "k"]),
1833            ("move_down", &["down", "j"]),
1834            ("submit", &["enter"]),
1835            ("help", &["?"]),
1836        ]),
1837        // The F8 Schedule page (tix #1593): `j`/`k` walk the series rows and the
1838        // action keys are free letters on a page that carries no free text.
1839        InputContext::Schedule => defaults(&[
1840            ("close", &["q", "Q", "esc"]),
1841            ("move_up", &["up", "k"]),
1842            ("move_down", &["down", "j"]),
1843            ("series_new", &["n"]),
1844            ("series_generate", &["g"]),
1845            ("series_pause", &["p"]),
1846            ("series_resume", &["r"]),
1847            ("series_end", &["x"]),
1848            ("series_skip", &["s"]),
1849            ("series_defer", &["d"]),
1850            ("series_ignore", &["i"]),
1851            ("help", &["?"]),
1852        ]),
1853        // The F11 Library page (tix #1593). It reads two registries, so the tab
1854        // keys are its own: `tab`/`h` and `shift+tab`/`l` switch between Views and
1855        // Templates, `j`/`k` (and the arrows) walk rows, and `enter` applies the
1856        // selected view. No free text, so `q`/`esc` may close it.
1857        InputContext::Library => defaults(&[
1858            ("close", &["q", "Q", "esc"]),
1859            ("next", &["tab", "l", "right"]),
1860            ("previous", &["shift+tab", "h", "left"]),
1861            ("move_up", &["up", "k"]),
1862            ("move_down", &["down", "j"]),
1863            ("submit", &["enter"]),
1864            ("help", &["?"]),
1865        ]),
1866        // The F10 Portfolio page's Projects tab (WP06). It reads one registry, so it
1867        // has no tab keys: `j`/`k` (and the arrows) walk the project rows, the pane
1868        // beside them is read-only, and `q`/`esc` may close it because it carries no
1869        // free text.
1870        InputContext::Portfolio => defaults(&[
1871            ("close", &["q", "Q", "esc"]),
1872            ("next", &["tab"]),
1873            ("previous", &["shift+tab"]),
1874            ("move_up", &["up", "k"]),
1875            ("move_down", &["down", "j"]),
1876            ("move_left", &["left", "h"]),
1877            ("move_right", &["right", "l"]),
1878            ("submit", &["enter"]),
1879            ("help", &["?"]),
1880        ]),
1881        // The filter modal is picker-backed: `enter` (and `l`/`right`) open the
1882        // focused criterion's value list, or the calendar on a date row, so the
1883        // open-picker action owns them. `submit` moves to plain `a`, which is what
1884        // commits. **Not `ctrl+enter`**: without the kitty keyboard protocol a
1885        // terminal sends the same byte as plain `enter`, so the chord would be
1886        // unreachable. Both rewrite to their canonical event before the handler
1887        // runs — `open_picker` to `F(2)`, `submit` to `Enter` — so the handler can
1888        // tell "open the list" from "apply the filter" without seeing the chord.
1889        // WP05. The list is a menu: `n`/`e`/`u`/`d`/`x` are commands, so this
1890        // context deliberately has **no** text fallback — an unbound key is inert
1891        // rather than typed. The prompt beside it is the opposite, which is why
1892        // they are two contexts rather than one.
1893        InputContext::Views => defaults(&[
1894            ("close", &["q", "Q", "esc"]),
1895            ("move_up", &["up", "k"]),
1896            ("move_down", &["down", "j"]),
1897            ("apply_view", &["enter"]),
1898            ("new_view", &["n"]),
1899            ("rename_view", &["e"]),
1900            ("update_view", &["u"]),
1901            ("duplicate_view", &["d"]),
1902            ("delete_view", &["x", "X"]),
1903        ]),
1904        // Free text, so `submit`/`cancel` only and `cancel` is `esc` **alone**: a
1905        // view name containing `q` is ordinary, and binding it here would close
1906        // the prompt and apply the rest of the name as commands underneath.
1907        InputContext::ViewsPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1908        InputContext::FilterModal => defaults(&[
1909            ("cancel", &["esc"]),
1910            ("next", &["tab", "down"]),
1911            ("previous", &["shift+tab", "up"]),
1912            ("reset", &["r", "R"]),
1913            ("submit", &["a"]),
1914            ("open_picker", &["f2", "enter", "l", "right"]),
1915        ]),
1916        // The F12 Config mode's sections (tui-config-mode). The dashboards
1917        // section edits the active board, so `tab`/`shift+tab` switch boards
1918        // (`next_board`/`previous_board`, whose canonical events are Tab and
1919        // BackTab). `left`/`right` move the pane focus between the section rail
1920        // and the body, and `up`/`down` move inside the focused pane — the
1921        // section when the rail is focused, the row when the body is. `n`/`e`/`x`
1922        // are the new/rename/delete keys in both the dashboards section and the
1923        // cycles/milestones sections — the section decides what they act on.
1924        //
1925        // `enter` is `submit`, not a second `toggle` chord: it steps from the
1926        // rail into the body and then activates the row, while `space` keeps
1927        // toggling wherever the cursor is. Both bound to `toggle` would
1928        // canonicalize to the same event and there would be no Enter behaviour
1929        // to implement.
1930        InputContext::Settings => defaults(&[
1931            ("close", &["q", "Q", "esc"]),
1932            ("next_board", &["tab", "]"]),
1933            ("previous_board", &["shift+tab", "["]),
1934            ("move_left", &["left", "h"]),
1935            ("move_right", &["right", "l"]),
1936            ("move_up", &["up", "k"]),
1937            ("move_down", &["down", "j"]),
1938            ("toggle", &["space"]),
1939            ("submit", &["enter"]),
1940            ("add_column", &["a"]),
1941            ("move_column_up", &["K"]),
1942            ("move_column_down", &["J"]),
1943            ("new_board", &["n"]),
1944            ("edit_planning_item", &["e"]),
1945            ("delete", &["x", "X"]),
1946        ]),
1947        InputContext::SettingsPrompt => defaults(&[
1948            ("cancel", &["esc", "q", "Q"]),
1949            ("submit", &["enter", "y", "Y"]),
1950        ]),
1951        InputContext::PlanningManagement => defaults(&[
1952            ("close", &["q", "Q", "esc"]),
1953            ("move_up", &["up", "k"]),
1954            ("move_down", &["down", "j"]),
1955            // The segments (Cycles / Milestones) are a horizontal axis, so the
1956            // horizontal movement keys switch them — the same rule the Config
1957            // rail's vertical section list deliberately does not follow: there
1958            // `h`/`l` are the pane axis and `j`/`k` walk the sections.
1959            ("move_left", &["left", "h"]),
1960            ("move_right", &["right", "l"]),
1961            ("next", &["tab", "shift+tab"]),
1962            ("new_planning_item", &["n"]),
1963            ("edit_planning_item", &["e"]),
1964            ("delete_planning_item", &["x", "X"]),
1965        ]),
1966        InputContext::PlanningConfirm => {
1967            defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1968        }
1969        InputContext::DatePicker => defaults(&[
1970            ("cancel", &["esc", "q"]),
1971            ("submit", &["enter"]),
1972            ("move_up", &["up", "k"]),
1973            ("move_down", &["down", "j"]),
1974            ("move_left", &["left", "h"]),
1975            ("move_right", &["right", "l"]),
1976            ("previous_month", &["pgup"]),
1977            ("next_month", &["pgdn"]),
1978            ("previous_year", &["["]),
1979            ("next_year", &["]"]),
1980            ("reset", &["delete", "c", "C"]),
1981        ]),
1982        // #1506/#1510. The repair prompt the editor round trip raises when the
1983        // saved file has a problem: apply the proposed fix, declare the key,
1984        // reopen the editor on the retained copy, acknowledge, or discard.
1985        // `confirm` keeps `a`/`y` — the chord #1506's warning advertised — and
1986        // every other key is a decision, never a stray exit. `discard` is `x`
1987        // and requires a second press; `Esc` acknowledges and **keeps** the copy.
1988        InputContext::EditRepair => defaults(&[
1989            ("confirm", &["a", "y"]),
1990            ("declare_key", &["d", "D"]),
1991            ("reedit", &["r", "R"]),
1992            ("ignore", &["i", "I", "enter", "esc"]),
1993            ("discard", &["x", "X"]),
1994        ]),
1995        // #1510. The conflict prompt: overwrite, abandon, compare. The chords are
1996        // the ones the handler's arms key on — `handle_key` rewrites a bound key to
1997        // its action's canonical event, which is `y` for `confirm`, `Enter` for
1998        // `ignore`, and `r` for `reedit` — so every chord listed here arrives as
1999        // that canonical code. `Esc` is bound to `ignore`, which is what makes it
2000        // the safe default the prompt promises. `q` is deliberately *not* bound:
2001        // the prompt closes only through a decision it names.
2002        InputContext::EditConflict => defaults(&[
2003            ("confirm", &["o", "O", "y", "Y"]),
2004            ("ignore", &["a", "A", "esc"]),
2005            ("reedit", &["c", "C", "r", "R"]),
2006        ]),
2007        // The mode shell (tui-mode-navigation). Five switches and a display-state
2008        // toggle, nothing else: every other key belongs to the active mode, and this
2009        // context is consulted only when that mode declines the key.
2010        InputContext::Shell => defaults(&[
2011            ("switch_to_new_issue", &["f1"]),
2012            ("switch_to_triage", &["f2"]),
2013            ("switch_to_board", &["f3"]),
2014            ("switch_to_cycle", &["f4"]),
2015            ("switch_to_milestones", &["f5"]),
2016            // #1529: F6 was free in the shell; the repo list follows the
2017            // milestone registry.
2018            ("switch_to_repos", &["f6"]),
2019            // Mission `project-issue-health-status-01M3C8HX` (WP06): F7 now has its
2020            // page — the Stats page's Health tab.
2021            ("switch_to_stats", &["f7"]),
2022            // #1593: F7 stays reserved; F8 is the Schedule page, whose Series tab
2023            // ships with the recurring-issues mission.
2024            ("switch_to_schedule", &["f8"]),
2025            // WP06: F10 was the last reserved page key; the Portfolio page's
2026            // Projects tab takes it.
2027            ("switch_to_portfolio", &["f10"]),
2028            // #1593: F11 is the Library page.
2029            ("switch_to_library", &["f11"]),
2030            ("switch_to_config", &["f12"]),
2031            ("toggle_navigation", &["ctrl+n"]),
2032        ]),
2033        // #1359. Free text, so `q` must be typeable: a title fragment or a path
2034        // containing `q` is unremarkable, and binding it to cancel closes the prompt
2035        // and applies the rest of the input as commands underneath. See the note on
2036        // the `Asset`/`Comment`/`Filter` group for the full reasoning.
2037        InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
2038    }
2039}
2040
2041macro_rules! overlay_fields {
2042    ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
2043        $(
2044            if let Some(values) = &$overrides.$field {
2045                let mut chords = Vec::with_capacity(values.len());
2046                for value in values {
2047                    chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
2048                        "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
2049                    )))?);
2050                }
2051                if chords.is_empty() {
2052                    return Err(TixError::Config(format!(
2053                        "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
2054                    )));
2055                }
2056                $map.insert(TuiAction::$action, chords);
2057            }
2058        )+
2059    };
2060}
2061
2062fn overlay(
2063    map: &mut HashMap<TuiAction, Vec<KeyChord>>,
2064    overrides: &ActionBindings,
2065    context: InputContext,
2066) -> Result<()> {
2067    overlay_fields!(map, overrides, context,
2068        move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
2069        next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
2070        agent => Agent, view => View, toggle_column => ToggleColumn, filter_modal => FilterModal,
2071        bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
2072        open_mentions => OpenMentions,
2073        views => Views, apply_view => ApplyView, new_view => NewView,
2074        rename_view => RenameView, update_view => UpdateView,
2075        duplicate_view => DuplicateView, delete_view => DeleteView,
2076        move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
2077        clone_issue => CloneIssue,
2078        toggle_archived => ToggleArchived,
2079        toggle_children => ToggleChildren,
2080        delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
2081        new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
2082        reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
2083        comment => Comment, asset => Asset, edit_parent => EditParent,
2084        submit => Submit, cancel => Cancel, next => Next,
2085        previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
2086        add_column => AddColumn, move_column_up => MoveColumnUp, move_column_down => MoveColumnDown,
2087        new_planning_item => NewPlanningItem,
2088        edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
2089        confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
2090        previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
2091        declare_key => DeclareKey, discard => Discard,
2092        switch_to_board => SwitchToBoard, switch_to_triage => SwitchToTriage,
2093        switch_to_cycle => SwitchToCycle, switch_to_milestones => SwitchToMilestones,
2094        switch_to_repos => SwitchToRepos,
2095        switch_to_stats => SwitchToStats, health_form => HealthForm,
2096        time_add => TimeAdd, time_edit => TimeEdit, time_delete => TimeDelete,
2097        time_filter => TimeFilter, time_breakdown => TimeBreakdown, time_history => TimeHistory,
2098        switch_to_schedule => SwitchToSchedule,
2099        series_generate => SeriesGenerate, series_pause => SeriesPause,
2100        series_resume => SeriesResume, series_end => SeriesEnd,
2101        series_skip => SeriesSkip, series_defer => SeriesDefer,
2102        series_ignore => SeriesIgnore,
2103        series_new => SeriesNew,
2104        switch_to_new_issue => SwitchToNewIssue,
2105        switch_to_library => SwitchToLibrary,
2106        switch_to_portfolio => SwitchToPortfolio,
2107        switch_to_new_issue => SwitchToNewIssue,
2108        switch_to_config => SwitchToConfig, toggle_navigation => ToggleNavigation,
2109    );
2110    let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
2111    for (action, chords) in map {
2112        for key in chords {
2113            if let Some(previous) = seen.insert(*key, *action) {
2114                return Err(TixError::Config(format!(
2115                    "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
2116                    context.as_str(),
2117                    key,
2118                    previous.as_str(),
2119                    action.as_str()
2120                )));
2121            }
2122        }
2123    }
2124    Ok(())
2125}
2126
2127impl ActionBindings {
2128    pub fn is_empty(&self) -> bool {
2129        self == &Self::default()
2130    }
2131}
2132
2133#[cfg(test)]
2134mod tests {
2135    use super::*;
2136
2137    #[test]
2138    fn parses_and_normalizes_documented_chords() {
2139        assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
2140        assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
2141        assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
2142        assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
2143        assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
2144        assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
2145        assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
2146        assert!(KeyChord::parse("ctrl+wat").is_err());
2147        assert!(KeyChord::parse("f99").is_err());
2148    }
2149
2150    #[test]
2151    fn defaults_validate_and_cross_mode_reuse_is_allowed() {
2152        let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2153        assert!(
2154            defaults
2155                .action(
2156                    InputContext::Board,
2157                    KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
2158                )
2159                .is_some()
2160        );
2161        let mut overrides = KeyBindings::default();
2162        overrides.board.open = Some(vec!["f4".into()]);
2163        overrides.detail.close = Some(vec!["f4".into()]);
2164        assert!(KeyBindings::resolve(&overrides).is_ok());
2165    }
2166
2167    #[test]
2168    fn filter_modal_opens_the_picker_and_commits_on_the_apply_key() {
2169        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2170        let ctx = InputContext::FilterModal;
2171        // `enter`, `l` and `right` all open the focused row's picker and rewrite to
2172        // the action's canonical event, so the handler sees one key rather than
2173        // three. A regression here would make `enter` commit again (the pre-overhaul
2174        // behavior) with no handler change to notice it.
2175        for code in [KeyCode::Enter, KeyCode::Char('l'), KeyCode::Right] {
2176            let event = KeyEvent::new(code, KeyModifiers::NONE);
2177            let action = bindings.action(ctx, event);
2178            assert_eq!(
2179                action,
2180                Some(TuiAction::OpenPicker),
2181                "{code:?} must open the focused row's picker"
2182            );
2183            assert_eq!(
2184                bindings.canonical_event(ctx, TuiAction::OpenPicker),
2185                KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE),
2186                "{code:?} must canonicalize to the picker's F2"
2187            );
2188        }
2189        // A plain, always-deliverable key commits. It canonicalizes to `enter` so
2190        // the handler can tell it apart from the opener — and it is deliberately not
2191        // `ctrl+enter`, which a terminal without the kitty keyboard protocol sends
2192        // as the same byte as plain `enter`.
2193        let commit = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
2194        assert_eq!(bindings.action(ctx, commit), Some(TuiAction::Submit));
2195        assert_eq!(
2196            bindings.canonical_event(ctx, TuiAction::Submit),
2197            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2198        );
2199        // The plain `enter` that opens the picker is not the one that commits.
2200        assert_eq!(
2201            bindings.action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
2202            Some(TuiAction::OpenPicker)
2203        );
2204        assert!(
2205            bindings
2206                .action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
2207                .is_none(),
2208            "the unreachable `ctrl+enter` chord must not be bound at all"
2209        );
2210    }
2211
2212    #[test]
2213    fn board_binds_v_to_views_and_shift_v_to_view_and_detail_keeps_v() {
2214        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2215        // The board's `v` is the saved-views modal (WP05), so the read-only viewer
2216        // (#1507) takes the shifted letter there; Detail has no views modal and
2217        // keeps the plain `v`.
2218        let board_view = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE);
2219        assert_eq!(
2220            bindings.action(InputContext::Board, board_view),
2221            Some(TuiAction::View),
2222            "the board must bind `V` to view"
2223        );
2224        assert_eq!(
2225            bindings.canonical_event(InputContext::Board, TuiAction::View),
2226            board_view,
2227            "`view` must canonicalize to the board's own chord so the handler sees it as typed"
2228        );
2229        assert_eq!(
2230            bindings.action(
2231                InputContext::Board,
2232                KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
2233            ),
2234            Some(TuiAction::Views),
2235            "the board must bind `v` to the saved-views modal"
2236        );
2237        let detail_view = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
2238        assert_eq!(
2239            bindings.action(InputContext::Detail, detail_view),
2240            Some(TuiAction::View),
2241            "Detail must bind `v` to view"
2242        );
2243        assert_eq!(
2244            bindings.canonical_event(InputContext::Detail, TuiAction::View),
2245            detail_view,
2246            "`view` must canonicalize to `v` in Detail"
2247        );
2248    }
2249
2250    /// The conflict prompt's three decisions (#1510): every choice is reachable by
2251    /// two chords, and each pair resolves to an action whose canonical event is the
2252    /// code the handler's arms read (`y`, `Enter`, `r`) — the indirection that makes
2253    /// `Esc` abandon and `c` compare without a chord name in the logic.
2254    #[test]
2255    fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
2256        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2257        let cases = [
2258            (KeyCode::Char('o'), TuiAction::Confirm),
2259            (KeyCode::Char('y'), TuiAction::Confirm),
2260            (KeyCode::Char('a'), TuiAction::Ignore),
2261            (KeyCode::Esc, TuiAction::Ignore),
2262            (KeyCode::Char('c'), TuiAction::Reedit),
2263            (KeyCode::Char('r'), TuiAction::Reedit),
2264        ];
2265        for (code, action) in cases {
2266            assert_eq!(
2267                bindings.action(
2268                    InputContext::EditConflict,
2269                    KeyEvent::new(code, KeyModifiers::NONE)
2270                ),
2271                Some(action),
2272                "{code:?} must be {action:?} in the conflict prompt"
2273            );
2274        }
2275        // …and the canonical events are what `handle_edit_conflict_key` matches on,
2276        // so the mapping is asserted rather than assumed.
2277        for (action, expected) in [
2278            (TuiAction::Confirm, KeyCode::Char('y')),
2279            (TuiAction::Ignore, KeyCode::Enter),
2280            (TuiAction::Reedit, KeyCode::Char('r')),
2281        ] {
2282            assert_eq!(
2283                bindings.canonical_event(InputContext::EditConflict, action),
2284                KeyEvent::new(expected, KeyModifiers::NONE),
2285                "{action:?} must hand the handler `{expected:?}`"
2286            );
2287        }
2288        // A key nobody bound does nothing: `q` must not close the prompt.
2289        assert_eq!(
2290            bindings.action(
2291                InputContext::EditConflict,
2292                KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
2293            ),
2294            None
2295        );
2296    }
2297
2298    /// The Config mode's column reordering (tui-config-mode). `Shift+K`/
2299    /// `Shift+J` move the selected column; they are distinct from the plain
2300    /// `k`/`j` cursor moves, they accept the documented TOML override, and a
2301    /// conflict inside the same context is still rejected.
2302    #[test]
2303    fn settings_binds_column_moves_distinct_from_the_cursor() {
2304        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2305        let shifted = |ch: char| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT);
2306        assert_eq!(
2307            bindings.action(InputContext::Settings, shifted('K')),
2308            Some(TuiAction::MoveColumnUp)
2309        );
2310        assert_eq!(
2311            bindings.action(InputContext::Settings, shifted('J')),
2312            Some(TuiAction::MoveColumnDown)
2313        );
2314        // Plain `k`/`j` stay the cursor; only the shifted pair reorders.
2315        assert_eq!(
2316            bindings.action(
2317                InputContext::Settings,
2318                KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)
2319            ),
2320            Some(TuiAction::MoveUp)
2321        );
2322        assert_eq!(
2323            bindings.action(
2324                InputContext::Settings,
2325                KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
2326            ),
2327            Some(TuiAction::MoveDown)
2328        );
2329        // The canonical events are the uppercase codes the handler matches on.
2330        assert_eq!(
2331            bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnUp),
2332            shifted('K')
2333        );
2334        assert_eq!(
2335            bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnDown),
2336            shifted('J')
2337        );
2338
2339        // The documented TOML fields load and take effect.
2340        let mut overrides = KeyBindings::default();
2341        overrides.settings.move_column_up = Some(vec!["ctrl+u".into()]);
2342        overrides.settings.move_column_down = Some(vec!["ctrl+d".into()]);
2343        let overridden = KeyBindings::resolve(&overrides).unwrap();
2344        assert_eq!(
2345            overridden.action(
2346                InputContext::Settings,
2347                KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)
2348            ),
2349            Some(TuiAction::MoveColumnUp)
2350        );
2351        assert_eq!(
2352            overridden.action(
2353                InputContext::Settings,
2354                KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
2355            ),
2356            Some(TuiAction::MoveColumnDown)
2357        );
2358
2359        // A same-context conflict is still rejected, naming the mode and action.
2360        let mut conflict = KeyBindings::default();
2361        conflict.settings.move_column_up = Some(vec!["a".into()]);
2362        let error = KeyBindings::resolve(&conflict).unwrap_err().to_string();
2363        assert!(
2364            error.contains("settings") && error.contains("move_column_up"),
2365            "the conflict must name the mode and the action: {error}"
2366        );
2367    }
2368
2369    /// Task 5.4: `i` is bound to the mentions view on the board, resolves without a
2370    /// conflict against every other board default, and canonicalizes back to `i` — so
2371    /// the board's own handler can read the key it advertises.
2372    #[test]
2373    fn the_mentions_view_binds_i_on_the_board_without_a_conflict() {
2374        let bindings = KeyBindings::resolve(&KeyBindings::default())
2375            .expect("adding open_mentions must not conflict with an existing board chord");
2376        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
2377        assert_eq!(
2378            bindings.action(InputContext::Board, key),
2379            Some(TuiAction::OpenMentions)
2380        );
2381        assert_eq!(
2382            bindings.canonical_event(InputContext::Board, TuiAction::OpenMentions),
2383            key
2384        );
2385        assert_eq!(
2386            bindings
2387                .chords(InputContext::Board, TuiAction::OpenMentions)
2388                .len(),
2389            1
2390        );
2391        // The action is reachable by name from `tix.toml`, like every other one.
2392        let mut overrides = KeyBindings::default();
2393        overrides.board.open_mentions = Some(vec!["ctrl+m".into()]);
2394        let rebound = KeyBindings::resolve(&overrides).expect("a rebind resolves");
2395        assert_eq!(
2396            rebound.action(
2397                InputContext::Board,
2398                KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL)
2399            ),
2400            Some(TuiAction::OpenMentions)
2401        );
2402    }
2403
2404    #[test]
2405    fn conflicts_report_mode_and_actions() {
2406        let mut overrides = KeyBindings::default();
2407        overrides.board.open = Some(vec!["ctrl+n".into()]);
2408        overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
2409        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
2410        assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
2411    }
2412
2413    #[test]
2414    fn shell_defaults_resolve_mode_switches_and_toggle() {
2415        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2416        // F1 New Issue, F2 Triage, F3 Board, F4 Cycles, F5 Milestones,
2417        // F6 Repos (tix #1529), F12 Config.
2418        let cases = [
2419            (KeyCode::F(1), TuiAction::SwitchToNewIssue),
2420            (KeyCode::F(2), TuiAction::SwitchToTriage),
2421            (KeyCode::F(3), TuiAction::SwitchToBoard),
2422            (KeyCode::F(4), TuiAction::SwitchToCycle),
2423            (KeyCode::F(5), TuiAction::SwitchToMilestones),
2424            (KeyCode::F(6), TuiAction::SwitchToRepos),
2425            (KeyCode::F(12), TuiAction::SwitchToConfig),
2426        ];
2427        for (code, action) in cases {
2428            assert_eq!(
2429                bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
2430                Some(action),
2431                "{code:?} must switch modes in the shell"
2432            );
2433            assert_eq!(
2434                bindings.canonical_event(InputContext::Shell, action),
2435                KeyEvent::new(code, KeyModifiers::NONE),
2436                "{action:?} must canonicalize to its default chord"
2437            );
2438        }
2439        assert_eq!(
2440            bindings.action(
2441                InputContext::Shell,
2442                KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
2443            ),
2444            Some(TuiAction::ToggleNavigation)
2445        );
2446        // F6 switches to the Repos mode (tix #1529); F8 and F11 are the Schedule
2447        // and Library pages (tix #1593) — the first of the reserved keys to be
2448        // spent.
2449        assert_eq!(
2450            bindings.action(
2451                InputContext::Shell,
2452                KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)
2453            ),
2454            Some(TuiAction::SwitchToSchedule)
2455        );
2456        assert_eq!(
2457            bindings.action(
2458                InputContext::Shell,
2459                KeyEvent::new(KeyCode::F(11), KeyModifiers::NONE)
2460            ),
2461            Some(TuiAction::SwitchToLibrary)
2462        );
2463        // WP06: F10 was reserved too, and the Portfolio page's Projects tab took
2464        // it.
2465        assert_eq!(
2466            bindings.action(
2467                InputContext::Shell,
2468                KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)
2469            ),
2470            Some(TuiAction::SwitchToPortfolio)
2471        );
2472        // F9 stays reserved: the shell binds nothing there until its page exists.
2473        // `#1593` reserves F9 for the Data page (Import/Export, Archive, Duplicates),
2474        // and this mission's epic surface is the F10 Portfolio page's Epics tab — so
2475        // F9 is deliberately **not** spent here. F7 left this list when mission
2476        // `project-issue-health-status-01M3C8HX` gave it the Stats page's Health tab,
2477        // and F10 left it with the Portfolio page (WP06).
2478        assert_eq!(
2479            bindings.action(
2480                InputContext::Shell,
2481                KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
2482            ),
2483            None,
2484            "F9 must stay unbound in the shell until the Data page exists"
2485        );
2486    }
2487
2488    #[test]
2489    fn shell_conflict_reports_both_actions_and_mode() {
2490        let mut overrides = KeyBindings::default();
2491        overrides.shell.switch_to_board = Some(vec!["f2".into()]);
2492        overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
2493        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
2494        assert!(
2495            error.contains("shell")
2496                && error.contains("switch_to_board")
2497                && error.contains("switch_to_triage"),
2498            "got {error:?}"
2499        );
2500    }
2501}