//! TUI keymap config schema and canonical key-spec normalization. //! //! This module defines the on-disk `[tui.keymap]` contract used by //! `~/.codex/config.toml` and normalizes user-entered key specs into canonical //! forms consumed by runtime keymap resolution in `codex-rs/tui/src/keymap.rs`. //! //! Responsibilities: //! //! 1. Define strongly typed config contexts/actions with unknown-field //! rejection. //! 2. Normalize accepted key aliases into canonical names. //! 3. Reject malformed bindings early with user-facing diagnostics. //! //! Non-responsibilities: //! //! 1. Dispatch precedence and conflict validation. //! 2. Input event matching at runtime. use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; use serde::Serialize; use serde::de::Error as SerdeError; use std::collections::BTreeMap; /// Normalized string representation of a single key event (for example `ctrl-a`). /// /// The parser accepts a small alias set (for example `escape` -> `esc`, /// `pageup` -> `page-up`) and stores the canonical form. /// /// This deliberately represents one terminal key event, not a sequence of /// events. A value like `ctrl-x ctrl-s` is not a chord in this schema; adding /// multi-step chords would require a separate runtime state machine. #[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(transparent)] pub struct KeybindingSpec(#[schemars(with = "String")] pub String); impl KeybindingSpec { /// Returns the canonical key-spec string (for example `ctrl-a`). pub fn as_str(&self) -> &str { self.0.as_str() } } impl<'de> Deserialize<'de> for KeybindingSpec { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let raw = String::deserialize(deserializer)?; let normalized = normalize_keybinding_spec(&raw).map_err(SerdeError::custom)?; Ok(Self(normalized)) } } /// One action binding value in config. /// /// This accepts either: /// /// 1. A single key spec string (`"ctrl-a"`). /// 2. A list of key spec strings (`["ctrl-a", "alt-a"]`). /// /// An empty list explicitly unbinds the action in that scope. Because an /// explicit empty list is still a configured value, runtime resolution must not /// fall through to global or built-in defaults for that action. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[serde(untagged)] pub enum KeybindingsSpec { One(KeybindingSpec), Many(Vec), } impl KeybindingsSpec { /// Returns all configured key specs for one action in declaration order. /// /// Callers should preserve this ordering when deriving UI hints so the /// first binding remains the primary affordance shown to users. pub fn specs(&self) -> Vec<&KeybindingSpec> { match self { Self::One(spec) => vec![spec], Self::Many(specs) => specs.iter().collect(), } } } /// Global keybindings. These are used when a context does not define an override. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiGlobalKeymap { /// Open the transcript overlay. pub open_transcript: Option, /// Open the external editor for the current draft. pub open_external_editor: Option, /// Copy the last agent response to the clipboard. pub copy: Option, /// Clear the terminal UI. pub clear_terminal: Option, /// Submit the current composer draft. pub submit: Option, /// Queue the current composer draft while a task is running. pub queue: Option, /// Toggle the composer shortcut overlay. pub toggle_shortcuts: Option, /// Toggle Vim mode for the composer input. pub toggle_vim_mode: Option, /// Toggle Fast mode. pub toggle_fast_mode: Option, /// Toggle raw scrollback mode for copy-friendly transcript selection. pub toggle_raw_output: Option, } /// Chat context keybindings. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiChatKeymap { /// Decrease the active reasoning effort. pub decrease_reasoning_effort: Option, /// Increase the active reasoning effort. pub increase_reasoning_effort: Option, /// Edit the most recently queued message. pub edit_queued_message: Option, } /// Composer context keybindings. These override corresponding `global` actions. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiComposerKeymap { /// Submit the current composer draft. pub submit: Option, /// Queue the current composer draft while a task is running. pub queue: Option, /// Toggle the composer shortcut overlay. pub toggle_shortcuts: Option, /// Open reverse history search or move to the previous match. pub history_search_previous: Option, /// Move to the next match in reverse history search. pub history_search_next: Option, } /// Editor context keybindings for text editing inside text areas. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiEditorKeymap { /// Insert a newline in the editor. pub insert_newline: Option, /// Move cursor left by one grapheme. pub move_left: Option, /// Move cursor right by one grapheme. pub move_right: Option, /// Move cursor up one visual line. pub move_up: Option, /// Move cursor down one visual line. pub move_down: Option, /// Move cursor to beginning of previous word. pub move_word_left: Option, /// Move cursor to end of next word. pub move_word_right: Option, /// Move cursor to beginning of line. pub move_line_start: Option, /// Move cursor to end of line. pub move_line_end: Option, /// Delete one grapheme to the left. pub delete_backward: Option, /// Delete one grapheme to the right. pub delete_forward: Option, /// Delete the previous word. pub delete_backward_word: Option, /// Delete the next word. pub delete_forward_word: Option, /// Kill text from cursor to line start. pub kill_line_start: Option, /// Kill the current line. pub kill_whole_line: Option, /// Kill text from cursor to line end. pub kill_line_end: Option, /// Yank the kill buffer. pub yank: Option, } /// Vim normal-mode keybindings for modal editing inside text areas. /// /// Actions that use uppercase letters (like `A` for append-line-end) should /// be specified as `shift-a` in config; the runtime matcher handles /// cross-terminal shift-reporting differences automatically. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct TuiVimNormalKeymap { /// Enter insert mode at cursor (`i`). pub enter_insert: Option, /// Enter insert mode after cursor (`a`). pub append_after_cursor: Option, /// Enter insert mode at end of line (`A`). pub append_line_end: Option, /// Enter insert mode at first non-blank of line (`I`). pub insert_line_start: Option, /// Open a new line below and enter insert mode (`o`). pub open_line_below: Option, /// Open a new line above and enter insert mode (`O`). pub open_line_above: Option, /// Move cursor left (`h`). pub move_left: Option, /// Move cursor right (`l`). pub move_right: Option, /// Move cursor up (`k`), or recall older composer history at history boundaries. pub move_up: Option, /// Move cursor down (`j`), or recall newer composer history at history boundaries. pub move_down: Option, /// Move cursor to start of next word (`w`). pub move_word_forward: Option, /// Move cursor to start of previous word (`b`). pub move_word_backward: Option, /// Move cursor to end of current/next word (`e`). pub move_word_end: Option, /// Move cursor to start of line (`0`). pub move_line_start: Option, /// Move cursor to end of line (`$`). pub move_line_end: Option, /// Delete character under cursor (`x`). pub delete_char: Option, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option, /// Yank the entire line (`Y`). pub yank_line: Option, /// Paste after cursor (`p`). pub paste_after: Option, /// Begin delete operator; next key selects motion (`d`). pub start_delete_operator: Option, /// Begin yank operator; next key selects motion (`y`). pub start_yank_operator: Option, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option, } /// Vim operator-pending keybindings for modal editing inside text areas. /// /// This context is active only while waiting for a motion after `d` or `y`. /// Repeating the operator key (`dd`, `yy`) targets the entire line. Pressing /// `Esc` cancels the pending operator and returns to normal mode without /// modifying text. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct TuiVimOperatorKeymap { /// Repeat delete operator to delete the whole line (`dd`). pub delete_line: Option, /// Repeat yank operator to yank the whole line (`yy`). pub yank_line: Option, /// Motion: left (`h`). pub motion_left: Option, /// Motion: right (`l`). pub motion_right: Option, /// Motion: up one line (`k`). pub motion_up: Option, /// Motion: down one line (`j`). pub motion_down: Option, /// Motion: to start of next word (`w`). pub motion_word_forward: Option, /// Motion: to start of previous word (`b`). pub motion_word_backward: Option, /// Motion: to end of current/next word (`e`). pub motion_word_end: Option, /// Motion: to start of line (`0`). pub motion_line_start: Option, /// Motion: to end of line (`$`). pub motion_line_end: Option, /// Cancel the pending operator and return to normal mode. pub cancel: Option, } /// Pager context keybindings for transcript and static overlays. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiPagerKeymap { /// Scroll up by one row. pub scroll_up: Option, /// Scroll down by one row. pub scroll_down: Option, /// Scroll up by one page. pub page_up: Option, /// Scroll down by one page. pub page_down: Option, /// Scroll up by half a page. pub half_page_up: Option, /// Scroll down by half a page. pub half_page_down: Option, /// Jump to the beginning. pub jump_top: Option, /// Jump to the end. pub jump_bottom: Option, /// Close the pager overlay. pub close: Option, /// Close the transcript overlay via its dedicated toggle key. pub close_transcript: Option, } /// List selection context keybindings for popup-style selectable lists. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiListKeymap { /// Move list selection up. pub move_up: Option, /// Move list selection down. pub move_down: Option, /// Accept current selection. pub accept: Option, /// Cancel and close selection view. pub cancel: Option, } /// Approval overlay keybindings. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiApprovalKeymap { /// Open the full-screen approval details view. pub open_fullscreen: Option, /// Open the thread that requested approval when shown from another thread. pub open_thread: Option, /// Approve the primary option. pub approve: Option, /// Approve for session when that option exists. pub approve_for_session: Option, /// Approve with exec-policy prefix when that option exists. pub approve_for_prefix: Option, /// Deny without providing follow-up guidance. pub deny: Option, /// Decline and provide corrective guidance. pub decline: Option, /// Cancel an elicitation request. pub cancel: Option, } /// Raw keymap configuration from `[tui.keymap]`. /// /// Each context contains action-level overrides. Missing actions inherit from /// built-in defaults, and selected chat/composer actions can fall back /// through `global` during runtime resolution. /// /// This type is intentionally a persistence shape, not the structure used by /// input handlers. Runtime consumers should resolve it into /// `RuntimeKeymap` first so precedence, empty-list unbinding, and duplicate-key /// validation are applied consistently. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[serde(deny_unknown_fields)] #[schemars(deny_unknown_fields)] pub struct TuiKeymap { #[serde(default)] pub global: TuiGlobalKeymap, #[serde(default)] pub chat: TuiChatKeymap, #[serde(default)] pub composer: TuiComposerKeymap, #[serde(default)] pub editor: TuiEditorKeymap, #[serde(default)] pub vim_normal: TuiVimNormalKeymap, #[serde(default)] pub vim_operator: TuiVimOperatorKeymap, #[serde(default)] pub pager: TuiPagerKeymap, #[serde(default)] pub list: TuiListKeymap, #[serde(default)] pub approval: TuiApprovalKeymap, } /// Normalize one user-entered key spec into canonical storage format. /// /// The output always orders modifiers as `ctrl-alt-shift-` when present /// and applies accepted aliases (`escape` -> `esc`, `pageup` -> `page-up`). /// Inputs that cannot be represented unambiguously are rejected. /// /// Normalization happens at config-deserialization time so downstream runtime /// code only has to parse one spelling for each key. Callers should not bypass /// this function when accepting user-authored key specs, or otherwise equivalent /// keys can fail to compare equal in tests, UI hints, and duplicate detection. fn normalize_keybinding_spec(raw: &str) -> Result { let lower = raw.trim().to_ascii_lowercase(); if lower.is_empty() { return Err( "keybinding cannot be empty. Use values like `ctrl-a` or `shift-enter`.\n\ See the Codex keymap documentation for supported actions and examples." .to_string(), ); } let segments: Vec<&str> = lower .split('-') .filter(|segment| !segment.is_empty()) .collect(); if segments.is_empty() { return Err(format!( "invalid keybinding `{raw}`. Use values like `ctrl-a`, `shift-enter`, or `page-down`." )); } let mut modifiers = BTreeMap::<&str, bool>::from([("ctrl", false), ("alt", false), ("shift", false)]); let mut key_segments = Vec::new(); let mut saw_key = false; for segment in segments { let canonical_mod = match segment { "ctrl" | "control" => Some("ctrl"), "alt" | "option" => Some("alt"), "shift" => Some("shift"), _ => None, }; if !saw_key && let Some(modifier) = canonical_mod { if modifiers.get(modifier).copied().unwrap_or(false) { return Err(format!( "duplicate modifier in keybinding `{raw}`. Use each modifier at most once." )); } modifiers.insert(modifier, true); continue; } saw_key = true; key_segments.push(segment); } if key_segments.is_empty() { return Err(format!( "missing key in keybinding `{raw}`. Add a key name like `a`, `enter`, or `page-down`." )); } if key_segments .iter() .any(|segment| matches!(*segment, "ctrl" | "control" | "alt" | "option" | "shift")) { return Err(format!( "invalid keybinding `{raw}`: modifiers must come before the key (for example `ctrl-a`)." )); } let key = normalize_key_name(&key_segments.join("-"), raw)?; let mut normalized = Vec::new(); if modifiers.get("ctrl").copied().unwrap_or(false) { normalized.push("ctrl".to_string()); } if modifiers.get("alt").copied().unwrap_or(false) { normalized.push("alt".to_string()); } if modifiers.get("shift").copied().unwrap_or(false) { normalized.push("shift".to_string()); } normalized.push(key); Ok(normalized.join("-")) } /// Normalize and validate one key name segment. /// /// This accepts a constrained key vocabulary to keep runtime parser behavior /// deterministic across platforms. fn normalize_key_name(key: &str, original: &str) -> Result { let alias = match key { "escape" => "esc", "return" => "enter", "spacebar" => "space", "pgup" | "pageup" => "page-up", "pgdn" | "pagedown" => "page-down", "del" => "delete", other => other, }; if alias.len() == 1 { let ch = alias.chars().next().unwrap_or_default(); if ch.is_ascii() && !ch.is_ascii_control() && ch != '-' { return Ok(alias.to_string()); } } if matches!( alias, "enter" | "tab" | "backspace" | "esc" | "delete" | "up" | "down" | "left" | "right" | "home" | "end" | "page-up" | "page-down" | "space" ) { return Ok(alias.to_string()); } if let Some(number) = alias.strip_prefix('f') && let Ok(number) = number.parse::() && (1..=12).contains(&number) { return Ok(alias.to_string()); } Err(format!( "unknown key `{key}` in keybinding `{original}`. \ Use a printable character (for example `a`), function keys (`f1`-`f12`), \ or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space.\n\ See the Codex keymap documentation for supported actions and examples." )) } #[cfg(test)] mod tests { use super::*; #[test] fn misplaced_action_at_keymap_root_is_rejected() { // Actions placed directly under [tui.keymap] instead of a context // sub-table (e.g. [tui.keymap.global]) must produce a parse error, // not be silently ignored. let toml_input = r#" open_transcript = "ctrl-s" "#; let result = toml::from_str::(toml_input); assert!( result.is_err(), "expected error for action at keymap root, got: {result:?}" ); } #[test] fn misspelled_action_under_context_is_rejected() { let toml_input = r#" [global] open_transcrip = "ctrl-x" "#; let err = toml::from_str::(toml_input) .expect_err("expected unknown action under context"); assert!( err.to_string().contains("open_transcrip"), "expected error to mention misspelled field, got: {err}" ); } #[test] fn removed_backtrack_actions_are_rejected() { for (context, action) in [ ("global", "edit_previous_message"), ("global", "confirm_edit_previous_message"), ("chat", "edit_previous_message"), ("chat", "confirm_edit_previous_message"), ("pager", "edit_previous_message"), ("pager", "edit_next_message"), ("pager", "confirm_edit_message"), ] { let toml_input = format!( r#" [{context}] {action} = "ctrl-x" "# ); let err = toml::from_str::(&toml_input) .expect_err("expected removed backtrack action to be rejected"); assert!( err.to_string().contains(action), "expected error to mention removed field {action}, got: {err}" ); } } #[test] fn action_under_global_context_is_accepted() { let toml_input = r#" [global] open_transcript = "ctrl-s" "#; let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config"); assert!(keymap.global.open_transcript.is_some()); } }