mirror of
https://github.com/openai/codex.git
synced 2026-09-14 11:57:03 +00:00
agentydragon(tasks): implement double Ctrl+D confirmation on TUI exit
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ChatWidgetArgs>,
|
||||
session_id: Option<Uuid>,
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
92
codex-rs/tui/src/confirm_ctrl_d.rs
Normal file
92
codex-rs/tui/src/confirm_ctrl_d.rs
Normal file
@@ -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<Instant>,
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user