Skip to main content

tix/ui/
commandbar.rs

1use std::fmt::Write;
2
3use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
4use crate::ui::nav::TopMode;
5
6pub const MODE_BOARD: &str = "board";
7pub const MODE_DETAIL: &str = "detail";
8pub const MODE_HELP: &str = "help";
9pub const MODE_COMMENT: &str = "comment";
10pub const MODE_ASSET: &str = "asset";
11pub const MODE_FILTER: &str = "filter";
12
13const MAX_HINT_CHARS: usize = 80;
14
15pub struct Cmd {
16    pub key: &'static str,
17    pub desc: &'static str,
18    pub modes: &'static [&'static str],
19    pub mode: &'static str,
20}
21
22pub const COMMANDS: &[Cmd] = &[
23    Cmd {
24        key: "j/k",
25        desc: "move up/down",
26        modes: &[MODE_BOARD],
27        mode: MODE_BOARD,
28    },
29    Cmd {
30        key: "n",
31        desc: "new issue",
32        modes: &[MODE_BOARD],
33        mode: MODE_BOARD,
34    },
35    Cmd {
36        key: "?",
37        desc: "help",
38        modes: &[MODE_BOARD, MODE_DETAIL],
39        mode: "board/detail",
40    },
41    Cmd {
42        key: "h/l",
43        desc: "column",
44        modes: &[MODE_BOARD],
45        mode: MODE_BOARD,
46    },
47    Cmd {
48        key: "enter",
49        desc: "open",
50        modes: &[MODE_BOARD],
51        mode: MODE_BOARD,
52    },
53    Cmd {
54        key: "d",
55        desc: "delete",
56        modes: &[MODE_BOARD],
57        mode: MODE_BOARD,
58    },
59    Cmd {
60        key: "< / >",
61        desc: "move",
62        modes: &[MODE_BOARD],
63        mode: MODE_BOARD,
64    },
65    Cmd {
66        key: "r",
67        desc: "refresh",
68        modes: &[MODE_BOARD],
69        mode: MODE_BOARD,
70    },
71    Cmd {
72        key: "/",
73        desc: "search",
74        modes: &[MODE_BOARD],
75        mode: MODE_BOARD,
76    },
77    Cmd {
78        key: "f",
79        desc: "criteria",
80        modes: &[MODE_BOARD],
81        mode: MODE_BOARD,
82    },
83    Cmd {
84        key: "c",
85        desc: "comment",
86        modes: &[MODE_DETAIL],
87        mode: MODE_DETAIL,
88    },
89    Cmd {
90        key: "a",
91        desc: "asset",
92        modes: &[MODE_DETAIL],
93        mode: MODE_DETAIL,
94    },
95];
96
97#[allow(clippy::too_many_lines)]
98fn actions(context: InputContext) -> &'static [TuiAction] {
99    match context {
100        InputContext::Board => &[
101            TuiAction::Open,
102            TuiAction::NewIssue,
103            TuiAction::Help,
104            TuiAction::MoveUp,
105            TuiAction::MoveDown,
106            TuiAction::Mark,
107            TuiAction::Unmark,
108            TuiAction::ClearMarks,
109            TuiAction::Planning,
110            // #1399: the sub-issue toggle, so the key is discoverable from the
111            // footer rather than only from the help overlay.
112            TuiAction::ToggleChildren,
113            TuiAction::Quit,
114        ],
115        InputContext::Detail => &[
116            TuiAction::Comment,
117            TuiAction::Asset,
118            // #1513: the related-issues keys, placed early because the hint bar
119            // truncates at 80 columns — a key listed after the relationship group
120            // would never be drawn.
121            TuiAction::RelatedPicker,
122            TuiAction::FollowRelated,
123            TuiAction::NavBack,
124            // #1359: the three mutating relationship operations and the cursor that
125            // names which edge `E` and `d` act on. A binding that exists but is not
126            // in the footer is a binding nobody finds.
127            TuiAction::NewRelationship,
128            TuiAction::NextRelationship,
129            TuiAction::PreviousRelationship,
130            TuiAction::EditRelationship,
131            TuiAction::RemoveRelationship,
132            // #1384: set/change/clear the parent. A binding that exists but is not
133            // in the footer is a binding nobody finds.
134            TuiAction::EditParent,
135            // Mission `project-issue-health-status-01M3C8HX` (WP06): the form that
136            // records this issue's health.
137            TuiAction::HealthForm,
138            TuiAction::Edit,
139            TuiAction::ScrollLineDown,
140            TuiAction::ScrollLineUp,
141            TuiAction::ScrollUp,
142            TuiAction::ScrollDown,
143            TuiAction::Help,
144        ],
145        // The remaining top-level modes' footers. Each names a handful of the
146        // bindings effective in that mode, so the hint bar follows the active mode
147        // (tui-mode-navigation) instead of always describing the board.
148        InputContext::Triage => &[
149            TuiAction::MoveUp,
150            TuiAction::MoveDown,
151            TuiAction::Toggle,
152            TuiAction::Submit,
153            TuiAction::Cancel,
154        ],
155        // The F3 Cycle mode's own keys: the cycle listing, the group jump, the
156        // way into planning management, help, and the way out.
157        InputContext::Cycle => &[
158            TuiAction::MoveUp,
159            TuiAction::MoveDown,
160            TuiAction::MoveLeft,
161            TuiAction::MoveRight,
162            TuiAction::Planning,
163            TuiAction::Help,
164            TuiAction::Close,
165        ],
166        // The F5 Milestones mode's footer: the registry cursor and the planning
167        // commands, whose labels come from the Milestones context.
168        InputContext::Milestones => &[
169            TuiAction::MoveUp,
170            TuiAction::MoveDown,
171            TuiAction::Planning,
172            TuiAction::NewPlanningItem,
173            TuiAction::EditPlanningItem,
174            TuiAction::DeletePlanningItem,
175            TuiAction::Help,
176            TuiAction::Close,
177        ],
178        InputContext::Form | InputContext::PlanningForm => &[
179            TuiAction::Submit,
180            TuiAction::Next,
181            TuiAction::Previous,
182            TuiAction::OpenPicker,
183            TuiAction::Cancel,
184        ],
185        InputContext::FormPicker => &[
186            TuiAction::Next,
187            TuiAction::Previous,
188            TuiAction::Submit,
189            TuiAction::Create,
190            TuiAction::Cancel,
191        ],
192        InputContext::PlanningManagement => &[
193            TuiAction::MoveUp,
194            TuiAction::MoveDown,
195            TuiAction::MoveLeft,
196            TuiAction::MoveRight,
197            TuiAction::Next,
198            TuiAction::NewPlanningItem,
199            TuiAction::EditPlanningItem,
200            TuiAction::DeletePlanningItem,
201            TuiAction::Close,
202        ],
203        InputContext::Settings => &[
204            TuiAction::MoveUp,
205            TuiAction::MoveDown,
206            TuiAction::Toggle,
207            TuiAction::AddColumn,
208            TuiAction::MoveColumnUp,
209            TuiAction::MoveColumnDown,
210            TuiAction::Submit,
211            TuiAction::NewBoard,
212            TuiAction::EditPlanningItem,
213            TuiAction::Delete,
214            TuiAction::NextBoard,
215            TuiAction::PreviousBoard,
216            TuiAction::MoveLeft,
217            TuiAction::MoveRight,
218            TuiAction::Close,
219        ],
220        // The shell's own switches, so the mode navigation is discoverable from the
221        // footer too.
222        InputContext::Shell => &[
223            TuiAction::SwitchToBoard,
224            TuiAction::SwitchToTriage,
225            TuiAction::SwitchToCycle,
226            TuiAction::SwitchToMilestones,
227            TuiAction::SwitchToRepos,
228            TuiAction::SwitchToStats,
229            TuiAction::SwitchToSchedule,
230            TuiAction::SwitchToLibrary,
231            TuiAction::SwitchToPortfolio,
232            TuiAction::SwitchToNewIssue,
233            TuiAction::SwitchToConfig,
234            TuiAction::ToggleNavigation,
235        ],
236        // The F6 Repos mode's footer (tix #1529).
237        InputContext::Repos => &[
238            TuiAction::MoveUp,
239            TuiAction::MoveDown,
240            TuiAction::Submit,
241            TuiAction::Close,
242        ],
243        // The F7 Stats page (mission `project-issue-health-status-01M3C8HX`; WP05 adds
244        // the Time tab): the row cursor, the two tabs, the Time tab's own commands and
245        // the way out. The tabs are advertised because there are two now.
246        InputContext::Stats => &[
247            TuiAction::MoveUp,
248            TuiAction::MoveDown,
249            TuiAction::Next,
250            TuiAction::Previous,
251            TuiAction::TimeAdd,
252            TuiAction::TimeEdit,
253            TuiAction::TimeDelete,
254            TuiAction::TimeFilter,
255            TuiAction::TimeBreakdown,
256            TuiAction::TimeHistory,
257            TuiAction::Submit,
258            TuiAction::Help,
259            TuiAction::Close,
260        ],
261        _ => &[],
262    }
263}
264
265/// The context whose bindings the hint bar advertises for a top-level mode.
266///
267/// Hints follow the active mode (tui-mode-navigation). `Detail` is handled by the
268/// caller: it is a board-mode surface, so it keeps its own context.
269pub const fn hint_context_for_mode(mode: TopMode) -> InputContext {
270    match mode {
271        TopMode::Board => InputContext::Board,
272        TopMode::Triage => InputContext::Triage,
273        TopMode::Cycle => InputContext::Cycle,
274        TopMode::Milestones => InputContext::Milestones,
275        TopMode::Repos => InputContext::Repos,
276        TopMode::Stats => InputContext::Stats,
277        TopMode::Schedule => InputContext::Schedule,
278        TopMode::Library => InputContext::Library,
279        TopMode::Portfolio => InputContext::Portfolio,
280        TopMode::NewIssue => InputContext::Form,
281        TopMode::Config => InputContext::Settings,
282    }
283}
284
285pub fn hint_bar(active_modes: &[&str]) -> String {
286    let bindings =
287        KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
288    for mode in active_modes {
289        if let Some(context) = match *mode {
290            MODE_BOARD => Some(InputContext::Board),
291            MODE_DETAIL => Some(InputContext::Detail),
292            _ => None,
293        } {
294            return hint_bar_for(&bindings, context);
295        }
296    }
297    String::new()
298}
299
300pub fn hint_bar_for(bindings: &EffectiveBindings, context: InputContext) -> String {
301    hint_bar_for_with(bindings, context, None)
302}
303
304/// The hint bar for `context`, with the `F7` Stats page's rows scoped to `stats_tab`
305/// (WP05).
306///
307/// The hint bar is generated from the binding **context**, which cannot see a tab, so
308/// an unscoped bar advertised the Time tab's six commands on the Health tab, where
309/// none of them fires — the same defect the help overlay's tab scoping fixed, found in
310/// the footer by a reviewer. `Some(tab)` names the tab's own rows; `None` names the
311/// context's in full, which is what a caller that does not know the tab (and the
312/// completeness guards) gets.
313pub fn hint_bar_for_with(
314    bindings: &EffectiveBindings,
315    context: InputContext,
316    stats_tab: Option<crate::app::StatsTab>,
317) -> String {
318    if context == InputContext::Board
319        && bindings.label(context, TuiAction::MoveUp) == "Up / k"
320        && bindings.label(context, TuiAction::MoveDown) == "Down / j"
321    {
322        return truncate(
323            &format!(
324                "j/k nav · {} new issue · {} mark · {} help",
325                bindings.label(context, TuiAction::NewIssue),
326                bindings.label(context, TuiAction::Mark),
327                bindings.label(context, TuiAction::Help),
328            ),
329            MAX_HINT_CHARS,
330        );
331    }
332    let scoped: Option<&'static [TuiAction]> = if context == InputContext::Stats {
333        stats_tab.map(stats_footer_actions)
334    } else {
335        None
336    };
337    let rows: &'static [TuiAction] = scoped.unwrap_or_else(|| actions(context));
338    let mut text = String::new();
339    for action in rows {
340        let _ = write!(
341            text,
342            "{} {} · ",
343            bindings.label(context, *action),
344            action.description_in(context)
345        );
346    }
347    truncate(text.trim_end_matches(" · "), MAX_HINT_CHARS)
348}
349
350/// The contexts the keybindings overlay documents, in reading order.
351///
352/// One list, shared with the completeness guard in this module's tests: a context
353/// added to the overlay but not to the guard (or the reverse) is how a whole surface
354/// becomes invisible in the help without anyone noticing. The overlay itself now
355/// scopes its rows with [`contexts_for_mode`], so this list is the test-only record
356/// of what must stay covered.
357#[cfg(test)]
358const HELP_CONTEXTS: [InputContext; 36] = [
359    InputContext::Detail,
360    InputContext::Board,
361    InputContext::Rollover,
362    InputContext::Form,
363    InputContext::FormPicker,
364    InputContext::ConfirmDelete,
365    InputContext::MoveIntoDone,
366    // WP04: the blocked-move prompt's keys belong in the help overlay too, or
367    // a user who never guessed `y` has no way to learn it.
368    InputContext::ConfirmBlockedMove,
369    InputContext::Help,
370    InputContext::HelpSearch,
371    InputContext::ContextMenu,
372    // #1513: the related-issues picker's keys. Listed here for the same reason
373    // the other pickers are — a key nobody can discover is a key nobody uses.
374    InputContext::RelatedPicker,
375    InputContext::Filter,
376    InputContext::FilterModal,
377    InputContext::Settings,
378    InputContext::SettingsPrompt,
379    InputContext::PlanningManagement,
380    InputContext::PlanningConfirm,
381    InputContext::PlanningForm,
382    InputContext::DatePicker,
383    InputContext::Comment,
384    InputContext::Asset,
385    InputContext::LinkPrompt,
386    InputContext::EditRepair,
387    // #1510: the conflict prompt's keys, for the same reason the edit repair's
388    // are listed — a key nobody can discover is a key nobody uses, and every one
389    // of the three decisions is reachable only from this prompt.
390    InputContext::EditConflict,
391    // WP07: the batch surface's keys, for the same reason WP04 put the
392    // blocked-move prompt's there — a key nobody can discover is a key nobody
393    // uses, and the batch surface is entirely keyboard-driven.
394    InputContext::BulkModal,
395    // WP06: the triage inbox's keys, same reason. The `t` that opens it is
396    // listed by the `board` rows below.
397    InputContext::Triage,
398    // The F3 Cycle mode's listing keys.
399    InputContext::Cycle,
400    // The F5 Milestones mode's listing keys.
401    InputContext::Milestones,
402    // The F6 Repos mode's listing keys (tix #1529).
403    InputContext::Repos,
404    // The F8 Schedule page's keys (tix #1593).
405    InputContext::Schedule,
406    // The F11 Library page's keys (tix #1593).
407    InputContext::Library,
408    // The F10 Portfolio page's keys (WP06), for the same reason: a key nobody can
409    // discover is a key nobody uses, and the page is entirely keyboard-driven.
410    InputContext::Portfolio,
411    // The mode shell's switches and the navigation toggle (tui-mode-navigation).
412    InputContext::Shell,
413    // WP05: the saved-views modal's keys.
414    InputContext::Views,
415    InputContext::ViewsPrompt,
416];
417
418/// The contexts whose bindings the help overlay shows for a top-level mode.
419///
420/// Each top-level mode owns the contexts reachable from it, so `?` in the Cycle
421/// mode lists cycle keys and not the board's. The shell's switches appear in every
422/// mode, so the way out of a mode is always listed.
423pub const fn contexts_for_mode(mode: TopMode) -> &'static [InputContext] {
424    match mode {
425        // The board and every overlay reachable from it: the detail pane, the
426        // delete/rollover/blocked prompts, filter and search, the batch and triage
427        // decision surfaces, and the two text prompts. They are the board's keys
428        // because the board is where they are opened from.
429        TopMode::Board => &[
430            InputContext::Shell,
431            InputContext::Board,
432            InputContext::Detail,
433            InputContext::Rollover,
434            InputContext::ConfirmDelete,
435            InputContext::MoveIntoDone,
436            InputContext::ConfirmBlockedMove,
437            InputContext::Help,
438            InputContext::HelpSearch,
439            InputContext::ContextMenu,
440            // #1513: the related picker is a detail-pane overlay, so its keys are
441            // documented among the board's.
442            InputContext::RelatedPicker,
443            InputContext::Filter,
444            InputContext::FilterModal,
445            InputContext::Comment,
446            InputContext::Asset,
447            InputContext::LinkPrompt,
448            InputContext::EditRepair,
449            // #1510: the conflict prompt is reachable from the board (an editor
450            // round trip) and from nothing else, so its keys are documented there.
451            InputContext::EditConflict,
452            InputContext::BulkModal,
453            // WP05: the saved-views modal's keys. The `v` that opens it is listed
454            // by the `board` rows below, and the modal's own footer names the same
455            // keys.
456            InputContext::Views,
457            InputContext::ViewsPrompt,
458        ],
459        TopMode::Triage => &[InputContext::Shell, InputContext::Triage],
460        TopMode::Cycle => &[
461            InputContext::Shell,
462            InputContext::Cycle,
463            InputContext::PlanningManagement,
464            InputContext::PlanningConfirm,
465            InputContext::PlanningForm,
466            InputContext::DatePicker,
467        ],
468        // The Milestones mode is the Config surface pinned to the milestones
469        // section: its own keys, the planning surfaces `m` opens, and the
470        // settings-confirm prompt a milestone delete raises.
471        TopMode::Milestones => &[
472            InputContext::Shell,
473            InputContext::Milestones,
474            InputContext::PlanningManagement,
475            InputContext::PlanningConfirm,
476            InputContext::PlanningForm,
477            InputContext::DatePicker,
478            InputContext::SettingsPrompt,
479        ],
480        // The Repos mode's listing keys, plus the shell switches every mode shows
481        // (tix #1529).
482        TopMode::Repos => &[InputContext::Shell, InputContext::Repos],
483        // The F7 Stats page's Health tab, and the form it opens (mission
484        // `project-issue-health-status-01M3C8HX`).
485        TopMode::Stats => &[
486            InputContext::Shell,
487            InputContext::Stats,
488            InputContext::Comment,
489        ],
490        // The Schedule page's own keys, plus the shell switches every mode shows
491        // (tix #1593).
492        TopMode::Schedule => &[InputContext::Shell, InputContext::Schedule],
493        // The Library page's own keys, plus the shell switches every mode shows
494        // (tix #1593).
495        TopMode::Library => &[InputContext::Shell, InputContext::Library],
496        // The Portfolio page's own keys, plus the shell switches every mode shows
497        // (WP06).
498        TopMode::Portfolio => &[InputContext::Shell, InputContext::Portfolio],
499        TopMode::NewIssue => &[
500            InputContext::Shell,
501            InputContext::Form,
502            InputContext::FormPicker,
503        ],
504        TopMode::Config => &[
505            InputContext::Shell,
506            InputContext::Settings,
507            InputContext::SettingsPrompt,
508        ],
509    }
510}
511
512/// The rows the help overlay shows for `mode`.
513///
514/// A caller that knows the `F8` Schedule page's active tab passes it to
515/// [`help_overlay_for`]; this is the tab-agnostic form, which shows the page's
516/// original Series rows.
517pub fn help_overlay(bindings: &EffectiveBindings, mode: TopMode) -> Vec<(String, String, String)> {
518    help_overlay_for(bindings, mode, None, None)
519}
520
521/// The `F7` Stats page's **Health** tab keys (WP05): the page's own cursor, the two
522/// tabs, opening a row's issue, help and the way out.
523///
524/// The Time tab's six commands act on nothing here, so they are not advertised — the
525/// tab's own hint row names the keys it does have.
526const STATS_HEALTH_HELP_ACTIONS: &[TuiAction] = &[
527    TuiAction::MoveUp,
528    TuiAction::MoveDown,
529    TuiAction::Next,
530    TuiAction::Previous,
531    TuiAction::Submit,
532    TuiAction::Help,
533    TuiAction::Close,
534];
535
536/// The `F7` Stats page's **Time** tab keys (WP05): the same cursor and strip keys,
537/// the three mutations, the filter, the breakdown and the audit view.
538///
539/// `Submit` is here because `Enter` **works** on this tab (it opens the selected row's
540/// issue), and an overlay that omitted a working key is the defect the completeness
541/// guard exists to prevent — a reviewer found `Enter` missing from this list, which is
542/// why the test that covers it now asserts every action the context binds is listed.
543const STATS_TIME_HELP_ACTIONS: &[TuiAction] = &[
544    TuiAction::MoveUp,
545    TuiAction::MoveDown,
546    TuiAction::Next,
547    TuiAction::Previous,
548    TuiAction::Submit,
549    TuiAction::TimeAdd,
550    TuiAction::TimeEdit,
551    TuiAction::TimeDelete,
552    TuiAction::TimeFilter,
553    TuiAction::TimeBreakdown,
554    TuiAction::TimeHistory,
555    TuiAction::Help,
556    TuiAction::Close,
557];
558
559/// The `F7` Stats page's footer rows for its **Health** tab (WP05): the cursor, the
560/// two tabs, opening a row's issue, help and the way out.
561const STATS_HEALTH_FOOTER: &[TuiAction] = &[
562    TuiAction::MoveUp,
563    TuiAction::MoveDown,
564    TuiAction::Next,
565    TuiAction::Previous,
566    TuiAction::Submit,
567    TuiAction::Help,
568    TuiAction::Close,
569];
570
571/// The `F7` Stats page's footer rows for its **Time** tab (WP05).
572///
573/// The tab's own commands lead, because the bar truncates at
574/// [`MAX_HINT_CHARS`] and the keys that exist *only* on this tab are the ones a reader
575/// cannot find anywhere else; the page's in-body hint row spells the whole set.
576const STATS_TIME_FOOTER: &[TuiAction] = &[
577    TuiAction::TimeAdd,
578    TuiAction::TimeEdit,
579    TuiAction::TimeDelete,
580    TuiAction::TimeFilter,
581    TuiAction::TimeBreakdown,
582    TuiAction::TimeHistory,
583    TuiAction::MoveUp,
584    TuiAction::MoveDown,
585    TuiAction::Next,
586    TuiAction::Previous,
587    TuiAction::Submit,
588    TuiAction::Help,
589    TuiAction::Close,
590];
591
592/// The `F7` Stats page's overlay rows for one tab, so the overlay and the footer
593/// cannot disagree about which keys the tab has.
594fn stats_help_actions(tab: crate::app::StatsTab) -> &'static [TuiAction] {
595    match tab {
596        crate::app::StatsTab::Health => STATS_HEALTH_HELP_ACTIONS,
597        crate::app::StatsTab::Time => STATS_TIME_HELP_ACTIONS,
598        // WP06: the Burndown tab's rows are the tab's own — the module that reads its
599        // controls is the module that names them, exactly as the `F8` Schedule page's
600        // tabs keep theirs beside their tabs.
601        crate::app::StatsTab::Burndown => crate::ui::analytics::HELP_ACTIONS,
602    }
603}
604
605/// The `F7` Stats page's footer rows for one tab.
606fn stats_footer_actions(tab: crate::app::StatsTab) -> &'static [TuiAction] {
607    match tab {
608        crate::app::StatsTab::Health => STATS_HEALTH_FOOTER,
609        crate::app::StatsTab::Time => STATS_TIME_FOOTER,
610        crate::app::StatsTab::Burndown => crate::ui::analytics::FOOTER_ACTIONS,
611    }
612}
613
614/// The keys a tab of the `F7` Stats page reads **raw**, with what each one does
615/// (WP06).
616///
617/// A raw key is one no [`TuiAction`] names: the tab's handler reads it ahead of the
618/// binding layer, so the overlay has to spell it beside the bound rows or the key
619/// works and cannot be discovered. Only the Burndown tab has any — the Health and Time
620/// tabs act entirely through bound actions.
621fn stats_raw_rows(tab: crate::app::StatsTab) -> &'static [(&'static str, &'static str)] {
622    match tab {
623        crate::app::StatsTab::Burndown => crate::ui::analytics::HELP_RAW,
624        crate::app::StatsTab::Health | crate::app::StatsTab::Time => &[],
625    }
626}
627
628/// The rows the help overlay shows for `mode`, with the **tabbed** pages' rows
629/// scoped to their active tab: the `F8` Schedule page by `schedule_tab` (mission
630/// `repeating-cycle-schedules-01M3C7W1`, WP07 review) and the `F7` Stats page by
631/// `stats_tab` (WP05).
632///
633/// A tabbed page is a mode whose **tab** decides which keys are live: the Schedule
634/// page's Series tab's keys and its Cycle schedules tab's are different sets, and the
635/// Stats page's Health tab has no mutation keys at all while its Time tab's six
636/// commands act on nothing on Health. The overlay is generated from the binding
637/// *context*, which cannot see a tab, so drawing it unscoped advertised `x end` and
638/// `d defer` on the Cycle schedules tab — an instruction that contradicted the page's
639/// own hint row — and would advertise `a add a time entry` on the Health tab where
640/// the key does nothing. With `Some(tab)` the tab's own rows replace the context's;
641/// every other mode, and a caller that does not know the tab, is unchanged (which is
642/// what the completeness guards, which walk every context's bindings, rely on).
643pub fn help_overlay_for(
644    bindings: &EffectiveBindings,
645    mode: TopMode,
646    schedule_tab: Option<crate::ui::schedule::ScheduleTab>,
647    stats_tab: Option<crate::app::StatsTab>,
648) -> Vec<(String, String, String)> {
649    use crate::ui::schedule::{
650        CAPACITY_HELP_ACTIONS, CAPACITY_HELP_RAW, CYCLE_HELP_ACTIONS, CYCLE_HELP_RAW, ScheduleTab,
651    };
652
653    let tab_rows = (mode == TopMode::Schedule)
654        .then_some(schedule_tab)
655        .flatten()
656        .and_then(|tab| match tab {
657            ScheduleTab::Cycles => Some((CYCLE_HELP_ACTIONS, CYCLE_HELP_RAW)),
658            ScheduleTab::Capacity => Some((CAPACITY_HELP_ACTIONS, CAPACITY_HELP_RAW)),
659            // The Series tab keeps the keybinding table's own rows, which name the
660            // Series actions it is bound to.
661            ScheduleTab::Series => None,
662        });
663    // WP05: the `F7` Stats page's two tabs are two different key sets, so the rows
664    // are scoped to the tab the overlay was opened from — exactly the fix the
665    // Schedule page's tab scoping made. On the Health tab the Time tab's six
666    // commands act on nothing (and the reverse: `Enter` has no row to open), so
667    // leaving them in would print an instruction the page contradicts.
668    let stats_rows = if mode == TopMode::Stats {
669        stats_tab.map(stats_help_actions)
670    } else {
671        None
672    };
673    // WP06: the raw keys beside them — the Burndown tab's controls have no `TuiAction`
674    // to be listed through, so the overlay would otherwise omit keys that work.
675    let stats_raw = if mode == TopMode::Stats {
676        stats_tab.map(stats_raw_rows)
677    } else {
678        None
679    };
680    let mut rows = Vec::new();
681    // The pointer rows describe the board's mouse affordances; a non-board mode has
682    // none to advertise, so they are scoped to the Board mode like every other row.
683    if mode == TopMode::Board {
684        rows.extend([
685            (
686                "mouse click".to_string(),
687                "click".to_string(),
688                "board".to_string(),
689            ),
690            (
691                "mouse double".to_string(),
692                "double-click".to_string(),
693                "board".to_string(),
694            ),
695            (
696                "mouse right".to_string(),
697                "right-click".to_string(),
698                "board".to_string(),
699            ),
700            (
701                "mouse drag".to_string(),
702                "drag".to_string(),
703                "board".to_string(),
704            ),
705        ]);
706    }
707    for context in contexts_for_mode(mode) {
708        let schedule_context = *context == InputContext::Schedule;
709        if let Some((actions, raw)) = tab_rows
710            && schedule_context
711        {
712            // The tab's own rows: the bound letters carry this tab's meanings (so a
713            // rebind still renames the row), and the keys the page reads raw are
714            // spelled beside them, because no `TuiAction` names them.
715            rows.extend(actions.iter().map(|(action, description)| {
716                (
717                    bindings.label(*context, *action),
718                    (*description).to_string(),
719                    context.as_str().to_string(),
720                )
721            }));
722            rows.extend(raw.iter().map(|(key, description)| {
723                (
724                    (*key).to_string(),
725                    (*description).to_string(),
726                    context.as_str().to_string(),
727                )
728            }));
729            continue;
730        }
731        if let Some(actions) = stats_rows
732            && *context == InputContext::Stats
733        {
734            // WP05: the Stats page's tab's live keys, with the bound letters read
735            // back so a rebind renames the row like every other row.
736            rows.extend(actions.iter().map(|action| {
737                (
738                    bindings.label(*context, *action),
739                    action.description_in(*context).to_string(),
740                    context.as_str().to_string(),
741                )
742            }));
743            // WP06: and the tab's raw keys, in the tab's own words.
744            for (key, description) in stats_raw.unwrap_or(&[]) {
745                rows.push((
746                    (*key).to_string(),
747                    (*description).to_string(),
748                    context.as_str().to_string(),
749                ));
750            }
751            continue;
752        }
753        for action in all_actions(*context) {
754            rows.push((
755                bindings.label(*context, *action),
756                action.description_in(*context).to_string(),
757                context.as_str().to_string(),
758            ));
759        }
760    }
761    rows
762}
763
764#[allow(clippy::too_many_lines)]
765fn all_actions(context: InputContext) -> &'static [TuiAction] {
766    match context {
767        InputContext::Board => &[
768            TuiAction::MoveUp,
769            TuiAction::MoveDown,
770            TuiAction::MoveLeft,
771            TuiAction::MoveRight,
772            TuiAction::NextBoard,
773            TuiAction::PreviousBoard,
774            TuiAction::Open,
775            TuiAction::Edit,
776            TuiAction::View,
777            TuiAction::Agent,
778            TuiAction::ToggleColumn,
779            TuiAction::FilterModal,
780            TuiAction::BulkModal,
781            TuiAction::Triage,
782            // The mentions view (OpenSpec change `issue-mentions`): `i`, and the
783            // key the bottom indicator row advertises. The overlay's list is
784            // hand-written, so a binding it does not name is a key that works and
785            // cannot be discovered.
786            TuiAction::OpenMentions,
787            // WP05: the `v` that opens the saved-views modal.
788            TuiAction::Views,
789            TuiAction::Mark,
790            TuiAction::Unmark,
791            TuiAction::ClearMarks,
792            TuiAction::MovePrevious,
793            TuiAction::MoveNext,
794            // The page keys (#1442): bound on the board, and until this guard existed
795            // they were the only way to reach a card the column had scrolled past —
796            // documented nowhere.
797            TuiAction::ScrollUp,
798            TuiAction::ScrollDown,
799            TuiAction::NewIssue,
800            // The two board keys added after this list was written: the overlay's list
801            // is hand-written, so a binding it does not name is a key that works and
802            // cannot be discovered. `every_bound_action_appears_in_the_overlay` is the
803            // guard, and it is what found these.
804            TuiAction::CloneIssue,
805            TuiAction::ToggleArchived,
806            // #1399: sub-issue visibility. The overlay's list is hand-written, so a
807            // binding it does not name is a key that works and cannot be discovered;
808            // `every_bound_action_appears_in_the_overlay` is the guard.
809            TuiAction::ToggleChildren,
810            TuiAction::Delete,
811            TuiAction::Refresh,
812            TuiAction::Settings,
813            TuiAction::Planning,
814            TuiAction::NewBoard,
815            TuiAction::Help,
816            TuiAction::Quit,
817            TuiAction::TextFilter,
818            TuiAction::ReorderUp,
819            TuiAction::ReorderDown,
820        ],
821        InputContext::Detail => &[
822            // The pane's own list, not the footer's: `close` is how you get back, and a
823            // key that returns you is not one to leave out of the reference.
824            TuiAction::Close,
825            TuiAction::Edit,
826            TuiAction::View,
827            TuiAction::ScrollUp,
828            TuiAction::ScrollDown,
829            TuiAction::ScrollLineDown,
830            TuiAction::ScrollLineUp,
831            TuiAction::ScrollTop,
832            TuiAction::ScrollBottom,
833            TuiAction::Help,
834            TuiAction::Comment,
835            TuiAction::Asset,
836            TuiAction::NewRelationship,
837            TuiAction::NextRelationship,
838            TuiAction::PreviousRelationship,
839            TuiAction::EditRelationship,
840            TuiAction::RemoveRelationship,
841            // #1384: set/change/clear the parent.
842            TuiAction::EditParent,
843            // Mission `project-issue-health-status-01M3C8HX` (WP06): the form that
844            // records this issue's health.
845            TuiAction::HealthForm,
846            // #1513: the related-issues surface. The overlay's list is hand-written,
847            // so a binding it does not name is a key that works and cannot be
848            // discovered; `every_bound_action_appears_in_the_overlay` is the guard.
849            TuiAction::RelatedPicker,
850            TuiAction::FollowRelated,
851            TuiAction::NavBack,
852        ],
853        InputContext::RelatedPicker => &[
854            TuiAction::Cancel,
855            TuiAction::Next,
856            TuiAction::Previous,
857            TuiAction::Submit,
858        ],
859        InputContext::Rollover => &[TuiAction::Submit, TuiAction::Previous, TuiAction::Cancel],
860        InputContext::Form | InputContext::PlanningForm => &[
861            TuiAction::Submit,
862            TuiAction::Cancel,
863            TuiAction::Next,
864            TuiAction::Previous,
865            TuiAction::OpenPicker,
866        ],
867        InputContext::FormPicker => &[
868            TuiAction::Cancel,
869            TuiAction::Next,
870            TuiAction::Previous,
871            TuiAction::Submit,
872            TuiAction::Create,
873        ],
874        InputContext::ConfirmDelete
875        | InputContext::PlanningConfirm
876        | InputContext::ConfirmBlockedMove => &[TuiAction::Confirm, TuiAction::Cancel],
877        InputContext::MoveIntoDone
878        | InputContext::Filter
879        | InputContext::Comment
880        | InputContext::Asset
881        | InputContext::LinkPrompt => &[TuiAction::Submit, TuiAction::Cancel],
882        InputContext::Help => &[
883            TuiAction::Cancel,
884            TuiAction::Next,
885            TuiAction::Previous,
886            // #1496: the search key, so the overlay names the way to search it.
887            TuiAction::TextFilter,
888            // #1441/#1442: the overlay's page keys. These are the overlay's *rows*, not
889            // a footer — the footer for this context is the hint bar `render_help` draws
890            // underneath, which is the board's or the pane's.
891            TuiAction::ScrollUp,
892            TuiAction::ScrollDown,
893            TuiAction::Submit,
894        ],
895        InputContext::ContextMenu => &[
896            TuiAction::Cancel,
897            TuiAction::Next,
898            TuiAction::Previous,
899            TuiAction::Submit,
900            TuiAction::ScrollUp,
901            TuiAction::ScrollDown,
902        ],
903        // Free text: `Esc` leaves the line (and clears it), `Enter` keeps the query.
904        InputContext::HelpSearch => &[TuiAction::Cancel, TuiAction::Submit],
905        InputContext::BulkModal => &[
906            TuiAction::Next,
907            TuiAction::Previous,
908            TuiAction::Submit,
909            TuiAction::Cancel,
910        ],
911        // WP06: only what the context actually binds. The inbox's decision keys
912        // (`a`, `d`, `u`, `f`, `p`, `y`) are deliberately unbound so they stay
913        // typeable in a reason field, so they cannot appear here as chords —
914        // they are named by the modal's own footer instead, which is how the
915        // batch surface names its unbound keys too.
916        InputContext::Triage => &[
917            TuiAction::MoveUp,
918            TuiAction::MoveDown,
919            TuiAction::Toggle,
920            TuiAction::Submit,
921            TuiAction::Cancel,
922        ],
923        InputContext::Cycle => &[
924            TuiAction::MoveUp,
925            TuiAction::MoveDown,
926            TuiAction::MoveLeft,
927            TuiAction::MoveRight,
928            TuiAction::Planning,
929            TuiAction::Help,
930            TuiAction::Close,
931        ],
932        InputContext::FilterModal => &[
933            TuiAction::Next,
934            TuiAction::Previous,
935            TuiAction::Reset,
936            TuiAction::Submit,
937            TuiAction::Cancel,
938            TuiAction::OpenPicker,
939        ],
940        // The F5 Milestones mode: the same registry keys the Cycle listing uses,
941        // plus the create/edit/delete commands the planning rows support.
942        InputContext::Milestones => &[
943            TuiAction::MoveUp,
944            TuiAction::MoveDown,
945            TuiAction::Submit,
946            TuiAction::NewPlanningItem,
947            TuiAction::EditPlanningItem,
948            TuiAction::DeletePlanningItem,
949            TuiAction::Planning,
950            TuiAction::Help,
951            TuiAction::Close,
952        ],
953        // The F7 Stats page (mission `project-issue-health-status-01M3C8HX`; WP05 adds
954        // the Time tab): walk the active tab's rows, switch tabs, open the selected
955        // Health row's issue, and — on the Time tab — add, correct, delete, filter,
956        // break down and audit. The overlay is where a key nobody guessed becomes
957        // discoverable, so every bound Stats action appears here.
958        InputContext::Stats => &[
959            TuiAction::MoveUp,
960            TuiAction::MoveDown,
961            TuiAction::Next,
962            TuiAction::Previous,
963            TuiAction::Submit,
964            TuiAction::TimeAdd,
965            TuiAction::TimeEdit,
966            TuiAction::TimeDelete,
967            TuiAction::TimeFilter,
968            TuiAction::TimeBreakdown,
969            TuiAction::TimeHistory,
970            TuiAction::Help,
971            TuiAction::Close,
972        ],
973        // The F6 Repos mode (tix #1529): walk the list and run tix in the
974        // selected repository.
975        InputContext::Repos => &[
976            TuiAction::MoveUp,
977            TuiAction::MoveDown,
978            TuiAction::Submit,
979            TuiAction::Help,
980            TuiAction::Close,
981        ],
982        // The F8 Schedule page (tix #1593): walk the series rows, then act. The
983        // action keys are the page's own (`handle_schedule_key` reads them raw).
984        InputContext::Schedule => &[
985            TuiAction::MoveUp,
986            TuiAction::MoveDown,
987            TuiAction::SeriesNew,
988            TuiAction::SeriesGenerate,
989            TuiAction::SeriesPause,
990            TuiAction::SeriesResume,
991            TuiAction::SeriesEnd,
992            TuiAction::SeriesSkip,
993            TuiAction::SeriesDefer,
994            TuiAction::SeriesIgnore,
995            TuiAction::Help,
996            TuiAction::Close,
997        ],
998        // The F11 Library page (tix #1593): switch registry, walk rows, and
999        // apply the selected view.
1000        InputContext::Library => &[
1001            TuiAction::Next,
1002            TuiAction::Previous,
1003            TuiAction::MoveUp,
1004            TuiAction::MoveDown,
1005            TuiAction::Submit,
1006            TuiAction::Help,
1007            TuiAction::Close,
1008        ],
1009        // The F10 Portfolio page (WP06): switch tab, walk the active tab's rows,
1010        // step into the selected epic's members, open the selected member issue
1011        // directly (FR-004), and leave. There is no mutating key — the page is a
1012        // reader.
1013        InputContext::Portfolio => &[
1014            TuiAction::Next,
1015            TuiAction::Previous,
1016            TuiAction::MoveUp,
1017            TuiAction::MoveDown,
1018            TuiAction::MoveLeft,
1019            TuiAction::MoveRight,
1020            TuiAction::Submit,
1021            TuiAction::Help,
1022            TuiAction::Close,
1023        ],
1024        // WP05: exactly what each context binds. The list is a menu, so an
1025        // unbound letter there is inert rather than typeable; the prompt is the
1026        // free-text side of the same modal and binds only submit/cancel.
1027        InputContext::Views => &[
1028            TuiAction::MoveUp,
1029            TuiAction::MoveDown,
1030            TuiAction::ApplyView,
1031            TuiAction::NewView,
1032            TuiAction::RenameView,
1033            TuiAction::UpdateView,
1034            TuiAction::DuplicateView,
1035            TuiAction::DeleteView,
1036            TuiAction::Close,
1037        ],
1038        InputContext::ViewsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
1039        InputContext::Settings => &[
1040            TuiAction::MoveUp,
1041            TuiAction::MoveDown,
1042            TuiAction::Toggle,
1043            TuiAction::AddColumn,
1044            TuiAction::MoveColumnUp,
1045            TuiAction::MoveColumnDown,
1046            TuiAction::Submit,
1047            TuiAction::NewBoard,
1048            TuiAction::EditPlanningItem,
1049            TuiAction::Delete,
1050            TuiAction::NextBoard,
1051            TuiAction::PreviousBoard,
1052            TuiAction::MoveLeft,
1053            TuiAction::MoveRight,
1054            TuiAction::Close,
1055        ],
1056        InputContext::SettingsPrompt => &[TuiAction::Submit, TuiAction::Cancel],
1057        InputContext::PlanningManagement => &[
1058            TuiAction::MoveUp,
1059            TuiAction::MoveDown,
1060            TuiAction::MoveLeft,
1061            TuiAction::MoveRight,
1062            TuiAction::Next,
1063            TuiAction::NewPlanningItem,
1064            TuiAction::EditPlanningItem,
1065            TuiAction::DeletePlanningItem,
1066            TuiAction::Close,
1067        ],
1068        InputContext::DatePicker => &[
1069            TuiAction::MoveUp,
1070            TuiAction::MoveDown,
1071            TuiAction::MoveLeft,
1072            TuiAction::MoveRight,
1073            TuiAction::PreviousMonth,
1074            TuiAction::NextMonth,
1075            TuiAction::PreviousYear,
1076            TuiAction::NextYear,
1077            TuiAction::Submit,
1078            TuiAction::Reset,
1079            TuiAction::Cancel,
1080        ],
1081        // #1506: the repair prompt's five decisions. `confirm` applies the
1082        // proposed fix (which for an unknown key is the declaration), `declare_key`
1083        // writes `[custom_attributes]` explicitly, `reedit` reopens the editor on
1084        // the retained copy, `ignore` acknowledges and keeps it, and `discard`
1085        // removes it behind a second confirmation. The modal's own footer
1086        // advertises them too, but the overlay is where a key nobody guessed
1087        // becomes discoverable.
1088        InputContext::EditRepair => &[
1089            TuiAction::Confirm,
1090            TuiAction::DeclareKey,
1091            TuiAction::Reedit,
1092            TuiAction::Ignore,
1093            TuiAction::Discard,
1094        ],
1095        // #1510: the conflict prompt's three decisions. Same actions as the edit
1096        // warning because they are the same kinds of decision, different words
1097        // because they do different things here (see `description_in`).
1098        InputContext::EditConflict => &[TuiAction::Reedit, TuiAction::Ignore, TuiAction::Confirm],
1099        InputContext::Shell => &[
1100            TuiAction::SwitchToBoard,
1101            TuiAction::SwitchToTriage,
1102            TuiAction::SwitchToCycle,
1103            TuiAction::SwitchToMilestones,
1104            TuiAction::SwitchToRepos,
1105            TuiAction::SwitchToStats,
1106            TuiAction::SwitchToSchedule,
1107            TuiAction::SwitchToLibrary,
1108            TuiAction::SwitchToPortfolio,
1109            TuiAction::SwitchToNewIssue,
1110            TuiAction::SwitchToConfig,
1111            TuiAction::ToggleNavigation,
1112        ],
1113    }
1114}
1115
1116fn truncate(value: &str, max_chars: usize) -> String {
1117    if max_chars == 0 || value.chars().count() <= max_chars {
1118        return value.to_string();
1119    }
1120    let take = max_chars.saturating_sub(1);
1121    let head: String = value.chars().take(take).collect();
1122    format!("{}…", head.trim_end())
1123}
1124
1125#[cfg(test)]
1126mod tests {
1127    #[allow(clippy::wildcard_imports)]
1128    use super::*;
1129
1130    /// Every action a documented context binds must appear in the overlay.
1131    ///
1132    /// `all_actions` is written by hand per context, so a binding can be added to the
1133    /// keymap and never appear in the help — the worst of both worlds, because the key
1134    /// works and nobody can discover it. It had already happened twice before this
1135    /// guard existed (`clone_issue` and `toggle_archived`, both added while this list
1136    /// was not).
1137    #[test]
1138    fn every_bound_action_appears_in_the_overlay() {
1139        let bindings =
1140            KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1141        let mut missing: Vec<String> = Vec::new();
1142        for context in HELP_CONTEXTS {
1143            let listed = all_actions(context);
1144            for action in bindings.bound_actions(context) {
1145                if !listed.contains(&action) {
1146                    missing.push(format!("{} — {}", context.as_str(), action.as_str()));
1147                }
1148            }
1149        }
1150        assert!(
1151            missing.is_empty(),
1152            "the overlay does not list these bound actions, so nothing in the help \
1153             would tell a user they exist:\n  {}",
1154            missing.join("\n  ")
1155        );
1156    }
1157
1158    /// Every row a mode's help draws comes from a context that mode owns, and the
1159    /// union over all modes still covers every documented context.
1160    #[test]
1161    fn the_overlay_and_its_guard_cover_the_same_contexts() {
1162        let bindings =
1163            KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1164        let drawn: Vec<(String, String, String)> = TopMode::ALL
1165            .iter()
1166            .flat_map(|mode| help_overlay(&bindings, *mode))
1167            .collect();
1168        for context in HELP_CONTEXTS {
1169            let scope = context.as_str();
1170            assert!(
1171                drawn.iter().any(|(_, _, listed)| listed == scope),
1172                "`{scope}` is in HELP_CONTEXTS but contributes no rows to any mode's overlay"
1173            );
1174        }
1175    }
1176
1177    /// Help is scoped to the active mode: every row's scope belongs to that mode's
1178    /// contexts, and a binding only the board has is absent elsewhere.
1179    #[test]
1180    fn help_is_scoped_to_the_active_mode() {
1181        let bindings =
1182            KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1183        for mode in TopMode::ALL {
1184            let allowed: Vec<&str> = contexts_for_mode(mode).iter().map(|c| c.as_str()).collect();
1185            for (key, description, scope) in help_overlay(&bindings, mode) {
1186                assert!(
1187                    allowed.contains(&scope.as_str()),
1188                    "{mode:?} help shows `{key} {description}` from out-of-scope `{scope}`"
1189                );
1190            }
1191        }
1192        // `toggle column` is a board-only action; the cycle mode's help must not
1193        // offer it.
1194        let cycle = help_overlay(&bindings, TopMode::Cycle);
1195        assert!(
1196            !cycle
1197                .iter()
1198                .any(|(_, description, _)| description == "toggle column"),
1199            "cycle help must not list board bindings: {cycle:?}"
1200        );
1201        // …while the board's own help does list it.
1202        let board = help_overlay(&bindings, TopMode::Board);
1203        assert!(
1204            board
1205                .iter()
1206                .any(|(_, description, _)| description == "toggle column")
1207        );
1208        // The navigation switch is part of every mode's help.
1209        for mode in TopMode::ALL {
1210            let rows = help_overlay(&bindings, mode);
1211            assert!(
1212                rows.iter()
1213                    .any(|(_, description, _)| description == "toggle navigation"),
1214                "{mode:?} help must list the navigation control"
1215            );
1216        }
1217    }
1218
1219    #[test]
1220    fn dynamic_hint_uses_effective_aliases() {
1221        let mut overrides = KeyBindings::default();
1222        overrides.board.get_or_insert_default().new_issue =
1223            Some(vec!["ctrl+n".into(), "f4".into()]);
1224        overrides.board.get_or_insert_default().help = Some(vec!["f1".into()]);
1225        let bindings = KeyBindings::resolve(&overrides).unwrap();
1226        let hint = hint_bar_for(&bindings, InputContext::Board);
1227        assert!(hint.contains("Ctrl+n / F4 new issue"), "got {hint:?}");
1228        assert!(hint.contains("F1 help"), "got {hint:?}");
1229    }
1230
1231    /// The hint bar follows the active mode rather than always describing the board.
1232    #[test]
1233    fn hints_follow_the_active_mode() {
1234        let bindings =
1235            KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
1236        let board = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Board));
1237        let triage = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Triage));
1238        let config = hint_bar_for(&bindings, hint_context_for_mode(TopMode::Config));
1239        assert!(board.contains("new issue"), "got {board:?}");
1240        assert!(
1241            triage.contains("toggle") && !triage.contains("new issue"),
1242            "triage hint should describe triage, got {triage:?}"
1243        );
1244        assert!(
1245            config.contains("add column") && !config.contains("new issue"),
1246            "config hint should describe settings, got {config:?}"
1247        );
1248    }
1249
1250    #[test]
1251    fn help_covers_every_configured_action() {
1252        let bindings = KeyBindings::resolve(&KeyBindings::default()).unwrap();
1253        let rows: Vec<(String, String, String)> = TopMode::ALL
1254            .iter()
1255            .flat_map(|mode| help_overlay(&bindings, *mode))
1256            .collect();
1257        for context in InputContext::ALL {
1258            for action in all_actions(*context) {
1259                assert!(
1260                    rows.iter().any(|(_, description, mode)| description
1261                        == action.description_in(*context)
1262                        && mode == context.as_str()),
1263                    "{} / {} is not listed by any mode's help",
1264                    context.as_str(),
1265                    action.as_str()
1266                );
1267            }
1268        }
1269    }
1270}