tui: use config snapshots for pane-resumed forks

This commit is contained in:
Rakan El Khalil
2026-03-02 23:36:38 -08:00
parent 9275d1158e
commit 762ba5b928
2 changed files with 182 additions and 23 deletions

View File

@@ -1,8 +1,8 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_event::ForkPanePlacement;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
@@ -45,6 +45,7 @@ use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::CodexThread;
use codex_core::ThreadManager;
use codex_core::codex::SessionSettingsUpdate;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
@@ -98,6 +99,8 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::VecDeque;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
@@ -115,6 +118,7 @@ use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use uuid::Uuid;
mod pending_interactive_replay;
@@ -1425,8 +1429,8 @@ impl App {
}
fn fresh_session_config(&self) -> Config {
let mut config = self.config.clone();
config.service_tier = self.chat_widget.current_service_tier();
let mut config = self.chat_widget.config_ref().clone();
config.model = Some(self.chat_widget.current_model().to_string());
config
}
@@ -1444,16 +1448,62 @@ impl App {
let Some(multiplexer) = terminal_info.multiplexer.as_ref() else {
return false;
};
let description =
match spawn_fork_in_new_pane(multiplexer, &forked_thread_id, placement).await {
Ok(description) => description,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Forked session created but failed to open a new pane: {err}"
));
return false;
}
};
let thread_snapshot = forked_thread.config_snapshot().await;
let current_collaboration_mode = self.chat_widget.current_collaboration_mode();
let runtime_overrides = SessionSettingsUpdate {
cwd: Some(thread_snapshot.cwd.clone()),
approval_policy: Some(thread_snapshot.approval_policy),
sandbox_policy: Some(thread_snapshot.sandbox_policy.clone()),
windows_sandbox_level: None,
collaboration_mode: Some(CollaborationMode {
mode: self.chat_widget.active_collaboration_mode_kind(),
settings: Settings {
model: thread_snapshot.model.clone(),
reasoning_effort: thread_snapshot.reasoning_effort,
developer_instructions: current_collaboration_mode
.settings
.developer_instructions
.clone(),
},
}),
reasoning_summary: None,
service_tier: Some(self.chat_widget.current_service_tier()),
final_output_json_schema: None,
personality: thread_snapshot.personality,
app_server_client_name: None,
};
let runtime_overrides_path = match (|| -> io::Result<PathBuf> {
let overrides_dir = self.config.codex_home.join("tmp").join("runtime-overrides");
fs::create_dir_all(&overrides_dir)?;
let path = overrides_dir.join(format!("{}.json", Uuid::new_v4()));
let contents = serde_json::to_vec(&runtime_overrides).map_err(io::Error::other)?;
fs::write(&path, contents)?;
Ok(path)
})() {
Ok(path) => path,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Forked session created but failed to prepare runtime overrides: {err}"
));
return false;
}
};
let description = match spawn_fork_in_new_pane(
multiplexer,
&forked_thread_id,
&runtime_overrides_path,
placement,
)
.await
{
Ok(description) => description,
Err(err) => {
self.chat_widget.add_error_message(format!(
"Forked session created but failed to open a new pane: {err}"
));
return false;
}
};
self.suppressed_thread_created.insert(forked_thread_id);
if let Err(err) = forked_thread.submit(Op::Shutdown).await {
@@ -5036,13 +5086,15 @@ mod tests {
}
#[tokio::test]
async fn fresh_session_config_uses_current_service_tier() {
async fn fresh_session_config_uses_live_chat_widget_state() {
let mut app = make_test_app().await;
app.chat_widget.set_model("gpt-test");
app.chat_widget
.set_service_tier(Some(codex_protocol::config_types::ServiceTier::Fast));
let config = app.fresh_session_config();
assert_eq!(config.model.as_deref(), Some("gpt-test"));
assert_eq!(
config.service_tier,
Some(codex_protocol::config_types::ServiceTier::Fast)

View File

@@ -3,6 +3,8 @@ use codex_core::terminal::Multiplexer;
use codex_core::terminal::terminal_info;
use codex_protocol::ThreadId;
use shlex::try_join;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
@@ -14,14 +16,42 @@ pub(crate) struct MultiplexerSpawnConfig {
}
fn codex_executable() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("codex"))
std::env::current_exe()
.map(|path| resolve_codex_executable(&path))
.unwrap_or_else(|_| PathBuf::from("codex"))
}
fn resume_command_parts(exe: &Path, thread_id: &ThreadId) -> Vec<String> {
fn resolve_codex_executable(current_exe: &Path) -> PathBuf {
let Some(file_name) = current_exe.file_name().and_then(|name| name.to_str()) else {
return PathBuf::from("codex");
};
if !file_name.starts_with("codex-tui") {
return current_exe.to_path_buf();
}
let sibling = if let Some((_, extension)) = file_name.rsplit_once('.') {
current_exe.with_file_name(format!("codex.{extension}"))
} else {
current_exe.with_file_name("codex")
};
if sibling.is_file() {
sibling
} else {
PathBuf::from("codex")
}
}
fn resume_command_parts(
exe: &Path,
thread_id: &ThreadId,
runtime_overrides_path: &Path,
) -> Vec<String> {
vec![
exe.display().to_string(),
"resume".to_string(),
thread_id.to_string(),
"--runtime-overrides".to_string(),
runtime_overrides_path.display().to_string(),
]
}
@@ -86,9 +116,10 @@ fn fork_spawn_config(
multiplexer: &Multiplexer,
exe: &Path,
thread_id: &ThreadId,
runtime_overrides_path: &Path,
placement: Option<ForkPanePlacement>,
) -> MultiplexerSpawnConfig {
let resume_command = resume_command_parts(exe, thread_id);
let resume_command = resume_command_parts(exe, thread_id, runtime_overrides_path);
match multiplexer {
Multiplexer::Zellij {} => MultiplexerSpawnConfig {
program: "zellij",
@@ -138,21 +169,97 @@ pub(crate) fn validate_fork_placement(placement: Option<ForkPanePlacement>) -> R
pub(crate) async fn spawn_fork_in_new_pane(
multiplexer: &Multiplexer,
thread_id: &ThreadId,
runtime_overrides_path: &Path,
placement: Option<ForkPanePlacement>,
) -> Result<&'static str, String> {
let exe = codex_executable();
let config = fork_spawn_config(multiplexer, &exe, thread_id, placement);
let config = fork_spawn_config(
multiplexer,
&exe,
thread_id,
runtime_overrides_path,
placement,
);
let MultiplexerSpawnConfig {
program,
args,
description,
} = config;
let status = tokio::task::spawn_blocking(move || Command::new(program).args(args).status())
.await
.map_err(|err| format!("failed to spawn {program} pane: {err}"))?;
let status = match tokio::task::spawn_blocking(move || {
Command::new(program).args(args).status()
})
.await
{
Ok(status) => status,
Err(err) => {
cleanup_runtime_overrides(runtime_overrides_path);
return Err(format!("failed to spawn {program} pane: {err}"));
}
};
match status {
Ok(status) if status.success() => Ok(description),
Ok(status) => Err(format!("{program} exited with status {status}")),
Err(err) => Err(format!("failed to run {program}: {err}")),
Ok(status) => {
cleanup_runtime_overrides(runtime_overrides_path);
Err(format!("{program} exited with status {status}"))
}
Err(err) => {
cleanup_runtime_overrides(runtime_overrides_path);
Err(format!("failed to run {program}: {err}"))
}
}
}
fn cleanup_runtime_overrides(path: &Path) {
if let Err(err) = fs::remove_file(path)
&& err.kind() != io::ErrorKind::NotFound
{
tracing::warn!(path = %path.display(), error = %err, "failed to remove runtime overrides");
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn resolve_codex_executable_rewrites_codex_tui_to_sibling_codex() {
let tmp = tempfile::tempdir().expect("tempdir");
let current_exe = tmp.path().join("codex-tui");
let sibling_codex = tmp.path().join("codex");
std::fs::write(&sibling_codex, "").expect("write sibling codex");
let resolved = resolve_codex_executable(&current_exe);
assert_eq!(resolved, sibling_codex);
}
#[test]
fn resolve_codex_executable_keeps_non_tui_binary() {
let current_exe = PathBuf::from("/tmp/codex");
let resolved = resolve_codex_executable(&current_exe);
assert_eq!(resolved, current_exe);
}
#[test]
fn resume_command_parts_include_runtime_overrides() {
let exe = PathBuf::from("/tmp/codex");
let thread_id = ThreadId::new();
let runtime_overrides_path = PathBuf::from("/tmp/runtime-overrides.json");
let command = resume_command_parts(&exe, &thread_id, &runtime_overrides_path);
assert_eq!(
command,
vec![
"/tmp/codex".to_string(),
"resume".to_string(),
thread_id.to_string(),
"--runtime-overrides".to_string(),
"/tmp/runtime-overrides.json".to_string(),
]
);
}
}