Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions codex-rs/config/src/tui_keymap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions codex-rs/core/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -3094,6 +3095,14 @@
}
],
"description": "Increase the active reasoning effort."
},
"interrupt_turn": {
"allOf": [
{
"$ref": "#/definitions/KeybindingsSpec"
}
],
"description": "Interrupt the active turn."
}
},
"type": "object"
Expand Down Expand Up @@ -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": {
Expand Down
45 changes: 40 additions & 5 deletions codex-rs/tui/src/bottom_pane/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -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>();
Expand Down
39 changes: 30 additions & 9 deletions codex-rs/tui/src/bottom_pane/pending_input_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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)),
}
}

Expand All @@ -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>>,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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();
Expand Down
78 changes: 71 additions & 7 deletions codex-rs/tui/src/bottom_pane/request_user_input/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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,
]
}
3 changes: 1 addition & 2 deletions codex-rs/tui/src/chatwidget/interaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading