Skip to main content

rab/tui/
keybindings.rs

1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use crossterm::event::KeyEvent;
5
6use crate::tui::keys::match_key_id;
7
8// =============================================================================
9// Keybinding action identifiers — matching pi's TUI_KEYBINDINGS
10// =============================================================================
11
12// ── Editor actions ──
13pub const ACTION_EDITOR_CURSOR_LEFT: &str = "tui.editor.cursorLeft";
14pub const ACTION_EDITOR_CURSOR_RIGHT: &str = "tui.editor.cursorRight";
15pub const ACTION_EDITOR_CURSOR_UP: &str = "tui.editor.cursorUp";
16pub const ACTION_EDITOR_CURSOR_DOWN: &str = "tui.editor.cursorDown";
17pub const ACTION_EDITOR_CURSOR_LINE_START: &str = "tui.editor.cursorLineStart";
18pub const ACTION_EDITOR_CURSOR_LINE_END: &str = "tui.editor.cursorLineEnd";
19pub const ACTION_EDITOR_CURSOR_WORD_LEFT: &str = "tui.editor.cursorWordLeft";
20pub const ACTION_EDITOR_CURSOR_WORD_RIGHT: &str = "tui.editor.cursorWordRight";
21pub const ACTION_EDITOR_DELETE_CHAR_BACKWARD: &str = "tui.editor.deleteCharBackward";
22pub const ACTION_EDITOR_DELETE_CHAR_FORWARD: &str = "tui.editor.deleteCharForward";
23pub const ACTION_EDITOR_DELETE_WORD_BACKWARD: &str = "tui.editor.deleteWordBackward";
24pub const ACTION_EDITOR_DELETE_WORD_FORWARD: &str = "tui.editor.deleteWordForward";
25pub const ACTION_EDITOR_DELETE_TO_LINE_START: &str = "tui.editor.deleteToLineStart";
26pub const ACTION_EDITOR_DELETE_TO_LINE_END: &str = "tui.editor.deleteToLineEnd";
27pub const ACTION_EDITOR_YANK: &str = "tui.editor.yank";
28pub const ACTION_EDITOR_YANK_POP: &str = "tui.editor.yankPop";
29pub const ACTION_EDITOR_UNDO: &str = "tui.editor.undo";
30pub const ACTION_EDITOR_PAGE_UP: &str = "tui.editor.pageUp";
31pub const ACTION_EDITOR_PAGE_DOWN: &str = "tui.editor.pageDown";
32pub const ACTION_EDITOR_JUMP_FORWARD: &str = "tui.editor.jumpForward";
33pub const ACTION_EDITOR_JUMP_BACKWARD: &str = "tui.editor.jumpBackward";
34
35// ── Input (single-line) actions ──
36pub const ACTION_INPUT_SUBMIT: &str = "tui.input.submit";
37pub const ACTION_INPUT_TAB: &str = "tui.input.tab";
38pub const ACTION_INPUT_NEW_LINE: &str = "tui.input.newLine";
39pub const ACTION_INPUT_COPY: &str = "tui.input.copy";
40
41// ── Select list actions ──
42pub const ACTION_SELECT_UP: &str = "tui.select.up";
43pub const ACTION_SELECT_DOWN: &str = "tui.select.down";
44pub const ACTION_SELECT_CONFIRM: &str = "tui.select.confirm";
45pub const ACTION_SELECT_CANCEL: &str = "tui.select.cancel";
46
47// ── Application-level actions (matching pi's KEYBINDINGS) ──
48pub const ACTION_APP_ESCAPE: &str = "app.escape";
49pub const ACTION_APP_CLEAR: &str = "app.clear";
50pub const ACTION_APP_INTERRUPT: &str = "app.interrupt";
51pub const ACTION_APP_EXIT: &str = "app.exit";
52pub const ACTION_APP_SUSPEND: &str = "app.suspend";
53pub const ACTION_APP_THINKING_CYCLE: &str = "app.thinking.cycle";
54pub const ACTION_APP_MODEL_SELECTOR: &str = "app.model.select";
55pub const ACTION_APP_MODEL_CYCLE_FORWARD: &str = "app.model.cycleForward";
56pub const ACTION_APP_MODEL_CYCLE_BACKWARD: &str = "app.model.cycleBackward";
57pub const ACTION_APP_TOGGLE_THINKING: &str = "app.thinking.toggle";
58pub const ACTION_APP_TOOLS_EXPAND: &str = "app.tools.expand";
59pub const ACTION_APP_EDITOR_EXTERNAL: &str = "app.editor.external";
60pub const ACTION_APP_HELP: &str = "app.help";
61pub const ACTION_APP_HISTORY_UP: &str = "app.historyUp";
62pub const ACTION_APP_HISTORY_DOWN: &str = "app.historyDown";
63pub const ACTION_APP_MESSAGE_FOLLOW_UP: &str = "app.message.followUp";
64pub const ACTION_APP_MESSAGE_DEQUEUE: &str = "app.message.dequeue";
65pub const ACTION_APP_COMPACT_TOGGLE: &str = "app.compact.toggle";
66pub const ACTION_APP_SESSION_NEW: &str = "app.session.new";
67pub const ACTION_APP_SESSION_TREE: &str = "app.session.tree";
68pub const ACTION_APP_SESSION_FORK: &str = "app.session.fork";
69pub const ACTION_APP_SESSION_RESUME: &str = "app.session.resume";
70
71// =============================================================================
72// Keybindings
73// =============================================================================
74
75/// Mapping from action ID to list of key IDs that trigger it.
76#[derive(Debug, Clone)]
77pub struct Keybindings {
78    bindings: HashMap<String, Vec<String>>,
79}
80
81impl Keybindings {
82    pub fn new() -> Self {
83        Self {
84            bindings: HashMap::new(),
85        }
86    }
87
88    /// Create keybindings from default pi-compatible bindings.
89    pub fn with_defaults() -> Self {
90        let mut kb = Self::new();
91        kb.set_defaults();
92        kb
93    }
94
95    fn set_defaults(&mut self) {
96        self.set(
97            ACTION_EDITOR_CURSOR_LEFT,
98            vec!["left".into(), "ctrl+b".into()],
99        );
100        self.set(
101            ACTION_EDITOR_CURSOR_RIGHT,
102            vec!["right".into(), "ctrl+f".into()],
103        );
104        self.set(ACTION_EDITOR_CURSOR_UP, vec!["up".into()]);
105        self.set(ACTION_EDITOR_CURSOR_DOWN, vec!["down".into()]);
106        self.set(
107            ACTION_EDITOR_CURSOR_LINE_START,
108            vec!["home".into(), "ctrl+a".into()],
109        );
110        self.set(
111            ACTION_EDITOR_CURSOR_LINE_END,
112            vec!["end".into(), "ctrl+e".into()],
113        );
114        self.set(
115            ACTION_EDITOR_CURSOR_WORD_LEFT,
116            vec!["ctrl+left".into(), "alt+b".into()],
117        );
118        self.set(
119            ACTION_EDITOR_CURSOR_WORD_RIGHT,
120            vec!["ctrl+right".into(), "alt+f".into()],
121        );
122        self.set(
123            ACTION_EDITOR_DELETE_CHAR_BACKWARD,
124            vec!["backspace".into(), "ctrl+h".into()],
125        );
126        self.set(
127            ACTION_EDITOR_DELETE_CHAR_FORWARD,
128            vec!["delete".into(), "ctrl+d".into()],
129        );
130        self.set(ACTION_EDITOR_DELETE_WORD_BACKWARD, vec!["ctrl+w".into()]);
131        self.set(ACTION_EDITOR_DELETE_WORD_FORWARD, vec!["alt+d".into()]);
132        self.set(ACTION_EDITOR_DELETE_TO_LINE_START, vec!["ctrl+u".into()]);
133        self.set(ACTION_EDITOR_DELETE_TO_LINE_END, vec!["ctrl+k".into()]);
134        self.set(ACTION_EDITOR_YANK, vec!["ctrl+y".into()]);
135        self.set(ACTION_EDITOR_YANK_POP, vec!["alt+y".into()]);
136        self.set(ACTION_EDITOR_UNDO, vec!["ctrl+z".into()]);
137        self.set(ACTION_EDITOR_PAGE_UP, vec!["pageUp".into()]);
138        self.set(ACTION_EDITOR_PAGE_DOWN, vec!["pageDown".into()]);
139        self.set(ACTION_EDITOR_JUMP_FORWARD, vec!["alt+f".into()]);
140        self.set(ACTION_EDITOR_JUMP_BACKWARD, vec!["alt+b".into()]);
141
142        self.set(ACTION_INPUT_SUBMIT, vec!["enter".into()]);
143        self.set(ACTION_INPUT_TAB, vec!["tab".into()]);
144        self.set(ACTION_INPUT_NEW_LINE, vec!["ctrl+j".into()]);
145        self.set(ACTION_INPUT_COPY, vec!["ctrl+c".into()]);
146
147        self.set(ACTION_SELECT_UP, vec!["up".into()]);
148        self.set(ACTION_SELECT_DOWN, vec!["down".into()]);
149        self.set(ACTION_SELECT_CONFIRM, vec!["enter".into()]);
150        self.set(ACTION_SELECT_CANCEL, vec!["escape".into()]);
151
152        self.set(ACTION_APP_ESCAPE, vec!["escape".into()]);
153        self.set(ACTION_APP_CLEAR, vec!["ctrl+c".into()]);
154        self.set(ACTION_APP_INTERRUPT, vec!["escape".into()]);
155        self.set(ACTION_APP_EXIT, vec!["ctrl+d".into()]);
156        self.set(ACTION_APP_SUSPEND, vec!["ctrl+z".into()]);
157        self.set(ACTION_APP_THINKING_CYCLE, vec!["shift+tab".into()]);
158        self.set(ACTION_APP_MODEL_SELECTOR, vec!["ctrl+l".into()]);
159        self.set(ACTION_APP_MODEL_CYCLE_FORWARD, vec!["ctrl+p".into()]);
160        self.set(ACTION_APP_MODEL_CYCLE_BACKWARD, vec!["ctrl+shift+p".into()]);
161        self.set(ACTION_APP_TOGGLE_THINKING, vec!["ctrl+t".into()]);
162        self.set(ACTION_APP_TOOLS_EXPAND, vec!["ctrl+o".into()]);
163        self.set(ACTION_APP_EDITOR_EXTERNAL, vec!["ctrl+g".into()]);
164        self.set(ACTION_APP_HELP, vec!["f1".into()]);
165        self.set(ACTION_APP_HISTORY_UP, vec!["up".into()]);
166        self.set(ACTION_APP_HISTORY_DOWN, vec!["down".into()]);
167        self.set(ACTION_APP_MESSAGE_FOLLOW_UP, vec!["alt+enter".into()]);
168        self.set(ACTION_APP_MESSAGE_DEQUEUE, vec!["alt+up".into()]);
169        self.set(ACTION_APP_COMPACT_TOGGLE, vec!["ctrl+shift+c".into()]);
170        // Session actions (deferred — no default keybindings)
171        self.set(ACTION_APP_SESSION_NEW, vec![]);
172        self.set(ACTION_APP_SESSION_TREE, vec![]);
173        self.set(ACTION_APP_SESSION_FORK, vec![]);
174        self.set(ACTION_APP_SESSION_RESUME, vec![]);
175    }
176
177    /// Set the key IDs for an action.
178    pub fn set(&mut self, action: &str, keys: Vec<String>) {
179        self.bindings.insert(action.to_string(), keys);
180    }
181
182    /// Merge another keybindings into this one (overwrites existing).
183    pub fn merge(&mut self, other: Keybindings) {
184        for (action, keys) in other.bindings {
185            self.bindings.insert(action, keys);
186        }
187    }
188
189    /// Check if a key event matches any of the keys bound to an action.
190    pub fn matches(&self, event: &KeyEvent, action_id: &str) -> bool {
191        if let Some(keys) = self.bindings.get(action_id) {
192            for key_id in keys {
193                if match_key_id(event, key_id) {
194                    return true;
195                }
196            }
197        }
198        false
199    }
200
201    /// Get the key IDs bound to an action.
202    pub fn get_keys(&self, action_id: &str) -> &[String] {
203        self.bindings
204            .get(action_id)
205            .map(|v| v.as_slice())
206            .unwrap_or(&[])
207    }
208
209    /// Load keybindings from a JSON file.
210    pub fn load(path: &std::path::Path) -> std::io::Result<Self> {
211        let content = std::fs::read_to_string(path)?;
212        let bindings: HashMap<String, Vec<String>> = serde_json::from_str(&content)
213            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
214        Ok(Self { bindings })
215    }
216
217    /// Save keybindings to a JSON file.
218    pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> {
219        let content = serde_json::to_string_pretty(&self.bindings)
220            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
221        std::fs::write(path, content)
222    }
223}
224
225impl Default for Keybindings {
226    fn default() -> Self {
227        Self::with_defaults()
228    }
229}
230
231// =============================================================================
232// Global keybindings accessor
233// =============================================================================
234
235static GLOBAL_KEYBINDINGS: OnceLock<Keybindings> = OnceLock::new();
236
237/// Get the global keybindings instance (initialized with defaults on first call).
238pub fn get_keybindings() -> &'static Keybindings {
239    GLOBAL_KEYBINDINGS.get_or_init(Keybindings::with_defaults)
240}
241
242/// Initialize (or replace) the global keybindings.
243pub fn init_keybindings(kb: Keybindings) {
244    let _ = GLOBAL_KEYBINDINGS.set(kb);
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
251
252    #[test]
253    fn test_defaults_loaded() {
254        let kb = get_keybindings();
255        assert!(kb.matches(
256            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
257            ACTION_INPUT_COPY,
258        ));
259        assert!(!kb.matches(
260            &KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
261            ACTION_INPUT_COPY,
262        ));
263    }
264
265    #[test]
266    fn test_editor_undo() {
267        let kb = get_keybindings();
268        assert!(kb.matches(
269            &KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
270            ACTION_EDITOR_UNDO,
271        ));
272    }
273
274    #[test]
275    fn test_select_up_down() {
276        let kb = get_keybindings();
277        assert!(kb.matches(
278            &KeyEvent::new(KeyCode::Up, KeyModifiers::NONE),
279            ACTION_SELECT_UP
280        ));
281        assert!(kb.matches(
282            &KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
283            ACTION_SELECT_DOWN
284        ));
285        assert!(kb.matches(
286            &KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE),
287            ACTION_SELECT_CONFIRM
288        ));
289        assert!(kb.matches(
290            &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
291            ACTION_SELECT_CANCEL
292        ));
293    }
294
295    #[test]
296    fn test_delete_word_backward() {
297        let kb = get_keybindings();
298        assert!(kb.matches(
299            &KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL),
300            ACTION_EDITOR_DELETE_WORD_BACKWARD,
301        ));
302    }
303
304    #[test]
305    fn test_app_clear() {
306        let kb = get_keybindings();
307        assert!(kb.matches(
308            &KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
309            ACTION_APP_CLEAR,
310        ));
311        assert!(!kb.matches(
312            &KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE),
313            ACTION_APP_CLEAR,
314        ));
315    }
316
317    #[test]
318    fn test_app_suspend() {
319        let kb = get_keybindings();
320        assert!(kb.matches(
321            &KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
322            ACTION_APP_SUSPEND,
323        ));
324    }
325
326    #[test]
327    fn test_app_thinking_cycle() {
328        let kb = get_keybindings();
329        assert!(kb.matches(
330            &KeyEvent::new(KeyCode::BackTab, KeyModifiers::NONE),
331            ACTION_APP_THINKING_CYCLE,
332        ));
333        assert!(kb.matches(
334            &KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT),
335            ACTION_APP_THINKING_CYCLE,
336        ));
337    }
338
339    #[test]
340    fn test_app_model_cycle() {
341        let kb = get_keybindings();
342        assert!(kb.matches(
343            &KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL),
344            ACTION_APP_MODEL_CYCLE_FORWARD,
345        ));
346        assert!(kb.matches(
347            &KeyEvent::new(
348                KeyCode::Char('p'),
349                KeyModifiers::CONTROL | KeyModifiers::SHIFT
350            ),
351            ACTION_APP_MODEL_CYCLE_BACKWARD,
352        ));
353    }
354
355    #[test]
356    fn test_app_tools_expand() {
357        let kb = get_keybindings();
358        assert!(kb.matches(
359            &KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL),
360            ACTION_APP_TOOLS_EXPAND,
361        ));
362    }
363
364    #[test]
365    fn test_app_editor_external() {
366        let kb = get_keybindings();
367        assert!(kb.matches(
368            &KeyEvent::new(KeyCode::Char('g'), KeyModifiers::CONTROL),
369            ACTION_APP_EDITOR_EXTERNAL,
370        ));
371    }
372
373    #[test]
374    fn test_app_follow_up_dequeue() {
375        let kb = get_keybindings();
376        assert!(kb.matches(
377            &KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT),
378            ACTION_APP_MESSAGE_FOLLOW_UP,
379        ));
380    }
381
382    #[test]
383    fn test_cursor_word_left() {
384        let kb = get_keybindings();
385        assert!(kb.matches(
386            &KeyEvent::new(KeyCode::Left, KeyModifiers::CONTROL),
387            ACTION_EDITOR_CURSOR_WORD_LEFT,
388        ));
389        assert!(kb.matches(
390            &KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT),
391            ACTION_EDITOR_CURSOR_WORD_LEFT,
392        ));
393    }
394}