//! 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;/// Highest function key supported by portable TUI keymap configuration.pub const MAX_FUNCTION_KEY: u8 = 24;/// 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<D>(deserializer: D) -> Result<Self, D::Error> 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<KeybindingSpec>),}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<KeybindingsSpec>, /// Open the external editor for the current draft. pub open_external_editor: Option<KeybindingsSpec>, /// Copy the last agent response to the clipboard. pub copy: Option<KeybindingsSpec>, /// Clear the terminal UI. pub clear_terminal: Option<KeybindingsSpec>, /// Submit the current composer draft. pub submit: Option<KeybindingsSpec>, /// Queue the current composer draft while a task is running. pub queue: Option<KeybindingsSpec>, /// Toggle the composer shortcut overlay. pub toggle_shortcuts: Option<KeybindingsSpec>, /// Toggle Vim mode for the composer input. pub toggle_vim_mode: Option<KeybindingsSpec>, /// Toggle Fast mode. pub toggle_fast_mode: Option<KeybindingsSpec>, /// Toggle raw scrollback mode for copy-friendly transcript selection. pub toggle_raw_output: Option<KeybindingsSpec>,}/// Chat context keybindings.#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]#[serde(deny_unknown_fields)]#[schemars(deny_unknown_fields)]pub struct TuiChatKeymap { /// Interrupt the active turn. pub interrupt_turn: Option<KeybindingsSpec>, /// Decrease the active reasoning effort. pub decrease_reasoning_effort: Option<KeybindingsSpec>, /// Increase the active reasoning effort. pub increase_reasoning_effort: Option<KeybindingsSpec>, /// Edit the most recently queued message. pub edit_queued_message: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Queue the current composer draft while a task is running. pub queue: Option<KeybindingsSpec>, /// Toggle the composer shortcut overlay. pub toggle_shortcuts: Option<KeybindingsSpec>, /// Open reverse history search or move to the previous match. pub history_search_previous: Option<KeybindingsSpec>, /// Move to the next match in reverse history search. pub history_search_next: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Move cursor left by one grapheme. pub move_left: Option<KeybindingsSpec>, /// Move cursor right by one grapheme. pub move_right: Option<KeybindingsSpec>, /// Move cursor up one visual line. pub move_up: Option<KeybindingsSpec>, /// Move cursor down one visual line. pub move_down: Option<KeybindingsSpec>, /// Move cursor to beginning of previous word. pub move_word_left: Option<KeybindingsSpec>, /// Move cursor to end of next word. pub move_word_right: Option<KeybindingsSpec>, /// Move cursor to beginning of line. pub move_line_start: Option<KeybindingsSpec>, /// Move cursor to end of line. pub move_line_end: Option<KeybindingsSpec>, /// Delete one grapheme to the left. pub delete_backward: Option<KeybindingsSpec>, /// Delete one grapheme to the right. pub delete_forward: Option<KeybindingsSpec>, /// Delete the previous word. pub delete_backward_word: Option<KeybindingsSpec>, /// Delete the next word. pub delete_forward_word: Option<KeybindingsSpec>, /// Kill text from cursor to line start. pub kill_line_start: Option<KeybindingsSpec>, /// Kill the current line. pub kill_whole_line: Option<KeybindingsSpec>, /// Kill text from cursor to line end. pub kill_line_end: Option<KeybindingsSpec>, /// Yank the kill buffer. pub yank: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Enter insert mode after cursor (`a`). pub append_after_cursor: Option<KeybindingsSpec>, /// Enter insert mode at end of line (`A`). pub append_line_end: Option<KeybindingsSpec>, /// Enter insert mode at first non-blank of line (`I`). pub insert_line_start: Option<KeybindingsSpec>, /// Open a new line below and enter insert mode (`o`). pub open_line_below: Option<KeybindingsSpec>, /// Open a new line above and enter insert mode (`O`). pub open_line_above: Option<KeybindingsSpec>, /// Move cursor left (`h`). pub move_left: Option<KeybindingsSpec>, /// Move cursor right (`l`). pub move_right: Option<KeybindingsSpec>, /// Move cursor up (`k`), or recall older composer history at history boundaries. pub move_up: Option<KeybindingsSpec>, /// Move cursor down (`j`), or recall newer composer history at history boundaries. pub move_down: Option<KeybindingsSpec>, /// Move cursor to start of next word (`w`). pub move_word_forward: Option<KeybindingsSpec>, /// Move cursor to start of previous word (`b`). pub move_word_backward: Option<KeybindingsSpec>, /// Move cursor to end of current/next word (`e`). pub move_word_end: Option<KeybindingsSpec>, /// Move cursor to start of line (`0`). pub move_line_start: Option<KeybindingsSpec>, /// Move cursor to end of line (`$`). pub move_line_end: Option<KeybindingsSpec>, /// Delete character under cursor (`x`). pub delete_char: Option<KeybindingsSpec>, /// Delete character under cursor and enter insert mode (`s`). pub substitute_char: Option<KeybindingsSpec>, /// Delete from cursor to end of line (`D`). pub delete_to_line_end: Option<KeybindingsSpec>, /// Change from cursor to end of line and enter insert mode (`C`). pub change_to_line_end: Option<KeybindingsSpec>, /// Yank the entire line (`Y`). pub yank_line: Option<KeybindingsSpec>, /// Paste after cursor (`p`). pub paste_after: Option<KeybindingsSpec>, /// Begin delete operator; next key selects motion (`d`). pub start_delete_operator: Option<KeybindingsSpec>, /// Begin yank operator; next key selects motion (`y`). pub start_yank_operator: Option<KeybindingsSpec>, /// Begin change operator; next keys select a text object. pub start_change_operator: Option<KeybindingsSpec>, /// Cancel a pending operator and return to normal mode. pub cancel_operator: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Repeat yank operator to yank the whole line (`yy`). pub yank_line: Option<KeybindingsSpec>, /// Motion: left (`h`). pub motion_left: Option<KeybindingsSpec>, /// Motion: right (`l`). pub motion_right: Option<KeybindingsSpec>, /// Motion: up one line (`k`). pub motion_up: Option<KeybindingsSpec>, /// Motion: down one line (`j`). pub motion_down: Option<KeybindingsSpec>, /// Motion: to start of next word (`w`). pub motion_word_forward: Option<KeybindingsSpec>, /// Motion: to start of previous word (`b`). pub motion_word_backward: Option<KeybindingsSpec>, /// Motion: to end of current/next word (`e`). pub motion_word_end: Option<KeybindingsSpec>, /// Motion: to start of line (`0`). pub motion_line_start: Option<KeybindingsSpec>, /// Motion: to end of line (`$`). pub motion_line_end: Option<KeybindingsSpec>, /// Select an inner text object after an operator. pub select_inner_text_object: Option<KeybindingsSpec>, /// Select an around text object after an operator. pub select_around_text_object: Option<KeybindingsSpec>, /// Cancel the pending operator and return to normal mode. pub cancel: Option<KeybindingsSpec>,}/// Vim text-object keybindings for modal editing inside text areas.#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]#[serde(deny_unknown_fields)]#[schemars(deny_unknown_fields)]pub struct TuiVimTextObjectKeymap { /// Text object: word. pub word: Option<KeybindingsSpec>, /// Text object: whitespace-delimited WORD. pub big_word: Option<KeybindingsSpec>, /// Text object: parentheses. pub parentheses: Option<KeybindingsSpec>, /// Text object: brackets. pub brackets: Option<KeybindingsSpec>, /// Text object: braces. pub braces: Option<KeybindingsSpec>, /// Text object: double quotes. pub double_quote: Option<KeybindingsSpec>, /// Text object: single quotes. pub single_quote: Option<KeybindingsSpec>, /// Text object: backticks. pub backtick: Option<KeybindingsSpec>, /// Cancel the pending text-object command. pub cancel: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Scroll down by one row. pub scroll_down: Option<KeybindingsSpec>, /// Scroll up by one page. pub page_up: Option<KeybindingsSpec>, /// Scroll down by one page. pub page_down: Option<KeybindingsSpec>, /// Scroll up by half a page. pub half_page_up: Option<KeybindingsSpec>, /// Scroll down by half a page. pub half_page_down: Option<KeybindingsSpec>, /// Jump to the beginning. pub jump_top: Option<KeybindingsSpec>, /// Jump to the end. pub jump_bottom: Option<KeybindingsSpec>, /// Close the pager overlay. pub close: Option<KeybindingsSpec>, /// Close the transcript overlay via its dedicated toggle key. pub close_transcript: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Move list selection down. pub move_down: Option<KeybindingsSpec>, /// Move horizontally left in list pickers that support horizontal actions. pub move_left: Option<KeybindingsSpec>, /// Move horizontally right in list pickers that support horizontal actions. pub move_right: Option<KeybindingsSpec>, /// Move list selection up by one page. pub page_up: Option<KeybindingsSpec>, /// Move list selection down by one page. pub page_down: Option<KeybindingsSpec>, /// Jump to the first list item. pub jump_top: Option<KeybindingsSpec>, /// Jump to the last list item. pub jump_bottom: Option<KeybindingsSpec>, /// Accept current selection. pub accept: Option<KeybindingsSpec>, /// Cancel and close selection view. pub cancel: Option<KeybindingsSpec>,}/// 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<KeybindingsSpec>, /// Open the thread that requested approval when shown from another thread. pub open_thread: Option<KeybindingsSpec>, /// Approve the primary option. pub approve: Option<KeybindingsSpec>, /// Approve for session when that option exists. pub approve_for_session: Option<KeybindingsSpec>, /// Approve with exec-policy prefix when that option exists. pub approve_for_prefix: Option<KeybindingsSpec>, /// Deny without providing follow-up guidance. pub deny: Option<KeybindingsSpec>, /// Decline and provide corrective guidance. pub decline: Option<KeybindingsSpec>, /// Cancel an elicitation request. pub cancel: Option<KeybindingsSpec>,}/// 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 vim_text_object: TuiVimTextObjectKeymap, #[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-<key>` 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<String, String> { 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<String, String> { 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" | "minus" ) { return Ok(alias.to_string()); } if let Some(number) = alias.strip_prefix('f') && let Ok(number) = number.parse::<u8>() && (1..=MAX_FUNCTION_KEY).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`-`f{MAX_FUNCTION_KEY}`), \or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space, minus.\n\See the Codex keymap documentation for supported actions and examples." ))}#[cfg(test)]mod tests { use super::*; use pretty_assertions::assert_eq; #[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::<TuiKeymap>(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::<TuiKeymap>(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 misspelled_vim_text_object_action_is_rejected() { let toml_input = r#" [vim_text_object] double_quotes = "shift-quote" "#; let err = toml::from_str::<TuiKeymap>(toml_input) .expect_err("expected unknown vim text object action"); assert!( err.to_string().contains("double_quotes"), "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::<TuiKeymap>(&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()); } #[test] fn minus_bindings_under_global_context_are_accepted() { for (spec, expected) in [ ( "minus", KeybindingsSpec::One(KeybindingSpec("minus".to_string())), ), ( "alt-minus", KeybindingsSpec::One(KeybindingSpec("alt-minus".to_string())), ), ] { let toml_input = format!( r#" [global] open_transcript = "{spec}" "# ); let keymap: TuiKeymap = toml::from_str(&toml_input).expect("valid config"); let mut expected_keymap = TuiKeymap::default(); expected_keymap.global.open_transcript = Some(expected); assert_eq!(keymap, expected_keymap); } } #[test] fn function_keys_through_f24_are_accepted() { assert_eq!(normalize_keybinding_spec("F13"), Ok("f13".to_string())); assert_eq!(normalize_keybinding_spec("f24"), Ok("f24".to_string())); assert!(normalize_keybinding_spec("f25").is_err()); }}