Skip to main content

vtcode_ui/tui/core_tui/session/
action.rs

1use hashbrown::{HashMap, HashSet};
2use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use std::time::{Duration, Instant};
4
5/// Rebindable user-facing actions.
6///
7/// Each variant corresponds to a command-level action that a user may want to
8/// remap.  Fine-grained editing shortcuts (character insertion, cursor movement,
9/// text selection, Backspace, Delete, Home/End, Ctrl+A/E/W/U/K, Enter/Tab/Esc
10/// with their context-sensitive logic) remain hardcoded in `events.rs`.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum Action {
13    /// Send an interrupt signal (Ctrl+C) to the running agent.
14    Interrupt,
15    /// Exit the TUI session.
16    Exit,
17    /// Run the current operation in the background.
18    BackgroundOperation,
19    /// Open the model picker dialog.
20    OpenModelPicker,
21    /// Clear the terminal screen.
22    ClearScreen,
23    /// Scroll the conversation view up by one page.
24    ScrollPageUp,
25    /// Scroll the conversation view down by one page.
26    ScrollPageDown,
27    /// Open the input edit queue for reordering queued prompts.
28    EditQueue,
29    /// Recall the previous input from history.
30    HistoryPrevious,
31    /// Recall the next input from history.
32    HistoryNext,
33    /// Toggle the log panel visibility.
34    ToggleLogs,
35    /// Toggle compact per-call tool summaries.
36    ToggleToolDisplayMode,
37    /// Toggle the task panel visibility.
38    ToggleTaskPanel,
39    /// Open or close the whole-conversation transcript review overlay.
40    OpenTranscriptReview,
41    /// Toggle rich and raw transcript review rendering.
42    ToggleTranscriptRenderMode,
43    /// Generate an inline prompt suggestion via the LLM.
44    GeneratePromptSuggestion,
45    /// Jump to the most recent transcript change, pinned to the bottom edge.
46    JumpToLastChange,
47}
48
49impl Action {
50    /// Human-readable name for config file serialization.
51    fn name(self) -> &'static str {
52        match self {
53            Action::Interrupt => "interrupt",
54            Action::Exit => "exit",
55            Action::BackgroundOperation => "background_operation",
56            Action::OpenModelPicker => "open_model_picker",
57            Action::ClearScreen => "clear_screen",
58            Action::ScrollPageUp => "scroll_page_up",
59            Action::ScrollPageDown => "scroll_page_down",
60            Action::EditQueue => "edit_queue",
61            Action::HistoryPrevious => "history_previous",
62            Action::HistoryNext => "history_next",
63            Action::ToggleLogs => "toggle_logs",
64            Action::ToggleToolDisplayMode => "toggle_tool_display_mode",
65            Action::ToggleTaskPanel => "toggle_task_panel",
66            Action::OpenTranscriptReview => "open_transcript_review",
67            Action::ToggleTranscriptRenderMode => "toggle_transcript_render_mode",
68            Action::GeneratePromptSuggestion => "generate_prompt_suggestion",
69            Action::JumpToLastChange => "jump_to_last_change",
70        }
71    }
72
73    /// All defined actions.
74    fn all() -> &'static [Action] {
75        &[
76            Action::Interrupt,
77            Action::Exit,
78            Action::BackgroundOperation,
79            Action::OpenModelPicker,
80            Action::ClearScreen,
81            Action::ScrollPageUp,
82            Action::ScrollPageDown,
83            Action::EditQueue,
84            Action::HistoryPrevious,
85            Action::HistoryNext,
86            Action::ToggleLogs,
87            Action::ToggleToolDisplayMode,
88            Action::ToggleTaskPanel,
89            Action::OpenTranscriptReview,
90            Action::ToggleTranscriptRenderMode,
91            Action::GeneratePromptSuggestion,
92            Action::JumpToLastChange,
93        ]
94    }
95
96    /// Look up an action by its serialized name.
97    fn from_name(name: &str) -> Option<Self> {
98        Self::all().iter().find(|a| a.name() == name).copied()
99    }
100}
101
102/// Parse a key binding spec like `"ctrl+c"`, `"alt+shift+enter"`, `"pageup"`.
103///
104/// Supported modifiers: `ctrl`, `shift`, `alt`, `meta`, `cmd`, `super`.
105/// Key names: single characters (`a`, `?`), `enter`, `tab`, `backtab`, `esc`,
106/// `backspace`, `delete`, `space`, `up`, `down`, `left`, `right`, `pageup`,
107/// `pagedown`, `home`, `end`, `f1`…`f12`.
108pub fn parse_key_binding(s: &str) -> Option<(KeyCode, KeyModifiers)> {
109    let normalized = s.trim().to_ascii_lowercase();
110    let s = normalized.as_str();
111    if s.is_empty() {
112        return None;
113    }
114
115    let parts: Vec<&str> = s.split('+').collect();
116    let (modifiers, key_part) = if parts.len() == 1 {
117        (KeyModifiers::empty(), parts[0])
118    } else {
119        let mut mods = KeyModifiers::empty();
120        for part in &parts[..parts.len() - 1] {
121            match *part {
122                "ctrl" | "control" => mods.insert(KeyModifiers::CONTROL),
123                "shift" => mods.insert(KeyModifiers::SHIFT),
124                "alt" | "option" => mods.insert(KeyModifiers::ALT),
125                "meta" => mods.insert(KeyModifiers::META),
126                "cmd" | "command" | "super" | "gui" | "win" => {
127                    mods.insert(KeyModifiers::SUPER);
128                }
129                _ => return None,
130            }
131        }
132        (mods, parts[parts.len() - 1])
133    };
134
135    let code = match key_part {
136        "enter" => KeyCode::Enter,
137        "tab" => KeyCode::Tab,
138        "backtab" => KeyCode::BackTab,
139        "esc" | "escape" => KeyCode::Esc,
140        "backspace" => KeyCode::Backspace,
141        "delete" => KeyCode::Delete,
142        "space" => KeyCode::Char(' '),
143        "up" => KeyCode::Up,
144        "down" => KeyCode::Down,
145        "left" => KeyCode::Left,
146        "right" => KeyCode::Right,
147        "pageup" => KeyCode::PageUp,
148        "pagedown" => KeyCode::PageDown,
149        "home" => KeyCode::Home,
150        "end" => KeyCode::End,
151        "insert" => KeyCode::Insert,
152        "null" => KeyCode::Null,
153        "capslock" => KeyCode::CapsLock,
154        "scrolllock" => KeyCode::ScrollLock,
155        "numlock" => KeyCode::NumLock,
156        "printscreen" => KeyCode::PrintScreen,
157        "pause" => KeyCode::Pause,
158        "menu" => KeyCode::Menu,
159        name if name.starts_with('f') && name.len() > 1 => {
160            let n: u8 = name[1..].parse().ok()?;
161            match n {
162                1 => KeyCode::F(1),
163                2 => KeyCode::F(2),
164                3 => KeyCode::F(3),
165                4 => KeyCode::F(4),
166                5 => KeyCode::F(5),
167                6 => KeyCode::F(6),
168                7 => KeyCode::F(7),
169                8 => KeyCode::F(8),
170                9 => KeyCode::F(9),
171                10 => KeyCode::F(10),
172                11 => KeyCode::F(11),
173                12 => KeyCode::F(12),
174                _ => return None,
175            }
176        }
177        ch => {
178            let chars: Vec<char> = ch.chars().collect();
179            if chars.len() == 1 {
180                KeyCode::Char(chars[0])
181            } else {
182                return None;
183            }
184        }
185    };
186
187    Some((code, modifiers))
188}
189
190/// Default key → action mappings matching the current hardcoded dispatch.
191fn default_bindings() -> HashMap<Action, Vec<(KeyCode, KeyModifiers)>> {
192    use Action::*;
193    let mut m = HashMap::new();
194
195    m.insert(
196        Interrupt,
197        vec![
198            (KeyCode::Char('c'), KeyModifiers::CONTROL),
199            (KeyCode::Char('C'), KeyModifiers::CONTROL),
200            (KeyCode::Char('\u{3}'), KeyModifiers::empty()),
201        ],
202    );
203    m.insert(
204        Exit,
205        vec![
206            (KeyCode::Char('d'), KeyModifiers::CONTROL),
207            (KeyCode::Char('D'), KeyModifiers::CONTROL),
208        ],
209    );
210    m.insert(
211        BackgroundOperation,
212        vec![
213            (KeyCode::Char('b'), KeyModifiers::CONTROL),
214            (KeyCode::Char('B'), KeyModifiers::CONTROL),
215        ],
216    );
217    m.insert(
218        OpenModelPicker,
219        vec![
220            (KeyCode::Char('m'), KeyModifiers::CONTROL),
221            (KeyCode::Char('M'), KeyModifiers::CONTROL),
222        ],
223    );
224    m.insert(
225        ClearScreen,
226        vec![
227            (KeyCode::Char('l'), KeyModifiers::CONTROL),
228            (KeyCode::Char('L'), KeyModifiers::CONTROL),
229        ],
230    );
231    m.insert(ScrollPageUp, vec![(KeyCode::PageUp, KeyModifiers::empty())]);
232    m.insert(ScrollPageDown, vec![(KeyCode::PageDown, KeyModifiers::empty())]);
233
234    m.insert(EditQueue, vec![(KeyCode::Up, KeyModifiers::ALT), (KeyCode::Up, KeyModifiers::META)]);
235
236    m.insert(HistoryPrevious, vec![(KeyCode::Up, KeyModifiers::empty())]);
237    m.insert(HistoryNext, vec![(KeyCode::Down, KeyModifiers::empty())]);
238
239    m.insert(
240        ToggleToolDisplayMode,
241        vec![
242            (KeyCode::Char('t'), KeyModifiers::ALT),
243            (KeyCode::Char('T'), KeyModifiers::ALT),
244        ],
245    );
246    m.insert(
247        ToggleTaskPanel,
248        vec![
249            (KeyCode::Char('g'), KeyModifiers::ALT),
250            (KeyCode::Char('G'), KeyModifiers::ALT),
251        ],
252    );
253    m.insert(
254        OpenTranscriptReview,
255        vec![
256            (KeyCode::Char('t'), KeyModifiers::CONTROL),
257            (KeyCode::Char('T'), KeyModifiers::CONTROL),
258        ],
259    );
260    m.insert(ToggleTranscriptRenderMode, vec![(KeyCode::Char('r'), KeyModifiers::empty())]);
261    m.insert(
262        GeneratePromptSuggestion,
263        vec![
264            (KeyCode::Char('p'), KeyModifiers::ALT),
265            (KeyCode::Char('P'), KeyModifiers::ALT),
266        ],
267    );
268    m.insert(JumpToLastChange, vec![(KeyCode::End, KeyModifiers::CONTROL)]);
269
270    m
271}
272
273/// Compiled store of key → action mappings, built from defaults + user overrides.
274///
275/// Lookup is O(number of mapped keys) — the total is small (<50 entries) so a
276/// simple linear scan is faster than a nested hash map.
277#[derive(Debug, Clone)]
278pub struct BindingStore {
279    /// Flat list of (key, modifiers) → action for O(n) scan.
280    entries: Vec<(KeyCode, KeyModifiers, Action)>,
281    /// First configured key for each action, used by UI affordances.
282    primary_labels: HashMap<Action, String>,
283    /// Actions whose defaults were explicitly replaced or unbound.
284    overridden_actions: HashSet<Action>,
285}
286
287impl Default for BindingStore {
288    fn default() -> Self {
289        Self::defaults()
290    }
291}
292
293impl BindingStore {
294    /// Build from a user-provided overlay on top of the built-in defaults.
295    ///
296    /// `overlay` is a `HashMap<action_name, Vec<key_spec_string>>` — exactly
297    /// the shape of `KeyBindingConfig::bindings` and
298    /// `UserPreferences::keybindings`.
299    pub(crate) fn new(overlay: HashMap<String, Vec<String>>) -> Self {
300        let mut merged: HashMap<Action, Vec<(KeyCode, KeyModifiers)>> = default_bindings();
301        let mut configured_actions = HashSet::new();
302
303        for (action_name, key_specs) in overlay {
304            let Some(action) = Action::from_name(&action_name) else {
305                tracing::debug!(%action_name, "unknown action in keybinding overlay, skipping");
306                continue;
307            };
308
309            let parsed: Vec<(KeyCode, KeyModifiers)> = key_specs.iter().filter_map(|s| parse_key_binding(s)).collect();
310
311            if key_specs.is_empty() {
312                // Empty list → unbind (remove defaults).
313                merged.remove(&action);
314                configured_actions.insert(action);
315            } else if parsed.is_empty() {
316                tracing::warn!(
317                    action = action.name(),
318                    "keybinding overlay contains no valid bindings; keeping defaults"
319                );
320            } else {
321                merged.insert(action, parsed);
322                configured_actions.insert(action);
323            }
324        }
325
326        let mut entries = Vec::new();
327        // HashMap iteration is intentionally randomized. Keep configured
328        // actions ahead of defaults, but use the stable Action::all order for
329        // both groups so a collision always has the same winner.
330        for action in Action::all()
331            .iter()
332            .copied()
333            .filter(|action| configured_actions.contains(action))
334            .chain(
335                Action::all()
336                    .iter()
337                    .copied()
338                    .filter(|action| !configured_actions.contains(action)),
339            )
340        {
341            let Some(keys) = merged.get(&action) else {
342                continue;
343            };
344            for &(code, mods) in keys {
345                entries.push((code, mods, action));
346            }
347        }
348
349        // Derive labels from the effective entries rather than the requested
350        // lists. If two actions claim the same key, the deterministic entry
351        // order above decides the winner; the losing action must not advertise
352        // a shortcut that can never reach it.
353        let mut primary_labels = HashMap::new();
354        let mut claimed_bindings = Vec::with_capacity(entries.len());
355        for &(code, mods, action) in &entries {
356            let is_reachable = !claimed_bindings
357                .iter()
358                .any(|&(claimed_code, claimed_mods)| binding_covers(claimed_code, claimed_mods, code, mods));
359            if is_reachable {
360                primary_labels.entry(action).or_insert_with(|| format_key_label(code, mods));
361            }
362            claimed_bindings.push((code, mods));
363        }
364
365        Self {
366            entries,
367            primary_labels,
368            overridden_actions: configured_actions,
369        }
370    }
371
372    /// Build with only the default bindings.
373    fn defaults() -> Self {
374        Self::new(HashMap::new())
375    }
376
377    /// Look up the action bound to a given key event.
378    ///
379    /// Returns `None` when the key has no binding (fall through to hardcoded
380    /// dispatch).
381    pub(crate) fn resolve(&self, key: &KeyEvent) -> Option<Action> {
382        // Terminals may report Ctrl+letter as the corresponding C0 control
383        // character without a CONTROL modifier. Normalize that form before
384        // matching so configured bindings behave consistently across terminal
385        // implementations (notably macOS terminal paths for Ctrl+T).
386        let (key_code, key_modifiers) = normalize_terminal_control_key(key.code, key.modifiers);
387        let mut best: Option<(usize, Action)> = None;
388
389        // Iterate entries. For `Char` codes we also try a case-insensitive
390        // match to handle terminal ambiguity (e.g. Ctrl+C vs Ctrl+Shift+C).
391        for (i, &(code, mods, action)) in self.entries.iter().enumerate() {
392            let code_match = match (code, key_code) {
393                (KeyCode::Char(bc), KeyCode::Char(kc)) if bc.eq_ignore_ascii_case(&kc) => true,
394                _ => code == key_code,
395            };
396
397            if !code_match {
398                continue;
399            }
400
401            // All declared modifiers must be present.
402            if !key_modifiers.contains(mods) {
403                continue;
404            }
405
406            // For Char codes, SHIFT is already reflected in character case,
407            // so allow it as an "extra" modifier without penalty.
408            let char_shift_grace = if let KeyCode::Char(_) = key_code {
409                KeyModifiers::SHIFT
410            } else {
411                KeyModifiers::empty()
412            };
413            let extra = key_modifiers.difference(mods);
414            if extra.intersection(!char_shift_grace) != KeyModifiers::empty() {
415                continue;
416            }
417
418            // Prefer earlier entries (user overrides come first, or we use
419            // insertion order and give priority to the first binding).
420            best = match best {
421                None => Some((i, action)),
422                Some((bi, _)) if i < bi => Some((i, action)),
423                Some(other) => Some(other),
424            };
425        }
426
427        best.map(|(_, action)| action)
428    }
429
430    pub(crate) fn primary_key_label(&self, action: Action) -> Option<&str> {
431        self.primary_labels.get(&action).map(String::as_str)
432    }
433
434    pub(crate) fn action_is_overridden(&self, action: Action) -> bool {
435        self.overridden_actions.contains(&action)
436    }
437}
438
439fn binding_covers(
440    existing_code: KeyCode,
441    existing_modifiers: KeyModifiers,
442    candidate_code: KeyCode,
443    candidate_modifiers: KeyModifiers,
444) -> bool {
445    let code_matches = match (existing_code, candidate_code) {
446        (KeyCode::Char(existing), KeyCode::Char(candidate)) => existing.eq_ignore_ascii_case(&candidate),
447        _ => existing_code == candidate_code,
448    };
449    if !code_matches || !candidate_modifiers.contains(existing_modifiers) {
450        return false;
451    }
452
453    let char_shift_grace = if matches!(candidate_code, KeyCode::Char(_)) {
454        KeyModifiers::SHIFT
455    } else {
456        KeyModifiers::empty()
457    };
458    candidate_modifiers
459        .difference(existing_modifiers)
460        .intersection(!char_shift_grace)
461        == KeyModifiers::empty()
462}
463
464pub(crate) fn normalize_terminal_control_event(mut key: KeyEvent) -> KeyEvent {
465    let (code, modifiers) = normalize_terminal_control_key(key.code, key.modifiers);
466    key.code = code;
467    key.modifiers = modifiers;
468    key
469}
470
471/// Window for consecutive double-Escape detection.
472///
473/// First Esc arms the composer timer; a second Esc within this window clears
474/// the current line (multiline) or the entire input (single-line) when content
475/// is present, or opens the rewind picker (`/rewind`) on an empty composer.
476/// Matches the double-Ctrl+C exit window scale (1s) but slightly tighter.
477pub(crate) const DOUBLE_ESCAPE_WINDOW: Duration = Duration::from_millis(800);
478
479/// Whether `now` is a consecutive second Escape press.
480///
481/// Shared by the core and app session key handlers so the two entry points
482/// cannot drift on the window or comparison.
483pub(crate) fn is_double_escape_press(last_press: Option<Instant>, now: Instant) -> bool {
484    last_press.is_some_and(|last| now.duration_since(last) <= DOUBLE_ESCAPE_WINDOW)
485}
486
487/// Return whether a key belongs to the composer shortcuts that intentionally
488/// remain outside the configurable action dispatch.
489pub(crate) fn is_readline_editing_key(key: &KeyEvent) -> bool {
490    let modifiers = key.modifiers;
491    let has_control = modifiers.contains(KeyModifiers::CONTROL);
492    let has_alt = modifiers.contains(KeyModifiers::ALT);
493    let has_command = modifiers.intersects(KeyModifiers::SUPER | KeyModifiers::META);
494
495    if has_command {
496        return false;
497    }
498
499    (has_control
500        && !has_alt
501        && matches!(
502            key.code,
503            KeyCode::Char('f')
504                | KeyCode::Char('F')
505                | KeyCode::Char('p')
506                | KeyCode::Char('P')
507                | KeyCode::Char('n')
508                | KeyCode::Char('N')
509                | KeyCode::Char('t')
510                | KeyCode::Char('T')
511        ))
512        || (has_alt
513            && !has_control
514            && matches!(
515                key.code,
516                KeyCode::Char('d')
517                    | KeyCode::Char('D')
518                    | KeyCode::Char('u')
519                    | KeyCode::Char('U')
520                    | KeyCode::Char('l')
521                    | KeyCode::Char('L')
522                    | KeyCode::Char('c')
523                    | KeyCode::Char('C')
524                    | KeyCode::Char('\\')
525            ))
526}
527
528fn normalize_terminal_control_key(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
529    let Some(control) = (match code {
530        KeyCode::Char(character) => Some(character as u32),
531        _ => None,
532    }) else {
533        return (code, modifiers);
534    };
535
536    // Preserve the terminal's dedicated Tab and Enter paths. In particular,
537    // treating C0 tab/return bytes as Ctrl+I/Ctrl+M would bypass multiline
538    // input and submit handling on terminals that encode those keys directly.
539    if !(1..=26).contains(&control) || matches!(control, 9 | 13) {
540        return (code, modifiers);
541    }
542
543    let Some(letter) = char::from_u32(u32::from(b'a') + control - 1) else {
544        return (code, modifiers);
545    };
546    (KeyCode::Char(letter), modifiers | KeyModifiers::CONTROL)
547}
548
549fn format_key_label(code: KeyCode, modifiers: KeyModifiers) -> String {
550    let mut parts = Vec::with_capacity(5);
551    if modifiers.contains(KeyModifiers::CONTROL) {
552        parts.push("Ctrl");
553    }
554    if modifiers.contains(KeyModifiers::ALT) {
555        parts.push("Alt");
556    }
557    if modifiers.contains(KeyModifiers::SUPER) {
558        parts.push("Cmd");
559    } else if modifiers.contains(KeyModifiers::META) {
560        parts.push("Meta");
561    }
562    if modifiers.contains(KeyModifiers::SHIFT) {
563        parts.push("Shift");
564    }
565
566    let key = match code {
567        KeyCode::Char(' ') => "Space".to_string(),
568        KeyCode::Char(character) => character.to_ascii_uppercase().to_string(),
569        KeyCode::Enter => "Enter".to_string(),
570        KeyCode::Tab => "Tab".to_string(),
571        KeyCode::BackTab => "Backtab".to_string(),
572        KeyCode::Esc => "Esc".to_string(),
573        KeyCode::Backspace => "Backspace".to_string(),
574        KeyCode::Delete => "Delete".to_string(),
575        KeyCode::Insert => "Insert".to_string(),
576        KeyCode::Up => "Up".to_string(),
577        KeyCode::Down => "Down".to_string(),
578        KeyCode::Left => "Left".to_string(),
579        KeyCode::Right => "Right".to_string(),
580        KeyCode::PageUp => "PageUp".to_string(),
581        KeyCode::PageDown => "PageDown".to_string(),
582        KeyCode::Home => "Home".to_string(),
583        KeyCode::End => "End".to_string(),
584        KeyCode::F(number) => format!("F{number}"),
585        other => format!("{other:?}"),
586    };
587    parts.push(key.as_str());
588    parts.join("+")
589}
590
591impl super::Session {
592    pub(crate) fn resolve_rebindable_action(&self, key: &KeyEvent) -> Option<Action> {
593        self.bindings.resolve(key)
594    }
595
596    pub(crate) fn dispatch_rebindable_action(&mut self, action: Action) -> Option<super::super::types::InlineEvent> {
597        super::events::dispatch_rebindable_action(self, action)
598    }
599
600    pub(crate) fn primary_binding_label(&self, action: Action) -> Option<&str> {
601        self.bindings.primary_key_label(action)
602    }
603
604    pub(crate) fn rebindable_action_is_overridden(&self, action: Action) -> bool {
605        self.bindings.action_is_overridden(action)
606    }
607
608    pub(crate) fn set_bindings(&mut self, bindings: BindingStore) {
609        self.bindings = bindings;
610        self.mark_visual_dirty();
611    }
612
613    pub(crate) fn transcript_review_hints_visible(&self) -> bool {
614        self.appearance.show_transcript_review_hints
615    }
616
617    pub(crate) fn transcript_review_shortcut_guide_visible(&self) -> bool {
618        self.appearance.show_transcript_review_shortcut_guide
619    }
620
621    pub(crate) fn transcript_review_close_button_visible(&self) -> bool {
622        self.appearance.show_transcript_review_close_button
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use ratatui::crossterm::event::KeyEvent;
630
631    #[test]
632    fn test_parse_key_binding_simple() {
633        let (code, mods) = parse_key_binding("ctrl+c").unwrap();
634        assert_eq!(code, KeyCode::Char('c'));
635        assert!(mods.contains(KeyModifiers::CONTROL));
636        assert!(!mods.contains(KeyModifiers::SHIFT));
637    }
638
639    #[test]
640    fn test_parse_key_binding_modifier_combos() {
641        let (code, mods) = parse_key_binding("ctrl+shift+enter").unwrap();
642        assert_eq!(code, KeyCode::Enter);
643        assert!(mods.contains(KeyModifiers::CONTROL));
644        assert!(mods.contains(KeyModifiers::SHIFT));
645    }
646
647    #[test]
648    fn test_parse_key_binding_is_case_insensitive() {
649        let (code, mods) = parse_key_binding("CTRL+SHIFT+X").unwrap();
650        assert_eq!(code, KeyCode::Char('x'));
651        assert!(mods.contains(KeyModifiers::CONTROL | KeyModifiers::SHIFT));
652    }
653
654    #[test]
655    fn test_parse_key_binding_func_keys() {
656        let (code, _) = parse_key_binding("f5").unwrap();
657        assert_eq!(code, KeyCode::F(5));
658    }
659
660    #[test]
661    fn test_parse_key_binding_special() {
662        let (code, _) = parse_key_binding("pageup").unwrap();
663        assert_eq!(code, KeyCode::PageUp);
664        let (code, _) = parse_key_binding("backtab").unwrap();
665        assert_eq!(code, KeyCode::BackTab);
666    }
667
668    #[test]
669    fn test_default_bindings_resolve() {
670        let store = BindingStore::defaults();
671
672        // Ctrl+C → Interrupt
673        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
674        assert_eq!(store.resolve(&key), Some(Action::Interrupt));
675
676        // PageUp → ScrollPageUp
677        let key = KeyEvent::new(KeyCode::PageUp, KeyModifiers::empty());
678        assert_eq!(store.resolve(&key), Some(Action::ScrollPageUp));
679
680        // Alt+Up → EditQueue
681        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::ALT);
682        assert_eq!(store.resolve(&key), Some(Action::EditQueue));
683
684        // Unbound → None
685        let key = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
686        assert_eq!(store.resolve(&key), None);
687    }
688
689    #[test]
690    fn test_default_bindings_case_insensitive() {
691        let store = BindingStore::defaults();
692
693        // Ctrl+C (uppercase C) → Interrupt
694        let key = KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL);
695        assert_eq!(store.resolve(&key), Some(Action::Interrupt));
696    }
697
698    #[test]
699    fn test_default_tool_display_binding_is_alt_t() {
700        let store = BindingStore::defaults();
701        let key = KeyEvent::new(KeyCode::Char('t'), KeyModifiers::ALT);
702        assert_eq!(store.resolve(&key), Some(Action::ToggleToolDisplayMode));
703    }
704
705    #[test]
706    fn test_default_task_panel_binding_is_alt_g() {
707        let store = BindingStore::defaults();
708        let key = KeyEvent::new(KeyCode::Char('g'), KeyModifiers::ALT);
709        assert_eq!(store.resolve(&key), Some(Action::ToggleTaskPanel));
710    }
711
712    #[test]
713    fn test_default_transcript_review_bindings() {
714        let store = BindingStore::defaults();
715        assert_eq!(
716            store.resolve(&KeyEvent::new(KeyCode::Char('t'), KeyModifiers::CONTROL)),
717            Some(Action::OpenTranscriptReview)
718        );
719        assert_eq!(
720            store.resolve(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty())),
721            Some(Action::ToggleTranscriptRenderMode)
722        );
723    }
724
725    #[test]
726    fn test_raw_control_character_matches_transcript_review_binding() {
727        let store = BindingStore::defaults();
728
729        assert_eq!(
730            store.resolve(&KeyEvent::new(KeyCode::Char('\u{14}'), KeyModifiers::empty())),
731            Some(Action::OpenTranscriptReview)
732        );
733    }
734
735    #[test]
736    fn test_transcript_review_bindings_can_be_rebound() {
737        let mut overlay = HashMap::new();
738        overlay.insert("open_transcript_review".to_string(), vec!["ctrl+x".to_string()]);
739        overlay.insert("toggle_transcript_render_mode".to_string(), vec!["alt+x".to_string()]);
740        let store = BindingStore::new(overlay);
741
742        assert_eq!(
743            store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
744            Some(Action::OpenTranscriptReview)
745        );
746        assert_eq!(
747            store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::ALT)),
748            Some(Action::ToggleTranscriptRenderMode)
749        );
750        assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::empty())), None);
751    }
752
753    #[test]
754    fn test_task_panel_binding_can_be_rebound() {
755        let mut overlay = HashMap::new();
756        overlay.insert("toggle_task_panel".to_string(), vec!["ctrl+x".to_string()]);
757        let store = BindingStore::new(overlay);
758
759        assert_eq!(
760            store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
761            Some(Action::ToggleTaskPanel)
762        );
763        assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('g'), KeyModifiers::ALT)), None);
764    }
765
766    #[test]
767    fn test_tool_display_binding_can_be_rebound() {
768        let mut overlay = HashMap::new();
769        overlay.insert("toggle_tool_display_mode".to_string(), vec!["ctrl+x".to_string()]);
770        let store = BindingStore::new(overlay);
771
772        assert_eq!(
773            store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)),
774            Some(Action::ToggleToolDisplayMode)
775        );
776        assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('t'), KeyModifiers::ALT)), None);
777    }
778
779    #[test]
780    fn test_user_overlay_overrides_default() {
781        let mut overlay = HashMap::new();
782        overlay.insert("interrupt".to_string(), vec!["ctrl+x".to_string()]);
783        let store = BindingStore::new(overlay);
784
785        // Old binding no longer works
786        let key_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
787        assert_eq!(store.resolve(&key_c), None);
788
789        // New binding works
790        let key_x = KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
791        assert_eq!(store.resolve(&key_x), Some(Action::Interrupt));
792    }
793
794    #[test]
795    fn test_user_overlay_unbind() {
796        let mut overlay = HashMap::new();
797        overlay.insert("interrupt".to_string(), Vec::new());
798        let store = BindingStore::new(overlay);
799
800        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
801        assert_eq!(store.resolve(&key), None);
802    }
803
804    #[test]
805    fn test_invalid_overlay_keeps_defaults_and_does_not_override_action() {
806        let mut overlay = HashMap::new();
807        overlay.insert("interrupt".to_string(), vec!["not-a-key".to_string()]);
808        let store = BindingStore::new(overlay);
809
810        assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)), Some(Action::Interrupt));
811        assert!(!store.action_is_overridden(Action::Interrupt));
812    }
813
814    #[test]
815    fn test_configured_binding_collision_is_deterministic() {
816        let mut overlay = HashMap::new();
817        overlay.insert("interrupt".to_string(), vec!["ctrl+x".to_string()]);
818        overlay.insert("open_model_picker".to_string(), vec!["ctrl+x".to_string()]);
819        let store = BindingStore::new(overlay);
820
821        assert_eq!(store.resolve(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL)), Some(Action::Interrupt));
822        assert_eq!(store.primary_key_label(Action::Interrupt), Some("Ctrl+X"));
823        assert_eq!(store.primary_key_label(Action::OpenModelPicker), None);
824    }
825
826    #[test]
827    fn test_parse_invalid_key() {
828        assert!(parse_key_binding("").is_none());
829        assert!(parse_key_binding("invalid_key_name").is_none());
830        assert!(parse_key_binding("ctrl+invalid").is_none());
831        assert!(parse_key_binding("+ctrl+c").is_none());
832    }
833
834    #[test]
835    fn test_action_name_roundtrip() {
836        for action in Action::all() {
837            let name = action.name();
838            let parsed = Action::from_name(name);
839            assert_eq!(parsed, Some(*action));
840        }
841    }
842
843    #[test]
844    fn test_action_from_name_unknown() {
845        assert_eq!(Action::from_name("nonexistent"), None);
846    }
847
848    #[test]
849    fn double_escape_press_requires_an_armed_consecutive_press() {
850        let now = Instant::now();
851        let exactly_at_window = now.checked_sub(DOUBLE_ESCAPE_WINDOW).expect("clock is past the window");
852        let beyond_window = now
853            .checked_sub(DOUBLE_ESCAPE_WINDOW + Duration::from_millis(1))
854            .expect("clock is past the window");
855
856        assert!(!is_double_escape_press(None, now));
857        assert!(is_double_escape_press(Some(now), now));
858        assert!(is_double_escape_press(Some(exactly_at_window), now));
859        assert!(!is_double_escape_press(Some(beyond_window), now));
860    }
861}