feat(tui): add configurable keymap support (#18593)

## Why

The TUI currently handles keyboard shortcuts as hard-coded event matches
spread across app, composer, pager, list, approval, and navigation code.
That makes shortcuts hard to customize, makes displayed hints easy to
drift from actual behavior, and makes future keymap work riskier because
there is no central action inventory.

This PR adds the foundation for configurable, action-based keymaps
without adding the interactive remapping UI yet. Onboarding
intentionally stays on fixed startup shortcuts because users cannot
reasonably configure keymaps before completing onboarding.

This is PR1 in the keymap stack:

- PR1: #18593: configurable keymap foundation
- PR2: #18594: `/keymap` picker and guided remapping UI
- PR3: #18595: Vim composer mode and the remap option

## Design Notes

The new model resolves named actions into concrete runtime bindings once
from config, then passes those bindings to the UI surfaces that handle
input or render shortcut hints.

The main concepts are:

- **Context**: a scope where an action is active, such as `global`,
`chat`, `composer`, `editor`, `pager`, `list`, or `approval`.
- **Action**: a named operation inside a context, such as
`global.open_transcript`, `composer.submit`, or `pager.close`.
- **Binding**: one or more single-key shortcuts assigned to an action,
written as config strings such as `ctrl-t`, `alt-backspace`, or
`page-down`. Multi-step sequences such as `ctrl-x ctrl-s`, `g g`, or
leader-key flows are not part of this PR.
- **Resolution order**: context-specific config wins first, supported
global fallbacks come next, and built-in defaults fill in anything
unset.
- **Explicit unbinding**: an empty array removes an action binding in
that scope and does not fall through to a fallback binding.
- **Conflict validation**: a resolved keymap rejects duplicate active
bindings inside the same scope so one keypress cannot dispatch two
actions.

## What Changed

- Added `TuiKeymap` config support under `[tui.keymap]`, including typed
contexts/actions, key alias normalization, generated schema coverage,
and user-facing config errors.
- Added `RuntimeKeymap` resolution in `codex-rs/tui/src/keymap.rs`,
including fallback precedence, built-in defaults, explicit unbinding,
and per-context conflict validation.
- Rewired existing TUI handlers to consume resolved keymap actions
instead of directly matching hard-coded keys in each component.
- Updated key hint rendering and footer/pager/list surfaces so displayed
shortcuts follow the resolved keymap.
- Kept onboarding shortcuts fixed in
`codex-rs/tui/src/onboarding/keys.rs` instead of exposing them through
`[tui.keymap]`.

## Validation

The branch includes focused coverage for config parsing, key
normalization, runtime fallback resolution, explicit unbinding,
duplicate-key conflict validation, default keymap consistency,
onboarding startup key behavior, and UI hint snapshots affected by
resolved key bindings.
This commit is contained in:
Felipe Coury
2026-04-28 12:52:25 -03:00
committed by GitHub
parent a61c785040
commit 5e737372ee
63 changed files with 8142 additions and 877 deletions

View File

@@ -21,6 +21,7 @@ pub mod schema;
mod skills_config;
mod state;
mod thread_config;
mod tui_keymap;
pub mod types;
pub const CONFIG_TOML_FILE: &str = "config.toml";

View File

@@ -0,0 +1,483 @@
//! 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<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>,
}
/// 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<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 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)]
#[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>,
/// 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 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"
) {
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\
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::<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 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());
}
}

View File

@@ -28,6 +28,17 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
pub use crate::tui_keymap::KeybindingSpec;
pub use crate::tui_keymap::KeybindingsSpec;
pub use crate::tui_keymap::TuiApprovalKeymap;
pub use crate::tui_keymap::TuiChatKeymap;
pub use crate::tui_keymap::TuiComposerKeymap;
pub use crate::tui_keymap::TuiEditorKeymap;
pub use crate::tui_keymap::TuiGlobalKeymap;
pub use crate::tui_keymap::TuiKeymap;
pub use crate::tui_keymap::TuiListKeymap;
pub use crate::tui_keymap::TuiPagerKeymap;
pub const DEFAULT_OTEL_ENVIRONMENT: &str = "dev";
pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 2;
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 10;
@@ -598,6 +609,13 @@ pub struct Tui {
#[serde(default)]
pub theme: Option<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,
/// Startup tooltip availability NUX state persisted by the TUI.
#[serde(default)]
pub model_availability_nux: ModelAvailabilityNuxConfig,