Skip to main content

gwm/tui/
modal_keymap.rs

1//! Contextual modal / overlay keymap (issue #219).
2//!
3//! The global keymap in [`crate::tui::keymap`] resolves the `View::List`
4//! verbs (`j`, `g g`, `o`, `R`, …) and, deliberately, *only* those: its
5//! module note explains why `Esc` / `Enter` were kept hard-coded — their
6//! meaning depends on the active view, and a single global table cannot
7//! express "Enter submits in the create modal but activates the focused
8//! button in the confirm modal".
9//!
10//! This module lifts that limitation for the modals/overlays. Every modal
11//! is a [`KeyContext`]; each context owns a small set of typed verbs
12//! ([`ModalAction`]); the same physical key can map to different verbs in
13//! different contexts without any global conflict, because resolution is
14//! always scoped to the **active** context.
15//!
16//! ## Single-stroke only
17//!
18//! Unlike the global keymap, modal bindings are **single keystrokes** —
19//! no chords, no prefixes, no pending buffer. Modals are short-lived and
20//! the project refuses a Vim-style runtime timeout (see the global
21//! keymap's chord/prefix note). A user override that supplies a
22//! multi-stroke chord (`"g g"`) is rejected at load time with a precise
23//! message rather than silently never firing.
24//!
25//! ## What stays hard-coded
26//!
27//! - `Ctrl+C` — the emergency quit in `run_app`, ahead of every lookup.
28//! - `View::List`'s `Esc` / `Enter` — still contextual on filter / picker
29//!   / sticky-filter state, which the config language cannot express.
30//! - The PTY overlay's `Esc` — it is an *emergency* detach from a child
31//!   that otherwise receives every keystroke; making it rebindable would
32//!   silently steal a key from lazygit / the shell. Documented in the
33//!   `View::Pty` branch.
34//!
35//! ## Config surface
36//!
37//! Bindings live under `[tui.keys.modal.<context-path>]` in `.gwm.toml`,
38//! nested below a dedicated `modal` namespace inside the global `[tui.keys]`
39//! table. The separate namespace keeps a modal context from colliding with a
40//! same-named global action (`create` / `help` / `command_logs` / `link` are
41//! both) at the `tui.keys.<name>` path — a collision the layered merge would
42//! otherwise resolve by silently dropping the global override (issue #219
43//! review):
44//!
45//! ```toml
46//! [tui.keys]                  # global verbs — arrays, unchanged
47//! quit   = ["q"]
48//! create = ["c"]              # global action; coexists with the modal below
49//!
50//! [tui.keys.modal.confirm]    # contextual verbs — single strokes
51//! confirm = ["y"]
52//! cancel  = ["n", "Esc"]
53//!
54//! [tui.keys.modal.link.choose_target]
55//! issue = ["i"]
56//! pr    = ["p"]
57//! ```
58//!
59//! The walker that turns that TOML into a [`ModalKeymap`] lives in
60//! [`crate::config`]; this module owns the typed model, the defaults, the
61//! per-context conflict validation, and the single-stroke resolver.
62
63use crate::error::{GwmError, Result};
64use crate::tui::keymap::{KeyStroke, Source};
65use crossterm::event::{KeyCode, KeyModifiers};
66use std::collections::HashMap;
67
68// ---------------------------------------------------------------------------
69// KeyContext
70// ---------------------------------------------------------------------------
71
72/// One modal / overlay surface whose keys are independently rebindable.
73///
74/// `config_path` is the dotted key under `[tui.keys.modal]` that addresses
75/// the context's sub-table (`confirm`, `link.choose_target`, `config.edit`).
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
77pub enum KeyContext {
78  /// Create-worktree modal (also reused by the rename / `View::Edit` modal).
79  Create,
80  /// Delete-confirmation modal.
81  Confirm,
82  /// Keybindings / help overlay (scroll-only).
83  Help,
84  /// Generic detail overlay (issue #408, scroll-only / close) — agent
85  /// sessions today, the rich PR/Issue view tomorrow.
86  Detail,
87  /// Command-logs overlay (issue #226, scroll-only + copy).
88  CommandLogs,
89  /// Settings panel navigation (issue #232).
90  Config,
91  /// Settings panel while a numeric field is being edited (sub-mode of
92  /// [`KeyContext::Config`]; a separate context because `Enter` means
93  /// *commit* here but *activate* in nav).
94  ConfigEdit,
95  /// Bootstrap-report overlay (scroll-only / close).
96  Report,
97  /// Browse-links menu (issue #224 / #290).
98  OpenMenu,
99  /// Command palette overlay (issue #32).
100  CommandPalette,
101  /// Link prompt, stage 1 — choose issue vs PR.
102  LinkChooseTarget,
103  /// Link prompt, stage 2 — type the issue / PR number.
104  LinkInputNumber,
105  /// Exec profile picker overlay (issue #325).
106  ExecPicker,
107  /// Clean reclaim overlay (issue #325).
108  Clean,
109  /// CI checks overlay (issue #436) — the detail-overlay shell opened on
110  /// the linked PR's per-check rollup list.
111  CiChecks,
112}
113
114impl KeyContext {
115  /// Strokes an ALWAYS-typing context reserves for its input (Codex
116  /// review #456): the dispatch routes them into the query / number /
117  /// value before the modal resolution, so a verb bound to one would be
118  /// unreachable — and `close = ["x"]` would leave the overlay with no
119  /// exit at all. [`ModalKeymap::apply_override`] refuses such bindings
120  /// up front. ConfigEdit qualifies too (iteration 13): the context only
121  /// exists while a value edit is live, and a text field consumes every
122  /// unmodified printable (uppercase included) plus Backspace — its two
123  /// verbs are the edit's only exits. Create is exempt at the context
124  /// level (its type-cycling verbs live on the Type field, which takes
125  /// no text input) — the per-verb exception is
126  /// [`ModalAction::reserved_typing_stroke`]. Mirrors the dispatch
127  /// routes (`App::palette_input_key`, the link number stage,
128  /// `App::settings_edit_input_key`).
129  pub fn reserved_typing_stroke(self, stroke: &KeyStroke) -> bool {
130    use crossterm::event::{KeyCode as KC, KeyModifiers as KM};
131    if stroke.modifiers.intersects(KM::CONTROL | KM::ALT) {
132      return false;
133    }
134    match (self, stroke.code) {
135      (KeyContext::CommandPalette | KeyContext::LinkInputNumber | KeyContext::ConfigEdit, KC::Backspace) => true,
136      // A shifted letter is an uppercase (kitty-style) — not palette
137      // input, so it stays bindable.
138      (KeyContext::CommandPalette, KC::Char(c)) if stroke.modifiers.contains(KM::SHIFT) => c.is_ascii_digit(),
139      (KeyContext::CommandPalette, KC::Char(c)) => c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-',
140      (KeyContext::LinkInputNumber, KC::Char(c)) => c.is_ascii_digit(),
141      // A text field takes uppercase input, so unlike the palette a
142      // shifted letter IS typing here.
143      (KeyContext::ConfigEdit, KC::Char(_)) => true,
144      _ => false,
145    }
146  }
147
148  /// Dotted key under `[tui.keys.modal]` addressing this context's sub-table.
149  pub fn config_path(self) -> &'static str {
150    match self {
151      KeyContext::Create => "create",
152      KeyContext::Confirm => "confirm",
153      KeyContext::Help => "help",
154      KeyContext::Detail => "detail",
155      KeyContext::CommandLogs => "command_logs",
156      KeyContext::Config => "config",
157      KeyContext::ConfigEdit => "config.edit",
158      KeyContext::Report => "report",
159      KeyContext::OpenMenu => "open_menu",
160      KeyContext::CommandPalette => "palette",
161      KeyContext::LinkChooseTarget => "link.choose_target",
162      KeyContext::LinkInputNumber => "link.input_number",
163      KeyContext::ExecPicker => "exec",
164      KeyContext::Clean => "clean",
165      KeyContext::CiChecks => "ci_checks",
166    }
167  }
168
169  /// Inverse of [`Self::config_path`] — used by the config walker to map a
170  /// `[tui.keys.modal.<path>]` sub-table back to a typed context.
171  pub fn from_config_path(path: &str) -> Option<Self> {
172    Self::all().iter().copied().find(|c| c.config_path() == path)
173  }
174
175  /// Every context, in declaration order (the order `gwm tui keys` lists).
176  pub fn all() -> &'static [KeyContext] {
177    use KeyContext::*;
178    &[
179      Create,
180      Confirm,
181      Help,
182      Detail,
183      CommandLogs,
184      Config,
185      ConfigEdit,
186      Report,
187      OpenMenu,
188      CommandPalette,
189      LinkChooseTarget,
190      LinkInputNumber,
191      ExecPicker,
192      Clean,
193      CiChecks,
194    ]
195  }
196}
197
198// ---------------------------------------------------------------------------
199// ModalAction + defaults table
200// ---------------------------------------------------------------------------
201
202/// Declarative definition of every modal verb, grouped by context, with its
203/// local verb slug and built-in default keystrokes. One ordered list keeps
204/// the enum, the `context`/`verb`/`default` accessors, and `all()` in sync.
205macro_rules! define_modal_actions {
206  ( $( $ctx:ident { $( $variant:ident => $verb:literal [ $( $chord:literal ),* $(,)? ] ),* $(,)? } )* ) => {
207    /// A context-qualified modal verb. Variant names are
208    /// `<Context><Verb>` so the flat enum stays unambiguous; the
209    /// `(context, verb)` pair is what the config surface addresses.
210    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
211    pub enum ModalAction {
212      $( $( $variant, )* )*
213    }
214
215    impl ModalAction {
216      /// The context this verb belongs to.
217      pub fn context(self) -> KeyContext {
218        match self { $( $( ModalAction::$variant => KeyContext::$ctx, )* )* }
219      }
220
221      /// The context-local verb slug used under `[tui.keys.modal.<context>]`.
222      pub fn verb(self) -> &'static str {
223        match self { $( $( ModalAction::$variant => $verb, )* )* }
224      }
225
226      /// Built-in default keystroke literals (each a single stroke).
227      fn default_chord_strs(self) -> &'static [&'static str] {
228        match self { $( $( ModalAction::$variant => &[ $( $chord, )* ], )* )* }
229      }
230
231      /// Every verb, in declaration order.
232      pub fn all() -> impl Iterator<Item = Self> {
233        [ $( $( ModalAction::$variant, )* )* ].into_iter()
234      }
235    }
236  };
237}
238
239define_modal_actions! {
240  Create {
241    CreateCancel    => "cancel"     [ "Esc" ],
242    CreateNextField => "next_field" [ "Tab" ],
243    CreatePrevField => "prev_field" [ "BackTab" ],
244    CreateSubmit    => "submit"     [ "Enter" ],
245    CreatePrevType  => "prev_type"  [ "Up", "Left", "h" ],
246    CreateNextType  => "next_type"  [ "Down", "Right", "l" ],
247    // Issue #416. Ctrl-modified on purpose: the create overlay reserves
248    // unmodified printable keys for the text fields, so a bare letter here
249    // would be swallowed while typing a description.
250    CreateToggleMode => "toggle_mode" [ "Ctrl+t" ],
251  }
252  Confirm {
253    ConfirmConfirm      => "confirm"       [ "y" ],
254    ConfirmActivate     => "activate"      [ "Enter" ],
255    ConfirmCancel       => "cancel"        [ "n", "Esc" ],
256    ConfirmFocusConfirm => "focus_confirm" [ "Left", "h" ],
257    ConfirmFocusCancel  => "focus_cancel"  [ "Right", "l" ],
258    ConfirmToggleFocus  => "toggle_focus"  [ "Tab" ],
259  }
260  Help {
261    HelpClose        => "close"         [ "Esc", "q", "?" ],
262    HelpScrollDown   => "scroll_down"   [ "Down", "j" ],
263    HelpScrollUp     => "scroll_up"     [ "Up", "k" ],
264    HelpScrollRight  => "scroll_right"  [ "Right", "l" ],
265    HelpScrollLeft   => "scroll_left"   [ "Left", "h" ],
266    HelpScrollTop    => "scroll_top"    [ "Home", "g" ],
267    HelpScrollBottom => "scroll_bottom" [ "End", "G" ],
268  }
269  Detail {
270    DetailClose      => "close"        [ "Esc", "q" ],
271    DetailSelectNext => "select_next"  [ "Down", "j" ],
272    DetailSelectPrev => "select_prev"  [ "Up", "k" ],
273    DetailAttach     => "attach"       [ "a" ],
274    DetailDetach     => "detach"       [ "d" ],
275    DetailInput      => "attach_by_id" [ "i" ],
276  }
277  CommandLogs {
278    CommandLogsClose        => "close"         [ "Esc", "q" ],
279    CommandLogsCopy         => "copy"          [ "y" ],
280    CommandLogsScrollDown   => "scroll_down"   [ "Down", "j" ],
281    CommandLogsScrollUp     => "scroll_up"     [ "Up", "k" ],
282    CommandLogsScrollRight  => "scroll_right"  [ "Right", "l" ],
283    CommandLogsScrollLeft   => "scroll_left"   [ "Left", "h" ],
284    CommandLogsScrollTop    => "scroll_top"    [ "Home", "g" ],
285    CommandLogsScrollBottom => "scroll_bottom" [ "End", "G" ],
286  }
287  Config {
288    ConfigClose        => "close"         [ "Esc", "q" ],
289    ConfigNextTab      => "next_tab"      [ "Tab" ],
290    ConfigPrevTab      => "prev_tab"      [ "BackTab" ],
291    ConfigToggleLayer  => "toggle_layer"  [ "L" ],
292    ConfigActivate     => "activate"      [ "Space", "Enter" ],
293    ConfigSelectNext   => "select_next"   [ "Down", "j" ],
294    ConfigSelectPrev   => "select_prev"   [ "Up", "k" ],
295    ConfigScrollRight  => "scroll_right"  [ "Right", "l" ],
296    ConfigScrollLeft   => "scroll_left"   [ "Left", "h" ],
297    ConfigScrollTop    => "scroll_top"    [ "Home", "g" ],
298    ConfigScrollBottom => "scroll_bottom" [ "End", "G" ],
299  }
300  ConfigEdit {
301    ConfigEditSubmit => "submit" [ "Enter" ],
302    ConfigEditCancel => "cancel" [ "Esc" ],
303  }
304  Report {
305    ReportClose => "close" [ "Esc", "q", "Enter" ],
306  }
307  OpenMenu {
308    OpenMenuClose  => "close"  [ "Esc", "q" ],
309    OpenMenuToggle => "toggle" [ "j", "k", "Down", "Up" ],
310    OpenMenuAccept => "accept" [ "Enter" ],
311    OpenMenuIssue  => "issue"  [ "i" ],
312    OpenMenuPr     => "pr"     [ "p" ],
313  }
314  CommandPalette {
315    CommandPaletteClose  => "close"  [ "Esc" ],
316    CommandPaletteAccept => "accept" [ "Enter" ],
317    CommandPalettePrev   => "prev"   [ "Up" ],
318    CommandPaletteNext   => "next"   [ "Down", "Tab" ],
319  }
320  LinkChooseTarget {
321    LinkChooseNext   => "next"   [ "j", "Down" ],
322    LinkChoosePrev   => "prev"   [ "k", "Up" ],
323    LinkChooseIssue  => "issue"  [ "i" ],
324    LinkChoosePr     => "pr"     [ "p" ],
325    LinkChooseAccept => "accept" [ "Enter" ],
326    LinkChooseCancel => "cancel" [ "Esc" ],
327  }
328  LinkInputNumber {
329    LinkInputSubmit => "submit" [ "Enter" ],
330    LinkInputCancel => "cancel" [ "Esc" ],
331  }
332  ExecPicker {
333    ExecPickerNext   => "next"   [ "j", "Down" ],
334    ExecPickerPrev   => "prev"   [ "k", "Up" ],
335    ExecPickerAccept => "accept" [ "Enter" ],
336    ExecPickerCancel => "cancel" [ "Esc" ],
337  }
338  Clean {
339    CleanNext    => "next"    [ "j", "Down" ],
340    CleanPrev    => "prev"    [ "k", "Up" ],
341    CleanConfirm => "confirm" [ "y", "Enter" ],
342    CleanCancel  => "cancel"  [ "n", "Esc" ],
343  }
344  // #436: the defaults mirror the list view's own keys — `/` filters and
345  // `f` refreshes there too (user feedback 2026-07-24).
346  CiChecks {
347    CiChecksClose   => "close"       [ "Esc", "q" ],
348    CiChecksNext    => "select_next" [ "j", "Down" ],
349    CiChecksPrev    => "select_prev" [ "k", "Up" ],
350    CiChecksOpen    => "open"        [ "Enter" ],
351    CiChecksFilter  => "filter"      [ "/" ],
352    CiChecksRefresh => "refresh"     [ "f" ],
353  }
354}
355
356impl ModalAction {
357  /// Resolve a `(context, verb-slug)` pair to a typed verb. Used by the
358  /// config walker to translate `[tui.keys.modal.<context>].<verb>` keys.
359  pub fn from_context_verb(ctx: KeyContext, verb: &str) -> Option<Self> {
360    Self::all().find(|a| a.context() == ctx && a.verb() == verb)
361  }
362
363  /// `true` when binding this verb to `stroke` would leave it unreachable
364  /// or misleading because a typing route consumes the key first (Codex
365  /// review #456). Context-wide reservations
366  /// ([`KeyContext::reserved_typing_stroke`]) apply to every verb; the
367  /// create verbs that must stay operative from the TEXT fields — submit
368  /// (only ever fires from Description), cancel and the field navigation
369  /// — add a per-verb case: every unmodified printable and Backspace is
370  /// typing there. Only the type-cycling verbs keep bare letters: they
371  /// act on the Type field, which takes no text input.
372  pub fn reserved_typing_stroke(self, stroke: &KeyStroke) -> bool {
373    use crossterm::event::{KeyCode as KC, KeyModifiers as KM};
374    if self.context().reserved_typing_stroke(stroke) {
375      return true;
376    }
377    matches!(
378      self,
379      ModalAction::CreateSubmit
380        | ModalAction::CreateCancel
381        | ModalAction::CreateNextField
382        | ModalAction::CreatePrevField
383        // #416: free-form mode has `Name` as its only field, so a bare
384        // printable bound here would be swallowed as typing with no way
385        // back to the structured form.
386        | ModalAction::CreateToggleMode
387    ) && !stroke.modifiers.intersects(KM::CONTROL | KM::ALT)
388      && matches!(stroke.code, KC::Char(_) | KC::Backspace)
389  }
390
391  /// Default keystrokes for this verb, parsed. Panics on a malformed
392  /// literal — that is a programmer error in the table above, never user
393  /// input (same contract as the global keymap's `def`).
394  fn default_keys(self) -> Vec<KeyStroke> {
395    self
396      .default_chord_strs()
397      .iter()
398      .map(|s| {
399        parse_single(s)
400          .unwrap_or_else(|e| panic!("default modal binding {:?} for {:?} failed to parse: {}", s, self, e))
401      })
402      .collect()
403  }
404}
405
406/// Parse a binding string that must resolve to exactly one keystroke.
407/// Modal bindings have no chord machinery, so a multi-stroke string is a
408/// hard error (returned to the user verbatim by the config walker).
409pub fn parse_single(s: &str) -> Result<KeyStroke> {
410  let strokes = KeyStroke::parse_chord(s)?;
411  let stroke = match strokes.into_iter().collect::<Vec<_>>().as_slice() {
412    [one] => one.clone(),
413    _ => {
414      return Err(GwmError::Config(format!(
415        "modal bindings must be a single keystroke, got chord {:?} (modals have no chord timeout)",
416        s
417      )))
418    }
419  };
420  // Ctrl+C is the emergency quit handled in `run_app` ahead of every lookup,
421  // so a modal binding to it would never fire — reject it rather than let
422  // `gwm tui keys` / footer hints advertise an unreachable action (#219 review).
423  if stroke.modifiers.contains(KeyModifiers::CONTROL)
424    && matches!(stroke.code, KeyCode::Char(c) if c.eq_ignore_ascii_case(&'c'))
425  {
426    return Err(GwmError::Config(format!(
427      "modal bindings cannot use {:?}: Ctrl+C is the reserved emergency quit (handled before any modal lookup)",
428      s
429    )));
430  }
431  Ok(stroke)
432}
433
434// ---------------------------------------------------------------------------
435// ModalKeymap
436// ---------------------------------------------------------------------------
437
438/// One resolved binding: a verb, the single-strokes that fire it (any one
439/// match suffices), and the layer it came from.
440#[derive(Debug, Clone)]
441pub struct ModalBinding {
442  pub action: ModalAction,
443  pub keys: Vec<KeyStroke>,
444  pub source: Source,
445}
446
447/// The resolved contextual keymap: every modal verb with its keys. Built
448/// from [`ModalKeymap::defaults`] then layered with user overrides via
449/// [`ModalKeymap::apply_override`].
450#[derive(Debug, Clone)]
451pub struct ModalKeymap {
452  entries: Vec<ModalBinding>,
453}
454
455impl ModalKeymap {
456  /// Built-in defaults — mirror the historical hard-coded modal routing in
457  /// `src/tui/mod.rs` before issue #219.
458  pub fn defaults() -> Self {
459    let entries = ModalAction::all()
460      .map(|action| ModalBinding {
461        action,
462        keys: action.default_keys(),
463        source: Source::Default,
464      })
465      .collect();
466    Self { entries }
467  }
468
469  /// Replace the keys bound to `action` with `keys` and re-validate the
470  /// action's **context**. An empty `Vec` unbinds the verb.
471  ///
472  /// Validation rejects the same stroke wired to two different verbs *in
473  /// the same context* (cross-context reuse is fine — that is the whole
474  /// point). A default binding in the same context silently vacates any
475  /// stroke the override claims, mirroring the global keymap: explicit
476  /// user intent wins over a shipped default.
477  pub fn apply_override(&mut self, action: ModalAction, keys: Vec<KeyStroke>) -> Result<()> {
478    let ctx = action.context();
479    // Refuse a binding the reserved typing would swallow (Codex review
480    // #456): with `palette.close = ["x"]` the override replaces Esc, then
481    // the filter typing consumes `x` — the overlay is left with no exit
482    // short of Ctrl-C. Better a clear config error up front.
483    for k in &keys {
484      if action.reserved_typing_stroke(k) {
485        return Err(GwmError::Config(format!(
486          "context {}: key {} is reserved for typing input there and cannot be bound to {} — \
487           the dispatch routes it into the input before the modal resolution",
488          ctx.config_path(),
489          k,
490          action.verb()
491        )));
492      }
493    }
494    let claimed: Vec<&KeyStroke> = keys.iter().collect();
495
496    // Build the post-override key→action map for this context (excluding the
497    // action being replaced), vacating claimed strokes from defaults.
498    let mut map: HashMap<KeyStroke, ModalAction> = HashMap::new();
499    for b in &self.entries {
500      if b.action.context() != ctx || b.action == action {
501        continue;
502      }
503      for k in &b.keys {
504        if b.source == Source::Default && claimed.contains(&k) {
505          continue;
506        }
507        map.insert(k.clone(), b.action);
508      }
509    }
510    for k in &keys {
511      if let Some(prev) = map.get(k) {
512        return Err(GwmError::Config(format!(
513          "context {}: key {} bound to both {:?} and {:?} — conflict",
514          ctx.config_path(),
515          k,
516          prev.verb(),
517          action.verb()
518        )));
519      }
520    }
521
522    // Commit: vacate claimed strokes from same-context defaults, then
523    // replace the target verb's binding.
524    let claimed_owned: Vec<KeyStroke> = keys.clone();
525    for entry in self.entries.iter_mut() {
526      if entry.action.context() == ctx && entry.action != action && entry.source == Source::Default {
527        entry.keys.retain(|k| !claimed_owned.contains(k));
528      }
529    }
530    if let Some(entry) = self.entries.iter_mut().find(|b| b.action == action) {
531      entry.keys = keys;
532      entry.source = Source::UserConfig;
533    } else {
534      self.entries.push(ModalBinding {
535        action,
536        keys,
537        source: Source::UserConfig,
538      });
539    }
540    Ok(())
541  }
542
543  /// Resolve a single keystroke against the bindings of `ctx`. Returns the
544  /// matched verb, or `None` when nothing in this context binds the stroke
545  /// (the caller then applies the context's text-input / default fallback).
546  pub fn resolve(&self, ctx: KeyContext, stroke: &KeyStroke) -> Option<ModalAction> {
547    self
548      .entries
549      .iter()
550      .filter(|b| b.action.context() == ctx)
551      .find(|b| b.keys.iter().any(|k| k == stroke))
552      .map(|b| b.action)
553  }
554
555  /// Every binding whose verb lives in `ctx`, in declaration order.
556  pub fn bindings_for(&self, ctx: KeyContext) -> Vec<&ModalBinding> {
557    self.entries.iter().filter(|b| b.action.context() == ctx).collect()
558  }
559
560  /// Snapshot of every binding, declaration order, for `gwm tui keys` /
561  /// the help overlay / `gwm doctor`.
562  pub fn list(&self) -> &[ModalBinding] {
563    &self.entries
564  }
565
566  /// The first key bound to `action`, rendered for an inline hint (the
567  /// statusbar chip / help-overlay footer). `None` when the verb is
568  /// unbound — the caller drops it rather than advertise a phantom key.
569  /// Mirrors [`crate::tui::keymap::Keymap::primary_chord`].
570  pub fn primary_key(&self, action: ModalAction) -> Option<String> {
571    self
572      .entries
573      .iter()
574      .find(|b| b.action == action)
575      .and_then(|b| b.keys.first())
576      .map(|k| k.to_string())
577  }
578
579  /// Every key bound to `action`, comma-joined (`"n, Esc"`) or empty when
580  /// unbound — the help-overlay row form, matching the global keymap's
581  /// `keys_for` rendering.
582  pub fn keys_display(&self, action: ModalAction) -> String {
583    self
584      .entries
585      .iter()
586      .find(|b| b.action == action)
587      .map(|b| b.keys.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(", "))
588      .unwrap_or_default()
589  }
590}
591
592impl Default for ModalKeymap {
593  fn default() -> Self {
594    Self::defaults()
595  }
596}