mirror of
https://github.com/openai/codex.git
synced 2026-11-05 08:28:29 +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:
1 parent
8d398d3c52
commit
2d1ad374a7
21 files changed
+352
-42
No files matched your search
@@ -115,6 +115,8 @@ pub struct TuiGlobalKeymap {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
#[schemars(deny_unknown_fields)]
|
#[schemars(deny_unknown_fields)]
|
||||||
pub struct TuiChatKeymap {
|
pub struct TuiChatKeymap {
|
||||||
|
/// Interrupt the active turn.
|
||||||
|
pub interrupt_turn: Option<KeybindingsSpec>,
|
||||||
/// Decrease the active reasoning effort.
|
/// Decrease the active reasoning effort.
|
||||||
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
|
pub decrease_reasoning_effort: Option<KeybindingsSpec>,
|
||||||
/// Increase the active reasoning effort.
|
/// Increase the active reasoning effort.
|
||||||
|
|||||||
@@ -2766,7 +2766,8 @@
|
|||||||
"chat": {
|
"chat": {
|
||||||
"decrease_reasoning_effort": null,
|
"decrease_reasoning_effort": null,
|
||||||
"edit_queued_message": null,
|
"edit_queued_message": null,
|
||||||
"increase_reasoning_effort": null
|
"increase_reasoning_effort": null,
|
||||||
|
"interrupt_turn": null
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
"history_search_next": null,
|
"history_search_next": null,
|
||||||
@@ -3094,6 +3095,14 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Increase the active reasoning effort."
|
"description": "Increase the active reasoning effort."
|
||||||
|
},
|
||||||
|
"interrupt_turn": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/KeybindingsSpec"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Interrupt the active turn."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -3405,7 +3414,8 @@
|
|||||||
"default": {
|
"default": {
|
||||||
"decrease_reasoning_effort": null,
|
"decrease_reasoning_effort": null,
|
||||||
"edit_queued_message": null,
|
"edit_queued_message": null,
|
||||||
"increase_reasoning_effort": null
|
"increase_reasoning_effort": null,
|
||||||
|
"interrupt_turn": null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"composer": {
|
"composer": {
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ use crate::bottom_pane::pending_thread_approvals::PendingThreadApprovals;
|
|||||||
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
||||||
use crate::key_hint;
|
use crate::key_hint;
|
||||||
use crate::key_hint::KeyBinding;
|
use crate::key_hint::KeyBinding;
|
||||||
|
use crate::key_hint::KeyBindingListExt;
|
||||||
use crate::keymap::RuntimeKeymap;
|
use crate::keymap::RuntimeKeymap;
|
||||||
|
use crate::keymap::primary_binding;
|
||||||
use crate::render::renderable::FlexRenderable;
|
use crate::render::renderable::FlexRenderable;
|
||||||
use crate::render::renderable::Renderable;
|
use crate::render::renderable::Renderable;
|
||||||
use crate::render::renderable::RenderableItem;
|
use crate::render::renderable::RenderableItem;
|
||||||
@@ -352,6 +354,12 @@ impl BottomPane {
|
|||||||
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
pub fn set_keymap_bindings(&mut self, keymap: &RuntimeKeymap) {
|
||||||
self.keymap = keymap.clone();
|
self.keymap = keymap.clone();
|
||||||
self.composer.set_keymap_bindings(keymap);
|
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();
|
self.request_redraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,13 +625,12 @@ impl BottomPane {
|
|||||||
.and_then(parse_slash_name)
|
.and_then(parse_slash_name)
|
||||||
.is_some_and(|(name, _, _)| name == "agent");
|
.is_some_and(|(name, _, _)| name == "agent");
|
||||||
|
|
||||||
// If a task is running and a status line is visible, allow Esc to
|
// If a task is running and a status line is visible, allow the
|
||||||
// send an interrupt even while the composer has focus.
|
// configured action to interrupt even while the composer has focus.
|
||||||
// When a popup is active, prefer dismissing it over interrupting the task.
|
// When a popup is active, prefer dismissing it over interrupting the task.
|
||||||
if key_event.code == KeyCode::Esc
|
if self.keymap.chat.interrupt_turn.is_pressed(key_event)
|
||||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
|
||||||
&& self.is_task_running
|
&& self.is_task_running
|
||||||
&& !is_agent_command
|
&& !(is_agent_command && key_event.code == KeyCode::Esc)
|
||||||
&& !self.composer.popup_active()
|
&& !self.composer.popup_active()
|
||||||
&& !self.composer_should_handle_vim_insert_escape(key_event)
|
&& !self.composer_should_handle_vim_insert_escape(key_event)
|
||||||
&& let Some(status) = &self.status
|
&& let Some(status) = &self.status
|
||||||
@@ -989,6 +996,7 @@ impl BottomPane {
|
|||||||
}
|
}
|
||||||
if let Some(status) = self.status.as_mut() {
|
if let Some(status) = self.status.as_mut() {
|
||||||
status.set_interrupt_hint_visible(/*visible*/ true);
|
status.set_interrupt_hint_visible(/*visible*/ true);
|
||||||
|
status.set_interrupt_binding(primary_binding(&self.keymap.chat.interrupt_turn));
|
||||||
}
|
}
|
||||||
self.sync_status_inline_message();
|
self.sync_status_inline_message();
|
||||||
self.request_redraw();
|
self.request_redraw();
|
||||||
@@ -1017,6 +1025,9 @@ impl BottomPane {
|
|||||||
self.frame_requester.clone(),
|
self.frame_requester.clone(),
|
||||||
self.animations_enabled,
|
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.sync_status_inline_message();
|
||||||
self.request_redraw();
|
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]
|
#[test]
|
||||||
fn selection_view_esc_respects_remapped_list_cancel() {
|
fn selection_view_esc_respects_remapped_list_cancel() {
|
||||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
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
|
/// The widget renders pending steers first, then rejected steers that will be
|
||||||
/// resubmitted at end of turn, then ordinary queued user messages. Pending
|
/// resubmitted at end of turn, then ordinary queued user messages. Pending
|
||||||
/// steers explain that they will be submitted after the next tool/result
|
/// 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
|
/// 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
|
/// queued user inputs to pop back into the composer. Because some terminals
|
||||||
/// intercept certain modifier-key combinations, the displayed binding is
|
/// 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
|
/// Key combination rendered in the hint line. Defaults to Alt+Up but may
|
||||||
/// be overridden for terminals where that chord is unavailable.
|
/// be overridden for terminals where that chord is unavailable.
|
||||||
edit_binding: Option<key_hint::KeyBinding>,
|
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;
|
const PREVIEW_LINE_LIMIT: usize = 3;
|
||||||
@@ -38,6 +40,7 @@ impl PendingInputPreview {
|
|||||||
rejected_steers: Vec::new(),
|
rejected_steers: Vec::new(),
|
||||||
queued_messages: Vec::new(),
|
queued_messages: Vec::new(),
|
||||||
edit_binding: Some(key_hint::alt(KeyCode::Up)),
|
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;
|
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(
|
fn push_truncated_preview_lines(
|
||||||
lines: &mut Vec<Line<'static>>,
|
lines: &mut Vec<Line<'static>>,
|
||||||
wrapped: Vec<Line<'static>>,
|
wrapped: Vec<Line<'static>>,
|
||||||
@@ -81,16 +88,15 @@ impl PendingInputPreview {
|
|||||||
let mut lines = vec![];
|
let mut lines = vec![];
|
||||||
|
|
||||||
if !self.pending_steers.is_empty() {
|
if !self.pending_steers.is_empty() {
|
||||||
Self::push_section_header(
|
let mut header = vec!["Messages to be submitted after next tool call".into()];
|
||||||
&mut lines,
|
if let Some(interrupt_binding) = self.interrupt_binding {
|
||||||
width,
|
header.extend(vec![
|
||||||
Line::from(vec![
|
|
||||||
"Messages to be submitted after next tool call".into(),
|
|
||||||
" (press ".dim(),
|
" (press ".dim(),
|
||||||
key_hint::plain(KeyCode::Esc).into(),
|
interrupt_binding.into(),
|
||||||
" to interrupt and send immediately)".dim(),
|
" to interrupt and send immediately)".dim(),
|
||||||
]),
|
]);
|
||||||
);
|
}
|
||||||
|
Self::push_section_header(&mut lines, width, Line::from(header));
|
||||||
|
|
||||||
for steer in &self.pending_steers {
|
for steer in &self.pending_steers {
|
||||||
let wrapped = adaptive_wrap_lines(
|
let wrapped = adaptive_wrap_lines(
|
||||||
@@ -327,6 +333,21 @@ mod tests {
|
|||||||
assert_snapshot!("render_one_pending_steer", format!("{buf:?}"));
|
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]
|
#[test]
|
||||||
fn render_pending_steers_above_queued_messages() {
|
fn render_pending_steers_above_queued_messages() {
|
||||||
let mut queue = PendingInputPreview::new();
|
let mut queue = PendingInputPreview::new();
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ pub(crate) struct RequestUserInputOverlay {
|
|||||||
pending_submission_draft: Option<ComposerDraft>,
|
pending_submission_draft: Option<ComposerDraft>,
|
||||||
confirm_unanswered: Option<ScrollState>,
|
confirm_unanswered: Option<ScrollState>,
|
||||||
composer_submit_keys: Vec<KeyBinding>,
|
composer_submit_keys: Vec<KeyBinding>,
|
||||||
|
interrupt_turn_keys: Vec<KeyBinding>,
|
||||||
list_keymap: ListKeymap,
|
list_keymap: ListKeymap,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,6 +199,7 @@ impl RequestUserInputOverlay {
|
|||||||
pending_submission_draft: None,
|
pending_submission_draft: None,
|
||||||
confirm_unanswered: None,
|
confirm_unanswered: None,
|
||||||
composer_submit_keys: keymap.composer.submit.clone(),
|
composer_submit_keys: keymap.composer.submit.clone(),
|
||||||
|
interrupt_turn_keys: keymap.chat.interrupt_turn.clone(),
|
||||||
list_keymap: keymap.list,
|
list_keymap: keymap.list,
|
||||||
};
|
};
|
||||||
overlay.reset_for_request();
|
overlay.reset_for_request();
|
||||||
@@ -506,8 +508,15 @@ impl RequestUserInputOverlay {
|
|||||||
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
|
tips.push(FooterTip::new("ctrl + p / ctrl + n change question"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !(self.has_options() && notes_visible) {
|
if let Some(interrupt_key) = self.interrupt_turn_keys.first()
|
||||||
tips.push(FooterTip::new("esc to interrupt"));
|
&& !(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
|
tips
|
||||||
}
|
}
|
||||||
@@ -1056,11 +1065,12 @@ impl BottomPaneView for RequestUserInputOverlay {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(key_event.code, KeyCode::Esc) {
|
if matches!(key_event.code, KeyCode::Esc) && self.has_options() && self.notes_ui_visible() {
|
||||||
if self.has_options() && self.notes_ui_visible() {
|
self.clear_notes_and_focus_options();
|
||||||
self.clear_notes_and_focus_options();
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
if self.interrupt_turn_keys.is_pressed(key_event) {
|
||||||
// TODO: Emit interrupted request_user_input results (including committed answers)
|
// TODO: Emit interrupted request_user_input results (including committed answers)
|
||||||
// once core supports persisting them reliably without follow-up turn issues.
|
// once core supports persisting them reliably without follow-up turn issues.
|
||||||
self.app_event_tx.interrupt();
|
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]
|
#[test]
|
||||||
fn tab_opens_notes_when_option_selected() {
|
fn tab_opens_notes_when_option_selected() {
|
||||||
let (tx, _rx) = test_sender();
|
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]
|
#[test]
|
||||||
fn request_user_input_multi_question_first_snapshot() {
|
fn request_user_input_multi_question_first_snapshot() {
|
||||||
let (tx, _rx) = test_sender();
|
let (tx, _rx) = test_sender();
|
||||||
|
|||||||
+13
@@ -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
|
||||||
+20
@@ -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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if matches!(key_event.code, KeyCode::Esc)
|
if self.chat_keymap.interrupt_turn.is_pressed(key_event)
|
||||||
&& matches!(key_event.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
|
||||||
&& !self.input_queue.pending_steers.is_empty()
|
&& !self.input_queue.pending_steers.is_empty()
|
||||||
&& self.bottom_pane.is_task_running()
|
&& self.bottom_pane.is_task_running()
|
||||||
&& self.bottom_pane.no_modal_or_popup_active()
|
&& 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);
|
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]
|
#[tokio::test]
|
||||||
async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
async fn restore_thread_input_state_syncs_sleep_inhibitor_state() {
|
||||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
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.
|
/// handler code, not here.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) struct ChatKeymap {
|
pub(crate) struct ChatKeymap {
|
||||||
|
/// Interrupt the active turn.
|
||||||
|
pub(crate) interrupt_turn: Vec<KeyBinding>,
|
||||||
/// Decrease the active reasoning effort.
|
/// Decrease the active reasoning effort.
|
||||||
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
|
pub(crate) decrease_reasoning_effort: Vec<KeyBinding>,
|
||||||
/// Increase the active reasoning effort.
|
/// Increase the active reasoning effort.
|
||||||
@@ -420,6 +422,11 @@ impl RuntimeKeymap {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let chat = ChatKeymap {
|
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(
|
decrease_reasoning_effort: resolve_bindings(
|
||||||
keymap.chat.decrease_reasoning_effort.as_ref(),
|
keymap.chat.decrease_reasoning_effort.as_ref(),
|
||||||
&defaults.chat.decrease_reasoning_effort,
|
&defaults.chat.decrease_reasoning_effort,
|
||||||
@@ -878,6 +885,7 @@ impl RuntimeKeymap {
|
|||||||
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
|
toggle_raw_output: default_bindings![alt(KeyCode::Char('r'))],
|
||||||
},
|
},
|
||||||
chat: ChatKeymap {
|
chat: ChatKeymap {
|
||||||
|
interrupt_turn: default_bindings![plain(KeyCode::Esc)],
|
||||||
decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
|
decrease_reasoning_effort: default_bindings![alt(KeyCode::Char(','))],
|
||||||
increase_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)],
|
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_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||||
("toggle_raw_output", self.app.toggle_raw_output.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",
|
"chat.decrease_reasoning_effort",
|
||||||
self.chat.decrease_reasoning_effort.as_slice(),
|
self.chat.decrease_reasoning_effort.as_slice(),
|
||||||
@@ -1169,6 +1178,7 @@ impl RuntimeKeymap {
|
|||||||
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
("toggle_vim_mode", self.app.toggle_vim_mode.as_slice()),
|
||||||
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
("toggle_fast_mode", self.app.toggle_fast_mode.as_slice()),
|
||||||
("toggle_raw_output", self.app.toggle_raw_output.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",
|
"chat.decrease_reasoning_effort",
|
||||||
self.chat.decrease_reasoning_effort.as_slice(),
|
self.chat.decrease_reasoning_effort.as_slice(),
|
||||||
@@ -1197,6 +1207,11 @@ impl RuntimeKeymap {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
MAIN_RESERVED_BINDINGS,
|
MAIN_RESERVED_BINDINGS,
|
||||||
|
[(
|
||||||
|
"chat.interrupt_turn",
|
||||||
|
"fixed.backtrack",
|
||||||
|
key_hint::plain(KeyCode::Esc),
|
||||||
|
)],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
validate_no_shadow_with_allowed_overlaps(
|
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
|
// While the composer is focused, these main-surface handlers always
|
||||||
// consume matching keys before the event reaches the textarea editor.
|
// consume matching keys before the event reaches the textarea editor.
|
||||||
validate_no_shadow_with_allowed_overlaps(
|
validate_no_shadow_with_allowed_overlaps(
|
||||||
@@ -1261,6 +1288,7 @@ impl RuntimeKeymap {
|
|||||||
),
|
),
|
||||||
("copy", self.app.copy.as_slice()),
|
("copy", self.app.copy.as_slice()),
|
||||||
("clear_terminal", self.app.clear_terminal.as_slice()),
|
("clear_terminal", self.app.clear_terminal.as_slice()),
|
||||||
|
("chat.interrupt_turn", self.chat.interrupt_turn.as_slice()),
|
||||||
(
|
(
|
||||||
"chat.decrease_reasoning_effort",
|
"chat.decrease_reasoning_effort",
|
||||||
self.chat.decrease_reasoning_effort.as_slice(),
|
self.chat.decrease_reasoning_effort.as_slice(),
|
||||||
@@ -1522,6 +1550,7 @@ impl RuntimeKeymap {
|
|||||||
("close_transcript", self.pager.close_transcript.as_slice()),
|
("close_transcript", self.pager.close_transcript.as_slice()),
|
||||||
],
|
],
|
||||||
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
|
TRANSCRIPT_BACKTRACK_RESERVED_BINDINGS,
|
||||||
|
[],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
validate_unique(
|
validate_unique(
|
||||||
@@ -1675,10 +1704,11 @@ See the Codex keymap documentation for supported actions and examples."
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_no_reserved<const N: usize>(
|
fn validate_no_reserved<const N: usize, const A: usize>(
|
||||||
context: &str,
|
context: &str,
|
||||||
pairs: [(&'static str, &[KeyBinding]); N],
|
pairs: [(&'static str, &[KeyBinding]); N],
|
||||||
reserved: &[(&'static str, KeyBinding)],
|
reserved: &[(&'static str, KeyBinding)],
|
||||||
|
allowed_overlaps: [(&'static str, &'static str, KeyBinding); A],
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
for (action, bindings) in pairs {
|
for (action, bindings) in pairs {
|
||||||
for binding in bindings {
|
for binding in bindings {
|
||||||
@@ -1687,6 +1717,15 @@ fn validate_no_reserved<const N: usize>(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|(_, reserved_binding)| reserved_binding.parts() == key)
|
.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!(
|
return Err(format!(
|
||||||
"Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \
|
"Ambiguous `tui.keymap.{context}` bindings: `{action}` uses a key reserved by `{reserved_action}`. \
|
||||||
Set a different key in `~/.codex/config.toml` and retry. \
|
Set a different key in `~/.codex/config.toml` and retry. \
|
||||||
@@ -2076,6 +2115,10 @@ mod tests {
|
|||||||
vec![key_hint::ctrl(KeyCode::Char('l'))]
|
vec![key_hint::ctrl(KeyCode::Char('l'))]
|
||||||
);
|
);
|
||||||
assert_eq!(runtime.app.toggle_fast_mode, Vec::new());
|
assert_eq!(runtime.app.toggle_fast_mode, Vec::new());
|
||||||
|
assert_eq!(
|
||||||
|
runtime.chat.interrupt_turn,
|
||||||
|
vec![key_hint::plain(KeyCode::Esc)]
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
runtime.chat.decrease_reasoning_effort,
|
runtime.chat.decrease_reasoning_effort,
|
||||||
vec![key_hint::alt(KeyCode::Char(','))]
|
vec![key_hint::alt(KeyCode::Char(','))]
|
||||||
@@ -2498,6 +2541,44 @@ mod tests {
|
|||||||
expect_conflict(&keymap, "composer.submit", "fixed.paste_image");
|
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]
|
#[test]
|
||||||
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
fn rejects_pager_bindings_that_collide_with_transcript_backtrack_keys() {
|
||||||
let mut keymap = TuiKeymap::default();
|
let mut keymap = TuiKeymap::default();
|
||||||
|
|||||||
@@ -1009,6 +1009,7 @@ mod tests {
|
|||||||
actions,
|
actions,
|
||||||
vec![
|
vec![
|
||||||
"Composer.submit",
|
"Composer.submit",
|
||||||
|
"Chat.interrupt_turn",
|
||||||
"Editor.insert_newline",
|
"Editor.insert_newline",
|
||||||
"Composer.queue",
|
"Composer.queue",
|
||||||
"Global.open_external_editor",
|
"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."),
|
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),
|
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("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", "decrease_reasoning_effort", "Decrease reasoning effort."),
|
||||||
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
action("chat", "Chat", "increase_reasoning_effort", "Increase reasoning effort."),
|
||||||
action("chat", "Chat", "edit_queued_message", "Edit the most recently queued message."),
|
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_vim_mode") => Some(&mut keymap.global.toggle_vim_mode),
|
||||||
("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode),
|
("global", "toggle_fast_mode") => Some(&mut keymap.global.toggle_fast_mode),
|
||||||
("global", "toggle_raw_output") => Some(&mut keymap.global.toggle_raw_output),
|
("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", "decrease_reasoning_effort") => Some(&mut keymap.chat.decrease_reasoning_effort),
|
||||||
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
("chat", "increase_reasoning_effort") => Some(&mut keymap.chat.increase_reasoning_effort),
|
||||||
("chat", "edit_queued_message") => Some(&mut keymap.chat.edit_queued_message),
|
("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_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_fast_mode") => Some(runtime_keymap.app.toggle_fast_mode.as_slice()),
|
||||||
("global", "toggle_raw_output") => Some(runtime_keymap.app.toggle_raw_output.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", "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", "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()),
|
("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)] = &[
|
const KEYMAP_COMMON_ACTIONS: &[(&str, &str)] = &[
|
||||||
("composer", "submit"),
|
("composer", "submit"),
|
||||||
|
("chat", "interrupt_turn"),
|
||||||
("editor", "insert_newline"),
|
("editor", "insert_newline"),
|
||||||
("composer", "queue"),
|
("composer", "queue"),
|
||||||
("global", "toggle_fast_mode"),
|
("global", "toggle_fast_mode"),
|
||||||
|
|||||||
+1
-1
@@ -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
|
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 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
|
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
|
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
|
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
|
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
|
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
|
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
|
|
||||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
[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 Clear Terminal ctrl-l
|
||||||
Global - Toggle Vim Mode unbound
|
Global - Toggle Vim Mode unbound
|
||||||
Global Toggle Raw Output alt-r
|
Global Toggle Raw Output alt-r
|
||||||
|
Chat Interrupt Turn esc
|
||||||
Chat Decrease Reasoning Effort alt-,
|
Chat Decrease Reasoning Effort alt-,
|
||||||
Chat Increase Reasoning Effort alt-.
|
|
||||||
|
|
||||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
[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 Vim Mode unbound
|
||||||
Global - Toggle Fast Mode unbound
|
Global - Toggle Fast Mode unbound
|
||||||
Global Toggle Raw Output alt-r
|
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
|
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||||
+4
-4
@@ -2,11 +2,11 @@
|
|||||||
source: tui/src/keymap_setup.rs
|
source: tui/src/keymap_setup.rs
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
tab: All (106 selectable)
|
tab: All (107 selectable)
|
||||||
tab: Common (19 selectable)
|
tab: Common (20 selectable)
|
||||||
tab: Customized (0) (0 selectable)
|
tab: Customized (0) (0 selectable)
|
||||||
tab: Unbound (2) (2 selectable)
|
tab: Unbound (2) (2 selectable)
|
||||||
tab: App (9 selectable)
|
tab: App (10 selectable)
|
||||||
tab: Composer (5 selectable)
|
tab: Composer (5 selectable)
|
||||||
tab: Editor (17 selectable)
|
tab: Editor (17 selectable)
|
||||||
tab: Vim (47 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
|
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 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
|
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
|
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
|
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
|
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
|
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
|
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
|
|
||||||
+2
-2
@@ -5,7 +5,7 @@ expression: "render_picker(params, 78)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
[All] Common Customized (0) Unbound (2) App Composer Editor Vim
|
||||||
Navigation Approval Debug
|
Navigation Approval Debug
|
||||||
@@ -17,7 +17,7 @@ expression: "render_picker(params, 78)"
|
|||||||
Global Clear Terminal ctrl-l
|
Global Clear Terminal ctrl-l
|
||||||
Global - Toggle Vim Mode unbound
|
Global - Toggle Vim Mode unbound
|
||||||
Global Toggle Raw Output alt-r
|
Global Toggle Raw Output alt-r
|
||||||
|
Chat Interrupt Turn esc
|
||||||
Chat Decrease Reasoning Effort alt-,
|
Chat Decrease Reasoning Effort alt-,
|
||||||
Chat Increase Reasoning Effort alt-.
|
|
||||||
|
|
||||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||||
@@ -5,7 +5,7 @@ expression: "render_picker(params, 120)"
|
|||||||
|
|
||||||
Keymap
|
Keymap
|
||||||
All configurable shortcuts.
|
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
|
[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 Clear Terminal ctrl-l
|
||||||
Global - Toggle Vim Mode unbound
|
Global - Toggle Vim Mode unbound
|
||||||
Global Toggle Raw Output alt-r
|
Global Toggle Raw Output alt-r
|
||||||
|
Chat Interrupt Turn esc
|
||||||
Chat Decrease Reasoning Effort alt-,
|
Chat Decrease Reasoning Effort alt-,
|
||||||
Chat Increase Reasoning Effort alt-.
|
|
||||||
|
|
||||||
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
left/right group · enter edit shortcut · * custom · - unbound · esc close
|
||||||
+5
@@ -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::app_event_sender::AppEventSender;
|
||||||
use crate::key_hint;
|
use crate::key_hint;
|
||||||
|
use crate::key_hint::KeyBinding;
|
||||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||||
use crate::motion::MotionMode;
|
use crate::motion::MotionMode;
|
||||||
use crate::motion::ReducedMotionIndicator;
|
use crate::motion::ReducedMotionIndicator;
|
||||||
@@ -49,6 +50,7 @@ pub(crate) struct StatusIndicatorWidget {
|
|||||||
/// Optional suffix rendered after the elapsed/interrupt segment.
|
/// Optional suffix rendered after the elapsed/interrupt segment.
|
||||||
inline_message: Option<String>,
|
inline_message: Option<String>,
|
||||||
show_interrupt_hint: bool,
|
show_interrupt_hint: bool,
|
||||||
|
interrupt_binding: Option<KeyBinding>,
|
||||||
|
|
||||||
elapsed_running: Duration,
|
elapsed_running: Duration,
|
||||||
last_resume_at: Instant,
|
last_resume_at: Instant,
|
||||||
@@ -87,6 +89,7 @@ impl StatusIndicatorWidget {
|
|||||||
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
|
details_max_lines: STATUS_DETAILS_DEFAULT_MAX_LINES,
|
||||||
inline_message: None,
|
inline_message: None,
|
||||||
show_interrupt_hint: true,
|
show_interrupt_hint: true,
|
||||||
|
interrupt_binding: Some(key_hint::plain(KeyCode::Esc)),
|
||||||
elapsed_running: Duration::ZERO,
|
elapsed_running: Duration::ZERO,
|
||||||
last_resume_at: Instant::now(),
|
last_resume_at: Instant::now(),
|
||||||
is_paused: false,
|
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
|
/// Callers should provide plain, already-contextualized text. Passing
|
||||||
/// verbose status prose here can cause frequent width truncation and hide
|
/// verbose status prose here can cause frequent width truncation and hide
|
||||||
@@ -150,6 +153,10 @@ impl StatusIndicatorWidget {
|
|||||||
self.show_interrupt_hint = visible;
|
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) {
|
pub(crate) fn pause_timer(&mut self) {
|
||||||
self.pause_timer_at(Instant::now());
|
self.pause_timer_at(Instant::now());
|
||||||
}
|
}
|
||||||
@@ -257,10 +264,12 @@ impl Renderable for StatusIndicatorWidget {
|
|||||||
if !spans.is_empty() {
|
if !spans.is_empty() {
|
||||||
spans.push(" ".into());
|
spans.push(" ".into());
|
||||||
}
|
}
|
||||||
if self.show_interrupt_hint {
|
if self.show_interrupt_hint
|
||||||
|
&& let Some(interrupt_binding) = self.interrupt_binding
|
||||||
|
{
|
||||||
spans.extend(vec![
|
spans.extend(vec![
|
||||||
format!("({pretty_elapsed} • ").dim(),
|
format!("({pretty_elapsed} • ").dim(),
|
||||||
key_hint::plain(KeyCode::Esc).into(),
|
interrupt_binding.into(),
|
||||||
" to interrupt)".dim(),
|
" to interrupt)".dim(),
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
@@ -405,6 +414,26 @@ mod tests {
|
|||||||
assert!(line.starts_with("Working (0s • esc to interrupt)"));
|
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]
|
#[test]
|
||||||
fn timer_pauses_when_requested() {
|
fn timer_pauses_when_requested() {
|
||||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||||
|
|||||||
Reference in new issue
Block a user