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