From 2e293ce90305bf03a5e7eb043dfbc0170abcb1f7 Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Fri, 27 Jun 2025 13:37:11 -0400 Subject: [PATCH] Handle Ctrl+C quit when idle (#1402) ## Summary - show `Ctrl+C to quit` hint when pressing Ctrl+C with no active task - exiting with Ctrl+C if the hint is already visible - clear the hint when tasks begin or other keys are pressed https://github.com/user-attachments/assets/931e2d7c-1c80-4b45-9908-d119f74df23c ------ https://chatgpt.com/s/cd_685ec8875a308191beaa95886dc1379e Fixes #1245 --- codex-rs/tui/src/app.rs | 6 ++--- codex-rs/tui/src/bottom_pane/chat_composer.rs | 22 +++++++++++++--- codex-rs/tui/src/bottom_pane/mod.rs | 26 +++++++++++++++++++ codex-rs/tui/src/chatwidget.rs | 18 +++++++++++++ 4 files changed, 65 insertions(+), 7 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 73d512bc..4c8f004a 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -11,7 +11,6 @@ use crate::slash_command::SlashCommand; use crate::tui; use codex_core::config::Config; use codex_core::protocol::Event; -use codex_core::protocol::Op; use color_eyre::eyre::Result; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -193,10 +192,11 @@ impl<'a> App<'a> { modifiers: crossterm::event::KeyModifiers::CONTROL, .. } => { - // Forward interrupt to ChatWidget when active. match &mut self.app_state { AppState::Chat { widget } => { - widget.submit_op(Op::Interrupt); + if widget.on_ctrl_c() { + self.app_event_tx.send(AppEvent::ExitRequest); + } } AppState::Login { .. } | AppState::GitWarning { .. } => { // No-op. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4ec82990..5e5819fa 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -38,6 +38,7 @@ pub(crate) struct ChatComposer<'a> { command_popup: Option, app_event_tx: AppEventSender, history: ChatComposerHistory, + ctrl_c_quit_hint: bool, } impl ChatComposer<'_> { @@ -51,6 +52,7 @@ impl ChatComposer<'_> { command_popup: None, app_event_tx, history: ChatComposerHistory::new(), + ctrl_c_quit_hint: false, }; this.update_border(has_input_focus); this @@ -114,6 +116,11 @@ impl ChatComposer<'_> { self.update_border(has_focus); } + pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) { + self.ctrl_c_quit_hint = show; + self.update_border(has_focus); + } + /// Handle a key event coming from the main UI. pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) { let result = match self.command_popup { @@ -304,10 +311,17 @@ impl ChatComposer<'_> { } let bs = if has_focus { - BlockState { - right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline") - .alignment(Alignment::Right), - border_style: Style::default(), + if self.ctrl_c_quit_hint { + BlockState { + right_title: Line::from("Ctrl+C to quit").alignment(Alignment::Right), + border_style: Style::default(), + } + } else { + BlockState { + right_title: Line::from("Enter to send | Ctrl+D to quit | Ctrl+J for newline") + .alignment(Alignment::Right), + border_style: Style::default(), + } } } else { BlockState { diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e3234e99..d9b1fcc9 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -37,6 +37,7 @@ pub(crate) struct BottomPane<'a> { app_event_tx: AppEventSender, has_input_focus: bool, is_task_running: bool, + ctrl_c_quit_hint: bool, } pub(crate) struct BottomPaneParams { @@ -52,6 +53,7 @@ impl BottomPane<'_> { app_event_tx: params.app_event_tx, has_input_focus: params.has_input_focus, is_task_running: false, + ctrl_c_quit_hint: false, } } @@ -100,6 +102,26 @@ impl BottomPane<'_> { self.composer.set_input_focus(has_focus); } + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { + self.ctrl_c_quit_hint = true; + self.composer + .set_ctrl_c_quit_hint(true, self.has_input_focus); + self.request_redraw(); + } + + pub(crate) fn clear_ctrl_c_quit_hint(&mut self) { + if self.ctrl_c_quit_hint { + self.ctrl_c_quit_hint = false; + self.composer + .set_ctrl_c_quit_hint(false, self.has_input_focus); + self.request_redraw(); + } + } + + pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool { + self.ctrl_c_quit_hint + } + pub fn set_task_running(&mut self, running: bool) { self.is_task_running = running; @@ -130,6 +152,10 @@ impl BottomPane<'_> { } } + pub(crate) fn is_task_running(&self) -> bool { + self.is_task_running + } + /// Update the *context-window remaining* indicator in the composer. This /// is forwarded directly to the underlying `ChatComposer`. pub(crate) fn set_token_usage( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 92c01220..78e828f0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -138,6 +138,7 @@ impl ChatWidget<'_> { } pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) { + self.bottom_pane.clear_ctrl_c_quit_hint(); // Special-case : normally toggles focus between history and bottom panes. // However, when the slash-command popup is visible we forward the key // to the bottom pane so it can handle auto-completion. @@ -244,6 +245,7 @@ impl ChatWidget<'_> { } } EventMsg::TaskStarted => { + self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.request_redraw(); } @@ -402,6 +404,22 @@ impl ChatWidget<'_> { self.request_redraw(); } + /// Handle Ctrl-C key press. + /// Returns true if the key press was handled, false if it was not. + /// If the key press was not handled, the caller should handle it (likely by exiting the process). + pub(crate) fn on_ctrl_c(&mut self) -> bool { + if self.bottom_pane.is_task_running() { + self.bottom_pane.clear_ctrl_c_quit_hint(); + self.submit_op(Op::Interrupt); + false + } else if self.bottom_pane.ctrl_c_quit_hint_visible() { + true + } else { + self.bottom_pane.show_ctrl_c_quit_hint(); + false + } + } + /// Forward an `Op` directly to codex. pub(crate) fn submit_op(&self, op: Op) { if let Err(e) = self.codex_op_tx.send(op) {