feat(core): add keymap config schema and types

Introduce keymap configuration types and schema support in core without
wiring runtime key handling yet. This keeps behavior unchanged while
adding the configuration surface needed by later commits.
This commit is contained in:
Josh McKinney
2026-02-05 22:55:52 -08:00
parent 70b281bb37
commit c267dd07bb
4 changed files with 1269 additions and 1 deletions

View File

@@ -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": [
{

View File

@@ -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<Vec<String>>,
/// 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(),
};

View File

@@ -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<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.
#[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)]
#[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>,
/// In an empty composer, begin or advance "edit previous message" flow.
pub edit_previous_message: Option<KeybindingsSpec>,
/// Confirm editing the selected previous message.
pub confirm_edit_previous_message: 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>,
}
/// 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<KeybindingsSpec>,
/// Confirm editing the selected previous message.
pub confirm_edit_previous_message: Option<KeybindingsSpec>,
}
/// 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<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>,
}
/// 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<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 text from cursor to line end.
pub kill_line_end: Option<KeybindingsSpec>,
/// Yank the kill buffer.
pub yank: Option<KeybindingsSpec>,
}
/// 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<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>,
/// In backtrack preview mode, step to older message.
pub edit_previous_message: Option<KeybindingsSpec>,
/// In backtrack preview mode, step to newer message.
pub edit_next_message: Option<KeybindingsSpec>,
/// In backtrack preview mode, confirm selected message.
pub confirm_edit_message: Option<KeybindingsSpec>,
}
/// 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<KeybindingsSpec>,
/// Move list selection down.
pub move_down: 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)]
#[schemars(deny_unknown_fields)]
pub struct TuiApprovalKeymap {
/// Open the full-screen approval details view.
pub open_fullscreen: 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>,
/// Decline and provide corrective guidance.
pub decline: Option<KeybindingsSpec>,
/// Cancel an elicitation request.
pub cancel: Option<KeybindingsSpec>,
}
/// 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<KeybindingsSpec>,
/// Move selection down.
pub move_down: Option<KeybindingsSpec>,
/// Pick first option.
pub select_first: Option<KeybindingsSpec>,
/// Pick second option.
pub select_second: Option<KeybindingsSpec>,
/// Pick third option.
pub select_third: Option<KeybindingsSpec>,
/// Confirm current selection.
pub confirm: Option<KeybindingsSpec>,
/// Cancel current screen action.
pub cancel: Option<KeybindingsSpec>,
/// Quit onboarding flow.
pub quit: Option<KeybindingsSpec>,
/// Cycle welcome animation variant.
pub toggle_animation: Option<KeybindingsSpec>,
}
/// 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-<key>` 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<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\
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<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"
) {
return Ok(alias.to_string());
}
if let Some(number) = alias.strip_prefix('f')
&& let Ok(number) = number.parse::<u8>()
&& (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"
))
}

View File

@@ -623,12 +623,32 @@ pub struct Tui {
/// `current-dir`.
#[serde(default)]
pub status_line: Option<Vec<String>>,
/// 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.