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}