tui: exit session on Ctrl+C in cwd change prompt (#12040)

## Summary
- change the cwd-change prompt (shown when resuming/forking across
different directories) so `Ctrl+C`/`Ctrl+D` exits the session instead of
implicitly selecting "Use session directory"
- introduce explicit prompt and resolver exit outcomes so this intent is
propagated cleanly through both startup resume/fork and in-app `/resume`
flows
- add a unit test that verifies `Ctrl+C` exits rather than selecting an
option

## Why
Previously, pressing `Ctrl+C` on this prompt silently picked one of the
options, which made it hard to abort. This aligns the prompt with the
expected quit behavior.

## Codex author
`codex resume 019c6d39-bbfb-7dc3-8008-1388a054e86d`
This commit is contained in:
Charley Cunningham 2026-02-17 14:48:12 -08:00 committed by GitHub
parent c4bb7db159
commit 709e2133bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 67 additions and 15 deletions

View file

@ -1452,8 +1452,11 @@ impl App {
)
.await?
{
Some(cwd) => cwd,
None => current_cwd.clone(),
crate::ResolveCwdOutcome::Continue(Some(cwd)) => cwd,
crate::ResolveCwdOutcome::Continue(None) => current_cwd.clone(),
crate::ResolveCwdOutcome::Exit => {
return Ok(AppRunControl::Exit(ExitReason::UserRequested));
}
};
let mut resume_config = if crate::cwds_differ(&current_cwd, &resume_cwd) {
match self.rebuild_config_for_cwd(resume_cwd).await {

View file

@ -51,6 +51,12 @@ pub(crate) enum CwdSelection {
Session,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum CwdPromptOutcome {
Selection(CwdSelection),
Exit,
}
impl CwdSelection {
fn next(self) -> Self {
match self {
@ -72,7 +78,7 @@ pub(crate) async fn run_cwd_selection_prompt(
action: CwdPromptAction,
current_cwd: &Path,
session_cwd: &Path,
) -> Result<CwdSelection> {
) -> Result<CwdPromptOutcome> {
let mut screen = CwdPromptScreen::new(
tui.frame_requester(),
action,
@ -102,7 +108,13 @@ pub(crate) async fn run_cwd_selection_prompt(
}
}
Ok(screen.selection().unwrap_or(CwdSelection::Session))
if screen.should_exit {
Ok(CwdPromptOutcome::Exit)
} else {
Ok(CwdPromptOutcome::Selection(
screen.selection().unwrap_or(CwdSelection::Session),
))
}
}
struct CwdPromptScreen {
@ -112,6 +124,7 @@ struct CwdPromptScreen {
session_cwd: String,
highlighted: CwdSelection,
selection: Option<CwdSelection>,
should_exit: bool,
}
impl CwdPromptScreen {
@ -128,6 +141,7 @@ impl CwdPromptScreen {
session_cwd,
highlighted: CwdSelection::Session,
selection: None,
should_exit: false,
}
}
@ -138,7 +152,9 @@ impl CwdPromptScreen {
if key_event.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
{
self.select(CwdSelection::Session);
self.selection = None;
self.should_exit = true;
self.request_frame.schedule_frame();
return;
}
match key_event.code {
@ -166,7 +182,7 @@ impl CwdPromptScreen {
}
fn is_done(&self) -> bool {
self.selection.is_some()
self.should_exit || self.selection.is_some()
}
fn selection(&self) -> Option<CwdSelection> {
@ -283,4 +299,12 @@ mod tests {
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(screen.selection(), Some(CwdSelection::Current));
}
#[test]
fn cwd_prompt_ctrl_c_exits_instead_of_selecting() {
let mut screen = new_prompt();
screen.handle_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
assert_eq!(screen.selection(), None);
assert!(screen.is_done());
}
}

View file

@ -44,6 +44,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_oss::ensure_oss_provider_ready;
use codex_utils_oss::get_default_model_for_oss_provider;
use cwd_prompt::CwdPromptAction;
use cwd_prompt::CwdPromptOutcome;
use cwd_prompt::CwdSelection;
use std::fs::OpenOptions;
use std::path::Path;
@ -672,8 +673,22 @@ async fn run_ratatui_app(
};
let fallback_cwd = match action_and_path_if_resume_or_fork {
Some((action, path)) => {
resolve_cwd_for_resume_or_fork(&mut tui, &current_cwd, path, action, allow_prompt)
match resolve_cwd_for_resume_or_fork(&mut tui, &current_cwd, path, action, allow_prompt)
.await?
{
ResolveCwdOutcome::Continue(cwd) => cwd,
ResolveCwdOutcome::Exit => {
restore();
session_log::log_session_end();
return Ok(AppExitInfo {
token_usage: codex_core::protocol::TokenUsage::default(),
thread_id: None,
thread_name: None,
update_action: None,
exit_reason: ExitReason::UserRequested,
});
}
}
}
None => None,
};
@ -780,25 +795,35 @@ pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
}
}
pub(crate) enum ResolveCwdOutcome {
Continue(Option<PathBuf>),
Exit,
}
pub(crate) async fn resolve_cwd_for_resume_or_fork(
tui: &mut Tui,
current_cwd: &Path,
path: &Path,
action: CwdPromptAction,
allow_prompt: bool,
) -> color_eyre::Result<Option<PathBuf>> {
) -> color_eyre::Result<ResolveCwdOutcome> {
let Some(history_cwd) = read_session_cwd(path).await else {
return Ok(None);
return Ok(ResolveCwdOutcome::Continue(None));
};
if allow_prompt && cwds_differ(current_cwd, &history_cwd) {
let selection =
let selection_outcome =
cwd_prompt::run_cwd_selection_prompt(tui, action, current_cwd, &history_cwd).await?;
return Ok(Some(match selection {
CwdSelection::Current => current_cwd.to_path_buf(),
CwdSelection::Session => history_cwd,
}));
return Ok(match selection_outcome {
CwdPromptOutcome::Selection(CwdSelection::Current) => {
ResolveCwdOutcome::Continue(Some(current_cwd.to_path_buf()))
}
CwdPromptOutcome::Selection(CwdSelection::Session) => {
ResolveCwdOutcome::Continue(Some(history_cwd))
}
CwdPromptOutcome::Exit => ResolveCwdOutcome::Exit,
});
}
Ok(Some(history_cwd))
Ok(ResolveCwdOutcome::Continue(Some(history_cwd)))
}
#[expect(