From 40f784e5c3bd2d96d2547bd8301afb20f84225ae Mon Sep 17 00:00:00 2001 From: "Rai (Michael Pokorny)" Date: Tue, 24 Jun 2025 22:26:58 -0700 Subject: [PATCH] agentydragon(tasks): implement double Ctrl+D confirmation on TUI exit --- agentydragon/tasks/16-confirm-on-ctrl-d.md | 27 ++++--- codex-rs/core/src/config_types.rs | 13 +++ codex-rs/tui/src/app.rs | 39 +++++++-- codex-rs/tui/src/chatwidget.rs | 11 +++ codex-rs/tui/src/confirm_ctrl_d.rs | 92 ++++++++++++++++++++++ codex-rs/tui/src/lib.rs | 1 + 6 files changed, 163 insertions(+), 20 deletions(-) create mode 100644 codex-rs/tui/src/confirm_ctrl_d.rs diff --git a/agentydragon/tasks/16-confirm-on-ctrl-d.md b/agentydragon/tasks/16-confirm-on-ctrl-d.md index cd70274003..a24db229f1 100644 --- a/agentydragon/tasks/16-confirm-on-ctrl-d.md +++ b/agentydragon/tasks/16-confirm-on-ctrl-d.md @@ -12,8 +12,8 @@ last_updated = "2025-06-25T01:40:09.513723" ## Status -**General Status**: Not started -**Summary**: Not started; missing Implementation details (How it was implemented and How it works). +**General Status**: Done +**Summary**: Double Ctrl+D confirmation implemented and tested. ## Goal @@ -33,19 +33,20 @@ Require two consecutive Ctrl+D keystrokes (within a short timeout) to exit the T ## Implementation **How it was implemented** -- Introduce `require_double_ctrl_d: bool` in `ConfigToml` → `Config` under the `tui` section, with default `false`. -- Extend the TUI event loop (e.g. in `tui/src/app.rs`) to handle SIGINT events: - 1. If `require_double_ctrl_d` is disabled, behave as before (exit on first Ctrl+D). - 2. If enabled and not already confirming, enter a `ConfirmExit` state, record timestamp, and display confirmation message. - 3. If enabled and in `ConfirmExit` state, exit immediately on second Ctrl+D. - 4. On each TUI tick, if in `ConfirmExit` and timeout elapsed, clear `ConfirmExit` state. - 5. Intercept EOF (Ctrl+D) events in the input handler and apply the same `ConfirmExit` logic as for Ctrl+D when `require_double_ctrl_d` is enabled. -- Add rendering logic in the status bar (`tui/src/status_indicator_widget.rs` or similar) to show the confirmation prompt. +- Added `require_double_ctrl_d` and `double_ctrl_d_timeout_secs` to the TUI config in `core/src/config_types.rs` with defaults. +- Introduced `ConfirmCtrlD` helper in `tui/src/confirm_ctrl_d.rs` to manage confirmation state and expiration logic. +- Extended `App` in `tui/src/app.rs`: + - Initialized `confirm_ctrl_d` from config in `App::new`. + - Expired stale confirmation windows each event-loop tick and cleared the status overlay when timed out. + - Replaced the Ctrl+D handler to invoke `ConfirmCtrlD::handle`, exiting only on confirmed press and otherwise displaying a prompt via `BottomPane`. +- Leveraged `BottomPane::set_task_running(true)` and `update_status_text` to render the confirmation prompt overlay. +- Added unit tests for `ConfirmCtrlD` in `tui/src/confirm_ctrl_d.rs` covering disabled mode, confirmation press, and timeout expiration. **How it works** -- On startup, the TUI reads `require_double_ctrl_d` from config. -- When SIGINT is captured by the event loop, double‑Ctrl+D logic intercepts and requires confirmation. -- Child processes continue to get raw SIGINT from the OS because the TUI should delegate signals while awaiting child termination. +- When `require_double_ctrl_d = true`, the first Ctrl+D press shows "Press Ctrl+D again to confirm exit" in the status overlay. +- A second Ctrl+D within `double_ctrl_d_timeout_secs` exits the TUI; otherwise the prompt and state clear after timeout. +- When `require_double_ctrl_d = false`, Ctrl+D exits immediately as before. +- Child processes still receive SIGINT normally since only the TUI event loop intercepts Ctrl+D. ## Notes diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 3a3ef7427a..e66eb3deca 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -99,6 +99,12 @@ pub struct Tui { /// Defaults to the `VISUAL` or `EDITOR` environment variable, falling back to `nvim`. #[serde(default = "default_editor")] pub editor: String, + /// Require two consecutive Ctrl+D keystrokes to exit the TUI when enabled. + #[serde(default)] + pub require_double_ctrl_d: bool, + /// Timeout in seconds for requiring second Ctrl+D to confirm exit. + #[serde(default = "default_double_ctrl_d_timeout_secs")] + pub double_ctrl_d_timeout_secs: u64, } fn default_composer_max_rows() -> usize { @@ -110,12 +116,19 @@ fn default_editor() -> String { std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "nvim".into()) } +/// Default timeout in seconds for the second Ctrl+D confirmation to exit the TUI. +fn default_double_ctrl_d_timeout_secs() -> u64 { + 2 +} + impl Default for Tui { fn default() -> Self { Self { disable_mouse_capture: Default::default(), composer_max_rows: default_composer_max_rows(), editor: default_editor(), + require_double_ctrl_d: false, + double_ctrl_d_timeout_secs: default_double_ctrl_d_timeout_secs(), } } } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 13d51eac02..4d7cf7e6b4 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,4 +1,5 @@ use crate::app_event::AppEvent; +use crate::confirm_ctrl_d::ConfirmCtrlD; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::git_warning_screen::GitWarningOutcome; @@ -18,6 +19,7 @@ use crossterm::event::MouseEventKind; use std::path::PathBuf; use std::sync::mpsc::Receiver; use std::sync::mpsc::channel; +use std::time::Instant; use codex_core::ResponseItem; use uuid::Uuid; @@ -49,6 +51,8 @@ pub(crate) struct App<'a> { /// after dismissing the Git-repo warning. chat_args: Option, session_id: Option, + /// Tracks Ctrl+D confirmation state when enabled in config. + confirm_ctrl_d: ConfirmCtrlD, } /// Aggregate parameters needed to create a `ChatWidget`, as creation may be @@ -252,9 +256,13 @@ impl<'a> App<'a> { app_event_tx, app_event_rx, app_state, - config, + config: config.clone(), chat_args, session_id: None, + confirm_ctrl_d: ConfirmCtrlD::new( + config.tui.require_double_ctrl_d, + config.tui.double_ctrl_d_timeout_secs, + ), } } @@ -293,6 +301,14 @@ impl<'a> App<'a> { app_event_tx.send(AppEvent::Redraw); while let Ok(event) = self.app_event_rx.recv() { + // Expire pending Ctrl+D confirmation and clear any prompt overlay. + let now = Instant::now(); + self.confirm_ctrl_d.expire(now); + if self.config.tui.require_double_ctrl_d && !self.confirm_ctrl_d.is_confirming() { + if let AppState::Chat { widget } = &mut self.app_state { + widget.clear_exit_confirmation_prompt(); + } + } match event { AppEvent::Redraw => { self.draw_next_frame(terminal)?; @@ -338,13 +354,22 @@ impl<'a> App<'a> { } } } - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - .. - } => { - self.app_event_tx.send(AppEvent::ExitRequest); + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + .. + } => { + // Handle Ctrl+D exit confirmation when enabled. + let now = Instant::now(); + if self.confirm_ctrl_d.handle(now) { + break; } + if let AppState::Chat { widget } = &mut self.app_state { + widget.show_exit_confirmation_prompt( + "Press Ctrl+D again to confirm exit".to_string(), + ); + } + } _ => { self.dispatch_key_event(key_event); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b2a55b8895..b69107e4b3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -435,6 +435,17 @@ impl ChatWidget<'_> { self.bottom_pane.update_status_text(line); } + /// Show the Ctrl+D exit confirmation prompt via the status overlay. + pub(crate) fn show_exit_confirmation_prompt(&mut self, msg: String) { + self.bottom_pane.set_task_running(true); + self.bottom_pane.update_status_text(msg); + } + + /// Clear any pending exit confirmation prompt. + pub(crate) fn clear_exit_confirmation_prompt(&mut self) { + self.bottom_pane.set_task_running(false); + } + /// Launch interactive mount-add dialog. pub fn push_mount_add_interactive(&mut self) { self.bottom_pane.push_mount_add_interactive(); diff --git a/codex-rs/tui/src/confirm_ctrl_d.rs b/codex-rs/tui/src/confirm_ctrl_d.rs new file mode 100644 index 0000000000..b4fe61e141 --- /dev/null +++ b/codex-rs/tui/src/confirm_ctrl_d.rs @@ -0,0 +1,92 @@ +use std::time::{Duration, Instant}; + +/// Helper to track and enforce double Ctrl+D confirmation within a timeout. +pub(crate) struct ConfirmCtrlD { + require_double: bool, + timeout: Duration, + deadline: Option, +} + +impl ConfirmCtrlD { + /// Create a new ConfirmCtrlD state. + /// + /// `require_double` indicates if double Ctrl+D is required to exit. + /// `timeout_secs` specifies the confirmation window in seconds. + pub fn new(require_double: bool, timeout_secs: u64) -> Self { + ConfirmCtrlD { + require_double, + timeout: Duration::from_secs(timeout_secs), + deadline: None, + } + } + + /// Handle a Ctrl+D event at the given instant. + /// + /// Returns `true` if the event should trigger exit, or `false` to prompt confirmation. + pub fn handle(&mut self, now: Instant) -> bool { + if !self.require_double { + return true; + } + if let Some(deadline) = self.deadline { + if now <= deadline { + return true; + } + } + // Start or reset confirmation window. + self.deadline = Some(now + self.timeout); + false + } + + /// Clear the confirmation state if the deadline has passed. + pub fn expire(&mut self, now: Instant) { + if let Some(deadline) = self.deadline { + if now > deadline { + self.deadline = None; + } + } + } + + /// Returns true if a confirmation window is currently active. + pub fn is_confirming(&self) -> bool { + self.deadline.is_some() + } +} + +#[cfg(test)] +mod tests { + use super::ConfirmCtrlD; + use std::time::{Duration, Instant}; + + #[test] + fn exit_without_double_when_disabled() { + let mut c = ConfirmCtrlD::new(false, 1); + let now = Instant::now(); + assert!(c.handle(now)); + } + + #[test] + fn require_double_ctrl_d() { + let mut c = ConfirmCtrlD::new(true, 2); + let t0 = Instant::now(); + // First press should not exit + assert!(!c.handle(t0)); + assert!(c.is_confirming()); + // Before timeout, second press exits + let t1 = t0 + Duration::from_secs(1); + assert!(c.handle(t1)); + } + + #[test] + fn confirmation_expires() { + let mut c = ConfirmCtrlD::new(true, 1); + let t0 = Instant::now(); + assert!(!c.handle(t0)); + assert!(c.is_confirming()); + // After timeout, expire() clears state + let t2 = t0 + Duration::from_secs(2); + c.expire(t2); + assert!(!c.is_confirming()); + // Next press should again not exit + assert!(!c.handle(t2)); + } +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9c3786017d..c1f32767fd 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -41,6 +41,7 @@ mod markdown; mod mouse_capture; mod scroll_event_helper; mod slash_command; +mod confirm_ctrl_d; mod status_indicator_widget; mod context; mod text_block;