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:
Felipe Coury
2026-05-27 15:59:17 -03:00
committed by GitHub
parent 8d398d3c52
commit 2d1ad374a7
21 changed files with 352 additions and 42 deletions

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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>();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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

View File

@@ -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,
]
}

View File

@@ -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()

View File

@@ -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;

View File

@@ -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();

View File

@@ -1009,6 +1009,7 @@ mod tests {
actions,
vec![
"Composer.submit",
"Chat.interrupt_turn",
"Editor.insert_newline",
"Composer.queue",
"Global.open_external_editor",

View File

@@ -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()),

View File

@@ -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"),

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,5 @@
---
source: tui/src/status_indicator_widget.rs
expression: terminal.backend()
---
"Working (0s • f12 to interrupt) "

View File

@@ -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>();