Skip to main content

gwm/tui/
keymap.rs

1//! Configurable TUI keymap (issue #87).
2//!
3//! Three layers, in order of authority:
4//!
5//! 1. **Built-in defaults** ([`Keymap::defaults`]) — the bindings the
6//!    binary ships with. Captures the historical hard-coded set
7//!    (`j/k`, `g g`, `Tab`, `o`, `l`, `R`, `y`, `p`, `f/F`, `/`, `?`,
8//!    `q`, …).
9//! 2. **User overrides** ([`Keymap::apply_override`]) — fed from
10//!    `[tui.keys]` in `.gwm.toml`. An override **replaces** the
11//!    default for the targeted action; it does not merge. Passing an
12//!    empty `Vec` unbinds the action entirely.
13//! 3. **Hard-coded escape hatches** — `Ctrl+C` (emergency quit) and
14//!    `Esc` / `Enter` keep their contextual handling in
15//!    `src/tui/mod.rs`. They are deliberately outside the keymap
16//!    because their semantics depend on the active view (filter bar,
17//!    picker mode, sticky filter, etc.) — folding them into the
18//!    keymap would require modal state the configuration language
19//!    has no way to express.
20//!
21//! ## Chord / prefix policy
22//!
23//! Per the design decision recorded on PR #87, binding a chord that
24//! is a strict prefix of another chord (e.g. `g` alone while `g g` is
25//! also bound) is a **hard error at load time**. Resolving the
26//! ambiguity at runtime would require a Vim-style 500 ms timeout in
27//! the event loop, which conflicts with the project's preference for
28//! a pure state-machine TUI. Easier to refuse the config and force
29//! the user to pick.
30//!
31//! ## Quit policy
32//!
33//! `quit` is the only action with a guaranteed escape hatch: the
34//! hard-coded `Ctrl+C` branch in `run_app` runs *before* any keymap
35//! lookup, so even an empty / hostile user keymap can be exited. The
36//! doctor check defined in `crate::doctor` emits a warning when no
37//! non-`Ctrl+C` binding for `quit` survives the override layer.
38
39use crate::error::{GwmError, Result};
40use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
41use std::fmt;
42
43// ---------------------------------------------------------------------------
44// Action enum + ACTIONS table
45// ---------------------------------------------------------------------------
46
47/// Declarative macro that defines `Action`, its slug roundtrip, the
48/// `ACTIONS` table, and `Action::all()` from a **single** ordered list
49/// of `(Variant => "slug")` pairs. Adding a new action means editing
50/// one place; the rest stays in sync mechanically.
51macro_rules! define_actions {
52  ($( $variant:ident => $slug:literal ),* $(,)?) => {
53    /// Every user-rebindable verb the TUI exposes.
54    ///
55    /// Variants whose semantics depend on the active view (`Enter`,
56    /// `Esc`, `Ctrl+C`) are deliberately **not** listed — see the
57    /// module-level "Hard-coded escape hatches" note for why.
58    #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
59    pub enum Action {
60      $( $variant, )*
61    }
62
63    impl Action {
64      /// Stable string slug used in `[tui.keys]`, `gwm tui keys`, and
65      /// any future docs generator. Lowercase + underscores; never
66      /// renamed in a backwards-incompatible way without a deprecation
67      /// alias.
68      pub fn slug(self) -> &'static str {
69        match self {
70          $( Action::$variant => $slug, )*
71        }
72      }
73
74      /// Inverse of [`Action::slug`]. Used by the config loader to
75      /// translate `.gwm.toml` keys into typed actions.
76      pub fn from_slug(s: &str) -> Option<Self> {
77        match s {
78          $( $slug => Some(Action::$variant), )*
79          _ => None,
80        }
81      }
82
83      /// Iterator over every variant. Order matches the declarative
84      /// macro invocation below, which is also the order surfaced by
85      /// `gwm tui keys`.
86      pub fn all() -> impl Iterator<Item = Self> {
87        [ $( Action::$variant, )* ].into_iter()
88      }
89    }
90
91    /// Static (Action, slug) table — convenience for callers that
92    /// want both at once (the help-overlay renderer, `gwm tui keys`
93    /// printer, `gwm doctor` reporter).
94    pub const ACTIONS: &[(Action, &str)] = &[
95      $( (Action::$variant, $slug), )*
96    ];
97  };
98}
99
100define_actions! {
101  // Navigation
102  Down              => "down",
103  Up                => "up",
104  Top               => "top",
105  Bottom            => "bottom",
106  ToggleSidebar     => "toggle_sidebar",
107  ToggleSidebarMode => "toggle_sidebar_mode",
108  CycleSidebarLayout => "cycle_sidebar_layout",
109  ToggleSidebarPosition => "toggle_sidebar_position",
110  FocusSwap         => "focus_swap",
111  FocusWorktrees    => "focus_worktrees",
112  FocusStatus       => "focus_status",
113  // Filter
114  Filter            => "filter",
115  // Lifecycle / mutating
116  Refresh           => "refresh",
117  Sync              => "sync",
118  Create            => "create",
119  DeleteConfirm     => "delete",
120  Bootstrap         => "bootstrap",
121  ToggleDeleteBranch => "delete_branch",
122  Pull              => "pull",
123  Push              => "push",
124  EditWorktree      => "edit_worktree",
125  ExitToWorktree    => "exit_to_worktree",
126  LazyGitPty        => "lazygit_pty",
127  LazyGitFullscreen => "lazygit_fullscreen",
128  ReviewFullscreen  => "review_fullscreen",
129  ReviewPty         => "review_pty",
130  YankPath          => "yank_path",
131  YankBranchName    => "yank_branch_name",
132  YankWorktreeName  => "yank_worktree_name",
133  TerminalPty       => "terminal_pty",
134  TerminalFullscreen => "terminal_fullscreen",
135  BrowseLinks       => "browse_links",
136  OpenDocs          => "open_docs",
137  LinkPrompt        => "link",
138  FetchGithub       => "fetch_github",
139  MuxPane           => "mux_pane",
140  Macro1            => "macro_one",
141  Macro2            => "macro_two",
142  // Overlays
143  CommandLogs       => "command_logs",
144  ConfigPanel       => "config_panel",
145  ExecOverlay       => "exec_overlay",
146  CleanOverlay      => "clean_overlay",
147  Help              => "help",
148  Quit              => "quit",
149  // Future surface — bound to ':' by default, picked up by #32.
150  CommandPalette    => "command_palette",
151}
152
153impl Action {
154  /// Like [`Action::from_slug`] but also accepts the pre-#290 slugs that were
155  /// renamed. Use this in config deserialization so existing `.gwm.toml` files
156  /// with the old key names keep working after the upgrade.
157  ///
158  /// The canonical slug is tried first; only on a miss do the compat aliases
159  /// fire. The aliases are intentionally one-way: `Action::slug()` still
160  /// returns the new canonical slug, so `gwm tui keys` and the help overlay
161  /// stay up-to-date.
162  pub fn from_slug_compat(s: &str) -> Option<Self> {
163    if let Some(a) = Self::from_slug(s) {
164      return Some(a);
165    }
166    COMPAT_ALIASES.iter().find(|(slug, _)| *slug == s).map(|(_, a)| *a)
167  }
168
169  /// Whether this action mutates a repo through the *active* repo context
170  /// (`App.repo`/`workdir`/`config`) rather than only the selected worktree's
171  /// path. In workspace mode (#304) these are blocked while the selected row's
172  /// repo can't be activated, since they would otherwise target the previously
173  /// active repo. Navigation, yanks and read-only launchers are absent on
174  /// purpose — they don't write through the active repo handle.
175  pub fn is_repo_mutating(self) -> bool {
176    matches!(
177      self,
178      Action::Create
179        | Action::DeleteConfirm
180        | Action::Bootstrap
181        | Action::Sync
182        | Action::Pull
183        | Action::Push
184        | Action::EditWorktree
185        | Action::LinkPrompt
186        // FetchGithub persists detected PR/issue titles + states into the
187        // active repo's git config, so it writes through `App.repo` too (#304).
188        | Action::FetchGithub
189        // #325: the exec / clean overlays resolve their command / dir-set from
190        // the *active* repo's `[exec]` / `[clean]` config and act on the
191        // selected worktree's path. With a stale workspace selection both the
192        // config and the path belong to the previously active repo — and exec
193        // runs an arbitrary command while clean deletes directories — so they
194        // must be blocked before the overlay opens (Codex #333 review).
195        | Action::ExecOverlay
196        | Action::CleanOverlay
197    )
198  }
199
200  /// The pre-#290 alias slug(s) that resolve to this action, if any. Used by
201  /// the in-TUI keymap editor (issue #294) to strip a stale alias from a
202  /// legacy config when the canonical slug is (re)written — otherwise the
203  /// alias, applied later in the sorted override walk, would silently shadow
204  /// the new binding (Codex #297 review).
205  pub fn compat_alias_slugs(self) -> impl Iterator<Item = &'static str> {
206    COMPAT_ALIASES
207      .iter()
208      .filter(move |(_, a)| *a == self)
209      .map(|(slug, _)| *slug)
210  }
211}
212
213/// Pre-#290 slug aliases, accepted by [`Action::from_slug_compat`] so existing
214/// `.gwm.toml` files keep working after the #290 rename. The canonical slug is
215/// always preferred; these only fire on a miss. Single source of truth for both
216/// directions (resolve + [`Action::compat_alias_slugs`]).
217const COMPAT_ALIASES: &[(&str, Action)] = &[
218  ("git_tui", Action::LazyGitFullscreen),
219  ("git_tui_overlay", Action::LazyGitPty),
220  ("review", Action::ReviewFullscreen),
221  ("review_overlay", Action::ReviewPty),
222  ("yank", Action::YankPath),
223  ("open", Action::TerminalFullscreen),
224  ("open_terminal_overlay", Action::TerminalPty),
225  ("open_menu", Action::BrowseLinks),
226];
227
228// ---------------------------------------------------------------------------
229// Key-string parser
230// ---------------------------------------------------------------------------
231
232/// One keystroke in a chord. Wraps a crossterm [`KeyCode`] +
233/// [`KeyModifiers`] pair, but only retains the three modifier bits
234/// that are meaningful at the keymap layer (Ctrl / Alt / Shift) —
235/// other crossterm bits (keypad, repeat, …) are filtered in
236/// [`KeyStroke::from_event`].
237#[derive(Debug, Clone, PartialEq, Eq, Hash)]
238pub struct KeyStroke {
239  pub code: KeyCode,
240  pub modifiers: KeyModifiers,
241}
242
243impl KeyStroke {
244  pub fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
245    let (code, modifiers) = Self::normalize(code, Self::sanitize(modifiers));
246    Self { code, modifiers }
247  }
248
249  /// Build a stroke from a raw crossterm event, dropping modifier
250  /// bits we never bind against (KEYPAD, REPEAT, SUPER, HYPER, META).
251  pub fn from_event(ev: &KeyEvent) -> Self {
252    Self::new(ev.code, ev.modifiers)
253  }
254
255  fn sanitize(m: KeyModifiers) -> KeyModifiers {
256    m & (KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT)
257  }
258
259  /// Fold a shifted character keystroke to a terminal-independent
260  /// canonical form. A shifted letter already encodes its shift state
261  /// in the glyph itself, but terminals disagree on how they report it:
262  ///
263  /// - legacy terminals: `Char('V')` with **no** modifier;
264  /// - many modern terminals: `Char('V')` **with** `SHIFT`;
265  /// - the kitty keyboard protocol: the base key `Char('v')` with `SHIFT`.
266  ///
267  /// All three mean the same keystroke. We canonicalise any `Char` that
268  /// still carries `SHIFT` to its uppercase form with the `SHIFT` bit
269  /// dropped, so a binding written `"V"` (parsed to `Char('V')`, no
270  /// modifier) matches every variant. Without this, the bound chord and
271  /// the runtime event compared unequal on SHIFT-reporting terminals and
272  /// every uppercase binding (`G`, `R`, `V`, `H`, …) silently did nothing
273  /// (PR #192).
274  ///
275  /// `BackTab` gets the same treatment for the same reason: it *is* the
276  /// shifted Tab, but terminals disagree on whether they additionally set
277  /// the `SHIFT` bit (some send bare `BackTab`, others `BackTab` + `SHIFT`,
278  /// kitty `BackTab` + `SHIFT`). We canonicalise to bare `BackTab` so a
279  /// binding written `"BackTab"` matches every variant. Without this the
280  /// modal `prev_field` / `prev_tab` defaults silently stopped firing on
281  /// SHIFT-reporting terminals once they routed through the keymap instead
282  /// of a modifier-blind `match KeyCode::BackTab` (issue #219 review).
283  fn normalize(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
284    match code {
285      KeyCode::Char(c) if modifiers.contains(KeyModifiers::SHIFT) => {
286        (KeyCode::Char(c.to_ascii_uppercase()), modifiers - KeyModifiers::SHIFT)
287      }
288      KeyCode::BackTab if modifiers.contains(KeyModifiers::SHIFT) => {
289        (KeyCode::BackTab, modifiers - KeyModifiers::SHIFT)
290      }
291      _ => (code, modifiers),
292    }
293  }
294
295  /// Parse a chord string (`"j"`, `"g g"`, `"Ctrl+x Ctrl+s"`) into
296  /// its sequence of keystrokes. Whitespace separates keystrokes;
297  /// `+` separates modifiers from the key. Use `"Space"` for the
298  /// literal space character.
299  pub fn parse_chord(s: &str) -> Result<Vec<KeyStroke>> {
300    let trimmed = s.trim();
301    if trimmed.is_empty() {
302      return Err(GwmError::Config(format!("keymap: empty key string {:?}", s)));
303    }
304    trimmed.split_whitespace().map(Self::parse_single).collect()
305  }
306
307  fn parse_single(token: &str) -> Result<KeyStroke> {
308    if token.is_empty() {
309      return Err(GwmError::Config("keymap: empty keystroke token".into()));
310    }
311    let parts: Vec<&str> = token.split('+').collect();
312    if parts.iter().any(|p| p.is_empty()) {
313      return Err(GwmError::Config(format!("keymap: dangling '+' in {:?}", token)));
314    }
315    let (key_str, mod_strs) = parts.split_last().expect("token is non-empty, split_last cannot fail");
316
317    let mut modifiers = KeyModifiers::empty();
318    for m in mod_strs {
319      let bit = match *m {
320        "Ctrl" => KeyModifiers::CONTROL,
321        "Alt" => KeyModifiers::ALT,
322        "Shift" => KeyModifiers::SHIFT,
323        other => {
324          return Err(GwmError::Config(format!(
325            "keymap: unknown modifier {:?} in {:?}",
326            other, token
327          )))
328        }
329      };
330      if modifiers.contains(bit) {
331        return Err(GwmError::Config(format!(
332          "keymap: duplicate modifier {:?} in {:?}",
333          m, token
334        )));
335      }
336      modifiers |= bit;
337    }
338
339    let code = parse_keycode(key_str, token)?;
340    // Route through `new` so a chord written `"Shift+v"` canonicalises
341    // to the same `Char('V')` (no SHIFT) as `"V"` — and matches whatever
342    // shift encoding the terminal delivers at runtime. See `normalize`.
343    Ok(KeyStroke::new(code, modifiers))
344  }
345}
346
347fn parse_keycode(s: &str, full_token: &str) -> Result<KeyCode> {
348  let code = match s {
349    "Tab" => KeyCode::Tab,
350    "Enter" => KeyCode::Enter,
351    "Esc" => KeyCode::Esc,
352    "Up" => KeyCode::Up,
353    "Down" => KeyCode::Down,
354    "Left" => KeyCode::Left,
355    "Right" => KeyCode::Right,
356    "Backspace" => KeyCode::Backspace,
357    "BackTab" => KeyCode::BackTab,
358    "Home" => KeyCode::Home,
359    "End" => KeyCode::End,
360    "PageUp" => KeyCode::PageUp,
361    "PageDown" => KeyCode::PageDown,
362    "Insert" => KeyCode::Insert,
363    "Delete" => KeyCode::Delete,
364    "Space" => KeyCode::Char(' '),
365    other if other.starts_with('F') && other.len() > 1 => {
366      let n: u8 = other[1..]
367        .parse()
368        .map_err(|_| GwmError::Config(format!("keymap: invalid function key {:?}", other)))?;
369      if !(1..=12).contains(&n) {
370        return Err(GwmError::Config(format!(
371          "keymap: function key out of range {:?} (expected F1..=F12)",
372          other
373        )));
374      }
375      KeyCode::F(n)
376    }
377    other => {
378      let mut chars = other.chars();
379      let (first, second) = (chars.next(), chars.next());
380      match (first, second) {
381        (Some(c), None) => KeyCode::Char(c),
382        _ => {
383          return Err(GwmError::Config(format!(
384            "keymap: unknown key {:?} in {:?}",
385            other, full_token
386          )))
387        }
388      }
389    }
390  };
391  Ok(code)
392}
393
394impl fmt::Display for KeyStroke {
395  /// Canonical rendering used by `gwm tui keys` and the help overlay.
396  /// Modifier order is always `Ctrl+Alt+Shift+<key>` so two bindings
397  /// that compare equal also render identically.
398  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
399    if self.modifiers.contains(KeyModifiers::CONTROL) {
400      write!(f, "Ctrl+")?;
401    }
402    if self.modifiers.contains(KeyModifiers::ALT) {
403      write!(f, "Alt+")?;
404    }
405    if self.modifiers.contains(KeyModifiers::SHIFT) {
406      write!(f, "Shift+")?;
407    }
408    match self.code {
409      KeyCode::Char(' ') => write!(f, "Space"),
410      KeyCode::Char(c) => write!(f, "{c}"),
411      KeyCode::Tab => write!(f, "Tab"),
412      KeyCode::Enter => write!(f, "Enter"),
413      KeyCode::Esc => write!(f, "Esc"),
414      KeyCode::Up => write!(f, "Up"),
415      KeyCode::Down => write!(f, "Down"),
416      KeyCode::Left => write!(f, "Left"),
417      KeyCode::Right => write!(f, "Right"),
418      KeyCode::Backspace => write!(f, "Backspace"),
419      KeyCode::BackTab => write!(f, "BackTab"),
420      KeyCode::Home => write!(f, "Home"),
421      KeyCode::End => write!(f, "End"),
422      KeyCode::PageUp => write!(f, "PageUp"),
423      KeyCode::PageDown => write!(f, "PageDown"),
424      KeyCode::Insert => write!(f, "Insert"),
425      KeyCode::Delete => write!(f, "Delete"),
426      KeyCode::F(n) => write!(f, "F{n}"),
427      other => write!(f, "{other:?}"),
428    }
429  }
430}
431
432fn format_chord(strokes: &[KeyStroke]) -> String {
433  strokes.iter().map(|s| s.to_string()).collect::<Vec<_>>().join(" ")
434}
435
436// ---------------------------------------------------------------------------
437// Keymap
438// ---------------------------------------------------------------------------
439
440/// Where a given binding came from. Surfaces in `gwm tui keys` as the
441/// third column so the user can audit what's hers vs. what shipped.
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum Source {
444  Default,
445  UserConfig,
446}
447
448/// One entry in the resolved keymap: an action, the list of chords
449/// that fire it (any one match suffices), and the source layer the
450/// binding came from.
451#[derive(Debug, Clone)]
452pub struct Binding {
453  pub action: Action,
454  pub chords: Vec<Vec<KeyStroke>>,
455  pub source: Source,
456}
457
458/// Outcome of [`Keymap::lookup`] for a given pending-keys buffer.
459#[derive(Debug, PartialEq, Eq)]
460pub enum ChordResolution {
461  /// Buffer exactly matches a bound chord. Caller fires the action
462  /// and clears the buffer.
463  Matched(Action),
464  /// Buffer is the strict prefix of at least one bound chord.
465  /// Caller keeps the buffer armed and waits for the next stroke.
466  PendingPrefix,
467  /// Buffer matches no binding and is not the prefix of any.
468  /// Caller clears the buffer (and may retry the last stroke alone
469  /// — that policy lives in the event loop, not here).
470  NoMatch,
471}
472
473#[derive(Debug, Clone)]
474pub struct Keymap {
475  entries: Vec<Binding>,
476}
477
478impl Keymap {
479  /// Built-in defaults. Mirrors the historical hard-coded set in
480  /// `src/tui/mod.rs` before issue #87. Adding a default binding
481  /// here automatically surfaces it in `gwm tui keys` and the help
482  /// overlay.
483  pub fn defaults() -> Self {
484    let entries = vec![
485      def(Action::Down, &["j", "Down"]),
486      def(Action::Up, &["k", "Up"]),
487      def(Action::Top, &["g g"]),
488      def(Action::Bottom, &["G", "End"]),
489      // #290: V=toggle show/hide, S=cycle content (Commits↔Stashes),
490      // Space=cycle orientation (auto/side-by-side/stacked), v=toggle position.
491      def(Action::ToggleSidebar, &["V"]),
492      def(Action::ToggleSidebarMode, &["S"]),
493      def(Action::CycleSidebarLayout, &["Space"]),
494      def(Action::ToggleSidebarPosition, &["v"]),
495      def(Action::FocusSwap, &["Tab"]),
496      def(Action::FocusWorktrees, &["1"]),
497      def(Action::FocusStatus, &["2"]),
498      def(Action::CommandLogs, &["3"]),
499      def(Action::ConfigPanel, &["4"]),
500      // #325: `x` opens the exec profile picker overlay.
501      def(Action::ExecOverlay, &["x"]),
502      // #325: `X` opens the clean reclaim overlay.
503      def(Action::CleanOverlay, &["X"]),
504      def(Action::Filter, &["/"]),
505      def(Action::Refresh, &["f"]),
506      // #290: `s` (lowercase) is now Sync — replaces ToggleSidebarMode.
507      def(Action::Sync, &["s"]),
508      def(Action::Create, &["n"]),
509      def(Action::DeleteConfirm, &["d"]),
510      def(Action::Bootstrap, &["b"]),
511      // #290: `D` (uppercase) is now ToggleDeleteBranch — `p` repurposed as Pull.
512      def(Action::ToggleDeleteBranch, &["D"]),
513      // #290: `p` is now Pull (was ToggleDeleteBranch before).
514      def(Action::Pull, &["p"]),
515      // #290: `P` is Push.
516      def(Action::Push, &["P"]),
517      // #290: `c` opens the edit-worktree modal (rename branch).
518      def(Action::EditWorktree, &["c"]),
519      // #290: `e` exits the TUI and prints the selected worktree path to stdout.
520      def(Action::ExitToWorktree, &["e"]),
521      // #35/#290: `l` opens lazygit in an embedded PTY overlay.
522      def(Action::LazyGitPty, &["l"]),
523      // #290: `L` opens lazygit fullscreen (was unbound before #290).
524      def(Action::LazyGitFullscreen, &["L"]),
525      // #290: `R` opens the review launcher fullscreen (renamed from review).
526      def(Action::ReviewFullscreen, &["R"]),
527      // #35/#290: `r` opens the review launcher in an embedded PTY overlay.
528      def(Action::ReviewPty, &["r"]),
529      // #290: `Y` yanks the worktree path (was `y` before #290).
530      def(Action::YankPath, &["Y"]),
531      // #290: `y` yanks the branch name (was yank-path `y` before #290).
532      def(Action::YankBranchName, &["y"]),
533      // #290: `w` yanks the worktree slug/name.
534      def(Action::YankWorktreeName, &["w"]),
535      // #35/#290: `o` opens a native terminal PTY overlay (renamed from open_terminal_overlay).
536      def(Action::TerminalPty, &["o"]),
537      // #290: `O` opens a native terminal fullscreen (was unbound before #290).
538      def(Action::TerminalFullscreen, &["O"]),
539      // #290: `B` opens the browse-links menu (was `O` for open_menu before #290).
540      def(Action::BrowseLinks, &["B"]),
541      def(Action::OpenDocs, &["."]),
542      // #290: `i` links the selected worktree to an issue/PR (was `L` before #290).
543      def(Action::LinkPrompt, &["i"]),
544      def(Action::FetchGithub, &["F"]),
545      // #290: `t` opens the selected worktree in a new multiplexer pane/tab.
546      def(Action::MuxPane, &["t"]),
547      // #290: `h`/`H` fire user-configured macro1/macro2.
548      def(Action::Macro1, &["h"]),
549      def(Action::Macro2, &["H"]),
550      def(Action::Help, &["?"]),
551      def(Action::Quit, &["q"]),
552      def(Action::CommandPalette, &[":"]),
553    ];
554    Self { entries }
555  }
556
557  /// Replace the chords bound to `action` with `chords` and re-validate
558  /// the full keymap. An empty `Vec` unbinds the action. The validation
559  /// pass rejects:
560  ///
561  /// - the same chord wired to two different actions (conflict);
562  /// - a chord that is a strict prefix of another (`g` while `g g`
563  ///   is bound) — see the module-level chord/prefix policy note.
564  ///
565  /// Returns `Err(GwmError::Config(_))` on failure; the keymap is
566  /// left untouched on error so callers can surface the message and
567  /// move on.
568  pub fn apply_override(&mut self, action: Action, chords: Vec<Vec<KeyStroke>>) -> Result<()> {
569    // Build the candidate list. For default bindings on other actions, silently
570    // vacate any chord that the new user override is claiming — user intent is
571    // explicit and wins over shipped defaults. User-vs-user conflicts still
572    // fail validation below.
573    let new_chord_set: std::collections::HashSet<&[KeyStroke]> = chords.iter().map(|c| c.as_slice()).collect();
574    let mut candidate: Vec<(Action, Vec<Vec<KeyStroke>>)> = self
575      .entries
576      .iter()
577      .map(|b| {
578        if b.action == action {
579          (b.action, chords.clone())
580        } else if b.source == Source::Default {
581          let pruned: Vec<Vec<KeyStroke>> = b
582            .chords
583            .iter()
584            .filter(|c| !new_chord_set.contains(c.as_slice()))
585            .cloned()
586            .collect();
587          (b.action, pruned)
588        } else {
589          (b.action, b.chords.clone())
590        }
591      })
592      .collect();
593    if !candidate.iter().any(|(a, _)| *a == action) {
594      candidate.push((action, chords.clone()));
595    }
596    Self::validate(&candidate)?;
597
598    // Commit: vacate the claimed chords from default bindings on other actions,
599    // then update (or insert) the overridden action's binding.
600    for entry in self.entries.iter_mut() {
601      if entry.action != action && entry.source == Source::Default {
602        entry.chords.retain(|c| !new_chord_set.contains(c.as_slice()));
603      }
604    }
605
606    let mut replaced = false;
607    for entry in self.entries.iter_mut() {
608      if entry.action == action {
609        entry.chords = chords.clone();
610        entry.source = Source::UserConfig;
611        replaced = true;
612        break;
613      }
614    }
615    if !replaced {
616      self.entries.push(Binding {
617        action,
618        chords,
619        source: Source::UserConfig,
620      });
621    }
622    Ok(())
623  }
624
625  fn validate(entries: &[(Action, Vec<Vec<KeyStroke>>)]) -> Result<()> {
626    let mut all: Vec<(&[KeyStroke], Action)> = Vec::new();
627    for (action, chords) in entries {
628      for chord in chords {
629        if chord.is_empty() {
630          return Err(GwmError::Config(format!(
631            "keymap: empty chord bound to {:?}",
632            action.slug()
633          )));
634        }
635        all.push((chord.as_slice(), *action));
636      }
637    }
638    for i in 0..all.len() {
639      for j in (i + 1)..all.len() {
640        if all[i].0 == all[j].0 {
641          if all[i].1 != all[j].1 {
642            return Err(GwmError::Config(format!(
643              "keymap: chord {:?} bound to both {:?} and {:?} — conflict",
644              format_chord(all[i].0),
645              all[i].1.slug(),
646              all[j].1.slug()
647            )));
648          }
649          continue;
650        }
651        let (short, long) = if all[i].0.len() < all[j].0.len() {
652          (i, j)
653        } else {
654          (j, i)
655        };
656        if all[short].0.len() < all[long].0.len() && all[long].0.starts_with(all[short].0) {
657          return Err(GwmError::Config(format!(
658            "keymap: chord {:?} (action {:?}) is a prefix of {:?} (action {:?}) — refused at load time so the event loop never has to time out",
659            format_chord(all[short].0),
660            all[short].1.slug(),
661            format_chord(all[long].0),
662            all[long].1.slug()
663          )));
664        }
665      }
666    }
667    Ok(())
668  }
669
670  /// Resolve a pending-keys buffer against the keymap.
671  pub fn lookup(&self, keys: &[KeyStroke]) -> ChordResolution {
672    let mut pending = false;
673    for entry in &self.entries {
674      for chord in &entry.chords {
675        if chord.as_slice() == keys {
676          return ChordResolution::Matched(entry.action);
677        }
678        if chord.len() > keys.len() && chord.starts_with(keys) {
679          pending = true;
680        }
681      }
682    }
683    if pending {
684      ChordResolution::PendingPrefix
685    } else {
686      ChordResolution::NoMatch
687    }
688  }
689
690  /// Snapshot the resolved keymap for `gwm tui keys` / help overlay.
691  /// Order matches the declarative `define_actions!` invocation so the
692  /// rendered table stays stable across runs.
693  pub fn list(&self) -> Vec<Binding> {
694    self.entries.clone()
695  }
696
697  /// The canonical rendering of the **first** chord bound to `action`,
698  /// or `None` when the action is unbound. Used by UI copy that names a
699  /// key inline (e.g. pane titles such as `Issue / PR [F]`,
700  /// issue #224) so the hint tracks user overrides under `[tui.keys]`
701  /// instead of hard-coding a default that may have been rebound. A
702  /// multi-chord action returns its first chord in declaration order,
703  /// matching what `gwm tui keys` lists first.
704  pub fn primary_chord(&self, action: Action) -> Option<String> {
705    self
706      .entries
707      .iter()
708      .find(|b| b.action == action)
709      .and_then(|b| b.chords.first())
710      .map(|chord| format_chord(chord))
711  }
712
713  /// Every chord bound to `action`, comma-joined (`"j, Down"`) or empty when
714  /// unbound — the help-overlay / Keys-tab row form. Mirrors
715  /// [`crate::tui::modal_keymap::ModalKeymap::keys_display`].
716  pub fn keys_display(&self, action: Action) -> String {
717    self
718      .entries
719      .iter()
720      .find(|b| b.action == action)
721      .map(|b| b.chords.iter().map(|c| format_chord(c)).collect::<Vec<_>>().join(", "))
722      .unwrap_or_default()
723  }
724}
725
726/// Build a default `Binding` from a list of chord literals. Panics
727/// if a literal does not parse — that is a programmer error in the
728/// defaults table, never user input.
729fn def(action: Action, chord_literals: &[&str]) -> Binding {
730  let chords = chord_literals
731    .iter()
732    .map(|s| {
733      KeyStroke::parse_chord(s).unwrap_or_else(|e| panic!("default keymap chord {:?} failed to parse: {}", s, e))
734    })
735    .collect();
736  Binding {
737    action,
738    chords,
739    source: Source::Default,
740  }
741}