Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/core/src/config/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
10 changes: 7 additions & 3 deletions codex-rs/tui/src/app.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand Down Expand Up @@ -855,9 +856,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)));
}
Expand Down
35 changes: 33 additions & 2 deletions codex-rs/tui/src/app_event.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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,
Expand Down
67 changes: 47 additions & 20 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,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;
Expand Down Expand Up @@ -167,7 +168,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>,
Expand Down Expand Up @@ -221,7 +223,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,
Expand Down Expand Up @@ -566,16 +569,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);
Expand Down Expand Up @@ -1475,10 +1499,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
Expand Down Expand Up @@ -1767,7 +1788,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
Expand All @@ -1779,6 +1800,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,
Expand All @@ -1789,8 +1811,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,
}
Expand Down Expand Up @@ -2331,16 +2358,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));
});

Expand Down
Loading
Loading