tui: double-press Ctrl+C/Ctrl+D to quit (#8936)
## Problem
Codex’s TUI quit behavior has historically been easy to trigger
accidentally and hard to reason
about.
- `Ctrl+C`/`Ctrl+D` could terminate the UI immediately, which is a
common key to press while trying
to dismiss a modal, cancel a command, or recover from a stuck state.
- “Quit” and “shutdown” were not consistently separated, so some exit
paths could bypass the
shutdown/cleanup work that should run before the process terminates.
This PR makes quitting both safer (harder to do by accident) and more
uniform across quit
gestures, while keeping the shutdown-first semantics explicit.
## Mental model
After this change, the system treats quitting as a UI request that is
coordinated by the app
layer.
- The UI requests exit via `AppEvent::Exit(ExitMode)`.
- `ExitMode::ShutdownFirst` is the normal user path: the app triggers
`Op::Shutdown`, continues
rendering while shutdown runs, and only ends the UI loop once shutdown
has completed.
- `ExitMode::Immediate` exists as an escape hatch (and as the
post-shutdown “now actually exit”
signal); it bypasses cleanup and should not be the default for
user-triggered quits.
User-facing quit gestures are intentionally “two-step” for safety:
- `Ctrl+C` and `Ctrl+D` no longer exit immediately.
- The first press arms a 1-second window and shows a footer hint (“ctrl
+ <key> again to quit”).
- Pressing the same key again within the window requests a
shutdown-first quit; otherwise the
hint expires and the next press starts a fresh window.
Key routing remains modal-first:
- A modal/popup gets first chance to consume `Ctrl+C`.
- If a modal handles `Ctrl+C`, any armed quit shortcut is cleared so
dismissing a modal cannot
prime a subsequent `Ctrl+C` to quit.
- `Ctrl+D` only participates in quitting when the composer is empty and
no modal/popup is active.
The design doc `docs/exit-confirmation-prompt-design.md` captures the
intended routing and the
invariants the UI should maintain.
## Non-goals
- This does not attempt to redesign modal UX or make modals uniformly
dismissible via `Ctrl+C`.
It only ensures modals get priority and that quit arming does not leak
across modal handling.
- This does not introduce a persistent confirmation prompt/menu for
quitting; the goal is to keep
the exit gesture lightweight and consistent.
- This does not change the semantics of core shutdown itself; it changes
how the UI requests and
sequences it.
## Tradeoffs
- Quitting via `Ctrl+C`/`Ctrl+D` now requires a deliberate second
keypress, which adds friction for
users who relied on the old “instant quit” behavior.
- The UI now maintains a small time-bounded state machine for the armed
shortcut, which increases
complexity and introduces timing-dependent behavior.
This design was chosen over alternatives (a modal confirmation prompt or
a long-lived “are you
sure” state) because it provides an explicit safety barrier while
keeping the flow fast and
keyboard-native.
## Architecture
- `ChatWidget` owns the quit-shortcut state machine and decides when a
quit gesture is allowed
(idle vs cancellable work, composer state, etc.).
- `BottomPane` owns rendering and local input routing for modals/popups.
It is responsible for
consuming cancellation keys when a view is active and for
showing/expiring the footer hint.
- `App` owns shutdown sequencing: translating
`AppEvent::Exit(ShutdownFirst)` into `Op::Shutdown`
and only terminating the UI loop when exit is safe.
This keeps “what should happen” decisions (quit vs interrupt vs ignore)
in the chat/widget layer,
while keeping “how it looks and which view gets the key” in the
bottom-pane layer.
## Observability
You can tell this is working by running the TUIs and exercising the quit
gestures:
- While idle: pressing `Ctrl+C` (or `Ctrl+D` with an empty composer and
no modal) shows a footer
hint for ~1 second; pressing again within that window exits via
shutdown-first.
- While streaming/tools/review are active: `Ctrl+C` interrupts work
rather than quitting.
- With a modal/popup open: `Ctrl+C` dismisses/handles the modal (if it
chooses to) and does not
arm a quit shortcut; a subsequent quick `Ctrl+C` should not quit unless
the user re-arms it.
Failure modes are visible as:
- Quits that happen immediately (no hint window) from `Ctrl+C`/`Ctrl+D`.
- Quits that occur while a modal is open and consuming `Ctrl+C`.
- UI termination before shutdown completes (cleanup skipped).
## Tests
- Updated/added unit and snapshot coverage in `codex-tui` and
`codex-tui2` to validate:
- The quit hint appears and expires on the expected key.
- Double-press within the window triggers a shutdown-first quit request.
- Modal-first routing prevents quit bypass and clears any armed shortcut
when a modal consumes
`Ctrl+C`.
These tests focus on the UI-level invariants and rendered output; they
do not attempt to validate
real terminal key-repeat timing or end-to-end process shutdown behavior.
---
Screenshot:
<img width="912" height="740" alt="Screenshot 2026-01-13 at 1 05 28 PM"
src="https://github.com/user-attachments/assets/18f3d22e-2557-47f2-a369-ae7a9531f29f"
/>
This commit is contained in:
parent
92472e7baa
commit
4283a7432b
25 changed files with 932 additions and 199 deletions
|
|
@ -1007,6 +1007,7 @@ hide_rate_limit_model_nudge = true
|
|||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_hide_gpt5_1_migration_prompt_preserves_table() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -858,9 +859,12 @@ impl App {
|
|||
}
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
||||
}
|
||||
AppEvent::Exit(mode) => match mode {
|
||||
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
|
||||
ExitMode::Immediate => {
|
||||
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
||||
}
|
||||
},
|
||||
AppEvent::FatalExitRequest(message) => {
|
||||
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
//! Application-level events used to coordinate UI actions.
|
||||
//!
|
||||
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
|
||||
//! Widgets emit events to request actions that must be handled at the app layer (like opening
|
||||
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
|
||||
//! `App` internals.
|
||||
//!
|
||||
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
|
||||
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
|
|
@ -41,8 +51,13 @@ pub(crate) enum AppEvent {
|
|||
/// Open the fork picker inside the running TUI session.
|
||||
OpenForkPicker,
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
/// Request to exit the application.
|
||||
///
|
||||
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
|
||||
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
|
||||
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
|
||||
/// background tasks, rollout flush, or child process cleanup).
|
||||
Exit(ExitMode),
|
||||
|
||||
/// Request to exit the application due to a fatal error.
|
||||
FatalExitRequest(String),
|
||||
|
|
@ -215,6 +230,22 @@ pub(crate) enum AppEvent {
|
|||
LaunchExternalEditor,
|
||||
}
|
||||
|
||||
/// The exit strategy requested by the UI layer.
|
||||
///
|
||||
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
|
||||
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
|
||||
/// already completed (or is being bypassed) and the UI loop should terminate right away.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ExitMode {
|
||||
/// Shutdown core and exit after completion.
|
||||
ShutdownFirst,
|
||||
/// Exit the UI loop immediately without waiting for shutdown.
|
||||
///
|
||||
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
|
||||
/// cleanup that normally runs before `ShutdownComplete` can be missed.
|
||||
Immediate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FeedbackCategory {
|
||||
BadResult,
|
||||
|
|
|
|||
|
|
@ -57,7 +57,8 @@
|
|||
//! edits and renders a placeholder prompt instead of the editable textarea. This is part of the
|
||||
//! overall state machine, since it affects which transitions are even possible from a given UI
|
||||
//! state.
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::key_hint::has_ctrl_or_alt;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
|
@ -168,7 +169,8 @@ pub(crate) struct ChatComposer {
|
|||
active_popup: ActivePopup,
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
ctrl_c_quit_hint: bool,
|
||||
quit_shortcut_expires_at: Option<Instant>,
|
||||
quit_shortcut_key: KeyBinding,
|
||||
esc_backtrack_hint: bool,
|
||||
use_shift_enter_hint: bool,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
|
|
@ -222,7 +224,8 @@ impl ChatComposer {
|
|||
active_popup: ActivePopup::None,
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
ctrl_c_quit_hint: false,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint,
|
||||
dismissed_file_popup_token: None,
|
||||
|
|
@ -578,16 +581,37 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
||||
self.ctrl_c_quit_hint = show;
|
||||
if show {
|
||||
self.footer_mode = FooterMode::CtrlCReminder;
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
/// Show the transient "press again to quit" hint for `key`.
|
||||
///
|
||||
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
|
||||
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
|
||||
/// even when the UI is otherwise idle.
|
||||
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
|
||||
self.quit_shortcut_expires_at = Instant::now()
|
||||
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
|
||||
.or_else(|| Some(Instant::now()));
|
||||
self.quit_shortcut_key = key;
|
||||
self.footer_mode = FooterMode::QuitShortcutReminder;
|
||||
self.set_has_focus(has_focus);
|
||||
}
|
||||
|
||||
/// Clear the "press again to quit" hint immediately.
|
||||
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
self.set_has_focus(has_focus);
|
||||
}
|
||||
|
||||
/// Whether the quit shortcut hint should currently be shown.
|
||||
///
|
||||
/// This is time-based rather than event-based: it may become false without
|
||||
/// any additional user input, so the UI schedules a redraw when the hint
|
||||
/// expires.
|
||||
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
|
||||
self.quit_shortcut_expires_at
|
||||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||||
}
|
||||
|
||||
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
|
||||
let base = format!("[Pasted Content {char_count} chars]");
|
||||
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
|
||||
|
|
@ -1497,10 +1521,7 @@ impl ChatComposer {
|
|||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.is_empty() => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
} if self.is_empty() => (InputResult::None, false),
|
||||
// -------------------------------------------------------------
|
||||
// History navigation (Up / Down) – only when the composer is not
|
||||
// empty or when the cursor is at the correct position, to avoid
|
||||
|
|
@ -1801,7 +1822,7 @@ impl ChatComposer {
|
|||
return false;
|
||||
}
|
||||
|
||||
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
|
||||
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
|
||||
let changed = next != self.footer_mode;
|
||||
self.footer_mode = next;
|
||||
changed
|
||||
|
|
@ -1813,6 +1834,7 @@ impl ChatComposer {
|
|||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
quit_shortcut_key: self.quit_shortcut_key,
|
||||
steer_enabled: self.steer_enabled,
|
||||
context_window_percent: self.context_window_percent,
|
||||
context_window_used_tokens: self.context_window_used_tokens,
|
||||
|
|
@ -1823,8 +1845,13 @@ impl ChatComposer {
|
|||
match self.footer_mode {
|
||||
FooterMode::EscHint => FooterMode::EscHint,
|
||||
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
|
||||
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
|
||||
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
|
||||
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
|
||||
FooterMode::QuitShortcutReminder
|
||||
}
|
||||
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
|
||||
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
|
||||
FooterMode::QuitShortcutReminder
|
||||
}
|
||||
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
|
||||
other => other,
|
||||
}
|
||||
|
|
@ -2365,16 +2392,16 @@ mod tests {
|
|||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
|
||||
composer.set_task_running(true);
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
//! The bottom-pane footer renders transient hints and context indicators.
|
||||
//!
|
||||
//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state.
|
||||
//! It intentionally does not decide *which* footer content should be shown; that is owned by the
|
||||
//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like
|
||||
//! `ChatWidget` (which decides when quit/interrupt is allowed).
|
||||
//!
|
||||
//! Some footer content is time-based rather than event-based, such as the "press again to quit"
|
||||
//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is
|
||||
//! otherwise idle.
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::clipboard_paste::is_probably_wsl;
|
||||
use crate::key_hint;
|
||||
|
|
@ -14,6 +24,12 @@ use ratatui::text::Span;
|
|||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
/// The rendering inputs for the footer area under the composer.
|
||||
///
|
||||
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
|
||||
/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as
|
||||
/// authoritative and does not attempt to infer missing state (for example, it does not query
|
||||
/// whether a task is running).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FooterProps {
|
||||
pub(crate) mode: FooterMode,
|
||||
|
|
@ -21,13 +37,22 @@ pub(crate) struct FooterProps {
|
|||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) steer_enabled: bool,
|
||||
/// Which key the user must press again to quit.
|
||||
///
|
||||
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
|
||||
pub(crate) quit_shortcut_key: KeyBinding,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
}
|
||||
|
||||
/// Selects which footer content is rendered.
|
||||
///
|
||||
/// The current mode is owned by `ChatComposer`, which may override it based on transient state
|
||||
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum FooterMode {
|
||||
CtrlCReminder,
|
||||
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
|
||||
QuitShortcutReminder,
|
||||
ShortcutSummary,
|
||||
ShortcutOverlay,
|
||||
EscHint,
|
||||
|
|
@ -35,12 +60,14 @@ pub(crate) enum FooterMode {
|
|||
}
|
||||
|
||||
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
|
||||
if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) {
|
||||
if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) {
|
||||
return current;
|
||||
}
|
||||
|
||||
match current {
|
||||
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
|
||||
FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => {
|
||||
FooterMode::ShortcutSummary
|
||||
}
|
||||
_ => FooterMode::ShortcutOverlay,
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +84,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
|||
match current {
|
||||
FooterMode::EscHint
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::CtrlCReminder
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
|
||||
other => other,
|
||||
}
|
||||
|
|
@ -82,9 +109,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
|||
// the shortcut hint is hidden). Hide it only for the multi-line
|
||||
// ShortcutOverlay.
|
||||
match props.mode {
|
||||
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
FooterMode::QuitShortcutReminder => {
|
||||
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
|
||||
}
|
||||
FooterMode::ShortcutSummary => {
|
||||
let mut line = context_window_line(
|
||||
props.context_window_percent,
|
||||
|
|
@ -126,11 +153,6 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
|||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CtrlCReminderState {
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ShortcutsState {
|
||||
use_shift_enter_hint: bool,
|
||||
|
|
@ -138,17 +160,8 @@ struct ShortcutsState {
|
|||
is_wsl: bool,
|
||||
}
|
||||
|
||||
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
|
||||
let action = if state.is_task_running {
|
||||
"interrupt"
|
||||
} else {
|
||||
"quit"
|
||||
};
|
||||
Line::from(vec![
|
||||
key_hint::ctrl(KeyCode::Char('c')).into(),
|
||||
format!(" again to {action}").into(),
|
||||
])
|
||||
.dim()
|
||||
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
|
||||
Line::from(vec![key.into(), " again to quit".into()]).dim()
|
||||
}
|
||||
|
||||
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
|
||||
|
|
@ -487,6 +500,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -500,6 +514,7 @@ mod tests {
|
|||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -508,11 +523,12 @@ mod tests {
|
|||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_idle",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
mode: FooterMode::QuitShortcutReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -521,11 +537,12 @@ mod tests {
|
|||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
mode: FooterMode::QuitShortcutReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -539,6 +556,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -552,6 +570,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -565,6 +584,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(72),
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -578,6 +598,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: Some(123_456),
|
||||
},
|
||||
|
|
@ -591,6 +612,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
@ -604,6 +626,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: true,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,9 +1,25 @@
|
|||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||
//! The bottom pane is the interactive footer of the chat UI.
|
||||
//!
|
||||
//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient
|
||||
//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused
|
||||
//! interactions like selection lists.
|
||||
//!
|
||||
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
|
||||
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
|
||||
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
|
||||
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
|
||||
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
|
||||
//! shortcut.
|
||||
//!
|
||||
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
|
||||
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::bottom_pane::unified_exec_footer::UnifiedExecFooter;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
|
|
@ -46,6 +62,20 @@ mod textarea;
|
|||
mod unified_exec_footer;
|
||||
pub(crate) use feedback_view::FeedbackNoteView;
|
||||
|
||||
/// How long the "press again to quit" hint stays visible.
|
||||
///
|
||||
/// This is shared between:
|
||||
/// - `ChatWidget`: arming the double-press quit shortcut.
|
||||
/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint.
|
||||
///
|
||||
/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.
|
||||
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The result of offering a cancellation key to a bottom-pane surface.
|
||||
///
|
||||
/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss
|
||||
/// themselves, and the caller can decide what higher-level action (if any) to take when the key is
|
||||
/// not handled locally.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CancellationEvent {
|
||||
Handled,
|
||||
|
|
@ -63,6 +93,10 @@ pub(crate) use list_selection_view::SelectionAction;
|
|||
pub(crate) use list_selection_view::SelectionItem;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
///
|
||||
/// This is the owning container for the prompt input (`ChatComposer`) and the view stack
|
||||
/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving
|
||||
/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`.
|
||||
pub(crate) struct BottomPane {
|
||||
/// Composer is retained even when a BottomPaneView is displayed so the
|
||||
/// input state is retained when the view is closed.
|
||||
|
|
@ -76,7 +110,6 @@ pub(crate) struct BottomPane {
|
|||
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
animations_enabled: bool,
|
||||
|
||||
|
|
@ -129,7 +162,6 @@ impl BottomPane {
|
|||
frame_requester,
|
||||
has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
status: None,
|
||||
unified_exec_footer: UnifiedExecFooter::new(),
|
||||
queued_user_messages: QueuedUserMessages::new(),
|
||||
|
|
@ -218,8 +250,14 @@ impl BottomPane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
/// chance to consume the event (e.g. to dismiss itself).
|
||||
/// Handles a Ctrl+C press within the bottom pane.
|
||||
///
|
||||
/// An active modal view is given the first chance to consume the key (typically to dismiss
|
||||
/// itself). If no view is active, Ctrl+C clears draft composer input.
|
||||
///
|
||||
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
|
||||
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
|
||||
/// quit/interrupt state machine and uses the result to decide what happens next.
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(view) = self.view_stack.last_mut() {
|
||||
let event = view.on_ctrl_c();
|
||||
|
|
@ -228,7 +266,7 @@ impl BottomPane {
|
|||
self.view_stack.pop();
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
self.show_ctrl_c_quit_hint();
|
||||
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
|
||||
}
|
||||
event
|
||||
} else if self.composer_is_empty() {
|
||||
|
|
@ -236,7 +274,7 @@ impl BottomPane {
|
|||
} else {
|
||||
self.view_stack.pop();
|
||||
self.clear_composer_for_ctrl_c();
|
||||
self.show_ctrl_c_quit_hint();
|
||||
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
|
@ -314,25 +352,41 @@ impl BottomPane {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
/// Show the transient "press again to quit" hint for `key`.
|
||||
///
|
||||
/// `ChatWidget` owns the quit shortcut state machine (it decides when quit is
|
||||
/// allowed), while the bottom pane owns rendering. We also schedule a redraw
|
||||
/// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user
|
||||
/// stops typing and no other events trigger a draw.
|
||||
pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) {
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(true, self.has_input_focus);
|
||||
.show_quit_shortcut_hint(key, self.has_input_focus);
|
||||
let frame_requester = self.frame_requester.clone();
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await;
|
||||
frame_requester.schedule_frame();
|
||||
});
|
||||
} else {
|
||||
// In tests (and other non-Tokio contexts), fall back to a thread so
|
||||
// the hint can still expire without requiring an explicit draw.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
|
||||
frame_requester.schedule_frame();
|
||||
});
|
||||
}
|
||||
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();
|
||||
}
|
||||
/// Clear the "press again to quit" hint immediately.
|
||||
pub(crate) fn clear_quit_shortcut_hint(&mut self) {
|
||||
self.composer.clear_quit_shortcut_hint(self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
self.ctrl_c_quit_hint
|
||||
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
|
||||
self.composer.quit_shortcut_hint_visible()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -463,6 +517,15 @@ impl BottomPane {
|
|||
self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
}
|
||||
|
||||
/// Returns true when the bottom pane has no active modal view and no active composer popup.
|
||||
///
|
||||
/// This is the UI-level definition of "no modal/popup is active" for key routing decisions.
|
||||
/// It intentionally does not include task state, since some actions are safe while a task is
|
||||
/// running and some are not.
|
||||
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
||||
self.can_launch_external_editor()
|
||||
}
|
||||
|
||||
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.push_view(view);
|
||||
}
|
||||
|
|
@ -648,7 +711,7 @@ mod tests {
|
|||
});
|
||||
pane.push_approval_request(exec_request(), &features);
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
assert!(pane.ctrl_c_quit_hint_visible());
|
||||
assert!(pane.quit_shortcut_hint_visible());
|
||||
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@ expression: terminal.backend()
|
|||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to interrupt "
|
||||
" ctrl + c again to quit "
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
source: tui/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ctrl + c again to interrupt "
|
||||
" ctrl + c again to quit "
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use std::collections::VecDeque;
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
|
|
@ -108,6 +109,7 @@ use tokio::task::JoinHandle;
|
|||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
|
|
@ -119,6 +121,7 @@ use crate::bottom_pane::BottomPaneParams;
|
|||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::ExperimentalFeaturesView;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
|
|
@ -136,6 +139,8 @@ use crate::history_cell::AgentMessageCell;
|
|||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
|
|
@ -358,12 +363,18 @@ pub(crate) enum ExternalEditorState {
|
|||
Active,
|
||||
}
|
||||
|
||||
/// Maintains the per-session UI state for the chat screen.
|
||||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||||
///
|
||||
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
|
||||
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
|
||||
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
|
||||
/// sending `Op` requests back to codex-core.
|
||||
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
|
||||
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
|
||||
/// intent (`Op` submissions and `AppEvent` requests).
|
||||
///
|
||||
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
|
||||
/// and by sending requests back to codex-core.
|
||||
///
|
||||
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
|
||||
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
|
||||
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
|
|
@ -431,6 +442,14 @@ pub(crate) struct ChatWidget {
|
|||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
/// When `Some`, the user has pressed a quit shortcut and the second press
|
||||
/// must occur before `quit_shortcut_expires_at`.
|
||||
quit_shortcut_expires_at: Option<Instant>,
|
||||
/// Tracks which quit shortcut key was pressed first.
|
||||
///
|
||||
/// We require the second press to match this key so `Ctrl+C` followed by
|
||||
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
|
||||
quit_shortcut_key: Option<KeyBinding>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// Snapshot of token usage to restore after review mode exits.
|
||||
|
|
@ -693,7 +712,9 @@ impl ChatWidget {
|
|||
|
||||
fn on_task_started(&mut self) {
|
||||
self.agent_turn_running = true;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.update_task_running_state();
|
||||
self.retry_status_header = None;
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
|
|
@ -1197,7 +1218,7 @@ impl ChatWidget {
|
|||
}
|
||||
|
||||
fn on_shutdown_complete(&mut self) {
|
||||
self.request_exit();
|
||||
self.request_immediate_exit();
|
||||
}
|
||||
|
||||
fn on_turn_diff(&mut self, unified_diff: String) {
|
||||
|
|
@ -1625,6 +1646,8 @@ impl ChatWidget {
|
|||
show_welcome_banner: is_first_run,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
|
|
@ -1717,6 +1740,8 @@ impl ChatWidget {
|
|||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
|
|
@ -1745,6 +1770,19 @@ impl ChatWidget {
|
|||
self.on_ctrl_c();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
|
||||
if self.on_ctrl_d() {
|
||||
return;
|
||||
}
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
|
|
@ -1757,7 +1795,9 @@ impl ChatWidget {
|
|||
return;
|
||||
}
|
||||
other if other.kind == KeyEventKind::Press => {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -1968,7 +2008,7 @@ impl ChatWidget {
|
|||
self.open_experimental_popup();
|
||||
}
|
||||
SlashCommand::Quit | SlashCommand::Exit => {
|
||||
self.request_exit();
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_core::auth::logout(
|
||||
|
|
@ -1977,7 +2017,7 @@ impl ChatWidget {
|
|||
) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
self.request_exit();
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
// SlashCommand::Undo => {
|
||||
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
||||
|
|
@ -2432,8 +2472,21 @@ impl ChatWidget {
|
|||
}
|
||||
}
|
||||
|
||||
fn request_exit(&self) {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
/// Exit the UI immediately without waiting for shutdown.
|
||||
///
|
||||
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
|
||||
/// this is mainly a fallback for shutdown completion or emergency exits.
|
||||
fn request_immediate_exit(&self) {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
}
|
||||
|
||||
/// Request a shutdown-first quit.
|
||||
///
|
||||
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
|
||||
/// the double-press Ctrl+C/Ctrl+D quit shortcut.
|
||||
fn request_quit_without_confirmation(&self) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
|
|
@ -3820,19 +3873,87 @@ impl ChatWidget {
|
|||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
/// Handles a Ctrl+C press at the chat-widget layer.
|
||||
///
|
||||
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
|
||||
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
|
||||
/// is armed.
|
||||
///
|
||||
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
|
||||
/// quit.
|
||||
fn on_ctrl_c(&mut self) {
|
||||
let key = key_hint::ctrl(KeyCode::Char('c'));
|
||||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||||
if modal_or_popup_active {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
} else {
|
||||
self.arm_quit_shortcut(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
if self.quit_shortcut_active_for(key) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_quit_without_confirmation();
|
||||
return;
|
||||
}
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
|
||||
if self.is_cancellable_work_active() {
|
||||
self.submit_op(Op::Interrupt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a Ctrl+D press at the chat-widget layer.
|
||||
///
|
||||
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
|
||||
/// Otherwise it should be routed to the active view and not attempt to quit.
|
||||
fn on_ctrl_d(&mut self) -> bool {
|
||||
let key = key_hint::ctrl(KeyCode::Char('d'));
|
||||
if self.quit_shortcut_active_for(key) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_quit_without_confirmation();
|
||||
return true;
|
||||
}
|
||||
|
||||
self.submit_op(Op::Shutdown);
|
||||
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
true
|
||||
}
|
||||
|
||||
/// True if `key` matches the armed quit shortcut and the window has not expired.
|
||||
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
|
||||
self.quit_shortcut_key == Some(key)
|
||||
&& self
|
||||
.quit_shortcut_expires_at
|
||||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||||
}
|
||||
|
||||
/// Arm the double-press quit shortcut and show the footer hint.
|
||||
///
|
||||
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
|
||||
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
|
||||
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
|
||||
fn arm_quit_shortcut(&mut self, key: KeyBinding) {
|
||||
self.quit_shortcut_expires_at = Instant::now()
|
||||
.checked_add(QUIT_SHORTCUT_TIMEOUT)
|
||||
.or_else(|| Some(Instant::now()));
|
||||
self.quit_shortcut_key = Some(key);
|
||||
self.bottom_pane.show_quit_shortcut_hint(key);
|
||||
}
|
||||
|
||||
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
|
||||
fn is_cancellable_work_active(&self) -> bool {
|
||||
self.bottom_pane.is_task_running() || self.is_review_mode
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub(crate) fn spawn_agent(
|
|||
msg: EventMsg::Error(err.to_error_event(None)),
|
||||
}));
|
||||
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
|
||||
tracing::error!("failed to initialize codex: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
|
|
@ -430,6 +431,8 @@ async fn make_chatwidget_manual(
|
|||
queued_user_messages: VecDeque::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
|
|
@ -1104,19 +1107,58 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
|||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
|
||||
assert!(chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_shutdown_ignores_caps_lock() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Shutdown) => {}
|
||||
other => panic!("expected Op::Shutdown, got {other:?}"),
|
||||
}
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_d_double_press_quits_without_prompt() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let width = 100;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw after ctrl+d");
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
.vt100()
|
||||
.screen()
|
||||
.contents()
|
||||
.contains("ctrl + d again to quit")
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_d_with_modal_open_does_not_quit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.open_approvals_popup();
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1135,7 +1177,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
|||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
|
||||
assert!(chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
let restored_text = chat.bottom_pane.composer_text();
|
||||
|
|
@ -1144,7 +1186,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
|||
"expected placeholder {placeholder:?} after history recall"
|
||||
);
|
||||
assert!(restored_text.starts_with("draft message "));
|
||||
assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible());
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
|
||||
let images = chat.bottom_pane.take_recent_submission_images();
|
||||
assert!(
|
||||
|
|
@ -1487,7 +1529,7 @@ async fn slash_quit_requests_exit() {
|
|||
|
||||
chat.dispatch_command(SlashCommand::Quit);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1496,7 +1538,7 @@ async fn slash_exit_requests_exit() {
|
|||
|
||||
chat.dispatch_command(SlashCommand::Exit);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
2
codex-rs/tui/tests/fixtures/oss-story.jsonl
vendored
2
codex-rs/tui/tests/fixtures/oss-story.jsonl
vendored
|
|
@ -8037,5 +8037,5 @@
|
|||
{"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"ExitRequest"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
use crate::app_backtrack::BacktrackState;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
|
|
@ -1645,9 +1646,12 @@ impl App {
|
|||
}
|
||||
self.chat_widget.handle_codex_event(event);
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
||||
}
|
||||
AppEvent::Exit(mode) => match mode {
|
||||
ExitMode::ShutdownFirst => self.chat_widget.submit_op(Op::Shutdown),
|
||||
ExitMode::Immediate => {
|
||||
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
|
||||
}
|
||||
},
|
||||
AppEvent::FatalExitRequest(message) => {
|
||||
return Ok(AppRunControl::Exit(ExitReason::Fatal(message)));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
//! Application-level events used to coordinate UI actions.
|
||||
//!
|
||||
//! `AppEvent` is the internal message bus between UI components and the top-level `App` loop.
|
||||
//! Widgets emit events to request actions that must be handled at the app layer (like opening
|
||||
//! pickers, persisting configuration, or shutting down the agent), without needing direct access to
|
||||
//! `App` internals.
|
||||
//!
|
||||
//! Exit is modelled explicitly via `AppEvent::Exit(ExitMode)` so callers can request shutdown-first
|
||||
//! quits without reaching into the app loop or coupling to shutdown/exit sequencing.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_common::approval_presets::ApprovalPreset;
|
||||
|
|
@ -40,8 +50,13 @@ pub(crate) enum AppEvent {
|
|||
/// Open the fork picker inside the running TUI session.
|
||||
OpenForkPicker,
|
||||
|
||||
/// Request to exit the application gracefully.
|
||||
ExitRequest,
|
||||
/// Request to exit the application.
|
||||
///
|
||||
/// Use `ShutdownFirst` for user-initiated quits so core cleanup runs and the
|
||||
/// UI exits only after `ShutdownComplete`. `Immediate` is a last-resort
|
||||
/// escape hatch that skips shutdown and may drop in-flight work (e.g.,
|
||||
/// background tasks, rollout flush, or child process cleanup).
|
||||
Exit(ExitMode),
|
||||
|
||||
/// Request to exit the application due to a fatal error.
|
||||
FatalExitRequest(String),
|
||||
|
|
@ -206,6 +221,22 @@ pub(crate) enum AppEvent {
|
|||
},
|
||||
}
|
||||
|
||||
/// The exit strategy requested by the UI layer.
|
||||
///
|
||||
/// Most user-initiated exits should use `ShutdownFirst` so core cleanup runs and the UI exits only
|
||||
/// after core acknowledges completion. `Immediate` is an escape hatch for cases where shutdown has
|
||||
/// already completed (or is being bypassed) and the UI loop should terminate right away.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ExitMode {
|
||||
/// Shutdown core and exit after completion.
|
||||
ShutdownFirst,
|
||||
/// Exit the UI loop immediately without waiting for shutdown.
|
||||
///
|
||||
/// This skips `Op::Shutdown`, so any in-flight work may be dropped and
|
||||
/// cleanup that normally runs before `ShutdownComplete` can be missed.
|
||||
Immediate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum FeedbackCategory {
|
||||
BadResult,
|
||||
|
|
|
|||
|
|
@ -170,7 +170,8 @@ pub(crate) struct ChatComposer {
|
|||
active_popup: ActivePopup,
|
||||
app_event_tx: AppEventSender,
|
||||
history: ChatComposerHistory,
|
||||
ctrl_c_quit_hint: bool,
|
||||
quit_shortcut_expires_at: Option<Instant>,
|
||||
quit_shortcut_key: KeyBinding,
|
||||
esc_backtrack_hint: bool,
|
||||
use_shift_enter_hint: bool,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
|
|
@ -229,7 +230,8 @@ impl ChatComposer {
|
|||
active_popup: ActivePopup::None,
|
||||
app_event_tx,
|
||||
history: ChatComposerHistory::new(),
|
||||
ctrl_c_quit_hint: false,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint,
|
||||
dismissed_file_popup_token: None,
|
||||
|
|
@ -511,16 +513,37 @@ impl ChatComposer {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn set_ctrl_c_quit_hint(&mut self, show: bool, has_focus: bool) {
|
||||
self.ctrl_c_quit_hint = show;
|
||||
if show {
|
||||
self.footer_mode = FooterMode::CtrlCReminder;
|
||||
} else {
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
}
|
||||
/// Show the transient "press again to quit" hint for `key`.
|
||||
///
|
||||
/// The owner (`BottomPane`/`ChatWidget`) is responsible for scheduling a
|
||||
/// redraw after [`super::QUIT_SHORTCUT_TIMEOUT`] so the hint can disappear
|
||||
/// even when the UI is otherwise idle.
|
||||
pub fn show_quit_shortcut_hint(&mut self, key: KeyBinding, has_focus: bool) {
|
||||
self.quit_shortcut_expires_at = Instant::now()
|
||||
.checked_add(super::QUIT_SHORTCUT_TIMEOUT)
|
||||
.or_else(|| Some(Instant::now()));
|
||||
self.quit_shortcut_key = key;
|
||||
self.footer_mode = FooterMode::QuitShortcutReminder;
|
||||
self.set_has_focus(has_focus);
|
||||
}
|
||||
|
||||
/// Clear the "press again to quit" hint immediately.
|
||||
pub fn clear_quit_shortcut_hint(&mut self, has_focus: bool) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.footer_mode = reset_mode_after_activity(self.footer_mode);
|
||||
self.set_has_focus(has_focus);
|
||||
}
|
||||
|
||||
/// Whether the quit shortcut hint should currently be shown.
|
||||
///
|
||||
/// This is time-based rather than event-based: it may become false without
|
||||
/// any additional user input, so the UI schedules a redraw when the hint
|
||||
/// expires.
|
||||
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
|
||||
self.quit_shortcut_expires_at
|
||||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||||
}
|
||||
|
||||
fn next_large_paste_placeholder(&mut self, char_count: usize) -> String {
|
||||
let base = format!("[Pasted Content {char_count} chars]");
|
||||
let next_suffix = self.large_paste_counters.entry(char_count).or_insert(0);
|
||||
|
|
@ -1431,10 +1454,7 @@ impl ChatComposer {
|
|||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if self.is_empty() => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
(InputResult::None, true)
|
||||
}
|
||||
} if self.is_empty() => (InputResult::None, false),
|
||||
// -------------------------------------------------------------
|
||||
// History navigation (Up / Down) – only when the composer is not
|
||||
// empty or when the cursor is at the correct position, to avoid
|
||||
|
|
@ -1741,7 +1761,7 @@ impl ChatComposer {
|
|||
return false;
|
||||
}
|
||||
|
||||
let next = toggle_shortcut_mode(self.footer_mode, self.ctrl_c_quit_hint);
|
||||
let next = toggle_shortcut_mode(self.footer_mode, self.quit_shortcut_hint_visible());
|
||||
let changed = next != self.footer_mode;
|
||||
self.footer_mode = next;
|
||||
changed
|
||||
|
|
@ -1753,6 +1773,7 @@ impl ChatComposer {
|
|||
esc_backtrack_hint: self.esc_backtrack_hint,
|
||||
use_shift_enter_hint: self.use_shift_enter_hint,
|
||||
is_task_running: self.is_task_running,
|
||||
quit_shortcut_key: self.quit_shortcut_key,
|
||||
steer_enabled: self.steer_enabled,
|
||||
context_window_percent: self.context_window_percent,
|
||||
context_window_used_tokens: self.context_window_used_tokens,
|
||||
|
|
@ -1768,8 +1789,13 @@ impl ChatComposer {
|
|||
match self.footer_mode {
|
||||
FooterMode::EscHint => FooterMode::EscHint,
|
||||
FooterMode::ShortcutOverlay => FooterMode::ShortcutOverlay,
|
||||
FooterMode::CtrlCReminder => FooterMode::CtrlCReminder,
|
||||
FooterMode::ShortcutSummary if self.ctrl_c_quit_hint => FooterMode::CtrlCReminder,
|
||||
FooterMode::QuitShortcutReminder if self.quit_shortcut_hint_visible() => {
|
||||
FooterMode::QuitShortcutReminder
|
||||
}
|
||||
FooterMode::QuitShortcutReminder => FooterMode::ShortcutSummary,
|
||||
FooterMode::ShortcutSummary if self.quit_shortcut_hint_visible() => {
|
||||
FooterMode::QuitShortcutReminder
|
||||
}
|
||||
FooterMode::ShortcutSummary if !self.is_empty() => FooterMode::ContextOnly,
|
||||
other => other,
|
||||
}
|
||||
|
|
@ -2341,16 +2367,16 @@ mod tests {
|
|||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_quit", true, |composer| {
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_interrupt", true, |composer| {
|
||||
composer.set_task_running(true);
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
});
|
||||
|
||||
snapshot_composer_state("footer_mode_ctrl_c_then_esc_hint", true, |composer| {
|
||||
composer.set_ctrl_c_quit_hint(true, true);
|
||||
composer.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')), true);
|
||||
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,13 @@
|
|||
//! The bottom-pane footer renders transient hints and context indicators.
|
||||
//!
|
||||
//! The footer is pure rendering: it formats `FooterProps` into `Line`s without mutating any state.
|
||||
//! It intentionally does not decide *which* footer content should be shown; that is owned by the
|
||||
//! `ChatComposer` (which selects a `FooterMode`) and by higher-level state machines like
|
||||
//! `ChatWidget` (which decides when quit/interrupt is allowed).
|
||||
//!
|
||||
//! Some footer content is time-based rather than event-based, such as the "press again to quit"
|
||||
//! hint. The owning widgets schedule redraws so time-based hints can expire even if the UI is
|
||||
//! otherwise idle.
|
||||
#[cfg(target_os = "linux")]
|
||||
use crate::clipboard_paste::is_probably_wsl;
|
||||
use crate::key_hint;
|
||||
|
|
@ -15,6 +25,12 @@ use ratatui::text::Span;
|
|||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
|
||||
/// The rendering inputs for the footer area under the composer.
|
||||
///
|
||||
/// Callers are expected to construct `FooterProps` from higher-level state (`ChatComposer`,
|
||||
/// `BottomPane`, and `ChatWidget`) and pass it to `render_footer`. The footer treats these values as
|
||||
/// authoritative and does not attempt to infer missing state (for example, it does not query
|
||||
/// whether a task is running).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct FooterProps {
|
||||
pub(crate) mode: FooterMode,
|
||||
|
|
@ -22,6 +38,10 @@ pub(crate) struct FooterProps {
|
|||
pub(crate) use_shift_enter_hint: bool,
|
||||
pub(crate) is_task_running: bool,
|
||||
pub(crate) steer_enabled: bool,
|
||||
/// Which key the user must press again to quit.
|
||||
///
|
||||
/// This is rendered when `mode` is `FooterMode::QuitShortcutReminder`.
|
||||
pub(crate) quit_shortcut_key: KeyBinding,
|
||||
pub(crate) context_window_percent: Option<i64>,
|
||||
pub(crate) context_window_used_tokens: Option<i64>,
|
||||
pub(crate) transcript_scrolled: bool,
|
||||
|
|
@ -31,9 +51,14 @@ pub(crate) struct FooterProps {
|
|||
pub(crate) transcript_copy_feedback: Option<TranscriptCopyFeedback>,
|
||||
}
|
||||
|
||||
/// Selects which footer content is rendered.
|
||||
///
|
||||
/// The current mode is owned by `ChatComposer`, which may override it based on transient state
|
||||
/// (for example, showing `QuitShortcutReminder` only while its timer is active).
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum FooterMode {
|
||||
CtrlCReminder,
|
||||
/// Transient "press again to quit" reminder (Ctrl+C/Ctrl+D).
|
||||
QuitShortcutReminder,
|
||||
ShortcutSummary,
|
||||
ShortcutOverlay,
|
||||
EscHint,
|
||||
|
|
@ -41,12 +66,14 @@ pub(crate) enum FooterMode {
|
|||
}
|
||||
|
||||
pub(crate) fn toggle_shortcut_mode(current: FooterMode, ctrl_c_hint: bool) -> FooterMode {
|
||||
if ctrl_c_hint && matches!(current, FooterMode::CtrlCReminder) {
|
||||
if ctrl_c_hint && matches!(current, FooterMode::QuitShortcutReminder) {
|
||||
return current;
|
||||
}
|
||||
|
||||
match current {
|
||||
FooterMode::ShortcutOverlay | FooterMode::CtrlCReminder => FooterMode::ShortcutSummary,
|
||||
FooterMode::ShortcutOverlay | FooterMode::QuitShortcutReminder => {
|
||||
FooterMode::ShortcutSummary
|
||||
}
|
||||
_ => FooterMode::ShortcutOverlay,
|
||||
}
|
||||
}
|
||||
|
|
@ -63,7 +90,7 @@ pub(crate) fn reset_mode_after_activity(current: FooterMode) -> FooterMode {
|
|||
match current {
|
||||
FooterMode::EscHint
|
||||
| FooterMode::ShortcutOverlay
|
||||
| FooterMode::CtrlCReminder
|
||||
| FooterMode::QuitShortcutReminder
|
||||
| FooterMode::ContextOnly => FooterMode::ShortcutSummary,
|
||||
other => other,
|
||||
}
|
||||
|
|
@ -103,9 +130,9 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
|||
// the shortcut hint is hidden). Hide it only for the multi-line
|
||||
// ShortcutOverlay.
|
||||
let mut lines = match props.mode {
|
||||
FooterMode::CtrlCReminder => vec![ctrl_c_reminder_line(CtrlCReminderState {
|
||||
is_task_running: props.is_task_running,
|
||||
})],
|
||||
FooterMode::QuitShortcutReminder => {
|
||||
vec![quit_shortcut_reminder_line(props.quit_shortcut_key)]
|
||||
}
|
||||
FooterMode::ShortcutSummary => {
|
||||
let mut line = context_window_line(
|
||||
props.context_window_percent,
|
||||
|
|
@ -170,11 +197,6 @@ fn footer_lines(props: FooterProps) -> Vec<Line<'static>> {
|
|||
lines
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct CtrlCReminderState {
|
||||
is_task_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct ShortcutsState {
|
||||
use_shift_enter_hint: bool,
|
||||
|
|
@ -182,17 +204,8 @@ struct ShortcutsState {
|
|||
is_wsl: bool,
|
||||
}
|
||||
|
||||
fn ctrl_c_reminder_line(state: CtrlCReminderState) -> Line<'static> {
|
||||
let action = if state.is_task_running {
|
||||
"interrupt"
|
||||
} else {
|
||||
"quit"
|
||||
};
|
||||
Line::from(vec![
|
||||
key_hint::ctrl(KeyCode::Char('c')).into(),
|
||||
format!(" again to {action}").into(),
|
||||
])
|
||||
.dim()
|
||||
fn quit_shortcut_reminder_line(key: KeyBinding) -> Line<'static> {
|
||||
Line::from(vec![key.into(), " again to quit".into()]).dim()
|
||||
}
|
||||
|
||||
fn esc_hint_line(esc_backtrack_hint: bool) -> Line<'static> {
|
||||
|
|
@ -518,6 +531,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -536,6 +550,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: true,
|
||||
|
|
@ -554,6 +569,7 @@ mod tests {
|
|||
use_shift_enter_hint: true,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -567,11 +583,12 @@ mod tests {
|
|||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_idle",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
mode: FooterMode::QuitShortcutReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -585,11 +602,12 @@ mod tests {
|
|||
snapshot_footer(
|
||||
"footer_ctrl_c_quit_running",
|
||||
FooterProps {
|
||||
mode: FooterMode::CtrlCReminder,
|
||||
mode: FooterMode::QuitShortcutReminder,
|
||||
esc_backtrack_hint: false,
|
||||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -608,6 +626,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -626,6 +645,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -644,6 +664,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: Some(72),
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -662,6 +683,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: Some(123_456),
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -680,6 +702,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -698,6 +721,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: true,
|
||||
steer_enabled: true,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
@ -716,6 +740,7 @@ mod tests {
|
|||
use_shift_enter_hint: false,
|
||||
is_task_running: false,
|
||||
steer_enabled: false,
|
||||
quit_shortcut_key: key_hint::ctrl(KeyCode::Char('c')),
|
||||
context_window_percent: None,
|
||||
context_window_used_tokens: None,
|
||||
transcript_scrolled: false,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,24 @@
|
|||
//! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active.
|
||||
//! The bottom pane is the interactive footer of the chat UI.
|
||||
//!
|
||||
//! The pane owns the [`ChatComposer`] (editable prompt input) and a stack of transient
|
||||
//! [`BottomPaneView`]s (popups/modals) that temporarily replace the composer for focused
|
||||
//! interactions like selection lists.
|
||||
//!
|
||||
//! Input routing is layered: `BottomPane` decides which local surface receives a key (view vs
|
||||
//! composer), while higher-level intent such as "interrupt" or "quit" is decided by the parent
|
||||
//! widget (`ChatWidget`). This split matters for Ctrl+C/Ctrl+D: the bottom pane gives the active
|
||||
//! view the first chance to consume Ctrl+C (typically to dismiss itself), and `ChatWidget` may
|
||||
//! treat an unhandled Ctrl+C as an interrupt or as the first press of a double-press quit
|
||||
//! shortcut.
|
||||
//!
|
||||
//! Some UI is time-based rather than input-based, such as the transient "press again to quit"
|
||||
//! hint. The pane schedules redraws so those hints can expire even when the UI is otherwise idle.
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::queued_user_messages::QueuedUserMessages;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::render::renderable::FlexRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableItem;
|
||||
|
|
@ -43,6 +59,20 @@ mod selection_popup_common;
|
|||
mod textarea;
|
||||
pub(crate) use feedback_view::FeedbackNoteView;
|
||||
|
||||
/// How long the "press again to quit" hint stays visible.
|
||||
///
|
||||
/// This is shared between:
|
||||
/// - `ChatWidget`: arming the double-press quit shortcut.
|
||||
/// - `BottomPane`/`ChatComposer`: rendering and expiring the footer hint.
|
||||
///
|
||||
/// Keeping a single value ensures Ctrl+C and Ctrl+D behave identically.
|
||||
pub(crate) const QUIT_SHORTCUT_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The result of offering a cancellation key to a bottom-pane surface.
|
||||
///
|
||||
/// This is primarily used for Ctrl+C routing: active views can consume the key to dismiss
|
||||
/// themselves, and the caller can decide what higher-level action (if any) to take when the key is
|
||||
/// not handled locally.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum CancellationEvent {
|
||||
Handled,
|
||||
|
|
@ -58,6 +88,10 @@ pub(crate) use list_selection_view::SelectionAction;
|
|||
pub(crate) use list_selection_view::SelectionItem;
|
||||
|
||||
/// Pane displayed in the lower half of the chat UI.
|
||||
///
|
||||
/// This is the owning container for the prompt input (`ChatComposer`) and the view stack
|
||||
/// (`BottomPaneView`). It performs local input routing and renders time-based hints, while leaving
|
||||
/// process-level decisions (quit, interrupt, shutdown) to `ChatWidget`.
|
||||
pub(crate) struct BottomPane {
|
||||
/// Composer is retained even when a BottomPaneView is displayed so the
|
||||
/// input state is retained when the view is closed.
|
||||
|
|
@ -71,7 +105,6 @@ pub(crate) struct BottomPane {
|
|||
|
||||
has_input_focus: bool,
|
||||
is_task_running: bool,
|
||||
ctrl_c_quit_hint: bool,
|
||||
esc_backtrack_hint: bool,
|
||||
animations_enabled: bool,
|
||||
|
||||
|
|
@ -122,7 +155,6 @@ impl BottomPane {
|
|||
frame_requester,
|
||||
has_input_focus,
|
||||
is_task_running: false,
|
||||
ctrl_c_quit_hint: false,
|
||||
status: None,
|
||||
queued_user_messages: QueuedUserMessages::new(),
|
||||
esc_backtrack_hint: false,
|
||||
|
|
@ -210,8 +242,14 @@ impl BottomPane {
|
|||
}
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
|
||||
/// chance to consume the event (e.g. to dismiss itself).
|
||||
/// Handles a Ctrl+C press within the bottom pane.
|
||||
///
|
||||
/// An active modal view is given the first chance to consume the key (typically to dismiss
|
||||
/// itself). If no view is active, Ctrl+C clears draft composer input.
|
||||
///
|
||||
/// This method may show the quit shortcut hint as a user-visible acknowledgement that Ctrl+C
|
||||
/// was received, but it does not decide whether the process should exit; `ChatWidget` owns the
|
||||
/// quit/interrupt state machine and uses the result to decide what happens next.
|
||||
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if let Some(view) = self.view_stack.last_mut() {
|
||||
let event = view.on_ctrl_c();
|
||||
|
|
@ -220,7 +258,7 @@ impl BottomPane {
|
|||
self.view_stack.pop();
|
||||
self.on_active_view_complete();
|
||||
}
|
||||
self.show_ctrl_c_quit_hint();
|
||||
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
|
||||
}
|
||||
event
|
||||
} else if self.composer_is_empty() {
|
||||
|
|
@ -228,7 +266,7 @@ impl BottomPane {
|
|||
} else {
|
||||
self.view_stack.pop();
|
||||
self.clear_composer_for_ctrl_c();
|
||||
self.show_ctrl_c_quit_hint();
|
||||
self.show_quit_shortcut_hint(key_hint::ctrl(KeyCode::Char('c')));
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
}
|
||||
|
|
@ -292,25 +330,41 @@ impl BottomPane {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
/// Show the transient "press again to quit" hint for `key`.
|
||||
///
|
||||
/// `ChatWidget` owns the quit shortcut state machine (it decides when quit is
|
||||
/// allowed), while the bottom pane owns rendering. We also schedule a redraw
|
||||
/// after [`QUIT_SHORTCUT_TIMEOUT`] so the hint disappears even if the user
|
||||
/// stops typing and no other events trigger a draw.
|
||||
pub(crate) fn show_quit_shortcut_hint(&mut self, key: KeyBinding) {
|
||||
self.composer
|
||||
.set_ctrl_c_quit_hint(true, self.has_input_focus);
|
||||
.show_quit_shortcut_hint(key, self.has_input_focus);
|
||||
let frame_requester = self.frame_requester.clone();
|
||||
if let Ok(handle) = tokio::runtime::Handle::try_current() {
|
||||
handle.spawn(async move {
|
||||
tokio::time::sleep(QUIT_SHORTCUT_TIMEOUT).await;
|
||||
frame_requester.schedule_frame();
|
||||
});
|
||||
} else {
|
||||
// In tests (and other non-Tokio contexts), fall back to a thread so
|
||||
// the hint can still expire without requiring an explicit draw.
|
||||
std::thread::spawn(move || {
|
||||
std::thread::sleep(QUIT_SHORTCUT_TIMEOUT);
|
||||
frame_requester.schedule_frame();
|
||||
});
|
||||
}
|
||||
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();
|
||||
}
|
||||
/// Clear the "press again to quit" hint immediately.
|
||||
pub(crate) fn clear_quit_shortcut_hint(&mut self) {
|
||||
self.composer.clear_quit_shortcut_hint(self.has_input_focus);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ctrl_c_quit_hint_visible(&self) -> bool {
|
||||
self.ctrl_c_quit_hint
|
||||
pub(crate) fn quit_shortcut_hint_visible(&self) -> bool {
|
||||
self.composer.quit_shortcut_hint_visible()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -450,6 +504,20 @@ impl BottomPane {
|
|||
!self.is_task_running && self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
}
|
||||
|
||||
/// Return true when no popups or modal views are active, regardless of task state.
|
||||
pub(crate) fn can_launch_external_editor(&self) -> bool {
|
||||
self.view_stack.is_empty() && !self.composer.popup_active()
|
||||
}
|
||||
|
||||
/// Returns true when the bottom pane has no active modal view and no active composer popup.
|
||||
///
|
||||
/// This is the UI-level definition of "no modal/popup is active" for key routing decisions.
|
||||
/// It intentionally does not include task state, since some actions are safe while a task is
|
||||
/// running and some are not.
|
||||
pub(crate) fn no_modal_or_popup_active(&self) -> bool {
|
||||
self.can_launch_external_editor()
|
||||
}
|
||||
|
||||
pub(crate) fn show_view(&mut self, view: Box<dyn BottomPaneView>) {
|
||||
self.push_view(view);
|
||||
}
|
||||
|
|
@ -629,7 +697,7 @@ mod tests {
|
|||
});
|
||||
pane.push_approval_request(exec_request(), &features);
|
||||
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
|
||||
assert!(pane.ctrl_c_quit_hint_visible());
|
||||
assert!(pane.quit_shortcut_hint_visible());
|
||||
assert_eq!(CancellationEvent::NotHandled, pane.on_ctrl_c());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,4 +10,4 @@ expression: terminal.backend()
|
|||
" "
|
||||
" "
|
||||
" "
|
||||
" ctrl + c again to interrupt "
|
||||
" ctrl + c again to quit "
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
source: tui2/src/bottom_pane/footer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" ctrl + c again to interrupt "
|
||||
" ctrl + c again to quit "
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ use std::collections::VecDeque;
|
|||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
|
|
@ -107,6 +108,7 @@ use tokio::task::JoinHandle;
|
|||
use tracing::debug;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
#[cfg(target_os = "windows")]
|
||||
use crate::app_event::WindowsSandboxEnableMode;
|
||||
use crate::app_event::WindowsSandboxFallbackReason;
|
||||
|
|
@ -116,6 +118,7 @@ use crate::bottom_pane::BottomPane;
|
|||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::QUIT_SHORTCUT_TIMEOUT;
|
||||
use crate::bottom_pane::SelectionAction;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::bottom_pane::SelectionViewParams;
|
||||
|
|
@ -132,6 +135,8 @@ use crate::history_cell::AgentMessageCell;
|
|||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::key_hint;
|
||||
use crate::key_hint::KeyBinding;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
|
|
@ -304,12 +309,18 @@ enum RateLimitSwitchPromptState {
|
|||
Shown,
|
||||
}
|
||||
|
||||
/// Maintains the per-session UI state for the chat screen.
|
||||
/// Maintains the per-session UI state and interaction state machines for the chat screen.
|
||||
///
|
||||
/// This type owns the state derived from a `codex_core::protocol` event stream (history cells,
|
||||
/// active streaming buffers, bottom-pane overlays, and transient status text). It is not
|
||||
/// responsible for running the agent itself; it only reflects progress by updating UI state and by
|
||||
/// sending `Op` requests back to codex-core.
|
||||
/// `ChatWidget` owns the state derived from the protocol event stream (history cells, streaming
|
||||
/// buffers, bottom-pane overlays, and transient status text) and turns key presses into user
|
||||
/// intent (`Op` submissions and `AppEvent` requests).
|
||||
///
|
||||
/// It is not responsible for running the agent itself; it reflects progress by updating UI state
|
||||
/// and by sending requests back to codex-core.
|
||||
///
|
||||
/// Quit/interrupt behavior intentionally spans layers: the bottom pane owns local input routing
|
||||
/// (which view gets Ctrl+C), while `ChatWidget` owns process-level decisions such as interrupting
|
||||
/// active work, arming the double-press quit shortcut, and requesting shutdown-first exit.
|
||||
pub(crate) struct ChatWidget {
|
||||
app_event_tx: AppEventSender,
|
||||
codex_op_tx: UnboundedSender<Op>,
|
||||
|
|
@ -375,6 +386,14 @@ pub(crate) struct ChatWidget {
|
|||
queued_user_messages: VecDeque<UserMessage>,
|
||||
// Pending notification to show when unfocused on next Draw
|
||||
pending_notification: Option<Notification>,
|
||||
/// When `Some`, the user has pressed a quit shortcut and the second press
|
||||
/// must occur before `quit_shortcut_expires_at`.
|
||||
quit_shortcut_expires_at: Option<Instant>,
|
||||
/// Tracks which quit shortcut key was pressed first.
|
||||
///
|
||||
/// We require the second press to match this key so `Ctrl+C` followed by
|
||||
/// `Ctrl+D` (or vice versa) doesn't quit accidentally.
|
||||
quit_shortcut_key: Option<KeyBinding>,
|
||||
// Simple review mode flag; used to adjust layout and banners.
|
||||
is_review_mode: bool,
|
||||
// Snapshot of token usage to restore after review mode exits.
|
||||
|
|
@ -610,7 +629,9 @@ impl ChatWidget {
|
|||
|
||||
fn on_task_started(&mut self) {
|
||||
self.agent_turn_running = true;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.update_task_running_state();
|
||||
self.retry_status_header = None;
|
||||
self.bottom_pane.set_interrupt_hint_visible(true);
|
||||
|
|
@ -1006,7 +1027,7 @@ impl ChatWidget {
|
|||
}
|
||||
|
||||
fn on_shutdown_complete(&mut self) {
|
||||
self.request_exit();
|
||||
self.request_immediate_exit();
|
||||
}
|
||||
|
||||
fn on_turn_diff(&mut self, unified_diff: String) {
|
||||
|
|
@ -1437,6 +1458,8 @@ impl ChatWidget {
|
|||
show_welcome_banner: is_first_run,
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
|
|
@ -1526,6 +1549,8 @@ impl ChatWidget {
|
|||
show_welcome_banner: false,
|
||||
suppress_session_configured_redraw: true,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
|
|
@ -1553,6 +1578,19 @@ impl ChatWidget {
|
|||
self.on_ctrl_c();
|
||||
return;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} if modifiers.contains(KeyModifiers::CONTROL) && c.eq_ignore_ascii_case(&'d') => {
|
||||
if self.on_ctrl_d() {
|
||||
return;
|
||||
}
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers,
|
||||
|
|
@ -1581,7 +1619,9 @@ impl ChatWidget {
|
|||
return;
|
||||
}
|
||||
other if other.kind == KeyEventKind::Press => {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
|
@ -1738,7 +1778,7 @@ impl ChatWidget {
|
|||
};
|
||||
}
|
||||
SlashCommand::Quit | SlashCommand::Exit => {
|
||||
self.request_exit();
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
SlashCommand::Logout => {
|
||||
if let Err(e) = codex_core::auth::logout(
|
||||
|
|
@ -1747,7 +1787,7 @@ impl ChatWidget {
|
|||
) {
|
||||
tracing::error!("failed to logout: {e}");
|
||||
}
|
||||
self.request_exit();
|
||||
self.request_quit_without_confirmation();
|
||||
}
|
||||
// SlashCommand::Undo => {
|
||||
// self.app_event_tx.send(AppEvent::CodexOp(Op::Undo));
|
||||
|
|
@ -2184,8 +2224,21 @@ impl ChatWidget {
|
|||
self.needs_final_message_separator = false;
|
||||
}
|
||||
|
||||
fn request_exit(&self) {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
/// Exit the UI immediately without waiting for shutdown.
|
||||
///
|
||||
/// Prefer [`Self::request_quit_without_confirmation`] for user-initiated exits;
|
||||
/// this is mainly a fallback for shutdown completion or emergency exits.
|
||||
fn request_immediate_exit(&self) {
|
||||
self.app_event_tx.send(AppEvent::Exit(ExitMode::Immediate));
|
||||
}
|
||||
|
||||
/// Request a shutdown-first quit.
|
||||
///
|
||||
/// This is used for explicit quit commands (`/quit`, `/exit`, `/logout`) and for
|
||||
/// the double-press Ctrl+C/Ctrl+D quit shortcut.
|
||||
fn request_quit_without_confirmation(&self) {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::Exit(ExitMode::ShutdownFirst));
|
||||
}
|
||||
|
||||
fn request_redraw(&mut self) {
|
||||
|
|
@ -3497,19 +3550,87 @@ impl ChatWidget {
|
|||
self.bottom_pane.on_file_search_result(query, matches);
|
||||
}
|
||||
|
||||
/// Handle Ctrl-C key press.
|
||||
/// Handles a Ctrl+C press at the chat-widget layer.
|
||||
///
|
||||
/// The first press arms a time-bounded quit shortcut and shows a footer hint via the bottom
|
||||
/// pane. If cancellable work is active, Ctrl+C also submits `Op::Interrupt` after the shortcut
|
||||
/// is armed.
|
||||
///
|
||||
/// If the same quit shortcut is pressed again before expiry, this requests a shutdown-first
|
||||
/// quit.
|
||||
fn on_ctrl_c(&mut self) {
|
||||
let key = key_hint::ctrl(KeyCode::Char('c'));
|
||||
let modal_or_popup_active = !self.bottom_pane.no_modal_or_popup_active();
|
||||
if self.bottom_pane.on_ctrl_c() == CancellationEvent::Handled {
|
||||
if modal_or_popup_active {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.bottom_pane.clear_quit_shortcut_hint();
|
||||
} else {
|
||||
self.arm_quit_shortcut(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.bottom_pane.show_ctrl_c_quit_hint();
|
||||
if self.quit_shortcut_active_for(key) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_quit_without_confirmation();
|
||||
return;
|
||||
}
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
|
||||
if self.is_cancellable_work_active() {
|
||||
self.submit_op(Op::Interrupt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles a Ctrl+D press at the chat-widget layer.
|
||||
///
|
||||
/// Ctrl-D only participates in quit when the composer is empty and no modal/popup is active.
|
||||
/// Otherwise it should be routed to the active view and not attempt to quit.
|
||||
fn on_ctrl_d(&mut self) -> bool {
|
||||
let key = key_hint::ctrl(KeyCode::Char('d'));
|
||||
if self.quit_shortcut_active_for(key) {
|
||||
self.quit_shortcut_expires_at = None;
|
||||
self.quit_shortcut_key = None;
|
||||
self.request_quit_without_confirmation();
|
||||
return true;
|
||||
}
|
||||
|
||||
self.submit_op(Op::Shutdown);
|
||||
if !self.bottom_pane.composer_is_empty() || !self.bottom_pane.no_modal_or_popup_active() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.arm_quit_shortcut(key);
|
||||
true
|
||||
}
|
||||
|
||||
/// True if `key` matches the armed quit shortcut and the window has not expired.
|
||||
fn quit_shortcut_active_for(&self, key: KeyBinding) -> bool {
|
||||
self.quit_shortcut_key == Some(key)
|
||||
&& self
|
||||
.quit_shortcut_expires_at
|
||||
.is_some_and(|expires_at| Instant::now() < expires_at)
|
||||
}
|
||||
|
||||
/// Arm the double-press quit shortcut and show the footer hint.
|
||||
///
|
||||
/// This keeps the state machine (`quit_shortcut_*`) in `ChatWidget`, since
|
||||
/// it is the component that interprets Ctrl+C vs Ctrl+D and decides whether
|
||||
/// quitting is currently allowed, while delegating rendering to `BottomPane`.
|
||||
fn arm_quit_shortcut(&mut self, key: KeyBinding) {
|
||||
self.quit_shortcut_expires_at = Instant::now()
|
||||
.checked_add(QUIT_SHORTCUT_TIMEOUT)
|
||||
.or_else(|| Some(Instant::now()));
|
||||
self.quit_shortcut_key = Some(key);
|
||||
self.bottom_pane.show_quit_shortcut_hint(key);
|
||||
}
|
||||
|
||||
// Review mode counts as cancellable work so Ctrl+C interrupts instead of quitting.
|
||||
fn is_cancellable_work_active(&self) -> bool {
|
||||
self.bottom_pane.is_task_running() || self.is_review_mode
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub(crate) fn spawn_agent(
|
|||
msg: EventMsg::Error(err.to_error_event(None)),
|
||||
}));
|
||||
app_event_tx_clone.send(AppEvent::FatalExitRequest(message));
|
||||
tracing::error!("failed to initialize codex: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event::ExitMode;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crate::tui::FrameRequester;
|
||||
|
|
@ -417,6 +418,8 @@ async fn make_chatwidget_manual(
|
|||
queued_user_messages: VecDeque::new(),
|
||||
suppress_session_configured_redraw: false,
|
||||
pending_notification: None,
|
||||
quit_shortcut_expires_at: None,
|
||||
quit_shortcut_key: None,
|
||||
is_review_mode: false,
|
||||
pre_review_token_info: None,
|
||||
needs_final_message_separator: false,
|
||||
|
|
@ -1054,19 +1057,58 @@ async fn streaming_final_answer_keeps_task_running_state() {
|
|||
Ok(Op::Interrupt) => {}
|
||||
other => panic!("expected Op::Interrupt, got {other:?}"),
|
||||
}
|
||||
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
|
||||
assert!(chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_c_shutdown_ignores_caps_lock() {
|
||||
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await;
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL));
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Ok(Op::Shutdown) => {}
|
||||
other => panic!("expected Op::Shutdown, got {other:?}"),
|
||||
}
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_d_double_press_quits_without_prompt() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
|
||||
let width = 100;
|
||||
let height = chat.desired_height(width);
|
||||
let mut terminal =
|
||||
ratatui::Terminal::new(VT100Backend::new(width, height)).expect("create terminal");
|
||||
terminal.set_viewport_area(Rect::new(0, 0, width, height));
|
||||
terminal
|
||||
.draw(|f| chat.render(f.area(), f.buffer_mut()))
|
||||
.expect("draw after ctrl+d");
|
||||
assert!(
|
||||
terminal
|
||||
.backend()
|
||||
.vt100()
|
||||
.screen()
|
||||
.contents()
|
||||
.contains("ctrl + d again to quit")
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ctrl_d_with_modal_open_does_not_quit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
|
||||
|
||||
chat.open_approvals_popup();
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL));
|
||||
|
||||
assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1085,7 +1127,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
|||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
|
||||
assert!(chat.bottom_pane.composer_text().is_empty());
|
||||
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
|
||||
assert!(chat.bottom_pane.ctrl_c_quit_hint_visible());
|
||||
assert!(chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
|
||||
let restored_text = chat.bottom_pane.composer_text();
|
||||
|
|
@ -1094,7 +1136,7 @@ async fn ctrl_c_cleared_prompt_is_recoverable_via_history() {
|
|||
"expected placeholder {placeholder:?} after history recall"
|
||||
);
|
||||
assert!(restored_text.starts_with("draft message "));
|
||||
assert!(!chat.bottom_pane.ctrl_c_quit_hint_visible());
|
||||
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
|
||||
|
||||
let images = chat.bottom_pane.take_recent_submission_images();
|
||||
assert!(
|
||||
|
|
@ -1291,7 +1333,7 @@ async fn slash_quit_requests_exit() {
|
|||
|
||||
chat.dispatch_command(SlashCommand::Quit);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
@ -1300,7 +1342,7 @@ async fn slash_exit_requests_exit() {
|
|||
|
||||
chat.dispatch_command(SlashCommand::Exit);
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::ShutdownFirst)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
2
codex-rs/tui2/tests/fixtures/oss-story.jsonl
vendored
2
codex-rs/tui2/tests/fixtures/oss-story.jsonl
vendored
|
|
@ -8037,5 +8037,5 @@
|
|||
{"ts":"2025-08-10T03:48:49.926Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Shutting down Codex instance"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"log_line","line":"[INFO codex_core::codex] Aborting existing session"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"codex_event","payload":{"id":"7","msg":{"type":"shutdown_complete"}}}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"ExitRequest"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"to_tui","kind":"app_event","variant":"Exit"}
|
||||
{"ts":"2025-08-10T03:48:49.927Z","dir":"meta","kind":"session_end"}
|
||||
|
|
|
|||
|
|
@ -21,3 +21,9 @@ Codex can run a notification hook when the agent finishes a turn. See the config
|
|||
## JSON Schema
|
||||
|
||||
The generated JSON Schema for `config.toml` lives at `codex-rs/core/config.schema.json`.
|
||||
|
||||
## Notices
|
||||
|
||||
Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.
|
||||
|
||||
Ctrl+C/Ctrl+D quitting uses a ~1 second double-press hint (`ctrl + c again to quit`).
|
||||
|
|
|
|||
96
docs/exit-confirmation-prompt-design.md
Normal file
96
docs/exit-confirmation-prompt-design.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# Exit and shutdown flow (tui + tui2)
|
||||
|
||||
This document describes how exit, shutdown, and interruption work in the Rust TUIs (`codex-rs/tui`
|
||||
and `codex-rs/tui2`). It is intended for Codex developers and Codex itself when reasoning about
|
||||
future exit/shutdown changes.
|
||||
|
||||
This doc replaces earlier separate history and design notes. High-level history is summarized
|
||||
below; full details are captured in PR #8936.
|
||||
|
||||
## Terms
|
||||
|
||||
- **Exit**: end the UI event loop and terminate the process.
|
||||
- **Shutdown**: request a graceful agent/core shutdown (`Op::Shutdown`) and wait for
|
||||
`ShutdownComplete` so cleanup can run.
|
||||
- **Interrupt**: cancel a running operation (`Op::Interrupt`).
|
||||
|
||||
## Event model (AppEvent)
|
||||
|
||||
Exit is coordinated via a single event with explicit modes:
|
||||
|
||||
- `AppEvent::Exit(ExitMode::ShutdownFirst)`
|
||||
- Prefer this for user-initiated quits so cleanup runs.
|
||||
- `AppEvent::Exit(ExitMode::Immediate)`
|
||||
- Escape hatch for immediate exit. This bypasses shutdown and can drop
|
||||
in-flight work (e.g., tasks, rollout flush, child process cleanup).
|
||||
|
||||
`App` is the coordinator: it submits `Op::Shutdown` and it exits the UI loop only when
|
||||
`ExitMode::Immediate` arrives (typically after `ShutdownComplete`).
|
||||
|
||||
## User-triggered quit flows
|
||||
|
||||
### Ctrl+C
|
||||
|
||||
Priority order in the UI layer:
|
||||
|
||||
1. Active modal/view gets the first chance to consume (`BottomPane::on_ctrl_c`).
|
||||
- If the modal handles it, the quit flow stops.
|
||||
- When a modal/popup handles Ctrl+C, the quit shortcut is cleared so dismissing a modal cannot
|
||||
accidentally prime a subsequent Ctrl+C to quit.
|
||||
2. If the user has already armed Ctrl+C and the 1 second window has not expired, the second Ctrl+C
|
||||
triggers shutdown-first quit immediately.
|
||||
3. Otherwise, `ChatWidget` arms Ctrl+C and shows the quit hint (`ctrl + c again to quit`) for
|
||||
1 second.
|
||||
4. If cancellable work is active (streaming/tools/review), `ChatWidget` submits `Op::Interrupt`.
|
||||
|
||||
### Ctrl+D
|
||||
|
||||
- Only participates in quit when the composer is empty **and** no modal is active.
|
||||
- On first press, show the quit hint (same as Ctrl+C) and start the 1 second timer.
|
||||
- If pressed again while the hint is visible, request shutdown-first quit.
|
||||
- With any modal/popup open, key events are routed to the view and Ctrl+D does not attempt to
|
||||
quit.
|
||||
|
||||
### Slash commands
|
||||
|
||||
- `/quit`, `/exit`, `/logout` request shutdown-first quit **without** a prompt,
|
||||
because slash commands are harder to trigger accidentally and imply clear intent to quit.
|
||||
|
||||
### /new
|
||||
|
||||
- Uses shutdown without exit (suppresses `ShutdownComplete`) so the app can
|
||||
start a fresh session without terminating.
|
||||
|
||||
## Shutdown completion and suppression
|
||||
|
||||
`ShutdownComplete` is the signal that core cleanup has finished. The UI treats it as the boundary
|
||||
for exit:
|
||||
|
||||
- `ChatWidget` requests `Exit(Immediate)` on `ShutdownComplete`.
|
||||
- `App` can suppress a single `ShutdownComplete` when shutdown is used as a
|
||||
cleanup step (e.g., `/new`).
|
||||
|
||||
## Edge cases and invariants
|
||||
|
||||
- **Review mode** counts as cancellable work. Ctrl+C should interrupt review, not
|
||||
quit.
|
||||
- **Modal open** means Ctrl+C/Ctrl+D should not quit unless the modal explicitly
|
||||
declines to handle Ctrl+C.
|
||||
- **Immediate exit** is not a normal user path; it is a fallback for shutdown
|
||||
completion or an emergency exit. Use it sparingly because it skips cleanup.
|
||||
|
||||
## Testing expectations
|
||||
|
||||
At a minimum, we want coverage for:
|
||||
|
||||
- Ctrl+C while working interrupts, does not quit.
|
||||
- Ctrl+C while idle and empty shows quit hint, then shutdown-first quit on second press.
|
||||
- Ctrl+D with modal open does not quit.
|
||||
- `/quit` / `/exit` / `/logout` quit without prompt, but still shutdown-first.
|
||||
- Ctrl+D while idle and empty shows quit hint, then shutdown-first quit on second press.
|
||||
|
||||
## History (high level)
|
||||
|
||||
Codex has historically mixed "exit immediately" and "shutdown-first" across quit gestures, largely
|
||||
due to incremental changes and regressions in state tracking. This doc reflects the current
|
||||
unified, shutdown-first approach. See PR #8936 for the detailed history and rationale.
|
||||
Loading…
Add table
Reference in a new issue