mirror of
https://github.com/openai/codex.git
synced 2026-05-28 23:10:20 +00: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:
@@ -115,6 +115,8 @@ pub struct TuiGlobalKeymap {
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct TuiChatKeymap {
|
||||
/// Interrupt the active turn.
|
||||
pub interrupt_turn: Option<KeybindingsSpec>,
|
||||
/// Decrease the active reasoning effort.
|
||||
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
|
||||
/// Increase the active reasoning effort.
|
||||
|
||||
@@ -2766,7 +2766,8 @@
|
||||
"chat": {
|
||||
"decrease_reasoning_effort": null,
|
||||
"edit_queued_message": null,
|
||||
"increase_reasoning_effort": null
|
||||
"increase_reasoning_effort": null,
|
||||
"interrupt_turn": null
|
||||
},
|
||||
"composer": {
|
||||
"history_search_next": null,
|
||||
@@ -3094,6 +3095,14 @@
|
||||
}
|
||||
],
|
||||
"description": "Increase the active reasoning effort."
|
||||
},
|
||||
"interrupt_turn": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/KeybindingsSpec"
|
||||
}
|
||||
],
|
||||
"description": "Interrupt the active turn."
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -3405,7 +3414,8 @@
|
||||
"default": {
|
||||
"decrease_reasoning_effort": null,
|
||||
"edit_queued_message": null,
|
||||
"increase_reasoning_effort": null
|
||||
"increase_reasoning_effort": null,
|
||||
"interrupt_turn": null
|
||||
}
|
||||
},
|
||||
"composer": {
|
||||
|
||||
@@ -25,7 +25,9 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
|
||||
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
use crate::keymap::primary_binding;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
@@ -352,6 +354,12 @@ impl BottomPane {
|
||||
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||
self.keymap = keymap.clone();
|
||||
self.composer.set_keymap_bindings(keymap);
|
||||
let interrupt_binding = primary_binding(&keymap.chat.interrupt_turn);
|
||||
self.pending_input_preview
|
||||
.set_interrupt_binding(interrupt_binding);
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_binding(interrupt_binding);
|
||||
}
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
@@ -617,13 +625,12 @@ impl BottomPane {
|
||||
.and_then(parse_slash_name)
|
||||
.is_some_and(|(name, _, _)| name == "agent");
|
||||
|
||||
// If a task is running and a status line is visible, allow Esc to
|
||||
// send an interrupt even while the composer has focus.
|
||||
// If a task is running and a status line is visible, allow the
|
||||
// configured action to interrupt even while the composer has focus.
|
||||
// When a popup is active, prefer dismissing it over interrupting the task.
|
||||
if key_event.code == KeyCode::Esc
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
if self.keymap.chat.interrupt_turn.is_pressed(key_event)
|
||||
&& self.is_task_running
|
||||
&& !is_agent_command
|
||||
&& !(is_agent_command && key_event.code == KeyCode::Esc)
|
||||
&& !self.composer.popup_active()
|
||||
&& !self.composer_should_handle_vim_insert_escape(key_event)
|
||||
&& let Some(status) = &self.status
|
||||
@@ -989,6 +996,7 @@ impl BottomPane {
|
||||
}
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_hint_visible(/*visible*/ true);
|
||||
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
|
||||
}
|
||||
self.sync_status_inline_message();
|
||||
self.request_redraw();
|
||||
@@ -1017,6 +1025,9 @@ impl BottomPane {
|
||||
self.frame_requester.clone(),
|
||||
self.animations_enabled,
|
||||
));
|
||||
if let Some(status) = self.status.as_mut() {
|
||||
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
|
||||
}
|
||||
self.sync_status_inline_message();
|
||||
self.request_redraw();
|
||||
}
|
||||
@@ -2731,6 +2742,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remapped_interrupt_turn_uses_configured_key_including_agent_drafts() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut pane = test_pane(tx);
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))];
|
||||
pane.set_keymap_bindings(&keymap);
|
||||
pane.set_task_running(/*running*/ true);
|
||||
pane.insert_str("/agent ");
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
assert!(
|
||||
rx.try_recv().is_err(),
|
||||
"expected Esc to remain local after remapping interruption"
|
||||
);
|
||||
|
||||
pane.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(AppEvent::CodexOp(Op::Interrupt))),
|
||||
"expected configured key to interrupt while `/agent` is being edited"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selection_view_esc_respects_remapped_list_cancel() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::wrapping::adaptive_wrap_lines;
|
||||
/// The widget renders pending steers first, then rejected steers that will be
|
||||
/// resubmitted at end of turn, then ordinary queued user messages. Pending
|
||||
/// steers explain that they will be submitted after the next tool/result
|
||||
/// boundary unless the user presses Esc to interrupt and send them
|
||||
/// boundary unless the user invokes the interrupt binding to send them
|
||||
/// immediately. The edit hint at the bottom only appears when there are actual
|
||||
/// queued user inputs to pop back into the composer. Because some terminals
|
||||
/// intercept certain modifier-key combinations, the displayed binding is
|
||||
@@ -27,6 +27,8 @@ pub(crate) struct PendingInputPreview {
|
||||
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||
/// be overridden for terminals where that chord is unavailable.
|
||||
edit_binding: Option<key_hint::KeyBinding>,
|
||||
/// Key combination rendered for immediately interrupting and sending steers.
|
||||
interrupt_binding: Option<key_hint::KeyBinding>,
|
||||
}
|
||||
|
||||
const PREVIEW_LINE_LIMIT: usize = 3;
|
||||
@@ -38,6 +40,7 @@ impl PendingInputPreview {
|
||||
rejected_steers: Vec::new(),
|
||||
queued_messages: Vec::new(),
|
||||
edit_binding: Some(key_hint::alt(KeyCode::Up)),
|
||||
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +51,10 @@ impl PendingInputPreview {
|
||||
self.edit_binding = binding;
|
||||
}
|
||||
|
||||
pub(crate) fn set_interrupt_binding(&mut self, binding: Option<key_hint::KeyBinding>) {
|
||||
self.interrupt_binding = binding;
|
||||
}
|
||||
|
||||
fn push_truncated_preview_lines(
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
wrapped: Vec<Line<'static>>,
|
||||
@@ -81,16 +88,15 @@ impl PendingInputPreview {
|
||||
let mut lines = vec![];
|
||||
|
||||
if !self.pending_steers.is_empty() {
|
||||
Self::push_section_header(
|
||||
&mut lines,
|
||||
width,
|
||||
Line::from(vec![
|
||||
"Messages to be submitted after next tool call".into(),
|
||||
let mut header = vec!["Messages to be submitted after next tool call".into()];
|
||||
if let Some(interrupt_binding) = self.interrupt_binding {
|
||||
header.extend(vec![
|
||||
" (press ".dim(),
|
||||
key_hint::plain(KeyCode::Esc).into(),
|
||||
interrupt_binding.into(),
|
||||
" to interrupt and send immediately)".dim(),
|
||||
]),
|
||||
);
|
||||
]);
|
||||
}
|
||||
Self::push_section_header(&mut lines, width, Line::from(header));
|
||||
|
||||
for steer in &self.pending_steers {
|
||||
let wrapped = adaptive_wrap_lines(
|
||||
@@ -327,6 +333,21 @@ mod tests {
|
||||
assert_snapshot!("render_one_pending_steer", format!("{buf:?}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_one_pending_steer_with_remapped_interrupt_binding() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
queue.pending_steers.push("Please continue.".to_string());
|
||||
queue.set_interrupt_binding(Some(key_hint::plain(KeyCode::F(12))));
|
||||
let width = 48;
|
||||
let height = queue.desired_height(width);
|
||||
let mut buf = Buffer::empty(Rect::new(0, 0, width, height));
|
||||
queue.render(Rect::new(0, 0, width, height), &mut buf);
|
||||
assert_snapshot!(
|
||||
"render_one_pending_steer_with_remapped_interrupt_binding",
|
||||
format!("{buf:?}")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_pending_steers_above_queued_messages() {
|
||||
let mut queue = PendingInputPreview::new();
|
||||
|
||||
@@ -143,6 +143,7 @@ pub(crate) struct RequestUserInputOverlay {
|
||||
pending_submission_draft: Option<ComposerDraft>,
|
||||
confirm_unanswered: Option<ScrollState>,
|
||||
composer_submit_keys: Vec<KeyBinding>,
|
||||
interrupt_turn_keys: Vec<KeyBinding>,
|
||||
list_keymap: ListKeymap,
|
||||
}
|
||||
|
||||
@@ -198,6 +199,7 @@ impl RequestUserInputOverlay {
|
||||
pending_submission_draft: None,
|
||||
confirm_unanswered: None,
|
||||
composer_submit_keys: keymap.composer.submit.clone(),
|
||||
interrupt_turn_keys: keymap.chat.interrupt_turn.clone(),
|
||||
list_keymap: keymap.list,
|
||||
};
|
||||
overlay.reset_for_request();
|
||||
@@ -506,8 +508,15 @@ impl RequestUserInputOverlay {
|
||||
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
|
||||
}
|
||||
}
|
||||
if !(self.has_options() && notes_visible) {
|
||||
tips.push(FooterTip::new("esc to interrupt"));
|
||||
if let Some(interrupt_key) = self.interrupt_turn_keys.first()
|
||||
&& !(self.has_options()
|
||||
&& notes_visible
|
||||
&& *interrupt_key == crate::key_hint::plain(KeyCode::Esc))
|
||||
{
|
||||
tips.push(FooterTip::new(format!(
|
||||
"{} to interrupt",
|
||||
interrupt_key.display_label()
|
||||
)));
|
||||
}
|
||||
tips
|
||||
}
|
||||
@@ -1056,11 +1065,12 @@ impl BottomPaneView for RequestUserInputOverlay {
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(key_event.code, KeyCode::Esc) {
|
||||
if self.has_options() && self.notes_ui_visible() {
|
||||
self.clear_notes_and_focus_options();
|
||||
return;
|
||||
}
|
||||
if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() {
|
||||
self.clear_notes_and_focus_options();
|
||||
return;
|
||||
}
|
||||
|
||||
if self.interrupt_turn_keys.is_pressed(key_event) {
|
||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||
// once core supports persisting them reliably without follow-up turn issues.
|
||||
self.app_event_tx.interrupt();
|
||||
@@ -2043,6 +2053,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_uses_remapped_interrupt_binding_while_notes_are_visible() {
|
||||
let (tx, mut rx) = test_sender();
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))];
|
||||
let mut overlay = RequestUserInputOverlay::new_with_keymap(
|
||||
request_event("turn-1", vec![question_with_options("q1", "Pick one")]),
|
||||
tx,
|
||||
/*has_input_focus*/ true,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
/*disable_paste_burst*/ false,
|
||||
keymap,
|
||||
);
|
||||
let answer = overlay.current_answer_mut().expect("answer missing");
|
||||
answer.options_state.selected_idx = Some(0);
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::Tab));
|
||||
|
||||
let tips = overlay.footer_tips();
|
||||
let tip_texts = tips.iter().map(|tip| tip.text.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
tip_texts,
|
||||
vec![
|
||||
"tab or esc to clear notes",
|
||||
"enter to submit answer",
|
||||
"f12 to interrupt",
|
||||
]
|
||||
);
|
||||
|
||||
overlay.handle_key_event(KeyEvent::from(KeyCode::F(12)));
|
||||
|
||||
assert_eq!(overlay.done, true);
|
||||
expect_interrupt_only(&mut rx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_opens_notes_when_option_selected() {
|
||||
let (tx, _rx) = test_sender();
|
||||
@@ -3171,6 +3215,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_freeform_remapped_interrupt_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
let mut keymap = RuntimeKeymap::defaults();
|
||||
keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))];
|
||||
let overlay = RequestUserInputOverlay::new_with_keymap(
|
||||
request_event("turn-1", vec![question_without_options("q1", "Goal")]),
|
||||
tx,
|
||||
/*has_input_focus*/ true,
|
||||
/*enhanced_keys_supported*/ false,
|
||||
/*disable_paste_burst*/ false,
|
||||
keymap,
|
||||
);
|
||||
let area = Rect::new(0, 0, 120, 10);
|
||||
insta::assert_snapshot!(
|
||||
"request_user_input_freeform_remapped_interrupt",
|
||||
render_snapshot(&overlay, area)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_user_input_multi_question_first_snapshot() {
|
||||
let (tx, _rx) = test_sender();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/request_user_input/mod.rs
|
||||
expression: "render_snapshot(&overlay, area)"
|
||||
---
|
||||
|
||||
Question 1/1 (1 unanswered)
|
||||
Share details.
|
||||
|
||||
› Type your answer (optional)
|
||||
|
||||
|
||||
|
||||
enter to submit answer | f12 to interrupt
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/pending_input_preview.rs
|
||||
expression: "format!(\"{buf:?}\")"
|
||||
---
|
||||
Buffer {
|
||||
area: Rect { x: 0, y: 0, width: 48, height: 3 },
|
||||
content: [
|
||||
"• Messages to be submitted after next tool call ",
|
||||
" (press f12 to interrupt and send immediately) ",
|
||||
" ↳ Please continue. ",
|
||||
],
|
||||
styles: [
|
||||
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 2, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 47, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
x: 0, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
|
||||
x: 20, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
|
||||
]
|
||||
}
|
||||
@@ -112,8 +112,7 @@ impl ChatWidget {
|
||||
return;
|
||||
}
|
||||
|
||||
if matches!(key_event.code, KeyCode::Esc)
|
||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||
if self.chat_keymap.interrupt_turn.is_pressed(key_event)
|
||||
&& !self.input_queue.pending_steers.is_empty()
|
||||
&& self.bottom_pane.is_task_running()
|
||||
&& self.bottom_pane.no_modal_or_popup_active()
|
||||
|
||||
@@ -953,6 +953,32 @@ async fn pending_steer_esc_does_not_steal_vim_insert_escape() {
|
||||
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_steer_interrupt_uses_remapped_binding() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut keymap = crate::keymap::RuntimeKeymap::defaults();
|
||||
keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))];
|
||||
chat.chat_keymap = keymap.chat.clone();
|
||||
chat.bottom_pane.set_keymap_bindings(&keymap);
|
||||
chat.bottom_pane.set_task_running(/*running*/ true);
|
||||
chat.input_queue
|
||||
.pending_steers
|
||||
.push_back(pending_steer("queued steer"));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
|
||||
assert!(!chat.input_queue.submit_pending_steers_after_interrupt);
|
||||
assert!(op_rx.try_recv().is_err());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(chat.input_queue.submit_pending_steers_after_interrupt);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
@@ -78,6 +78,8 @@ pub(crate) struct AppKeymap {
|
||||
/// handler code, not here.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct ChatKeymap {
|
||||
/// Interrupt the active turn.
|
||||
pub(crate) interrupt_turn: Vec<KeyBinding>,
|
||||
/// Decrease the active reasoning effort.
|
||||
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
|
||||
/// Increase the active reasoning effort.
|
||||
@@ -420,6 +422,11 @@ impl RuntimeKeymap {
|
||||
};
|
||||
|
||||
let chat = ChatKeymap {
|
||||
interrupt_turn: resolve_bindings(
|
||||
keymap.chat.interrupt_turn.as_ref(),
|
||||
&defaults.chat.interrupt_turn,
|
||||
"tui.keymap.chat.interrupt_turn",
|
||||
)?,
|
||||
decrease_reasoning_effort: resolve_bindings(
|
||||
keymap.chat.decrease_reasoning_effort.as_ref(),
|
||||
&defaults.chat.decrease_reasoning_effort,
|
||||
@@ -878,6 +885,7 @@ impl RuntimeKeymap {
|
||||
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
|
||||
},
|
||||
chat: ChatKeymap {
|
||||
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
|
||||
decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
|
||||
increase_reasoning_effort: default_bindings![alt(KeyCode::Char('.'))],
|
||||
edit_queued_message: default_bindings![alt(KeyCode::Up), shift(KeyCode::Left)],
|
||||
@@ -1127,6 +1135,7 @@ impl RuntimeKeymap {
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -1169,6 +1178,7 @@ impl RuntimeKeymap {
|
||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||
("toggle_raw_output", self.app.toggle_raw_output.as_slice()),
|
||||
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -1197,6 +1207,11 @@ impl RuntimeKeymap {
|
||||
),
|
||||
],
|
||||
MAIN_RESERVED_BINDINGS,
|
||||
[(
|
||||
"chat.interrupt_turn",
|
||||
"fixed.backtrack",
|
||||
key_hint::plain(KeyCode::Esc),
|
||||
)],
|
||||
)?;
|
||||
|
||||
validate_no_shadow_with_allowed_overlaps(
|
||||
@@ -1249,6 +1264,18 @@ impl RuntimeKeymap {
|
||||
)],
|
||||
)?;
|
||||
|
||||
// The request-user-input overlay consumes turn interruption before
|
||||
// configurable question navigation reaches its list handler.
|
||||
validate_no_shadow_with_allowed_overlaps(
|
||||
"request_user_input",
|
||||
[("chat.interrupt_turn", self.chat.interrupt_turn.as_slice())],
|
||||
[
|
||||
("list.move_left", self.list.move_left.as_slice()),
|
||||
("list.move_right", self.list.move_right.as_slice()),
|
||||
],
|
||||
[],
|
||||
)?;
|
||||
|
||||
// While the composer is focused, these main-surface handlers always
|
||||
// consume matching keys before the event reaches the textarea editor.
|
||||
validate_no_shadow_with_allowed_overlaps(
|
||||
@@ -1261,6 +1288,7 @@ impl RuntimeKeymap {
|
||||
),
|
||||
("copy", self.app.copy.as_slice()),
|
||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
||||
(
|
||||
"chat.decrease_reasoning_effort",
|
||||
self.chat.decrease_reasoning_effort.as_slice(),
|
||||
@@ -1522,6 +1550,7 @@ impl RuntimeKeymap {
|
||||
("close_transcript", self.pager.close_transcript.as_slice()),
|
||||
],
|
||||
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
|
||||
[],
|
||||
)?;
|
||||
|
||||
validate_unique(
|
||||
@@ -1675,10 +1704,11 @@ See the Codex keymap documentation for supported actions and examples."
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_no_reserved<const N: usize>(
|
||||
fn validate_no_reserved<const N: usize, const A: usize>(
|
||||
context: &str,
|
||||
pairs: [(&'static str, &[KeyBinding]); N],
|
||||
reserved: &[(&'static str, KeyBinding)],
|
||||
allowed_overlaps: [(&'static str, &'static str, KeyBinding); A],
|
||||
) -> Result<(), String> {
|
||||
for (action, bindings) in pairs {
|
||||
for binding in bindings {
|
||||
@@ -1687,6 +1717,15 @@ fn validate_no_reserved<const N: usize>(
|
||||
.iter()
|
||||
.find(|(_, reserved_binding)| reserved_binding.parts() == key)
|
||||
{
|
||||
if allowed_overlaps.iter().any(
|
||||
|(allowed_action, allowed_reserved_action, allowed_binding)| {
|
||||
*allowed_action == action
|
||||
&& *allowed_reserved_action == *reserved_action
|
||||
&& allowed_binding.parts() == key
|
||||
},
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
return Err(format!(
|
||||
"Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \
|
||||
Set a different key in `~/.codex/config.toml` and retry. \
|
||||
@@ -2076,6 +2115,10 @@ mod tests {
|
||||
vec![key_hint::ctrl(KeyCode::Char('l'))]
|
||||
);
|
||||
assert_eq!(runtime.app.toggle_fast_mode, Vec::new());
|
||||
assert_eq!(
|
||||
runtime.chat.interrupt_turn,
|
||||
vec![key_hint::plain(KeyCode::Esc)]
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.chat.decrease_reasoning_effort,
|
||||
vec![key_hint::alt(KeyCode::Char(','))]
|
||||
@@ -2498,6 +2541,44 @@ mod tests {
|
||||
expect_conflict(&keymap, "composer.submit", "fixed.paste_image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_turn_allows_backtrack_escape_and_can_be_remapped_or_unbound() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("default keymap should parse");
|
||||
assert_eq!(
|
||||
runtime.chat.interrupt_turn,
|
||||
vec![key_hint::plain(KeyCode::Esc)]
|
||||
);
|
||||
|
||||
keymap.chat.interrupt_turn = Some(one("f12"));
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("remapped keymap should parse");
|
||||
assert_eq!(
|
||||
runtime.chat.interrupt_turn,
|
||||
vec![key_hint::plain(KeyCode::F(12))]
|
||||
);
|
||||
|
||||
keymap.chat.interrupt_turn = Some(KeybindingsSpec::Many(vec![]));
|
||||
let runtime = RuntimeKeymap::from_config(&keymap).expect("unbound keymap should parse");
|
||||
assert!(runtime.chat.interrupt_turn.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_turn_rejects_other_fixed_shortcuts() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.chat.interrupt_turn = Some(one("ctrl-v"));
|
||||
|
||||
expect_conflict(&keymap, "chat.interrupt_turn", "fixed.paste_image");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interrupt_turn_rejects_request_user_input_question_navigation_bindings() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
keymap.chat.interrupt_turn = Some(one("f12"));
|
||||
keymap.list.move_right = Some(one("f12"));
|
||||
|
||||
expect_conflict(&keymap, "chat.interrupt_turn", "list.move_right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
||||
let mut keymap = TuiKeymap::default();
|
||||
|
||||
@@ -1009,6 +1009,7 @@ mod tests {
|
||||
actions,
|
||||
vec![
|
||||
"Composer.submit",
|
||||
"Chat.interrupt_turn",
|
||||
"Editor.insert_newline",
|
||||
"Composer.queue",
|
||||
"Global.open_external_editor",
|
||||
|
||||
@@ -93,6 +93,7 @@ pub(super) const KEYMAP_ACTIONS: &[KeymapActionDescriptor] = &[
|
||||
action("global", "Global", "toggle_vim_mode", "Turn Vim composer mode on or off."),
|
||||
gated_action("global", "Global", "toggle_fast_mode", "Turn Fast mode on or off.", KeymapActionFeature::FastMode),
|
||||
action("global", "Global", "toggle_raw_output", "Toggle raw scrollback mode."),
|
||||
action("chat", "Chat", "interrupt_turn", "Interrupt the active turn."),
|
||||
action("chat", "Chat", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
||||
@@ -234,6 +235,7 @@ pub(super) fn binding_slot<'a>(
|
||||
("global", "toggle_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
|
||||
("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode),
|
||||
("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output),
|
||||
("chat", "interrupt_turn") => Some(&mut keymap.chat.interrupt_turn),
|
||||
("chat", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
||||
@@ -357,6 +359,7 @@ pub(super) fn bindings_for_action<'a>(
|
||||
("global", "toggle_vim_mode") => Some(runtime_keymap.app.toggle_vim_mode.as_slice()),
|
||||
("global", "toggle_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()),
|
||||
("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.as_slice()),
|
||||
("chat", "interrupt_turn") => Some(runtime_keymap.chat.interrupt_turn.as_slice()),
|
||||
("chat", "decrease_reasoning_effort") => Some(runtime_keymap.chat.decrease_reasoning_effort.as_slice()),
|
||||
("chat", "increase_reasoning_effort") => Some(runtime_keymap.chat.increase_reasoning_effort.as_slice()),
|
||||
("chat", "edit_queued_message") => Some(runtime_keymap.chat.edit_queued_message.as_slice()),
|
||||
|
||||
@@ -60,6 +60,7 @@ struct KeymapContextTab {
|
||||
|
||||
const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
|
||||
("composer", "submit"),
|
||||
("chat", "interrupt_turn"),
|
||||
("editor", "insert_newline"),
|
||||
("composer", "queue"),
|
||||
("global", "toggle_fast_mode"),
|
||||
|
||||
@@ -8,9 +8,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard.
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
106 actions, 1 customized, 2 unbound.
|
||||
107 actions, 1 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (1) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -16,7 +16,7 @@ expression: "render_picker(params, 120)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
107 actions, 0 customized, 3 unbound.
|
||||
108 actions, 0 customized, 3 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (3) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -17,6 +17,6 @@ expression: "render_picker(params, 120)"
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global - Toggle Fast Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Interrupt Turn esc
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
source: tui/src/keymap_setup.rs
|
||||
expression: snapshot
|
||||
---
|
||||
tab: All (106 selectable)
|
||||
tab: Common (19 selectable)
|
||||
tab: All (107 selectable)
|
||||
tab: Common (20 selectable)
|
||||
tab: Customized (0) (0 selectable)
|
||||
tab: Unbound (2) (2 selectable)
|
||||
tab: App (9 selectable)
|
||||
tab: App (10 selectable)
|
||||
tab: Composer (5 selectable)
|
||||
tab: Editor (17 selectable)
|
||||
tab: Vim (47 selectable)
|
||||
@@ -19,9 +19,9 @@ Copy | ctrl-o | Global copy Copy Copy the last agent response to the clipboard.
|
||||
Clear Terminal | ctrl-l | Global clear_terminal Clear Terminal Clear the terminal UI. ctrl-l Default
|
||||
Toggle Vim Mode | unbound | Global toggle_vim_mode Toggle Vim Mode Turn Vim composer mode on or off. unbound Default
|
||||
Toggle Raw Output | alt-r | Global toggle_raw_output Toggle Raw Output Toggle raw scrollback mode. alt-r Default
|
||||
Interrupt Turn | esc | Chat interrupt_turn Interrupt Turn Interrupt the active turn. esc Default
|
||||
Decrease Reasoning Effort | alt-, | Chat decrease_reasoning_effort Decrease Reasoning Effort Decrease reasoning effort. alt-, Default
|
||||
Increase Reasoning Effort | alt-. | Chat increase_reasoning_effort Increase Reasoning Effort Increase reasoning effort. alt-. Default
|
||||
Edit Queued Message | alt-up, shift-left | Chat edit_queued_message Edit Queued Message Edit the most recently queued message. alt-up, shift-left Default
|
||||
Submit | enter | Composer submit Submit Submit the current composer draft. enter Default
|
||||
Queue | tab | Composer queue Queue Queue the draft while a task is running. tab Default
|
||||
Toggle Shortcuts | ?, shift-? | Composer toggle_shortcuts Toggle Shortcuts Show or hide the composer shortcut overlay. ?, shift-? Default
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
106 actions, 0 customized, 2 unbound.
|
||||
107 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||
Navigation Approval Debug
|
||||
@@ -17,7 +17,7 @@ expression: "render_picker(params, 78)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
||||
|
||||
Keymap
|
||||
All configurable shortcuts.
|
||||
106 actions, 0 customized, 2 unbound.
|
||||
107 actions, 0 customized, 2 unbound.
|
||||
|
||||
[All] Common Customized (0) Unbound (2) App Composer Editor Vim Navigation Approval Debug
|
||||
|
||||
@@ -16,7 +16,7 @@ expression: "render_picker(params, 120)"
|
||||
Global Clear Terminal ctrl-l
|
||||
Global - Toggle Vim Mode unbound
|
||||
Global Toggle Raw Output alt-r
|
||||
Chat Interrupt Turn esc
|
||||
Chat Decrease Reasoning Effort alt-,
|
||||
Chat Increase Reasoning Effort alt-.
|
||||
|
||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
source: tui/src/status_indicator_widget.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"Working (0s • f12 to interrupt) "
|
||||
@@ -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