Skip to content

feat(tui): configurable keymaps and Vim mode#17245

Open
fcoury-oai wants to merge 22 commits intomainfrom
fcoury/config-keybindings-vim-mode
Open

feat(tui): configurable keymaps and Vim mode#17245
fcoury-oai wants to merge 22 commits intomainfrom
fcoury/config-keybindings-vim-mode

Conversation

@fcoury-oai
Copy link
Copy Markdown
Contributor

@fcoury-oai fcoury-oai commented Apr 9, 2026

TL;DR

  • Adds configurable TUI keymaps with versioned defaults, conflict validation, and docs.
  • Adds Vim startup mode, shifted-letter compatibility, and Vim insert/normal cursor behavior fixes.
  • Adds a guided /keymap picker for setting or removing root-level custom shortcuts from the TUI.

Problem

TUI keybindings were hardcoded across dozens of match arms in app.rs, textarea.rs, and overlay handlers. Adding or changing a single binding required touching multiple files, and users had no way to remap keys without forking the codebase or hand-editing undocumented TOML. Vim mode existed, but users could neither start directly in Vim normal mode nor customize the Vim keys. Shifted-letter Vim commands (I, A, O, D, Y) were also unreliable across terminal emulators because different terminals report the same physical keypress as either Shift+lowercase or plain UPPERCASE.

Mental model

The keymap system is a three-layer resolver sitting between terminal input and application actions:

  1. Config layer (codex-rs/config/src/tui_keymap.rs) — defines the on-disk [tui.keymap] TOML contract. Each action lives under a context sub-table (global, chat, composer, editor, vim_normal, vim_operator, pager, list, approval, onboarding). Key specs are normalized at deserialization time into a canonical ctrl-alt-shift-<key> form. Unknown fields are rejected eagerly with #[serde(deny_unknown_fields)].

  2. Runtime resolver (codex-rs/tui/src/keymap.rs) — converts TuiKeymap config into a RuntimeKeymap of Vec<KeyBinding> per action. Resolution precedence is: context-specific override → global fallback (for selected chat/composer actions) → versioned preset defaults. After resolution, per-context conflict validation rejects duplicate key-to-action mappings within the same dispatch scope. Presets are frozen (v1) or additive (v2 adds alt-d as forward-word delete); latest is always an alias to the newest.

  3. Input matching (codex-rs/tui/src/key_hint.rs) — KeyBinding::is_press() checks incoming KeyEvents against bindings. A shifted-letter compatibility path (matches_shifted_ascii_letter_compat) ensures that a shift-a binding matches both Shift+a and plain A regardless of terminal behavior, while preserving additional modifiers like Ctrl.

A keypress flows: CrosstermEventTuiEvent::KeyApp::handle_key_event (app/chat bindings) → ChatWidget → composer dispatch → TextArea::input → modal dispatch (Vim insert vs normal vs operator-pending) or standard editor keymap. Conflict validation follows those dispatch scopes: app-level actions are checked against chat and composer actions that can be intercepted before the composer sees them; unrelated contexts like vim_normal and pager are allowed to reuse the same key because they are never evaluated together.

The /keymap command adds a guided editing layer on top of that resolver rather than a second source of truth. It builds a searchable catalog of supported actions from the same runtime keymap, shows the current binding and source, captures one replacement key, validates the resulting TuiKeymap, then persists only the chosen root-level [tui.keymap.<context>] action. Users can also remove a custom root binding, which deletes that config entry and lets the action fall back to preset/default resolution again.

Non-goals

  • Full Vim emulation: this is a single-line composer with basic modal editing, not a text editor. Visual mode, registers, counts, macros, and ex commands are out of scope.
  • Dynamic reloading: keymaps are resolved once at startup. Changing config.toml requires restarting the TUI.
  • Global conflict prevention: the same key appearing in independent scopes such as vim_normal and pager is allowed because they never evaluate simultaneously.
  • Profile-scoped interactive edits: the guided /keymap flow writes root-level [tui.keymap.*] entries so shortcuts stay consistent across profiles. Profile-specific keymaps remain possible by editing config manually.

Tradeoffs

  • Shifted-letter dual bindings: the default presets include both shift(KeyCode::Char('a')) and plain(KeyCode::Char('A')) for Vim commands that use uppercase letters. This duplicates entries but guarantees correctness across iTerm2, Terminal.app, Kitty, Alacritty, and Windows Terminal which all disagree on shift reporting. The alternative — relying solely on the matches_shifted_ascii_letter_compat runtime fallback — would work for user config but would make the defaults silently depend on a non-obvious compatibility path.
  • Preset freezing: v1 is immutable even if we discover better defaults later. Users who pin preset = "v1" get stable behavior forever. New improvements go into v2+ and latest rotates forward. The cost is carrying dead default tables; the benefit is that config files written today never silently change behavior.
  • Conflict validation is dispatch-order-aware: the validator runs two passes for the app scope because app-level handlers execute before composer handlers. If dispatch order changes, the two-pass structure must change in lockstep — this coupling is documented in validate_conflicts() but is not enforced structurally.
  • Startup default is separate from keymap defaults: [tui].vim_mode_default = true starts the composer in Vim normal mode. [tui.keymap] only decides which keypresses trigger actions; it does not implicitly enable Vim mode.

Architecture

~/.codex/config.toml
    │
    ▼
TuiKeymap (config/tui_keymap.rs)
    │  normalize_keybinding_spec() at deser time
    │  deny_unknown_fields on all context structs
    ▼
RuntimeKeymap::from_config() (tui/keymap.rs)
    │  resolve_local! / resolve_with_global! macros
    │  validate_conflicts()
    ▼
App.keymap (tui/app.rs:969)
    │
    ├─► App-level dispatch: toggle_vim_mode, open_transcript, ...
    ├─► ChatKeymap: edit_previous_message, confirm
    ├─► ComposerKeymap: submit, queue, toggle_shortcuts
    ├─► EditorKeymap / VimNormalKeymap / VimOperatorKeymap → TextArea
    ├─► PagerKeymap → transcript/help overlays
    ├─► ListKeymap → popup list pickers
    ├─► ApprovalKeymap → tool approval modal
    └─► OnboardingKeymap → welcome/auth/trust screens

/keymap (tui/keymap_setup.rs)
    │  search/select action
    │  capture replacement key or remove custom binding
    ▼
ConfigEdit::SetPath / ConfigEdit::ClearPath (core/config/edit.rs)
    │  writes root `[tui.keymap.<context>]`
    ▼
next TUI startup resolves through RuntimeKeymap::from_config()

Observability

  • Startup errors: invalid runtime key specs and conflicts fail TUI startup with the relevant tui.keymap... path, the offending value/action, and a link to the canonical keymap template at docs/default-keymap.toml. TOML shape errors such as unknown fields are rejected during config deserialization before runtime keymap resolution.
  • Vim mode indicator: the composer footer renders -- NORMAL -- / -- INSERT -- when Vim mode is active, driven by vim_mode_label() in textarea.rs.
  • Vim startup default: [tui].vim_mode_default is loaded into Config::tui_vim_mode_default; ChatWidget applies it when constructing the bottom pane so users can launch directly into normal mode.
  • Customization hints: the TUI surfaces keymap hint text showing the current primary binding for frequently-used actions.
  • Guided remapping: /keymap opens a searchable remap picker. Wide terminals show selected-action details in a side panel; narrow terminals keep a compact one-line description below the picker. Setting a binding replaces that action's root custom binding, while removing a binding clears only the root override and returns to defaults.

Tests

  • Config validation: unknown-field rejection (misplaced_action_at_keymap_root_is_rejected), valid context placement.
  • Startup default: config tests cover tui.vim_mode_default deserialization and ChatWidget integration tests assert both insert-mode and normal-mode startup.
  • Runtime resolver: canonical parsing, conflict detection across all 10 contexts, shadowing between app and composer scopes, global fallback, multi-binding arrays, deduplication, explicit unbinding via [], function key range.
  • Shifted-letter compatibility: shift-a matches both Shift+a and plain A; ctrl-shift-i matches Ctrl+I; no false positives on wrong case or unrelated uppercase letters.
  • Vim cursor behavior: escape-to-normal moves cursor back one position, handles position-zero without underflow, respects grapheme and atomic element boundaries, snapshot-locked cursor coordinates.
  • Preset integrity: defaults_pass_conflict_validation ensures built-in presets are self-consistent.
  • Guided remapping: config edit tests cover setting and clearing nested [tui.keymap.*] paths; TUI tests cover the picker catalog, action menu, capture view, conflict handling, remove-custom-binding enablement, selected-detail updates, and wide/narrow snapshots.

@fcoury-oai fcoury-oai force-pushed the fcoury/config-keybindings-vim-mode branch 4 times, most recently from acee7ed to 8d0bb6d Compare April 10, 2026 17:19
@fcoury-oai fcoury-oai changed the title feat(tui): add configurable keymaps and Vim startup mode feat(tui): configurable keymaps and Vim mode Apr 10, 2026
@fcoury-oai fcoury-oai force-pushed the fcoury/config-keybindings-vim-mode branch from b2c36ed to a56b93a Compare April 10, 2026 20:54
joshka-oai and others added 22 commits April 10, 2026 19:09
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.
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.
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.
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.
Actions placed directly under [tui.keymap] instead of a context
sub-table (e.g. [tui.keymap.global]) were silently ignored because
deny_unknown_fields was only on the schemars attribute, not serde.
Expose Vim bindings through the TUI keymap config and add a startup
toggle so Vim mode can be enabled by default.

Wire runtime keymap handling for Vim actions, add
`tui.vim_mode_default` to config parsing/runtime config, apply the
default in ChatWidget startup paths, and update docs/default
keymap/schema accordingly.
Update `tui/src/bottom_pane/textarea.rs` so `Esc` in Vim insert mode
moves the cursor to the previous atomic boundary before switching to
normal mode. This matches Vim behavior at end-of-line and avoids
leaving the cursor on a virtual trailing cell.

Add regression coverage in `tui/src/bottom_pane/textarea.rs` and
`tui/src/bottom_pane/chat_composer.rs`, plus a
`vim_escape_cursor_position` snapshot to lock rendered cursor
placement after `Esc`.
Make shifted letter bindings robust when terminals report uppercase
letters without explicit SHIFT modifiers.

This updates keybinding matching so bindings like `shift-i`, `shift-a`,
and `shift-o` also match `I`, `A`, and `O` event forms, and adds
regression tests covering Vim normal-mode actions for line-start insert,
line-end append, and open-line-above with shift-only bindings.
Clarify that shifted letter bindings are matched compatibly when
terminals emit uppercase letters without an explicit SHIFT modifier.

Update config guidance, the default keymap template, and the action
matrix with examples for shift-i, shift-a, and shift-o mapping to I, A,
and O.
Preserve mainline submit/queue semantics, update approval tests for
`SubmitThreadOp`, and keep permission-deny shortcuts from shadowing
`Esc` cancellation.

Add a `v2` keymap preset so `latest` can restore `alt-d`
`delete_forward_word` without mutating frozen `v1` defaults, and refresh
the generated config schema and docs.
Thread cursor style through the custom terminal and render tree so the chat composer can request an insert-like cursor while Vim mode is in Insert.

Restore the terminal default cursor style on Normal/non-Vim frames and TUI teardown, and cover the escape sequences plus composer mode transitions.
Let Vim Insert handle plain `Esc` before the chat edit-previous and backtrack shortcut layers so an empty composer can still return to Normal mode.

Keep existing empty-composer backtrack behavior after Vim has reached Normal mode, and cover both composer-local and app-level routing.
Regenerate the config schema after adding keymap rustdoc so the
fixture matches the generated `config.toml` schema.

Add the required argument comments for opaque literals in the vim and
cursor tests so the branch passes the argument-comment lint.
Apply the pinned Prettier layout to the keymap handoff and rollout notes
so the root `pnpm run format` check accepts the documentation.
Adds a `/keymap` flow for discovering actions, replacing root-level shortcut bindings, and removing custom bindings when users want to return to defaults.

The picker uses responsive layouts so wide terminals show richer action details while narrow terminals keep the guidance compact.
Adds argument-name comments to the keymap capture snapshot helper so the argument-comment lint accepts the test literals.
Add `global.copy` to the TUI keymap schema, runtime keymap,
and interactive remap action list so the Copy shortcut can be
remapped or unbound like other global shortcuts.

Move the existing `ctrl-o` default into a new `v3` preset while
preserving older presets, and update docs plus coverage for the
remapped copy behavior.
Set the manual `ChatWidget` test helper to use the default copy
shortcut binding so helper-built widgets match normal construction.

This fixes the CI compile failure from the new remappable copy field.
Add missing argument comments around positional boolean and numeric
literals that the TUI keymap work now reaches.

This keeps callsites self-documenting and satisfies the
argument-comment lint in CI.
Use a non-copy chord in the queue shadowing regression test so it still reaches the intended composer conflict after `global.copy` claimed `ctrl-o`.

Accept the keymap picker snapshots now that Copy is part of the configurable global keymap actions.
@fcoury-oai fcoury-oai force-pushed the fcoury/config-keybindings-vim-mode branch from 6793e76 to 638cde2 Compare April 10, 2026 22:11
@etraut-openai etraut-openai added the oai PRs contributed by OpenAI employees label Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

oai PRs contributed by OpenAI employees

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants