mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
@@ -340,6 +340,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();
|
||||
}
|
||||
@@ -1590,6 +1591,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, .. } => {
|
||||
@@ -1599,6 +1601,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::Permissions {
|
||||
@@ -1623,6 +1626,7 @@ impl App {
|
||||
self.overlay = Some(Overlay::new_static_with_renderables(
|
||||
vec![Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))],
|
||||
"P E R M I S S I O N S".to_string(),
|
||||
self.keymap.pager.clone(),
|
||||
));
|
||||
}
|
||||
ApprovalRequest::McpElicitation {
|
||||
@@ -1640,6 +1644,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(),
|
||||
));
|
||||
}
|
||||
},
|
||||
@@ -1736,10 +1741,154 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::OpenKeymapActionMenu { context, action } => {
|
||||
self.chat_widget
|
||||
.open_keymap_action_menu(context, action, &self.keymap);
|
||||
}
|
||||
AppEvent::OpenKeymapReplaceBindingMenu { context, action } => {
|
||||
self.chat_widget
|
||||
.open_keymap_replace_binding_menu(context, action, &self.keymap);
|
||||
}
|
||||
AppEvent::OpenKeymapCapture {
|
||||
context,
|
||||
action,
|
||||
intent,
|
||||
} => {
|
||||
self.chat_widget
|
||||
.open_keymap_capture(context, action, intent, &self.keymap);
|
||||
}
|
||||
AppEvent::KeymapCaptured {
|
||||
context,
|
||||
action,
|
||||
key,
|
||||
intent,
|
||||
} => {
|
||||
self.apply_keymap_capture(context, action, key, intent)
|
||||
.await;
|
||||
}
|
||||
AppEvent::KeymapCleared { context, action } => {
|
||||
self.apply_keymap_clear(context, action).await;
|
||||
}
|
||||
}
|
||||
Ok(AppRunControl::Continue)
|
||||
}
|
||||
|
||||
async fn apply_keymap_capture(
|
||||
&mut self,
|
||||
context: String,
|
||||
action: String,
|
||||
key: String,
|
||||
intent: crate::app_event::KeymapEditIntent,
|
||||
) {
|
||||
let outcome = match crate::keymap_setup::keymap_with_edit(
|
||||
&self.config.tui_keymap,
|
||||
&self.keymap,
|
||||
&context,
|
||||
&action,
|
||||
&key,
|
||||
&intent,
|
||||
) {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let (keymap_config, bindings, message) = match outcome {
|
||||
crate::keymap_setup::KeymapEditOutcome::Updated {
|
||||
keymap_config,
|
||||
bindings,
|
||||
message,
|
||||
} => (*keymap_config, bindings, message),
|
||||
crate::keymap_setup::KeymapEditOutcome::Unchanged { message } => {
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
|
||||
Ok(runtime_keymap) => runtime_keymap,
|
||||
Err(err) => {
|
||||
let params = crate::keymap_setup::build_keymap_conflict_params(
|
||||
context, action, key, intent, err,
|
||||
);
|
||||
self.chat_widget.show_selection_view(params);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let edit =
|
||||
crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.config.tui_keymap = keymap_config.clone();
|
||||
self.keymap = runtime_keymap.clone();
|
||||
self.chat_widget
|
||||
.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
self.chat_widget
|
||||
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
||||
self.chat_widget.add_info_message(message, /*hint*/ None);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to persist keymap binding");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save shortcut: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_keymap_clear(&mut self, context: String, action: String) {
|
||||
let keymap_config = match crate::keymap_setup::keymap_without_custom_binding(
|
||||
&self.config.tui_keymap,
|
||||
&context,
|
||||
&action,
|
||||
) {
|
||||
Ok(keymap_config) => keymap_config,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let runtime_keymap = match RuntimeKeymap::from_config(&keymap_config) {
|
||||
Ok(runtime_keymap) => runtime_keymap,
|
||||
Err(err) => {
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to refresh shortcuts: {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action);
|
||||
match ConfigEditsBuilder::new(&self.config.codex_home)
|
||||
.with_edits([edit])
|
||||
.apply()
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
self.config.tui_keymap = keymap_config.clone();
|
||||
self.keymap = runtime_keymap.clone();
|
||||
self.chat_widget
|
||||
.apply_keymap_update(keymap_config, &runtime_keymap);
|
||||
self.chat_widget
|
||||
.return_to_keymap_picker(&context, &action, &runtime_keymap);
|
||||
self.chat_widget.add_info_message(
|
||||
format!("Removed custom shortcut for `{context}.{action}`."),
|
||||
/*hint*/ None,
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!(error = %err, "failed to clear keymap binding");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to remove shortcut: {err}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_exit_mode(
|
||||
&mut self,
|
||||
app_server: &mut AppServerSession,
|
||||
|
||||
@@ -122,24 +122,46 @@ impl App {
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: 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();
|
||||
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);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('l'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(key_event.code, KeyCode::Esc)
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
{
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match key_event {
|
||||
_ if self.keymap.app.clear_terminal.is_pressed(key_event) => {
|
||||
if !self.chat_widget.can_run_ctrl_l_clear_now() {
|
||||
return;
|
||||
}
|
||||
@@ -153,38 +175,6 @@ impl App {
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('g'),
|
||||
modifiers: 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);
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
// Enter confirms backtrack when primed + count > 0. Otherwise pass to widget.
|
||||
KeyEvent {
|
||||
code: KeyCode::Enter,
|
||||
|
||||
@@ -33,6 +33,7 @@ pub(super) async fn make_test_app() -> App {
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
|
||||
@@ -3696,6 +3696,7 @@ async fn make_test_app() -> App {
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -3755,6 +3756,7 @@ async fn make_test_app_with_channels() -> (
|
||||
transcript_reflow: TranscriptReflowState::default(),
|
||||
initial_history_replay_buffer: None,
|
||||
enhanced_keys_supported: false,
|
||||
keymap: crate::keymap::RuntimeKeymap::defaults(),
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
|
||||
@@ -4754,7 +4756,10 @@ async fn queued_rollback_syncs_overlay_and_clears_deferred_history() {
|
||||
/*is_first_line*/ 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;
|
||||
@@ -4978,7 +4983,10 @@ async fn clear_only_ui_reset_preserves_chat_session_state() {
|
||||
local_image_paths: Vec::new(),
|
||||
remote_image_urls: Vec::new(),
|
||||
}) as Arc<dyn HistoryCell>];
|
||||
app.overlay = Some(Overlay::new_transcript(app.transcript_cells.clone()));
|
||||
app.overlay = Some(Overlay::new_transcript(
|
||||
app.transcript_cells.clone(),
|
||||
crate::keymap::RuntimeKeymap::defaults().pager,
|
||||
));
|
||||
app.deferred_history_lines = vec![Line::from("stale buffered line")];
|
||||
app.has_emitted_history_lines = true;
|
||||
app.backtrack.primed = true;
|
||||
|
||||
Reference in New Issue
Block a user