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
+78 -35
View File
@@ -311,6 +311,17 @@ fn queued_message_edit_binding_for_terminal(terminal_info: TerminalInfo) -> KeyB
}
}
fn queued_message_edit_hint_binding(
bindings: &[KeyBinding],
terminal_info: TerminalInfo,
) -> Option<KeyBinding> {
let terminal_binding = queued_message_edit_binding_for_terminal(terminal_info);
bindings
.contains(&terminal_binding)
.then_some(terminal_binding)
.or_else(|| bindings.first().copied())
}
use crate::app_event::AppEvent;
use crate::app_event::ConnectorsSnapshot;
use crate::app_event::ExitMode;
@@ -361,8 +372,9 @@ use crate::history_cell::PlainHistoryCell;
use crate::history_cell::WebSearchCell;
use crate::key_hint;
use crate::key_hint::KeyBinding;
#[cfg(test)]
use crate::markdown::append_markdown;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ChatKeymap;
use crate::keymap::RuntimeKeymap;
use crate::render::Insets;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::FlexRenderable;
@@ -382,6 +394,7 @@ use self::goal_status::goal_status_indicator_from_app_goal;
mod goal_menu;
mod interrupts;
use self::interrupts::InterruptManager;
mod keymap_picker;
mod session_header;
use self::session_header::SessionHeader;
mod skills;
@@ -832,6 +845,7 @@ pub(crate) struct ChatWidget {
plan_stream_controller: Option<PlanStreamController>,
/// Holds the platform clipboard lease so copied text remains available while supported.
clipboard_lease: Option<crate::clipboard_copy::ClipboardLease>,
copy_last_response_binding: Vec<KeyBinding>,
/// Raw markdown of the most recently completed agent response that
/// survived any local thread rollback.
last_agent_markdown: Option<String>,
@@ -965,11 +979,12 @@ pub(crate) struct ChatWidget {
// When set, the next interrupt should resubmit all pending steers as one
// fresh user turn instead of restoring them into the composer.
submit_pending_steers_after_interrupt: bool,
/// Terminal-appropriate keybinding for popping the most-recently queued
/// message back into the composer. Determined once at construction time via
/// [`queued_message_edit_binding_for_terminal`] and propagated to
/// `BottomPane` so the hint text matches the actual shortcut.
queued_message_edit_binding: KeyBinding,
/// Main chat-surface bindings resolved from `tui.keymap.chat`.
chat_keymap: ChatKeymap,
/// Keybinding to show for popping the most-recently queued message back
/// into the composer. This may differ from the first configured binding
/// when the default set includes a terminal-specific fallback.
queued_message_edit_hint_binding: Option<KeyBinding>,
// Pending notification to show when unfocused on next Draw
pending_notification: Option<Notification>,
/// When `Some`, the user has pressed a quit shortcut and the second press
@@ -5482,7 +5497,21 @@ impl ChatWidget {
let current_cwd = Some(config.cwd.to_path_buf());
let effective_service_tier = config.service_tier;
let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info());
let current_terminal_info = terminal_info();
let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok();
let default_keymap = RuntimeKeymap::defaults();
let copy_last_response_binding = runtime_keymap
.as_ref()
.map(|keymap| keymap.app.copy.clone())
.unwrap_or_else(|| default_keymap.app.copy.clone());
let chat_keymap = runtime_keymap
.as_ref()
.map(|keymap| keymap.chat.clone())
.unwrap_or_else(|| default_keymap.chat.clone());
let queued_message_edit_hint_binding = queued_message_edit_hint_binding(
&chat_keymap.edit_queued_message,
current_terminal_info,
);
let mut widget = Self {
app_event_tx: app_event_tx.clone(),
frame_requester: frame_requester.clone(),
@@ -5524,6 +5553,7 @@ impl ChatWidget {
stream_controller: None,
plan_stream_controller: None,
clipboard_lease: None,
copy_last_response_binding,
running_commands: HashMap::new(),
collab_agent_metadata: HashMap::new(),
pending_collab_spawn_requests: HashMap::new(),
@@ -5583,7 +5613,8 @@ impl ChatWidget {
rejected_steer_history_records: VecDeque::new(),
pending_steers: VecDeque::new(),
submit_pending_steers_after_interrupt: false,
queued_message_edit_binding,
chat_keymap,
queued_message_edit_hint_binding,
show_welcome_banner: is_first_run,
startup_tooltip_override,
suppress_session_configured_redraw: false,
@@ -5628,6 +5659,10 @@ impl ChatWidget {
last_non_retry_error: None,
};
widget.prefetch_rate_limits();
if let Some(keymap) = runtime_keymap {
widget.bottom_pane.set_keymap_bindings(&keymap);
}
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
@@ -5646,7 +5681,7 @@ impl ChatWidget {
widget.sync_goal_command_enabled();
widget
.bottom_pane
.set_queued_message_edit_binding(widget.queued_message_edit_binding);
.set_queued_message_edit_binding(widget.queued_message_edit_hint_binding);
#[cfg(target_os = "windows")]
widget.bottom_pane.set_windows_degraded_sandbox_active(
crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED
@@ -5666,6 +5701,24 @@ impl ChatWidget {
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
if self.bottom_pane.has_active_view()
&& !matches!(
key_event,
KeyEvent {
code: KeyCode::Char(c),
modifiers,
kind: KeyEventKind::Press,
..
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'c')
)
{
self.bottom_pane.handle_key_event(key_event);
if self.bottom_pane.no_modal_or_popup_active() {
self.maybe_send_next_queued_input();
}
return;
}
if self.handle_reasoning_shortcut(key_event) {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
@@ -5673,20 +5726,17 @@ impl ChatWidget {
return;
}
if key_event.kind == KeyEventKind::Press
&& self.copy_last_response_binding.is_pressed(key_event)
{
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
}
match key_event {
// Ctrl+O - copy last agent response from the main view.
KeyEvent {
code: KeyCode::Char('o'),
modifiers: KeyModifiers::CONTROL,
kind: KeyEventKind::Press,
..
} => {
self.bottom_pane.clear_quit_shortcut_hint();
self.quit_shortcut_expires_at = None;
self.quit_shortcut_key = None;
self.copy_last_agent_markdown();
return;
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
@@ -5745,8 +5795,9 @@ impl ChatWidget {
}
if key_event.kind == KeyEventKind::Press
&& self.queued_message_edit_binding.is_press(key_event)
&& self.chat_keymap.edit_queued_message.is_pressed(key_event)
&& self.has_queued_follow_up_messages()
&& self.bottom_pane.no_modal_or_popup_active()
{
if let Some(user_message) = self.pop_latest_queued_user_message() {
self.restore_user_message_to_composer(user_message);
@@ -7873,7 +7924,7 @@ impl ChatWidget {
} else {
// Show explanation when there are no structured findings.
let mut rendered: Vec<ratatui::text::Line<'static>> = vec!["".into()];
append_markdown(
crate::markdown::append_markdown(
&explanation,
/*width*/ None,
Some(self.config.cwd.as_path()),
@@ -9075,7 +9126,7 @@ impl ChatWidget {
"Access legacy models by running codex -m <model_name> or in your config.toml",
);
self.bottom_pane.show_selection_view(SelectionViewParams {
footer_hint: Some("Press enter to select reasoning effort, or esc to dismiss.".into()),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
header,
..Default::default()
@@ -11279,7 +11330,7 @@ impl ChatWidget {
SelectionViewParams {
view_id: Some(CONNECTORS_SELECTION_VIEW_ID),
header: Box::new(header),
footer_hint: Some(Self::connectors_popup_hint_line()),
footer_hint: Some(self.bottom_pane.standard_popup_hint_line()),
items,
is_searchable: true,
search_placeholder: Some("Type to search apps".to_string()),
@@ -11309,14 +11360,6 @@ impl ChatWidget {
);
}
fn connectors_popup_hint_line() -> Line<'static> {
Line::from(vec![
"Press ".into(),
key_hint::plain(KeyCode::Esc).into(),
" to close.".into(),
])
}
fn connector_brief_description(connector: &AppInfo) -> String {
let status_label = Self::connector_status_label(connector);
match Self::connector_description(connector) {