Skip to content

Commit 01f8853

Browse files
committed
tui: double-press Ctrl+C/Ctrl+D to quit
Pressing Ctrl+C or Ctrl+D now shows a brief "ctrl + <key> again to quit" hint instead of exiting immediately; pressing the same key again within 1s quits without showing the confirmation prompt. Applies to both tui and tui2; docs/tests updated.
1 parent e138073 commit 01f8853

16 files changed

+397
-168
lines changed

codex-rs/tui/src/bottom_pane/chat_composer.rs

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
use crate::key_hint;
2+
use crate::key_hint::KeyBinding;
13
use crate::key_hint::has_ctrl_or_alt;
24
use crossterm::event::KeyCode;
35
use crossterm::event::KeyEvent;
@@ -107,7 +109,8 @@ pub(crate) struct ChatComposer {
107109
active_popup: ActivePopup,
108110
app_event_tx: AppEventSender,
109111
history: ChatComposerHistory,
110-
ctrl_c_quit_hint: bool,
112+
quit_shortcut_expires_at: Option<Instant>,
113+
quit_shortcut_key: KeyBinding,
111114
esc_backtrack_hint: bool,
112115
use_shift_enter_hint: bool,
113116
dismissed_file_popup_token: Option<String>,
@@ -160,7 +163,8 @@ impl ChatComposer {
160163
active_popup: ActivePopup::None,
161164
app_event_tx,
162165
history: ChatComposerHistory::new(),
163-
ctrl_c_quit_hint: false,
166+
quit_shortcut_expires_at: None,
167+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
164168
esc_backtrack_hint: false,
165169
use_shift_enter_hint,
166170
dismissed_file_popup_token: None,
@@ -457,16 +461,26 @@ impl ChatComposer {
457461
}
458462
}
459463

460-
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
461-
self.ctrl_c_quit_hint = show;
462-
if show {
463-
self.footer_mode = FooterMode::CtrlCReminder;
464-
} else {
465-
self.footer_mode = reset_mode_after_activity(self.footer_mode);
466-
}
464+
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
465+
self.quit_shortcut_expires_at = Instant::now()
466+
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
467+
.or_else(|| Some(Instant::now()));
468+
self.quit_shortcut_key = key;
469+
self.footer_mode = FooterMode::CtrlCReminder;
467470
self.set_has_focus(has_focus);
468471
}
469472

473+
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
474+
self.quit_shortcut_expires_at = None;
475+
self.footer_mode = reset_mode_after_activity(self.footer_mode);
476+
self.set_has_focus(has_focus);
477+
}
478+
479+
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
480+
self.quit_shortcut_expires_at
481+
.is_some_and(|expires_at| Instant::now() < expires_at)
482+
}
483+
470484
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
471485
let base = format!("[Pasted Content {char_count} chars]");
472486
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
@@ -1687,7 +1701,7 @@ impl ChatComposer {
16871701
return false;
16881702
}
16891703

1690-
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
1704+
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
16911705
let changed = next != self.footer_mode;
16921706
self.footer_mode = next;
16931707
changed
@@ -1698,7 +1712,7 @@ impl ChatComposer {
16981712
mode: self.footer_mode(),
16991713
esc_backtrack_hint: self.esc_backtrack_hint,
17001714
use_shift_enter_hint: self.use_shift_enter_hint,
1701-
is_task_running: self.is_task_running,
1715+
quit_shortcut_key: self.quit_shortcut_key,
17021716
context_window_percent: self.context_window_percent,
17031717
context_window_used_tokens: self.context_window_used_tokens,
17041718
}
@@ -1708,8 +1722,13 @@ impl ChatComposer {
17081722
match self.footer_mode {
17091723
FooterMode::EscHint => FooterMode::EscHint,
17101724
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
1711-
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
1712-
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
1725+
FooterMode::CtrlCReminder if self.quit_shortcut_hint_visible() => {
1726+
FooterMode::CtrlCReminder
1727+
}
1728+
FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
1729+
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
1730+
FooterMode::CtrlCReminder
1731+
}
17131732
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
17141733
other => other,
17151734
}
@@ -2250,16 +2269,16 @@ mod tests {
22502269
});
22512270

22522271
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
2253-
composer.set_ctrl_c_quit_hint(true, true);
2272+
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
22542273
});
22552274

22562275
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
22572276
composer.set_task_running(true);
2258-
composer.set_ctrl_c_quit_hint(true, true);
2277+
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
22592278
});
22602279

22612280
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
2262-
composer.set_ctrl_c_quit_hint(true, true);
2281+
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
22632282
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
22642283
});
22652284

codex-rs/tui/src/bottom_pane/footer.rs

Lines changed: 12 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ pub(crate) struct FooterProps {
1919
pub(crate) mode: FooterMode,
2020
pub(crate) esc_backtrack_hint: bool,
2121
pub(crate) use_shift_enter_hint: bool,
22-
pub(crate) is_task_running: bool,
22+
pub(crate) quit_shortcut_key: KeyBinding,
2323
pub(crate) context_window_percent: Option<i64>,
2424
pub(crate) context_window_used_tokens: Option<i64>,
2525
}
@@ -81,9 +81,7 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
8181
// the shortcut hint is hidden). Hide it only for the multi-line
8282
// ShortcutOverlay.
8383
match props.mode {
84-
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
85-
is_task_running: props.is_task_running,
86-
})],
84+
FooterMode::CtrlCReminder => vec![quit_shortcut_reminder_line(props.quit_shortcut_key)],
8785
FooterMode::ShortcutSummary => {
8886
let mut line = context_window_line(
8987
props.context_window_percent,
@@ -117,29 +115,15 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
117115
}
118116
}
119117

120-
#[derive(Clone, Copy, Debug)]
121-
struct CtrlCReminderState {
122-
is_task_running: bool,
123-
}
124-
125118
#[derive(Clone, Copy, Debug)]
126119
struct ShortcutsState {
127120
use_shift_enter_hint: bool,
128121
esc_backtrack_hint: bool,
129122
is_wsl: bool,
130123
}
131124

132-
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
133-
let action = if state.is_task_running {
134-
"interrupt"
135-
} else {
136-
"quit"
137-
};
138-
Line::from(vec![
139-
key_hint::ctrl(KeyCode::Char('c')).into(),
140-
format!(" again to {action}").into(),
141-
])
142-
.dim()
125+
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
126+
Line::from(vec![key.into(), " again to quit".into()]).dim()
143127
}
144128

145129
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
@@ -463,7 +447,7 @@ mod tests {
463447
mode: FooterMode::ShortcutSummary,
464448
esc_backtrack_hint: false,
465449
use_shift_enter_hint: false,
466-
is_task_running: false,
450+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
467451
context_window_percent: None,
468452
context_window_used_tokens: None,
469453
},
@@ -475,7 +459,7 @@ mod tests {
475459
mode: FooterMode::ShortcutOverlay,
476460
esc_backtrack_hint: true,
477461
use_shift_enter_hint: true,
478-
is_task_running: false,
462+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
479463
context_window_percent: None,
480464
context_window_used_tokens: None,
481465
},
@@ -487,7 +471,7 @@ mod tests {
487471
mode: FooterMode::CtrlCReminder,
488472
esc_backtrack_hint: false,
489473
use_shift_enter_hint: false,
490-
is_task_running: false,
474+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
491475
context_window_percent: None,
492476
context_window_used_tokens: None,
493477
},
@@ -499,7 +483,7 @@ mod tests {
499483
mode: FooterMode::CtrlCReminder,
500484
esc_backtrack_hint: false,
501485
use_shift_enter_hint: false,
502-
is_task_running: true,
486+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
503487
context_window_percent: None,
504488
context_window_used_tokens: None,
505489
},
@@ -511,7 +495,7 @@ mod tests {
511495
mode: FooterMode::EscHint,
512496
esc_backtrack_hint: false,
513497
use_shift_enter_hint: false,
514-
is_task_running: false,
498+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
515499
context_window_percent: None,
516500
context_window_used_tokens: None,
517501
},
@@ -523,7 +507,7 @@ mod tests {
523507
mode: FooterMode::EscHint,
524508
esc_backtrack_hint: true,
525509
use_shift_enter_hint: false,
526-
is_task_running: false,
510+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
527511
context_window_percent: None,
528512
context_window_used_tokens: None,
529513
},
@@ -535,7 +519,7 @@ mod tests {
535519
mode: FooterMode::ShortcutSummary,
536520
esc_backtrack_hint: false,
537521
use_shift_enter_hint: false,
538-
is_task_running: true,
522+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
539523
context_window_percent: Some(72),
540524
context_window_used_tokens: None,
541525
},
@@ -547,7 +531,7 @@ mod tests {
547531
mode: FooterMode::ShortcutSummary,
548532
esc_backtrack_hint: false,
549533
use_shift_enter_hint: false,
550-
is_task_running: false,
534+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
551535
context_window_percent: None,
552536
context_window_used_tokens: Some(123_456),
553537
},

codex-rs/tui/src/bottom_pane/mod.rs

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use std::path::PathBuf;
44
use crate::app_event_sender::AppEventSender;
55
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
66
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
7+
use crate::key_hint;
8+
use crate::key_hint::KeyBinding;
79
use crate::render::renderable::FlexRenderable;
810
use crate::render::renderable::Renderable;
911
use crate::render::renderable::RenderableItem;
@@ -46,6 +48,8 @@ mod textarea;
4648
mod unified_exec_footer;
4749
pub(crate) use feedback_view::FeedbackNoteView;
4850

51+
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
52+
4953
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5054
pub(crate) enum CancellationEvent {
5155
Handled,
@@ -76,7 +80,6 @@ pub(crate) struct BottomPane {
7680

7781
has_input_focus: bool,
7882
is_task_running: bool,
79-
ctrl_c_quit_hint: bool,
8083
esc_backtrack_hint: bool,
8184
animations_enabled: bool,
8285

@@ -129,7 +132,6 @@ impl BottomPane {
129132
frame_requester,
130133
has_input_focus,
131134
is_task_running: false,
132-
ctrl_c_quit_hint: false,
133135
status: None,
134136
unified_exec_footer: UnifiedExecFooter::new(),
135137
queued_user_messages: QueuedUserMessages::new(),
@@ -224,15 +226,15 @@ impl BottomPane {
224226
self.view_stack.pop();
225227
self.on_active_view_complete();
226228
}
227-
self.show_ctrl_c_quit_hint();
229+
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
228230
}
229231
event
230232
} else if self.composer_is_empty() {
231233
CancellationEvent::NotHandled
232234
} else {
233235
self.view_stack.pop();
234236
self.clear_composer_for_ctrl_c();
235-
self.show_ctrl_c_quit_hint();
237+
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
236238
CancellationEvent::Handled
237239
}
238240
}
@@ -310,25 +312,32 @@ impl BottomPane {
310312
}
311313
}
312314

313-
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
314-
self.ctrl_c_quit_hint = true;
315+
pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) {
315316
self.composer
316-
.set_ctrl_c_quit_hint(true, self.has_input_focus);
317+
.show_quit_shortcut_hint(key, self.has_input_focus);
318+
let frame_requester = self.frame_requester.clone();
319+
if let Ok(handle) = tokio::runtime::Handle::try_current() {
320+
handle.spawn(async move {
321+
tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await;
322+
frame_requester.schedule_frame();
323+
});
324+
} else {
325+
std::thread::spawn(move || {
326+
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
327+
frame_requester.schedule_frame();
328+
});
329+
}
317330
self.request_redraw();
318331
}
319332

320-
pub(crate) fn clear_ctrl_c_quit_hint(&mut self) {
321-
if self.ctrl_c_quit_hint {
322-
self.ctrl_c_quit_hint = false;
323-
self.composer
324-
.set_ctrl_c_quit_hint(false, self.has_input_focus);
325-
self.request_redraw();
326-
}
333+
pub(crate) fn clear_quit_shortcut_hint(&mut self) {
334+
self.composer.clear_quit_shortcut_hint(self.has_input_focus);
335+
self.request_redraw();
327336
}
328337

329338
#[cfg(test)]
330-
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
331-
self.ctrl_c_quit_hint
339+
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
340+
self.composer.quit_shortcut_hint_visible()
332341
}
333342

334343
#[cfg(test)]
@@ -651,7 +660,7 @@ mod tests {
651660
});
652661
pane.push_approval_request(exec_request(), &features);
653662
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
654-
assert!(pane.ctrl_c_quit_hint_visible());
663+
assert!(pane.quit_shortcut_hint_visible());
655664
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
656665
}
657666

codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__footer_mode_ctrl_c_interrupt.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,4 @@ expression: terminal.backend()
1010
" "
1111
" "
1212
" "
13-
" ctrl + c again to interrupt "
13+
" ctrl + c again to quit "

codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__footer__tests__footer_ctrl_c_quit_running.snap

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
source: tui/src/bottom_pane/footer.rs
33
expression: terminal.backend()
44
---
5-
" ctrl + c again to interrupt "
5+
" ctrl + c again to quit "

0 commit comments

Comments
 (0)