mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(tui): make turn interruption keybind configurable (#24766)
## Why Interrupting an active turn is currently fixed to `Esc`, which is easy to hit accidentally and cannot be customized through `/keymap`. This gives users a less accidental binding while preserving the existing default. ## What Changed - Adds `tui.keymap.chat.interrupt_turn` to `/keymap`, defaulting to `esc` and supporting remapping or unbinding. - Uses the configured interrupt binding for running-turn status, queued steer interruption, and `request_user_input`, including the visible hints. - Preserves local `Esc` behavior for popups, Vim insert mode, and `/agent` editing while validating conflicts with fixed/backtrack and request-input navigation bindings. - Adds behavior and snapshot coverage for remapped interruption paths. ## How to Test 1. Run Codex and open `/keymap`, then set **Interrupt Turn** to `f12`. 2. Start a turn and confirm `Esc` no longer interrupts it while `f12` does; the running hint should display `f12 to interrupt`. 3. Queue a steer while a turn is running and confirm the preview displays `f12`; pressing it should interrupt and submit the steer immediately. 4. Trigger a `request_user_input` prompt and confirm its footer uses `f12`; with notes open, `Esc` should still clear notes while `f12` interrupts the turn. 5. Clear the Interrupt Turn binding and confirm the key-specific interrupt hint is removed while `Ctrl+C` remains available. Targeted validation: - `just write-config-schema` - `just fix -p codex-config` - `just fix -p codex-tui` - `just fmt` - `just argument-comment-lint-from-source -p codex-config -p codex-tui` - `just test -p codex-config` - `cargo insta pending-snapshots --manifest-path tui/Cargo.toml` - `just test -p codex-tui keymap_setup::tests` - `just test -p codex-tui` (fails in two pre-existing guardian feature-flag tests unrelated to this diff; the intentional picker snapshot updates were reviewed and accepted)
This commit is contained in:
@@ -20,6 +20,7 @@ use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
@@ -49,6 +50,7 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
/// Optional suffix rendered after the elapsed/interrupt segment.
|
||||
inline_message: Option<String>,
|
||||
show_interrupt_hint: bool,
|
||||
interrupt_binding: Option<KeyBinding>,
|
||||
|
||||
elapsed_running: Duration,
|
||||
last_resume_at: Instant,
|
||||
@@ -87,6 +89,7 @@ impl StatusIndicatorWidget {
|
||||
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||
inline_message: None,
|
||||
show_interrupt_hint: true,
|
||||
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
|
||||
elapsed_running: Duration::ZERO,
|
||||
last_resume_at: Instant::now(),
|
||||
is_paused: false,
|
||||
@@ -125,7 +128,7 @@ impl StatusIndicatorWidget {
|
||||
});
|
||||
}
|
||||
|
||||
/// Update the inline suffix text shown after `({elapsed} • esc to interrupt)`.
|
||||
/// Update the inline suffix text shown after the elapsed/interrupt hint.
|
||||
///
|
||||
/// Callers should provide plain, already-contextualized text. Passing
|
||||
/// verbose status prose here can cause frequent width truncation and hide
|
||||
@@ -150,6 +153,10 @@ impl StatusIndicatorWidget {
|
||||
self.show_interrupt_hint = visible;
|
||||
}
|
||||
|
||||
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<KeyBinding>) {
|
||||
self.interrupt_binding = binding;
|
||||
}
|
||||
|
||||
pub(crate) fn pause_timer(&mut self) {
|
||||
self.pause_timer_at(Instant::now());
|
||||
}
|
||||
@@ -257,10 +264,12 @@ impl Renderable for StatusIndicatorWidget {
|
||||
if !spans.is_empty() {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
if self.show_interrupt_hint {
|
||||
if self.show_interrupt_hint
|
||||
&& let Some(interrupt_binding) = self.interrupt_binding
|
||||
{
|
||||
spans.extend(vec![
|
||||
format!("({pretty_elapsed} • ").dim(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
interrupt_binding.into(),
|
||||
" to interrupt)".dim(),
|
||||
]);
|
||||
} else {
|
||||
@@ -405,6 +414,26 @@ mod tests {
|
||||
assert!(line.starts_with("Working (0s • esc to interrupt)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_remapped_interrupt_hint() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut w = StatusIndicatorWidget::new(
|
||||
tx,
|
||||
crate::tui::FrameRequester::test_dummy(),
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
w.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12))));
|
||||
w.is_paused = true;
|
||||
w.elapsed_running = Duration::ZERO;
|
||||
|
||||
let mut terminal = Terminal::new(TestBackend::new(80, 1)).expect("terminal");
|
||||
terminal
|
||||
.draw(|f| w.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw");
|
||||
insta::assert_snapshot!(terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timer_pauses_when_requested() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
Reference in New Issue
Block a user