Compare commits

...

5 Commits

Author SHA1 Message Date
Josh McKinney
56a2295863 feat(tui): surface keymap customization hints
Add a dedicated footer line pointing to `[tui.keymap]` in
`~/.codex/config.toml` so users can find where to rebind shortcuts.

Refresh tooltips and snapshots to mention the config entry and
the keymap template URL.
2026-02-19 16:24:18 -08:00
Josh McKinney
6970958332 feat(tui): wire runtime keymap into event handling 2026-02-19 16:24:18 -08:00
Josh McKinney
dc1d5a0831 test(tui): add runtime keymap resolver characterization suite
Introduce the TUI runtime keymap resolver and keybinding matching helpers
with a dedicated unit-test suite. This commit is additive only: it adds
resolution logic, conflict validation, parser coverage, and documented macros
without wiring input handlers to the new runtime map yet.
2026-02-19 16:24:18 -08:00
Josh McKinney
c267dd07bb 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.
2026-02-19 16:24:18 -08:00
Josh McKinney
70b281bb37 docs(keymap): establish long-term keymap documentation
Publish keymap system documentation first so the implementation stack can
be reviewed against explicit behavior and invariants.

This commit adds the keymap system guide, action matrix, default keymap
template, and config/example documentation updates, plus a rollout plan
used to stage the additive refactor and validation work.
2026-02-19 16:23:55 -08:00
34 changed files with 4587 additions and 693 deletions

View File

@@ -20,6 +20,7 @@ In the codex-rs folder where the rust code lives:
- After dependency changes, run `just bazel-lock-check` from the repo root so lockfile drift is caught
locally before CI.
- Do not create small helper methods that are referenced only once.
- For TUI keybind changes, do not mutate old preset defaults; add a new preset version, keep `latest` as an explicit pointer (currently to `v1`), and update `docs/default-keymap.toml` plus `docs/config.md` with migration notes (`TODO(docs): mirror this on developers.openai.com`).
Run `just fmt` (in `codex-rs` directory) automatically after you have finished making Rust code changes; do not ask for approval to run it. Additionally, run the tests:

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.

View File

@@ -20,6 +20,8 @@ use crate::history_cell;
use crate::history_cell::HistoryCell;
#[cfg(not(debug_assertions))]
use crate::history_cell::UpdateAvailableHistoryCell;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::RuntimeKeymap;
use crate::model_migration::ModelMigrationOutcome;
use crate::model_migration::migration_copy_for_models;
use crate::model_migration::run_model_migration_prompt;
@@ -71,7 +73,6 @@ use codex_protocol::protocol::SessionConfiguredEvent;
use codex_utils_absolute_path::AbsolutePathBuf;
use color_eyre::eyre::Result;
use color_eyre::eyre::WrapErr;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::style::Stylize;
@@ -538,6 +539,7 @@ pub(crate) struct App {
has_emitted_history_lines: bool,
pub(crate) enhanced_keys_supported: bool,
pub(crate) keymap: RuntimeKeymap,
/// Controls the animation thread that sends CommitTick events.
pub(crate) commit_anim_running: Arc<AtomicBool>,
@@ -1183,6 +1185,13 @@ impl App {
.maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup);
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| {
color_eyre::eyre::eyre!(
"Invalid `tui.keymap` configuration: {err}\n\
Fix the config and retry.\n\
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
)
})?;
#[cfg(not(debug_assertions))]
let upgrade_version = crate::updates::get_upgrade_version(&config);
@@ -1200,6 +1209,7 @@ impl App {
runtime_sandbox_policy_override: None,
file_search,
enhanced_keys_supported,
keymap: runtime_keymap,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
@@ -1686,6 +1696,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_lines(
pager_lines,
"D I F F".to_string(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
}
@@ -2479,6 +2490,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::Exec { command, .. } => {
@@ -2488,6 +2500,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
self.keymap.pager.clone(),
));
}
ApprovalRequest::McpElicitation {
@@ -2505,6 +2518,7 @@ impl App {
self.overlay = Some(Overlay::new_static_with_renderables(
vec![Box::new(paragraph)],
"E L I C I T A T I O N".to_string(),
self.keymap.pager.clone(),
));
}
},
@@ -2784,63 +2798,60 @@ impl App {
}
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
match key_event {
KeyEvent {
code: KeyCode::Char('t'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
tui.frame_requester().schedule_frame();
}
KeyEvent {
code: KeyCode::Char('g'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
if self.keymap.app.open_transcript.is_pressed(key_event) {
// Enter alternate screen and set viewport to full size.
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
return;
}
if self.keymap.app.open_external_editor.is_pressed(key_event) {
// Only launch the external editor if there is no overlay and the bottom pane is not in use.
// Note that it can be launched while a task is running to enable editing while the previous turn is ongoing.
if self.overlay.is_none()
&& self.chat_widget.can_launch_external_editor()
&& self.chat_widget.external_editor_state() == ExternalEditorState::Closed
{
self.request_external_editor_launch(tui);
}
return;
}
if self.keymap.chat.edit_previous_message.is_pressed(key_event) {
// Esc primes/advances backtracking only in normal (not working) mode
// with the composer focused and empty. In any other state, forward
// Esc so the active UI (e.g. status indicator, modals, popups)
// handles it.
KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
if self.chat_widget.is_normal_backtrack_mode()
&& self.chat_widget.composer_is_empty()
{
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
if self.chat_widget.is_normal_backtrack_mode() && self.chat_widget.composer_is_empty() {
self.handle_backtrack_esc_key(tui);
} else {
self.chat_widget.handle_key_event(key_event);
}
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
} if self.backtrack.primed
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty() =>
{
if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection);
}
return;
}
if key_event.kind == KeyEventKind::Press
&& self
.keymap
.chat
.confirm_edit_previous_message
.is_pressed(key_event)
&& self.backtrack.primed
&& self.backtrack.nth_user_message != usize::MAX
&& self.chat_widget.composer_is_empty()
{
// Confirm backtrack when primed + count > 0.
if let Some(selection) = self.confirm_backtrack_from_main() {
self.apply_backtrack_selection(tui, selection);
}
return;
}
match key_event {
KeyEvent {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
@@ -2848,7 +2859,9 @@ impl App {
// Any non-Esc key press should cancel a primed backtrack.
// This avoids stale "Esc-primed" state after the user starts typing
// (even if they later backspace to empty).
if key_event.code != KeyCode::Esc && self.backtrack.primed {
if !self.keymap.chat.edit_previous_message.is_pressed(key_event)
&& self.backtrack.primed
{
self.reset_backtrack_state();
}
self.chat_widget.handle_key_event(key_event);
@@ -2919,6 +2932,7 @@ mod tests {
use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement;
use codex_protocol::user_input::UserInput;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use insta::assert_snapshot;
use pretty_assertions::assert_eq;
@@ -3166,6 +3180,8 @@ mod tests {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let otel_manager = test_otel_manager(&config, model.as_str());
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
.expect("test config should always produce a valid runtime keymap");
App {
server,
@@ -3180,6 +3196,7 @@ mod tests {
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
keymap,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
@@ -3223,6 +3240,8 @@ mod tests {
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
let model = codex_core::test_support::get_model_offline(config.model.as_deref());
let otel_manager = test_otel_manager(&config, model.as_str());
let keymap = RuntimeKeymap::from_config(&config.tui_keymap)
.expect("test config should always produce a valid runtime keymap");
(
App {
@@ -3238,6 +3257,7 @@ mod tests {
runtime_approval_policy_override: None,
runtime_sandbox_policy_override: None,
file_search,
keymap,
transcript_cells: Vec::new(),
overlay: None,
deferred_history_lines: Vec::new(),
@@ -3934,7 +3954,10 @@ mod tests {
false,
)) as Arc<dyn HistoryCell>,
];
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
app.overlay = Some(Overlay::new_transcript(
app.transcript_cells.clone(),
app.keymap.pager.clone(),
));
app.deferred_history_lines = vec![Line::from("stale buffered line")];
app.backtrack.overlay_preview_active = true;
app.backtrack.nth_user_message = 1;

View File

@@ -31,6 +31,7 @@ use crate::app::App;
use crate::app_event::AppEvent;
use crate::history_cell::SessionInfoCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint::KeyBindingListExt;
use crate::pager_overlay::Overlay;
use crate::tui;
use crate::tui::TuiEvent;
@@ -41,8 +42,6 @@ use codex_core::protocol::Op;
use codex_protocol::ThreadId;
use codex_protocol::user_input::TextElement;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
/// Aggregates all backtrack-related state used by the App.
@@ -110,60 +109,51 @@ impl App {
tui: &mut tui::Tui,
event: TuiEvent,
) -> Result<bool> {
if self.backtrack.overlay_preview_active {
match event {
TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
if let TuiEvent::Key(key_event) = event {
let is_repeat = matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat);
if self.backtrack.overlay_preview_active && is_repeat {
if self
.keymap
.pager
.edit_previous_message
.is_pressed(key_event)
{
self.overlay_step_backtrack(tui, TuiEvent::Key(key_event))?;
return Ok(true);
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Left,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack(tui, event)?;
Ok(true)
if self.keymap.pager.edit_next_message.is_pressed(key_event) {
self.overlay_step_backtrack_forward(tui, TuiEvent::Key(key_event))?;
return Ok(true);
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Right,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) => {
self.overlay_step_backtrack_forward(tui, event)?;
Ok(true)
}
TuiEvent::Key(KeyEvent {
code: KeyCode::Enter,
kind: KeyEventKind::Press,
..
}) => {
if key_event.kind == KeyEventKind::Press
&& self.keymap.pager.confirm_edit_message.is_pressed(key_event)
{
self.overlay_confirm_backtrack(tui);
Ok(true)
}
// Catchall: forward any other events to the overlay widget.
_ => {
self.overlay_forward_event(tui, event)?;
Ok(true)
return Ok(true);
}
} else if !self.backtrack.overlay_preview_active
&& is_repeat
&& self
.keymap
.pager
.edit_previous_message
.is_pressed(key_event)
{
// First press of the edit-previous key in transcript overlay:
// begin backtrack preview at latest user message.
self.begin_overlay_backtrack_preview(tui);
return Ok(true);
}
} else if let TuiEvent::Key(KeyEvent {
code: KeyCode::Esc,
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
}) = event
{
// First Esc in transcript overlay: begin backtrack preview at latest user message.
self.begin_overlay_backtrack_preview(tui);
Ok(true)
} else {
// Not in backtrack mode: forward events to the overlay widget.
self.overlay_forward_event(tui, event)?;
Ok(true)
}
if self.backtrack.overlay_preview_active {
// In backtrack mode, any non-matching key is forwarded to overlay.
self.overlay_forward_event(tui, event)?;
return Ok(true);
}
// Not in backtrack mode: forward events to the overlay widget.
self.overlay_forward_event(tui, event)?;
Ok(true)
}
/// Handle global Esc presses for backtracking when no overlay is present.
@@ -230,7 +220,10 @@ impl App {
/// Open transcript overlay (enters alternate screen and shows full transcript).
pub(crate) fn open_transcript_overlay(&mut self, tui: &mut tui::Tui) {
let _ = tui.enter_alt_screen();
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(),
self.keymap.pager.clone(),
));
tui.frame_requester().schedule_frame();
}

View File

@@ -1,3 +1,16 @@
//! Approval modal rendering and decision routing for high-risk operations.
//!
//! This module converts agent approval requests (exec/apply-patch/MCP
//! elicitation) into a list-selection view with action-specific options and
//! shortcuts. It owns two important contracts:
//!
//! 1. Selection always emits an explicit decision event back to the app.
//! 2. MCP elicitation keeps `Esc` mapped to `Cancel`, even with custom
//! keybindings, so dismissal never silently becomes "continue without info".
//!
//! This module does not evaluate whether an action is safe to run; it only
//! presents choices and routes user decisions.
use std::collections::HashMap;
use std::path::PathBuf;
@@ -13,6 +26,9 @@ use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ApprovalKeymap;
use crate::keymap::ListKeymap;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -27,7 +43,6 @@ use codex_protocol::mcp::RequestId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
@@ -70,20 +85,30 @@ pub(crate) struct ApprovalOverlay {
current_complete: bool,
done: bool,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
}
impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender, features: Features) -> Self {
pub fn new(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
) -> Self {
let mut view = Self {
current_request: None,
current_variant: None,
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(Default::default(), app_event_tx),
list: ListSelectionView::new(Default::default(), app_event_tx, list_keymap.clone()),
options: Vec::new(),
current_complete: false,
done: false,
features,
approval_keymap,
list_keymap,
};
view.set_current(request);
view
@@ -98,15 +123,18 @@ impl ApprovalOverlay {
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
self.current_variant = Some(variant.clone());
self.current_complete = false;
let (options, params) = Self::build_options(variant, header, &self.features);
let (options, params) =
Self::build_options(variant, header, &self.features, &self.approval_keymap);
self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
self.list =
ListSelectionView::new(params, self.app_event_tx.clone(), self.list_keymap.clone());
}
fn build_options(
variant: ApprovalVariant,
header: Box<dyn Renderable>,
_features: &Features,
features: &Features,
approval_keymap: &ApprovalKeymap,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
ApprovalVariant::Exec {
@@ -117,6 +145,8 @@ impl ApprovalOverlay {
exec_options(
proposed_execpolicy_amendment.clone(),
network_approval_context.as_ref(),
features,
approval_keymap,
),
network_approval_context.as_ref().map_or_else(
|| "Would you like to run the following command?".to_string(),
@@ -129,11 +159,11 @@ impl ApprovalOverlay {
),
),
ApprovalVariant::ApplyPatch { .. } => (
patch_options(),
patch_options(approval_keymap),
"Would you like to make the following edits?".to_string(),
),
ApprovalVariant::McpElicitation { server_name, .. } => (
elicitation_options(),
elicitation_options(approval_keymap),
format!("{server_name} needs your approval."),
),
};
@@ -148,9 +178,7 @@ impl ApprovalOverlay {
.iter()
.map(|opt| SelectionItem {
name: opt.label.clone(),
display_shortcut: opt
.display_shortcut
.or_else(|| opt.additional_shortcuts.first().copied()),
display_shortcut: opt.shortcuts.first().copied(),
dismiss_on_select: false,
..Default::default()
})
@@ -243,34 +271,29 @@ impl ApprovalOverlay {
}
}
/// Apply approval-specific shortcuts before delegating to list navigation.
///
/// `open_fullscreen` is handled here because it is orthogonal to list item
/// selection and should work regardless of current highlighted row.
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
match key_event {
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char('a'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(request) = self.current_request.as_ref() {
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
true
} else {
false
}
}
e => {
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcuts().any(|s| s.is_press(*e)))
{
self.apply_selection(idx);
true
} else {
false
}
}
if key_event.kind == KeyEventKind::Press
&& self.approval_keymap.open_fullscreen.is_pressed(*key_event)
&& let Some(request) = self.current_request.as_ref()
{
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
return true;
}
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcuts.iter().any(|s| s.is_press(*key_event)))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
@@ -452,41 +475,31 @@ enum ApprovalDecision {
struct ApprovalOption {
label: String,
decision: ApprovalDecision,
display_shortcut: Option<KeyBinding>,
additional_shortcuts: Vec<KeyBinding>,
}
impl ApprovalOption {
fn shortcuts(&self) -> impl Iterator<Item = KeyBinding> + '_ {
self.display_shortcut
.into_iter()
.chain(self.additional_shortcuts.iter().copied())
}
shortcuts: Vec<KeyBinding>,
}
fn exec_options(
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
network_approval_context: Option<&NetworkApprovalContext>,
_features: &Features,
keymap: &ApprovalKeymap,
) -> Vec<ApprovalOption> {
if network_approval_context.is_some() {
return vec![
ApprovalOption {
label: "Yes, just this once".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "Yes, and allow this host for this session".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
shortcuts: keymap.approve_for_session.clone(),
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
shortcuts: keymap.decline.clone(),
},
];
}
@@ -494,8 +507,7 @@ fn exec_options(
vec![ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
shortcuts: keymap.approve.clone(),
}]
.into_iter()
.chain(proposed_execpolicy_amendment.and_then(|prefix| {
@@ -511,61 +523,74 @@ fn exec_options(
decision: ApprovalDecision::Review(ReviewDecision::ApprovedExecpolicyAmendment {
proposed_execpolicy_amendment: prefix,
}),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('p'))],
shortcuts: keymap.approve_for_prefix.clone(),
})
}))
.chain([ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
shortcuts: keymap.decline.clone(),
}])
.collect()
}
fn patch_options() -> Vec<ApprovalOption> {
fn patch_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Yes, proceed".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Approved),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "Yes, and don't ask again for these files".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::ApprovedForSession),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('a'))],
shortcuts: keymap.approve_for_session.clone(),
},
ApprovalOption {
label: "No, and tell Codex what to do differently".to_string(),
decision: ApprovalDecision::Review(ReviewDecision::Abort),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
shortcuts: keymap.decline.clone(),
},
]
}
fn elicitation_options() -> Vec<ApprovalOption> {
/// Build MCP elicitation options with stable cancellation semantics.
///
/// `Esc` is always treated as cancel for elicitation prompts, even if users
/// customize `decline`/`cancel` bindings. We keep this as a hard contract so
/// dismissal remains a safe abort path and never silently maps to "continue
/// without requested info." Any decline/cancel overlap is removed from the
/// decline option in elicitation mode to preserve this invariant.
fn elicitation_options(keymap: &ApprovalKeymap) -> Vec<ApprovalOption> {
let mut cancel_shortcuts = vec![key_hint::plain(KeyCode::Esc)];
for shortcut in &keymap.cancel {
if !cancel_shortcuts.contains(shortcut) {
cancel_shortcuts.push(*shortcut);
}
}
let decline_shortcuts: Vec<KeyBinding> = keymap
.decline
.iter()
.copied()
.filter(|shortcut| !cancel_shortcuts.contains(shortcut))
.collect();
vec![
ApprovalOption {
label: "Yes, provide the requested info".to_string(),
decision: ApprovalDecision::McpElicitation(ElicitationAction::Accept),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('y'))],
shortcuts: keymap.approve.clone(),
},
ApprovalOption {
label: "No, but continue without it".to_string(),
decision: ApprovalDecision::McpElicitation(ElicitationAction::Decline),
display_shortcut: None,
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('n'))],
shortcuts: decline_shortcuts,
},
ApprovalOption {
label: "Cancel this request".to_string(),
decision: ApprovalDecision::McpElicitation(ElicitationAction::Cancel),
display_shortcut: Some(key_hint::plain(KeyCode::Esc)),
additional_shortcuts: vec![key_hint::plain(KeyCode::Char('c'))],
shortcuts: cancel_shortcuts,
},
]
}
@@ -575,9 +600,41 @@ mod tests {
use super::*;
use crate::app_event::AppEvent;
use codex_core::protocol::NetworkApprovalProtocol;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use tokio::sync::mpsc::unbounded_channel;
fn make_overlay(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
) -> ApprovalOverlay {
let keymap = crate::keymap::RuntimeKeymap::defaults();
make_overlay_with_keymap(
request,
app_event_tx,
features,
keymap.approval,
keymap.list,
)
}
fn make_overlay_with_keymap(
request: ApprovalRequest,
app_event_tx: AppEventSender,
features: Features,
approval_keymap: ApprovalKeymap,
list_keymap: ListKeymap,
) -> ApprovalOverlay {
ApprovalOverlay::new(
request,
app_event_tx,
features,
approval_keymap,
list_keymap,
)
}
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
@@ -588,11 +645,19 @@ mod tests {
}
}
fn make_elicitation_request() -> ApprovalRequest {
ApprovalRequest::McpElicitation {
server_name: "test-server".to_string(),
request_id: RequestId::String("request-1".to_string()),
message: "Need more information".to_string(),
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults());
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
view.enqueue_request(make_exec_request());
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c());
assert!(view.queue.is_empty());
@@ -603,7 +668,7 @@ mod tests {
fn shortcut_triggers_selection() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults());
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
assert!(!view.is_complete());
view.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
// We expect at least one CodexOp message in the queue.
@@ -621,7 +686,7 @@ mod tests {
fn exec_prefix_option_emits_execpolicy_amendment() {
let (tx, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx);
let mut view = ApprovalOverlay::new(
let mut view = make_overlay(
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string()],
@@ -669,7 +734,7 @@ mod tests {
proposed_execpolicy_amendment: None,
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
@@ -694,9 +759,12 @@ mod tests {
host: "example.com".to_string(),
protocol: NetworkApprovalProtocol::Https,
};
let keymap = crate::keymap::RuntimeKeymap::defaults();
let options = exec_options(
Some(ExecPolicyAmendment::new(vec!["curl".to_string()])),
Some(&network_context),
&Features::with_defaults(),
&keymap.approval,
);
let labels: Vec<String> = options.into_iter().map(|option| option.label).collect();
@@ -725,7 +793,7 @@ mod tests {
proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec!["curl".into()])),
};
let view = ApprovalOverlay::new(exec_request, tx, Features::with_defaults());
let view = make_overlay(exec_request, tx, Features::with_defaults());
let mut buf = Buffer::empty(Rect::new(0, 0, 100, view.desired_height(100)));
view.render(Rect::new(0, 0, 100, view.desired_height(100)), &mut buf);
@@ -749,6 +817,27 @@ mod tests {
);
}
#[test]
fn ctrl_shift_a_opens_fullscreen() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
let mut saw_fullscreen = false;
while let Ok(ev) = rx.try_recv() {
if matches!(ev, AppEvent::FullScreenApprovalRequest(_)) {
saw_fullscreen = true;
break;
}
}
assert!(saw_fullscreen, "expected ctrl+shift+a to open fullscreen");
}
#[test]
fn exec_history_cell_wraps_with_two_space_indent() {
let command = vec![
@@ -776,11 +865,85 @@ mod tests {
assert_eq!(rendered, expected);
}
#[test]
fn esc_cancels_mcp_elicitation() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = make_overlay(make_elicitation_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let mut decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ResolveElicitation { decision: d, .. }) = ev {
decision = Some(d);
break;
}
}
assert_eq!(decision, Some(ElicitationAction::Cancel));
}
#[test]
fn esc_still_cancels_elicitation_with_custom_overlap() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.approval.decline = vec![
key_hint::plain(KeyCode::Esc),
key_hint::plain(KeyCode::Char('n')),
];
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap(
make_elicitation_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let mut esc_decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ResolveElicitation { decision, .. }) = ev {
esc_decision = Some(decision);
break;
}
}
assert_eq!(esc_decision, Some(ElicitationAction::Cancel));
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
keymap.approval.decline = vec![
key_hint::plain(KeyCode::Esc),
key_hint::plain(KeyCode::Char('n')),
];
keymap.approval.cancel = vec![key_hint::plain(KeyCode::Char('x'))];
let mut view = make_overlay_with_keymap(
make_elicitation_request(),
tx,
Features::with_defaults(),
keymap.approval,
keymap.list,
);
view.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let mut n_decision = None;
while let Ok(ev) = rx.try_recv() {
if let AppEvent::CodexOp(Op::ResolveElicitation { decision, .. }) = ev {
n_decision = Some(decision);
break;
}
}
assert_eq!(n_decision, Some(ElicitationAction::Decline));
}
#[test]
fn enter_sets_last_selected_index_without_dismissing() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut view = ApprovalOverlay::new(make_exec_request(), tx, Features::with_defaults());
let mut view = make_overlay(make_exec_request(), tx, Features::with_defaults());
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(

View File

@@ -169,6 +169,10 @@ use crate::bottom_pane::prompt_args::parse_slash_name;
use crate::bottom_pane::prompt_args::prompt_argument_names;
use crate::bottom_pane::prompt_args::prompt_command_with_arg_placeholders;
use crate::bottom_pane::prompt_args::prompt_has_numeric_placeholders;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use crate::keymap::primary_binding;
use crate::render::Insets;
use crate::render::RectExt;
use crate::render::renderable::Renderable;
@@ -336,6 +340,13 @@ pub(crate) struct ChatComposer {
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
submit_keys: Vec<KeyBinding>,
queue_keys: Vec<KeyBinding>,
toggle_shortcuts_keys: Vec<KeyBinding>,
editor_keymap: EditorKeymap,
footer_external_editor_key: Option<KeyBinding>,
footer_edit_previous_key: Option<KeyBinding>,
footer_show_transcript_key: Option<KeyBinding>,
}
#[derive(Clone, Debug)]
@@ -436,6 +447,16 @@ impl ChatComposer {
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
submit_keys: vec![key_hint::plain(KeyCode::Enter)],
queue_keys: vec![key_hint::plain(KeyCode::Tab)],
toggle_shortcuts_keys: vec![
key_hint::plain(KeyCode::Char('?')),
key_hint::shift(KeyCode::Char('?')),
],
editor_keymap: RuntimeKeymap::defaults().editor,
footer_external_editor_key: Some(key_hint::ctrl(KeyCode::Char('g'))),
footer_edit_previous_key: Some(key_hint::plain(KeyCode::Esc)),
footer_show_transcript_key: Some(key_hint::ctrl(KeyCode::Char('t'))),
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -495,6 +516,17 @@ impl ChatComposer {
self.connectors_enabled = enabled;
}
pub(crate) fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.submit_keys = keymap.composer.submit.clone();
self.queue_keys = keymap.composer.queue.clone();
self.toggle_shortcuts_keys = keymap.composer.toggle_shortcuts.clone();
self.editor_keymap = keymap.editor.clone();
self.textarea.set_keymap_bindings(&self.editor_keymap);
self.footer_external_editor_key = primary_binding(&keymap.app.open_external_editor);
self.footer_edit_previous_key = primary_binding(&keymap.chat.edit_previous_message);
self.footer_show_transcript_key = primary_binding(&keymap.app.open_transcript);
}
pub fn set_collaboration_mode_indicator(
&mut self,
indicator: Option<CollaborationModeIndicator>,
@@ -1369,7 +1401,7 @@ impl ChatComposer {
if self.disable_paste_burst {
// When burst detection is disabled, treat IME/non-ASCII input as normal typing.
// In particular, do not retro-capture or buffer already-inserted prefix text.
self.textarea.input(input);
self.textarea.input_with_keymap(input, &self.editor_keymap);
let text_after = self.textarea.text();
self.pending_pastes
.retain(|(placeholder, _)| text_after.contains(placeholder));
@@ -1426,7 +1458,7 @@ impl ChatComposer {
if let Some(pasted) = self.paste_burst.flush_before_modified_input() {
self.handle_paste(pasted);
}
self.textarea.input(input);
self.textarea.input_with_keymap(input, &self.editor_keymap);
let text_after = self.textarea.text();
self.pending_pastes
@@ -2521,6 +2553,15 @@ impl ChatComposer {
} else {
self.footer_mode = reset_mode_after_activity(self.footer_mode);
}
if self.queue_keys.is_pressed(key_event) && self.is_task_running {
return self.handle_submission(true);
}
if self.submit_keys.is_pressed(key_event) {
let should_queue = !self.steer_enabled;
return self.handle_submission(should_queue);
}
match key_event {
KeyEvent {
code: KeyCode::Char('d'),
@@ -2738,7 +2779,7 @@ impl ChatComposer {
Some(self.textarea.element_payloads())
};
self.textarea.input(input);
self.textarea.input_with_keymap(input, &self.editor_keymap);
if let Some(elements_before) = elements_before {
self.reconcile_deleted_elements(elements_before);
@@ -2806,13 +2847,18 @@ impl ChatComposer {
}
}
/// Handle the dedicated shortcut-overlay toggle key(s).
///
/// This only toggles when the composer is empty and no paste burst is in
/// progress, so typing/pasting `?` still inserts text instead of opening
/// help. The bound key list intentionally supports terminal-variant
/// modifier reporting (for example `?` vs `shift-?`).
fn handle_shortcut_overlay_key(&mut self, key_event: &KeyEvent) -> bool {
if key_event.kind != KeyEventKind::Press {
return false;
}
let toggles = matches!(key_event.code, KeyCode::Char('?'))
&& !has_ctrl_or_alt(key_event.modifiers)
let toggles = self.toggle_shortcuts_keys.is_pressed(*key_event)
&& self.is_empty()
&& !self.is_in_paste_burst();
@@ -4535,6 +4581,30 @@ mod tests {
assert_eq!(composer.footer_mode(), FooterMode::ComposerHasDraft);
}
#[test]
fn shift_question_mark_toggles_shortcut_overlay_when_empty() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
composer.set_steer_enabled(true);
let (result, needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Char('?'), KeyModifiers::SHIFT));
assert_eq!(result, InputResult::None);
assert!(needs_redraw, "toggling overlay should request redraw");
assert_eq!(composer.footer_mode, FooterMode::ShortcutOverlay);
}
/// Behavior: while a paste-like burst is being captured, `?` must not toggle the shortcut
/// overlay; it should be treated as part of the pasted content.
#[test]

View File

@@ -723,10 +723,14 @@ fn shortcut_overlay_lines(state: ShortcutsState) -> Vec<Line<'static>> {
if change_mode.width() > 0 {
ordered.push(change_mode);
}
ordered.push(Line::from(""));
ordered.push(show_transcript);
build_columns(ordered)
let mut lines = build_columns(ordered);
lines.push(Line::from(""));
lines.push(Line::from(
"[tui.keymap] customize shortcuts in ~/.codex/config.toml",
));
lines
}
fn build_columns(entries: Vec<Line<'static>>) -> Vec<Line<'static>> {

View File

@@ -16,6 +16,8 @@ use super::selection_popup_common::render_menu_surface;
use super::selection_popup_common::wrap_styled_line;
use crate::app_event_sender::AppEventSender;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
@@ -120,6 +122,7 @@ pub(crate) struct ListSelectionView {
last_selected_actual_idx: Option<usize>,
header: Box<dyn Renderable>,
initial_selected_idx: Option<usize>,
keymap: ListKeymap,
}
impl ListSelectionView {
@@ -130,7 +133,11 @@ impl ListSelectionView {
/// When search is enabled, rows without `search_value` will disappear as
/// soon as the query is non-empty, which can look like dropped data unless
/// callers intentionally populate that field.
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
pub fn new(
params: SelectionViewParams,
app_event_tx: AppEventSender,
keymap: ListKeymap,
) -> Self {
let mut header = params.header;
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
@@ -161,6 +168,7 @@ impl ListSelectionView {
last_selected_actual_idx: None,
header,
initial_selected_idx: params.initial_selected_idx,
keymap,
};
s.apply_filter();
s
@@ -365,47 +373,46 @@ impl ListSelectionView {
impl BottomPaneView for ListSelectionView {
fn handle_key_event(&mut self, key_event: KeyEvent) {
let is_plain_text_char = matches!(
key_event,
KeyEvent {
code: KeyCode::Char(_),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL) && !modifiers.contains(KeyModifiers::ALT)
);
let allow_plain_char_navigation = !self.is_searchable || !is_plain_text_char;
let c0_ctrl_p = matches!(
key_event,
KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
}
);
let c0_ctrl_n = matches!(
key_event,
KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
}
);
match key_event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
// Handle fallbacks for Ctrl-P/N here so navigation works everywhere.
KeyEvent {
code: KeyCode::Up, ..
_ if c0_ctrl_p
|| (allow_plain_char_navigation && self.keymap.move_up.is_pressed(key_event)) =>
{
self.move_up()
}
| KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
_ if c0_ctrl_n
|| (allow_plain_char_navigation && self.keymap.move_down.is_pressed(key_event)) =>
{
self.move_down()
}
| KeyEvent {
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} /* ^P */ => self.move_up(),
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::NONE,
..
} if !self.is_searchable => self.move_up(),
KeyEvent {
code: KeyCode::Down,
..
}
| KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} /* ^N */ => self.move_down(),
KeyEvent {
code: KeyCode::Char('j'),
modifiers: KeyModifiers::NONE,
..
} if !self.is_searchable => self.move_down(),
KeyEvent {
code: KeyCode::Backspace,
..
@@ -413,9 +420,7 @@ impl BottomPaneView for ListSelectionView {
self.search_query.pop();
self.apply_filter();
}
KeyEvent {
code: KeyCode::Esc, ..
} => {
_ if self.keymap.cancel.is_pressed(key_event) => {
self.on_ctrl_c();
}
KeyEvent {
@@ -451,11 +456,7 @@ impl BottomPaneView for ListSelectionView {
self.accept();
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
..
} => self.accept(),
_ if self.keymap.accept.is_pressed(key_event) => self.accept(),
_ => {}
}
}
@@ -686,6 +687,10 @@ mod tests {
use ratatui::layout::Rect;
use tokio::sync::mpsc::unbounded_channel;
fn new_view(params: SelectionViewParams, tx: AppEventSender) -> ListSelectionView {
ListSelectionView::new(params, tx, crate::keymap::RuntimeKeymap::defaults().list)
}
fn make_selection_view(subtitle: Option<&str>) -> ListSelectionView {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
@@ -705,7 +710,7 @@ mod tests {
..Default::default()
},
];
ListSelectionView::new(
new_view(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
subtitle: subtitle.map(str::to_string),
@@ -782,6 +787,7 @@ mod tests {
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, width);
@@ -824,7 +830,7 @@ mod tests {
"Use /setup-default-sandbox".cyan(),
" to allow network access.".dim(),
]);
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_note: Some(footer_note),
@@ -851,7 +857,7 @@ mod tests {
dismiss_on_select: true,
..Default::default()
}];
let mut view = ListSelectionView::new(
let mut view = new_view(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
footer_hint: Some(standard_popup_hint_line()),
@@ -887,7 +893,7 @@ mod tests {
..Default::default()
},
];
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Approval".to_string()),
items,
@@ -945,7 +951,7 @@ mod tests {
..Default::default()
},
];
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Select Model and Effort".to_string()),
items,
@@ -979,7 +985,7 @@ mod tests {
..Default::default()
})
.collect();
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Debug".to_string()),
items,
@@ -1027,7 +1033,7 @@ mod tests {
..Default::default()
},
];
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Select Model and Effort".to_string()),
items,
@@ -1054,7 +1060,7 @@ mod tests {
..Default::default()
})
.collect();
let view = ListSelectionView::new(
let view = new_view(
SelectionViewParams {
title: Some("Debug".to_string()),
items,
@@ -1105,6 +1111,7 @@ mod tests {
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, 96);
@@ -1139,6 +1146,7 @@ mod tests {
..Default::default()
},
tx,
crate::keymap::RuntimeKeymap::defaults().list,
);
let before_scroll = render_lines_with_width(&view, width);

View File

@@ -21,6 +21,7 @@ use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::keymap::RuntimeKeymap;
use crate::render::renderable::FlexRenderable;
use crate::render::renderable::Renderable;
use crate::render::renderable::RenderableItem;
@@ -168,6 +169,7 @@ pub(crate) struct BottomPane {
queued_user_messages: QueuedUserMessages,
context_window_percent: Option<i64>,
context_window_used_tokens: Option<i64>,
keymap: RuntimeKeymap,
}
pub(crate) struct BottomPaneParams {
@@ -200,6 +202,8 @@ impl BottomPane {
placeholder_text,
disable_paste_burst,
);
let keymap = RuntimeKeymap::defaults();
composer.set_keymap_bindings(&keymap);
composer.set_skill_mentions(skills);
Self {
@@ -218,6 +222,7 @@ impl BottomPane {
animations_enabled,
context_window_percent: None,
context_window_used_tokens: None,
keymap,
}
}
@@ -247,6 +252,12 @@ impl BottomPane {
self.composer.take_recent_submission_mention_bindings()
}
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
self.keymap = keymap.clone();
self.composer.set_keymap_bindings(keymap);
self.request_redraw();
}
/// Clear pending attachments and mention bindings e.g. when a slash command doesn't submit text.
pub(crate) fn drain_pending_submission_state(&mut self) {
let _ = self.take_recent_submission_images_with_placeholders();
@@ -676,7 +687,11 @@ impl BottomPane {
/// Show a generic list selection view with the provided items.
pub(crate) fn show_selection_view(&mut self, params: list_selection_view::SelectionViewParams) {
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
let view = list_selection_view::ListSelectionView::new(
params,
self.app_event_tx.clone(),
self.keymap.list.clone(),
);
self.push_view(Box::new(view));
}
@@ -695,7 +710,11 @@ impl BottomPane {
}
self.view_stack.pop();
let view = list_selection_view::ListSelectionView::new(params, self.app_event_tx.clone());
let view = list_selection_view::ListSelectionView::new(
params,
self.app_event_tx.clone(),
self.keymap.list.clone(),
);
self.push_view(Box::new(view));
true
}
@@ -781,7 +800,13 @@ impl BottomPane {
};
// Otherwise create a new approval modal overlay.
let modal = ApprovalOverlay::new(request, self.app_event_tx.clone(), features.clone());
let modal = ApprovalOverlay::new(
request,
self.app_event_tx.clone(),
features.clone(),
self.keymap.approval.clone(),
self.keymap.list.clone(),
);
self.pause_status_timer_for_modal();
self.push_view(Box::new(modal));
}

View File

@@ -14,5 +14,6 @@ expression: terminal.backend()
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" ctrl + c to exit ctrl + t to view transcript "
" "
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "

View File

@@ -1,6 +1,5 @@
---
source: tui/src/bottom_pane/footer.rs
assertion_line: 535
expression: terminal.backend()
---
" / for commands ! for shell commands "
@@ -8,4 +7,6 @@ expression: terminal.backend()
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc esc to edit previous message "
" ctrl + c to exit shift + tab to change mode "
" ctrl + t to view transcript "
" ctrl + t to view transcript "
" "
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "

View File

@@ -6,5 +6,6 @@ expression: terminal.backend()
" shift + enter for newline tab to queue message "
" @ for file paths ctrl + v to paste images "
" ctrl + g to edit in external editor esc again to edit previous message "
" ctrl + c to exit "
" ctrl + t to view transcript "
" ctrl + c to exit ctrl + t to view transcript "
" "
" [tui.keymap] customize shortcuts in ~/.codex/config.toml "

View File

@@ -1,4 +1,7 @@
use crate::key_hint::KeyBindingListExt;
use crate::key_hint::is_altgr;
use crate::keymap::EditorKeymap;
use crate::keymap::RuntimeKeymap;
use codex_protocol::user_input::ByteRange;
use codex_protocol::user_input::TextElement as UserTextElement;
use crossterm::event::KeyCode;
@@ -45,6 +48,7 @@ pub(crate) struct TextArea {
elements: Vec<TextElement>,
next_element_id: u64,
kill_buffer: String,
editor_keymap: EditorKeymap,
}
#[derive(Debug, Clone)]
@@ -69,9 +73,14 @@ impl TextArea {
elements: Vec::new(),
next_element_id: 1,
kill_buffer: String::new(),
editor_keymap: RuntimeKeymap::defaults().editor,
}
}
pub fn set_keymap_bindings(&mut self, keymap: &EditorKeymap) {
self.editor_keymap = keymap.clone();
}
/// Replace the textarea text and clear any existing text elements.
pub fn set_text_clearing_elements(&mut self, text: &str) {
self.set_text_inner(text, None);
@@ -256,241 +265,160 @@ impl TextArea {
}
pub fn input(&mut self, event: KeyEvent) {
let keymap = self.editor_keymap.clone();
self.input_with_keymap(event, &keymap);
}
pub fn input_with_keymap(&mut self, event: KeyEvent, keymap: &EditorKeymap) {
match event {
// Some terminals (or configurations) send Control key chords as
// C0 control characters without reporting the CONTROL modifier.
// Handle common fallbacks for Ctrl-B/F/P/N here so they don't get
// inserted as literal control bytes.
KeyEvent { code: KeyCode::Char('\u{0002}'), modifiers: KeyModifiers::NONE, .. } /* ^B */ => {
self.move_cursor_left();
}
KeyEvent { code: KeyCode::Char('\u{0006}'), modifiers: KeyModifiers::NONE, .. } /* ^F */ => {
self.move_cursor_right();
}
KeyEvent { code: KeyCode::Char('\u{0010}'), modifiers: KeyModifiers::NONE, .. } /* ^P */ => {
self.move_cursor_up();
}
KeyEvent { code: KeyCode::Char('\u{000e}'), modifiers: KeyModifiers::NONE, .. } /* ^N */ => {
self.move_cursor_down();
}
// Keep these as compatibility fallbacks.
KeyEvent {
code: KeyCode::Char(c),
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
// for word navigation. Those are handled explicitly below.
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Char('j' | 'm'),
modifiers: KeyModifiers::CONTROL,
..
}
| KeyEvent {
code: KeyCode::Enter,
..
} => self.insert_str("\n"),
KeyEvent {
code: KeyCode::Char('h'),
modifiers,
..
} if modifiers == (KeyModifiers::CONTROL | KeyModifiers::ALT) => {
self.delete_backward_word()
},
// Windows AltGr generates ALT|CONTROL; treat as a plain character input unless
// we match a specific Control+Alt binding above.
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if is_altgr(modifiers) => self.insert_str(&c.to_string()),
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::ALT,
..
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
..
}
| KeyEvent {
code: KeyCode::Char('h'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_backward(1),
KeyEvent {
code: KeyCode::Delete,
modifiers: KeyModifiers::ALT,
..
} => self.delete_forward_word(),
KeyEvent {
code: KeyCode::Delete,
..
}
| KeyEvent {
code: KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
} => self.delete_forward(1),
KeyEvent {
code: KeyCode::Char('w'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.delete_backward_word();
}
// Meta-b -> move to beginning of previous word
// Meta-f -> move to end of next word
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::ALT,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Char('u'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.kill_to_beginning_of_line();
}
KeyEvent {
code: KeyCode::Char('k'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.kill_to_end_of_line();
}
KeyEvent {
code: KeyCode::Char('y'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.yank();
}
// Cursor movement
KeyEvent {
code: KeyCode::Left,
code: KeyCode::Char('\u{0002}'),
modifiers: KeyModifiers::NONE,
..
} => {
} /* ^B */ => {
self.move_cursor_left();
return;
}
KeyEvent {
code: KeyCode::Right,
code: KeyCode::Char('\u{0006}'),
modifiers: KeyModifiers::NONE,
..
} => {
} /* ^F */ => {
self.move_cursor_right();
return;
}
KeyEvent {
code: KeyCode::Char('b'),
modifiers: KeyModifiers::CONTROL,
code: KeyCode::Char('\u{0010}'),
modifiers: KeyModifiers::NONE,
..
} => {
self.move_cursor_left();
}
KeyEvent {
code: KeyCode::Char('f'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_right();
}
KeyEvent {
code: KeyCode::Char('p'),
modifiers: KeyModifiers::CONTROL,
..
} => {
} /* ^P */ => {
self.move_cursor_up();
return;
}
KeyEvent {
code: KeyCode::Char('n'),
modifiers: KeyModifiers::CONTROL,
code: KeyCode::Char('\u{000e}'),
modifiers: KeyModifiers::NONE,
..
} => {
} /* ^N */ => {
self.move_cursor_down();
return;
}
// Some terminals send Alt+Arrow for word-wise movement:
// Option/Left -> Alt+Left (previous word start)
// Option/Right -> Alt+Right (next word end)
KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Left,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.beginning_of_previous_word());
}
KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::ALT,
..
}
| KeyEvent {
code: KeyCode::Right,
modifiers: KeyModifiers::CONTROL,
..
} => {
self.set_cursor(self.end_of_next_word());
}
KeyEvent {
code: KeyCode::Up, ..
} => {
self.move_cursor_up();
}
KeyEvent {
code: KeyCode::Down,
..
} => {
self.move_cursor_down();
}
KeyEvent {
code: KeyCode::Home,
..
} => {
self.move_cursor_to_beginning_of_line(false);
}
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_to_beginning_of_line(true);
}
KeyEvent {
code: KeyCode::End, ..
} => {
self.move_cursor_to_end_of_line(false);
}
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
} => {
self.move_cursor_to_end_of_line(true);
}
_o => {
#[cfg(feature = "debug-logs")]
tracing::debug!("Unhandled key event in TextArea: {:?}", _o);
}
_ => {}
}
if keymap.insert_newline.is_pressed(event) {
self.insert_str("\n");
return;
}
if keymap.delete_backward_word.is_pressed(event) {
self.delete_backward_word();
return;
}
// Windows AltGr generates ALT|CONTROL. Preserve typed characters for AltGr users
// unless a specific shortcut already matched above.
if let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = event
&& is_altgr(modifiers)
{
self.insert_str(&c.to_string());
return;
}
if keymap.delete_backward.is_pressed(event) {
self.delete_backward(1);
return;
}
if keymap.delete_forward_word.is_pressed(event) {
self.delete_forward_word();
return;
}
if keymap.delete_forward.is_pressed(event) {
self.delete_forward(1);
return;
}
if keymap.kill_line_start.is_pressed(event) {
self.kill_to_beginning_of_line();
return;
}
if keymap.kill_line_end.is_pressed(event) {
self.kill_to_end_of_line();
return;
}
if keymap.yank.is_pressed(event) {
self.yank();
return;
}
if keymap.move_word_left.is_pressed(event) {
self.set_cursor(self.beginning_of_previous_word());
return;
}
if keymap.move_word_right.is_pressed(event) {
self.set_cursor(self.end_of_next_word());
return;
}
if keymap.move_left.is_pressed(event) {
self.move_cursor_left();
return;
}
if keymap.move_right.is_pressed(event) {
self.move_cursor_right();
return;
}
if keymap.move_up.is_pressed(event) {
self.move_cursor_up();
return;
}
if keymap.move_down.is_pressed(event) {
self.move_cursor_down();
return;
}
if keymap.move_line_start.is_pressed(event) {
let move_up_at_bol = matches!(
event,
KeyEvent {
code: KeyCode::Char('a'),
modifiers: KeyModifiers::CONTROL,
..
}
);
self.move_cursor_to_beginning_of_line(move_up_at_bol);
return;
}
if keymap.move_line_end.is_pressed(event) {
let move_down_at_eol = matches!(
event,
KeyEvent {
code: KeyCode::Char('e'),
modifiers: KeyModifiers::CONTROL,
..
}
);
self.move_cursor_to_end_of_line(move_down_at_eol);
return;
}
if let KeyEvent {
code: KeyCode::Char(c),
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
..
} = event
{
// Insert plain characters (and Shift-modified). Do not insert when ALT is held,
// because many terminals map Option/Meta combos to ALT+<char>.
self.insert_str(&c.to_string());
return;
}
#[cfg(feature = "debug-logs")]
tracing::debug!("Unhandled key event in TextArea: {:?}", event);
}
// ####### Input Functions #######

View File

@@ -199,6 +199,7 @@ use crate::history_cell::PlainHistoryCell;
use crate::history_cell::WebSearchCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::keymap::RuntimeKeymap;
use crate::markdown::append_markdown;
use crate::multi_agents;
use crate::render::Insets;
@@ -2690,6 +2691,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
@@ -2853,6 +2857,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));
@@ -3005,6 +3012,9 @@ impl ChatWidget {
};
widget.prefetch_rate_limits();
if let Ok(keymap) = RuntimeKeymap::from_config(&widget.config.tui_keymap) {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_steer_enabled(widget.config.features.enabled(Feature::Steer));

View File

@@ -15,7 +15,7 @@ const ALT_PREFIX: &str = "alt + ";
const CTRL_PREFIX: &str = "ctrl + ";
const SHIFT_PREFIX: &str = "shift + ";
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct KeyBinding {
key: KeyCode,
modifiers: KeyModifiers,
@@ -31,6 +31,22 @@ impl KeyBinding {
&& self.modifiers == event.modifiers
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
}
pub(crate) const fn parts(&self) -> (KeyCode, KeyModifiers) {
(self.key, self.modifiers)
}
}
/// Matching helpers for one action's keybinding set.
pub(crate) trait KeyBindingListExt {
/// True when any binding in this set matches `event`.
fn is_pressed(&self, event: KeyEvent) -> bool;
}
impl KeyBindingListExt for [KeyBinding] {
fn is_pressed(&self, event: KeyEvent) -> bool {
self.iter().any(|binding| binding.is_press(event))
}
}
pub(crate) const fn plain(key: KeyCode) -> KeyBinding {
@@ -110,3 +126,60 @@ pub(crate) fn is_altgr(mods: KeyModifiers) -> bool {
pub(crate) fn is_altgr(_mods: KeyModifiers) -> bool {
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_press_accepts_press_and_repeat_but_rejects_release() {
let binding = ctrl(KeyCode::Char('k'));
let press = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL);
let repeat = KeyEvent {
kind: KeyEventKind::Repeat,
..press
};
let release = KeyEvent {
kind: KeyEventKind::Release,
..press
};
let wrong_modifiers = KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE);
assert!(binding.is_press(press));
assert!(binding.is_press(repeat));
assert!(!binding.is_press(release));
assert!(!binding.is_press(wrong_modifiers));
}
#[test]
fn keybinding_list_ext_matches_any_binding() {
let bindings = [plain(KeyCode::Char('a')), ctrl(KeyCode::Char('b'))];
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)));
assert!(bindings.is_pressed(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::CONTROL)));
assert!(!bindings.is_pressed(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)));
}
#[test]
fn ctrl_alt_sets_both_modifiers() {
assert_eq!(
ctrl_alt(KeyCode::Char('v')).parts(),
(
KeyCode::Char('v'),
KeyModifiers::CONTROL | KeyModifiers::ALT
)
);
}
#[test]
fn has_ctrl_or_alt_checks_supported_modifier_combinations() {
assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL));
assert!(has_ctrl_or_alt(KeyModifiers::ALT));
#[cfg(windows)]
assert!(!has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
#[cfg(not(windows))]
assert!(has_ctrl_or_alt(KeyModifiers::CONTROL | KeyModifiers::ALT));
}
}

1128
codex-rs/tui/src/keymap.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,7 @@ mod get_git_diff;
mod history_cell;
pub mod insert_history;
mod key_hint;
mod keymap;
pub mod live_wrap;
mod markdown;
mod markdown_render;

View File

@@ -1,3 +1,10 @@
//! Authentication step UI and state transitions used by onboarding.
//!
//! This module owns the auth-step state machine (ChatGPT login/device-code/API
//! key), renders the corresponding UI, and handles auth-scoped keyboard input.
//! It intentionally does not decide onboarding flow completion; the enclosing
//! onboarding screen coordinates step progression.
#![allow(clippy::unwrap_used)]
use codex_core::AuthManager;
@@ -35,6 +42,11 @@ use codex_protocol::config_types::ForcedLoginMethod;
use std::sync::RwLock;
use crate::LoginStatus;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::keymap::primary_binding;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::shimmer::shimmer_spans;
@@ -100,55 +112,58 @@ impl KeyboardHandler for AuthModeWidget {
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.move_highlight(-1);
}
KeyCode::Down | KeyCode::Char('j') => {
self.move_highlight(1);
}
KeyCode::Char('1') => {
self.select_option_by_index(0);
}
KeyCode::Char('2') => {
self.select_option_by_index(1);
}
KeyCode::Char('3') => {
self.select_option_by_index(2);
}
KeyCode::Enter => {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
_ => {}
if self.onboarding_keymap.move_up.is_pressed(key_event) {
self.move_highlight(-1);
return;
}
if self.onboarding_keymap.move_down.is_pressed(key_event) {
self.move_highlight(1);
return;
}
if self.onboarding_keymap.select_first.is_pressed(key_event) {
self.select_option_by_index(0);
return;
}
if self.onboarding_keymap.select_second.is_pressed(key_event) {
self.select_option_by_index(1);
return;
}
if self.onboarding_keymap.select_third.is_pressed(key_event) {
self.select_option_by_index(2);
return;
}
if self.onboarding_keymap.confirm.is_pressed(key_event) {
let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() };
match sign_in_state {
SignInState::PickMode => {
self.handle_sign_in_option(self.highlighted_mode);
}
}
KeyCode::Esc => {
tracing::info!("Esc pressed");
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(_) => {
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
_ => {}
SignInState::ChatGptSuccessMessage => {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
}
_ => {}
}
return;
}
if self.onboarding_keymap.cancel.is_pressed(key_event) {
tracing::info!("Cancel onboarding auth step");
let mut sign_in_state = self.sign_in_state.write().unwrap();
match &*sign_in_state {
SignInState::ChatGptContinueInBrowser(_) => {
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
SignInState::ChatGptDeviceCode(state) => {
if let Some(cancel) = &state.cancel {
cancel.notify_one();
}
*sign_in_state = SignInState::PickMode;
drop(sign_in_state);
self.request_frame.schedule_frame();
}
_ => {}
}
_ => {}
}
}
@@ -170,9 +185,32 @@ pub(crate) struct AuthModeWidget {
pub forced_chatgpt_workspace_id: Option<String>,
pub forced_login_method: Option<ForcedLoginMethod>,
pub animations_enabled: bool,
pub onboarding_keymap: OnboardingKeymap,
}
impl AuthModeWidget {
/// Returns whether the auth flow is currently in API-key entry mode.
pub(crate) fn is_api_key_entry_active(&self) -> bool {
self.sign_in_state
.read()
.is_ok_and(|guard| matches!(&*guard, SignInState::ApiKeyEntry(_)))
}
/// Returns whether the API-key entry field currently contains any text.
pub(crate) fn api_key_entry_has_text(&self) -> bool {
self.sign_in_state.read().is_ok_and(
|guard| matches!(&*guard, SignInState::ApiKeyEntry(state) if !state.value.is_empty()),
)
}
fn confirm_binding(&self) -> KeyBinding {
primary_binding(&self.onboarding_keymap.confirm).unwrap_or(key_hint::plain(KeyCode::Enter))
}
fn cancel_binding(&self) -> KeyBinding {
primary_binding(&self.onboarding_keymap.cancel).unwrap_or(key_hint::plain(KeyCode::Esc))
}
fn is_api_login_allowed(&self) -> bool {
!matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt))
}
@@ -342,11 +380,11 @@ impl AuthModeWidget {
);
lines.push("".into());
}
lines.push(
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
// But leaving this for a future cleanup.
" Press Enter to continue".dim().into(),
);
lines.push(Line::from(vec![
" Press ".dim(),
self.confirm_binding().into(),
" to continue".dim(),
]));
if let Some(err) = &self.error {
lines.push("".into());
lines.push(err.as_str().red().into());
@@ -381,14 +419,20 @@ impl AuthModeWidget {
]));
lines.push("".into());
lines.push(Line::from(vec![
" On a remote or headless machine? Press Esc and choose ".into(),
" On a remote or headless machine? Press ".into(),
self.cancel_binding().into(),
" and choose ".into(),
"Sign in with Device Code".cyan(),
".".into(),
]));
lines.push("".into());
}
lines.push(" Press Esc to cancel".dim().into());
lines.push(Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to cancel".dim(),
]));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
@@ -417,7 +461,11 @@ impl AuthModeWidget {
])
.dim(),
"".into(),
" Press Enter to continue".fg(Color::Cyan).into(),
Line::from(vec![
" Press ".fg(Color::Cyan),
self.confirm_binding().into(),
" to continue".fg(Color::Cyan),
]),
];
Paragraph::new(lines)
@@ -496,8 +544,16 @@ impl AuthModeWidget {
.render(input_area, buf);
let mut footer_lines: Vec<Line> = vec![
" Press Enter to save".dim().into(),
" Press Esc to go back".dim().into(),
Line::from(vec![
" Press ".dim(),
self.confirm_binding().into(),
" to save".dim(),
]),
Line::from(vec![
" Press ".dim(),
self.cancel_binding().into(),
" to go back".dim(),
]),
];
if let Some(error) = &self.error {
footer_lines.push("".into());
@@ -515,46 +571,46 @@ impl AuthModeWidget {
{
let mut guard = self.sign_in_state.write().unwrap();
if let SignInState::ApiKeyEntry(state) = &mut *guard {
match key_event.code {
KeyCode::Esc => {
*guard = SignInState::PickMode;
self.error = None;
if self.onboarding_keymap.cancel.is_pressed(*key_event) {
*guard = SignInState::PickMode;
self.error = None;
should_request_frame = true;
} else if self.onboarding_keymap.confirm.is_pressed(*key_event) {
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.error = Some("API key cannot be empty".to_string());
should_request_frame = true;
} else {
should_save = Some(trimmed);
}
KeyCode::Enter => {
let trimmed = state.value.trim().to_string();
if trimmed.is_empty() {
self.error = Some("API key cannot be empty".to_string());
} else {
match key_event.code {
KeyCode::Backspace => {
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
} else {
state.value.pop();
}
self.error = None;
should_request_frame = true;
} else {
should_save = Some(trimmed);
}
}
KeyCode::Backspace => {
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
} else {
state.value.pop();
KeyCode::Char(c)
if key_event.kind == KeyEventKind::Press
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
}
state.value.push(c);
self.error = None;
should_request_frame = true;
}
self.error = None;
should_request_frame = true;
_ => {}
}
KeyCode::Char(c)
if key_event.kind == KeyEventKind::Press
&& !key_event.modifiers.contains(KeyModifiers::SUPER)
&& !key_event.modifiers.contains(KeyModifiers::CONTROL)
&& !key_event.modifiers.contains(KeyModifiers::ALT) =>
{
if state.prepopulated_from_env {
state.value.clear();
state.prepopulated_from_env = false;
}
state.value.push(c);
self.error = None;
should_request_frame = true;
}
_ => {}
}
// handled; let guard drop before potential save
} else {
@@ -812,6 +868,7 @@ mod tests {
forced_chatgpt_workspace_id: None,
forced_login_method: Some(ForcedLoginMethod::Chatgpt),
animations_enabled: true,
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
};
(widget, codex_home)
}

View File

@@ -181,7 +181,11 @@ pub(super) fn render_device_code_login(
lines.push("".into());
}
lines.push(" Press Esc to cancel".dim().into());
lines.push(Line::from(vec![
" Press ".dim(),
widget.cancel_binding().into(),
" to cancel".dim(),
]));
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);

View File

@@ -1,3 +1,15 @@
//! Onboarding screen orchestration and top-level keyboard routing.
//!
//! The onboarding flow is a small state machine over visible steps
//! (welcome/auth/trust). This module decides which step receives key/paste
//! events and enforces flow-level safety rules that cut across individual step
//! widgets.
//!
//! In particular, onboarding quit handling has a text-entry guard for API-key
//! input: printable quit bindings are treated as text input while the user is
//! editing a non-empty API-key field, while control/alt chords remain available
//! as explicit exit shortcuts.
use codex_core::AuthManager;
use codex_core::config::Config;
#[cfg(target_os = "windows")]
@@ -7,6 +19,7 @@ use codex_protocol::config_types::WindowsSandboxLevel;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -17,6 +30,9 @@ use ratatui::widgets::WidgetRef;
use codex_protocol::config_types::ForcedLoginMethod;
use crate::LoginStatus;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::keymap::RuntimeKeymap;
use crate::onboarding::auth::AuthModeWidget;
use crate::onboarding::auth::SignInOption;
use crate::onboarding::auth::SignInState;
@@ -56,6 +72,7 @@ pub(crate) trait StepStateProvider {
pub(crate) struct OnboardingScreen {
request_frame: FrameRequester,
steps: Vec<Step>,
onboarding_keymap: OnboardingKeymap,
is_done: bool,
should_exit: bool,
}
@@ -73,6 +90,14 @@ pub(crate) struct OnboardingResult {
pub should_exit: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
struct ApiKeyEntryContext {
/// True when onboarding is currently rendering the API-key entry state.
active: bool,
/// True when the API-key input field currently contains user text.
has_text: bool,
}
impl OnboardingScreen {
pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self {
let OnboardingScreenArgs {
@@ -87,11 +112,22 @@ impl OnboardingScreen {
let forced_login_method = config.forced_login_method;
let codex_home = config.codex_home.clone();
let cli_auth_credentials_store_mode = config.cli_auth_credentials_store_mode;
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).unwrap_or_else(|err| {
tracing::warn!(
"Invalid `tui.keymap` configuration during onboarding: {err}. \
Falling back to default onboarding keymap values. \
Fix `~/.codex/config.toml`.\n\
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml"
);
RuntimeKeymap::defaults()
});
let onboarding_keymap = runtime_keymap.onboarding;
let mut steps: Vec<Step> = Vec::new();
steps.push(Step::Welcome(WelcomeWidget::new(
!matches!(login_status, LoginStatus::NotAuthenticated),
tui.frame_requester(),
config.animations,
onboarding_keymap.clone(),
)));
if show_login_screen {
let highlighted_mode = match forced_login_method {
@@ -110,6 +146,7 @@ impl OnboardingScreen {
forced_chatgpt_workspace_id,
forced_login_method,
animations_enabled: config.animations,
onboarding_keymap: onboarding_keymap.clone(),
}))
}
#[cfg(target_os = "windows")]
@@ -127,12 +164,14 @@ impl OnboardingScreen {
selection: None,
highlighted,
error: None,
onboarding_keymap: onboarding_keymap.clone(),
}))
}
// TODO: add git warning.
Self {
request_frame: tui.frame_requester(),
steps,
onboarding_keymap,
is_done: false,
should_exit: false,
}
@@ -199,45 +238,39 @@ impl OnboardingScreen {
self.should_exit
}
fn is_api_key_entry_active(&self) -> bool {
self.steps.iter().any(|step| {
if let Step::Auth(widget) = step {
return widget
.sign_in_state
.read()
.is_ok_and(|g| matches!(&*g, SignInState::ApiKeyEntry(_)));
}
false
})
fn api_key_entry_context(&self) -> ApiKeyEntryContext {
self.steps
.iter()
.find_map(|step| {
if let Step::Auth(widget) = step {
Some(ApiKeyEntryContext {
active: widget.is_api_key_entry_active(),
has_text: widget.api_key_entry_has_text(),
})
} else {
None
}
})
.unwrap_or_default()
}
}
impl KeyboardHandler for OnboardingScreen {
/// Route key events to onboarding steps while preserving text-entry safety.
///
/// In API-key entry mode, printable quit bindings are suppressed only after
/// the user has started typing in the API-key field. This keeps custom
/// printable quit keys usable on an empty field while protecting in-progress
/// text entry from accidental exits. Control/alt quit chords still work as
/// emergency exits.
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
return;
}
let is_api_key_entry_active = self.is_api_key_entry_active();
let should_quit = match key_event {
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
}
| KeyEvent {
code: KeyCode::Char('c'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => true,
KeyEvent {
code: KeyCode::Char('q'),
kind: KeyEventKind::Press,
..
} => !is_api_key_entry_active,
_ => false,
};
let api_key_entry_context = self.api_key_entry_context();
let should_quit = key_event.kind == KeyEventKind::Press
&& self.onboarding_keymap.quit.is_pressed(key_event)
&& !suppress_quit_while_typing_api_key(key_event, api_key_entry_context);
if should_quit {
if self.is_auth_in_progress() {
// If the user cancels the auth menu, exit the app rather than
@@ -282,6 +315,24 @@ impl KeyboardHandler for OnboardingScreen {
}
}
/// Returns `true` when a quit shortcut should be ignored as text input.
///
/// This only applies while API-key entry is active and the key is a printable
/// character without control/alt modifiers and there is already text in the
/// input field. Empty input intentionally does not trigger suppression so
/// printable custom quit bindings can still exit onboarding.
fn suppress_quit_while_typing_api_key(
key_event: KeyEvent,
api_key_entry_context: ApiKeyEntryContext,
) -> bool {
api_key_entry_context.active
&& api_key_entry_context.has_text
&& matches!(key_event.code, KeyCode::Char(_))
&& !key_event
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
}
impl WidgetRef for &OnboardingScreen {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
@@ -461,3 +512,60 @@ pub(crate) async fn run_onboarding_app(
should_exit: onboarding_screen.should_exit(),
})
}
#[cfg(test)]
mod tests {
use super::ApiKeyEntryContext;
use super::suppress_quit_while_typing_api_key;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
#[test]
fn suppresses_printable_custom_quit_key_during_api_key_entry() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
ApiKeyEntryContext {
active: true,
has_text: true,
},
);
assert!(suppressed);
}
#[test]
fn does_not_suppress_printable_quit_key_when_api_key_input_is_empty() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
ApiKeyEntryContext {
active: true,
has_text: false,
},
);
assert!(!suppressed);
}
#[test]
fn does_not_suppress_control_quit_key_during_api_key_entry() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL),
ApiKeyEntryContext {
active: true,
has_text: true,
},
);
assert!(!suppressed);
}
#[test]
fn does_not_suppress_when_not_in_api_key_entry() {
let suppressed = suppress_quit_while_typing_api_key(
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE),
ApiKeyEntryContext {
active: false,
has_text: true,
},
);
assert!(!suppressed);
}
}

View File

@@ -15,6 +15,9 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::keymap::primary_binding;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::render::Insets;
@@ -32,6 +35,7 @@ pub(crate) struct TrustDirectoryWidget {
pub selection: Option<TrustDirectorySelection>,
pub highlighted: TrustDirectorySelection,
pub error: Option<String>,
pub onboarding_keymap: OnboardingKeymap,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -88,7 +92,9 @@ impl WidgetRef for &TrustDirectoryWidget {
column.push(
Line::from(vec![
"Press ".dim(),
key_hint::plain(KeyCode::Enter).into(),
primary_binding(&self.onboarding_keymap.confirm)
.unwrap_or(key_hint::plain(KeyCode::Enter))
.into(),
if self.show_windows_create_sandbox_hint {
" to continue and create a sandbox...".dim()
} else {
@@ -108,20 +114,22 @@ impl KeyboardHandler for TrustDirectoryWidget {
return;
}
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.highlighted = TrustDirectorySelection::Trust;
}
KeyCode::Down | KeyCode::Char('j') => {
self.highlighted = TrustDirectorySelection::Quit;
}
KeyCode::Char('1') | KeyCode::Char('y') => self.handle_trust(),
KeyCode::Char('2') | KeyCode::Char('n') => self.handle_quit(),
KeyCode::Enter => match self.highlighted {
if self.onboarding_keymap.move_up.is_pressed(key_event) {
self.highlighted = TrustDirectorySelection::Trust;
} else if self.onboarding_keymap.move_down.is_pressed(key_event) {
self.highlighted = TrustDirectorySelection::Quit;
} else if self.onboarding_keymap.select_first.is_pressed(key_event) {
self.handle_trust();
} else if self.onboarding_keymap.select_second.is_pressed(key_event)
|| self.onboarding_keymap.quit.is_pressed(key_event)
|| self.onboarding_keymap.cancel.is_pressed(key_event)
{
self.handle_quit();
} else if self.onboarding_keymap.confirm.is_pressed(key_event) {
match self.highlighted {
TrustDirectorySelection::Trust => self.handle_trust(),
TrustDirectorySelection::Quit => self.handle_quit(),
},
_ => {}
}
}
}
}
@@ -183,6 +191,7 @@ mod tests {
selection: None,
highlighted: TrustDirectorySelection::Quit,
error: None,
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
};
let release = KeyEvent {
@@ -208,6 +217,7 @@ mod tests {
selection: None,
highlighted: TrustDirectorySelection::Trust,
error: None,
onboarding_keymap: crate::keymap::RuntimeKeymap::defaults().onboarding,
};
let mut terminal = Terminal::new(VT100Backend::new(70, 14)).expect("terminal");

View File

@@ -1,7 +1,5 @@
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -14,6 +12,9 @@ use ratatui::widgets::Wrap;
use std::cell::Cell;
use crate::ascii_animation::AsciiAnimation;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::OnboardingKeymap;
use crate::onboarding::onboarding_screen::KeyboardHandler;
use crate::onboarding::onboarding_screen::StepStateProvider;
use crate::tui::FrameRequester;
@@ -27,17 +28,21 @@ pub(crate) struct WelcomeWidget {
pub is_logged_in: bool,
animation: AsciiAnimation,
animations_enabled: bool,
toggle_animation_keys: Vec<KeyBinding>,
layout_area: Cell<Option<Rect>>,
}
impl KeyboardHandler for WelcomeWidget {
/// Rotate the welcome animation when the configured toggle shortcut fires.
///
/// The key list comes from runtime keymap defaults/overrides and can
/// include compatibility variants for terminals that report modifier bits
/// differently.
fn handle_key_event(&mut self, key_event: KeyEvent) {
if !self.animations_enabled {
return;
}
if key_event.kind == KeyEventKind::Press
&& key_event.code == KeyCode::Char('.')
&& key_event.modifiers.contains(KeyModifiers::CONTROL)
if key_event.kind == KeyEventKind::Press && self.toggle_animation_keys.is_pressed(key_event)
{
tracing::warn!("Welcome background to press '.'");
let _ = self.animation.pick_random_variant();
@@ -50,11 +55,13 @@ impl WelcomeWidget {
is_logged_in: bool,
request_frame: FrameRequester,
animations_enabled: bool,
onboarding_keymap: OnboardingKeymap,
) -> Self {
Self {
is_logged_in,
animation: AsciiAnimation::new(request_frame),
animations_enabled,
toggle_animation_keys: onboarding_keymap.toggle_animation,
layout_area: Cell::new(None),
}
}
@@ -108,6 +115,8 @@ impl StepStateProvider for WelcomeWidget {
#[cfg(test)]
mod tests {
use super::*;
use crossterm::event::KeyCode;
use crossterm::event::KeyModifiers;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
@@ -128,7 +137,12 @@ mod tests {
#[test]
fn welcome_renders_animation_on_first_draw() {
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
let widget = WelcomeWidget::new(
false,
FrameRequester::test_dummy(),
true,
crate::keymap::RuntimeKeymap::defaults().onboarding,
);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT);
let mut buf = Buffer::empty(area);
let frame_lines = widget.animation.current_frame().lines().count() as u16;
@@ -140,7 +154,12 @@ mod tests {
#[test]
fn welcome_skips_animation_below_height_breakpoint() {
let widget = WelcomeWidget::new(false, FrameRequester::test_dummy(), true);
let widget = WelcomeWidget::new(
false,
FrameRequester::test_dummy(),
true,
crate::keymap::RuntimeKeymap::defaults().onboarding,
);
let area = Rect::new(0, 0, MIN_ANIMATION_WIDTH, MIN_ANIMATION_HEIGHT - 1);
let mut buf = Buffer::empty(area);
(&widget).render(area, &mut buf);
@@ -155,6 +174,9 @@ mod tests {
is_logged_in: false,
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
animations_enabled: true,
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
.onboarding
.toggle_animation,
layout_area: Cell::new(None),
};
@@ -167,4 +189,29 @@ mod tests {
"expected ctrl+. to switch welcome animation variant"
);
}
#[test]
fn ctrl_shift_dot_changes_animation_variant() {
let mut widget = WelcomeWidget {
is_logged_in: false,
animation: AsciiAnimation::with_variants(FrameRequester::test_dummy(), &VARIANTS, 0),
animations_enabled: true,
toggle_animation_keys: crate::keymap::RuntimeKeymap::defaults()
.onboarding
.toggle_animation,
layout_area: Cell::new(None),
};
let before = widget.animation.current_frame();
widget.handle_key_event(KeyEvent::new(
KeyCode::Char('.'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
));
let after = widget.animation.current_frame();
assert_ne!(
before, after,
"expected ctrl+shift+. to switch welcome animation variant"
);
}
}

View File

@@ -21,15 +21,15 @@ use std::sync::Arc;
use crate::chatwidget::ActiveCellTranscriptKey;
use crate::history_cell::HistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::PagerKeymap;
use crate::render::Insets;
use crate::render::renderable::InsetRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
@@ -51,19 +51,24 @@ pub(crate) enum Overlay {
}
impl Overlay {
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>) -> Self {
Self::Transcript(TranscriptOverlay::new(cells))
pub(crate) fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self::Transcript(TranscriptOverlay::new(cells, keymap))
}
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
Self::Static(StaticOverlay::with_title(lines, title))
pub(crate) fn new_static_with_lines(
lines: Vec<Line<'static>>,
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_title(lines, title, keymap))
}
pub(crate) fn new_static_with_renderables(
renderables: Vec<Box<dyn Renderable>>,
title: String,
keymap: PagerKeymap,
) -> Self {
Self::Static(StaticOverlay::with_renderables(renderables, title))
Self::Static(StaticOverlay::with_renderables(renderables, title, keymap))
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
@@ -81,37 +86,12 @@ impl Overlay {
}
}
const KEY_UP: KeyBinding = key_hint::plain(KeyCode::Up);
const KEY_DOWN: KeyBinding = key_hint::plain(KeyCode::Down);
const KEY_K: KeyBinding = key_hint::plain(KeyCode::Char('k'));
const KEY_J: KeyBinding = key_hint::plain(KeyCode::Char('j'));
const KEY_PAGE_UP: KeyBinding = key_hint::plain(KeyCode::PageUp);
const KEY_PAGE_DOWN: KeyBinding = key_hint::plain(KeyCode::PageDown);
const KEY_SPACE: KeyBinding = key_hint::plain(KeyCode::Char(' '));
const KEY_SHIFT_SPACE: KeyBinding = key_hint::shift(KeyCode::Char(' '));
const KEY_HOME: KeyBinding = key_hint::plain(KeyCode::Home);
const KEY_END: KeyBinding = key_hint::plain(KeyCode::End);
const KEY_LEFT: KeyBinding = key_hint::plain(KeyCode::Left);
const KEY_RIGHT: KeyBinding = key_hint::plain(KeyCode::Right);
const KEY_CTRL_F: KeyBinding = key_hint::ctrl(KeyCode::Char('f'));
const KEY_CTRL_D: KeyBinding = key_hint::ctrl(KeyCode::Char('d'));
const KEY_CTRL_B: KeyBinding = key_hint::ctrl(KeyCode::Char('b'));
const KEY_CTRL_U: KeyBinding = key_hint::ctrl(KeyCode::Char('u'));
const KEY_Q: KeyBinding = key_hint::plain(KeyCode::Char('q'));
const KEY_ESC: KeyBinding = key_hint::plain(KeyCode::Esc);
const KEY_ENTER: KeyBinding = key_hint::plain(KeyCode::Enter);
const KEY_CTRL_T: KeyBinding = key_hint::ctrl(KeyCode::Char('t'));
const KEY_CTRL_C: KeyBinding = key_hint::ctrl(KeyCode::Char('c'));
// Common pager navigation hints rendered on the first line
const PAGER_KEY_HINTS: &[(&[KeyBinding], &str)] = &[
(&[KEY_UP, KEY_DOWN], "to scroll"),
(&[KEY_PAGE_UP, KEY_PAGE_DOWN], "to page"),
(&[KEY_HOME, KEY_END], "to jump"),
];
fn first_or_empty(bindings: &[KeyBinding]) -> Vec<KeyBinding> {
bindings.first().copied().into_iter().collect()
}
// Render a single line of key hints from (key(s), description) pairs.
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&[KeyBinding], &str)]) {
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(Vec<KeyBinding>, &str)]) {
let mut spans: Vec<Span<'static>> = vec![" ".into()];
let mut first = true;
for (keys, desc) in pairs {
@@ -136,6 +116,7 @@ struct PagerView {
renderables: Vec<Box<dyn Renderable>>,
scroll_offset: usize,
title: String,
keymap: PagerKeymap,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// If set, on next render ensure this chunk is visible.
@@ -143,11 +124,17 @@ struct PagerView {
}
impl PagerView {
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
fn new(
renderables: Vec<Box<dyn Renderable>>,
title: String,
scroll_offset: usize,
keymap: PagerKeymap,
) -> Self {
Self {
renderables,
scroll_offset,
title,
keymap,
last_content_height: None,
last_rendered_height: None,
pending_scroll_chunk: None,
@@ -260,37 +247,34 @@ impl PagerView {
fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
match key_event {
e if KEY_UP.is_press(e) || KEY_K.is_press(e) => {
e if self.keymap.scroll_up.is_pressed(e) => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
}
e if KEY_DOWN.is_press(e) || KEY_J.is_press(e) => {
e if self.keymap.scroll_down.is_pressed(e) => {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
e if KEY_PAGE_UP.is_press(e)
|| KEY_SHIFT_SPACE.is_press(e)
|| KEY_CTRL_B.is_press(e) =>
{
e if self.keymap.page_up.is_pressed(e) => {
let page_height = self.page_height(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_sub(page_height);
}
e if KEY_PAGE_DOWN.is_press(e) || KEY_SPACE.is_press(e) || KEY_CTRL_F.is_press(e) => {
e if self.keymap.page_down.is_pressed(e) => {
let page_height = self.page_height(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_add(page_height);
}
e if KEY_CTRL_D.is_press(e) => {
e if self.keymap.half_page_down.is_pressed(e) => {
let area = self.content_area(tui.terminal.viewport_area);
let half_page = (area.height as usize).saturating_add(1) / 2;
self.scroll_offset = self.scroll_offset.saturating_add(half_page);
}
e if KEY_CTRL_U.is_press(e) => {
e if self.keymap.half_page_up.is_pressed(e) => {
let area = self.content_area(tui.terminal.viewport_area);
let half_page = (area.height as usize).saturating_add(1) / 2;
self.scroll_offset = self.scroll_offset.saturating_sub(half_page);
}
e if KEY_HOME.is_press(e) => {
e if self.keymap.jump_top.is_pressed(e) => {
self.scroll_offset = 0;
}
e if KEY_END.is_press(e) => {
e if self.keymap.jump_bottom.is_pressed(e) => {
self.scroll_offset = usize::MAX;
}
_ => {
@@ -453,12 +437,13 @@ impl TranscriptOverlay {
///
/// This overlay does not own the "active cell"; callers may optionally append a live tail via
/// `sync_live_tail` during draws to reflect in-flight activity.
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>) -> Self {
pub(crate) fn new(transcript_cells: Vec<Arc<dyn HistoryCell>>, keymap: PagerKeymap) -> Self {
Self {
view: PagerView::new(
Self::render_cells(&transcript_cells, None),
"T R A N S C R I P T".to_string(),
usize::MAX,
keymap,
),
cells: transcript_cells,
highlight_cell: None,
@@ -656,15 +641,51 @@ impl TranscriptOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
let mut pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
let mut pairs: Vec<(Vec<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
if self.highlight_cell.is_some() {
pairs.push((&[KEY_ESC, KEY_LEFT], "to edit prev"));
pairs.push((&[KEY_RIGHT], "to edit next"));
pairs.push((&[KEY_ENTER], "to edit message"));
pairs.push((
self.view.keymap.edit_previous_message.clone(),
"to edit prev",
));
pairs.push((self.view.keymap.edit_next_message.clone(), "to edit next"));
pairs.push((
self.view.keymap.confirm_edit_message.clone(),
"to edit message",
));
} else {
pairs.push((&[KEY_ESC], "to edit prev"));
pairs.push((
first_or_empty(&self.view.keymap.edit_previous_message),
"to edit prev",
));
}
render_key_hints(line2, buf, &pairs);
}
@@ -682,7 +703,9 @@ impl TranscriptOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) || KEY_CTRL_T.is_press(e) => {
e if self.view.keymap.close.is_pressed(e)
|| self.view.keymap.close_transcript.is_pressed(e) =>
{
self.is_done = true;
Ok(())
}
@@ -713,14 +736,26 @@ pub(crate) struct StaticOverlay {
}
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
pub(crate) fn with_title(
lines: Vec<Line<'static>>,
title: String,
keymap: PagerKeymap,
) -> Self {
let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false });
Self::with_renderables(vec![Box::new(CachedRenderable::new(paragraph))], title)
Self::with_renderables(
vec![Box::new(CachedRenderable::new(paragraph))],
title,
keymap,
)
}
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
pub(crate) fn with_renderables(
renderables: Vec<Box<dyn Renderable>>,
title: String,
keymap: PagerKeymap,
) -> Self {
Self {
view: PagerView::new(renderables, title, 0),
view: PagerView::new(renderables, title, 0, keymap),
is_done: false,
}
}
@@ -728,8 +763,35 @@ impl StaticOverlay {
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
let line1 = Rect::new(area.x, area.y, area.width, 1);
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
render_key_hints(line1, buf, PAGER_KEY_HINTS);
let pairs: Vec<(&[KeyBinding], &str)> = vec![(&[KEY_Q], "to quit")];
render_key_hints(
line1,
buf,
&[
(
first_or_empty(&self.view.keymap.scroll_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.scroll_down))
.collect(),
"to scroll",
),
(
first_or_empty(&self.view.keymap.page_up)
.into_iter()
.chain(first_or_empty(&self.view.keymap.page_down))
.collect(),
"to page",
),
(
first_or_empty(&self.view.keymap.jump_top)
.into_iter()
.chain(first_or_empty(&self.view.keymap.jump_bottom))
.collect(),
"to jump",
),
],
);
let pairs: Vec<(Vec<KeyBinding>, &str)> =
vec![(first_or_empty(&self.view.keymap.close), "to quit")];
render_key_hints(line2, buf, &pairs);
}
@@ -746,7 +808,7 @@ impl StaticOverlay {
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match event {
TuiEvent::Key(key_event) => match key_event {
e if KEY_Q.is_press(e) || KEY_CTRL_C.is_press(e) => {
e if self.view.keymap.close.is_pressed(e) => {
self.is_done = true;
Ok(())
}
@@ -839,9 +901,34 @@ mod tests {
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
}
fn default_pager_keymap() -> crate::keymap::PagerKeymap {
crate::keymap::RuntimeKeymap::defaults().pager
}
fn transcript_overlay(cells: Vec<Arc<dyn HistoryCell>>) -> TranscriptOverlay {
TranscriptOverlay::new(cells, default_pager_keymap())
}
fn static_overlay(lines: Vec<Line<'static>>, title: &str) -> StaticOverlay {
StaticOverlay::with_title(lines, title.to_string(), default_pager_keymap())
}
fn pager_view(
renderables: Vec<Box<dyn Renderable>>,
title: &str,
scroll_offset: usize,
) -> PagerView {
PagerView::new(
renderables,
title.to_string(),
scroll_offset,
default_pager_keymap(),
)
}
#[test]
fn edit_prev_hint_is_visible() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("hello")],
})]);
@@ -859,7 +946,7 @@ mod tests {
#[test]
fn edit_next_hint_is_visible_when_highlighted() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("hello")],
})]);
overlay.set_highlight_cell(Some(0));
@@ -879,7 +966,7 @@ mod tests {
#[test]
fn transcript_overlay_snapshot_basic() {
// Prepare a transcript overlay with a few lines
let mut overlay = TranscriptOverlay::new(vec![
let mut overlay = transcript_overlay(vec![
Arc::new(TestCell {
lines: vec![Line::from("alpha")],
}),
@@ -898,7 +985,7 @@ mod tests {
#[test]
fn transcript_overlay_renders_live_tail() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
overlay.sync_live_tail(
@@ -919,7 +1006,7 @@ mod tests {
#[test]
fn transcript_overlay_sync_live_tail_is_noop_for_identical_key() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
let mut overlay = transcript_overlay(vec![Arc::new(TestCell {
lines: vec![Line::from("alpha")],
})]);
@@ -1012,7 +1099,7 @@ mod tests {
let exec_cell: Arc<dyn HistoryCell> = Arc::new(exec_cell);
cells.push(exec_cell);
let mut overlay = TranscriptOverlay::new(cells);
let mut overlay = transcript_overlay(cells);
let area = Rect::new(0, 0, 80, 12);
let mut buf = Buffer::empty(area);
@@ -1026,7 +1113,7 @@ mod tests {
#[test]
fn transcript_overlay_keeps_scroll_pinned_at_bottom() {
let mut overlay = TranscriptOverlay::new(
let mut overlay = transcript_overlay(
(0..20)
.map(|i| {
Arc::new(TestCell {
@@ -1053,7 +1140,7 @@ mod tests {
#[test]
fn transcript_overlay_preserves_manual_scroll_position() {
let mut overlay = TranscriptOverlay::new(
let mut overlay = transcript_overlay(
(0..20)
.map(|i| {
Arc::new(TestCell {
@@ -1078,9 +1165,9 @@ mod tests {
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
let mut overlay = StaticOverlay::with_title(
let mut overlay = static_overlay(
vec!["one".into(), "two".into(), "three".into()],
"S T A T I C".to_string(),
"S T A T I C",
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
@@ -1116,7 +1203,7 @@ mod tests {
#[test]
fn transcript_overlay_paging_is_continuous_and_round_trips() {
let mut overlay = TranscriptOverlay::new(
let mut overlay = transcript_overlay(
(0..50)
.map(|i| {
Arc::new(TestCell {
@@ -1184,9 +1271,9 @@ mod tests {
#[test]
fn static_overlay_wraps_long_lines() {
let mut overlay = StaticOverlay::with_title(
let mut overlay = static_overlay(
vec!["a very long line that should wrap when rendered within a narrow pager overlay width".into()],
"S T A T I C".to_string(),
"S T A T I C",
);
let mut term = Terminal::new(TestBackend::new(24, 8)).expect("term");
term.draw(|f| overlay.render(f.area(), f.buffer_mut()))
@@ -1196,9 +1283,9 @@ mod tests {
#[test]
fn pager_view_content_height_counts_renderables() {
let pv = PagerView::new(
let pv = pager_view(
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
"T".to_string(),
"T",
0,
);
@@ -1207,13 +1294,13 @@ mod tests {
#[test]
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
let mut pv = PagerView::new(
let mut pv = pager_view(
vec![
paragraph_block("a", 1),
paragraph_block("b", 3),
paragraph_block("c", 3),
],
"T".to_string(),
"T",
0,
);
let area = Rect::new(0, 0, 20, 8);
@@ -1242,13 +1329,13 @@ mod tests {
#[test]
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
let mut pv = PagerView::new(
let mut pv = pager_view(
vec![
paragraph_block("a", 2),
paragraph_block("b", 3),
paragraph_block("c", 3),
],
"T".to_string(),
"T",
0,
);
let area = Rect::new(0, 0, 20, 3);
@@ -1261,7 +1348,7 @@ mod tests {
#[test]
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0);
let mut pv = pager_view(vec![paragraph_block("a", 10)], "T", 0);
let area = Rect::new(0, 0, 20, 8);
let mut buf = Buffer::empty(area);

View File

@@ -20,5 +20,7 @@ You can run any shell command from Codex using `!` (e.g. `!ls`)
Type / to open the command popup; Tab autocompletes slash commands.
When the composer is empty, press Esc to step back and edit your last message; Enter confirms.
Press Tab to queue a message when a task is running; otherwise it sends immediately (except `!`).
[tui.keymap] in ~/.codex/config.toml lets you rebind supported shortcuts.
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml
Paste an image with Ctrl+V to attach it to your next message.
You can resume a previous conversation by running `codex resume`

View File

@@ -6,6 +6,77 @@ For advanced configuration instructions, see [this documentation](https://develo
For a full configuration reference, see [this documentation](https://developers.openai.com/codex/config-reference).
## TUI keymap
The TUI supports rebinding shortcuts via `[tui.keymap]` in `~/.codex/config.toml`.
Use this complete, commented defaults template.
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml
For implementation details, safety contracts, and testing notes, see `docs/tui-keymap.md`.
### Precedence
Precedence is applied in this order (highest first):
1. Context-specific binding (`[tui.keymap.<context>]`)
2. Global binding (`[tui.keymap.global]`) for chat/composer fallback actions
3. Built-in preset defaults (`preset`)
### Presets
- `latest`: moving alias for the newest preset; today `latest -> v1`
- `v1`: frozen legacy/current defaults
When defaults change in the future, a new version (for example `v2`) is added and
`latest` may move to it. Pin to `v1` if you want stable historical behavior.
### Supported actions
- `global`: `open_transcript`, `open_external_editor`, `edit_previous_message`,
`confirm_edit_previous_message`, `submit`, `queue`, `toggle_shortcuts`
- `chat`: `edit_previous_message`, `confirm_edit_previous_message`
- `composer`: `submit`, `queue`, `toggle_shortcuts`
- `editor`: `insert_newline`, `move_left`, `move_right`, `move_up`, `move_down`,
`move_word_left`, `move_word_right`, `move_line_start`, `move_line_end`,
`delete_backward`, `delete_forward`, `delete_backward_word`, `delete_forward_word`,
`kill_line_start`, `kill_line_end`, `yank`
- `pager`: `scroll_up`, `scroll_down`, `page_up`, `page_down`, `half_page_up`,
`half_page_down`, `jump_top`, `jump_bottom`, `close`, `close_transcript`,
`edit_previous_message`, `edit_next_message`, `confirm_edit_message`
- `list`: `move_up`, `move_down`, `accept`, `cancel`
- `approval`: `open_fullscreen`, `approve`, `approve_for_session`,
`approve_for_prefix`, `decline`, `cancel`
- `onboarding`: `move_up`, `move_down`, `select_first`, `select_second`,
`select_third`, `confirm`, `cancel`, `quit`, `toggle_animation`
For long-term behavior and evolution guidance, see `docs/tui-keymap.md`.
For a quick action inventory, see `docs/keymap-action-matrix.md`.
On onboarding API-key entry, printable `quit` bindings are treated as text input
once the field contains text; use control/alt chords for always-available quit
shortcuts.
### Key format
Use lowercase key identifiers with `-` separators, for example:
- `ctrl-a`
- `shift-enter`
- `alt-page-down`
- `?`
Actions accept a single key or multiple keys:
- `submit = "enter"`
- `submit = ["enter", "ctrl-j"]`
- `submit = []` (explicitly unbind)
Some defaults intentionally include multiple variants for one logical shortcut
because terminal modifier reporting can differ by platform/emulator. For
example, `?` may arrive as plain `?` or `shift-?`, and control chords may
arrive with or without `SHIFT`.
Aliases like `escape`, `pageup`, and `pgdn` are normalized.
## Connecting to MCP servers
Codex can connect to MCP servers configured in `~/.codex/config.toml`. See the configuration reference for the latest MCP server options:

116
docs/default-keymap.toml Normal file
View File

@@ -0,0 +1,116 @@
# Codex TUI keymap template (v1 defaults)
#
# Copy the sections you need into ~/.codex/config.toml.
#
# Canonical source (Codex repo):
# https://github.com/openai/codex/blob/main/docs/default-keymap.toml
# Implementation reference: docs/tui-keymap.md
#
# Value format:
# - action = "ctrl-a" # one key
# - action = ["ctrl-a", "alt-a"] # multiple keys for one action
# - action = [] # explicit unbind for that action
#
# Precedence (highest first):
# 1. [tui.keymap.<context>] override
# 2. [tui.keymap.global] fallback (only for chat/composer actions)
# 3. preset defaults
[tui.keymap]
# Preset alias for built-in defaults.
# "latest" currently points to "v1".
preset = "latest"
[tui.keymap.global]
# Open transcript overlay.
open_transcript = "ctrl-t"
# Open external editor for current draft.
open_external_editor = "ctrl-g"
# Begin/advance "edit previous message" when composer is empty.
edit_previous_message = "esc"
# Confirm selected message in edit-previous flow.
confirm_edit_previous_message = "enter"
# Submit current draft.
submit = "enter"
# Queue current draft while a task is running.
queue = "tab"
# Toggle composer shortcut overlay.
# Include both forms because some terminals report `?` with SHIFT and others don't.
toggle_shortcuts = ["?", "shift-?"]
[tui.keymap.chat]
# Overrides [tui.keymap.global] for chat-only behavior.
edit_previous_message = "esc"
confirm_edit_previous_message = "enter"
[tui.keymap.composer]
# Overrides [tui.keymap.global] for composer behavior.
submit = "enter"
queue = "tab"
toggle_shortcuts = ["?", "shift-?"]
[tui.keymap.editor]
# Text input editor bindings.
insert_newline = ["ctrl-j", "ctrl-m", "enter", "shift-enter"]
move_left = ["left", "ctrl-b"]
move_right = ["right", "ctrl-f"]
move_up = ["up", "ctrl-p"]
move_down = ["down", "ctrl-n"]
move_word_left = ["alt-b", "alt-left", "ctrl-left"]
move_word_right = ["alt-f", "alt-right", "ctrl-right"]
move_line_start = ["home", "ctrl-a"]
move_line_end = ["end", "ctrl-e"]
delete_backward = ["backspace", "ctrl-h"]
delete_forward = ["delete", "ctrl-d"]
delete_backward_word = ["alt-backspace", "ctrl-w", "ctrl-alt-h"]
delete_forward_word = ["alt-delete"]
kill_line_start = ["ctrl-u"]
kill_line_end = ["ctrl-k"]
yank = ["ctrl-y"]
[tui.keymap.pager]
# Transcript/static overlay navigation.
scroll_up = ["up", "k"]
scroll_down = ["down", "j"]
page_up = ["page-up", "shift-space", "ctrl-b"]
page_down = ["page-down", "space", "ctrl-f"]
half_page_up = ["ctrl-u"]
half_page_down = ["ctrl-d"]
jump_top = ["home"]
jump_bottom = ["end"]
close = ["q", "ctrl-c"]
close_transcript = ["ctrl-t"]
# Backtrack controls inside transcript overlay.
edit_previous_message = ["esc", "left"]
edit_next_message = ["right"]
confirm_edit_message = ["enter"]
[tui.keymap.list]
# Generic list/picker navigation.
move_up = ["up", "ctrl-p", "k"]
move_down = ["down", "ctrl-n", "j"]
accept = ["enter"]
cancel = ["esc"]
[tui.keymap.approval]
# Approval modal actions.
# Include ctrl+shift fallback for terminals that preserve SHIFT on Ctrl letter chords.
open_fullscreen = ["ctrl-a", "ctrl-shift-a"]
approve = ["y"]
approve_for_session = ["a"]
approve_for_prefix = ["p"]
decline = ["esc", "n"]
cancel = ["c"]
[tui.keymap.onboarding]
# Onboarding/auth/trust screens.
move_up = ["up", "k"]
move_down = ["down", "j"]
select_first = ["1", "y"]
select_second = ["2", "n"]
select_third = ["3"]
confirm = ["enter"]
cancel = ["esc"]
quit = ["q", "ctrl-c", "ctrl-d"]
# Include ctrl+shift fallback for terminals that preserve SHIFT on Ctrl punctuation chords.
toggle_animation = ["ctrl-.", "ctrl-shift-."]

View File

@@ -1,3 +1,42 @@
# Sample configuration
For a sample configuration file, see [this documentation](https://developers.openai.com/codex/config-sample).
For a full sample configuration, see [this documentation](https://developers.openai.com/codex/config-sample).
## Keymap snippet
```toml
[tui.keymap]
preset = "latest" # currently points to v1 defaults
[tui.keymap.global]
open_transcript = "ctrl-t"
open_external_editor = "ctrl-g"
[tui.keymap.chat]
edit_previous_message = "esc"
confirm_edit_previous_message = "enter"
[tui.keymap.composer]
submit = "enter"
queue = "tab"
toggle_shortcuts = ["?", "shift-?"]
[tui.keymap.pager]
close = ["q", "ctrl-c"]
close_transcript = ["ctrl-t"]
[tui.keymap.list]
accept = "enter"
cancel = "esc"
[tui.keymap.approval]
approve = "y"
decline = ["esc", "n"]
[tui.keymap.onboarding]
quit = ["q", "ctrl-c", "ctrl-d"]
```
For a complete, commented template:
Keymap template: https://github.com/openai/codex/blob/main/docs/default-keymap.toml
For precedence, safety invariants, and testing notes, see `docs/tui-keymap.md`.

View File

@@ -0,0 +1,101 @@
# TUI Keymap Action Matrix
This file defines the supported keymap actions and their default `v1` bindings.
For runtime behavior, safety invariants, and testing guidance, see
`docs/tui-keymap.md`.
## Preset behavior
- `latest` is an alias to the newest shipped preset.
- Today, `latest -> v1`.
- To keep stable behavior over time, pin `preset = "v1"`.
## Precedence
1. `tui.keymap.<context>.<action>`
2. `tui.keymap.global.<action>` (chat/composer fallback actions only)
3. Preset default (`v1` today)
## Default `v1` Compatibility Notes
- Some actions intentionally ship with multiple bindings for the same logical
shortcut because terminals differ in modifier reporting.
- Today this includes:
- `composer.toggle_shortcuts`: `?` and `shift-?`
- `approval.open_fullscreen`: `ctrl-a` and `ctrl-shift-a`
- `onboarding.toggle_animation`: `ctrl-.` and `ctrl-shift-.`
- Keep these paired defaults unless/until key-event normalization is made
platform-consistent at a lower layer.
## Action Definitions
### `global`
- `open_transcript`: open transcript overlay
- `open_external_editor`: open external editor for current draft
- `edit_previous_message`: begin/advance edit-previous flow when composer is empty
- `confirm_edit_previous_message`: confirm selected previous message for editing
- `submit`: submit current draft
- `queue`: queue current draft while a task is running
- `toggle_shortcuts`: toggle composer shortcut overlay
### `chat`
- `edit_previous_message`: chat override for edit-previous flow
- `confirm_edit_previous_message`: chat override for edit confirmation
### `composer`
- `submit`: composer override for submit
- `queue`: composer override for queue
- `toggle_shortcuts`: composer override for shortcut overlay toggle
### `editor`
- `insert_newline`: insert newline in text editor
- `move_left` / `move_right` / `move_up` / `move_down`: cursor movement
- `move_word_left` / `move_word_right`: word movement
- `move_line_start` / `move_line_end`: line boundary movement
- `delete_backward` / `delete_forward`: single-char deletion
- `delete_backward_word` / `delete_forward_word`: word deletion
- `kill_line_start` / `kill_line_end`: kill to line boundary
- `yank`: paste kill-buffer contents
### `pager`
- `scroll_up` / `scroll_down`: row scroll
- `page_up` / `page_down`: page scroll
- `half_page_up` / `half_page_down`: half-page scroll
- `jump_top` / `jump_bottom`: jump to top/bottom
- `close`: close pager overlay
- `close_transcript`: close transcript via transcript toggle binding
- `edit_previous_message` / `edit_next_message`: backtrack navigation in transcript
- `confirm_edit_message`: confirm selected backtrack message
### `list`
- `move_up` / `move_down`: list navigation
- `accept`: select current item
- `cancel`: close list view
### `approval`
- `open_fullscreen`: open full-screen approval details
- `approve`: approve primary request
- `approve_for_session`: approve-for-session option
- `approve_for_prefix`: approve-for-prefix option
- `decline`: decline request
- `cancel`: cancel elicitation request
- MCP elicitation safety rule: `Esc` is always treated as `cancel` (never
`decline`) so dismissal cannot accidentally continue execution.
### `onboarding`
- `move_up` / `move_down`: onboarding list navigation
- `select_first` / `select_second` / `select_third`: numeric selection shortcuts
- `confirm`: confirm highlighted onboarding selection
- `cancel`: cancel current onboarding sub-flow
- `quit`: quit onboarding flow
- `toggle_animation`: switch welcome animation variant
- API-key entry guard: printable `quit` bindings are treated as text input once
the API-key field has text; control/alt quit chords are not suppressed.

View File

@@ -0,0 +1,208 @@
# Keymap Rewrite Handoff
Updated: February 19, 2026
Repo: `codex-config-keybinds`
Branch: `joshka/keymap-rewrite-start`
## Purpose
This handoff is the authoritative context for finishing and shipping the keymap rewrite commit
stack.
The original implementation existed as a single large commit (`0f03e76c`). The current stack
reorders that work into additive, review-friendly commits while preserving behavioral intent.
Key decisions that still apply:
1. Preserve end-state behavior equivalent to `0f03e76c`.
2. Keep `AGENTS.md` keymap guidance in the stack.
3. Treat keybinding coverage as a keymap-focused gate (not whole-crate perfection).
## Quick Orientation
Summary for incoming implementer:
1. The keymap feature is mostly implemented and wired.
2. The remaining technical gate is branch coverage closure on keybinding branches.
3. Preserve behavior while tightening internals and documentation quality.
4. Keep help affordances succinct and clearly visible.
Read these in order:
1. `docs/keymap-rewrite-handoff.md`
- Overall constraints, current status, and concrete next steps.
2. `docs/tui-keymap.md`
- Long-term architecture and behavior model for configurable keymaps.
3. `docs/keymap-action-matrix.md`
- Action-by-action reference mapping and expected command behavior.
4. `docs/config.md`
- User-facing config contract and migration notes for presets.
5. `docs/default-keymap.toml`
- Canonical template that should match the actual runtime/default behavior.
6. `codex-rs/tui/src/keymap.rs`
- Core resolver and matching logic.
7. `codex-rs/tui/src/bottom_pane/footer.rs`
- Help/affordance rendering and keymap customization guidance text.
## Keymap Implementation Constraints (Must Hold)
1. Behavioral contract:
- Preserve existing runtime behavior first, then refactor.
- End-state behavior should remain equivalent to `0f03e76c`.
2. Keymap type organization:
- Keep `TuiKeymap`, `TuiKeymapPreset`, and related keymap types out of oversized umbrella files
(for example `types.rs`) and in dedicated module files.
3. Preset/versioning policy:
- Do not mutate historical preset defaults.
- Add new preset versions for behavior changes and keep `latest` as an explicit pointer.
- Update both `docs/default-keymap.toml` and `docs/config.md` with migration notes whenever
preset behavior changes.
4. Simplification policy:
- Prefer reducing keymap repetition with small declarative macros where readability improves.
- Centralize key-event matching logic on `KeyBinding`/keybinding helpers rather than repeating
ad-hoc comparisons across callsites.
- Document new macros and helper abstractions with rustdoc/doc comments.
- If `key_hint::plain...` helpers are macroized, document invocation patterns and generated
behavior with rustdoc and examples.
5. Help and affordance UX:
- Keep keymap customization affordances succinct.
- Show the keymap affordance on its own line; avoid wrapped wording that buries the action.
6. Documentation expectations:
- Write long-term "how this works" documentation, not process diary notes.
- Keep details that affect behavior, contracts, or extension points.
- Keep docs self-contained for a new engineer without depending on external notes.
- Run a documentation pass mindset on changed modules: explain invariants and decision
boundaries, not just mechanics.
7. Template/docs link policy:
- `docs/default-keymap.toml` must point to a public URL for canonical docs (GitHub for now;
later `developers.openai.com`).
8. Test and coverage gates:
- Add characterization tests for pre-existing event behavior before swapping bindings.
- Maintain compile correctness across all callsites when APIs change (for example, constructor
arity updates).
- Use `cargo llvm-cov` branch coverage and push toward 100% on keybinding-related branches.
## Context And Rationale (Self-Contained)
This document is intentionally self-contained for implementation handoff.
Source of truth for intent and behavior:
1. Existing long-term docs in this repo:
`docs/tui-keymap.md`, `docs/keymap-action-matrix.md`, and `docs/keymap-rollout-plan.md`.
2. Commit stack and diffs listed below.
3. Test and coverage commands listed in this document.
## Current Additive Stack (Authoritative)
1. `b455e26a4a3e`
`docs(keymap): establish long-term keymap documentation`
Scope: long-term docs/spec (`docs/tui-keymap.md`, action matrix, template, config docs,
rollout plan).
2. `1913b55afb75`
`feat(core): add keymap config schema and types`
Scope: additive config surface only (`core/src/config/tui_keymap.rs`, schema, config exports).
3. `187aa6969e03`
`test(tui): add runtime keymap resolver characterization suite`
Scope: `tui/src/keymap.rs` and `key_hint` helper expansion, with resolver tests and conflict
guards, but no broad runtime wiring yet.
4. `aeb6caaecbbc`
`feat(tui): wire runtime keymap into event handling`
Scope: replace hardcoded key checks across app/composer/pager/approval/onboarding/list/textarea
routing with runtime keymap usage.
5. `HEAD`
`feat(tui): surface keymap customization hints`
Scope: UX/help affordances (`footer.rs`, tooltip text, snapshot updates) plus this handoff doc.
## Validation Evidence (Current)
All commands below were run in this workspace.
1. Config commit (`1913b55afb75`)
- `cd codex-rs && just fmt`
- `cd codex-rs && cargo test -p codex-core --lib`
- Result: pass (`996 passed; 0 failed; 4 ignored`).
2. Resolver/characterization commit (`187aa6969e03`)
- `cd codex-rs && just fmt`
- `cd codex-rs && cargo test -p codex-tui`
- Result: pass (`797 passed; 0 failed; 2 ignored` + integration/doctest pass).
3. Wiring commit (`aeb6caaecbbc`)
- `cd codex-rs && just fmt`
- `cd codex-rs && cargo test -p codex-tui`
- Result: pass (`806 passed; 0 failed; 2 ignored` + integration/doctest pass).
4. UX/hints commit (`HEAD`)
- `cd codex-rs && just fmt`
- `cd codex-rs && cargo test -p codex-tui`
- Result: pass (`806 passed; 0 failed; 2 ignored` + integration/doctest pass).
5. Post-rebase integration validation (current workspace)
- `cd codex-rs && just fmt`
- `cd codex-rs && cargo test -p codex-tui`
- Result: pass (`932 passed; 0 failed; 2 ignored` + integration/doctest pass).
## Coverage Status (Important)
Branch-coverage closure is still pending and is the main remaining technical gate.
What happened:
1. `cargo llvm-cov --branch` on stable failed as expected (needs nightly `-Z` support).
2. Nightly toolchain exists locally, and `llvm-tools-preview` is installed for nightly.
3. A nightly branch-coverage run was started but interrupted before artifact emission.
4. No `/tmp/codex_tui_cov.json` was produced in the latest attempt.
Implication:
- Do not claim branch-coverage completion yet.
- Next dev should run and record a successful branch report before declaring done.
## Remaining Work To Finish This Effort
1. Complete keybinding branch coverage run and record results.
2. Add missing tests if branch gaps remain in `tui/src/keymap.rs` or key-routing branches.
3. Re-run `cargo test -p codex-tui` after any coverage-driven test additions.
4. Decide whether `docs/keymap-rollout-plan.md` remains in the final PR scope or is dropped as
implementation scaffolding.
5. Final review pass on commit descriptions and stack readability, then push/open PR.
## Recommended Resume Commands
Run these first to rehydrate state:
```bash
git fetch origin
git status --short
git log --oneline --decorate --graph origin/main..HEAD
git diff --stat origin/main...HEAD
git diff origin/main...HEAD
```
Then run coverage work:
```bash
cd codex-rs
cargo +nightly llvm-cov -p codex-tui --tests --branch --json --output-path /tmp/codex_tui_cov.json
cargo +nightly llvm-cov report --summary-only
```
If needed, inspect key files directly in the coverage JSON and add tests before re-running.
## Notes For Reviewer/Implementer Handoff
1. The stack is intentionally additive and readable now; avoid collapsing commits unless explicitly
requested.
2. Commit 4 contains the behavioral swap; commit 5 should stay UX-only.
3. Keep this file in-tree until PR merge so reviewers and follow-up implementers share one
authoritative source of context.

View File

@@ -0,0 +1,69 @@
# TUI Keymap Additive Rollout Plan
This plan restructures the existing keymap implementation into a reviewable
additive stack while preserving the final behavior from revision `0f03e76c`.
## Goals
- Keep the final behavior effectively identical to `0f03e76c`.
- Reorder history to show intent and risk reduction clearly.
- Make each change compile, format cleanly, and pass scoped tests.
- Establish high-confidence behavior characterization before binding rewrites.
## Commit Sequence
1. **Docs and specification foundation**
- Add/reshape long-term docs that explain how the keymap system works.
- Include:
- conceptual model
- action matrix
- default keymap template guidance with explicit URL
- testing strategy notes
- No functional code change.
2. **Additive config primitives (not wired)**
- Introduce keymap config types and schema support in `core`.
- Keep runtime behavior unchanged by not consuming these config values yet.
- Ensure parsing/serialization paths are complete and documented.
3. **Behavior characterization tests (pre-rewrite)**
- Add tests that lock down existing event behavior before switching bindings.
- Cover key event matching and context-sensitive behaviors that must stay stable.
- Use these tests as the safety net for subsequent rewiring.
- Run `cargo llvm-cov` and target full branch coverage for keybinding logic.
4. **Binding replacement using characterized behavior**
- Introduce keymap-driven binding resolution and wire call sites.
- Replace legacy binding checks while preserving characterized behavior.
- Keep docs in sync with any semantics that became explicit.
5. **UX polish and affordances**
- Apply help/footer/tooltip/key-hint refinements once behavior is stable.
- Keep affordance text concise and formatted for readability.
- Update snapshots as needed.
## Validation Gates Per Commit
For each commit in the sequence:
1. Run formatting (`just fmt` in `codex-rs`).
2. Run scoped tests for affected crates (minimum `cargo test -p codex-tui` for TUI
changes and relevant `core` tests when `core` changes).
3. Ensure branch compiles cleanly (`cargo test` path used as compile gate).
4. For characterization and binding commits, run coverage and track branch
coverage for keybinding logic (`cargo llvm-cov`).
## JJ Workflow
- Build stack from `trunk()` with explicit ordered changes.
- Use `jj new -A` / `jj new -B` to place changes relative to target revisions.
- Rebase existing implementation changes into the new stack as needed.
- Resolve conflicts immediately and keep each change coherent.
- Use clear `jj describe` messages with title + explanatory body.
## Acceptance Criteria
- Final tip behavior matches `0f03e76c` in practice.
- Stack reads top-to-bottom as: docs -> config -> characterization -> rewiring ->
UX polish.
- Each commit is independently reviewable with passing checks.

277
docs/tui-keymap.md Normal file
View File

@@ -0,0 +1,277 @@
# TUI Keymap Implementation Reference
This document is the long-term implementation reference for Codex TUI keybindings.
It describes how keymap configuration is resolved at runtime, which safety
contracts are intentionally strict, and how to test behavior end to end.
## Scope and boundaries
This keymap system is action-based and context-aware. It supports user rebinding
for the TUI without requiring source code edits.
Responsibilities:
1. Resolve config values into runtime key bindings.
2. Apply deterministic precedence.
3. Reject ambiguous bindings in dispatch scopes where collisions are unsafe.
4. Preserve explicit safety semantics for approval elicitation and onboarding.
Non-responsibilities:
1. It does not choose which screen should handle an event.
2. It does not persist config.
3. It does not guarantee terminal modifier reporting consistency; defaults may
include compatibility variants for that reason.
## Source-of-truth map
- Runtime resolution: `codex-rs/tui/src/keymap.rs`
- Onboarding flow-level routing and quit guard:
`codex-rs/tui/src/onboarding/onboarding_screen.rs`
- Approval/MCP elicitation option semantics:
`codex-rs/tui/src/bottom_pane/approval_overlay.rs`
- Generic list popup navigation semantics:
`codex-rs/tui/src/bottom_pane/list_selection_view.rs`
- User-facing default template:
`https://github.com/openai/codex/blob/main/docs/default-keymap.toml`
- User-facing config overview: `docs/config.md`
## Config contract
`[tui.keymap]` is action-to-binding mapping by context.
```toml
[tui.keymap]
preset = "latest"
[tui.keymap.global]
submit = "enter"
[tui.keymap.composer]
submit = ["enter", "ctrl-j"]
```
Rules:
1. Mapping direction is `action -> key_or_keys`.
2. Values support:
1. `action = "key-spec"`
2. `action = ["key-spec-1", "key-spec-2"]`
3. `action = []` for explicit unbind.
3. Unknown contexts, actions, or key identifiers fail validation.
4. Aliases are normalized (for example `escape -> esc`, `pgdn -> page-down`).
5. Key identifiers are lowercase and use `-` separators.
## Contexts and actions
Supported contexts:
1. `global`
2. `chat`
3. `composer`
4. `editor`
5. `pager`
6. `list`
7. `approval`
8. `onboarding`
Action inventory by context is documented in `docs/config.md` and the template
at `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`.
## Presets and compatibility policy
Preset semantics:
1. `latest` is an alias to the newest shipped preset.
2. `v1` is the current frozen baseline.
3. Today, `latest -> v1`.
User guidance:
1. Pin `preset = "v1"` for stable behavior over time.
2. Use `preset = "latest"` to adopt new defaults when `latest` moves.
Developer policy:
1. Do not mutate old preset defaults after release.
2. Add a new version (for example `v2`) for behavior changes.
3. Update docs and migration notes whenever `latest` changes.
Compatibility detail:
Some actions intentionally ship with multiple default bindings because terminals
can report modifier combinations differently. Examples include `?` vs `shift-?`
and certain `ctrl` chords with optional `shift`.
## Resolution and precedence
Resolution order (highest first):
1. Context binding (`tui.keymap.<context>.<action>`)
2. Global fallback (`tui.keymap.global.<action>`) for chat/composer fallback
actions only
3. Preset default binding
If no binding matches, normal unhandled-key fallback behavior applies.
## Conflict validation model
Validation is dispatch-order aware, not globally uniform.
Current conflict passes in `RuntimeKeymap::validate_conflicts` enforce:
1. App-level uniqueness for app actions and app-level chat controls.
2. App/composer shadowing prevention, because app handlers execute before
forwarding to composer handlers.
3. Composer-local uniqueness for submit/queue/shortcut-toggle.
4. Context-local uniqueness in editor, pager, list, approval, and onboarding.
Intentionally allowed:
1. Same key across different contexts that are not co-evaluated in a way that
can cause unsafe shadowing.
2. Shared defaults where runtime context gating keeps semantics unambiguous.
## Safety invariants
### MCP elicitation cancel semantics
For MCP elicitation prompts, `Esc` is always treated as `cancel`.
Implementation contract:
1. `Esc` is always included in cancel shortcuts.
2. User-defined `approval.cancel` shortcuts are merged into cancel.
3. Any overlap is removed from `approval.decline` in elicitation mode.
Rationale: dismissal must remain a safe abort path and never silently map to
"continue without requested info".
### Onboarding API-key text-entry guard
During API-key entry, printable `onboarding.quit` bindings are suppressed only
when the API-key field already has text.
Implementation contract:
1. Guard applies only when API-key entry mode is active.
2. Guard applies only to printable char keys without control/alt modifiers.
3. Guard applies only when input is non-empty.
4. Control/alt quit chords are never suppressed by this guard.
Rationale: keep text-entry safe once typing has begun while preserving an
intentional printable-quit path on empty input.
## Dispatch model and handler boundaries
High-level behavior:
1. App-level event handling runs before some lower-level handlers.
2. Composer behavior depends on both app routing and composer-local checks.
3. Onboarding screen routing applies flow-level rules before delegating to step
widgets.
4. Approval overlay and list selection use context-specific bindings resolved by
`RuntimeKeymap`.
When changing dispatch order, re-evaluate conflict validation scopes in
`keymap.rs` and associated tests.
## Diagnostics contract
Validation errors should be actionable and include:
1. Problem summary.
2. Exact config path.
3. Why the value is invalid or ambiguous.
4. Concrete remediation step.
Categories currently covered:
1. Invalid key specification.
2. Unknown action/context mapping.
3. Same-scope ambiguity.
4. Shadowing collisions in dispatch-coupled scopes.
## Debug path
When keybindings do not behave as expected, trace in this order:
1. Verify config normalization and schema validation in
`codex-rs/core/src/config/tui_keymap.rs`.
2. Verify resolved runtime bindings and conflict checks in
`codex-rs/tui/src/keymap.rs` (`from_config`, `validate_conflicts`).
3. Verify handler-level dispatch order in:
1. `codex-rs/tui/src/app.rs` for app/chat/composer routing.
2. `codex-rs/tui/src/pager_overlay.rs` for pager controls.
3. `codex-rs/tui/src/bottom_pane/approval_overlay.rs` for approval safety
behavior.
4. `codex-rs/tui/src/onboarding/onboarding_screen.rs` for onboarding quit
guard behavior.
4. Reproduce with explicit bindings in `~/.codex/config.toml` and compare
against:
1. `docs/default-keymap.toml`
2. `docs/keymap-action-matrix.md`
## Testing notes
### Commands
Run from `codex-rs/`:
1. `just fmt`
2. `cargo test -p codex-tui --lib`
3. Optional full crate run (includes integration tests):
`cargo test -p codex-tui`
4. Optional focused runs while iterating:
`cargo test -p codex-tui --lib keymap::tests`
If `cargo test -p codex-tui` fails because the `codex` binary cannot be found
in local `target/`, run `--lib` for keymap behavior checks and then validate the
integration target in an environment where workspace binaries are available.
For intentional UI/text output changes in `codex-tui`:
1. `cargo insta pending-snapshots -p codex-tui`
2. `cargo insta show -p codex-tui <path/to/snapshot.snap.new>`
3. `cargo insta accept -p codex-tui` only when the full snapshot set is
expected.
### Behavior coverage checklist
Use this checklist before landing keymap behavior changes:
1. Precedence: context override beats global fallback and preset defaults.
2. Unbind behavior: `action = []` actually removes the binding.
3. Conflict rejection:
1. Same-context duplicates fail.
2. App/composer shadowing fails for submit, queue, and toggle-shortcuts.
4. Approval safety:
1. `Esc` resolves elicitation to cancel.
2. Decline shortcuts never contain cancel overlaps in elicitation mode.
5. Onboarding safety:
1. Printable quit key is suppressed when API-key input is active and
non-empty.
2. Printable quit key is not suppressed when input is empty.
3. Control/alt quit chords are not suppressed.
6. Footer/help hints continue to reflect effective primary bindings.
7. `https://github.com/openai/codex/blob/main/docs/default-keymap.toml`,
`docs/config.md`, and `docs/example-config.md` stay aligned with runtime
action names and defaults.
### Manual sanity checks
1. Start onboarding and enter API-key mode.
2. Bind `onboarding.quit` to a printable key.
3. Verify that key quits when input is empty, then types once text exists.
4. Verify `ctrl-c` or another control quit chord still exits.
5. Trigger an MCP elicitation request and verify `Esc` cancels, not declines.
## Documentation maintenance
When adding/changing keymap API surface:
1. Update runtime definitions and defaults in `codex-rs/tui/src/keymap.rs`.
2. Update `docs/default-keymap.toml`.
3. Update `docs/config.md` and `docs/example-config.md` snippets.
4. Update this file with behavioral or safety contract changes.
5. Add/update regression tests in `codex-rs/tui`.