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
Unverified
parent a61c785040
commit 5e737372ee
63 changed files with 8142 additions and 877 deletions
+7
View File
@@ -48,6 +48,7 @@ use codex_config::types::SandboxWorkspaceWrite;
use codex_config::types::SkillsConfig;
use codex_config::types::ToolSuggestDiscoverableType;
use codex_config::types::Tui;
use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_exec_server::LOCAL_FS;
use codex_features::Feature;
@@ -547,6 +548,7 @@ fn config_toml_deserializes_model_availability_nux() {
status_line: None,
terminal_title: None,
theme: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig {
shown_count: HashMap::from([
("gpt-bar".to_string(), 4),
@@ -1586,6 +1588,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
status_line: None,
terminal_title: None,
theme: None,
keymap: TuiKeymap::default(),
model_availability_nux: ModelAvailabilityNuxConfig::default(),
terminal_resize_reflow_max_rows: None,
}
@@ -5636,6 +5639,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
},
o3_profile_config
@@ -5829,6 +5833,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
};
@@ -5976,6 +5981,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
};
@@ -6108,6 +6114,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
tui_status_line: None,
tui_terminal_title: None,
tui_theme: None,
tui_keymap: TuiKeymap::default(),
otel: OtelConfig::default(),
};
+39
View File
@@ -113,6 +113,45 @@ pub fn terminal_title_items_edit(items: &[String]) -> ConfigEdit {
}
}
fn keymap_binding_value(keys: &[String]) -> TomlItem {
if let [key] = keys {
value(key.to_string())
} else {
let array = keys.iter().cloned().collect::<toml_edit::Array>();
TomlItem::Value(array.into())
}
}
/// Produces a config edit that replaces one root-level TUI keymap binding list.
pub fn keymap_bindings_edit(context: &str, action: &str, keys: &[String]) -> ConfigEdit {
ConfigEdit::SetPath {
segments: vec![
"tui".to_string(),
"keymap".to_string(),
context.to_string(),
action.to_string(),
],
value: keymap_binding_value(keys),
}
}
/// Produces a config edit that replaces one root-level TUI keymap binding.
pub fn keymap_binding_edit(context: &str, action: &str, key: &str) -> ConfigEdit {
keymap_bindings_edit(context, action, &[key.to_string()])
}
/// Produces a config edit that removes one root-level TUI keymap binding.
pub fn keymap_binding_clear_edit(context: &str, action: &str) -> ConfigEdit {
ConfigEdit::ClearPath {
segments: vec![
"tui".to_string(),
"keymap".to_string(),
context.to_string(),
action.to_string(),
],
}
}
pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) -> Vec<ConfigEdit> {
let mut shown_count_entries: Vec<_> = shown_count.iter().collect();
shown_count_entries.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
+165
View File
@@ -48,6 +48,171 @@ fn builder_with_edits_applies_custom_paths() {
assert_eq!(contents, "enabled = true\n");
}
#[test]
fn keymap_binding_edit_writes_root_action_binding() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[tui.keymap.composer]
submit = "ctrl-enter"
"#;
assert_eq!(contents, expected);
}
#[test]
fn keymap_bindings_edit_writes_single_binding_as_string() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([keymap_bindings_edit(
"composer",
"submit",
&["ctrl-enter".to_string()],
)])
.apply_blocking()
.expect("persist");
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let expected = r#"[tui.keymap.composer]
submit = "ctrl-enter"
"#;
assert_eq!(contents, expected);
}
#[test]
fn keymap_bindings_edit_writes_multiple_bindings_as_array() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
ConfigEditsBuilder::new(codex_home)
.with_edits([keymap_bindings_edit(
"composer",
"submit",
&["enter".to_string(), "ctrl-enter".to_string()],
)])
.apply_blocking()
.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let value: TomlValue = toml::from_str(&raw).expect("parse config");
assert_eq!(
value
.get("tui")
.and_then(|value| value.get("keymap"))
.and_then(|value| value.get("composer"))
.and_then(|value| value.get("submit"))
.and_then(TomlValue::as_array)
.map(|values| {
values
.iter()
.filter_map(TomlValue::as_str)
.collect::<Vec<_>>()
}),
Some(vec!["enter", "ctrl-enter"])
);
}
#[test]
fn keymap_binding_edit_replaces_existing_binding_without_touching_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"profile = "team"
[tui.keymap.composer]
submit = "enter"
[profiles.team.tui.keymap.composer]
submit = "shift-enter"
"#,
)
.expect("seed config");
ConfigEditsBuilder::new(codex_home)
.with_edits([keymap_binding_edit("composer", "submit", "ctrl-enter")])
.apply_blocking()
.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let value: TomlValue = toml::from_str(&raw).expect("parse config");
assert_eq!(
value
.get("tui")
.and_then(|value| value.get("keymap"))
.and_then(|value| value.get("composer"))
.and_then(|value| value.get("submit"))
.and_then(TomlValue::as_str),
Some("ctrl-enter")
);
assert_eq!(
value
.get("profiles")
.and_then(|value| value.get("team"))
.and_then(|value| value.get("tui"))
.and_then(|value| value.get("keymap"))
.and_then(|value| value.get("composer"))
.and_then(|value| value.get("submit"))
.and_then(TomlValue::as_str),
Some("shift-enter")
);
}
#[test]
fn keymap_binding_clear_edit_removes_root_action_binding_without_touching_profile() {
let tmp = tempdir().expect("tmpdir");
let codex_home = tmp.path();
std::fs::write(
codex_home.join(CONFIG_TOML_FILE),
r#"profile = "team"
[tui.keymap.composer]
submit = "enter"
[profiles.team.tui.keymap.composer]
submit = "shift-enter"
"#,
)
.expect("seed config");
ConfigEditsBuilder::new(codex_home)
.with_edits([keymap_binding_clear_edit("composer", "submit")])
.apply_blocking()
.expect("persist");
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
let value: TomlValue = toml::from_str(&raw).expect("parse config");
assert_eq!(
value
.get("tui")
.and_then(|value| value.get("keymap"))
.and_then(|value| value.get("composer"))
.and_then(|value| value.get("submit")),
None
);
assert_eq!(
value
.get("profiles")
.and_then(|value| value.get("team"))
.and_then(|value| value.get("tui"))
.and_then(|value| value.get("keymap"))
.and_then(|value| value.get("composer"))
.and_then(|value| value.get("submit"))
.and_then(TomlValue::as_str),
Some("shift-enter")
);
}
#[test]
fn set_model_availability_nux_count_writes_shown_count() {
let tmp = tempdir().expect("tmpdir");
+15 -1
View File
@@ -47,6 +47,7 @@ use codex_config::types::OtelConfigToml;
use codex_config::types::OtelExporterKind;
use codex_config::types::ToolSuggestConfig;
use codex_config::types::ToolSuggestDiscoverable;
use codex_config::types::TuiKeymap;
use codex_config::types::TuiNotificationSettings;
use codex_config::types::UriBasedFileOpener;
use codex_config::types::WindowsSandboxModeToml;
@@ -467,7 +468,6 @@ 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` and `current-dir`.
@@ -486,6 +486,15 @@ pub struct Config {
/// Terminal resize-reflow tuning knobs.
pub terminal_resize_reflow: TerminalResizeReflowConfig,
/// Keybinding overrides for the TUI.
///
/// Precedence is:
///
/// 1. context table (`tui.keymap.chat`, `tui.keymap.composer`, etc.)
/// 2. `tui.keymap.global`
/// 3. built-in defaults
pub tui_keymap: TuiKeymap,
/// The absolute 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.
@@ -2682,6 +2691,11 @@ impl Config {
tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()),
tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()),
terminal_resize_reflow,
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);