diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 5eab764fc3..304a0be022 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -500,6 +500,20 @@ } ] }, + "KeybindingsSpec": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + } + ], + "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope." + }, "MemoriesToml": { "additionalProperties": false, "description": "Memories settings loaded from config.toml.", @@ -1279,6 +1293,93 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "keymap": { + "allOf": [ + { + "$ref": "#/definitions/TuiKeymap" + } + ], + "default": { + "approval": { + "approve": null, + "approve_for_prefix": null, + "approve_for_session": null, + "cancel": null, + "decline": null, + "open_fullscreen": null + }, + "chat": { + "confirm_edit_previous_message": null, + "edit_previous_message": null + }, + "composer": { + "queue": null, + "submit": null, + "toggle_shortcuts": null + }, + "editor": { + "delete_backward": null, + "delete_backward_word": null, + "delete_forward": null, + "delete_forward_word": null, + "insert_newline": null, + "kill_line_end": null, + "kill_line_start": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_left": null, + "move_word_right": null, + "yank": null + }, + "global": { + "confirm_edit_previous_message": null, + "edit_previous_message": null, + "open_external_editor": null, + "open_transcript": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + }, + "list": { + "accept": null, + "cancel": null, + "move_down": null, + "move_up": null + }, + "onboarding": { + "cancel": null, + "confirm": null, + "move_down": null, + "move_up": null, + "quit": null, + "select_first": null, + "select_second": null, + "select_third": null, + "toggle_animation": null + }, + "pager": { + "close": null, + "close_transcript": null, + "confirm_edit_message": null, + "edit_next_message": null, + "edit_previous_message": null, + "half_page_down": null, + "half_page_up": null, + "jump_bottom": null, + "jump_top": null, + "page_down": null, + "page_up": null, + "scroll_down": null, + "scroll_up": null + }, + "preset": "latest" + }, + "description": "Keybinding overrides for the TUI.\n\nThis supports rebinding selected actions globally and by context. Context bindings take precedence over `global` bindings." + }, "notification_method": { "allOf": [ { @@ -1313,6 +1414,708 @@ }, "type": "object" }, + "TuiApprovalKeymap": { + "additionalProperties": false, + "description": "Approval overlay keybindings.", + "properties": { + "approve": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve the primary option." + }, + "approve_for_prefix": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve with exec-policy prefix when that option exists." + }, + "approve_for_session": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Approve for session when that option exists." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel an elicitation request." + }, + "decline": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Decline and provide corrective guidance." + }, + "open_fullscreen": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the full-screen approval details view." + } + }, + "type": "object" + }, + "TuiChatKeymap": { + "additionalProperties": false, + "description": "Chat context keybindings. These override corresponding `global` actions.", + "properties": { + "confirm_edit_previous_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Confirm editing the selected previous message." + }, + "edit_previous_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "In an empty composer, begin or advance \"edit previous message\" flow." + } + }, + "type": "object" + }, + "TuiComposerKeymap": { + "additionalProperties": false, + "description": "Composer context keybindings. These override corresponding `global` actions.", + "properties": { + "queue": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Queue the current composer draft while a task is running." + }, + "submit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Submit the current composer draft." + }, + "toggle_shortcuts": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle the composer shortcut overlay." + } + }, + "type": "object" + }, + "TuiEditorKeymap": { + "additionalProperties": false, + "description": "Editor context keybindings for text editing inside text areas.", + "properties": { + "delete_backward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete one grapheme to the left." + }, + "delete_backward_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete the previous word." + }, + "delete_forward": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete one grapheme to the right." + }, + "delete_forward_word": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Delete the next word." + }, + "insert_newline": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Insert a newline in the editor." + }, + "kill_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill text from cursor to line end." + }, + "kill_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill text from cursor to line start." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor down one visual line." + }, + "move_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor left by one grapheme." + }, + "move_line_end": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of line." + }, + "move_line_start": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to beginning of line." + }, + "move_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor right by one grapheme." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor up one visual line." + }, + "move_word_left": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to beginning of previous word." + }, + "move_word_right": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move cursor to end of next word." + }, + "yank": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Yank the kill buffer." + } + }, + "type": "object" + }, + "TuiGlobalKeymap": { + "additionalProperties": false, + "description": "Global keybindings. These are used when a context does not define an override.", + "properties": { + "confirm_edit_previous_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Confirm editing the selected previous message." + }, + "edit_previous_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "In an empty composer, begin or advance \"edit previous message\" flow." + }, + "open_external_editor": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the external editor for the current draft." + }, + "open_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Open the transcript overlay." + }, + "queue": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Queue the current composer draft while a task is running." + }, + "submit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Submit the current composer draft." + }, + "toggle_shortcuts": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle the composer shortcut overlay." + } + }, + "type": "object" + }, + "TuiKeymap": { + "additionalProperties": false, + "description": "Raw keymap configuration from `[tui.keymap]`.\n\nEach context contains action-level overrides. Missing actions inherit from runtime preset defaults, and selected chat/composer actions can fall back through `global` during runtime resolution.", + "properties": { + "approval": { + "allOf": [ + { + "$ref": "#/definitions/TuiApprovalKeymap" + } + ], + "default": { + "approve": null, + "approve_for_prefix": null, + "approve_for_session": null, + "cancel": null, + "decline": null, + "open_fullscreen": null + } + }, + "chat": { + "allOf": [ + { + "$ref": "#/definitions/TuiChatKeymap" + } + ], + "default": { + "confirm_edit_previous_message": null, + "edit_previous_message": null + } + }, + "composer": { + "allOf": [ + { + "$ref": "#/definitions/TuiComposerKeymap" + } + ], + "default": { + "queue": null, + "submit": null, + "toggle_shortcuts": null + } + }, + "editor": { + "allOf": [ + { + "$ref": "#/definitions/TuiEditorKeymap" + } + ], + "default": { + "delete_backward": null, + "delete_backward_word": null, + "delete_forward": null, + "delete_forward_word": null, + "insert_newline": null, + "kill_line_end": null, + "kill_line_start": null, + "move_down": null, + "move_left": null, + "move_line_end": null, + "move_line_start": null, + "move_right": null, + "move_up": null, + "move_word_left": null, + "move_word_right": null, + "yank": null + } + }, + "global": { + "allOf": [ + { + "$ref": "#/definitions/TuiGlobalKeymap" + } + ], + "default": { + "confirm_edit_previous_message": null, + "edit_previous_message": null, + "open_external_editor": null, + "open_transcript": null, + "queue": null, + "submit": null, + "toggle_shortcuts": null + } + }, + "list": { + "allOf": [ + { + "$ref": "#/definitions/TuiListKeymap" + } + ], + "default": { + "accept": null, + "cancel": null, + "move_down": null, + "move_up": null + } + }, + "onboarding": { + "allOf": [ + { + "$ref": "#/definitions/TuiOnboardingKeymap" + } + ], + "default": { + "cancel": null, + "confirm": null, + "move_down": null, + "move_up": null, + "quit": null, + "select_first": null, + "select_second": null, + "select_third": null, + "toggle_animation": null + } + }, + "pager": { + "allOf": [ + { + "$ref": "#/definitions/TuiPagerKeymap" + } + ], + "default": { + "close": null, + "close_transcript": null, + "confirm_edit_message": null, + "edit_next_message": null, + "edit_previous_message": null, + "half_page_down": null, + "half_page_up": null, + "jump_bottom": null, + "jump_top": null, + "page_down": null, + "page_up": null, + "scroll_down": null, + "scroll_up": null + } + }, + "preset": { + "allOf": [ + { + "$ref": "#/definitions/TuiKeymapPreset" + } + ], + "default": "latest" + } + }, + "type": "object" + }, + "TuiKeymapPreset": { + "description": "Versioned keymap defaults.", + "oneOf": [ + { + "description": "Pointer alias to the latest shipped preset.\n\nToday this resolves to `v1`.", + "enum": [ + "latest" + ], + "type": "string" + }, + { + "description": "Frozen keymap defaults that preserve legacy/current shortcut behavior.", + "enum": [ + "v1" + ], + "type": "string" + } + ] + }, + "TuiListKeymap": { + "additionalProperties": false, + "description": "List selection context keybindings for popup-style selectable lists.", + "properties": { + "accept": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Accept current selection." + }, + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel and close selection view." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection down." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move list selection up." + } + }, + "type": "object" + }, + "TuiOnboardingKeymap": { + "additionalProperties": false, + "description": "Onboarding keybindings.", + "properties": { + "cancel": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cancel current screen action." + }, + "confirm": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Confirm current selection." + }, + "move_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move selection down." + }, + "move_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Move selection up." + }, + "quit": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Quit onboarding flow." + }, + "select_first": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Pick first option." + }, + "select_second": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Pick second option." + }, + "select_third": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Pick third option." + }, + "toggle_animation": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Cycle welcome animation variant." + } + }, + "type": "object" + }, + "TuiPagerKeymap": { + "additionalProperties": false, + "description": "Pager context keybindings for transcript and static overlays.", + "properties": { + "close": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the pager overlay." + }, + "close_transcript": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Close the transcript overlay via its dedicated toggle key." + }, + "confirm_edit_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "In backtrack preview mode, confirm selected message." + }, + "edit_next_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "In backtrack preview mode, step to newer message." + }, + "edit_previous_message": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "In backtrack preview mode, step to older message." + }, + "half_page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by half a page." + }, + "half_page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by half a page." + }, + "jump_bottom": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the end." + }, + "jump_top": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Jump to the beginning." + }, + "page_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by one page." + }, + "page_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by one page." + }, + "scroll_down": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll down by one row." + }, + "scroll_up": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Scroll up by one row." + } + }, + "type": "object" + }, "UriBasedFileOpener": { "oneOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a27508d1a9..00a456bd74 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -20,6 +20,7 @@ use crate::config::types::ShellEnvironmentPolicy; use crate::config::types::ShellEnvironmentPolicyToml; use crate::config::types::SkillsConfig; use crate::config::types::Tui; +use crate::config::types::TuiKeymap; use crate::config::types::UriBasedFileOpener; use crate::config::types::WindowsSandboxModeToml; use crate::config::types::WindowsToml; @@ -96,6 +97,7 @@ mod permissions; pub mod profile; pub mod schema; pub mod service; +pub mod tui_keymap; pub mod types; pub use codex_config::Constrained; pub use codex_config::ConstraintError; @@ -264,13 +266,20 @@ pub struct Config { /// - `always`: Always use alternate screen (original behavior). /// - `never`: Never use alternate screen (inline mode, preserves scrollback). pub tui_alternate_screen: AltScreenMode, - /// Ordered list of status line item identifiers for the TUI. /// /// When unset, the TUI defaults to: `model-with-reasoning`, `context-remaining`, and /// `current-dir`. pub tui_status_line: Option>, + /// Keybinding overrides for the TUI. + /// + /// Precedence is: + /// + /// 1. context table (`tui.keymap.chat`, `tui.keymap.composer`, etc.) + /// 2. `tui.keymap.global` + /// 3. built-in preset defaults (`latest` currently points to `v1`) + pub tui_keymap: TuiKeymap, /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -2067,6 +2076,11 @@ impl Config { .map(|t| t.alternate_screen) .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), + tui_keymap: cfg + .tui + .as_ref() + .map(|t| t.keymap.clone()) + .unwrap_or_default(), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2482,6 +2496,7 @@ allowed_domains = ["openai.com"] show_tooltips: true, alternate_screen: AltScreenMode::Auto, status_line: None, + keymap: TuiKeymap::default(), } ); } @@ -4587,6 +4602,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }, o3_profile_config @@ -4705,6 +4721,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; @@ -4821,6 +4838,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; @@ -4923,6 +4941,7 @@ model_verbosity = "high" feedback_enabled: true, tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, + tui_keymap: TuiKeymap::default(), otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/tui_keymap.rs b/codex-rs/core/src/config/tui_keymap.rs new file mode 100644 index 0000000000..6e1e05dcc9 --- /dev/null +++ b/codex-rs/core/src/config/tui_keymap.rs @@ -0,0 +1,426 @@ +//! 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; + +/// Versioned keymap defaults. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "lowercase")] +pub enum TuiKeymapPreset { + /// Pointer alias to the latest shipped preset. + /// + /// Today this resolves to `v1`. + #[default] + Latest, + /// Frozen keymap defaults that preserve legacy/current shortcut behavior. + V1, +} + +/// Normalized string representation of a keybinding (for example `ctrl-a`). +/// +/// The parser accepts a small alias set (for example `escape` -> `esc`, +/// `pageup` -> `page-up`) and stores the canonical form. +#[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. +#[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)] +#[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, + /// In an empty composer, begin or advance "edit previous message" flow. + pub edit_previous_message: Option, + /// Confirm editing the selected previous message. + pub confirm_edit_previous_message: 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, +} + +/// Chat context keybindings. These override corresponding `global` actions. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiChatKeymap { + /// In an empty composer, begin or advance "edit previous message" flow. + pub edit_previous_message: Option, + /// Confirm editing the selected previous message. + pub confirm_edit_previous_message: Option, +} + +/// Composer context keybindings. These override corresponding `global` actions. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[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, +} + +/// Editor context keybindings for text editing inside text areas. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[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 text from cursor to line end. + pub kill_line_end: Option, + /// Yank the kill buffer. + pub yank: Option, +} + +/// Pager context keybindings for transcript and static overlays. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[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, + /// In backtrack preview mode, step to older message. + pub edit_previous_message: Option, + /// In backtrack preview mode, step to newer message. + pub edit_next_message: Option, + /// In backtrack preview mode, confirm selected message. + pub confirm_edit_message: Option, +} + +/// List selection context keybindings for popup-style selectable lists. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[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)] +#[schemars(deny_unknown_fields)] +pub struct TuiApprovalKeymap { + /// Open the full-screen approval details view. + pub open_fullscreen: 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, + /// Decline and provide corrective guidance. + pub decline: Option, + /// Cancel an elicitation request. + pub cancel: Option, +} + +/// Onboarding keybindings. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiOnboardingKeymap { + /// Move selection up. + pub move_up: Option, + /// Move selection down. + pub move_down: Option, + /// Pick first option. + pub select_first: Option, + /// Pick second option. + pub select_second: Option, + /// Pick third option. + pub select_third: Option, + /// Confirm current selection. + pub confirm: Option, + /// Cancel current screen action. + pub cancel: Option, + /// Quit onboarding flow. + pub quit: Option, + /// Cycle welcome animation variant. + pub toggle_animation: Option, +} + +/// Raw keymap configuration from `[tui.keymap]`. +/// +/// Each context contains action-level overrides. Missing actions inherit from +/// runtime preset defaults, and selected chat/composer actions can fall back +/// through `global` during runtime resolution. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct TuiKeymap { + #[serde(default)] + pub preset: TuiKeymapPreset, + #[serde(default)] + pub global: TuiGlobalKeymap, + #[serde(default)] + pub chat: TuiChatKeymap, + #[serde(default)] + pub composer: TuiComposerKeymap, + #[serde(default)] + pub editor: TuiEditorKeymap, + #[serde(default)] + pub pager: TuiPagerKeymap, + #[serde(default)] + pub list: TuiListKeymap, + #[serde(default)] + pub approval: TuiApprovalKeymap, + #[serde(default)] + pub onboarding: TuiOnboardingKeymap, +} + +/// 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. +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\ +Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml" + .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\ +Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml" + )) +} diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index ca90414c27..816432a39f 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -623,12 +623,32 @@ pub struct Tui { /// `current-dir`. #[serde(default)] pub status_line: Option>, + + /// Keybinding overrides for the TUI. + /// + /// This supports rebinding selected actions globally and by context. + /// Context bindings take precedence over `global` bindings. + #[serde(default)] + pub keymap: TuiKeymap, } const fn default_true() -> bool { true } +pub use super::tui_keymap::KeybindingSpec; +pub use super::tui_keymap::KeybindingsSpec; +pub use super::tui_keymap::TuiApprovalKeymap; +pub use super::tui_keymap::TuiChatKeymap; +pub use super::tui_keymap::TuiComposerKeymap; +pub use super::tui_keymap::TuiEditorKeymap; +pub use super::tui_keymap::TuiGlobalKeymap; +pub use super::tui_keymap::TuiKeymap; +pub use super::tui_keymap::TuiKeymapPreset; +pub use super::tui_keymap::TuiListKeymap; +pub use super::tui_keymap::TuiOnboardingKeymap; +pub use super::tui_keymap::TuiPagerKeymap; + /// Settings for notices we display to users via the tui and app-server clients /// (primarily the Codex IDE extension). NOTE: these are different from /// notifications - notices are warnings, NUX screens, acknowledgements, etc.