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:
Josh McKinney authored and GitHub committed 2026-01-14 17:42:52 +00:00
1 parent 92472e7baa
commit 4283a7432b
25 files changed
+932 -199

No files matched your search

+47 -20
View File
@@ -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));
});
+48 -25
View File
@@ -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,
},
+83 -20
View File
@@ -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 "