//! Runtime keymap resolution for the TUI. //! //! This module converts deserialized config (`TuiKeymap`) into a concrete //! `RuntimeKeymap` used by input handlers at runtime. //! //! Key responsibilities: //! //! 1. Apply deterministic precedence (`context -> global fallback -> defaults`). //! 2. Parse canonical key spec strings into `KeyBinding` values. //! 3. Enforce uniqueness across runtime surfaces so one key cannot trigger //! multiple actions on the same focused input path. //! 4. Return actionable, user-facing error messages with config paths and next //! steps. //! //! Non-responsibilities: //! //! 1. This module does not decide which action should run in a given screen. //! Callers resolve actions by checking the relevant action binding set. //! 2. This module does not persist configuration; it only resolves loaded config. use crate::key_hint; use crate::key_hint::KeyBinding; use codex_config::types::KeybindingsSpec; use codex_config::types::MAX_FUNCTION_KEY; use codex_config::types::TuiKeymap; use crossterm::event::KeyCode; use crossterm::event::KeyModifiers; use serde::Serialize; use std::collections::HashMap; /// Runtime keymap used by TUI input handlers. /// /// Resolution precedence is: /// /// 1. Context-specific binding (`tui.keymap.`). /// 2. `tui.keymap.global` for actions that support global fallback. /// 3. Built-in defaults. /// /// This is the only shape UI code should use for dispatch. It represents a /// fully resolved snapshot with parsing, fallback, explicit unbinding, and /// duplicate-key validation already applied. If a caller keeps using an older /// snapshot after config changes, visible hints and active handlers can drift. #[derive(Clone, Debug)] pub(crate) struct RuntimeKeymap { pub(crate) app: AppKeymap, pub(crate) chat: ChatKeymap, pub(crate) composer: ComposerKeymap, pub(crate) editor: EditorKeymap, pub(crate) vim_normal: VimNormalKeymap, pub(crate) vim_operator: VimOperatorKeymap, pub(crate) vim_text_object: VimTextObjectKeymap, pub(crate) pager: PagerKeymap, pub(crate) list: ListKeymap, pub(crate) approval: ApprovalKeymap, } #[derive(Clone, Debug)] pub(crate) struct AppKeymap { /// Open transcript overlay. pub(crate) open_transcript: Vec, /// Open external editor for the current draft. pub(crate) open_external_editor: Vec, /// Copy the last agent response to the clipboard. pub(crate) copy: Vec, /// Clear the terminal UI. pub(crate) clear_terminal: Vec, /// Toggle Vim mode for the composer input. pub(crate) toggle_vim_mode: Vec, /// Toggle Fast mode. pub(crate) toggle_fast_mode: Vec, /// Toggle raw scrollback mode for copy-friendly transcript selection. pub(crate) toggle_raw_output: Vec, } /// Chat-level keybindings evaluated at the app event layer. /// /// These participate in the first app-scope conflict validation pass alongside /// `AppKeymap` actions because both are checked before input reaches the /// composer. Dispatch gating (empty-composer guard for backtrack) happens in /// handler code, not here. #[derive(Clone, Debug)] pub(crate) struct ChatKeymap { /// Interrupt the active turn. pub(crate) interrupt_turn: Vec, /// Decrease the active reasoning effort. pub(crate) decrease_reasoning_effort: Vec, /// Increase the active reasoning effort. pub(crate) increase_reasoning_effort: Vec, /// Edit the most recently queued message. pub(crate) edit_queued_message: Vec, } /// Composer-level keybindings validated in the second app-scope conflict pass. /// /// App-level handlers execute before the composer receives input, so any key /// bound here that also appears in `AppKeymap` would be silently intercepted. /// The conflict validator prevents this by checking app + composer uniqueness. #[derive(Clone, Debug)] pub(crate) struct ComposerKeymap { /// Submit current draft. pub(crate) submit: Vec, /// Queue current draft while a task is running. pub(crate) queue: Vec, /// Toggle composer shortcut overlay. pub(crate) toggle_shortcuts: Vec, /// Open reverse history search or move to the previous match. pub(crate) history_search_previous: Vec, /// Move to the next match in reverse history search. pub(crate) history_search_next: Vec, } /// Editor-specific keybindings used by the composer textarea. /// /// These bindings are interpreted only by text-editing widgets and do not /// participate in global/chat fallback resolution. #[derive(Clone, Debug)] pub(crate) struct EditorKeymap { pub(crate) insert_newline: Vec, pub(crate) move_left: Vec, pub(crate) move_right: Vec, pub(crate) move_up: Vec, pub(crate) move_down: Vec, pub(crate) move_word_left: Vec, pub(crate) move_word_right: Vec, pub(crate) move_line_start: Vec, pub(crate) move_line_end: Vec, pub(crate) delete_backward: Vec, pub(crate) delete_forward: Vec, pub(crate) delete_backward_word: Vec, pub(crate) delete_forward_word: Vec, pub(crate) kill_line_start: Vec, pub(crate) kill_whole_line: Vec, pub(crate) kill_line_end: Vec, pub(crate) yank: Vec, } /// Vim normal-mode keybindings for modal editing in the composer textarea. /// /// Normal mode is the resting state when Vim is enabled. Pressing a movement /// or editing key here either moves the cursor, triggers an operator-pending /// state (via `start_delete_operator` / `start_yank_operator`), or transitions /// to insert mode. Default bindings include both `shift(letter)` and /// `plain(UPPERCASE)` variants for uppercase commands like `A`, `I`, `O` to /// handle cross-terminal shift-reporting inconsistencies. #[derive(Clone, Debug, Default)] pub(crate) struct VimNormalKeymap { pub(crate) enter_insert: Vec, pub(crate) append_after_cursor: Vec, pub(crate) append_line_end: Vec, pub(crate) insert_line_start: Vec, pub(crate) open_line_below: Vec, pub(crate) open_line_above: Vec, pub(crate) move_left: Vec, pub(crate) move_right: Vec, pub(crate) move_up: Vec, pub(crate) move_down: Vec, pub(crate) move_word_forward: Vec, pub(crate) move_word_backward: Vec, pub(crate) move_word_end: Vec, pub(crate) move_line_start: Vec, pub(crate) move_line_end: Vec, pub(crate) delete_char: Vec, pub(crate) substitute_char: Vec, pub(crate) delete_to_line_end: Vec, pub(crate) change_to_line_end: Vec, pub(crate) yank_line: Vec, pub(crate) paste_after: Vec, pub(crate) start_delete_operator: Vec, pub(crate) start_yank_operator: Vec, pub(crate) start_change_operator: Vec, pub(crate) cancel_operator: Vec, } /// Vim operator-pending keybindings active after `d` or `y` in normal mode. /// /// When an operator (`start_delete_operator` or `start_yank_operator`) is /// pressed, the next keypress is matched against this context to determine the /// motion range. Repeating the operator key (`dd`, `yy`) acts on the whole /// line. `Esc` cancels the pending operator and returns to normal mode. #[derive(Clone, Debug, Default)] pub(crate) struct VimOperatorKeymap { pub(crate) delete_line: Vec, pub(crate) yank_line: Vec, pub(crate) motion_left: Vec, pub(crate) motion_right: Vec, pub(crate) motion_up: Vec, pub(crate) motion_down: Vec, pub(crate) motion_word_forward: Vec, pub(crate) motion_word_backward: Vec, pub(crate) motion_word_end: Vec, pub(crate) motion_line_start: Vec, pub(crate) motion_line_end: Vec, pub(crate) select_inner_text_object: Vec, pub(crate) select_around_text_object: Vec, pub(crate) cancel: Vec, } /// Vim text-object keybindings active after an operator plus inner/around prefix. #[derive(Clone, Debug, Default)] pub(crate) struct VimTextObjectKeymap { pub(crate) word: Vec, pub(crate) big_word: Vec, pub(crate) parentheses: Vec, pub(crate) brackets: Vec, pub(crate) braces: Vec, pub(crate) double_quote: Vec, pub(crate) single_quote: Vec, pub(crate) backtick: Vec, pub(crate) cancel: Vec, } /// Pager/overlay keybindings for transcript and static help views. #[derive(Clone, Debug)] pub(crate) struct PagerKeymap { pub(crate) scroll_up: Vec, pub(crate) scroll_down: Vec, pub(crate) page_up: Vec, pub(crate) page_down: Vec, pub(crate) half_page_up: Vec, pub(crate) half_page_down: Vec, pub(crate) jump_top: Vec, pub(crate) jump_bottom: Vec, pub(crate) close: Vec, pub(crate) close_transcript: Vec, } /// Generic list picker keybindings shared across popup list views. /// /// These actions describe list intent rather than a specific widget layout. /// Vertical actions move the highlighted row, page and jump actions move within /// the current filtered row set, and horizontal actions are available to views /// that expose adjacent choices such as tabs, toolbar values, or ordered item /// movement. Views that also accept search text are responsible for checking /// `is_plain_text_key_event` before dispatching plain-character bindings so a /// configured `j`, `k`, `h`, or `l` does not steal query input. #[derive(Clone, Debug)] pub(crate) struct ListKeymap { pub(crate) move_up: Vec, pub(crate) move_down: Vec, pub(crate) move_left: Vec, pub(crate) move_right: Vec, pub(crate) page_up: Vec, pub(crate) page_down: Vec, pub(crate) jump_top: Vec, pub(crate) jump_bottom: Vec, pub(crate) accept: Vec, pub(crate) cancel: Vec, } /// Approval modal keybindings. /// /// This covers both selection actions and the "open details fullscreen" escape /// hatch for large approval payloads. #[derive(Clone, Debug)] pub(crate) struct ApprovalKeymap { pub(crate) open_fullscreen: Vec, pub(crate) open_thread: Vec, pub(crate) approve: Vec, pub(crate) approve_for_session: Vec, pub(crate) approve_for_prefix: Vec, pub(crate) deny: Vec, pub(crate) decline: Vec, pub(crate) cancel: Vec, } /// Returns the first binding, used as the primary UI hint for an action. /// /// Rendering code should prefer this for concise hints while preserving all /// bindings for actual input matching. pub(crate) fn primary_binding(bindings: &[KeyBinding]) -> Option { bindings.first().copied() } /// Resolve one context-local action binding from config. /// /// Expands to `resolve_bindings(...)` with: /// - configured source: `tui.keymap..` /// - fallback source: the same action from built-in defaults /// - error path: a stable string path for user-facing diagnostics /// /// This keeps the resolution table concise while guaranteeing path strings /// stay in sync with field names. macro_rules! resolve_local { ($keymap:expr, $defaults:expr, $context:ident, $action:ident) => { resolve_bindings( ($keymap).$context.$action.as_ref(), &($defaults).$context.$action, concat!( "tui.keymap.", stringify!($context), ".", stringify!($action) ), )? }; } /// Resolve one action binding with global fallback. /// /// Expands to `resolve_bindings_with_global_fallback(...)` with precedence: /// 1. `tui.keymap..` /// 2. `tui.keymap.global.` /// 3. built-in defaults for `.` /// /// Used only for actions that intentionally support global reuse. /// Context-local empty lists still count as configured values, so they unbind /// the action instead of falling back to `global`. macro_rules! resolve_with_global { ($keymap:expr, $defaults:expr, $context:ident, $action:ident) => { resolve_bindings_with_global_fallback( ($keymap).$context.$action.as_ref(), ($keymap).global.$action.as_ref(), &($defaults).$context.$action, concat!( "tui.keymap.", stringify!($context), ".", stringify!($action) ), )? }; } /// Expand one default-table binding entry into a [`KeyBinding`]. /// /// This is a small declarative layer over `key_hint::{plain, ctrl, alt, shift}` /// used by `default_bindings!` so `built_in_defaults` stays readable. /// /// Supported forms: /// - `plain()` /// - `ctrl()` /// - `alt()` /// - `shift()` /// - `raw()` for bindings that do not match the helpers /// (for example combined modifiers like Ctrl+Shift). macro_rules! default_binding { (plain($key:expr)) => { key_hint::plain($key) }; (ctrl($key:expr)) => { key_hint::ctrl($key) }; (alt($key:expr)) => { key_hint::alt($key) }; (shift($key:expr)) => { key_hint::shift($key) }; (raw($binding:expr)) => { $binding }; } /// Build a `Vec` for built-in defaults. /// /// This macro is intentionally scoped to built-in keymaps. Runtime /// config parsing still goes through `parse_bindings(...)` so user errors can /// be reported with config-path-aware diagnostics. macro_rules! default_bindings { ($($kind:ident($($arg:tt)*)),* $(,)?) => { vec![$(default_binding!($kind($($arg)*))),*] }; } impl RuntimeKeymap { /// Return built-in defaults. /// /// This is a convenience for tests and bootstrapping UI state before user /// config has been loaded. It should not be used as a fallback after /// parsing `TuiKeymap`, because doing so would ignore explicit user /// unbindings and conflict diagnostics. pub(crate) fn defaults() -> Self { Self::built_in_defaults() } /// Resolve a runtime keymap from config, applying precedence and validation. /// /// Returns an error when: /// /// 1. A keybinding spec cannot be parsed. /// 2. A context has ambiguous bindings (same key assigned to multiple actions). /// /// The error text includes the relevant config path and a concrete next step. /// Calling code should not merge bindings across unrelated contexts before /// dispatch, or conflict guarantees from this resolver no longer hold. pub(crate) fn from_config(keymap: &TuiKeymap) -> Result { let defaults = Self::built_in_defaults(); let app = AppKeymap { open_transcript: resolve_bindings( keymap.global.open_transcript.as_ref(), &defaults.app.open_transcript, "tui.keymap.global.open_transcript", )?, open_external_editor: resolve_bindings( keymap.global.open_external_editor.as_ref(), &defaults.app.open_external_editor, "tui.keymap.global.open_external_editor", )?, copy: resolve_bindings( keymap.global.copy.as_ref(), &defaults.app.copy, "tui.keymap.global.copy", )?, clear_terminal: resolve_bindings( keymap.global.clear_terminal.as_ref(), &defaults.app.clear_terminal, "tui.keymap.global.clear_terminal", )?, toggle_vim_mode: resolve_bindings( keymap.global.toggle_vim_mode.as_ref(), &defaults.app.toggle_vim_mode, "tui.keymap.global.toggle_vim_mode", )?, toggle_fast_mode: resolve_bindings( keymap.global.toggle_fast_mode.as_ref(), &defaults.app.toggle_fast_mode, "tui.keymap.global.toggle_fast_mode", )?, toggle_raw_output: resolve_bindings( keymap.global.toggle_raw_output.as_ref(), &defaults.app.toggle_raw_output, "tui.keymap.global.toggle_raw_output", )?, }; let mut chat = ChatKeymap { interrupt_turn: resolve_bindings( keymap.chat.interrupt_turn.as_ref(), &defaults.chat.interrupt_turn, "tui.keymap.chat.interrupt_turn", )?, decrease_reasoning_effort: resolve_bindings( keymap.chat.decrease_reasoning_effort.as_ref(), &defaults.chat.decrease_reasoning_effort, "tui.keymap.chat.decrease_reasoning_effort", )?, increase_reasoning_effort: resolve_bindings( keymap.chat.increase_reasoning_effort.as_ref(), &defaults.chat.increase_reasoning_effort, "tui.keymap.chat.increase_reasoning_effort", )?, edit_queued_message: resolve_bindings( keymap.chat.edit_queued_message.as_ref(), &defaults.chat.edit_queued_message, "tui.keymap.chat.edit_queued_message", )?, }; let composer = ComposerKeymap { submit: resolve_with_global!(keymap, defaults, composer, submit), queue: resolve_with_global!(keymap, defaults, composer, queue), toggle_shortcuts: resolve_with_global!(keymap, defaults, composer, toggle_shortcuts), history_search_previous: resolve_local!( keymap, defaults, composer, history_search_previous ), history_search_next: resolve_local!(keymap, defaults, composer, history_search_next), }; let editor = EditorKeymap { insert_newline: resolve_local!(keymap, defaults, editor, insert_newline), move_left: resolve_local!(keymap, defaults, editor, move_left), move_right: resolve_local!(keymap, defaults, editor, move_right), move_up: resolve_local!(keymap, defaults, editor, move_up), move_down: resolve_local!(keymap, defaults, editor, move_down), move_word_left: resolve_local!(keymap, defaults, editor, move_word_left), move_word_right: resolve_local!(keymap, defaults, editor, move_word_right), move_line_start: resolve_local!(keymap, defaults, editor, move_line_start), move_line_end: resolve_local!(keymap, defaults, editor, move_line_end), delete_backward: resolve_local!(keymap, defaults, editor, delete_backward), delete_forward: resolve_local!(keymap, defaults, editor, delete_forward), delete_backward_word: resolve_local!(keymap, defaults, editor, delete_backward_word), delete_forward_word: resolve_local!(keymap, defaults, editor, delete_forward_word), kill_line_start: resolve_local!(keymap, defaults, editor, kill_line_start), kill_whole_line: resolve_local!(keymap, defaults, editor, kill_whole_line), kill_line_end: resolve_local!(keymap, defaults, editor, kill_line_end), yank: resolve_local!(keymap, defaults, editor, yank), }; let mut vim_normal = VimNormalKeymap { enter_insert: resolve_local!(keymap, defaults, vim_normal, enter_insert), append_after_cursor: resolve_local!(keymap, defaults, vim_normal, append_after_cursor), append_line_end: resolve_local!(keymap, defaults, vim_normal, append_line_end), insert_line_start: resolve_local!(keymap, defaults, vim_normal, insert_line_start), open_line_below: resolve_local!(keymap, defaults, vim_normal, open_line_below), open_line_above: resolve_local!(keymap, defaults, vim_normal, open_line_above), move_left: resolve_local!(keymap, defaults, vim_normal, move_left), move_right: resolve_local!(keymap, defaults, vim_normal, move_right), move_up: resolve_local!(keymap, defaults, vim_normal, move_up), move_down: resolve_local!(keymap, defaults, vim_normal, move_down), move_word_forward: resolve_local!(keymap, defaults, vim_normal, move_word_forward), move_word_backward: resolve_local!(keymap, defaults, vim_normal, move_word_backward), move_word_end: resolve_local!(keymap, defaults, vim_normal, move_word_end), move_line_start: resolve_local!(keymap, defaults, vim_normal, move_line_start), move_line_end: resolve_local!(keymap, defaults, vim_normal, move_line_end), delete_char: resolve_local!(keymap, defaults, vim_normal, delete_char), substitute_char: resolve_local!(keymap, defaults, vim_normal, substitute_char), delete_to_line_end: resolve_local!(keymap, defaults, vim_normal, delete_to_line_end), change_to_line_end: resolve_local!(keymap, defaults, vim_normal, change_to_line_end), yank_line: resolve_local!(keymap, defaults, vim_normal, yank_line), paste_after: resolve_local!(keymap, defaults, vim_normal, paste_after), start_delete_operator: resolve_local!( keymap, defaults, vim_normal, start_delete_operator ), start_yank_operator: resolve_local!(keymap, defaults, vim_normal, start_yank_operator), start_change_operator: resolve_local!( keymap, defaults, vim_normal, start_change_operator ), cancel_operator: resolve_local!(keymap, defaults, vim_normal, cancel_operator), }; let configured_vim_normal_bindings_to_preserve = configured_bindings_to_preserve([ ( keymap.vim_normal.enter_insert.as_ref(), vim_normal.enter_insert.as_slice(), ), ( keymap.vim_normal.append_after_cursor.as_ref(), vim_normal.append_after_cursor.as_slice(), ), ( keymap.vim_normal.append_line_end.as_ref(), vim_normal.append_line_end.as_slice(), ), ( keymap.vim_normal.insert_line_start.as_ref(), vim_normal.insert_line_start.as_slice(), ), ( keymap.vim_normal.open_line_below.as_ref(), vim_normal.open_line_below.as_slice(), ), ( keymap.vim_normal.open_line_above.as_ref(), vim_normal.open_line_above.as_slice(), ), ( keymap.vim_normal.move_left.as_ref(), vim_normal.move_left.as_slice(), ), ( keymap.vim_normal.move_right.as_ref(), vim_normal.move_right.as_slice(), ), ( keymap.vim_normal.move_up.as_ref(), vim_normal.move_up.as_slice(), ), ( keymap.vim_normal.move_down.as_ref(), vim_normal.move_down.as_slice(), ), ( keymap.vim_normal.move_word_forward.as_ref(), vim_normal.move_word_forward.as_slice(), ), ( keymap.vim_normal.move_word_backward.as_ref(), vim_normal.move_word_backward.as_slice(), ), ( keymap.vim_normal.move_word_end.as_ref(), vim_normal.move_word_end.as_slice(), ), ( keymap.vim_normal.move_line_start.as_ref(), vim_normal.move_line_start.as_slice(), ), ( keymap.vim_normal.move_line_end.as_ref(), vim_normal.move_line_end.as_slice(), ), ( keymap.vim_normal.delete_char.as_ref(), vim_normal.delete_char.as_slice(), ), ( keymap.vim_normal.change_to_line_end.as_ref(), vim_normal.change_to_line_end.as_slice(), ), ( keymap.vim_normal.delete_to_line_end.as_ref(), vim_normal.delete_to_line_end.as_slice(), ), ( keymap.vim_normal.yank_line.as_ref(), vim_normal.yank_line.as_slice(), ), ( keymap.vim_normal.paste_after.as_ref(), vim_normal.paste_after.as_slice(), ), ( keymap.vim_normal.start_delete_operator.as_ref(), vim_normal.start_delete_operator.as_slice(), ), ( keymap.vim_normal.start_yank_operator.as_ref(), vim_normal.start_yank_operator.as_slice(), ), ( keymap.vim_normal.start_change_operator.as_ref(), vim_normal.start_change_operator.as_slice(), ), ( keymap.vim_normal.cancel_operator.as_ref(), vim_normal.cancel_operator.as_slice(), ), ]); if keymap.vim_normal.start_change_operator.is_none() { vim_normal .start_change_operator .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } if keymap.vim_normal.substitute_char.is_none() { vim_normal .substitute_char .retain(|binding| !configured_vim_normal_bindings_to_preserve.contains(binding)); } let mut vim_operator = VimOperatorKeymap { delete_line: resolve_local!(keymap, defaults, vim_operator, delete_line), yank_line: resolve_local!(keymap, defaults, vim_operator, yank_line), motion_left: resolve_local!(keymap, defaults, vim_operator, motion_left), motion_right: resolve_local!(keymap, defaults, vim_operator, motion_right), motion_up: resolve_local!(keymap, defaults, vim_operator, motion_up), motion_down: resolve_local!(keymap, defaults, vim_operator, motion_down), motion_word_forward: resolve_local!( keymap, defaults, vim_operator, motion_word_forward ), motion_word_backward: resolve_local!( keymap, defaults, vim_operator, motion_word_backward ), motion_word_end: resolve_local!(keymap, defaults, vim_operator, motion_word_end), motion_line_start: resolve_local!(keymap, defaults, vim_operator, motion_line_start), motion_line_end: resolve_local!(keymap, defaults, vim_operator, motion_line_end), select_inner_text_object: resolve_local!( keymap, defaults, vim_operator, select_inner_text_object ), select_around_text_object: resolve_local!( keymap, defaults, vim_operator, select_around_text_object ), cancel: resolve_local!(keymap, defaults, vim_operator, cancel), }; let configured_vim_operator_bindings_to_preserve = configured_bindings_to_preserve([ ( keymap.vim_operator.delete_line.as_ref(), vim_operator.delete_line.as_slice(), ), ( keymap.vim_operator.yank_line.as_ref(), vim_operator.yank_line.as_slice(), ), ( keymap.vim_operator.motion_left.as_ref(), vim_operator.motion_left.as_slice(), ), ( keymap.vim_operator.motion_right.as_ref(), vim_operator.motion_right.as_slice(), ), ( keymap.vim_operator.motion_up.as_ref(), vim_operator.motion_up.as_slice(), ), ( keymap.vim_operator.motion_down.as_ref(), vim_operator.motion_down.as_slice(), ), ( keymap.vim_operator.motion_word_forward.as_ref(), vim_operator.motion_word_forward.as_slice(), ), ( keymap.vim_operator.motion_word_backward.as_ref(), vim_operator.motion_word_backward.as_slice(), ), ( keymap.vim_operator.motion_word_end.as_ref(), vim_operator.motion_word_end.as_slice(), ), ( keymap.vim_operator.motion_line_start.as_ref(), vim_operator.motion_line_start.as_slice(), ), ( keymap.vim_operator.motion_line_end.as_ref(), vim_operator.motion_line_end.as_slice(), ), ( keymap.vim_operator.cancel.as_ref(), vim_operator.cancel.as_slice(), ), ]); if keymap.vim_operator.select_inner_text_object.is_none() { vim_operator .select_inner_text_object .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); } if keymap.vim_operator.select_around_text_object.is_none() { vim_operator .select_around_text_object .retain(|binding| !configured_vim_operator_bindings_to_preserve.contains(binding)); } let vim_text_object = VimTextObjectKeymap { word: resolve_local!(keymap, defaults, vim_text_object, word), big_word: resolve_local!(keymap, defaults, vim_text_object, big_word), parentheses: resolve_local!(keymap, defaults, vim_text_object, parentheses), brackets: resolve_local!(keymap, defaults, vim_text_object, brackets), braces: resolve_local!(keymap, defaults, vim_text_object, braces), double_quote: resolve_local!(keymap, defaults, vim_text_object, double_quote), single_quote: resolve_local!(keymap, defaults, vim_text_object, single_quote), backtick: resolve_local!(keymap, defaults, vim_text_object, backtick), cancel: resolve_local!(keymap, defaults, vim_text_object, cancel), }; // Reasoning arrow aliases are fallback defaults: existing explicit // bindings on the same input path keep the keys, while explicit // reasoning bindings remain authoritative. if keymap.chat.decrease_reasoning_effort.is_none() && configured_main_surface_alias_is_used(keymap, "shift-down") { chat.decrease_reasoning_effort .retain(|binding| *binding != key_hint::shift(KeyCode::Down)); } if keymap.chat.increase_reasoning_effort.is_none() && configured_main_surface_alias_is_used(keymap, "shift-up") { chat.increase_reasoning_effort .retain(|binding| *binding != key_hint::shift(KeyCode::Up)); } let pager = PagerKeymap { scroll_up: resolve_local!(keymap, defaults, pager, scroll_up), scroll_down: resolve_local!(keymap, defaults, pager, scroll_down), page_up: resolve_local!(keymap, defaults, pager, page_up), page_down: resolve_local!(keymap, defaults, pager, page_down), half_page_up: resolve_local!(keymap, defaults, pager, half_page_up), half_page_down: resolve_local!(keymap, defaults, pager, half_page_down), jump_top: resolve_local!(keymap, defaults, pager, jump_top), jump_bottom: resolve_local!(keymap, defaults, pager, jump_bottom), close: resolve_local!(keymap, defaults, pager, close), close_transcript: resolve_local!(keymap, defaults, pager, close_transcript), }; let approval = ApprovalKeymap { open_fullscreen: resolve_local!(keymap, defaults, approval, open_fullscreen), open_thread: resolve_local!(keymap, defaults, approval, open_thread), approve: resolve_local!(keymap, defaults, approval, approve), approve_for_session: resolve_local!(keymap, defaults, approval, approve_for_session), approve_for_prefix: resolve_local!(keymap, defaults, approval, approve_for_prefix), deny: resolve_local!(keymap, defaults, approval, deny), decline: resolve_local!(keymap, defaults, approval, decline), cancel: resolve_local!(keymap, defaults, approval, cancel), }; let list_move_up = resolve_local!(keymap, defaults, list, move_up); let list_move_down = resolve_local!(keymap, defaults, list, move_down); let list_accept = resolve_local!(keymap, defaults, list, accept); let list_cancel = resolve_local!(keymap, defaults, list, cancel); let configured_bindings_to_preserve = configured_bindings_to_preserve([ ( keymap.global.open_transcript.as_ref(), app.open_transcript.as_slice(), ), ( keymap.global.open_external_editor.as_ref(), app.open_external_editor.as_slice(), ), (keymap.global.copy.as_ref(), app.copy.as_slice()), ( keymap.global.clear_terminal.as_ref(), app.clear_terminal.as_slice(), ), ( keymap.global.toggle_vim_mode.as_ref(), app.toggle_vim_mode.as_slice(), ), ( keymap.global.toggle_fast_mode.as_ref(), app.toggle_fast_mode.as_slice(), ), ( keymap.global.toggle_raw_output.as_ref(), app.toggle_raw_output.as_slice(), ), (keymap.list.move_up.as_ref(), list_move_up.as_slice()), (keymap.list.move_down.as_ref(), list_move_down.as_slice()), (keymap.list.accept.as_ref(), list_accept.as_slice()), (keymap.list.cancel.as_ref(), list_cancel.as_slice()), ( keymap.approval.open_fullscreen.as_ref(), approval.open_fullscreen.as_slice(), ), ( keymap.approval.open_thread.as_ref(), approval.open_thread.as_slice(), ), ( keymap.approval.approve.as_ref(), approval.approve.as_slice(), ), ( keymap.approval.approve_for_session.as_ref(), approval.approve_for_session.as_slice(), ), ( keymap.approval.approve_for_prefix.as_ref(), approval.approve_for_prefix.as_slice(), ), (keymap.approval.deny.as_ref(), approval.deny.as_slice()), ( keymap.approval.decline.as_ref(), approval.decline.as_slice(), ), (keymap.approval.cancel.as_ref(), approval.cancel.as_slice()), ]); let list = ListKeymap { move_up: list_move_up, move_down: list_move_down, move_left: resolve_new_default_bindings( keymap.list.move_left.as_ref(), &defaults.list.move_left, &configured_bindings_to_preserve, "tui.keymap.list.move_left", )?, move_right: resolve_new_default_bindings( keymap.list.move_right.as_ref(), &defaults.list.move_right, &configured_bindings_to_preserve, "tui.keymap.list.move_right", )?, page_up: resolve_new_default_bindings( keymap.list.page_up.as_ref(), &defaults.list.page_up, &configured_bindings_to_preserve, "tui.keymap.list.page_up", )?, page_down: resolve_new_default_bindings( keymap.list.page_down.as_ref(), &defaults.list.page_down, &configured_bindings_to_preserve, "tui.keymap.list.page_down", )?, jump_top: resolve_new_default_bindings( keymap.list.jump_top.as_ref(), &defaults.list.jump_top, &configured_bindings_to_preserve, "tui.keymap.list.jump_top", )?, jump_bottom: resolve_new_default_bindings( keymap.list.jump_bottom.as_ref(), &defaults.list.jump_bottom, &configured_bindings_to_preserve, "tui.keymap.list.jump_bottom", )?, accept: list_accept, cancel: list_cancel, }; let resolved = Self { app, chat, composer, editor, vim_normal, vim_operator, vim_text_object, pager, list, approval, }; resolved.validate_conflicts()?; Ok(resolved) } /// Built-in keymap defaults. /// /// Some actions intentionally include compatibility variants (for example /// both `?` and `shift-?`) because terminals disagree on whether SHIFT is /// preserved for certain printable/control chords. fn built_in_defaults() -> Self { Self { app: AppKeymap { open_transcript: default_bindings![ctrl(KeyCode::Char('t'))], open_external_editor: default_bindings![ctrl(KeyCode::Char('g'))], copy: default_bindings![ctrl(KeyCode::Char('o'))], clear_terminal: default_bindings![ctrl(KeyCode::Char('l'))], toggle_vim_mode: default_bindings![], toggle_fast_mode: default_bindings![], toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))], }, chat: ChatKeymap { interrupt_turn: default_bindings![plain(KeyCode::Esc)], decrease_reasoning_effort: default_bindings![ alt(KeyCode::Char(',')), shift(KeyCode::Down) ], increase_reasoning_effort: default_bindings![ alt(KeyCode::Char('.')), shift(KeyCode::Up) ], edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)], }, composer: ComposerKeymap { submit: default_bindings![plain(KeyCode::Enter)], queue: default_bindings![plain(KeyCode::Tab)], toggle_shortcuts: default_bindings![ plain(KeyCode::Char('?')), shift(KeyCode::Char('?')) ], history_search_previous: default_bindings![ctrl(KeyCode::Char('r'))], history_search_next: default_bindings![ctrl(KeyCode::Char('s'))], }, editor: EditorKeymap { insert_newline: default_bindings![ ctrl(KeyCode::Char('j')), ctrl(KeyCode::Char('m')), plain(KeyCode::Enter), shift(KeyCode::Enter), alt(KeyCode::Enter) ], move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('b'))], move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('f'))], move_up: default_bindings![plain(KeyCode::Up), ctrl(KeyCode::Char('p'))], move_down: default_bindings![plain(KeyCode::Down), ctrl(KeyCode::Char('n'))], move_word_left: default_bindings![ alt(KeyCode::Char('b')), raw(KeyBinding::new(KeyCode::Left, KeyModifiers::ALT)), raw(KeyBinding::new(KeyCode::Left, KeyModifiers::CONTROL)) ], move_word_right: default_bindings![ alt(KeyCode::Char('f')), raw(KeyBinding::new(KeyCode::Right, KeyModifiers::ALT)), raw(KeyBinding::new(KeyCode::Right, KeyModifiers::CONTROL)) ], move_line_start: default_bindings![plain(KeyCode::Home), ctrl(KeyCode::Char('a'))], move_line_end: default_bindings![plain(KeyCode::End), ctrl(KeyCode::Char('e'))], delete_backward: default_bindings![ plain(KeyCode::Backspace), shift(KeyCode::Backspace), ctrl(KeyCode::Char('h')) ], delete_forward: default_bindings![ plain(KeyCode::Delete), shift(KeyCode::Delete), ctrl(KeyCode::Char('d')) ], delete_backward_word: default_bindings![ alt(KeyCode::Backspace), ctrl(KeyCode::Backspace), raw(KeyBinding::new( KeyCode::Backspace, KeyModifiers::CONTROL | KeyModifiers::SHIFT, )), ctrl(KeyCode::Char('w')), raw(KeyBinding::new( KeyCode::Char('h'), KeyModifiers::CONTROL | KeyModifiers::ALT, )) ], delete_forward_word: default_bindings![ alt(KeyCode::Delete), ctrl(KeyCode::Delete), raw(KeyBinding::new( KeyCode::Delete, KeyModifiers::CONTROL | KeyModifiers::SHIFT, )), alt(KeyCode::Char('d')) ], kill_line_start: default_bindings![ctrl(KeyCode::Char('u'))], kill_whole_line: default_bindings![], kill_line_end: default_bindings![ctrl(KeyCode::Char('k'))], yank: default_bindings![ctrl(KeyCode::Char('y'))], }, vim_normal: VimNormalKeymap { enter_insert: default_bindings![plain(KeyCode::Char('i')), plain(KeyCode::Insert)], append_after_cursor: default_bindings![plain(KeyCode::Char('a'))], append_line_end: default_bindings![ shift(KeyCode::Char('a')), plain(KeyCode::Char('A')) ], insert_line_start: default_bindings![ shift(KeyCode::Char('i')), plain(KeyCode::Char('I')) ], open_line_below: default_bindings![plain(KeyCode::Char('o'))], open_line_above: default_bindings![ shift(KeyCode::Char('o')), plain(KeyCode::Char('O')) ], move_left: default_bindings![plain(KeyCode::Char('h')), plain(KeyCode::Left)], move_right: default_bindings![plain(KeyCode::Char('l')), plain(KeyCode::Right)], move_up: default_bindings![plain(KeyCode::Char('k')), plain(KeyCode::Up)], move_down: default_bindings![plain(KeyCode::Char('j')), plain(KeyCode::Down)], move_word_forward: default_bindings![plain(KeyCode::Char('w'))], move_word_backward: default_bindings![plain(KeyCode::Char('b'))], move_word_end: default_bindings![plain(KeyCode::Char('e'))], move_line_start: default_bindings![plain(KeyCode::Char('0'))], move_line_end: default_bindings![ plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], delete_char: default_bindings![plain(KeyCode::Char('x'))], substitute_char: default_bindings![plain(KeyCode::Char('s'))], delete_to_line_end: default_bindings![ shift(KeyCode::Char('d')), plain(KeyCode::Char('D')) ], change_to_line_end: default_bindings![ shift(KeyCode::Char('c')), plain(KeyCode::Char('C')) ], yank_line: default_bindings![shift(KeyCode::Char('y')), plain(KeyCode::Char('Y'))], paste_after: default_bindings![plain(KeyCode::Char('p'))], start_delete_operator: default_bindings![plain(KeyCode::Char('d'))], start_yank_operator: default_bindings![plain(KeyCode::Char('y'))], start_change_operator: default_bindings![plain(KeyCode::Char('c'))], cancel_operator: default_bindings![plain(KeyCode::Esc)], }, vim_operator: VimOperatorKeymap { delete_line: default_bindings![plain(KeyCode::Char('d'))], yank_line: default_bindings![plain(KeyCode::Char('y'))], motion_left: default_bindings![plain(KeyCode::Char('h'))], motion_right: default_bindings![plain(KeyCode::Char('l'))], motion_up: default_bindings![plain(KeyCode::Char('k'))], motion_down: default_bindings![plain(KeyCode::Char('j'))], motion_word_forward: default_bindings![plain(KeyCode::Char('w'))], motion_word_backward: default_bindings![plain(KeyCode::Char('b'))], motion_word_end: default_bindings![plain(KeyCode::Char('e'))], motion_line_start: default_bindings![plain(KeyCode::Char('0'))], motion_line_end: default_bindings![ plain(KeyCode::Char('$')), shift(KeyCode::Char('$')) ], select_inner_text_object: default_bindings![plain(KeyCode::Char('i'))], select_around_text_object: default_bindings![plain(KeyCode::Char('a'))], cancel: default_bindings![plain(KeyCode::Esc)], }, vim_text_object: VimTextObjectKeymap { word: default_bindings![plain(KeyCode::Char('w'))], big_word: default_bindings![shift(KeyCode::Char('w')), plain(KeyCode::Char('W'))], parentheses: default_bindings![ plain(KeyCode::Char('(')), shift(KeyCode::Char('(')), plain(KeyCode::Char(')')), shift(KeyCode::Char(')')), plain(KeyCode::Char('b')) ], brackets: default_bindings![plain(KeyCode::Char('[')), plain(KeyCode::Char(']'))], braces: default_bindings![ plain(KeyCode::Char('{')), shift(KeyCode::Char('{')), plain(KeyCode::Char('}')), shift(KeyCode::Char('}')), shift(KeyCode::Char('b')), plain(KeyCode::Char('B')) ], double_quote: default_bindings![ plain(KeyCode::Char('"')), shift(KeyCode::Char('"')) ], single_quote: default_bindings![plain(KeyCode::Char('\''))], backtick: default_bindings![plain(KeyCode::Char('`'))], cancel: default_bindings![plain(KeyCode::Esc)], }, pager: PagerKeymap { scroll_up: default_bindings![plain(KeyCode::Up), plain(KeyCode::Char('k'))], scroll_down: default_bindings![plain(KeyCode::Down), plain(KeyCode::Char('j'))], page_up: default_bindings![ plain(KeyCode::PageUp), shift(KeyCode::Char(' ')), ctrl(KeyCode::Char('b')) ], page_down: default_bindings![ plain(KeyCode::PageDown), plain(KeyCode::Char(' ')), ctrl(KeyCode::Char('f')) ], half_page_up: default_bindings![ctrl(KeyCode::Char('u'))], half_page_down: default_bindings![ctrl(KeyCode::Char('d'))], jump_top: default_bindings![plain(KeyCode::Home)], jump_bottom: default_bindings![plain(KeyCode::End)], close: default_bindings![plain(KeyCode::Char('q')), ctrl(KeyCode::Char('c'))], close_transcript: default_bindings![ctrl(KeyCode::Char('t'))], }, list: ListKeymap { move_up: default_bindings![ plain(KeyCode::Up), ctrl(KeyCode::Char('p')), ctrl(KeyCode::Char('k')), plain(KeyCode::Char('k')) ], move_down: default_bindings![ plain(KeyCode::Down), ctrl(KeyCode::Char('n')), ctrl(KeyCode::Char('j')), plain(KeyCode::Char('j')) ], move_left: default_bindings![plain(KeyCode::Left), ctrl(KeyCode::Char('h'))], move_right: default_bindings![plain(KeyCode::Right), ctrl(KeyCode::Char('l'))], page_up: default_bindings![plain(KeyCode::PageUp), ctrl(KeyCode::Char('b'))], page_down: default_bindings![plain(KeyCode::PageDown), ctrl(KeyCode::Char('f'))], jump_top: default_bindings![plain(KeyCode::Home)], jump_bottom: default_bindings![plain(KeyCode::End)], accept: default_bindings![plain(KeyCode::Enter)], cancel: default_bindings![plain(KeyCode::Esc)], }, approval: ApprovalKeymap { open_fullscreen: default_bindings![ ctrl(KeyCode::Char('a')), raw(KeyBinding::new( KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, )) ], open_thread: default_bindings![plain(KeyCode::Char('o'))], approve: default_bindings![plain(KeyCode::Char('y'))], approve_for_session: default_bindings![plain(KeyCode::Char('a'))], approve_for_prefix: default_bindings![plain(KeyCode::Char('p'))], deny: default_bindings![plain(KeyCode::Char('d'))], decline: default_bindings![plain(KeyCode::Esc), plain(KeyCode::Char('n'))], cancel: default_bindings![plain(KeyCode::Char('c'))], }, } } /// Reject ambiguous bindings in scopes that are evaluated together. /// /// We validate in multiple passes because runtime handling has mixed /// precedence: /// /// 1. `app` actions can shadow composer actions because app checks run /// before forwarding to the composer. /// 2. Contexts with hard-coded sequence behavior, such as edit-previous /// backtracking, intentionally stay outside this configurable keymap. fn validate_conflicts(&self) -> Result<(), String> { validate_unique( "app", [ ("open_transcript", self.app.open_transcript.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), ), ( "chat.increase_reasoning_effort", self.chat.increase_reasoning_effort.as_slice(), ), ( "chat.edit_queued_message", self.chat.edit_queued_message.as_slice(), ), ("composer.submit", self.composer.submit.as_slice()), ("composer.queue", self.composer.queue.as_slice()), ( "composer.toggle_shortcuts", self.composer.toggle_shortcuts.as_slice(), ), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), ), ( "composer.history_search_next", self.composer.history_search_next.as_slice(), ), ], )?; validate_no_reserved( "main", [ ("open_transcript", self.app.open_transcript.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), ), ( "chat.increase_reasoning_effort", self.chat.increase_reasoning_effort.as_slice(), ), ( "chat.edit_queued_message", self.chat.edit_queued_message.as_slice(), ), ("composer.submit", self.composer.submit.as_slice()), ("composer.queue", self.composer.queue.as_slice()), ( "composer.toggle_shortcuts", self.composer.toggle_shortcuts.as_slice(), ), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), ), ( "composer.history_search_next", self.composer.history_search_next.as_slice(), ), ], MAIN_RESERVED_BINDINGS, [( "chat.interrupt_turn", "fixed.backtrack", key_hint::plain(KeyCode::Esc), )], )?; validate_no_shadow_with_allowed_overlaps( "app", [ ("open_transcript", self.app.open_transcript.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ], [ ("list.move_up", self.list.move_up.as_slice()), ("list.move_down", self.list.move_down.as_slice()), ("list.move_left", self.list.move_left.as_slice()), ("list.move_right", self.list.move_right.as_slice()), ("list.page_up", self.list.page_up.as_slice()), ("list.page_down", self.list.page_down.as_slice()), ("list.jump_top", self.list.jump_top.as_slice()), ("list.jump_bottom", self.list.jump_bottom.as_slice()), ("list.accept", self.list.accept.as_slice()), ("list.cancel", self.list.cancel.as_slice()), ( "approval.open_fullscreen", self.approval.open_fullscreen.as_slice(), ), ("approval.open_thread", self.approval.open_thread.as_slice()), ("approval.approve", self.approval.approve.as_slice()), ( "approval.approve_for_session", self.approval.approve_for_session.as_slice(), ), ( "approval.approve_for_prefix", self.approval.approve_for_prefix.as_slice(), ), ("approval.deny", self.approval.deny.as_slice()), ("approval.decline", self.approval.decline.as_slice()), ("approval.cancel", self.approval.cancel.as_slice()), ], [( "clear_terminal", "list.move_right", key_hint::ctrl(KeyCode::Char('l')), )], )?; // The request-user-input overlay consumes turn interruption before // configurable question navigation reaches its list handler. validate_no_shadow_with_allowed_overlaps( "request_user_input", [("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())], [ ("list.move_left", self.list.move_left.as_slice()), ("list.move_right", self.list.move_right.as_slice()), ], [], )?; // While the composer is focused, these main-surface handlers always // consume matching keys before the event reaches the textarea editor. validate_no_shadow_with_allowed_overlaps( "main", [ ("open_transcript", self.app.open_transcript.as_slice()), ( "open_external_editor", self.app.open_external_editor.as_slice(), ), ("copy", self.app.copy.as_slice()), ("clear_terminal", self.app.clear_terminal.as_slice()), ("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()), ( "chat.decrease_reasoning_effort", self.chat.decrease_reasoning_effort.as_slice(), ), ( "chat.increase_reasoning_effort", self.chat.increase_reasoning_effort.as_slice(), ), ("composer.submit", self.composer.submit.as_slice()), ("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()), ("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()), ("toggle_raw_output", self.app.toggle_raw_output.as_slice()), ( "composer.history_search_previous", self.composer.history_search_previous.as_slice(), ), ], [ ( "editor.insert_newline", self.editor.insert_newline.as_slice(), ), ("editor.move_left", self.editor.move_left.as_slice()), ("editor.move_right", self.editor.move_right.as_slice()), ("editor.move_up", self.editor.move_up.as_slice()), ("editor.move_down", self.editor.move_down.as_slice()), ( "editor.move_word_left", self.editor.move_word_left.as_slice(), ), ( "editor.move_word_right", self.editor.move_word_right.as_slice(), ), ( "editor.move_line_start", self.editor.move_line_start.as_slice(), ), ("editor.move_line_end", self.editor.move_line_end.as_slice()), ( "editor.delete_backward", self.editor.delete_backward.as_slice(), ), ( "editor.delete_forward", self.editor.delete_forward.as_slice(), ), ( "editor.delete_backward_word", self.editor.delete_backward_word.as_slice(), ), ( "editor.delete_forward_word", self.editor.delete_forward_word.as_slice(), ), ( "editor.kill_line_start", self.editor.kill_line_start.as_slice(), ), ( "editor.kill_whole_line", self.editor.kill_whole_line.as_slice(), ), ("editor.kill_line_end", self.editor.kill_line_end.as_slice()), ("editor.yank", self.editor.yank.as_slice()), ], [( "composer.submit", "editor.insert_newline", key_hint::plain(KeyCode::Enter), )], )?; validate_unique( "editor", [ ("insert_newline", self.editor.insert_newline.as_slice()), ("move_left", self.editor.move_left.as_slice()), ("move_right", self.editor.move_right.as_slice()), ("move_up", self.editor.move_up.as_slice()), ("move_down", self.editor.move_down.as_slice()), ("move_word_left", self.editor.move_word_left.as_slice()), ("move_word_right", self.editor.move_word_right.as_slice()), ("move_line_start", self.editor.move_line_start.as_slice()), ("move_line_end", self.editor.move_line_end.as_slice()), ("delete_backward", self.editor.delete_backward.as_slice()), ("delete_forward", self.editor.delete_forward.as_slice()), ( "delete_backward_word", self.editor.delete_backward_word.as_slice(), ), ( "delete_forward_word", self.editor.delete_forward_word.as_slice(), ), ("kill_line_start", self.editor.kill_line_start.as_slice()), ("kill_whole_line", self.editor.kill_whole_line.as_slice()), ("kill_line_end", self.editor.kill_line_end.as_slice()), ("yank", self.editor.yank.as_slice()), ], )?; validate_unique( "vim_normal", [ ("enter_insert", self.vim_normal.enter_insert.as_slice()), ( "append_after_cursor", self.vim_normal.append_after_cursor.as_slice(), ), ( "append_line_end", self.vim_normal.append_line_end.as_slice(), ), ( "insert_line_start", self.vim_normal.insert_line_start.as_slice(), ), ( "open_line_below", self.vim_normal.open_line_below.as_slice(), ), ( "open_line_above", self.vim_normal.open_line_above.as_slice(), ), ("move_left", self.vim_normal.move_left.as_slice()), ("move_right", self.vim_normal.move_right.as_slice()), ("move_up", self.vim_normal.move_up.as_slice()), ("move_down", self.vim_normal.move_down.as_slice()), ( "move_word_forward", self.vim_normal.move_word_forward.as_slice(), ), ( "move_word_backward", self.vim_normal.move_word_backward.as_slice(), ), ("move_word_end", self.vim_normal.move_word_end.as_slice()), ( "move_line_start", self.vim_normal.move_line_start.as_slice(), ), ("move_line_end", self.vim_normal.move_line_end.as_slice()), ("delete_char", self.vim_normal.delete_char.as_slice()), ( "substitute_char", self.vim_normal.substitute_char.as_slice(), ), ( "delete_to_line_end", self.vim_normal.delete_to_line_end.as_slice(), ), ( "change_to_line_end", self.vim_normal.change_to_line_end.as_slice(), ), ("yank_line", self.vim_normal.yank_line.as_slice()), ("paste_after", self.vim_normal.paste_after.as_slice()), ( "start_delete_operator", self.vim_normal.start_delete_operator.as_slice(), ), ( "start_yank_operator", self.vim_normal.start_yank_operator.as_slice(), ), ( "start_change_operator", self.vim_normal.start_change_operator.as_slice(), ), ( "cancel_operator", self.vim_normal.cancel_operator.as_slice(), ), ], )?; validate_unique( "vim_operator", [ ("delete_line", self.vim_operator.delete_line.as_slice()), ("yank_line", self.vim_operator.yank_line.as_slice()), ("motion_left", self.vim_operator.motion_left.as_slice()), ("motion_right", self.vim_operator.motion_right.as_slice()), ("motion_up", self.vim_operator.motion_up.as_slice()), ("motion_down", self.vim_operator.motion_down.as_slice()), ( "motion_word_forward", self.vim_operator.motion_word_forward.as_slice(), ), ( "motion_word_backward", self.vim_operator.motion_word_backward.as_slice(), ), ( "motion_word_end", self.vim_operator.motion_word_end.as_slice(), ), ( "motion_line_start", self.vim_operator.motion_line_start.as_slice(), ), ( "motion_line_end", self.vim_operator.motion_line_end.as_slice(), ), ( "select_inner_text_object", self.vim_operator.select_inner_text_object.as_slice(), ), ( "select_around_text_object", self.vim_operator.select_around_text_object.as_slice(), ), ("cancel", self.vim_operator.cancel.as_slice()), ], )?; validate_unique( "vim_text_object", [ ("word", self.vim_text_object.word.as_slice()), ("big_word", self.vim_text_object.big_word.as_slice()), ("parentheses", self.vim_text_object.parentheses.as_slice()), ("brackets", self.vim_text_object.brackets.as_slice()), ("braces", self.vim_text_object.braces.as_slice()), ("double_quote", self.vim_text_object.double_quote.as_slice()), ("single_quote", self.vim_text_object.single_quote.as_slice()), ("backtick", self.vim_text_object.backtick.as_slice()), ("cancel", self.vim_text_object.cancel.as_slice()), ], )?; validate_unique( "pager", [ ("scroll_up", self.pager.scroll_up.as_slice()), ("scroll_down", self.pager.scroll_down.as_slice()), ("page_up", self.pager.page_up.as_slice()), ("page_down", self.pager.page_down.as_slice()), ("half_page_up", self.pager.half_page_up.as_slice()), ("half_page_down", self.pager.half_page_down.as_slice()), ("jump_top", self.pager.jump_top.as_slice()), ("jump_bottom", self.pager.jump_bottom.as_slice()), ("close", self.pager.close.as_slice()), ("close_transcript", self.pager.close_transcript.as_slice()), ], )?; validate_no_reserved( "pager", [ ("scroll_up", self.pager.scroll_up.as_slice()), ("scroll_down", self.pager.scroll_down.as_slice()), ("page_up", self.pager.page_up.as_slice()), ("page_down", self.pager.page_down.as_slice()), ("half_page_up", self.pager.half_page_up.as_slice()), ("half_page_down", self.pager.half_page_down.as_slice()), ("jump_top", self.pager.jump_top.as_slice()), ("jump_bottom", self.pager.jump_bottom.as_slice()), ("close", self.pager.close.as_slice()), ("close_transcript", self.pager.close_transcript.as_slice()), ], TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS, [], )?; validate_unique( "list", [ ("move_up", self.list.move_up.as_slice()), ("move_down", self.list.move_down.as_slice()), ("move_left", self.list.move_left.as_slice()), ("move_right", self.list.move_right.as_slice()), ("page_up", self.list.page_up.as_slice()), ("page_down", self.list.page_down.as_slice()), ("jump_top", self.list.jump_top.as_slice()), ("jump_bottom", self.list.jump_bottom.as_slice()), ("accept", self.list.accept.as_slice()), ("cancel", self.list.cancel.as_slice()), ], )?; validate_unique( "approval", [ ("open_fullscreen", self.approval.open_fullscreen.as_slice()), ("open_thread", self.approval.open_thread.as_slice()), ("approve", self.approval.approve.as_slice()), ( "approve_for_session", self.approval.approve_for_session.as_slice(), ), ( "approve_for_prefix", self.approval.approve_for_prefix.as_slice(), ), ("deny", self.approval.deny.as_slice()), ("decline", self.approval.decline.as_slice()), ("cancel", self.approval.cancel.as_slice()), ], )?; let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); for (action, bindings) in [ ("list.move_up", self.list.move_up.as_slice()), ("list.move_down", self.list.move_down.as_slice()), ("list.move_left", self.list.move_left.as_slice()), ("list.move_right", self.list.move_right.as_slice()), ("list.page_up", self.list.page_up.as_slice()), ("list.page_down", self.list.page_down.as_slice()), ("list.jump_top", self.list.jump_top.as_slice()), ("list.jump_bottom", self.list.jump_bottom.as_slice()), ("list.accept", self.list.accept.as_slice()), ("list.cancel", self.list.cancel.as_slice()), ( "approval.open_fullscreen", self.approval.open_fullscreen.as_slice(), ), ("approval.open_thread", self.approval.open_thread.as_slice()), ("approval.approve", self.approval.approve.as_slice()), ( "approval.approve_for_session", self.approval.approve_for_session.as_slice(), ), ( "approval.approve_for_prefix", self.approval.approve_for_prefix.as_slice(), ), ("approval.deny", self.approval.deny.as_slice()), ("approval.decline", self.approval.decline.as_slice()), ("approval.cancel", self.approval.cancel.as_slice()), ] { for binding in bindings { let key = binding.parts(); if let Some(previous) = seen.insert(key, action) { // Approval overlays intentionally reserve Esc as a stable // cancellation path even though decline options may also // display it in contexts where that is safe. if previous == "list.cancel" && action == "approval.decline" && key == (KeyCode::Esc, KeyModifiers::NONE) { continue; } return Err(format!( "Ambiguous approval overlay keymap bindings: `{previous}` and `{action}` use the same key. \ Set unique keys in `~/.codex/config.toml` and retry. \ See the Codex keymap documentation for supported actions and examples." )); } } } Ok(()) } } /// Reject duplicate keys inside one effective context map. /// /// This intentionally allows the same key across different contexts; handlers /// only evaluate one context at a time. fn validate_unique( context: &str, pairs: [(&'static str, &[KeyBinding]); N], ) -> Result<(), String> { let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); for (action, bindings) in pairs { for binding in bindings { let key = binding.parts(); if let Some(previous) = seen.insert(key, action) { return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{previous}` and `{action}` use the same key. \ Set unique keys in `~/.codex/config.toml` and retry. \ See the Codex keymap documentation for supported actions and examples." )); } } } Ok(()) } fn validate_no_shadow_with_allowed_overlaps( context: &str, primary: [(&'static str, &[KeyBinding]); N], shadowed: [(&'static str, &[KeyBinding]); M], allowed_overlaps: [(&'static str, &'static str, KeyBinding); A], ) -> Result<(), String> { let mut seen: HashMap<(KeyCode, KeyModifiers), &'static str> = HashMap::new(); for (action, bindings) in primary { for binding in bindings { seen.insert(binding.parts(), action); } } for (action, bindings) in shadowed { for binding in bindings { let key = binding.parts(); if let Some(previous) = seen.get(&key) { if allowed_overlaps.iter().any( |(allowed_primary, allowed_shadowed, allowed_binding)| { *allowed_primary == *previous && *allowed_shadowed == action && allowed_binding.parts() == key }, ) { continue; } return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{previous}` shadows `{action}` with the same key. \ Set unique keys in `~/.codex/config.toml` and retry. \ See the Codex keymap documentation for supported actions and examples." )); } } } Ok(()) } fn validate_no_reserved( context: &str, pairs: [(&'static str, &[KeyBinding]); N], reserved: &[(&'static str, KeyBinding)], allowed_overlaps: [(&'static str, &'static str, KeyBinding); A], ) -> Result<(), String> { for (action, bindings) in pairs { for binding in bindings { let key = binding.parts(); if let Some((reserved_action, _)) = reserved .iter() .find(|(_, reserved_binding)| reserved_binding.parts() == key) { if allowed_overlaps.iter().any( |(allowed_action, allowed_reserved_action, allowed_binding)| { *allowed_action == action && *allowed_reserved_action == *reserved_action && allowed_binding.parts() == key }, ) { continue; } return Err(format!( "Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \ Set a different key in `~/.codex/config.toml` and retry. \ See the Codex keymap documentation for supported actions and examples." )); } } } Ok(()) } const MAIN_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ( "fixed.interrupt_or_quit", key_hint::ctrl(KeyCode::Char('c')), ), ("fixed.quit", key_hint::ctrl(KeyCode::Char('d'))), ("fixed.paste_image", key_hint::ctrl(KeyCode::Char('v'))), ("fixed.paste_image", key_hint::ctrl_alt(KeyCode::Char('v'))), ( "fixed.cycle_collaboration_mode", key_hint::shift(KeyCode::Tab), ), ("fixed.backtrack", key_hint::plain(KeyCode::Esc)), ("fixed.previous_agent", key_hint::alt(KeyCode::Left)), ("fixed.next_agent", key_hint::alt(KeyCode::Right)), ("fixed.slash_command", key_hint::plain(KeyCode::Char('/'))), ("fixed.shell_command", key_hint::plain(KeyCode::Char('!'))), ("fixed.file_paths", key_hint::plain(KeyCode::Char('@'))), ( "fixed.connector_mentions", key_hint::plain(KeyCode::Char('$')), ), ]; const TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS: &[(&str, KeyBinding)] = &[ ( "fixed.transcript_edit_previous", key_hint::plain(KeyCode::Esc), ), ( "fixed.transcript_edit_previous", key_hint::plain(KeyCode::Left), ), ( "fixed.transcript_edit_next", key_hint::plain(KeyCode::Right), ), ( "fixed.transcript_confirm_edit", key_hint::plain(KeyCode::Enter), ), ]; /// Resolve one action with context -> global -> default precedence. /// /// `path` should be the context-specific config path so parser errors point /// users at the override they attempted to set. /// /// A configured empty list is authoritative: it returns an empty binding set /// and does not continue to the global or built-in fallback. This is what makes /// explicit unbinding work for globally reusable actions like composer submit. fn resolve_bindings_with_global_fallback( configured: Option<&KeybindingsSpec>, global: Option<&KeybindingsSpec>, fallback: &[KeyBinding], path: &str, ) -> Result, String> { if let Some(configured) = configured { return parse_bindings(configured, path); } if let Some(global) = global { return parse_bindings(global, path); } Ok(fallback.to_vec()) } /// Resolve one action binding in a context without global fallback. /// /// Missing values inherit from the built-in fallback; configured values, including /// empty lists, replace that fallback for the action. fn resolve_bindings( configured: Option<&KeybindingsSpec>, fallback: &[KeyBinding], path: &str, ) -> Result, String> { let Some(spec) = configured else { return Ok(fallback.to_vec()); }; parse_bindings(spec, path) } fn configured_bindings_to_preserve( pairs: [(Option<&KeybindingsSpec>, &[KeyBinding]); N], ) -> Vec { let mut configured_bindings = Vec::new(); for (configured, resolved) in pairs { if configured.is_none() { continue; } for binding in resolved { if !configured_bindings.contains(binding) { configured_bindings.push(*binding); } } } configured_bindings } fn configured_main_surface_alias_is_used(keymap: &TuiKeymap, alias: &str) -> bool { let mut global = keymap.global.clone(); if keymap.composer.submit.is_some() { global.submit = None; } if keymap.composer.queue.is_some() { global.queue = None; } if keymap.composer.toggle_shortcuts.is_some() { global.toggle_shortcuts = None; } // Reasoning shortcuts run before composer/editor key handling, so fallback // aliases must yield to any explicit binding on the same main-surface input // path. configured_context_alias_is_used(&global, alias) || configured_context_alias_is_used(&keymap.chat, alias) || configured_context_alias_is_used(&keymap.composer, alias) || configured_context_alias_is_used(&keymap.editor, alias) || configured_context_alias_is_used(&keymap.vim_normal, alias) || configured_context_alias_is_used(&keymap.vim_operator, alias) || configured_context_alias_is_used(&keymap.vim_text_object, alias) } fn configured_context_alias_is_used(context: &impl Serialize, alias: &str) -> bool { let Ok(value) = serde_json::to_value(context) else { return false; }; keymap_value_contains_alias(&value, alias) } fn keymap_value_contains_alias(value: &serde_json::Value, alias: &str) -> bool { match value { serde_json::Value::String(value) => value == alias, serde_json::Value::Array(values) => values .iter() .any(|value| keymap_value_contains_alias(value, alias)), serde_json::Value::Object(values) => values .values() .any(|value| keymap_value_contains_alias(value, alias)), serde_json::Value::Bool(_) | serde_json::Value::Number(_) | serde_json::Value::Null => { false } } } fn resolve_new_default_bindings( configured: Option<&KeybindingsSpec>, fallback: &[KeyBinding], configured_bindings_to_preserve: &[KeyBinding], path: &str, ) -> Result, String> { let Some(spec) = configured else { return Ok(fallback .iter() .copied() .filter(|binding| !configured_bindings_to_preserve.contains(binding)) .collect()); }; parse_bindings(spec, path) } /// Parse one keybinding value (`string` or `list[string]`) into concrete bindings. /// /// Duplicate entries are de-duplicated while preserving first-seen order so the /// first key can remain the primary UI hint. fn parse_bindings(spec: &KeybindingsSpec, path: &str) -> Result, String> { let mut parsed = Vec::new(); for raw in spec.specs() { let binding = parse_keybinding(raw.as_str()).ok_or_else(|| { format!( "Invalid `{path}` = `{}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`. \ See the Codex keymap documentation for supported actions and examples.", raw.as_str() ) })?; if !parsed.contains(&binding) { parsed.push(binding); } } Ok(parsed) } /// Parse one normalized keybinding spec such as `ctrl-a` or `shift-enter`. /// /// Specs are expected to be normalized by config deserialization, but this /// parser remains strict to keep runtime error messages precise. fn parse_keybinding(spec: &str) -> Option { let mut parts = spec.split('-'); let mut modifiers = KeyModifiers::NONE; let mut key_name = None; for part in parts.by_ref() { match part { "ctrl" => modifiers |= KeyModifiers::CONTROL, "alt" => modifiers |= KeyModifiers::ALT, "shift" => modifiers |= KeyModifiers::SHIFT, other => { key_name = Some(other.to_string()); break; } } } let mut key_name = key_name?; for trailing in parts { key_name.push('-'); key_name.push_str(trailing); } let key = match key_name.as_str() { "enter" => KeyCode::Enter, "tab" => KeyCode::Tab, "backspace" => KeyCode::Backspace, "esc" => KeyCode::Esc, "delete" => KeyCode::Delete, "up" => KeyCode::Up, "down" => KeyCode::Down, "left" => KeyCode::Left, "right" => KeyCode::Right, "home" => KeyCode::Home, "end" => KeyCode::End, "page-up" => KeyCode::PageUp, "page-down" => KeyCode::PageDown, "space" => KeyCode::Char(' '), "minus" => KeyCode::Char('-'), other if other.len() == 1 => KeyCode::Char(char::from(other.as_bytes()[0])), other if other.starts_with('f') => { let number = other[1..].parse::().ok()?; if (1..=MAX_FUNCTION_KEY).contains(&number) { KeyCode::F(number) } else { return None; } } _ => return None, }; Some(KeyBinding::new(key, modifiers)) } #[cfg(test)] mod tests { use super::*; use codex_config::types::KeybindingSpec; fn one(spec: &str) -> KeybindingsSpec { KeybindingsSpec::One(KeybindingSpec(spec.to_string())) } fn expect_conflict(keymap: &TuiKeymap, first: &str, second: &str) { let err = RuntimeKeymap::from_config(keymap).expect_err("expected conflict"); assert!(err.contains(first)); assert!(err.contains(second)); } #[test] fn parses_canonical_binding() { let binding = parse_keybinding("ctrl-alt-shift-a").expect("binding should parse"); assert_eq!(binding.parts().0, KeyCode::Char('a')); assert_eq!( binding.parts().1, KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT ); } #[test] fn rejects_shadowing_composer_binding_in_app_scope() { let mut keymap = TuiKeymap::default(); keymap.global.open_transcript = Some(one("ctrl-t")); keymap.composer.submit = Some(one("ctrl-t")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("composer.submit")); assert!(err.contains("open_transcript")); } #[test] fn rejects_shadowing_composer_queue_in_app_scope() { let mut keymap = TuiKeymap::default(); keymap.global.open_external_editor = Some(one("ctrl-g")); keymap.composer.queue = Some(one("ctrl-g")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("composer.queue")); assert!(err.contains("open_external_editor")); } #[test] fn rejects_shadowing_composer_toggle_shortcuts_in_app_scope() { let mut keymap = TuiKeymap::default(); keymap.global.open_transcript = Some(one("ctrl-k")); keymap.composer.toggle_shortcuts = Some(one("ctrl-k")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("composer.toggle_shortcuts")); assert!(err.contains("open_transcript")); } #[test] fn rejects_shadowing_editor_binding_in_main_scope() { let mut keymap = TuiKeymap::default(); keymap.composer.submit = Some(one("ctrl-j")); keymap.editor.insert_newline = Some(one("ctrl-j")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("composer.submit")); assert!(err.contains("editor.insert_newline")); } #[test] fn rejects_shadowing_editor_binding_from_outer_main_handler() { let mut keymap = TuiKeymap::default(); keymap.global.copy = Some(one("ctrl-y")); keymap.editor.yank = Some(one("ctrl-y")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("copy")); assert!(err.contains("editor.yank")); } #[test] fn rejects_shadowing_approval_binding_in_app_scope() { let mut keymap = TuiKeymap::default(); keymap.global.open_transcript = Some(one("y")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("approval.approve")); assert!(err.contains("open_transcript")); } #[test] fn rejects_shadowing_list_binding_in_app_scope() { let mut keymap = TuiKeymap::default(); keymap.global.copy = Some(one("down")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected shadowing conflict"); assert!(err.contains("list.move_down")); assert!(err.contains("copy")); } #[test] fn supports_string_or_array_bindings() { let mut keymap = TuiKeymap::default(); keymap.composer.submit = Some(KeybindingsSpec::Many(vec![ KeybindingSpec("ctrl-enter".to_string()), KeybindingSpec("meta-enter".to_string()), ])); let err = RuntimeKeymap::from_config(&keymap).expect_err("meta is not a valid modifier"); assert!(err.contains("tui.keymap.composer.submit")); keymap.composer.submit = Some(KeybindingsSpec::Many(vec![ KeybindingSpec("ctrl-enter".to_string()), KeybindingSpec("ctrl-shift-enter".to_string()), ])); let runtime = RuntimeKeymap::from_config(&keymap).expect("valid multi-binding"); assert_eq!(runtime.composer.submit.len(), 2); } #[test] fn deduplicates_repeated_bindings_while_preserving_first_seen_order() { let mut keymap = TuiKeymap::default(); keymap.composer.submit = Some(KeybindingsSpec::Many(vec![ KeybindingSpec("ctrl-enter".to_string()), KeybindingSpec("ctrl-enter".to_string()), KeybindingSpec("ctrl-shift-enter".to_string()), ])); let runtime = RuntimeKeymap::from_config(&keymap).expect("valid multi-binding"); assert_eq!( runtime.composer.submit, vec![ key_hint::ctrl(KeyCode::Enter), KeyBinding::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) ] ); } #[test] fn falls_back_to_global_binding_when_context_override_is_not_set() { let mut keymap = TuiKeymap::default(); keymap.global.queue = Some(one("ctrl-q")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.composer.queue, vec![key_hint::ctrl(KeyCode::Char('q'))] ); } #[test] fn invalid_global_open_transcript_binding_reports_global_path() { let mut keymap = TuiKeymap::default(); keymap.global.open_transcript = Some(one("meta-t")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error"); assert!(err.contains("tui.keymap.global.open_transcript")); } #[test] fn invalid_global_open_external_editor_binding_reports_global_path() { let mut keymap = TuiKeymap::default(); keymap.global.open_external_editor = Some(one("meta-g")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error"); assert!(err.contains("tui.keymap.global.open_external_editor")); } #[test] fn default_copy_binding_is_ctrl_o() { let runtime = RuntimeKeymap::defaults(); assert_eq!(runtime.app.copy, vec![key_hint::ctrl(KeyCode::Char('o'))]); } #[test] fn defaults_include_reassignable_main_surface_actions() { let runtime = RuntimeKeymap::defaults(); assert_eq!( runtime.app.clear_terminal, vec![key_hint::ctrl(KeyCode::Char('l'))] ); assert_eq!(runtime.app.toggle_fast_mode, Vec::new()); assert_eq!( runtime.chat.interrupt_turn, vec![key_hint::plain(KeyCode::Esc)] ); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![ key_hint::alt(KeyCode::Char(',')), key_hint::shift(KeyCode::Down), ] ); assert_eq!( runtime.chat.increase_reasoning_effort, vec![ key_hint::alt(KeyCode::Char('.')), key_hint::shift(KeyCode::Up), ] ); assert_eq!( runtime.chat.edit_queued_message, vec![key_hint::alt(KeyCode::Up), key_hint::shift(KeyCode::Left)] ); assert_eq!( runtime.composer.history_search_previous, vec![key_hint::ctrl(KeyCode::Char('r'))] ); assert_eq!( runtime.composer.history_search_next, vec![key_hint::ctrl(KeyCode::Char('s'))] ); assert_eq!(runtime.editor.kill_whole_line, Vec::new()); } #[test] fn defaults_include_list_page_and_jump_actions() { let runtime = RuntimeKeymap::defaults(); assert_eq!( runtime.list.move_up, vec![ key_hint::plain(KeyCode::Up), key_hint::ctrl(KeyCode::Char('p')), key_hint::ctrl(KeyCode::Char('k')), key_hint::plain(KeyCode::Char('k')), ] ); assert_eq!( runtime.list.move_down, vec![ key_hint::plain(KeyCode::Down), key_hint::ctrl(KeyCode::Char('n')), key_hint::ctrl(KeyCode::Char('j')), key_hint::plain(KeyCode::Char('j')), ] ); assert_eq!( runtime.list.move_left, vec![ key_hint::plain(KeyCode::Left), key_hint::ctrl(KeyCode::Char('h')), ] ); assert_eq!( runtime.list.move_right, vec![ key_hint::plain(KeyCode::Right), key_hint::ctrl(KeyCode::Char('l')), ] ); assert_eq!( runtime.list.page_up, vec![ key_hint::plain(KeyCode::PageUp), key_hint::ctrl(KeyCode::Char('b')), ] ); assert_eq!( runtime.list.page_down, vec![ key_hint::plain(KeyCode::PageDown), key_hint::ctrl(KeyCode::Char('f')), ] ); assert_eq!(runtime.list.jump_top, vec![key_hint::plain(KeyCode::Home)]); assert_eq!( runtime.list.jump_bottom, vec![key_hint::plain(KeyCode::End)] ); } #[test] fn configured_main_surface_bindings_prune_reasoning_fallback_aliases() { let mut keymap = TuiKeymap::default(); keymap.editor.move_up = Some(one("shift-up")); keymap.vim_text_object.word = Some(one("shift-down")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!(runtime.editor.move_up, vec![key_hint::shift(KeyCode::Up)]); assert_eq!( runtime.vim_text_object.word, vec![key_hint::shift(KeyCode::Down)] ); assert_eq!( runtime.chat.decrease_reasoning_effort, vec![key_hint::alt(KeyCode::Char(','))] ); assert_eq!( runtime.chat.increase_reasoning_effort, vec![key_hint::alt(KeyCode::Char('.'))] ); } #[test] fn explicit_reasoning_binding_still_conflicts_with_editor_binding() { let mut keymap = TuiKeymap::default(); keymap.editor.move_up = Some(one("shift-up")); keymap.chat.increase_reasoning_effort = Some(one("shift-up")); expect_conflict(&keymap, "chat.increase_reasoning_effort", "editor.move_up"); } #[test] fn configured_legacy_list_bindings_prune_new_default_overlaps() { let mut keymap = TuiKeymap::default(); keymap.list.move_up = Some(one("page-up")); keymap.list.move_down = Some(one("page-down")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!(runtime.list.move_up, vec![key_hint::plain(KeyCode::PageUp)]); assert_eq!( runtime.list.move_down, vec![key_hint::plain(KeyCode::PageDown)] ); assert_eq!( runtime.list.page_up, vec![key_hint::ctrl(KeyCode::Char('b'))] ); assert_eq!( runtime.list.page_down, vec![key_hint::ctrl(KeyCode::Char('f'))] ); } #[test] fn configured_legacy_list_bindings_can_prune_all_new_default_keys() { let mut keymap = TuiKeymap::default(); keymap.list.move_up = Some(KeybindingsSpec::Many(vec![ KeybindingSpec("page-up".to_string()), KeybindingSpec("ctrl-b".to_string()), ])); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.list.move_up, vec![ key_hint::plain(KeyCode::PageUp), key_hint::ctrl(KeyCode::Char('b')), ] ); assert_eq!(runtime.list.page_up, Vec::new()); } #[test] fn explicit_new_list_bindings_still_conflict_with_legacy_bindings() { let mut keymap = TuiKeymap::default(); keymap.list.move_up = Some(one("page-up")); keymap.list.page_up = Some(one("page-up")); expect_conflict(&keymap, "move_up", "page_up"); } #[test] fn configured_app_bindings_prune_new_list_default_overlaps() { let mut keymap = TuiKeymap::default(); keymap.global.copy = Some(one("page-down")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!(runtime.app.copy, vec![key_hint::plain(KeyCode::PageDown)]); assert_eq!( runtime.list.page_down, vec![key_hint::ctrl(KeyCode::Char('f'))] ); } #[test] fn configured_approval_bindings_prune_new_list_default_overlaps() { let mut keymap = TuiKeymap::default(); keymap.approval.approve = Some(one("home")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.approval.approve, vec![key_hint::plain(KeyCode::Home)] ); assert_eq!(runtime.list.jump_top, Vec::new()); } #[test] fn explicit_new_list_bindings_still_conflict_with_configured_approval_bindings() { let mut keymap = TuiKeymap::default(); keymap.approval.approve = Some(one("home")); keymap.list.jump_top = Some(one("home")); expect_conflict(&keymap, "list.jump_top", "approval.approve"); } #[test] fn configured_legacy_vim_normal_bindings_prune_new_change_operator_default() { let mut keymap = TuiKeymap::default(); keymap.vim_normal.move_left = Some(one("c")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.vim_normal.move_left, vec![key_hint::plain(KeyCode::Char('c'))] ); assert_eq!(runtime.vim_normal.start_change_operator, Vec::new()); } #[test] fn explicit_new_vim_normal_binding_still_conflicts_with_legacy_binding() { let mut keymap = TuiKeymap::default(); keymap.vim_normal.move_left = Some(one("c")); keymap.vim_normal.start_change_operator = Some(one("c")); expect_conflict(&keymap, "move_left", "start_change_operator"); } #[test] fn configured_legacy_vim_normal_bindings_prune_new_substitute_default() { let mut keymap = TuiKeymap::default(); keymap.vim_normal.move_left = Some(one("s")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.vim_normal.move_left, vec![key_hint::plain(KeyCode::Char('s'))] ); assert_eq!(runtime.vim_normal.substitute_char, Vec::new()); } #[test] fn explicit_new_vim_normal_substitute_binding_still_conflicts_with_legacy_binding() { let mut keymap = TuiKeymap::default(); keymap.vim_normal.move_left = Some(one("s")); keymap.vim_normal.substitute_char = Some(one("s")); expect_conflict(&keymap, "move_left", "substitute_char"); } #[test] fn configured_legacy_vim_operator_bindings_prune_new_text_object_defaults() { let mut keymap = TuiKeymap::default(); keymap.vim_operator.motion_left = Some(one("i")); keymap.vim_operator.motion_right = Some(one("a")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.vim_operator.motion_left, vec![key_hint::plain(KeyCode::Char('i'))] ); assert_eq!( runtime.vim_operator.motion_right, vec![key_hint::plain(KeyCode::Char('a'))] ); assert_eq!(runtime.vim_operator.select_inner_text_object, Vec::new()); assert_eq!(runtime.vim_operator.select_around_text_object, Vec::new()); } #[test] fn explicit_new_vim_operator_binding_still_conflicts_with_legacy_binding() { let mut keymap = TuiKeymap::default(); keymap.vim_operator.motion_left = Some(one("i")); keymap.vim_operator.select_inner_text_object = Some(one("i")); expect_conflict(&keymap, "motion_left", "select_inner_text_object"); } #[test] fn vim_normal_defaults_include_insert_and_arrow_aliases() { let runtime = RuntimeKeymap::defaults(); assert_eq!( runtime.vim_normal.enter_insert, vec![ key_hint::plain(KeyCode::Char('i')), key_hint::plain(KeyCode::Insert) ] ); assert_eq!( runtime.vim_normal.move_left, vec![ key_hint::plain(KeyCode::Char('h')), key_hint::plain(KeyCode::Left) ] ); assert_eq!( runtime.vim_normal.move_right, vec![ key_hint::plain(KeyCode::Char('l')), key_hint::plain(KeyCode::Right) ] ); assert_eq!( runtime.vim_normal.move_up, vec![ key_hint::plain(KeyCode::Char('k')), key_hint::plain(KeyCode::Up) ] ); assert_eq!( runtime.vim_normal.move_down, vec![ key_hint::plain(KeyCode::Char('j')), key_hint::plain(KeyCode::Down) ] ); } #[test] fn invalid_global_copy_binding_reports_global_path() { let mut keymap = TuiKeymap::default(); keymap.global.copy = Some(one("meta-o")); let err = RuntimeKeymap::from_config(&keymap).expect_err("expected parse error"); assert!(err.contains("tui.keymap.global.copy")); } #[test] fn rejects_conflicting_editor_bindings() { let mut keymap = TuiKeymap::default(); keymap.editor.move_left = Some(one("ctrl-h")); keymap.editor.move_right = Some(one("ctrl-h")); expect_conflict(&keymap, "move_left", "move_right"); } #[test] fn rejects_conflicting_pager_bindings() { let mut keymap = TuiKeymap::default(); keymap.pager.scroll_up = Some(one("ctrl-u")); keymap.pager.scroll_down = Some(one("ctrl-u")); expect_conflict(&keymap, "scroll_up", "scroll_down"); } #[test] fn rejects_conflicting_list_bindings() { let mut keymap = TuiKeymap::default(); keymap.list.move_up = Some(one("up")); keymap.list.move_down = Some(one("up")); expect_conflict(&keymap, "move_up", "move_down"); let mut keymap = TuiKeymap::default(); keymap.list.move_left = Some(one("left")); keymap.list.move_right = Some(one("left")); expect_conflict(&keymap, "move_left", "move_right"); } #[test] fn rejects_conflicting_list_page_and_jump_bindings() { let mut keymap = TuiKeymap::default(); keymap.list.page_up = Some(one("home")); keymap.list.jump_top = Some(one("home")); expect_conflict(&keymap, "page_up", "jump_top"); } #[test] fn rejects_conflicting_approval_bindings() { let mut keymap = TuiKeymap::default(); keymap.approval.approve = Some(one("y")); keymap.approval.decline = Some(one("y")); expect_conflict(&keymap, "approve", "decline"); } #[test] fn rejects_conflicting_approval_deny_binding() { let mut keymap = TuiKeymap::default(); keymap.approval.approve = Some(one("y")); keymap.approval.deny = Some(one("y")); expect_conflict(&keymap, "approve", "deny"); } #[test] fn rejects_conflicting_approval_overlay_accept_binding() { let mut keymap = TuiKeymap::default(); keymap.list.accept = Some(one("y")); expect_conflict(&keymap, "list.accept", "approval.approve"); } #[test] fn rejects_conflicting_approval_overlay_cancel_binding() { let mut keymap = TuiKeymap::default(); keymap.list.cancel = Some(one("c")); expect_conflict(&keymap, "list.cancel", "approval.cancel"); } #[test] fn reassignable_fixed_shortcuts_conflict_until_original_action_is_unbound() { let mut keymap = TuiKeymap::default(); keymap.global.copy = Some(one("alt-.")); expect_conflict(&keymap, "copy", "chat.increase_reasoning_effort"); keymap.chat.increase_reasoning_effort = Some(KeybindingsSpec::Many(vec![])); let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free"); assert_eq!(runtime.app.copy, vec![key_hint::alt(KeyCode::Char('.'))]); } #[test] fn kill_whole_line_can_be_assigned_without_default_binding() { let mut keymap = TuiKeymap::default(); keymap.editor.kill_whole_line = Some(one("ctrl-shift-u")); let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); assert_eq!( runtime.editor.kill_whole_line, vec![KeyBinding::new( KeyCode::Char('u'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, )] ); } #[test] fn kill_whole_line_conflicts_until_kill_line_start_is_unbound() { let mut keymap = TuiKeymap::default(); keymap.editor.kill_whole_line = Some(one("ctrl-u")); expect_conflict(&keymap, "kill_line_start", "kill_whole_line"); keymap.editor.kill_line_start = Some(KeybindingsSpec::Many(vec![])); let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped key should be free"); assert_eq!( runtime.editor.kill_whole_line, vec![key_hint::ctrl(KeyCode::Char('u'))] ); } #[test] fn toggle_fast_mode_can_be_assigned_without_default_binding() { let mut keymap = TuiKeymap::default(); keymap.global.toggle_fast_mode = Some(one("ctrl-shift-f")); let runtime = RuntimeKeymap::from_config(&keymap).expect("runtime keymap"); assert_eq!( runtime.app.toggle_fast_mode, vec![KeyBinding::new( KeyCode::Char('f'), KeyModifiers::CONTROL | KeyModifiers::SHIFT, )] ); } #[test] fn toggle_fast_mode_conflicts_with_existing_main_surface_bindings() { let mut keymap = TuiKeymap::default(); keymap.global.toggle_fast_mode = Some(one("ctrl-l")); expect_conflict(&keymap, "clear_terminal", "toggle_fast_mode"); } #[test] fn rejects_main_bindings_that_collide_with_remaining_fixed_shortcuts() { let mut keymap = TuiKeymap::default(); keymap.composer.submit = Some(one("ctrl-v")); expect_conflict(&keymap, "composer.submit", "fixed.paste_image"); } #[test] fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() { let mut keymap = TuiKeymap::default(); let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse"); assert_eq!( runtime.chat.interrupt_turn, vec![key_hint::plain(KeyCode::Esc)] ); keymap.chat.interrupt_turn = Some(one("f12")); let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse"); assert_eq!( runtime.chat.interrupt_turn, vec![key_hint::plain(KeyCode::F(12))] ); keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![])); let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse"); assert!(runtime.chat.interrupt_turn.is_empty()); } #[test] fn interrupt_turn_rejects_other_fixed_shortcuts() { let mut keymap = TuiKeymap::default(); keymap.chat.interrupt_turn = Some(one("ctrl-v")); expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image"); } #[test] fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() { let mut keymap = TuiKeymap::default(); keymap.chat.interrupt_turn = Some(one("f12")); keymap.list.move_right = Some(one("f12")); expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right"); } #[test] fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() { let mut keymap = TuiKeymap::default(); keymap.pager.close = Some(one("left")); expect_conflict(&keymap, "close", "fixed.transcript_edit_previous"); } #[test] fn parses_function_keys_and_rejects_out_of_range_function_keys() { assert_eq!( parse_keybinding("f1").map(|binding| binding.parts()), Some((KeyCode::F(1), KeyModifiers::NONE)) ); assert_eq!( parse_keybinding("f24").map(|binding| binding.parts()), Some((KeyCode::F(24), KeyModifiers::NONE)) ); assert_eq!(parse_keybinding("f25"), None); } #[test] fn parses_all_named_non_character_keys() { let cases = [ ("tab", KeyCode::Tab), ("backspace", KeyCode::Backspace), ("esc", KeyCode::Esc), ("delete", KeyCode::Delete), ("up", KeyCode::Up), ("down", KeyCode::Down), ("left", KeyCode::Left), ("right", KeyCode::Right), ("home", KeyCode::Home), ("end", KeyCode::End), ("page-up", KeyCode::PageUp), ("page-down", KeyCode::PageDown), ("space", KeyCode::Char(' ')), ("minus", KeyCode::Char('-')), ]; for (spec, expected_key) in cases { assert_eq!( parse_keybinding(spec).map(|binding| binding.parts()), Some((expected_key, KeyModifiers::NONE)), "failed to parse {spec}" ); } } #[test] fn rejects_modifier_only_and_nonnumeric_function_key_specs() { assert_eq!(parse_keybinding("ctrl"), None); assert_eq!(parse_keybinding("ff"), None); } #[test] fn parses_minus_alias_and_legacy_literal_minus() { assert_eq!( parse_keybinding("alt-minus").map(|binding| binding.parts()), Some((KeyCode::Char('-'), KeyModifiers::ALT)) ); assert_eq!( parse_keybinding("alt--").map(|binding| binding.parts()), Some((KeyCode::Char('-'), KeyModifiers::ALT)) ); assert_eq!( parse_keybinding("-").map(|binding| binding.parts()), Some((KeyCode::Char('-'), KeyModifiers::NONE)) ); } #[test] fn explicit_empty_array_unbinds_action() { let mut keymap = TuiKeymap::default(); keymap.composer.toggle_shortcuts = Some(KeybindingsSpec::Many(vec![])); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert!(runtime.composer.toggle_shortcuts.is_empty()); } #[test] fn raw_output_toggle_defaults_to_alt_r() { let runtime = RuntimeKeymap::defaults(); assert_eq!( runtime.app.toggle_raw_output, vec![key_hint::alt(KeyCode::Char('r'))] ); } #[test] fn raw_output_toggle_can_be_remapped() { let mut keymap = TuiKeymap::default(); keymap.global.toggle_raw_output = Some(one("f12")); let runtime = RuntimeKeymap::from_config(&keymap).expect("config should parse"); assert_eq!( runtime.app.toggle_raw_output, vec![key_hint::plain(KeyCode::F(12))] ); } #[test] fn default_editor_insert_newline_includes_current_aliases() { let runtime = RuntimeKeymap::defaults(); assert_eq!( runtime.editor.insert_newline, vec![ key_hint::ctrl(KeyCode::Char('j')), key_hint::ctrl(KeyCode::Char('m')), key_hint::plain(KeyCode::Enter), key_hint::shift(KeyCode::Enter), key_hint::alt(KeyCode::Enter), ] ); } #[test] fn default_editor_delete_forward_word_includes_alt_d() { let runtime = RuntimeKeymap::defaults(); assert!( runtime .editor .delete_forward_word .contains(&key_hint::alt(KeyCode::Char('d'))) ); } #[test] fn default_editor_deletion_includes_modified_backspace_delete_aliases() { let runtime = RuntimeKeymap::defaults(); assert!( runtime .editor .delete_backward .contains(&key_hint::shift(KeyCode::Backspace)) ); assert!( runtime .editor .delete_forward .contains(&key_hint::shift(KeyCode::Delete)) ); assert!( runtime .editor .delete_backward_word .contains(&key_hint::ctrl(KeyCode::Backspace)) ); assert!( runtime .editor .delete_backward_word .contains(&KeyBinding::new( KeyCode::Backspace, KeyModifiers::CONTROL | KeyModifiers::SHIFT )) ); assert!( runtime .editor .delete_forward_word .contains(&key_hint::ctrl(KeyCode::Delete)) ); assert!( runtime .editor .delete_forward_word .contains(&KeyBinding::new( KeyCode::Delete, KeyModifiers::CONTROL | KeyModifiers::SHIFT )) ); } #[test] fn default_composer_toggle_shortcuts_includes_shift_question_mark() { let runtime = RuntimeKeymap::defaults(); assert!( runtime .composer .toggle_shortcuts .contains(&key_hint::shift(KeyCode::Char('?'))) ); } #[test] fn default_approval_open_fullscreen_includes_ctrl_shift_a() { let runtime = RuntimeKeymap::defaults(); assert!(runtime.approval.open_fullscreen.contains(&KeyBinding::new( KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::SHIFT ))); } #[test] fn primary_binding_returns_first_or_none() { let bindings = vec![ key_hint::ctrl(KeyCode::Char('a')), key_hint::shift(KeyCode::Char('b')), ]; assert_eq!( primary_binding(&bindings), Some(key_hint::ctrl(KeyCode::Char('a'))) ); assert_eq!(primary_binding(&[]), None); } #[test] fn defaults_pass_conflict_validation() { RuntimeKeymap::defaults() .validate_conflicts() .expect("default keymap should be conflict free"); } }