Skip to content

Commit 28f6f6d

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 28f6f6d

26 files changed

+534
-596
lines changed

.markdownlint-cli2.jsonc

Lines changed: 0 additions & 8 deletions
This file was deleted.

.markdownlint-cli2.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
config:
2+
MD013:
3+
line_length: 100

codex-rs/core/src/config/edit.rs

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,6 @@ pub enum ConfigEdit {
2828
SetNoticeHideWorldWritableWarning(bool),
2929
/// Toggle the rate limit model nudge acknowledgement flag.
3030
SetNoticeHideRateLimitModelNudge(bool),
31-
/// Toggle the exit confirmation prompt acknowledgement flag.
32-
SetNoticeHideExitConfirmationPrompt(bool),
3331
/// Toggle the Windows onboarding acknowledgement flag.
3432
SetWindowsWslSetupAcknowledged(bool),
3533
/// Toggle the model migration prompt acknowledgement flag.
@@ -282,11 +280,6 @@ impl ConfigDocument {
282280
&[Notice::TABLE_KEY, "hide_rate_limit_model_nudge"],
283281
value(*acknowledged),
284282
)),
285-
ConfigEdit::SetNoticeHideExitConfirmationPrompt(acknowledged) => Ok(self.write_value(
286-
Scope::Global,
287-
&[Notice::TABLE_KEY, "hide_exit_confirmation_prompt"],
288-
value(*acknowledged),
289-
)),
290283
ConfigEdit::SetNoticeHideModelMigrationPrompt(migration_config, acknowledged) => {
291284
Ok(self.write_value(
292285
Scope::Global,
@@ -621,14 +614,6 @@ impl ConfigEditsBuilder {
621614
self
622615
}
623616

624-
pub fn set_hide_exit_confirmation_prompt(mut self, acknowledged: bool) -> Self {
625-
self.edits
626-
.push(ConfigEdit::SetNoticeHideExitConfirmationPrompt(
627-
acknowledged,
628-
));
629-
self
630-
}
631-
632617
pub fn set_hide_model_migration_prompt(mut self, model: &str, acknowledged: bool) -> Self {
633618
self.edits
634619
.push(ConfigEdit::SetNoticeHideModelMigrationPrompt(
@@ -1023,33 +1008,6 @@ hide_rate_limit_model_nudge = true
10231008
assert_eq!(contents, expected);
10241009
}
10251010

1026-
#[test]
1027-
fn blocking_set_hide_exit_confirmation_prompt_preserves_table() {
1028-
let tmp = tempdir().expect("tmpdir");
1029-
let codex_home = tmp.path();
1030-
std::fs::write(
1031-
codex_home.join(CONFIG_TOML_FILE),
1032-
r#"[notice]
1033-
existing = "value"
1034-
"#,
1035-
)
1036-
.expect("seed");
1037-
1038-
apply_blocking(
1039-
codex_home,
1040-
None,
1041-
&[ConfigEdit::SetNoticeHideExitConfirmationPrompt(true)],
1042-
)
1043-
.expect("persist");
1044-
1045-
let contents =
1046-
std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
1047-
let expected = r#"[notice]
1048-
existing = "value"
1049-
hide_exit_confirmation_prompt = true
1050-
"#;
1051-
assert_eq!(contents, expected);
1052-
}
10531011
#[test]
10541012
fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() {
10551013
let tmp = tempdir().expect("tmpdir");

codex-rs/core/src/config/types.rs

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -552,8 +552,6 @@ pub struct Notice {
552552
pub hide_world_writable_warning: Option<bool>,
553553
/// Tracks whether the user opted out of the rate limit model switch reminder.
554554
pub hide_rate_limit_model_nudge: Option<bool>,
555-
/// Tracks whether the exit confirmation prompt should be suppressed.
556-
pub hide_exit_confirmation_prompt: Option<bool>,
557555
/// Tracks whether the user has seen the model migration prompt
558556
pub hide_gpt5_1_migration_prompt: Option<bool>,
559557
/// Tracks whether the user has seen the gpt-5.1-codex-max migration prompt

codex-rs/tui/src/app.rs

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -721,14 +721,7 @@ impl App {
721721
self.on_conversation_history_for_backtrack(tui, ev).await?;
722722
}
723723
AppEvent::Exit(mode) => match mode {
724-
ExitMode::ShutdownFirst { confirm } => {
725-
let prompt_hidden = self.chat_widget.exit_confirmation_prompt_hidden();
726-
if confirm && !prompt_hidden {
727-
self.chat_widget.open_exit_confirmation_prompt();
728-
} else {
729-
self.chat_widget.submit_op(Op::Shutdown);
730-
}
731-
}
724+
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
732725
ExitMode::Immediate => {
733726
return Ok(false);
734727
}
@@ -1093,9 +1086,6 @@ impl App {
10931086
AppEvent::UpdateRateLimitSwitchPromptHidden(hidden) => {
10941087
self.chat_widget.set_rate_limit_switch_prompt_hidden(hidden);
10951088
}
1096-
AppEvent::UpdateExitConfirmationPromptHidden(hidden) => {
1097-
self.chat_widget.set_exit_confirmation_prompt_hidden(hidden);
1098-
}
10991089
AppEvent::PersistFullAccessWarningAcknowledged => {
11001090
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
11011091
.set_hide_full_access_warning(true)
@@ -1141,21 +1131,6 @@ impl App {
11411131
));
11421132
}
11431133
}
1144-
AppEvent::PersistExitConfirmationPromptHidden => {
1145-
if let Err(err) = ConfigEditsBuilder::new(&self.config.codex_home)
1146-
.set_hide_exit_confirmation_prompt(true)
1147-
.apply()
1148-
.await
1149-
{
1150-
tracing::error!(
1151-
error = %err,
1152-
"failed to persist exit confirmation prompt preference"
1153-
);
1154-
self.chat_widget.add_error_message(format!(
1155-
"Failed to save exit confirmation preference: {err}"
1156-
));
1157-
}
1158-
}
11591134
AppEvent::PersistModelMigrationPromptAcknowledged {
11601135
from_model,
11611136
to_model,

codex-rs/tui/src/app_event.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,6 @@ pub(crate) enum AppEvent {
158158
/// Update whether the full access warning prompt has been acknowledged.
159159
UpdateFullAccessWarningAcknowledged(bool),
160160

161-
/// Update whether the exit confirmation prompt should be hidden.
162-
UpdateExitConfirmationPromptHidden(bool),
163-
164161
/// Update whether the world-writable directories warning has been acknowledged.
165162
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
166163
UpdateWorldWritableWarningAcknowledged(bool),
@@ -171,9 +168,6 @@ pub(crate) enum AppEvent {
171168
/// Persist the acknowledgement flag for the full access warning prompt.
172169
PersistFullAccessWarningAcknowledged,
173170

174-
/// Persist the acknowledgement flag for the exit confirmation prompt.
175-
PersistExitConfirmationPromptHidden,
176-
177171
/// Persist the acknowledgement flag for the world-writable directories warning.
178172
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
179173
PersistWorldWritableWarningAcknowledged,
@@ -226,8 +220,8 @@ pub(crate) enum AppEvent {
226220

227221
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
228222
pub(crate) enum ExitMode {
229-
/// Shutdown core and exit after completion; `confirm` controls the prompt.
230-
ShutdownFirst { confirm: bool },
223+
/// Shutdown core and exit after completion.
224+
ShutdownFirst,
231225
/// Exit the UI loop immediately without waiting for shutdown.
232226
///
233227
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and

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

Lines changed: 47 additions & 22 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;
@@ -48,7 +50,6 @@ use codex_protocol::custom_prompts::CustomPrompt;
4850
use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX;
4951

5052
use crate::app_event::AppEvent;
51-
use crate::app_event::ExitMode;
5253
use crate::app_event_sender::AppEventSender;
5354
use crate::bottom_pane::textarea::TextArea;
5455
use crate::bottom_pane::textarea::TextAreaState;
@@ -107,7 +108,8 @@ pub(crate) struct ChatComposer {
107108
active_popup: ActivePopup,
108109
app_event_tx: AppEventSender,
109110
history: ChatComposerHistory,
110-
ctrl_c_quit_hint: bool,
111+
quit_shortcut_expires_at: Option<Instant>,
112+
quit_shortcut_key: KeyBinding,
111113
esc_backtrack_hint: bool,
112114
use_shift_enter_hint: bool,
113115
dismissed_file_popup_token: Option<String>,
@@ -160,7 +162,8 @@ impl ChatComposer {
160162
active_popup: ActivePopup::None,
161163
app_event_tx,
162164
history: ChatComposerHistory::new(),
163-
ctrl_c_quit_hint: false,
165+
quit_shortcut_expires_at: None,
166+
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
164167
esc_backtrack_hint: false,
165168
use_shift_enter_hint,
166169
dismissed_file_popup_token: None,
@@ -457,16 +460,37 @@ impl ChatComposer {
457460
}
458461
}
459462

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-
}
463+
/// Show the transient "press again to quit" hint for `key`.
464+
///
465+
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
466+
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
467+
/// even when the UI is otherwise idle.
468+
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
469+
self.quit_shortcut_expires_at = Instant::now()
470+
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
471+
.or_else(|| Some(Instant::now()));
472+
self.quit_shortcut_key = key;
473+
self.footer_mode = FooterMode::QuitShortcutReminder;
474+
self.set_has_focus(has_focus);
475+
}
476+
477+
/// Clear the "press again to quit" hint immediately.
478+
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
479+
self.quit_shortcut_expires_at = None;
480+
self.footer_mode = reset_mode_after_activity(self.footer_mode);
467481
self.set_has_focus(has_focus);
468482
}
469483

484+
/// Whether the quit shortcut hint should currently be shown.
485+
///
486+
/// This is time-based rather than event-based: it may become false without
487+
/// any additional user input, so the UI schedules a redraw when the hint
488+
/// expires.
489+
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
490+
self.quit_shortcut_expires_at
491+
.is_some_and(|expires_at| Instant::now() < expires_at)
492+
}
493+
470494
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
471495
let base = format!("[Pasted Content {char_count} chars]");
472496
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
@@ -1184,11 +1208,7 @@ impl ChatComposer {
11841208
modifiers: crossterm::event::KeyModifiers::CONTROL,
11851209
kind: KeyEventKind::Press,
11861210
..
1187-
} if self.is_empty() => {
1188-
self.app_event_tx
1189-
.send(AppEvent::Exit(ExitMode::ShutdownFirst { confirm: true }));
1190-
(InputResult::None, true)
1191-
}
1211+
} if self.is_empty() => (InputResult::None, false),
11921212
// -------------------------------------------------------------
11931213
// History navigation (Up / Down) – only when the composer is not
11941214
// empty or when the cursor is at the correct position, to avoid
@@ -1687,7 +1707,7 @@ impl ChatComposer {
16871707
return false;
16881708
}
16891709

1690-
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
1710+
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
16911711
let changed = next != self.footer_mode;
16921712
self.footer_mode = next;
16931713
changed
@@ -1698,7 +1718,7 @@ impl ChatComposer {
16981718
mode: self.footer_mode(),
16991719
esc_backtrack_hint: self.esc_backtrack_hint,
17001720
use_shift_enter_hint: self.use_shift_enter_hint,
1701-
is_task_running: self.is_task_running,
1721+
quit_shortcut_key: self.quit_shortcut_key,
17021722
context_window_percent: self.context_window_percent,
17031723
context_window_used_tokens: self.context_window_used_tokens,
17041724
}
@@ -1708,8 +1728,13 @@ impl ChatComposer {
17081728
match self.footer_mode {
17091729
FooterMode::EscHint => FooterMode::EscHint,
17101730
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
1711-
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
1712-
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
1731+
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
1732+
FooterMode::QuitShortcutReminder
1733+
}
1734+
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
1735+
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
1736+
FooterMode::QuitShortcutReminder
1737+
}
17131738
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
17141739
other => other,
17151740
}
@@ -2250,16 +2275,16 @@ mod tests {
22502275
});
22512276

22522277
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
2253-
composer.set_ctrl_c_quit_hint(true, true);
2278+
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
22542279
});
22552280

22562281
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
22572282
composer.set_task_running(true);
2258-
composer.set_ctrl_c_quit_hint(true, true);
2283+
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
22592284
});
22602285

22612286
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
2262-
composer.set_ctrl_c_quit_hint(true, true);
2287+
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
22632288
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
22642289
});
22652290

0 commit comments

Comments
 (0)