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

## Why

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

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

This is PR1 in the keymap stack:

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

## Design Notes

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

The main concepts are:

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

## What Changed

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

## Validation

The branch includes focused coverage for config parsing, key
normalization, runtime fallback resolution, explicit unbinding,
duplicate-key conflict validation, default keymap consistency,
onboarding startup key behavior, and UI hint snapshots affected by
resolved key bindings.
This commit is contained in:
Felipe Coury
2026-04-28 12:52:25 -03:00
committed by GitHub
parent a61c785040
commit 5e737372ee
63 changed files with 8142 additions and 877 deletions
@@ -0,0 +1,165 @@
//! `ChatWidget` integration points for the `/keymap` picker flow.
//!
//! The picker model, capture view, and edit semantics live in [`crate::keymap_setup`]. This module
//! keeps only the `ChatWidget`-owned responsibilities: opening those views in the bottom pane,
//! routing users back to the right picker row after an edit, and synchronizing the committed
//! keymap config back into the live widget state. Keeping these methods outside `chatwidget.rs`
//! keeps the main transcript/event surface from also owning the `/keymap` navigation details.
//!
//! The important invariant is that any accepted keymap edit must update three places together:
//! the stored `Config.tui_keymap`, the cached copy-response binding used by app-level shortcuts,
//! and the bottom pane's runtime keymap bindings. Updating only one of those would make the UI
//! appear to accept a remap while some handlers still respond to the old keys.
use codex_config::types::TuiKeymap;
use codex_terminal_detection::terminal_info;
use super::ChatWidget;
use super::queued_message_edit_hint_binding;
use crate::app_event::KeymapEditIntent;
use crate::keymap::RuntimeKeymap;
use crate::keymap_setup;
impl ChatWidget {
/// Opens the root `/keymap` picker using the current `tui.keymap` configuration.
///
/// This validates the persisted keymap before building picker rows because every subsequent
/// picker screen needs the effective runtime bindings, including preset defaults and user
/// overrides. If the config is invalid, the user sees the parse error instead of a partial
/// picker that could commit edits against stale runtime state.
pub(crate) fn open_keymap_picker(&mut self) {
match RuntimeKeymap::from_config(&self.config.tui_keymap) {
Ok(runtime_keymap) => {
let params = keymap_setup::build_keymap_picker_params(
&runtime_keymap,
&self.config.tui_keymap,
);
self.bottom_pane.show_selection_view(params);
}
Err(err) => {
self.add_error_message(format!("Invalid `tui.keymap` configuration: {err}"));
}
}
}
/// Opens the per-action menu for one keymap action.
///
/// Callers pass the already-resolved runtime keymap from the app event that selected the
/// action. Recomputing it here would risk showing a menu for a different config if another
/// keymap edit was applied between the picker event and this handler.
pub(crate) fn open_keymap_action_menu(
&mut self,
context: String,
action: String,
runtime_keymap: &RuntimeKeymap,
) {
let params = keymap_setup::build_keymap_action_menu_params(
context,
action,
runtime_keymap,
&self.config.tui_keymap,
);
self.bottom_pane.show_selection_view(params);
}
/// Opens the key-capture view for a set, replace, or alternate-binding edit.
///
/// The capture view owns raw key interpretation, but `ChatWidget` supplies the event sender so
/// the captured key can come back through the same app-event path as menu selections. Bypassing
/// that path would skip config persistence and leave the runtime keymap cache unchanged.
pub(crate) fn open_keymap_capture(
&mut self,
context: String,
action: String,
intent: KeymapEditIntent,
runtime_keymap: &RuntimeKeymap,
) {
let view = keymap_setup::build_keymap_capture_view(
context,
action,
intent,
runtime_keymap,
self.app_event_tx.clone(),
);
self.bottom_pane.show_view(Box::new(view));
self.request_redraw();
}
/// Opens the menu that lets the user choose which existing binding to replace.
///
/// This is only used for actions with multiple effective bindings. The chosen binding is
/// carried through the subsequent capture intent so replacement edits do not accidentally
/// collapse alternate bindings that should remain available.
pub(crate) fn open_keymap_replace_binding_menu(
&mut self,
context: String,
action: String,
runtime_keymap: &RuntimeKeymap,
) {
let params =
keymap_setup::build_keymap_replace_binding_menu_params(context, action, runtime_keymap);
self.bottom_pane.show_selection_view(params);
}
/// Returns to the root picker with the edited action selected.
///
/// The preferred path replaces any active keymap picker submenu in place so the bottom-pane
/// back stack does not accumulate obsolete menus after each edit. If the expected view stack is
/// no longer active, this falls back to showing a fresh picker rather than dropping the user on
/// a stale screen.
pub(crate) fn return_to_keymap_picker(
&mut self,
context: &str,
action: &str,
runtime_keymap: &RuntimeKeymap,
) {
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
runtime_keymap,
&self.config.tui_keymap,
context,
action,
);
let replaced = self.bottom_pane.replace_active_views_with_selection_view(
&[
keymap_setup::KEYMAP_PICKER_VIEW_ID,
keymap_setup::KEYMAP_ACTION_MENU_VIEW_ID,
keymap_setup::KEYMAP_REPLACE_BINDING_MENU_VIEW_ID,
],
params,
);
if !replaced {
let params = keymap_setup::build_keymap_picker_params_for_selected_action(
runtime_keymap,
&self.config.tui_keymap,
context,
action,
);
self.bottom_pane.show_selection_view(params);
}
self.request_redraw();
}
/// Applies a committed keymap edit to the live chat widget.
///
/// The caller is responsible for persisting the config file before invoking this method. This
/// method updates the in-memory config, app-level copy binding cache, and bottom-pane keymap
/// bindings as one unit; callers that update only `self.config.tui_keymap` would leave visible
/// picker state and active key handlers disagreeing until the next restart.
pub(crate) fn apply_keymap_update(
&mut self,
keymap_config: TuiKeymap,
runtime_keymap: &RuntimeKeymap,
) {
self.config.tui_keymap = keymap_config;
self.copy_last_response_binding = runtime_keymap.app.copy.clone();
self.chat_keymap = runtime_keymap.chat.clone();
self.queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&self.chat_keymap.edit_queued_message,
terminal_info(),
);
self.bottom_pane
.set_queued_message_edit_binding(self.queued_message_edit_hint_binding);
self.bottom_pane.set_keymap_bindings(runtime_keymap);
self.request_redraw();
}
}
@@ -15,14 +15,12 @@
use codex_protocol::config_types::ModeKind;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use crossterm::event::KeyModifiers;
use strum::IntoEnumIterator;
use super::ChatWidget;
use crate::app_event::AppEvent;
use crate::key_hint::KeyBindingListExt;
/// Direction requested by a reasoning-level shortcut.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -32,18 +30,6 @@ pub(super) enum ReasoningShortcutDirection {
}
impl ReasoningShortcutDirection {
fn from_key_event(key_event: KeyEvent) -> Option<Self> {
if key_event.kind != KeyEventKind::Press || key_event.modifiers != KeyModifiers::ALT {
return None;
}
match key_event.code {
KeyCode::Char(',') => Some(Self::Lower),
KeyCode::Char('.') => Some(Self::Raise),
_ => None,
}
}
fn bound_message(self, effort: ReasoningEffortConfig) -> String {
let label = ChatWidget::reasoning_effort_label(effort).to_lowercase();
match self {
@@ -66,7 +52,19 @@ impl ChatWidget {
/// persisting them. In Plan mode, shortcuts apply only to the active
/// Plan-mode override and skip the global-vs-Plan scope prompt.
pub(super) fn handle_reasoning_shortcut(&mut self, key_event: KeyEvent) -> bool {
let Some(direction) = ReasoningShortcutDirection::from_key_event(key_event) else {
let direction = if self
.chat_keymap
.decrease_reasoning_effort
.is_pressed(key_event)
{
ReasoningShortcutDirection::Lower
} else if self
.chat_keymap
.increase_reasoning_effort
.is_pressed(key_event)
{
ReasoningShortcutDirection::Raise
} else {
return false;
};
@@ -243,6 +243,9 @@ impl ChatWidget {
SlashCommand::Permissions => {
self.open_permissions_popup();
}
SlashCommand::Keymap => {
self.open_keymap_picker();
}
SlashCommand::ElevateSandbox => {
#[cfg(target_os = "windows")]
{
@@ -860,6 +863,7 @@ impl ChatWidget {
| SlashCommand::Goal
| SlashCommand::Collab
| SlashCommand::Side
| SlashCommand::Keymap
| SlashCommand::Agent
| SlashCommand::MultiAgents
| SlashCommand::Approvals
@@ -1,8 +1,10 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: before
---
Apps
Loading installed and available apps...
Loading apps... This updates when the full list is ready.
Press enter to confirm or esc to go back
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
How was this?
@@ -11,3 +11,5 @@ expression: popup
4. safety check Benign usage blocked due to safety checks or refusals.
5. other Slowness, feature suggestion, UX feedback, or anything
else.
Press enter to confirm or esc to go back
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Select Model and Effort
@@ -7,4 +7,4 @@ expression: popup
1. test-visible-model (current) test-visible-model description
Press enter to select reasoning effort, or esc to dismiss.
Press enter to confirm or esc to go back
@@ -14,4 +14,4 @@ expression: popup
5. gpt-5.2 (current) Optimized for professional work and long-running
agents.
Press enter to select reasoning effort, or esc to dismiss.
Press enter to confirm or esc to go back
@@ -1,5 +1,5 @@
---
source: tui/src/chatwidget/tests.rs
source: tui/src/chatwidget/tests/popups_and_settings.rs
expression: popup
---
Plugins
@@ -7,3 +7,5 @@ expression: popup
This updates when the marketplace list is ready.
Loading plugins... This updates when the marketplace list is ready.
Press enter to confirm or esc to go back
@@ -937,9 +937,10 @@ async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
#[tokio::test]
async fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_message_edit_binding = crate::key_hint::alt(KeyCode::Up);
chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)];
chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up));
chat.bottom_pane
.set_queued_message_edit_binding(crate::key_hint::alt(KeyCode::Up));
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
// Simulate a running task so messages would normally be queued.
chat.bottom_pane.set_task_running(/*running*/ true);
@@ -967,6 +968,24 @@ async fn alt_up_edits_most_recent_queued_message() {
);
}
#[tokio::test]
async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.chat_keymap.edit_queued_message = Vec::new();
chat.queued_message_edit_hint_binding = None;
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
chat.bottom_pane.set_task_running(/*running*/ true);
chat.queued_user_messages
.push_back(UserMessage::from("queued".to_string()).into());
chat.refresh_pending_input_preview();
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT));
assert!(chat.bottom_pane.composer_text().is_empty());
assert_eq!(chat.queued_user_messages.len(), 1);
}
#[tokio::test]
async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() {
assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo {
+6 -3
View File
@@ -211,6 +211,7 @@ pub(super) async fn make_chatwidget_manual(
stream_controller: None,
plan_stream_controller: None,
clipboard_lease: None,
copy_last_response_binding: crate::keymap::RuntimeKeymap::defaults().app.copy,
pending_guardian_review_status: PendingGuardianReviewStatus::default(),
recent_auto_review_denials: RecentAutoReviewDenials::default(),
terminal_title_status_kind: TerminalTitleStatusKind::Working,
@@ -275,7 +276,8 @@ pub(super) async fn make_chatwidget_manual(
rejected_steer_history_records: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding: crate::key_hint::alt(KeyCode::Up),
chat_keymap: crate::keymap::RuntimeKeymap::defaults().chat,
queued_message_edit_hint_binding: Some(crate::key_hint::alt(KeyCode::Up)),
suppress_session_configured_redraw: false,
suppress_initial_user_message_submit: false,
pending_notification: None,
@@ -739,9 +741,10 @@ pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_termi
terminal_info: TerminalInfo,
) {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info);
chat.queued_message_edit_hint_binding =
Some(queued_message_edit_binding_for_terminal(terminal_info));
chat.bottom_pane
.set_queued_message_edit_binding(chat.queued_message_edit_binding);
.set_queued_message_edit_binding(chat.queued_message_edit_hint_binding);
// Simulate a running task so messages would normally be queued.
chat.bottom_pane.set_task_running(/*running*/ true);
@@ -1341,6 +1341,65 @@ async fn ctrl_o_copy_reports_when_no_agent_response_exists() {
);
}
#[tokio::test]
async fn keymap_capture_can_capture_current_copy_shortcut() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let runtime_keymap = crate::keymap::RuntimeKeymap::defaults();
chat.open_keymap_capture(
"composer".to_string(),
"submit".to_string(),
crate::app_event::KeymapEditIntent::ReplaceAll,
&runtime_keymap,
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
let AppEvent::KeymapCaptured {
context,
action,
key,
intent,
} = rx.try_recv().expect("captured key event")
else {
panic!("expected keymap capture event");
};
assert_eq!(context, "composer");
assert_eq!(action, "submit");
assert_eq!(key, "ctrl-o");
assert_eq!(intent, crate::app_event::KeymapEditIntent::ReplaceAll);
assert!(
drain_insert_history(&mut rx).is_empty(),
"copy shortcut should not run while key capture is active"
);
}
#[tokio::test]
async fn copy_shortcut_can_be_remapped() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let mut keymap_config = chat.config_ref().tui_keymap.clone();
keymap_config.global.copy = Some(codex_config::types::KeybindingsSpec::One(
codex_config::types::KeybindingSpec("ctrl-x".to_string()),
));
let runtime_keymap =
crate::keymap::RuntimeKeymap::from_config(&keymap_config).expect("valid copy remap");
chat.apply_keymap_update(keymap_config, &runtime_keymap);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL));
assert!(
drain_insert_history(&mut rx).is_empty(),
"old copy shortcut should no longer copy"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one info message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("No agent response to copy"),
"expected remapped copy shortcut to run, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_copy_stores_clipboard_lease_and_preserves_it_on_failure() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;