diff --git a/src-tauri/src/backend/app_server.rs b/src-tauri/src/backend/app_server.rs index ecb860d25..5896f05db 100644 --- a/src-tauri/src/backend/app_server.rs +++ b/src-tauri/src/backend/app_server.rs @@ -56,6 +56,7 @@ const REQUEST_TIMEOUT: Duration = Duration::from_secs(300); pub(crate) struct WorkspaceSession { pub(crate) entry: WorkspaceEntry, + pub(crate) codex_args: Option, pub(crate) child: Mutex, pub(crate) stdin: Mutex, pub(crate) pending: Mutex>>, @@ -339,6 +340,7 @@ pub(crate) async fn spawn_workspace_session( let session = Arc::new(WorkspaceSession { entry: entry.clone(), + codex_args, child: Mutex::new(child), stdin: Mutex::new(stdin), pending: Mutex::new(HashMap::new()), diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index b53f47cd9..8c7d205ca 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -530,6 +530,32 @@ impl DaemonState { .await } + async fn set_workspace_runtime_codex_args( + &self, + workspace_id: String, + codex_args: Option, + client_version: String, + ) -> Result { + workspaces_core::set_workspace_runtime_codex_args_core( + workspace_id, + codex_args, + &self.workspaces, + &self.sessions, + &self.app_settings, + move |entry, default_bin, next_args, codex_home| { + spawn_with_client( + self.event_sink.clone(), + client_version.clone(), + entry, + default_bin, + next_args, + codex_home, + ) + }, + ) + .await + } + async fn get_app_settings(&self) -> AppSettings { settings_core::get_app_settings_core(&self.app_settings).await } diff --git a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs index 09a1d5c6f..66e8ea9d0 100644 --- a/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs +++ b/src-tauri/src/bin/codex_monitor_daemon/rpc/workspace.rs @@ -146,6 +146,23 @@ pub(super) async fn try_handle( .map(|_| json!({ "ok": true })), ) } + "set_workspace_runtime_codex_args" => { + let workspace_id = match parse_string(params, "workspaceId") { + Ok(value) => value, + Err(err) => return Some(Err(err)), + }; + let codex_args = parse_optional_string(params, "codexArgs"); + Some( + state + .set_workspace_runtime_codex_args( + workspace_id, + codex_args, + client_version.to_string(), + ) + .await + .and_then(|value| serde_json::to_value(value).map_err(|e| e.to_string())), + ) + } "remove_workspace" => { let id = match parse_string(params, "id") { Ok(value) => value, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4d1ea7b46..79c65556b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -188,6 +188,7 @@ pub fn run() { workspaces::apply_worktree_changes, workspaces::update_workspace_settings, workspaces::update_workspace_codex_bin, + workspaces::set_workspace_runtime_codex_args, codex::start_thread, codex::send_user_message, codex::turn_steer, diff --git a/src-tauri/src/remote_backend/mod.rs b/src-tauri/src/remote_backend/mod.rs index 0aba83795..4ae3868d9 100644 --- a/src-tauri/src/remote_backend/mod.rs +++ b/src-tauri/src/remote_backend/mod.rs @@ -151,6 +151,7 @@ fn can_retry_after_disconnect(method: &str) -> bool { | "collaboration_mode_list" | "connect_workspace" | "experimental_feature_list" + | "set_workspace_runtime_codex_args" | "file_read" | "get_agents_settings" | "get_config_model" diff --git a/src-tauri/src/shared/workspaces_core.rs b/src-tauri/src/shared/workspaces_core.rs index 2b0eac9a0..90bd4e708 100644 --- a/src-tauri/src/shared/workspaces_core.rs +++ b/src-tauri/src/shared/workspaces_core.rs @@ -3,6 +3,7 @@ mod crud_persistence; mod git_orchestration; mod helpers; mod io; +mod runtime_codex_args; mod worktree; pub(crate) use connect::connect_workspace_core; @@ -16,6 +17,9 @@ pub(crate) use io::{ get_open_app_icon_core, list_workspace_files_core, open_workspace_in_core, read_workspace_file_core, }; +pub(crate) use runtime_codex_args::{ + set_workspace_runtime_codex_args_core, WorkspaceRuntimeCodexArgsResult, +}; pub(crate) use worktree::{ add_worktree_core, remove_worktree_core, rename_worktree_core, rename_worktree_upstream_core, worktree_setup_mark_ran_core, worktree_setup_status_core, diff --git a/src-tauri/src/shared/workspaces_core/runtime_codex_args.rs b/src-tauri/src/shared/workspaces_core/runtime_codex_args.rs new file mode 100644 index 000000000..f858e7f7f --- /dev/null +++ b/src-tauri/src/shared/workspaces_core/runtime_codex_args.rs @@ -0,0 +1,263 @@ +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +use crate::backend::app_server::WorkspaceSession; +use crate::codex::args::resolve_workspace_codex_args; +use crate::codex::home::resolve_workspace_codex_home; +use crate::shared::process_core::kill_child_process_tree; +use crate::types::{AppSettings, WorkspaceEntry}; + +use super::helpers::resolve_entry_and_parent; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WorkspaceRuntimeCodexArgsResult { + pub(crate) applied_codex_args: Option, + pub(crate) respawned: bool, +} + +pub(crate) async fn set_workspace_runtime_codex_args_core( + workspace_id: String, + codex_args_override: Option, + workspaces: &Mutex>, + sessions: &Mutex>>, + app_settings: &Mutex, + spawn_session: F, +) -> Result +where + F: Fn(WorkspaceEntry, Option, Option, Option) -> Fut, + Fut: Future, String>>, +{ + let (entry, parent_entry) = resolve_entry_and_parent(workspaces, &workspace_id).await?; + + let (default_bin, resolved_args) = { + let settings = app_settings.lock().await; + ( + settings.codex_bin.clone(), + resolve_workspace_codex_args(&entry, parent_entry.as_ref(), Some(&settings)), + ) + }; + + let target_args = codex_args_override + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or(resolved_args); + + // If we are not connected, we can't respawn. Treat this as a no-op success; callers + // should call again after connecting. + let current = sessions.lock().await.get(&entry.id).cloned(); + let Some(current) = current else { + return Ok(WorkspaceRuntimeCodexArgsResult { + applied_codex_args: target_args, + respawned: false, + }); + }; + + if current.codex_args == target_args { + return Ok(WorkspaceRuntimeCodexArgsResult { + applied_codex_args: target_args, + respawned: false, + }); + } + + let codex_home = resolve_workspace_codex_home(&entry, parent_entry.as_ref()); + let new_session = spawn_session(entry.clone(), default_bin, target_args.clone(), codex_home).await?; + if let Some(old_session) = sessions.lock().await.insert(entry.id.clone(), new_session) { + let mut child = old_session.child.lock().await; + kill_child_process_tree(&mut child).await; + } + + Ok(WorkspaceRuntimeCodexArgsResult { + applied_codex_args: target_args, + respawned: true, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::process::Stdio; + use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; + + use tokio::process::Command; + + use crate::types::{WorkspaceKind, WorkspaceSettings}; + + fn make_workspace_entry(id: &str) -> WorkspaceEntry { + WorkspaceEntry { + id: id.to_string(), + name: id.to_string(), + path: "/tmp".to_string(), + codex_bin: None, + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings::default(), + } + } + + fn make_session(entry: WorkspaceEntry, codex_args: Option) -> WorkspaceSession { + let mut cmd = if cfg!(windows) { + let mut cmd = Command::new("cmd"); + cmd.args(["/C", "more"]); + cmd + } else { + let mut cmd = Command::new("sh"); + cmd.args(["-c", "cat"]); + cmd + }; + + cmd.stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + let mut child = cmd.spawn().expect("spawn dummy child"); + let stdin = child.stdin.take().expect("dummy child stdin"); + + WorkspaceSession { + entry, + codex_args, + child: Mutex::new(child), + stdin: Mutex::new(stdin), + pending: Mutex::new(HashMap::new()), + next_id: AtomicU64::new(0), + background_thread_callbacks: Mutex::new(HashMap::new()), + } + } + + #[test] + fn set_workspace_runtime_codex_args_is_noop_when_workspace_not_connected() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let entry = make_workspace_entry("ws-1"); + let workspaces = Mutex::new(HashMap::from([(entry.id.clone(), entry.clone())])); + let sessions = Mutex::new(HashMap::>::new()); + let app_settings = Mutex::new(AppSettings::default()); + + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let spawn_calls_ref = spawn_calls.clone(); + + let result = set_workspace_runtime_codex_args_core( + entry.id.clone(), + Some(" --profile dev ".to_string()), + &workspaces, + &sessions, + &app_settings, + move |entry, _bin, args, _home| { + let spawn_calls_ref = spawn_calls_ref.clone(); + async move { + spawn_calls_ref.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(make_session(entry, args))) + } + }, + ) + .await + .expect("core call succeeds"); + + assert_eq!( + result, + WorkspaceRuntimeCodexArgsResult { + applied_codex_args: Some("--profile dev".to_string()), + respawned: false + } + ); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 0); + }); + } + + #[test] + fn set_workspace_runtime_codex_args_is_noop_when_args_match() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let entry = make_workspace_entry("ws-1"); + let workspaces = Mutex::new(HashMap::from([(entry.id.clone(), entry.clone())])); + let current_session = Arc::new(make_session(entry.clone(), Some("--same".to_string()))); + let sessions = Mutex::new(HashMap::from([(entry.id.clone(), current_session)])); + let app_settings = Mutex::new(AppSettings::default()); + + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let spawn_calls_ref = spawn_calls.clone(); + + let result = set_workspace_runtime_codex_args_core( + entry.id.clone(), + Some("--same".to_string()), + &workspaces, + &sessions, + &app_settings, + move |entry, _bin, args, _home| { + let spawn_calls_ref = spawn_calls_ref.clone(); + async move { + spawn_calls_ref.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(make_session(entry, args))) + } + }, + ) + .await + .expect("core call succeeds"); + + assert_eq!( + result, + WorkspaceRuntimeCodexArgsResult { + applied_codex_args: Some("--same".to_string()), + respawned: false + } + ); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 0); + }); + } + + #[test] + fn set_workspace_runtime_codex_args_respawns_when_args_change() { + tokio::runtime::Runtime::new().unwrap().block_on(async { + let entry = make_workspace_entry("ws-1"); + let workspaces = Mutex::new(HashMap::from([(entry.id.clone(), entry.clone())])); + let current_session = Arc::new(make_session(entry.clone(), Some("--old".to_string()))); + let sessions = Mutex::new(HashMap::from([(entry.id.clone(), current_session)])); + let app_settings = Mutex::new(AppSettings::default()); + + let spawn_calls = Arc::new(AtomicUsize::new(0)); + let spawn_calls_ref = spawn_calls.clone(); + + let result = set_workspace_runtime_codex_args_core( + entry.id.clone(), + Some("--new".to_string()), + &workspaces, + &sessions, + &app_settings, + move |entry, _bin, args, _home| { + let spawn_calls_ref = spawn_calls_ref.clone(); + async move { + spawn_calls_ref.fetch_add(1, Ordering::SeqCst); + Ok(Arc::new(make_session(entry, args))) + } + }, + ) + .await + .expect("core call succeeds"); + + assert_eq!( + result, + WorkspaceRuntimeCodexArgsResult { + applied_codex_args: Some("--new".to_string()), + respawned: true + } + ); + assert_eq!(spawn_calls.load(Ordering::SeqCst), 1); + + let next = sessions + .lock() + .await + .get(&entry.id) + .expect("session updated") + .codex_args + .clone(); + assert_eq!(next, Some("--new".to_string())); + }); + } +} diff --git a/src-tauri/src/workspaces/commands.rs b/src-tauri/src/workspaces/commands.rs index 76a4df488..0402b4676 100644 --- a/src-tauri/src/workspaces/commands.rs +++ b/src-tauri/src/workspaces/commands.rs @@ -76,6 +76,35 @@ pub(crate) async fn list_workspaces( Ok(workspaces_core::list_workspaces_core(&state.workspaces, &state.sessions).await) } +#[tauri::command] +pub(crate) async fn set_workspace_runtime_codex_args( + workspace_id: String, + codex_args: Option, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + if remote_backend::is_remote_mode(&*state).await { + let response = remote_backend::call_remote( + &*state, + app, + "set_workspace_runtime_codex_args", + json!({ "workspaceId": workspace_id, "codexArgs": codex_args }), + ) + .await?; + return serde_json::from_value(response).map_err(|err| err.to_string()); + } + + workspaces_core::set_workspace_runtime_codex_args_core( + workspace_id, + codex_args, + &state.workspaces, + &state.sessions, + &state.app_settings, + |entry, default_bin, args, home| spawn_with_app(&app, entry, default_bin, args, home), + ) + .await +} + #[tauri::command] pub(crate) async fn is_workspace_path_dir( path: String, diff --git a/src/App.tsx b/src/App.tsx index 804646ba3..be5e33140 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -144,6 +144,13 @@ import { useWorkspaceOrderingOrchestration, } from "@app/orchestration/useWorkspaceOrchestration"; import { useAppShellOrchestration } from "@app/orchestration/useLayoutOrchestration"; +import { buildCodexArgsOptions } from "@threads/utils/codexArgsProfiles"; +import { normalizeCodexArgsInput } from "@/utils/codexArgsInput"; +import { + resolveWorkspaceRuntimeCodexArgsBadgeLabel, + resolveWorkspaceRuntimeCodexArgsOverride, +} from "@threads/utils/threadCodexParamsSeed"; +import { setWorkspaceRuntimeCodexArgs } from "@services/tauri"; const AboutView = lazy(() => import("@/features/about/components/AboutView").then((module) => ({ @@ -267,6 +274,8 @@ function MainApp() { setPreferredEffort, preferredCollabModeId, setPreferredCollabModeId, + preferredCodexArgsOverride, + setPreferredCodexArgsOverride, threadCodexSelectionKey, setThreadCodexSelectionKey, activeThreadIdRef, @@ -412,11 +421,19 @@ function MainApp() { onDebug: addDebugEntry, }); + const [selectedCodexArgsOverride, setSelectedCodexArgsOverride] = useState( + null, + ); + useEffect(() => { + setSelectedCodexArgsOverride(normalizeCodexArgsInput(preferredCodexArgsOverride)); + }, [preferredCodexArgsOverride, threadCodexSelectionKey]); + const { handleSelectModel, handleSelectEffort, handleSelectCollaborationMode, handleSelectAccessMode, + handleSelectCodexArgsOverride, } = useThreadSelectionHandlersOrchestration({ appSettingsLoading, setAppSettings, @@ -426,6 +443,7 @@ function MainApp() { setSelectedEffort, setSelectedCollaborationModeId, setAccessMode, + setSelectedCodexArgsOverride, persistThreadCodexParams, }); @@ -487,6 +505,35 @@ function MainApp() { } = useCustomPrompts({ activeWorkspace, onDebug: addDebugEntry }); const resolvedModel = selectedModel?.model ?? null; const resolvedEffort = reasoningSupported ? selectedEffort : null; + const codexArgsOptions = useMemo( + () => + buildCodexArgsOptions({ + appCodexArgs: appSettings.codexArgs ?? null, + workspaceCodexArgs: workspaces.map((workspace) => workspace.settings.codexArgs), + additionalCodexArgs: [selectedCodexArgsOverride], + }), + [appSettings.codexArgs, selectedCodexArgsOverride, workspaces], + ); + const ensureWorkspaceRuntimeCodexArgs = useCallback( + async (workspaceId: string, threadId: string | null) => { + const sanitizedCodexArgsOverride = resolveWorkspaceRuntimeCodexArgsOverride({ + workspaceId, + threadId, + getThreadCodexParams, + }); + await setWorkspaceRuntimeCodexArgs(workspaceId, sanitizedCodexArgsOverride); + }, + [getThreadCodexParams], + ); + const getThreadArgsBadge = useCallback( + (workspaceId: string, threadId: string) => + resolveWorkspaceRuntimeCodexArgsBadgeLabel({ + workspaceId, + threadId, + getThreadCodexParams, + }), + [getThreadCodexParams], + ); const { collaborationModePayload } = useCollaborationModeSelection({ selectedCollaborationMode, @@ -569,6 +616,7 @@ function MainApp() { effort: resolvedEffort, collaborationMode: collaborationModePayload, accessMode, + ensureWorkspaceRuntimeCodexArgs, reviewDeliveryMode: appSettings.reviewDeliveryMode, steerEnabled: appSettings.steerEnabled, threadTitleAutogenerationEnabled: appSettings.threadTitleAutogenerationEnabled, @@ -865,12 +913,14 @@ function MainApp() { setPreferredModelId, setPreferredEffort, setPreferredCollabModeId, + setPreferredCodexArgsOverride, activeThreadIdRef, pendingNewThreadSeedRef, selectedModelId, resolvedEffort, accessMode, selectedCollaborationModeId, + selectedCodexArgsOverride, }); const { handleSetThreadListSortKey, handleRefreshAllWorkspaceThreads } = @@ -1763,6 +1813,7 @@ function MainApp() { activeThreadId, accessMode, selectedCollaborationModeId, + selectedCodexArgsOverride, pendingNewThreadSeedRef, runWithDraftStart, handleComposerSend, @@ -2290,6 +2341,9 @@ function MainApp() { collaborationModes, selectedCollaborationModeId, onSelectCollaborationMode: handleSelectCollaborationMode, + codexArgsOptions, + selectedCodexArgsOverride, + onSelectCodexArgsOverride: handleSelectCodexArgsOverride, models, selectedModelId, onSelectModel: handleSelectModel, @@ -2367,6 +2421,7 @@ function MainApp() { onWorkspaceDragEnter: handleWorkspaceDragEnter, onWorkspaceDragLeave: handleWorkspaceDragLeave, onWorkspaceDrop: handleWorkspaceDrop, + getThreadArgsBadge, }); const gitRootOverride = activeWorkspace?.settings.gitRoot; diff --git a/src/features/app/components/PinnedThreadList.tsx b/src/features/app/components/PinnedThreadList.tsx index e108ba7fc..94333d6eb 100644 --- a/src/features/app/components/PinnedThreadList.tsx +++ b/src/features/app/components/PinnedThreadList.tsx @@ -20,6 +20,7 @@ type PinnedThreadListProps = { threadStatusById: ThreadStatusMap; pendingUserInputKeys?: Set; getThreadTime: (thread: ThreadSummary) => string | null; + getThreadArgsBadge?: (workspaceId: string, threadId: string) => string | null; isThreadPinned: (workspaceId: string, threadId: string) => boolean; onSelectThread: (workspaceId: string, threadId: string) => void; onShowThreadMenu: ( @@ -37,6 +38,7 @@ export function PinnedThreadList({ threadStatusById, pendingUserInputKeys, getThreadTime, + getThreadArgsBadge, isThreadPinned, onSelectThread, onShowThreadMenu, @@ -45,6 +47,7 @@ export function PinnedThreadList({
{rows.map(({ thread, depth, workspaceId }) => { const relativeTime = getThreadTime(thread); + const badge = getThreadArgsBadge?.(workspaceId, thread.id) ?? null; const indentStyle = depth > 0 ? ({ "--thread-indent": `${depth * 14}px` } as CSSProperties) @@ -95,6 +98,7 @@ export function PinnedThreadList({ )} {thread.name}
+ {badge && {badge}} {relativeTime && {relativeTime}}