Files
codex/prs/bolinfest/PR-1402.md
2025-09-02 15:17:45 -07:00

7.7 KiB

PR #1402: Handle Ctrl+C quit when idle

Description

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

Full Diff

diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs
index 73d512bcf0..4c8f004ad5 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 4ec8299081..5e5819fa04 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<CommandPopup>,
     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 e3234e99a6..d9b1fcc96c 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 92c0122003..78e828f02b 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 <Tab>: 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) {

Review Comments

codex-rs/tui/src/chatwidget.rs

@@ -402,6 +404,19 @@ impl ChatWidget<'_> {
         self.request_redraw();
     }
 
+    pub(crate) fn on_ctrl_c(&mut self) -> bool {

Maybe a docstring to explain what the return value represents?