agentydragon(tasks): implement double Ctrl+D confirmation on TUI exit

This commit is contained in:
Rai (Michael Pokorny)
2025-06-24 22:26:58 -07:00
parent bbce68d8c3
commit 40f784e5c3
6 changed files with 163 additions and 20 deletions

View File

@@ -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, doubleCtrl+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

View File

@@ -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(),
}
}
}

View File

@@ -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);
}

View File

@@ -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();

View 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));
}
}

View File

@@ -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;