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        // The issue action menu. It reads **free text**: letters filter the
1744        // list, so only the navigation keys are bound here and `allows_text_fallback`
1745        // keeps `j`/`k`/`q`/`?` typeable.
1746        InputContext::ContextMenu => defaults(&[
1747            ("cancel", &["esc"]),
1748            ("next", &["down"]),
1749            ("previous", &["up"]),
1750            ("submit", &["enter"]),
1751            ("scroll_up", &["pgup"]),
1752            ("scroll_down", &["pgdn"]),
1753        ]),
1754        // The overlay's `/` input (tix #1496): free text, so `cancel` is `Esc` and only
1755        // `Esc`. `q` and `?` stay ordinary characters here — binding them to cancel
1756        // would rewrite them to `esc` inside `App::handle_key`, before the handler saw
1757        // them, and a query could never contain a `q`.
1758        InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1759        InputContext::BulkModal => defaults(&[
1760            ("cancel", &["esc"]),
1761            ("next", &["tab", "down"]),
1762            ("previous", &["shift+tab", "up"]),
1763            ("submit", &["enter"]),
1764        ]),
1765        // WP06. Five bindings and nothing else, because every remaining inbox
1766        // key (`q`, `j`, `k`, `a`, `d`, `u`, `f`, `p`, `y`, `?`) has to arrive
1767        // **raw**: a decline reason containing `q` and a duplicate target
1768        // containing `a` are ordinary, and `allows_text_fallback` is what keeps
1769        // them letters instead of turning them into a close or an accept.
1770        InputContext::Triage => defaults(&[
1771            ("cancel", &["esc"]),
1772            ("submit", &["enter"]),
1773            ("move_up", &["up"]),
1774            ("move_down", &["down"]),
1775            ("toggle", &["space"]),
1776        ]),
1777        // The F3 Cycle mode (tui-cycle-mode). `m` opens the planning-management
1778        // surface for the same reason the board binds it: it is where cycles and
1779        // milestones are created, renamed, and deleted. Nothing here collides with
1780        // the surface's own keys, so opening it from this mode is unambiguous.
1781        InputContext::Cycle => defaults(&[
1782            ("close", &["q", "Q", "esc"]),
1783            ("move_up", &["up", "k"]),
1784            ("move_down", &["down", "j"]),
1785            // The listing's horizontal axis: `h`/`l` and the arrows land on the
1786            // first entry of the previous / next rendered group.
1787            ("move_left", &["left", "h"]),
1788            ("move_right", &["right", "l"]),
1789            ("planning", &["m"]),
1790            ("help", &["?"]),
1791        ]),
1792        // The F5 Milestones mode. `m`, `j`/`k`, `q`/`esc` mirror the Cycle
1793        // listing: it is the same kind of registry view. The planning commands
1794        // (`n`/`e`/`x`, `Enter` to edit) are bound here too, so the milestone
1795        // registry is editable from its own mode and not only from F12.
1796        InputContext::Milestones => defaults(&[
1797            ("close", &["q", "Q", "esc"]),
1798            ("move_up", &["up", "k"]),
1799            ("move_down", &["down", "j"]),
1800            ("submit", &["enter"]),
1801            ("new_planning_item", &["n"]),
1802            ("edit_planning_item", &["e"]),
1803            ("delete_planning_item", &["x", "X"]),
1804            ("planning", &["m"]),
1805            ("help", &["?"]),
1806        ]),
1807        // The F6 Repos mode (tix #1529). `j`/`k` walk the list, `Enter` runs tix
1808        // in the selected repository, and `q`/`esc` leave. No free text, so the
1809        // letters are safe as close keys.
1810        // The F7 Stats page: its two tabs and each tab's own keys.
1811        //
1812        // `Tab`/`Shift+Tab` switch between the Health and Time tabs; `Enter` opens the
1813        // selected Health row's issue's detail (where the health form lives);
1814        // `j`/`k` walk the active tab's rows; and `q`/`esc` leave. No free text, so the
1815        // letters are safe as close and command keys.
1816        //
1817        // The Time tab's six commands are all **inert without `Enter`**: `a`/`e`/`x`
1818        // open a form, `f` opens the filter editor, and `s`/`h` change only what the
1819        // tab is showing — so no key on the page writes anything by itself (WP05).
1820        InputContext::Stats => defaults(&[
1821            ("close", &["q", "Q", "esc"]),
1822            ("move_up", &["up", "k"]),
1823            ("move_down", &["down", "j"]),
1824            ("next", &["tab"]),
1825            ("previous", &["shift+tab"]),
1826            ("submit", &["enter"]),
1827            ("time_add", &["a"]),
1828            ("time_edit", &["e"]),
1829            ("time_delete", &["x"]),
1830            ("time_filter", &["f"]),
1831            ("time_breakdown", &["s"]),
1832            ("time_history", &["h"]),
1833            ("help", &["?"]),
1834        ]),
1835        InputContext::Repos => defaults(&[
1836            ("close", &["q", "Q", "esc"]),
1837            ("move_up", &["up", "k"]),
1838            ("move_down", &["down", "j"]),
1839            ("submit", &["enter"]),
1840            ("help", &["?"]),
1841        ]),
1842        // The F8 Schedule page (tix #1593): `j`/`k` walk the series rows and the
1843        // action keys are free letters on a page that carries no free text.
1844        InputContext::Schedule => defaults(&[
1845            ("close", &["q", "Q", "esc"]),
1846            ("move_up", &["up", "k"]),
1847            ("move_down", &["down", "j"]),
1848            ("series_new", &["n"]),
1849            ("series_generate", &["g"]),
1850            ("series_pause", &["p"]),
1851            ("series_resume", &["r"]),
1852            ("series_end", &["x"]),
1853            ("series_skip", &["s"]),
1854            ("series_defer", &["d"]),
1855            ("series_ignore", &["i"]),
1856            ("help", &["?"]),
1857        ]),
1858        // The F11 Library page (tix #1593). It reads two registries, so the tab
1859        // keys are its own: `tab`/`h` and `shift+tab`/`l` switch between Views and
1860        // Templates, `j`/`k` (and the arrows) walk rows, and `enter` applies the
1861        // selected view. No free text, so `q`/`esc` may close it.
1862        InputContext::Library => defaults(&[
1863            ("close", &["q", "Q", "esc"]),
1864            ("next", &["tab", "l", "right"]),
1865            ("previous", &["shift+tab", "h", "left"]),
1866            ("move_up", &["up", "k"]),
1867            ("move_down", &["down", "j"]),
1868            ("submit", &["enter"]),
1869            ("help", &["?"]),
1870        ]),
1871        // The F10 Portfolio page's Projects tab (WP06). It reads one registry, so it
1872        // has no tab keys: `j`/`k` (and the arrows) walk the project rows, the pane
1873        // beside them is read-only, and `q`/`esc` may close it because it carries no
1874        // free text.
1875        InputContext::Portfolio => defaults(&[
1876            ("close", &["q", "Q", "esc"]),
1877            ("next", &["tab"]),
1878            ("previous", &["shift+tab"]),
1879            ("move_up", &["up", "k"]),
1880            ("move_down", &["down", "j"]),
1881            ("move_left", &["left", "h"]),
1882            ("move_right", &["right", "l"]),
1883            ("submit", &["enter"]),
1884            ("help", &["?"]),
1885        ]),
1886        // The filter modal is picker-backed: `enter` (and `l`/`right`) open the
1887        // focused criterion's value list, or the calendar on a date row, so the
1888        // open-picker action owns them. `submit` moves to plain `a`, which is what
1889        // commits. **Not `ctrl+enter`**: without the kitty keyboard protocol a
1890        // terminal sends the same byte as plain `enter`, so the chord would be
1891        // unreachable. Both rewrite to their canonical event before the handler
1892        // runs — `open_picker` to `F(2)`, `submit` to `Enter` — so the handler can
1893        // tell "open the list" from "apply the filter" without seeing the chord.
1894        // WP05. The list is a menu: `n`/`e`/`u`/`d`/`x` are commands, so this
1895        // context deliberately has **no** text fallback — an unbound key is inert
1896        // rather than typed. The prompt beside it is the opposite, which is why
1897        // they are two contexts rather than one.
1898        InputContext::Views => defaults(&[
1899            ("close", &["q", "Q", "esc"]),
1900            ("move_up", &["up", "k"]),
1901            ("move_down", &["down", "j"]),
1902            ("apply_view", &["enter"]),
1903            ("new_view", &["n"]),
1904            ("rename_view", &["e"]),
1905            ("update_view", &["u"]),
1906            ("duplicate_view", &["d"]),
1907            ("delete_view", &["x", "X"]),
1908        ]),
1909        // Free text, so `submit`/`cancel` only and `cancel` is `esc` **alone**: a
1910        // view name containing `q` is ordinary, and binding it here would close
1911        // the prompt and apply the rest of the name as commands underneath.
1912        InputContext::ViewsPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
1913        InputContext::FilterModal => defaults(&[
1914            ("cancel", &["esc"]),
1915            ("next", &["tab", "down"]),
1916            ("previous", &["shift+tab", "up"]),
1917            ("reset", &["r", "R"]),
1918            ("submit", &["a"]),
1919            ("open_picker", &["f2", "enter", "l", "right"]),
1920        ]),
1921        // The F12 Config mode's sections (tui-config-mode). The dashboards
1922        // section edits the active board, so `tab`/`shift+tab` switch boards
1923        // (`next_board`/`previous_board`, whose canonical events are Tab and
1924        // BackTab). `left`/`right` move the pane focus between the section rail
1925        // and the body, and `up`/`down` move inside the focused pane — the
1926        // section when the rail is focused, the row when the body is. `n`/`e`/`x`
1927        // are the new/rename/delete keys in both the dashboards section and the
1928        // cycles/milestones sections — the section decides what they act on.
1929        //
1930        // `enter` is `submit`, not a second `toggle` chord: it steps from the
1931        // rail into the body and then activates the row, while `space` keeps
1932        // toggling wherever the cursor is. Both bound to `toggle` would
1933        // canonicalize to the same event and there would be no Enter behaviour
1934        // to implement.
1935        InputContext::Settings => defaults(&[
1936            ("close", &["q", "Q", "esc"]),
1937            ("next_board", &["tab", "]"]),
1938            ("previous_board", &["shift+tab", "["]),
1939            ("move_left", &["left", "h"]),
1940            ("move_right", &["right", "l"]),
1941            ("move_up", &["up", "k"]),
1942            ("move_down", &["down", "j"]),
1943            ("toggle", &["space"]),
1944            ("submit", &["enter"]),
1945            ("add_column", &["a"]),
1946            ("move_column_up", &["K"]),
1947            ("move_column_down", &["J"]),
1948            ("new_board", &["n"]),
1949            ("edit_planning_item", &["e"]),
1950            ("delete", &["x", "X"]),
1951        ]),
1952        InputContext::SettingsPrompt => defaults(&[
1953            ("cancel", &["esc", "q", "Q"]),
1954            ("submit", &["enter", "y", "Y"]),
1955        ]),
1956        InputContext::PlanningManagement => defaults(&[
1957            ("close", &["q", "Q", "esc"]),
1958            ("move_up", &["up", "k"]),
1959            ("move_down", &["down", "j"]),
1960            // The segments (Cycles / Milestones) are a horizontal axis, so the
1961            // horizontal movement keys switch them — the same rule the Config
1962            // rail's vertical section list deliberately does not follow: there
1963            // `h`/`l` are the pane axis and `j`/`k` walk the sections.
1964            ("move_left", &["left", "h"]),
1965            ("move_right", &["right", "l"]),
1966            ("next", &["tab", "shift+tab"]),
1967            ("new_planning_item", &["n"]),
1968            ("edit_planning_item", &["e"]),
1969            ("delete_planning_item", &["x", "X"]),
1970        ]),
1971        InputContext::PlanningConfirm => {
1972            defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
1973        }
1974        InputContext::DatePicker => defaults(&[
1975            ("cancel", &["esc", "q"]),
1976            ("submit", &["enter"]),
1977            ("move_up", &["up", "k"]),
1978            ("move_down", &["down", "j"]),
1979            ("move_left", &["left", "h"]),
1980            ("move_right", &["right", "l"]),
1981            ("previous_month", &["pgup"]),
1982            ("next_month", &["pgdn"]),
1983            ("previous_year", &["["]),
1984            ("next_year", &["]"]),
1985            ("reset", &["delete", "c", "C"]),
1986        ]),
1987        // #1506/#1510. The repair prompt the editor round trip raises when the
1988        // saved file has a problem: apply the proposed fix, declare the key,
1989        // reopen the editor on the retained copy, acknowledge, or discard.
1990        // `confirm` keeps `a`/`y` — the chord #1506's warning advertised — and
1991        // every other key is a decision, never a stray exit. `discard` is `x`
1992        // and requires a second press; `Esc` acknowledges and **keeps** the copy.
1993        InputContext::EditRepair => defaults(&[
1994            ("confirm", &["a", "y"]),
1995            ("declare_key", &["d", "D"]),
1996            ("reedit", &["r", "R"]),
1997            ("ignore", &["i", "I", "enter", "esc"]),
1998            ("discard", &["x", "X"]),
1999        ]),
2000        // #1510. The conflict prompt: overwrite, abandon, compare. The chords are
2001        // the ones the handler's arms key on — `handle_key` rewrites a bound key to
2002        // its action's canonical event, which is `y` for `confirm`, `Enter` for
2003        // `ignore`, and `r` for `reedit` — so every chord listed here arrives as
2004        // that canonical code. `Esc` is bound to `ignore`, which is what makes it
2005        // the safe default the prompt promises. `q` is deliberately *not* bound:
2006        // the prompt closes only through a decision it names.
2007        InputContext::EditConflict => defaults(&[
2008            ("confirm", &["o", "O", "y", "Y"]),
2009            ("ignore", &["a", "A", "esc"]),
2010            ("reedit", &["c", "C", "r", "R"]),
2011        ]),
2012        // The mode shell (tui-mode-navigation). Five switches and a display-state
2013        // toggle, nothing else: every other key belongs to the active mode, and this
2014        // context is consulted only when that mode declines the key.
2015        InputContext::Shell => defaults(&[
2016            ("switch_to_new_issue", &["f1"]),
2017            ("switch_to_triage", &["f2"]),
2018            ("switch_to_board", &["f3"]),
2019            ("switch_to_cycle", &["f4"]),
2020            ("switch_to_milestones", &["f5"]),
2021            // #1529: F6 was free in the shell; the repo list follows the
2022            // milestone registry.
2023            ("switch_to_repos", &["f6"]),
2024            // Mission `project-issue-health-status-01M3C8HX` (WP06): F7 now has its
2025            // page — the Stats page's Health tab.
2026            ("switch_to_stats", &["f7"]),
2027            // #1593: F7 stays reserved; F8 is the Schedule page, whose Series tab
2028            // ships with the recurring-issues mission.
2029            ("switch_to_schedule", &["f8"]),
2030            // WP06: F10 was the last reserved page key; the Portfolio page's
2031            // Projects tab takes it.
2032            ("switch_to_portfolio", &["f10"]),
2033            // #1593: F11 is the Library page.
2034            ("switch_to_library", &["f11"]),
2035            ("switch_to_config", &["f12"]),
2036            ("toggle_navigation", &["ctrl+n"]),
2037        ]),
2038        // #1359. Free text, so `q` must be typeable: a title fragment or a path
2039        // containing `q` is unremarkable, and binding it to cancel closes the prompt
2040        // and applies the rest of the input as commands underneath. See the note on
2041        // the `Asset`/`Comment`/`Filter` group for the full reasoning.
2042        InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
2043    }
2044}
2045
2046macro_rules! overlay_fields {
2047    ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
2048        $(
2049            if let Some(values) = &$overrides.$field {
2050                let mut chords = Vec::with_capacity(values.len());
2051                for value in values {
2052                    chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
2053                        "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
2054                    )))?);
2055                }
2056                if chords.is_empty() {
2057                    return Err(TixError::Config(format!(
2058                        "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
2059                    )));
2060                }
2061                $map.insert(TuiAction::$action, chords);
2062            }
2063        )+
2064    };
2065}
2066
2067fn overlay(
2068    map: &mut HashMap<TuiAction, Vec<KeyChord>>,
2069    overrides: &ActionBindings,
2070    context: InputContext,
2071) -> Result<()> {
2072    overlay_fields!(map, overrides, context,
2073        move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
2074        next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
2075        agent => Agent, view => View, toggle_column => ToggleColumn, filter_modal => FilterModal,
2076        bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
2077        open_mentions => OpenMentions,
2078        views => Views, apply_view => ApplyView, new_view => NewView,
2079        rename_view => RenameView, update_view => UpdateView,
2080        duplicate_view => DuplicateView, delete_view => DeleteView,
2081        move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
2082        clone_issue => CloneIssue,
2083        toggle_archived => ToggleArchived,
2084        toggle_children => ToggleChildren,
2085        delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
2086        new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
2087        reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
2088        comment => Comment, asset => Asset, edit_parent => EditParent,
2089        submit => Submit, cancel => Cancel, next => Next,
2090        previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
2091        add_column => AddColumn, move_column_up => MoveColumnUp, move_column_down => MoveColumnDown,
2092        new_planning_item => NewPlanningItem,
2093        edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
2094        confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
2095        previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
2096        declare_key => DeclareKey, discard => Discard,
2097        switch_to_board => SwitchToBoard, switch_to_triage => SwitchToTriage,
2098        switch_to_cycle => SwitchToCycle, switch_to_milestones => SwitchToMilestones,
2099        switch_to_repos => SwitchToRepos,
2100        switch_to_stats => SwitchToStats, health_form => HealthForm,
2101        time_add => TimeAdd, time_edit => TimeEdit, time_delete => TimeDelete,
2102        time_filter => TimeFilter, time_breakdown => TimeBreakdown, time_history => TimeHistory,
2103        switch_to_schedule => SwitchToSchedule,
2104        series_generate => SeriesGenerate, series_pause => SeriesPause,
2105        series_resume => SeriesResume, series_end => SeriesEnd,
2106        series_skip => SeriesSkip, series_defer => SeriesDefer,
2107        series_ignore => SeriesIgnore,
2108        series_new => SeriesNew,
2109        switch_to_new_issue => SwitchToNewIssue,
2110        switch_to_library => SwitchToLibrary,
2111        switch_to_portfolio => SwitchToPortfolio,
2112        switch_to_new_issue => SwitchToNewIssue,
2113        switch_to_config => SwitchToConfig, toggle_navigation => ToggleNavigation,
2114    );
2115    let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
2116    for (action, chords) in map {
2117        for key in chords {
2118            if let Some(previous) = seen.insert(*key, *action) {
2119                return Err(TixError::Config(format!(
2120                    "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
2121                    context.as_str(),
2122                    key,
2123                    previous.as_str(),
2124                    action.as_str()
2125                )));
2126            }
2127        }
2128    }
2129    Ok(())
2130}
2131
2132impl ActionBindings {
2133    pub fn is_empty(&self) -> bool {
2134        self == &Self::default()
2135    }
2136}
2137
2138#[cfg(test)]
2139mod tests {
2140    use super::*;
2141
2142    #[test]
2143    fn parses_and_normalizes_documented_chords() {
2144        assert_eq!(KeyChord::parse("ESC").unwrap(), chord("esc"));
2145        assert_eq!(KeyChord::parse("Enter").unwrap(), chord("enter"));
2146        assert_eq!(KeyChord::parse("LEFT").unwrap(), chord("left"));
2147        assert_eq!(KeyChord::parse("PgUp").unwrap(), chord("pgup"));
2148        assert_eq!(KeyChord::parse("F2").unwrap(), chord("f2"));
2149        assert_eq!(KeyChord::parse("Ctrl+UP").unwrap(), chord("ctrl+up"));
2150        assert_eq!(KeyChord::parse("A").unwrap().display(), "Shift+A");
2151        assert!(KeyChord::parse("ctrl+wat").is_err());
2152        assert!(KeyChord::parse("f99").is_err());
2153    }
2154
2155    #[test]
2156    fn defaults_validate_and_cross_mode_reuse_is_allowed() {
2157        let defaults = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2158        assert!(
2159            defaults
2160                .action(
2161                    InputContext::Board,
2162                    KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
2163                )
2164                .is_some()
2165        );
2166        let mut overrides = KeyBindings::default();
2167        overrides.board.open = Some(vec!["f4".into()]);
2168        overrides.detail.close = Some(vec!["f4".into()]);
2169        assert!(KeyBindings::resolve(&overrides).is_ok());
2170    }
2171
2172    #[test]
2173    fn filter_modal_opens_the_picker_and_commits_on_the_apply_key() {
2174        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2175        let ctx = InputContext::FilterModal;
2176        // `enter`, `l` and `right` all open the focused row's picker and rewrite to
2177        // the action's canonical event, so the handler sees one key rather than
2178        // three. A regression here would make `enter` commit again (the pre-overhaul
2179        // behavior) with no handler change to notice it.
2180        for code in [KeyCode::Enter, KeyCode::Char('l'), KeyCode::Right] {
2181            let event = KeyEvent::new(code, KeyModifiers::NONE);
2182            let action = bindings.action(ctx, event);
2183            assert_eq!(
2184                action,
2185                Some(TuiAction::OpenPicker),
2186                "{code:?} must open the focused row's picker"
2187            );
2188            assert_eq!(
2189                bindings.canonical_event(ctx, TuiAction::OpenPicker),
2190                KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE),
2191                "{code:?} must canonicalize to the picker's F2"
2192            );
2193        }
2194        // A plain, always-deliverable key commits. It canonicalizes to `enter` so
2195        // the handler can tell it apart from the opener — and it is deliberately not
2196        // `ctrl+enter`, which a terminal without the kitty keyboard protocol sends
2197        // as the same byte as plain `enter`.
2198        let commit = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE);
2199        assert_eq!(bindings.action(ctx, commit), Some(TuiAction::Submit));
2200        assert_eq!(
2201            bindings.canonical_event(ctx, TuiAction::Submit),
2202            KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)
2203        );
2204        // The plain `enter` that opens the picker is not the one that commits.
2205        assert_eq!(
2206            bindings.action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
2207            Some(TuiAction::OpenPicker)
2208        );
2209        assert!(
2210            bindings
2211                .action(ctx, KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL))
2212                .is_none(),
2213            "the unreachable `ctrl+enter` chord must not be bound at all"
2214        );
2215    }
2216
2217    #[test]
2218    fn board_binds_v_to_views_and_shift_v_to_view_and_detail_keeps_v() {
2219        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2220        // The board's `v` is the saved-views modal (WP05), so the read-only viewer
2221        // (#1507) takes the shifted letter there; Detail has no views modal and
2222        // keeps the plain `v`.
2223        let board_view = KeyEvent::new(KeyCode::Char('V'), KeyModifiers::NONE);
2224        assert_eq!(
2225            bindings.action(InputContext::Board, board_view),
2226            Some(TuiAction::View),
2227            "the board must bind `V` to view"
2228        );
2229        assert_eq!(
2230            bindings.canonical_event(InputContext::Board, TuiAction::View),
2231            board_view,
2232            "`view` must canonicalize to the board's own chord so the handler sees it as typed"
2233        );
2234        assert_eq!(
2235            bindings.action(
2236                InputContext::Board,
2237                KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE)
2238            ),
2239            Some(TuiAction::Views),
2240            "the board must bind `v` to the saved-views modal"
2241        );
2242        let detail_view = KeyEvent::new(KeyCode::Char('v'), KeyModifiers::NONE);
2243        assert_eq!(
2244            bindings.action(InputContext::Detail, detail_view),
2245            Some(TuiAction::View),
2246            "Detail must bind `v` to view"
2247        );
2248        assert_eq!(
2249            bindings.canonical_event(InputContext::Detail, TuiAction::View),
2250            detail_view,
2251            "`view` must canonicalize to `v` in Detail"
2252        );
2253    }
2254
2255    /// The conflict prompt's three decisions (#1510): every choice is reachable by
2256    /// two chords, and each pair resolves to an action whose canonical event is the
2257    /// code the handler's arms read (`y`, `Enter`, `r`) — the indirection that makes
2258    /// `Esc` abandon and `c` compare without a chord name in the logic.
2259    #[test]
2260    fn the_conflict_prompt_binds_overwrite_abandon_and_compare() {
2261        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2262        let cases = [
2263            (KeyCode::Char('o'), TuiAction::Confirm),
2264            (KeyCode::Char('y'), TuiAction::Confirm),
2265            (KeyCode::Char('a'), TuiAction::Ignore),
2266            (KeyCode::Esc, TuiAction::Ignore),
2267            (KeyCode::Char('c'), TuiAction::Reedit),
2268            (KeyCode::Char('r'), TuiAction::Reedit),
2269        ];
2270        for (code, action) in cases {
2271            assert_eq!(
2272                bindings.action(
2273                    InputContext::EditConflict,
2274                    KeyEvent::new(code, KeyModifiers::NONE)
2275                ),
2276                Some(action),
2277                "{code:?} must be {action:?} in the conflict prompt"
2278            );
2279        }
2280        // …and the canonical events are what `handle_edit_conflict_key` matches on,
2281        // so the mapping is asserted rather than assumed.
2282        for (action, expected) in [
2283            (TuiAction::Confirm, KeyCode::Char('y')),
2284            (TuiAction::Ignore, KeyCode::Enter),
2285            (TuiAction::Reedit, KeyCode::Char('r')),
2286        ] {
2287            assert_eq!(
2288                bindings.canonical_event(InputContext::EditConflict, action),
2289                KeyEvent::new(expected, KeyModifiers::NONE),
2290                "{action:?} must hand the handler `{expected:?}`"
2291            );
2292        }
2293        // A key nobody bound does nothing: `q` must not close the prompt.
2294        assert_eq!(
2295            bindings.action(
2296                InputContext::EditConflict,
2297                KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
2298            ),
2299            None
2300        );
2301    }
2302
2303    /// The Config mode's column reordering (tui-config-mode). `Shift+K`/
2304    /// `Shift+J` move the selected column; they are distinct from the plain
2305    /// `k`/`j` cursor moves, they accept the documented TOML override, and a
2306    /// conflict inside the same context is still rejected.
2307    #[test]
2308    fn settings_binds_column_moves_distinct_from_the_cursor() {
2309        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2310        let shifted = |ch: char| KeyEvent::new(KeyCode::Char(ch), KeyModifiers::SHIFT);
2311        assert_eq!(
2312            bindings.action(InputContext::Settings, shifted('K')),
2313            Some(TuiAction::MoveColumnUp)
2314        );
2315        assert_eq!(
2316            bindings.action(InputContext::Settings, shifted('J')),
2317            Some(TuiAction::MoveColumnDown)
2318        );
2319        // Plain `k`/`j` stay the cursor; only the shifted pair reorders.
2320        assert_eq!(
2321            bindings.action(
2322                InputContext::Settings,
2323                KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)
2324            ),
2325            Some(TuiAction::MoveUp)
2326        );
2327        assert_eq!(
2328            bindings.action(
2329                InputContext::Settings,
2330                KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)
2331            ),
2332            Some(TuiAction::MoveDown)
2333        );
2334        // The canonical events are the uppercase codes the handler matches on.
2335        assert_eq!(
2336            bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnUp),
2337            shifted('K')
2338        );
2339        assert_eq!(
2340            bindings.canonical_event(InputContext::Settings, TuiAction::MoveColumnDown),
2341            shifted('J')
2342        );
2343
2344        // The documented TOML fields load and take effect.
2345        let mut overrides = KeyBindings::default();
2346        overrides.settings.move_column_up = Some(vec!["ctrl+u".into()]);
2347        overrides.settings.move_column_down = Some(vec!["ctrl+d".into()]);
2348        let overridden = KeyBindings::resolve(&overrides).unwrap();
2349        assert_eq!(
2350            overridden.action(
2351                InputContext::Settings,
2352                KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)
2353            ),
2354            Some(TuiAction::MoveColumnUp)
2355        );
2356        assert_eq!(
2357            overridden.action(
2358                InputContext::Settings,
2359                KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
2360            ),
2361            Some(TuiAction::MoveColumnDown)
2362        );
2363
2364        // A same-context conflict is still rejected, naming the mode and action.
2365        let mut conflict = KeyBindings::default();
2366        conflict.settings.move_column_up = Some(vec!["a".into()]);
2367        let error = KeyBindings::resolve(&conflict).unwrap_err().to_string();
2368        assert!(
2369            error.contains("settings") && error.contains("move_column_up"),
2370            "the conflict must name the mode and the action: {error}"
2371        );
2372    }
2373
2374    /// Task 5.4: `i` is bound to the mentions view on the board, resolves without a
2375    /// conflict against every other board default, and canonicalizes back to `i` — so
2376    /// the board's own handler can read the key it advertises.
2377    #[test]
2378    fn the_mentions_view_binds_i_on_the_board_without_a_conflict() {
2379        let bindings = KeyBindings::resolve(&KeyBindings::default())
2380            .expect("adding open_mentions must not conflict with an existing board chord");
2381        let key = KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE);
2382        assert_eq!(
2383            bindings.action(InputContext::Board, key),
2384            Some(TuiAction::OpenMentions)
2385        );
2386        assert_eq!(
2387            bindings.canonical_event(InputContext::Board, TuiAction::OpenMentions),
2388            key
2389        );
2390        assert_eq!(
2391            bindings
2392                .chords(InputContext::Board, TuiAction::OpenMentions)
2393                .len(),
2394            1
2395        );
2396        // The action is reachable by name from `tix.toml`, like every other one.
2397        let mut overrides = KeyBindings::default();
2398        overrides.board.open_mentions = Some(vec!["ctrl+m".into()]);
2399        let rebound = KeyBindings::resolve(&overrides).expect("a rebind resolves");
2400        assert_eq!(
2401            rebound.action(
2402                InputContext::Board,
2403                KeyEvent::new(KeyCode::Char('m'), KeyModifiers::CONTROL)
2404            ),
2405            Some(TuiAction::OpenMentions)
2406        );
2407    }
2408
2409    #[test]
2410    fn conflicts_report_mode_and_actions() {
2411        let mut overrides = KeyBindings::default();
2412        overrides.board.open = Some(vec!["ctrl+n".into()]);
2413        overrides.board.new_issue = Some(vec!["ctrl+n".into()]);
2414        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
2415        assert!(error.contains("board") && error.contains("open") && error.contains("new_issue"));
2416    }
2417
2418    #[test]
2419    fn shell_defaults_resolve_mode_switches_and_toggle() {
2420        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
2421        // F1 New Issue, F2 Triage, F3 Board, F4 Cycles, F5 Milestones,
2422        // F6 Repos (tix #1529), F12 Config.
2423        let cases = [
2424            (KeyCode::F(1), TuiAction::SwitchToNewIssue),
2425            (KeyCode::F(2), TuiAction::SwitchToTriage),
2426            (KeyCode::F(3), TuiAction::SwitchToBoard),
2427            (KeyCode::F(4), TuiAction::SwitchToCycle),
2428            (KeyCode::F(5), TuiAction::SwitchToMilestones),
2429            (KeyCode::F(6), TuiAction::SwitchToRepos),
2430            (KeyCode::F(12), TuiAction::SwitchToConfig),
2431        ];
2432        for (code, action) in cases {
2433            assert_eq!(
2434                bindings.action(InputContext::Shell, KeyEvent::new(code, KeyModifiers::NONE)),
2435                Some(action),
2436                "{code:?} must switch modes in the shell"
2437            );
2438            assert_eq!(
2439                bindings.canonical_event(InputContext::Shell, action),
2440                KeyEvent::new(code, KeyModifiers::NONE),
2441                "{action:?} must canonicalize to its default chord"
2442            );
2443        }
2444        assert_eq!(
2445            bindings.action(
2446                InputContext::Shell,
2447                KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)
2448            ),
2449            Some(TuiAction::ToggleNavigation)
2450        );
2451        // F6 switches to the Repos mode (tix #1529); F8 and F11 are the Schedule
2452        // and Library pages (tix #1593) — the first of the reserved keys to be
2453        // spent.
2454        assert_eq!(
2455            bindings.action(
2456                InputContext::Shell,
2457                KeyEvent::new(KeyCode::F(8), KeyModifiers::NONE)
2458            ),
2459            Some(TuiAction::SwitchToSchedule)
2460        );
2461        assert_eq!(
2462            bindings.action(
2463                InputContext::Shell,
2464                KeyEvent::new(KeyCode::F(11), KeyModifiers::NONE)
2465            ),
2466            Some(TuiAction::SwitchToLibrary)
2467        );
2468        // WP06: F10 was reserved too, and the Portfolio page's Projects tab took
2469        // it.
2470        assert_eq!(
2471            bindings.action(
2472                InputContext::Shell,
2473                KeyEvent::new(KeyCode::F(10), KeyModifiers::NONE)
2474            ),
2475            Some(TuiAction::SwitchToPortfolio)
2476        );
2477        // F9 stays reserved: the shell binds nothing there until its page exists.
2478        // `#1593` reserves F9 for the Data page (Import/Export, Archive, Duplicates),
2479        // and this mission's epic surface is the F10 Portfolio page's Epics tab — so
2480        // F9 is deliberately **not** spent here. F7 left this list when mission
2481        // `project-issue-health-status-01M3C8HX` gave it the Stats page's Health tab,
2482        // and F10 left it with the Portfolio page (WP06).
2483        assert_eq!(
2484            bindings.action(
2485                InputContext::Shell,
2486                KeyEvent::new(KeyCode::F(9), KeyModifiers::NONE)
2487            ),
2488            None,
2489            "F9 must stay unbound in the shell until the Data page exists"
2490        );
2491    }
2492
2493    #[test]
2494    fn shell_conflict_reports_both_actions_and_mode() {
2495        let mut overrides = KeyBindings::default();
2496        overrides.shell.switch_to_board = Some(vec!["f2".into()]);
2497        overrides.shell.switch_to_triage = Some(vec!["f2".into()]);
2498        let error = KeyBindings::resolve(&overrides).unwrap_err().to_string();
2499        assert!(
2500            error.contains("shell")
2501                && error.contains("switch_to_board")
2502                && error.contains("switch_to_triage"),
2503            "got {error:?}"
2504        );
2505    }
2506}