mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
Ask for cwd choice when resuming session from different cwd (#9731)
# Summary - Fix resume/fork config rebuild so cwd changes inside the TUI produce a fully rebuilt Config (trust/approval/sandbox) instead of mutating only the cwd. - Preserve `--add-dir` behavior across resume/fork by normalizing relative roots to absolute paths once (based on the original cwd). - Prefer latest `TurnContext.cwd` for resume/fork prompts but fall back to `SessionMeta.cwd` if the latest cwd no longer exists. - Align resume/fork selection handling and ensure UI config matches the resumed thread config. - Fix Windows test TOML path escaping in trust-level test. # Details - Rebuild Config via `ConfigBuilder` when resuming into a different cwd; carry forward runtime approval/sandbox overrides. - Add `normalize_harness_overrides_for_cwd` to resolve relative `additional_writable_roots` against the initial cwd before reuse. - Guard `read_session_cwd` with filesystem existence check for the latest `TurnContext.cwd`. - Update naming/flow around cwd comparison and prompt selection. <img width="603" height="150" alt="Screenshot 2026-01-23 at 5 42 13 PM" src="https://github.com/user-attachments/assets/d1897386-bb28-4e8a-98cf-187fdebbecb0" /> And proof the model understands the new cwd: <img width="828" height="353" alt="Screenshot 2026-01-22 at 5 36 45 PM" src="https://github.com/user-attachments/assets/12aed8ca-dec3-4b64-8dae-c6b8cff78387" />
This commit is contained in:
committed by
GitHub
parent
182000999c
commit
18acec09df
@@ -12,6 +12,7 @@ use crate::bottom_pane::SelectionViewParams;
|
||||
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::chatwidget::ExternalEditorState;
|
||||
use crate::cwd_prompt::CwdPromptAction;
|
||||
use crate::diff_render::DiffSummary;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::external_editor;
|
||||
@@ -36,6 +37,8 @@ use codex_core::AuthManager;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::edit::ConfigEdit;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::config_loader::ConfigLayerStackOrdering;
|
||||
@@ -44,12 +47,14 @@ use codex_core::features::Feature;
|
||||
use codex_core::models_manager::manager::RefreshStrategy;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::models_manager::model_presets::HIDE_GPT5_1_MIGRATION_PROMPT_CONFIG;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::DeprecationNoticeEvent;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::FinalOutput;
|
||||
use codex_core::protocol::ListSkillsResponseEvent;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::SkillErrorInfo;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -60,6 +65,7 @@ use codex_protocol::openai_models::ModelPreset;
|
||||
use codex_protocol::openai_models::ModelUpgrade;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::SessionConfiguredEvent;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -87,6 +93,7 @@ use tokio::sync::broadcast;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue.";
|
||||
const THREAD_EVENT_CHANNEL_CAPACITY: usize = 1024;
|
||||
@@ -498,6 +505,10 @@ pub(crate) struct App {
|
||||
/// Config is stored here so we can recreate ChatWidgets as needed.
|
||||
pub(crate) config: Config,
|
||||
pub(crate) active_profile: Option<String>,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
runtime_approval_policy_override: Option<AskForApproval>,
|
||||
runtime_sandbox_policy_override: Option<SandboxPolicy>,
|
||||
|
||||
pub(crate) file_search: FileSearchManager,
|
||||
|
||||
@@ -545,6 +556,23 @@ struct WindowsSandboxState {
|
||||
skip_world_writable_scan_once: bool,
|
||||
}
|
||||
|
||||
fn normalize_harness_overrides_for_cwd(
|
||||
mut overrides: ConfigOverrides,
|
||||
base_cwd: &Path,
|
||||
) -> Result<ConfigOverrides> {
|
||||
if overrides.additional_writable_roots.is_empty() {
|
||||
return Ok(overrides);
|
||||
}
|
||||
|
||||
let mut normalized = Vec::with_capacity(overrides.additional_writable_roots.len());
|
||||
for root in overrides.additional_writable_roots.drain(..) {
|
||||
let absolute = AbsolutePathBuf::resolve_path_against_base(root, base_cwd)?;
|
||||
normalized.push(absolute.into_path_buf());
|
||||
}
|
||||
overrides.additional_writable_roots = normalized;
|
||||
Ok(overrides)
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn chatwidget_init_for_forked_or_resumed_thread(
|
||||
&self,
|
||||
@@ -567,6 +595,38 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result<Config> {
|
||||
let mut overrides = self.harness_overrides.clone();
|
||||
overrides.cwd = Some(cwd.clone());
|
||||
let cwd_display = cwd.display().to_string();
|
||||
ConfigBuilder::default()
|
||||
.codex_home(self.config.codex_home.clone())
|
||||
.cli_overrides(self.cli_kv_overrides.clone())
|
||||
.harness_overrides(overrides)
|
||||
.build()
|
||||
.await
|
||||
.wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}"))
|
||||
}
|
||||
|
||||
fn apply_runtime_policy_overrides(&mut self, config: &mut Config) {
|
||||
if let Some(policy) = self.runtime_approval_policy_override.as_ref()
|
||||
&& let Err(err) = config.approval_policy.set(*policy)
|
||||
{
|
||||
tracing::warn!(%err, "failed to carry forward approval policy override");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to carry forward approval policy override: {err}"
|
||||
));
|
||||
}
|
||||
if let Some(policy) = self.runtime_sandbox_policy_override.as_ref()
|
||||
&& let Err(err) = config.sandbox_policy.set(policy.clone())
|
||||
{
|
||||
tracing::warn!(%err, "failed to carry forward sandbox policy override");
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to carry forward sandbox policy override: {err}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_current_thread(&mut self) {
|
||||
if let Some(thread_id) = self.chat_widget.thread_id() {
|
||||
// Clear any in-flight rollback guard when switching threads.
|
||||
@@ -824,6 +884,8 @@ impl App {
|
||||
tui: &mut tui::Tui,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
mut config: Config,
|
||||
cli_kv_overrides: Vec<(String, TomlValue)>,
|
||||
harness_overrides: ConfigOverrides,
|
||||
active_profile: Option<String>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<PathBuf>,
|
||||
@@ -838,6 +900,8 @@ impl App {
|
||||
emit_deprecation_notice(&app_event_tx, ollama_chat_support_notice);
|
||||
emit_project_config_warnings(&app_event_tx, &config);
|
||||
|
||||
let harness_overrides =
|
||||
normalize_harness_overrides_for_cwd(harness_overrides, &config.cwd)?;
|
||||
let thread_manager = Arc::new(ThreadManager::new(
|
||||
config.codex_home.clone(),
|
||||
auth_manager.clone(),
|
||||
@@ -979,6 +1043,10 @@ impl App {
|
||||
auth_manager: auth_manager.clone(),
|
||||
config,
|
||||
active_profile,
|
||||
cli_kv_overrides,
|
||||
harness_overrides,
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
enhanced_keys_supported,
|
||||
transcript_cells: Vec::new(),
|
||||
@@ -1203,6 +1271,34 @@ impl App {
|
||||
.await?
|
||||
{
|
||||
SessionSelection::Resume(path) => {
|
||||
let current_cwd = self.config.cwd.clone();
|
||||
let resume_cwd = match crate::resolve_cwd_for_resume_or_fork(
|
||||
tui,
|
||||
¤t_cwd,
|
||||
&path,
|
||||
CwdPromptAction::Resume,
|
||||
true,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Some(cwd) => cwd,
|
||||
None => current_cwd.clone(),
|
||||
};
|
||||
let mut resume_config = if crate::cwds_differ(¤t_cwd, &resume_cwd) {
|
||||
match self.rebuild_config_for_cwd(resume_cwd).await {
|
||||
Ok(cfg) => cfg,
|
||||
Err(err) => {
|
||||
self.chat_widget.add_error_message(format!(
|
||||
"Failed to rebuild configuration for resume: {err}"
|
||||
));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No rebuild needed: current_cwd comes from self.config.cwd.
|
||||
self.config.clone()
|
||||
};
|
||||
self.apply_runtime_policy_overrides(&mut resume_config);
|
||||
let summary = session_summary(
|
||||
self.chat_widget.token_usage(),
|
||||
self.chat_widget.thread_id(),
|
||||
@@ -1210,7 +1306,7 @@ impl App {
|
||||
match self
|
||||
.server
|
||||
.resume_thread_from_rollout(
|
||||
self.config.clone(),
|
||||
resume_config.clone(),
|
||||
path.clone(),
|
||||
self.auth_manager.clone(),
|
||||
)
|
||||
@@ -1218,6 +1314,11 @@ impl App {
|
||||
{
|
||||
Ok(resumed) => {
|
||||
self.shutdown_current_thread().await;
|
||||
self.config = resume_config;
|
||||
self.file_search = FileSearchManager::new(
|
||||
self.config.cwd.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
let init = self.chatwidget_init_for_forked_or_resumed_thread(
|
||||
tui,
|
||||
self.config.clone(),
|
||||
@@ -1660,6 +1761,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
AppEvent::UpdateAskForApprovalPolicy(policy) => {
|
||||
self.runtime_approval_policy_override = Some(policy);
|
||||
if let Err(err) = self.config.approval_policy.set(policy) {
|
||||
tracing::warn!(%err, "failed to set approval policy on app config");
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to set approval policy: {err}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.chat_widget.set_approval_policy(policy);
|
||||
}
|
||||
AppEvent::UpdateSandboxPolicy(policy) => {
|
||||
@@ -1688,6 +1796,8 @@ impl App {
|
||||
.add_error_message(format!("Failed to set sandbox policy: {err}"));
|
||||
return Ok(AppRunControl::Continue);
|
||||
}
|
||||
self.runtime_sandbox_policy_override =
|
||||
Some(self.config.sandbox_policy.get().clone());
|
||||
|
||||
// If sandbox policy becomes workspace-write or read-only, run the Windows world-writable scan.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -2236,6 +2346,7 @@ mod tests {
|
||||
use codex_core::CodexAuth;
|
||||
use codex_core::ThreadManager;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::models_manager::manager::ModelsManager;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
@@ -2254,6 +2365,25 @@ mod tests {
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn normalize_harness_overrides_resolves_relative_add_dirs() -> Result<()> {
|
||||
let temp_dir = tempdir()?;
|
||||
let base_cwd = temp_dir.path().join("base");
|
||||
std::fs::create_dir_all(&base_cwd)?;
|
||||
|
||||
let overrides = ConfigOverrides {
|
||||
additional_writable_roots: vec![PathBuf::from("rel")],
|
||||
..Default::default()
|
||||
};
|
||||
let normalized = normalize_harness_overrides_for_cwd(overrides, &base_cwd)?;
|
||||
|
||||
assert_eq!(
|
||||
normalized.additional_writable_roots,
|
||||
vec![base_cwd.join("rel")]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn make_test_app() -> App {
|
||||
let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await;
|
||||
let config = chat_widget.config_ref().clone();
|
||||
@@ -2275,6 +2405,10 @@ mod tests {
|
||||
auth_manager,
|
||||
config,
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
@@ -2323,6 +2457,10 @@ mod tests {
|
||||
auth_manager,
|
||||
config,
|
||||
active_profile: None,
|
||||
cli_kv_overrides: Vec::new(),
|
||||
harness_overrides: ConfigOverrides::default(),
|
||||
runtime_approval_policy_override: None,
|
||||
runtime_sandbox_policy_override: None,
|
||||
file_search,
|
||||
transcript_cells: Vec::new(),
|
||||
overlay: None,
|
||||
|
||||
286
codex-rs/tui/src/cwd_prompt.rs
Normal file
286
codex-rs/tui/src/cwd_prompt.rs
Normal file
@@ -0,0 +1,286 @@
|
||||
use std::path::Path;
|
||||
|
||||
use crate::key_hint;
|
||||
use crate::render::Insets;
|
||||
use crate::render::renderable::ColumnRenderable;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::render::renderable::RenderableExt as _;
|
||||
use crate::selection_list::selection_option_row;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use color_eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CwdPromptAction {
|
||||
Resume,
|
||||
Fork,
|
||||
}
|
||||
|
||||
impl CwdPromptAction {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
CwdPromptAction::Resume => "resume",
|
||||
CwdPromptAction::Fork => "fork",
|
||||
}
|
||||
}
|
||||
|
||||
fn past_participle(self) -> &'static str {
|
||||
match self {
|
||||
CwdPromptAction::Resume => "resumed",
|
||||
CwdPromptAction::Fork => "forked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum CwdSelection {
|
||||
Current,
|
||||
Session,
|
||||
}
|
||||
|
||||
impl CwdSelection {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
CwdSelection::Current => CwdSelection::Session,
|
||||
CwdSelection::Session => CwdSelection::Current,
|
||||
}
|
||||
}
|
||||
|
||||
fn prev(self) -> Self {
|
||||
match self {
|
||||
CwdSelection::Current => CwdSelection::Session,
|
||||
CwdSelection::Session => CwdSelection::Current,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn run_cwd_selection_prompt(
|
||||
tui: &mut Tui,
|
||||
action: CwdPromptAction,
|
||||
current_cwd: &Path,
|
||||
session_cwd: &Path,
|
||||
) -> Result<CwdSelection> {
|
||||
let mut screen = CwdPromptScreen::new(
|
||||
tui.frame_requester(),
|
||||
action,
|
||||
current_cwd.display().to_string(),
|
||||
session_cwd.display().to_string(),
|
||||
);
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
})?;
|
||||
|
||||
let events = tui.event_stream();
|
||||
tokio::pin!(events);
|
||||
|
||||
while !screen.is_done() {
|
||||
if let Some(event) = events.next().await {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => screen.handle_key(key_event),
|
||||
TuiEvent::Paste(_) => {}
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(u16::MAX, |frame| {
|
||||
frame.render_widget_ref(&screen, frame.area());
|
||||
})?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(screen.selection().unwrap_or(CwdSelection::Session))
|
||||
}
|
||||
|
||||
struct CwdPromptScreen {
|
||||
request_frame: FrameRequester,
|
||||
action: CwdPromptAction,
|
||||
current_cwd: String,
|
||||
session_cwd: String,
|
||||
highlighted: CwdSelection,
|
||||
selection: Option<CwdSelection>,
|
||||
}
|
||||
|
||||
impl CwdPromptScreen {
|
||||
fn new(
|
||||
request_frame: FrameRequester,
|
||||
action: CwdPromptAction,
|
||||
current_cwd: String,
|
||||
session_cwd: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
request_frame,
|
||||
action,
|
||||
current_cwd,
|
||||
session_cwd,
|
||||
highlighted: CwdSelection::Session,
|
||||
selection: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key(&mut self, key_event: KeyEvent) {
|
||||
if key_event.kind == KeyEventKind::Release {
|
||||
return;
|
||||
}
|
||||
if key_event.modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& matches!(key_event.code, KeyCode::Char('c') | KeyCode::Char('d'))
|
||||
{
|
||||
self.select(CwdSelection::Session);
|
||||
return;
|
||||
}
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => self.set_highlight(self.highlighted.prev()),
|
||||
KeyCode::Down | KeyCode::Char('j') => self.set_highlight(self.highlighted.next()),
|
||||
KeyCode::Char('1') => self.select(CwdSelection::Session),
|
||||
KeyCode::Char('2') => self.select(CwdSelection::Current),
|
||||
KeyCode::Enter => self.select(self.highlighted),
|
||||
KeyCode::Esc => self.select(CwdSelection::Session),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_highlight(&mut self, highlight: CwdSelection) {
|
||||
if self.highlighted != highlight {
|
||||
self.highlighted = highlight;
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, selection: CwdSelection) {
|
||||
self.highlighted = selection;
|
||||
self.selection = Some(selection);
|
||||
self.request_frame.schedule_frame();
|
||||
}
|
||||
|
||||
fn is_done(&self) -> bool {
|
||||
self.selection.is_some()
|
||||
}
|
||||
|
||||
fn selection(&self) -> Option<CwdSelection> {
|
||||
self.selection
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &CwdPromptScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let mut column = ColumnRenderable::new();
|
||||
|
||||
let action_verb = self.action.verb();
|
||||
let action_past = self.action.past_participle();
|
||||
let current_cwd = self.current_cwd.as_str();
|
||||
let session_cwd = self.session_cwd.as_str();
|
||||
|
||||
column.push("");
|
||||
column.push(Line::from(vec![
|
||||
"Choose working directory to ".into(),
|
||||
action_verb.bold(),
|
||||
" this session".into(),
|
||||
]));
|
||||
column.push("");
|
||||
column.push(
|
||||
Line::from(format!(
|
||||
"Session = latest cwd recorded in the {action_past} session"
|
||||
))
|
||||
.dim()
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push(
|
||||
Line::from("Current = your current working directory".dim())
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.push("");
|
||||
column.push(selection_option_row(
|
||||
0,
|
||||
format!("Use session directory ({session_cwd})"),
|
||||
self.highlighted == CwdSelection::Session,
|
||||
));
|
||||
column.push(selection_option_row(
|
||||
1,
|
||||
format!("Use current directory ({current_cwd})"),
|
||||
self.highlighted == CwdSelection::Current,
|
||||
));
|
||||
column.push("");
|
||||
column.push(
|
||||
Line::from(vec![
|
||||
"Press ".dim(),
|
||||
key_hint::plain(KeyCode::Enter).into(),
|
||||
" to continue".dim(),
|
||||
])
|
||||
.inset(Insets::tlbr(0, 2, 0, 0)),
|
||||
);
|
||||
column.render(area, buf);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_backend::VT100Backend;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::Terminal;
|
||||
|
||||
fn new_prompt() -> CwdPromptScreen {
|
||||
CwdPromptScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
CwdPromptAction::Resume,
|
||||
"/Users/example/current".to_string(),
|
||||
"/Users/example/session".to_string(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_snapshot() {
|
||||
let screen = new_prompt();
|
||||
let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||
.expect("render cwd prompt");
|
||||
insta::assert_snapshot!("cwd_prompt_modal", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_fork_snapshot() {
|
||||
let screen = CwdPromptScreen::new(
|
||||
FrameRequester::test_dummy(),
|
||||
CwdPromptAction::Fork,
|
||||
"/Users/example/current".to_string(),
|
||||
"/Users/example/session".to_string(),
|
||||
);
|
||||
let mut terminal = Terminal::new(VT100Backend::new(80, 14)).expect("terminal");
|
||||
terminal
|
||||
.draw(|frame| frame.render_widget_ref(&screen, frame.area()))
|
||||
.expect("render cwd prompt");
|
||||
insta::assert_snapshot!("cwd_prompt_fork_modal", terminal.backend());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_selects_session_by_default() {
|
||||
let mut screen = new_prompt();
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(screen.selection(), Some(CwdSelection::Session));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cwd_prompt_can_select_current() {
|
||||
let mut screen = new_prompt();
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||
screen.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_eq!(screen.selection(), Some(CwdSelection::Current));
|
||||
}
|
||||
}
|
||||
@@ -27,13 +27,19 @@ use codex_core::config_loader::ConfigLoadError;
|
||||
use codex_core::config_loader::format_config_error_with_source;
|
||||
use codex_core::find_thread_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::path_utils;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::read_session_meta_line;
|
||||
use codex_core::terminal::Multiplexer;
|
||||
use codex_protocol::config_types::AltScreenMode;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use cwd_prompt::CwdPromptAction;
|
||||
use cwd_prompt::CwdSelection;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
use tracing_appender::non_blocking;
|
||||
@@ -54,6 +60,7 @@ mod collab;
|
||||
mod collaboration_modes;
|
||||
mod color;
|
||||
pub mod custom_terminal;
|
||||
mod cwd_prompt;
|
||||
mod diff_render;
|
||||
mod exec_cell;
|
||||
mod exec_command;
|
||||
@@ -577,25 +584,27 @@ async fn run_ratatui_app(
|
||||
resume_picker::SessionSelection::StartFresh
|
||||
};
|
||||
|
||||
let current_cwd = config.cwd.clone();
|
||||
let allow_prompt = cli.cwd.is_none();
|
||||
let action_and_path_if_resume_or_fork = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(path) => Some((CwdPromptAction::Resume, path)),
|
||||
resume_picker::SessionSelection::Fork(path) => Some((CwdPromptAction::Fork, path)),
|
||||
_ => None,
|
||||
};
|
||||
let fallback_cwd = match action_and_path_if_resume_or_fork {
|
||||
Some((action, path)) => {
|
||||
resolve_cwd_for_resume_or_fork(&mut tui, ¤t_cwd, path, action, allow_prompt)
|
||||
.await?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let config = match &session_selection {
|
||||
resume_picker::SessionSelection::Resume(path)
|
||||
| resume_picker::SessionSelection::Fork(path) => {
|
||||
let history_cwd = match read_session_meta_line(path).await {
|
||||
Ok(meta_line) => Some(meta_line.meta.cwd),
|
||||
Err(err) => {
|
||||
let rollout_path = path.display().to_string();
|
||||
tracing::warn!(
|
||||
%rollout_path,
|
||||
%err,
|
||||
"Failed to read session metadata from rollout"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => {
|
||||
load_config_or_exit_with_fallback_cwd(
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
history_cwd,
|
||||
fallback_cwd,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -618,6 +627,8 @@ async fn run_ratatui_app(
|
||||
&mut tui,
|
||||
auth_manager,
|
||||
config,
|
||||
cli_kv_overrides.clone(),
|
||||
overrides.clone(),
|
||||
active_profile,
|
||||
prompt,
|
||||
images,
|
||||
@@ -635,6 +646,77 @@ async fn run_ratatui_app(
|
||||
app_result
|
||||
}
|
||||
|
||||
pub(crate) async fn read_session_cwd(path: &Path) -> Option<PathBuf> {
|
||||
// Prefer the latest TurnContext cwd so resume/fork reflects the most recent
|
||||
// session directory (for the changed-cwd prompt). The alternative would be
|
||||
// mutating the SessionMeta line when the session cwd changes, but the rollout
|
||||
// is an append-only JSONL log and rewriting the head would be error-prone.
|
||||
// When rollouts move to SQLite, we can drop this scan.
|
||||
if let Some(cwd) = parse_latest_turn_context_cwd(path).await {
|
||||
return Some(cwd);
|
||||
}
|
||||
match read_session_meta_line(path).await {
|
||||
Ok(meta_line) => Some(meta_line.meta.cwd),
|
||||
Err(err) => {
|
||||
let rollout_path = path.display().to_string();
|
||||
tracing::warn!(
|
||||
%rollout_path,
|
||||
%err,
|
||||
"Failed to read session metadata from rollout"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn parse_latest_turn_context_cwd(path: &Path) -> Option<PathBuf> {
|
||||
let text = tokio::fs::read_to_string(path).await.ok()?;
|
||||
for line in text.lines().rev() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
if let RolloutItem::TurnContext(item) = rollout_line.item {
|
||||
return Some(item.cwd);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn cwds_differ(current_cwd: &Path, session_cwd: &Path) -> bool {
|
||||
match (
|
||||
path_utils::normalize_for_path_comparison(current_cwd),
|
||||
path_utils::normalize_for_path_comparison(session_cwd),
|
||||
) {
|
||||
(Ok(current), Ok(session)) => current != session,
|
||||
_ => current_cwd != session_cwd,
|
||||
}
|
||||
}
|
||||
|
||||
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>> {
|
||||
let Some(history_cwd) = read_session_cwd(path).await else {
|
||||
return Ok(None);
|
||||
};
|
||||
if allow_prompt && cwds_differ(current_cwd, &history_cwd) {
|
||||
let selection =
|
||||
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,
|
||||
}));
|
||||
}
|
||||
Ok(Some(history_cwd))
|
||||
}
|
||||
|
||||
#[expect(
|
||||
clippy::print_stderr,
|
||||
reason = "TUI should no longer be displayed, so we can write to stderr."
|
||||
@@ -772,7 +854,14 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::ProjectConfig;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -846,4 +935,180 @@ mod tests {
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_turn_context(config: &Config, cwd: PathBuf) -> TurnContextItem {
|
||||
let model = config
|
||||
.model
|
||||
.clone()
|
||||
.unwrap_or_else(|| "gpt-5.1".to_string());
|
||||
TurnContextItem {
|
||||
cwd,
|
||||
approval_policy: config.approval_policy.value(),
|
||||
sandbox_policy: config.sandbox_policy.get().clone(),
|
||||
model,
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
effort: config.model_reasoning_effort,
|
||||
summary: config.model_reasoning_summary,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_session_cwd_prefers_latest_turn_context() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let config = build_config(&temp_dir).await?;
|
||||
let first = temp_dir.path().join("first");
|
||||
let second = temp_dir.path().join("second");
|
||||
std::fs::create_dir_all(&first)?;
|
||||
std::fs::create_dir_all(&second)?;
|
||||
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
let lines = vec![
|
||||
RolloutLine {
|
||||
timestamp: "t0".to_string(),
|
||||
item: RolloutItem::TurnContext(build_turn_context(&config, first)),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "t1".to_string(),
|
||||
item: RolloutItem::TurnContext(build_turn_context(&config, second.clone())),
|
||||
},
|
||||
];
|
||||
let mut text = String::new();
|
||||
for line in lines {
|
||||
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::write(&rollout_path, text)?;
|
||||
|
||||
let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
|
||||
assert_eq!(cwd, second);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn should_prompt_when_meta_matches_current_but_latest_turn_differs() -> std::io::Result<()>
|
||||
{
|
||||
let temp_dir = TempDir::new()?;
|
||||
let config = build_config(&temp_dir).await?;
|
||||
let current = temp_dir.path().join("current");
|
||||
let latest = temp_dir.path().join("latest");
|
||||
std::fs::create_dir_all(¤t)?;
|
||||
std::fs::create_dir_all(&latest)?;
|
||||
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
let session_meta = SessionMeta {
|
||||
cwd: current.clone(),
|
||||
..SessionMeta::default()
|
||||
};
|
||||
let lines = vec![
|
||||
RolloutLine {
|
||||
timestamp: "t0".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git: None,
|
||||
}),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "t1".to_string(),
|
||||
item: RolloutItem::TurnContext(build_turn_context(&config, latest.clone())),
|
||||
},
|
||||
];
|
||||
let mut text = String::new();
|
||||
for line in lines {
|
||||
text.push_str(&serde_json::to_string(&line).expect("serialize rollout"));
|
||||
text.push('\n');
|
||||
}
|
||||
std::fs::write(&rollout_path, text)?;
|
||||
|
||||
let session_cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
|
||||
assert_eq!(session_cwd, latest);
|
||||
assert!(cwds_differ(¤t, &session_cwd));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_rebuild_changes_trust_defaults_with_cwd() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let codex_home = temp_dir.path().to_path_buf();
|
||||
let trusted = temp_dir.path().join("trusted");
|
||||
let untrusted = temp_dir.path().join("untrusted");
|
||||
std::fs::create_dir_all(&trusted)?;
|
||||
std::fs::create_dir_all(&untrusted)?;
|
||||
|
||||
// TOML keys need escaped backslashes on Windows paths.
|
||||
let trusted_display = trusted.display().to_string().replace('\\', "\\\\");
|
||||
let untrusted_display = untrusted.display().to_string().replace('\\', "\\\\");
|
||||
let config_toml = format!(
|
||||
r#"[projects."{trusted_display}"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."{untrusted_display}"]
|
||||
trust_level = "untrusted"
|
||||
"#
|
||||
);
|
||||
std::fs::write(temp_dir.path().join("config.toml"), config_toml)?;
|
||||
|
||||
let trusted_overrides = ConfigOverrides {
|
||||
cwd: Some(trusted.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let trusted_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.clone())
|
||||
.harness_overrides(trusted_overrides.clone())
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
trusted_config.approval_policy.value(),
|
||||
AskForApproval::OnRequest
|
||||
);
|
||||
|
||||
let untrusted_overrides = ConfigOverrides {
|
||||
cwd: Some(untrusted),
|
||||
..trusted_overrides
|
||||
};
|
||||
let untrusted_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.harness_overrides(untrusted_overrides)
|
||||
.build()
|
||||
.await?;
|
||||
assert_eq!(
|
||||
untrusted_config.approval_policy.value(),
|
||||
AskForApproval::UnlessTrusted
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_session_cwd_falls_back_to_session_meta() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let _config = build_config(&temp_dir).await?;
|
||||
let session_cwd = temp_dir.path().join("session");
|
||||
std::fs::create_dir_all(&session_cwd)?;
|
||||
|
||||
let rollout_path = temp_dir.path().join("rollout.jsonl");
|
||||
let session_meta = SessionMeta {
|
||||
cwd: session_cwd.clone(),
|
||||
..SessionMeta::default()
|
||||
};
|
||||
let meta_line = RolloutLine {
|
||||
timestamp: "t0".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git: None,
|
||||
}),
|
||||
};
|
||||
let text = format!(
|
||||
"{}\n",
|
||||
serde_json::to_string(&meta_line).expect("serialize meta")
|
||||
);
|
||||
std::fs::write(&rollout_path, text)?;
|
||||
|
||||
let cwd = read_session_cwd(&rollout_path).await.expect("expected cwd");
|
||||
assert_eq!(cwd, session_cwd);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/cwd_prompt.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
|
||||
Choose working directory to fork this session
|
||||
|
||||
Session = latest cwd recorded in the forked session
|
||||
Current = your current working directory
|
||||
|
||||
› 1. Use session directory (/Users/example/session)
|
||||
2. Use current directory (/Users/example/current)
|
||||
|
||||
Press enter to continue
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/cwd_prompt.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
|
||||
Choose working directory to resume this session
|
||||
|
||||
Session = latest cwd recorded in the resumed session
|
||||
Current = your current working directory
|
||||
|
||||
› 1. Use session directory (/Users/example/session)
|
||||
2. Use current directory (/Users/example/current)
|
||||
|
||||
Press enter to continue
|
||||
Reference in New Issue
Block a user