tix-tracker 0.11.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
macro_rules! overlay_fields {
    ($map:ident, $overrides:ident, $context:expr, $($field:ident => $action:ident),+ $(,)?) => {
        $(
            if let Some(values) = &$overrides.$field {
                let mut chords = Vec::with_capacity(values.len());
                for value in values {
                    chords.push(KeyChord::parse(value).map_err(|message| TixError::Config(format!(
                        "`keybindings.{}.{}`: {message}", $context.as_str(), stringify!($field)
                    )))?);
                }
                if chords.is_empty() {
                    return Err(TixError::Config(format!(
                        "`keybindings.{}.{}` must not be empty", $context.as_str(), stringify!($field)
                    )));
                }
                $map.insert(TuiAction::$action, chords);
            }
        )+
    };
}

#[allow(clippy::wildcard_imports)]
use super::*;

pub(super) fn chord(value: &str) -> KeyChord {
    KeyChord::parse(value).expect("built-in key chord must parse")
}

pub(super) fn set(map: &mut HashMap<TuiAction, Vec<KeyChord>>, action: TuiAction, values: &[&str]) {
    map.insert(action, values.iter().map(|value| chord(value)).collect());
}

pub(super) fn defaults(values: &[(&str, &[&str])]) -> HashMap<TuiAction, Vec<KeyChord>> {
    let mut map = HashMap::new();
    for (action, keys) in values {
        let action = match *action {
            "move_up" => TuiAction::MoveUp,
            "move_down" => TuiAction::MoveDown,
            "move_left" => TuiAction::MoveLeft,
            "move_right" => TuiAction::MoveRight,
            "next_board" => TuiAction::NextBoard,
            "previous_board" => TuiAction::PreviousBoard,
            "open" => TuiAction::Open,
            "edit" => TuiAction::Edit,
            "agent" => TuiAction::Agent,
            "view" => TuiAction::View,
            "toggle_column" => TuiAction::ToggleColumn,
            "filter_modal" => TuiAction::FilterModal,
            "bulk_modal" => TuiAction::BulkModal,
            "triage" => TuiAction::Triage,
            "open_mentions" => TuiAction::OpenMentions,
            "views" => TuiAction::Views,
            "apply_view" => TuiAction::ApplyView,
            "new_view" => TuiAction::NewView,
            "rename_view" => TuiAction::RenameView,
            "update_view" => TuiAction::UpdateView,
            "duplicate_view" => TuiAction::DuplicateView,
            "delete_view" => TuiAction::DeleteView,
            "mark" => TuiAction::Mark,
            "unmark" => TuiAction::Unmark,
            "move_previous" => TuiAction::MovePrevious,
            "move_next" => TuiAction::MoveNext,
            "new_issue" => TuiAction::NewIssue,
            "clone_issue" => TuiAction::CloneIssue,
            "toggle_archived" => TuiAction::ToggleArchived,
            "toggle_children" => TuiAction::ToggleChildren,
            "delete" => TuiAction::Delete,
            "refresh" => TuiAction::Refresh,
            "settings" => TuiAction::Settings,
            "planning" => TuiAction::Planning,
            "new_board" => TuiAction::NewBoard,
            "help" => TuiAction::Help,
            "quit" => TuiAction::Quit,
            "text_filter" => TuiAction::TextFilter,
            "reorder_up" => TuiAction::ReorderUp,
            "reorder_down" => TuiAction::ReorderDown,
            "close" => TuiAction::Close,
            "scroll_up" => TuiAction::ScrollUp,
            "scroll_down" => TuiAction::ScrollDown,
            "comment" => TuiAction::Comment,
            "asset" => TuiAction::Asset,
            "new_relationship" => TuiAction::NewRelationship,
            "edit_relationship" => TuiAction::EditRelationship,
            "remove_relationship" => TuiAction::RemoveRelationship,
            "edit_parent" => TuiAction::EditParent,
            "next_relationship" => TuiAction::NextRelationship,
            "previous_relationship" => TuiAction::PreviousRelationship,
            "related_picker" => TuiAction::RelatedPicker,
            "follow_related" => TuiAction::FollowRelated,
            "nav_back" => TuiAction::NavBack,
            "submit" => TuiAction::Submit,
            "cancel" => TuiAction::Cancel,
            "next" => TuiAction::Next,
            "previous" => TuiAction::Previous,
            "open_picker" => TuiAction::OpenPicker,
            "reset" => TuiAction::Reset,
            "toggle" => TuiAction::Toggle,
            "add_column" => TuiAction::AddColumn,
            "move_column_up" => TuiAction::MoveColumnUp,
            "move_column_down" => TuiAction::MoveColumnDown,
            "new_planning_item" => TuiAction::NewPlanningItem,
            "edit_planning_item" => TuiAction::EditPlanningItem,
            "delete_planning_item" => TuiAction::DeletePlanningItem,
            "confirm" => TuiAction::Confirm,
            "create" => TuiAction::Create,
            "previous_year" => TuiAction::PreviousYear,
            "next_year" => TuiAction::NextYear,
            "previous_month" => TuiAction::PreviousMonth,
            "next_month" => TuiAction::NextMonth,
            "reedit" => TuiAction::Reedit,
            "ignore" => TuiAction::Ignore,
            "declare_key" => TuiAction::DeclareKey,
            "discard" => TuiAction::Discard,
            "switch_to_board" => TuiAction::SwitchToBoard,
            "switch_to_triage" => TuiAction::SwitchToTriage,
            "switch_to_cycle" => TuiAction::SwitchToCycle,
            "switch_to_milestones" => TuiAction::SwitchToMilestones,
            "switch_to_repos" => TuiAction::SwitchToRepos,
            "switch_to_stats" => TuiAction::SwitchToStats,
            "health_form" => TuiAction::HealthForm,
            "time_add" => TuiAction::TimeAdd,
            "time_edit" => TuiAction::TimeEdit,
            "time_delete" => TuiAction::TimeDelete,
            "time_filter" => TuiAction::TimeFilter,
            "time_breakdown" => TuiAction::TimeBreakdown,
            "time_history" => TuiAction::TimeHistory,
            "switch_to_schedule" => TuiAction::SwitchToSchedule,
            "series_generate" => TuiAction::SeriesGenerate,
            "series_pause" => TuiAction::SeriesPause,
            "series_resume" => TuiAction::SeriesResume,
            "series_end" => TuiAction::SeriesEnd,
            "series_skip" => TuiAction::SeriesSkip,
            "series_defer" => TuiAction::SeriesDefer,
            "series_ignore" => TuiAction::SeriesIgnore,
            "series_new" => TuiAction::SeriesNew,
            "switch_to_library" => TuiAction::SwitchToLibrary,
            "switch_to_portfolio" => TuiAction::SwitchToPortfolio,
            "switch_to_new_issue" => TuiAction::SwitchToNewIssue,
            "switch_to_config" => TuiAction::SwitchToConfig,
            "toggle_navigation" => TuiAction::ToggleNavigation,
            _ => unreachable!(),
        };
        set(&mut map, action, keys);
    }
    map
}

#[allow(clippy::too_many_lines)]
pub(super) fn default_mode(context: InputContext) -> HashMap<TuiAction, Vec<KeyChord>> {
    match context {
        InputContext::Board => defaults(&[
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            // tix #1442 (follow-up): a screen of cards. They have to be *bound* for the
            // board to see them at all: `App::handle_key` routes an unbound key in a
            // non-text context to `F(12)` rather than passing it through, which is why
            // `PgUp`/`PgDn` did nothing here while the wheel next to them worked. They
            // canonicalize to `PageUp`/`PageDown`, so the handler can tell a page from
            // a row.
            ("scroll_up", &["pgup"]),
            ("scroll_down", &["pgdn"]),
            ("move_left", &["left", "h"]),
            ("move_right", &["right", "l"]),
            ("next_board", &["tab"]),
            ("previous_board", &["shift+tab"]),
            ("open", &["enter"]),
            ("edit", &["e"]),
            // #1507: read the selected card in the resolved viewer, read-only.
            // The viewer keeps `v` in the **Detail** context; on the board `v` is
            // the saved-views modal (WP05 — 11 tests, and documented in
            // `README.md`, `docs/key-bindings.md` and `docs/saved-views.md`), so
            // the board's viewer chord is the shifted letter, matching the board's
            // `A`/`B`/`C`/`J`/`K` family.
            ("view", &["V"]),
            ("agent", &["A"]),
            ("toggle_column", &["c"]),
            ("filter_modal", &["f"]),
            ("bulk_modal", &["B"]),
            // WP06: the triage inbox. `t` was unbound, so this adds a key
            // rather than changing what any existing one means.
            ("triage", &["t"]),
            // The mentions view: `i` was unbound on the board, and the bottom
            // indicator row spells the chord it is bound to.
            ("open_mentions", &["i"]),
            // WP05: the saved-views modal. `v` was unbound, for the same reason.
            ("views", &["v"]),
            // The chord is spelled `space`, not a literal blank: `KeyChord::parse`
            // trims its input, so a bare `" "` is the empty chord and every
            // built-in binding panics at startup.
            ("mark", &["space"]),
            ("unmark", &["x"]),
            ("move_previous", &["<"]),
            ("move_next", &[">"]),
            ("new_issue", &["n"]),
            // #1484: the capital of `c` (toggle column), not a new letter — the
            // board's free letters are its navigation, and `C` keeps "clone" beside
            // the toggle it is the second half of.
            ("clone_issue", &["C"]),
            // #1492: `a` was free on the board, and "archived" is what it means.
            ("toggle_archived", &["a"]),
            // #1399: `u` was free on the board; "sub-issues" is what it shows.
            ("toggle_children", &["u"]),
            ("delete", &["d"]),
            ("refresh", &["r"]),
            ("settings", &["s"]),
            ("planning", &["m", "M"]),
            ("new_board", &["b"]),
            ("help", &["?"]),
            ("quit", &["q", "Q", "esc"]),
            ("text_filter", &["/"]),
            // #1586: `Shift+K`/`Shift+Up` and `Shift+J`/`Shift+Down` reorder the
            // selected card exactly as `Ctrl+Up`/`Ctrl+Down` do — the same
            // move the mouse drag performs. The shifted letters are free on the
            // board (plain `k`/`j` are the cursor), so this adds keys without
            // changing what any existing one means.
            ("reorder_up", &["ctrl+up", "shift+up", "K"]),
            ("reorder_down", &["ctrl+down", "shift+down", "J"]),
        ]),
        InputContext::Rollover => defaults(&[
            ("submit", &["enter"]),
            ("previous", &["left", "right", "tab"]),
            ("cancel", &["esc"]),
        ]),
        InputContext::Detail => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("edit", &["e"]),
            // #1507: view the open issue in the resolved read-only viewer.
            ("view", &["v"]),
            ("scroll_up", &["pgup"]),
            ("scroll_down", &["pgdn"]),
            ("help", &["?"]),
            ("comment", &["c"]),
            ("asset", &["a"]),
            // #1359: the three mutating relationship operations, plus the cursor
            // that names which edge `E` and `d` act on. `e` stays the editor, so
            // re-kind is `E`.
            ("new_relationship", &["n"]),
            ("edit_relationship", &["E"]),
            ("remove_relationship", &["d"]),
            // #1384: `P` was free in the detail pane. Set/change/clear the open
            // issue's parent through the shared hierarchy service, which is the
            // TUI half of `tix edit --parent` that the create-only form could
            // not express.
            ("edit_parent", &["P"]),
            ("next_relationship", &["j", "down"]),
            ("previous_relationship", &["k", "up"]),
            // #1513: the related-issues surface. `r` lists them, `o` follows the
            // selected one, `Backspace` returns to the previous pane. `j`/`k`
            // keep moving the **relationship** cursor; the picker is the
            // selection surface for related entries.
            ("related_picker", &["r"]),
            ("follow_related", &["o"]),
            ("nav_back", &["backspace"]), // Mission `project-issue-health-status-01M3C8HX` (WP06): the health form, on
            // the issue the pane is showing.
            ("health_form", &["H"]),
        ]),
        InputContext::RelatedPicker => defaults(&[
            ("cancel", &["esc"]),
            ("next", &["down"]),
            ("previous", &["up"]),
            ("submit", &["enter"]),
        ]),
        InputContext::Form | InputContext::PlanningForm => defaults(&[
            ("cancel", &["esc"]),
            ("submit", &["enter"]),
            ("next", &["tab", "down"]),
            ("previous", &["shift+tab", "up"]),
            ("open_picker", &["f2"]),
        ]),
        InputContext::FormPicker => defaults(&[
            ("cancel", &["esc", "f2"]),
            ("next", &["up"]),
            ("previous", &["down"]),
            ("submit", &["enter"]),
            ("create", &["+", "insert"]),
        ]),
        InputContext::ConfirmDelete => defaults(&[("confirm", &["y", "Y"]), ("cancel", &["esc"])]),
        // `n` cancels as well as `esc`: a yes/no prompt whose only "no" is a
        // non-printing key is a trap for anyone who does not know it.
        InputContext::ConfirmBlockedMove => defaults(&[
            ("confirm", &["y", "Y", "enter"]),
            ("cancel", &["n", "N", "esc"]),
        ]),
        // Free-text entry. `cancel` is **`esc` only**, and that is load-bearing
        // rather than a style choice.
        //
        // All four of these read arbitrary text into a buffer — a commit message, a
        // comment body, a **filesystem path**, a filter string. Binding `q` to cancel
        // means the letter `q` cannot be typed, and it is worse than that: the
        // binding resolves to the action's canonical event (`esc`), so `q` does not
        // merely fail to type, it **leaves the mode**, and every character after it
        // is then interpreted as a command in the mode underneath.
        //
        // This was found by a test flake (#1305), not by inspection. The asset
        // round-trip test types the source path through `handle_key`, and the path
        // came from a `tempfile` temp directory whose six random characters are drawn
        // from `[A-Za-z0-9]`. Roughly one run in forty produced a name containing a
        // `q`; the prompt closed mid-path and the rest of the string was applied as
        // Detail-mode keys — which is how a run came to report
        // `status = "Commented by …"` and no asset at all. A user attaching an asset
        // from a path containing `q` gets the same thing, and it silently creates a
        // comment on the issue.
        //
        // The test was at fault for depending on a random name, but the behaviour is
        // a real defect, so the binding moves rather than the fixture. `esc` remains
        // the cancel key, which is already this codebase's convention for text entry
        // — `FilterModal` and `ConfirmDelete` both bind `esc` and nothing else.
        //
        // `q`-to-close is kept where there is no text to type: `Help`,
        // `ContextMenu`, `Settings`, `PlanningManagement`, `DatePicker`, and the
        // yes/no `ConfirmBlockedMove` keep it.
        InputContext::MoveIntoDone
        | InputContext::Comment
        | InputContext::Asset
        | InputContext::Filter => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
        // The overlay and the context menu are *not* the same surface, and sharing a
        // context made them one: a binding added for the overlay (`pgup`/`pgdn`, tix
        // #1441) would silently become a context-menu key too. They share a key set
        // today only by coincidence, so they get their own arms.
        InputContext::Help => defaults(&[
            ("cancel", &["esc", "q", "?"]),
            ("next", &["down", "j"]),
            ("previous", &["up", "k"]),
            ("submit", &["enter"]),
            // #1496: `/` filters the list. Bound rather than handled as a raw key so it
            // is rebindable like everything else — and so the overlay's own rows name
            // it, since a search nobody knows about is a search nobody uses.
            ("text_filter", &["/"]),
            // #1441/#1442: one page at a time. These have to be *bound* for the
            // overlay to see them at all — `App::handle_key` routes an unbound key in
            // a non-text context to `F(12)` rather than passing it through — and they
            // canonicalize to themselves so the handler can tell a page from a row.
            ("scroll_up", &["pgup"]),
            ("scroll_down", &["pgdn"]),
        ]),
        // The issue action menu. It reads **free text**: letters filter the
        // list, so only the navigation keys are bound here and `allows_text_fallback`
        // keeps `j`/`k`/`q`/`?` typeable.
        InputContext::ContextMenu => defaults(&[
            ("cancel", &["esc"]),
            ("next", &["down"]),
            ("previous", &["up"]),
            ("submit", &["enter"]),
            ("scroll_up", &["pgup"]),
            ("scroll_down", &["pgdn"]),
        ]),
        // The overlay's `/` input (tix #1496): free text, so `cancel` is `Esc` and only
        // `Esc`. `q` and `?` stay ordinary characters here — binding them to cancel
        // would rewrite them to `esc` inside `App::handle_key`, before the handler saw
        // them, and a query could never contain a `q`.
        InputContext::HelpSearch => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
        InputContext::BulkModal => defaults(&[
            ("cancel", &["esc"]),
            ("next", &["tab", "down"]),
            ("previous", &["shift+tab", "up"]),
            ("submit", &["enter"]),
        ]),
        // WP06. Five bindings and nothing else, because every remaining inbox
        // key (`q`, `j`, `k`, `a`, `d`, `u`, `f`, `p`, `y`, `?`) has to arrive
        // **raw**: a decline reason containing `q` and a duplicate target
        // containing `a` are ordinary, and `allows_text_fallback` is what keeps
        // them letters instead of turning them into a close or an accept.
        InputContext::Triage => defaults(&[
            ("cancel", &["esc"]),
            ("submit", &["enter"]),
            ("move_up", &["up"]),
            ("move_down", &["down"]),
            ("toggle", &["space"]),
        ]),
        // The F3 Cycle mode (tui-cycle-mode). `m` opens the planning-management
        // surface for the same reason the board binds it: it is where cycles and
        // milestones are created, renamed, and deleted. Nothing here collides with
        // the surface's own keys, so opening it from this mode is unambiguous.
        InputContext::Cycle => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            // The listing's horizontal axis: `h`/`l` and the arrows land on the
            // first entry of the previous / next rendered group.
            ("move_left", &["left", "h"]),
            ("move_right", &["right", "l"]),
            ("planning", &["m"]),
            ("help", &["?"]),
        ]),
        // The F5 Milestones mode. `m`, `j`/`k`, `q`/`esc` mirror the Cycle
        // listing: it is the same kind of registry view. The planning commands
        // (`n`/`e`/`x`, `Enter` to edit) are bound here too, so the milestone
        // registry is editable from its own mode and not only from F12.
        InputContext::Milestones => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("submit", &["enter"]),
            ("new_planning_item", &["n"]),
            ("edit_planning_item", &["e"]),
            ("delete_planning_item", &["x", "X"]),
            ("planning", &["m"]),
            ("help", &["?"]),
        ]),
        // The F6 Repos mode (tix #1529). `j`/`k` walk the list, `Enter` runs tix
        // in the selected repository, and `q`/`esc` leave. No free text, so the
        // letters are safe as close keys.
        // The F7 Stats page: its two tabs and each tab's own keys.
        //
        // `Tab`/`Shift+Tab` switch between the Health and Time tabs; `Enter` opens the
        // selected Health row's issue's detail (where the health form lives);
        // `j`/`k` walk the active tab's rows; and `q`/`esc` leave. No free text, so the
        // letters are safe as close and command keys.
        //
        // The Time tab's six commands are all **inert without `Enter`**: `a`/`e`/`x`
        // open a form, `f` opens the filter editor, and `s`/`h` change only what the
        // tab is showing — so no key on the page writes anything by itself (WP05).
        InputContext::Stats => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("next", &["tab"]),
            ("previous", &["shift+tab"]),
            ("submit", &["enter"]),
            ("time_add", &["a"]),
            ("time_edit", &["e"]),
            ("time_delete", &["x"]),
            ("time_filter", &["f"]),
            ("time_breakdown", &["s"]),
            ("time_history", &["h"]),
            ("help", &["?"]),
        ]),
        InputContext::Repos => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("submit", &["enter"]),
            ("help", &["?"]),
        ]),
        // The F8 Schedule page (tix #1593): `j`/`k` walk the series rows and the
        // action keys are free letters on a page that carries no free text.
        InputContext::Schedule => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("series_new", &["n"]),
            ("series_generate", &["g"]),
            ("series_pause", &["p"]),
            ("series_resume", &["r"]),
            ("series_end", &["x"]),
            ("series_skip", &["s"]),
            ("series_defer", &["d"]),
            ("series_ignore", &["i"]),
            ("help", &["?"]),
        ]),
        // The F11 Library page (tix #1593). It reads two registries, so the tab
        // keys are its own: `tab`/`h` and `shift+tab`/`l` switch between Views and
        // Templates, `j`/`k` (and the arrows) walk rows, and `enter` applies the
        // selected view. No free text, so `q`/`esc` may close it.
        InputContext::Library => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("next", &["tab", "l", "right"]),
            ("previous", &["shift+tab", "h", "left"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("submit", &["enter"]),
            ("help", &["?"]),
        ]),
        // The F10 Portfolio page's Projects tab (WP06). It reads one registry, so it
        // has no tab keys: `j`/`k` (and the arrows) walk the project rows, the pane
        // beside them is read-only, and `q`/`esc` may close it because it carries no
        // free text.
        InputContext::Portfolio => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("next", &["tab"]),
            ("previous", &["shift+tab"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("move_left", &["left", "h"]),
            ("move_right", &["right", "l"]),
            ("submit", &["enter"]),
            ("help", &["?"]),
        ]),
        // The filter modal is picker-backed: `enter` (and `l`/`right`) open the
        // focused criterion's value list, or the calendar on a date row, so the
        // open-picker action owns them. `submit` moves to plain `a`, which is what
        // commits. **Not `ctrl+enter`**: without the kitty keyboard protocol a
        // terminal sends the same byte as plain `enter`, so the chord would be
        // unreachable. Both rewrite to their canonical event before the handler
        // runs — `open_picker` to `F(2)`, `submit` to `Enter` — so the handler can
        // tell "open the list" from "apply the filter" without seeing the chord.
        // WP05. The list is a menu: `n`/`e`/`u`/`d`/`x` are commands, so this
        // context deliberately has **no** text fallback — an unbound key is inert
        // rather than typed. The prompt beside it is the opposite, which is why
        // they are two contexts rather than one.
        InputContext::Views => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("apply_view", &["enter"]),
            ("new_view", &["n"]),
            ("rename_view", &["e"]),
            ("update_view", &["u"]),
            ("duplicate_view", &["d"]),
            ("delete_view", &["x", "X"]),
        ]),
        // Free text, so `submit`/`cancel` only and `cancel` is `esc` **alone**: a
        // view name containing `q` is ordinary, and binding it here would close
        // the prompt and apply the rest of the name as commands underneath.
        InputContext::ViewsPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
        InputContext::FilterModal => defaults(&[
            ("cancel", &["esc"]),
            ("next", &["tab", "down"]),
            ("previous", &["shift+tab", "up"]),
            ("reset", &["r", "R"]),
            ("submit", &["a"]),
            ("open_picker", &["f2", "enter", "l", "right"]),
        ]),
        // The F12 Config mode's sections (tui-config-mode). The dashboards
        // section edits the active board, so `tab`/`shift+tab` switch boards
        // (`next_board`/`previous_board`, whose canonical events are Tab and
        // BackTab). `left`/`right` move the pane focus between the section rail
        // and the body, and `up`/`down` move inside the focused pane — the
        // section when the rail is focused, the row when the body is. `n`/`e`/`x`
        // are the new/rename/delete keys in both the dashboards section and the
        // cycles/milestones sections — the section decides what they act on.
        //
        // `enter` is `submit`, not a second `toggle` chord: it steps from the
        // rail into the body and then activates the row, while `space` keeps
        // toggling wherever the cursor is. Both bound to `toggle` would
        // canonicalize to the same event and there would be no Enter behaviour
        // to implement.
        InputContext::Settings => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("next_board", &["tab", "]"]),
            ("previous_board", &["shift+tab", "["]),
            ("move_left", &["left", "h"]),
            ("move_right", &["right", "l"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("toggle", &["space"]),
            ("submit", &["enter"]),
            ("add_column", &["a"]),
            ("move_column_up", &["K"]),
            ("move_column_down", &["J"]),
            ("new_board", &["n"]),
            ("edit_planning_item", &["e"]),
            ("delete", &["x", "X"]),
        ]),
        InputContext::SettingsPrompt => defaults(&[
            ("cancel", &["esc", "q", "Q"]),
            ("submit", &["enter", "y", "Y"]),
        ]),
        InputContext::PlanningManagement => defaults(&[
            ("close", &["q", "Q", "esc"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            // The segments (Cycles / Milestones) are a horizontal axis, so the
            // horizontal movement keys switch them — the same rule the Config
            // rail's vertical section list deliberately does not follow: there
            // `h`/`l` are the pane axis and `j`/`k` walk the sections.
            ("move_left", &["left", "h"]),
            ("move_right", &["right", "l"]),
            ("next", &["tab", "shift+tab"]),
            ("new_planning_item", &["n"]),
            ("edit_planning_item", &["e"]),
            ("delete_planning_item", &["x", "X"]),
        ]),
        InputContext::PlanningConfirm => {
            defaults(&[("confirm", &["y", "Y", "enter"]), ("cancel", &["esc"])])
        }
        InputContext::DatePicker => defaults(&[
            ("cancel", &["esc", "q"]),
            ("submit", &["enter"]),
            ("move_up", &["up", "k"]),
            ("move_down", &["down", "j"]),
            ("move_left", &["left", "h"]),
            ("move_right", &["right", "l"]),
            ("previous_month", &["pgup"]),
            ("next_month", &["pgdn"]),
            ("previous_year", &["["]),
            ("next_year", &["]"]),
            ("reset", &["delete", "c", "C"]),
        ]),
        // #1506/#1510. The repair prompt the editor round trip raises when the
        // saved file has a problem: apply the proposed fix, declare the key,
        // reopen the editor on the retained copy, acknowledge, or discard.
        // `confirm` keeps `a`/`y` — the chord #1506's warning advertised — and
        // every other key is a decision, never a stray exit. `discard` is `x`
        // and requires a second press; `Esc` acknowledges and **keeps** the copy.
        InputContext::EditRepair => defaults(&[
            ("confirm", &["a", "y"]),
            ("declare_key", &["d", "D"]),
            ("reedit", &["r", "R"]),
            ("ignore", &["i", "I", "enter", "esc"]),
            ("discard", &["x", "X"]),
        ]),
        // #1510. The conflict prompt: overwrite, abandon, compare. The chords are
        // the ones the handler's arms key on — `handle_key` rewrites a bound key to
        // its action's canonical event, which is `y` for `confirm`, `Enter` for
        // `ignore`, and `r` for `reedit` — so every chord listed here arrives as
        // that canonical code. `Esc` is bound to `ignore`, which is what makes it
        // the safe default the prompt promises. `q` is deliberately *not* bound:
        // the prompt closes only through a decision it names.
        InputContext::EditConflict => defaults(&[
            ("confirm", &["o", "O", "y", "Y"]),
            ("ignore", &["a", "A", "esc"]),
            ("reedit", &["c", "C", "r", "R"]),
        ]),
        // The mode shell (tui-mode-navigation). Five switches and a display-state
        // toggle, nothing else: every other key belongs to the active mode, and this
        // context is consulted only when that mode declines the key.
        InputContext::Shell => defaults(&[
            ("switch_to_new_issue", &["f1"]),
            ("switch_to_triage", &["f2"]),
            ("switch_to_board", &["f3"]),
            ("switch_to_cycle", &["f4"]),
            ("switch_to_milestones", &["f5"]),
            // #1529: F6 was free in the shell; the repo list follows the
            // milestone registry.
            ("switch_to_repos", &["f6"]),
            // Mission `project-issue-health-status-01M3C8HX` (WP06): F7 now has its
            // page — the Stats page's Health tab.
            ("switch_to_stats", &["f7"]),
            // #1593: F7 stays reserved; F8 is the Schedule page, whose Series tab
            // ships with the recurring-issues mission.
            ("switch_to_schedule", &["f8"]),
            // WP06: F10 was the last reserved page key; the Portfolio page's
            // Projects tab takes it.
            ("switch_to_portfolio", &["f10"]),
            // #1593: F11 is the Library page.
            ("switch_to_library", &["f11"]),
            ("switch_to_config", &["f12"]),
            ("toggle_navigation", &["ctrl+n"]),
        ]),
        // #1359. Free text, so `q` must be typeable: a title fragment or a path
        // containing `q` is unremarkable, and binding it to cancel closes the prompt
        // and applies the rest of the input as commands underneath. See the note on
        // the `Asset`/`Comment`/`Filter` group for the full reasoning.
        InputContext::LinkPrompt => defaults(&[("cancel", &["esc"]), ("submit", &["enter"])]),
    }
}

pub(super) fn overlay(
    map: &mut HashMap<TuiAction, Vec<KeyChord>>,
    overrides: &ActionBindings,
    context: InputContext,
) -> Result<()> {
    overlay_fields!(map, overrides, context,
        move_up => MoveUp, move_down => MoveDown, move_left => MoveLeft, move_right => MoveRight,
        next_board => NextBoard, previous_board => PreviousBoard, open => Open, edit => Edit,
        agent => Agent, view => View, toggle_column => ToggleColumn, filter_modal => FilterModal,
        bulk_modal => BulkModal, mark => Mark, unmark => Unmark, triage => Triage,
        open_mentions => OpenMentions,
        views => Views, apply_view => ApplyView, new_view => NewView,
        rename_view => RenameView, update_view => UpdateView,
        duplicate_view => DuplicateView, delete_view => DeleteView,
        move_previous => MovePrevious, move_next => MoveNext, new_issue => NewIssue,
        clone_issue => CloneIssue,
        toggle_archived => ToggleArchived,
        toggle_children => ToggleChildren,
        delete => Delete, refresh => Refresh, settings => Settings, planning => Planning,
        new_board => NewBoard, help => Help, quit => Quit, text_filter => TextFilter, reorder_up => ReorderUp,
        reorder_down => ReorderDown, close => Close, scroll_up => ScrollUp, scroll_down => ScrollDown,
        comment => Comment, asset => Asset, edit_parent => EditParent,
        submit => Submit, cancel => Cancel, next => Next,
        previous => Previous, open_picker => OpenPicker, reset => Reset, toggle => Toggle,
        add_column => AddColumn, move_column_up => MoveColumnUp, move_column_down => MoveColumnDown,
        new_planning_item => NewPlanningItem,
        edit_planning_item => EditPlanningItem, delete_planning_item => DeletePlanningItem,
        confirm => Confirm, create => Create, previous_year => PreviousYear, next_year => NextYear,
        previous_month => PreviousMonth, next_month => NextMonth, reedit => Reedit, ignore => Ignore,
        declare_key => DeclareKey, discard => Discard,
        switch_to_board => SwitchToBoard, switch_to_triage => SwitchToTriage,
        switch_to_cycle => SwitchToCycle, switch_to_milestones => SwitchToMilestones,
        switch_to_repos => SwitchToRepos,
        switch_to_stats => SwitchToStats, health_form => HealthForm,
        time_add => TimeAdd, time_edit => TimeEdit, time_delete => TimeDelete,
        time_filter => TimeFilter, time_breakdown => TimeBreakdown, time_history => TimeHistory,
        switch_to_schedule => SwitchToSchedule,
        series_generate => SeriesGenerate, series_pause => SeriesPause,
        series_resume => SeriesResume, series_end => SeriesEnd,
        series_skip => SeriesSkip, series_defer => SeriesDefer,
        series_ignore => SeriesIgnore,
        series_new => SeriesNew,
        switch_to_new_issue => SwitchToNewIssue,
        switch_to_library => SwitchToLibrary,
        switch_to_portfolio => SwitchToPortfolio,
        switch_to_new_issue => SwitchToNewIssue,
        switch_to_config => SwitchToConfig, toggle_navigation => ToggleNavigation,
    );
    let mut seen: HashMap<KeyChord, TuiAction> = HashMap::new();
    for (action, chords) in map {
        for key in chords {
            if let Some(previous) = seen.insert(*key, *action) {
                return Err(TixError::Config(format!(
                    "`keybindings.{}`: `{}` is assigned to both `{}` and `{}`",
                    context.as_str(),
                    key,
                    previous.as_str(),
                    action.as_str()
                )));
            }
        }
    }
    Ok(())
}