supercode_harness/tui/keymap.rs
1//! P5-4 (§3.1 `capabilities.tui.keymap.<action> = "<key>"`, "configurable
2//! keybindings"): a name→[`KeyEvent`] table for the small set of GLOBAL
3//! actions a user can rebind, layered over sensible defaults. Modal-local
4//! navigation (arrow keys, `Enter`/`Esc` to confirm/cancel a prompt) is
5//! deliberately NOT part of this table — those are fixed, universal
6//! conventions, not a rebind surface — only the actions listed in
7//! [`KeymapAction::ALL`] are.
8
9use std::collections::HashMap;
10
11use super::key::{Key, KeyEvent};
12
13/// A rebindable global TUI action.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
15pub enum KeymapAction {
16 /// Send the current input buffer as a turn.
17 Submit,
18 /// Insert a newline without submitting (multi-line composing).
19 Newline,
20 /// Quit the TUI (falls back to the REPL's own double-press-to-exit
21 /// convention at the state-machine level — see
22 /// [`crate::tui::state::TuiState::handle_key`]'s doc comment).
23 Quit,
24 /// Open cross-session prompt-history search (D5, "Ctrl+R-style
25 /// search").
26 HistorySearch,
27 /// Toggle the dark/light [`crate::tui::Theme`].
28 ToggleTheme,
29 /// Scroll the transcript up one page.
30 ScrollUp,
31 /// Scroll the transcript down one page.
32 ScrollDown,
33 /// Open `$EDITOR` on the current input buffer.
34 ExternalEditor,
35}
36
37impl KeymapAction {
38 /// Every action, for iterating a whole keymap (defaults, config
39 /// parsing).
40 pub const ALL: &'static [KeymapAction] = &[
41 KeymapAction::Submit,
42 KeymapAction::Newline,
43 KeymapAction::Quit,
44 KeymapAction::HistorySearch,
45 KeymapAction::ToggleTheme,
46 KeymapAction::ScrollUp,
47 KeymapAction::ScrollDown,
48 KeymapAction::ExternalEditor,
49 ];
50
51 /// The `capabilities.tui.keymap.<name>` config key this action reads
52 /// from.
53 pub fn name(self) -> &'static str {
54 match self {
55 KeymapAction::Submit => "submit",
56 KeymapAction::Newline => "newline",
57 KeymapAction::Quit => "quit",
58 KeymapAction::HistorySearch => "history_search",
59 KeymapAction::ToggleTheme => "toggle_theme",
60 KeymapAction::ScrollUp => "scroll_up",
61 KeymapAction::ScrollDown => "scroll_down",
62 KeymapAction::ExternalEditor => "external_editor",
63 }
64 }
65
66 /// This action's built-in default binding.
67 pub fn default_key(self) -> KeyEvent {
68 match self {
69 KeymapAction::Submit => KeyEvent::plain(Key::Enter),
70 KeymapAction::Newline => KeyEvent {
71 key: Key::Enter,
72 ctrl: false,
73 alt: true,
74 shift: false,
75 },
76 KeymapAction::Quit => KeyEvent::ctrl(Key::Char('c')),
77 KeymapAction::HistorySearch => KeyEvent::ctrl(Key::Char('r')),
78 KeymapAction::ToggleTheme => KeyEvent::ctrl(Key::Char('t')),
79 KeymapAction::ScrollUp => KeyEvent::plain(Key::PageUp),
80 KeymapAction::ScrollDown => KeyEvent::plain(Key::PageDown),
81 KeymapAction::ExternalEditor => KeyEvent::ctrl(Key::Char('e')),
82 }
83 }
84}
85
86/// The resolved action→key table — [`KeymapAction::default_key`] for every
87/// action, with any [`Self::with_overrides`] substitutions applied.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Keymap {
90 bindings: HashMap<KeymapAction, KeyEvent>,
91}
92
93impl Default for Keymap {
94 fn default() -> Self {
95 let bindings = KeymapAction::ALL
96 .iter()
97 .map(|&a| (a, a.default_key()))
98 .collect();
99 Keymap { bindings }
100 }
101}
102
103impl Keymap {
104 /// Build a keymap from [`Config::tui_keymap`](crate::Config)-shaped
105 /// overrides (`action name` → key spec string, [`KeyEvent::parse`]
106 /// syntax). An unknown action name, or a key spec that fails to parse,
107 /// is silently skipped (keeps the default for that action) — a typo in
108 /// a user's keymap table degrades to "unchanged", never a startup
109 /// failure or a panic.
110 pub fn with_overrides(overrides: &HashMap<String, String>) -> Self {
111 let mut km = Keymap::default();
112 for action in KeymapAction::ALL {
113 if let Some(spec) = overrides.get(action.name()) {
114 if let Some(key) = KeyEvent::parse(spec) {
115 km.bindings.insert(*action, key);
116 }
117 }
118 }
119 km
120 }
121
122 /// The key currently bound to `action`.
123 pub fn key_for(&self, action: KeymapAction) -> KeyEvent {
124 self.bindings
125 .get(&action)
126 .copied()
127 .unwrap_or_else(|| action.default_key())
128 }
129
130 /// Which action (if any) `key` is bound to.
131 pub fn action_for(&self, key: KeyEvent) -> Option<KeymapAction> {
132 self.bindings
133 .iter()
134 .find(|(_, &bound)| bound == key)
135 .map(|(&a, _)| a)
136 }
137}