diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 6a96e101e0..11b1b3dfb0 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -677,6 +677,23 @@ impl AppServerRequestHandle { } impl AppServerClient { + /// App-server platform family, which can differ from the executor's platform. + /// Older remote servers may omit this metadata. + pub fn platform_family(&self) -> Option<&str> { + match self { + Self::InProcess(_) => Some(std::env::consts::FAMILY), + Self::Remote(client) => client.platform_family(), + } + } + + /// App-server operating system as reported at initialization, including unknown values. + pub fn platform_os(&self) -> Option<&str> { + match self { + Self::InProcess(_) => Some(std::env::consts::OS), + Self::Remote(client) => client.platform_os(), + } + } + pub fn codex_home(&self, local_codex_home: &AbsolutePathBuf) -> Option { match self { Self::InProcess(_) => Some(AppServerPath::from_app_server( @@ -930,6 +947,22 @@ mod tests { async fn expect_remote_initialize(websocket: &mut tokio_tungstenite::WebSocketStream) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { + expect_remote_initialize_with_metadata( + websocket, + serde_json::json!({ + "userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust", + "codexHome": "/server/.codex", + }), + ) + .await; + } + + async fn expect_remote_initialize_with_metadata( + websocket: &mut tokio_tungstenite::WebSocketStream, + metadata: serde_json::Value, + ) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, { let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else { panic!("expected initialize request"); @@ -939,10 +972,7 @@ mod tests { websocket, JSONRPCMessage::Response(JSONRPCResponse { id: request.id, - result: serde_json::json!({ - "userAgent": "codex_cli_rs/9.8.7-test (Test OS; x86_64) rust", - "codexHome": "/server/.codex", - }), + result: metadata, }), ) .await; @@ -1079,7 +1109,15 @@ mod tests { #[tokio::test] async fn typed_request_roundtrip_works() { - let client = start_test_client(SessionSource::Exec).await; + let TestClient { + _codex_home, + client, + } = start_test_client(SessionSource::Exec).await; + let client = AppServerClient::InProcess(client); + assert_eq!( + (client.platform_family(), client.platform_os()), + (Some(std::env::consts::FAMILY), Some(std::env::consts::OS)) + ); let _response: ConfigRequirementsReadResponse = client .request_typed(ClientRequest::ConfigRequirementsRead { request_id: RequestId::Integer(1), @@ -1255,6 +1293,41 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_platform_metadata_preserves_reported_and_missing_values() { + for (family, os) in [ + (Some("windows"), Some("windows")), + (Some("unix"), Some("linux")), + (Some("future-family"), Some("future-os")), + (Some("unix"), None), + (None, Some("linux")), + (None, None), + ] { + let websocket_url = start_test_remote_server(move |mut websocket| async move { + let mut metadata = serde_json::json!({}); + if let Some(family) = family { + metadata["platformFamily"] = family.into(); + } + if let Some(os) = os { + metadata["platformOs"] = os.into(); + } + expect_remote_initialize_with_metadata(&mut websocket, metadata).await; + websocket.close(None).await.expect("close should succeed"); + }) + .await; + let client = AppServerClient::Remote( + RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) + .await + .expect("remote client should connect"), + ); + assert_eq!( + (client.platform_family(), client.platform_os()), + (family, os) + ); + client.shutdown().await.expect("shutdown should complete"); + } + } + #[tokio::test] async fn remote_typed_request_roundtrip_works() { let websocket_url = start_test_remote_server(|mut websocket| async move { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 0e375eef6f..ea7311f948 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -148,12 +148,19 @@ enum RemoteClientCommand { }, } +#[derive(Default)] +struct RemoteServerMetadata { + server_version: Option, + codex_home: Option, + platform_family: Option, + platform_os: Option, +} + pub struct RemoteAppServerClient { command_tx: mpsc::Sender, event_rx: mpsc::UnboundedReceiver, pending_events: VecDeque, - server_version: Option, - codex_home: Option, + metadata: RemoteServerMetadata, worker_handle: tokio::task::JoinHandle<()>, } @@ -185,11 +192,19 @@ impl RemoteAppServerClient { } pub fn server_version(&self) -> Option<&str> { - self.server_version.as_deref() + self.metadata.server_version.as_deref() } pub fn codex_home(&self) -> Option<&str> { - self.codex_home.as_deref() + self.metadata.codex_home.as_deref() + } + + pub fn platform_family(&self) -> Option<&str> { + self.metadata.platform_family.as_deref() + } + + pub fn platform_os(&self) -> Option<&str> { + self.metadata.platform_os.as_deref() } async fn connect_with_stream( @@ -202,7 +217,7 @@ impl RemoteAppServerClient { S: AsyncRead + AsyncWrite + Unpin + Send + 'static, { let mut stream = stream; - let (pending_events, server_version, codex_home) = initialize_remote_connection( + let (pending_events, metadata) = initialize_remote_connection( &mut stream, &endpoint, initialize_params, @@ -478,8 +493,7 @@ impl RemoteAppServerClient { command_tx, event_rx, pending_events: pending_events.into(), - server_version, - codex_home, + metadata, worker_handle, }) } @@ -604,8 +618,7 @@ impl RemoteAppServerClient { command_tx, event_rx, pending_events: _pending_events, - server_version: _server_version, - codex_home: _codex_home, + metadata: _, worker_handle, } = self; let mut worker_handle = worker_handle; @@ -800,14 +813,13 @@ async fn initialize_remote_connection( endpoint: &str, params: InitializeParams, initialize_timeout: Duration, -) -> IoResult<(Vec, Option, Option)> +) -> IoResult<(Vec, RemoteServerMetadata)> where S: AsyncRead + AsyncWrite + Unpin, { let initialize_request_id = RequestId::String("initialize".to_string()); let mut pending_events = Vec::new(); - let mut server_version = None; - let mut codex_home = None; + let mut metadata = RemoteServerMetadata::default(); write_jsonrpc_message( stream, JSONRPCMessage::Request(jsonrpc_request_from_client_request( @@ -831,7 +843,7 @@ where })?; match message { JSONRPCMessage::Response(response) if response.id == initialize_request_id => { - server_version = response + metadata.server_version = response .result .get("userAgent") .and_then(serde_json::Value::as_str) @@ -839,12 +851,16 @@ where let (_, rest) = user_agent.split_once('/')?; rest.split_whitespace().next().map(str::to_string) }); - codex_home = response + metadata.codex_home = response .result .get("codexHome") .and_then(serde_json::Value::as_str) .filter(|codex_home| !codex_home.is_empty()) .map(str::to_string); + metadata.platform_family = response.result.get("platformFamily") + .and_then(serde_json::Value::as_str).map(str::to_string); + metadata.platform_os = response.result.get("platformOs") + .and_then(serde_json::Value::as_str).map(str::to_string); break Ok(()); } JSONRPCMessage::Error(error) if error.id == initialize_request_id => { @@ -937,7 +953,7 @@ where ) .await?; - Ok((pending_events, server_version, codex_home)) + Ok((pending_events, metadata)) } fn app_server_event_from_notification(notification: JSONRPCNotification) -> Option { @@ -1027,8 +1043,7 @@ mod tests { command_tx, event_rx, pending_events: VecDeque::new(), - server_version: None, - codex_home: None, + metadata: RemoteServerMetadata::default(), worker_handle, }; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index a8650e70fb..eb6d181a3f 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -536,8 +536,9 @@ pub(crate) struct App { pub(crate) app_event_tx: AppEventSender, pub(crate) chat_widget: ChatWidget, workspace_command_runner: Option, - /// Config is stored here so we can recreate ChatWidgets as needed. + /// Legacy bootstrap and server-setting inputs; local preferences live in `local_settings`. pub(crate) config: Config, + pub(crate) local_settings: crate::local_settings::LocalSettings, launch_cwd: PathBuf, /// Resume anchor selected by `/cd`; ordinary resumes retain the immutable launch cwd. runtime_working_directory_override: Option, @@ -779,6 +780,7 @@ impl App { initial_user_message: Option, ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { + local_settings: self.local_settings.clone(), config: cfg, frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), @@ -786,6 +788,7 @@ impl App { initial_user_message, enhanced_keys_supported: self.enhanced_keys_supported, has_chatgpt_account: self.chat_widget.has_chatgpt_account(), + requires_openai_auth: self.chat_widget.requires_openai_auth, has_codex_backend_auth: self.chat_widget.has_codex_backend_auth(), model_catalog: self.model_catalog.clone(), feedback: self.feedback.clone(), diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index e61b914894..c07369511d 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -336,7 +336,7 @@ impl App { target_thread.status, codex_app_server_protocol::ThreadStatus::NotLoaded ); - let mut resume_config = if unloaded { + let (mut resume_config, local_settings) = if unloaded { let target_session = SessionTarget { path: target_thread.path.clone(), thread_id: root_thread_id, @@ -404,7 +404,12 @@ impl App { } }; let resumed = match app_server - .resume_thread(resume_config.clone(), root_thread_id, resume_model_settings) + .resume_thread( + &local_settings, + resume_config.clone(), + root_thread_id, + resume_model_settings, + ) .await { Ok(resumed) => resumed, @@ -455,10 +460,11 @@ impl App { && profile.turn_override == RuntimePermissionProfileTurnOverride::LegacySandbox }); + self.local_settings = local_settings; self.config = resume_config; tui.set_notification_settings( - self.config.tui_notifications.method, - self.config.tui_notifications.condition, + self.local_settings.tui.notification_settings.method, + self.local_settings.tui.notification_settings.condition, ); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 4cb7d3b8f1..8725f22e14 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -202,6 +202,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res for thread_id in [ids[0], ids[21]] { app_server .resume_thread( + &app.local_settings, config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -257,6 +258,7 @@ async fn shared_overview_seeds_once_and_retains_locally_resumed_history() -> Res // Opening older history is a local addition, even without starting a turn. let resumed = app_server .resume_thread( + &app.local_settings, config.clone(), ids[1], crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -699,6 +701,45 @@ async fn failed_root_switch_keeps_background_requests_on_the_active_session() -> Ok(()) } +#[tokio::test] +async fn root_switch_loads_local_preferences_from_disk() -> Result<()> { + let mut app = make_test_app().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let previous = app_server.start_thread(&app.config).await?; + app.enqueue_primary_thread_session(previous.session, previous.turns) + .await?; + let target_thread_id = ThreadId::from_string( + &app_test_support::create_fake_rollout( + app.config.codex_home.as_path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Target task", + Some(&app.config.model_provider_id), + /*git_info*/ None, + ) + .expect("materialize target rollout"), + )?; + std::fs::write( + app.local_settings.user_config_path.as_path(), + "[tui]\ntheme = \"dracula\"\nresume_cwd = \"session\"\n[history]\npersistence = \"none\"\n", + )?; + let config = app + .rebuild_config_for_cwd(app.config.cwd.to_path_buf()) + .await?; + let expected = crate::local_settings::LocalSettings::from(&config); + let mut tui = crate::tui::test_support::make_test_tui()?; + + app.select_agents_overview_thread(&mut tui, &mut app_server, target_thread_id) + .await?; + + assert_eq!(app.current_displayed_thread_id(), Some(target_thread_id)); + assert_eq!(app.local_settings, expected); + assert_eq!(app.chat_widget.local_settings, expected); + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> { let mut app = make_test_app().await; @@ -721,6 +762,7 @@ async fn root_switch_preserves_idle_root_with_running_subagent() -> Result<()> { )?; let target = app_server .resume_thread( + &app.local_settings, app.config.clone(), target_thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -801,6 +843,7 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() let mut app_server = crate::start_embedded_app_server_for_picker(&server_config).await?; let loaded = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&server_config), server_config.clone(), thread_ids[0], crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -861,6 +904,7 @@ async fn overview_selection_applies_user_permissions_only_to_unloaded_threads() .await?; let observed = app_server .resume_thread( + &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -961,6 +1005,7 @@ async fn overview_cold_resume_honors_working_directory_selection() -> Result<()> .await?; let observed = app_server .resume_thread( + &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, diff --git a/codex-rs/tui/src/app/backend_banner_fallback.rs b/codex-rs/tui/src/app/backend_banner_fallback.rs index c173bb07d7..287112d0a1 100644 --- a/codex-rs/tui/src/app/backend_banner_fallback.rs +++ b/codex-rs/tui/src/app/backend_banner_fallback.rs @@ -40,6 +40,7 @@ impl App { collaboration_mode: Some(mode.clone()), service_tier: service_tier_resolution::service_tier_update_for_core( self.chat_widget.config_ref(), + &self.local_settings.notices, &target.model, &self.model_catalog.try_list_models().unwrap_or_default(), ), diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 861c4f6328..7ca01b5eb0 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -238,6 +238,13 @@ impl App { .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config, RuntimePolicyOverrideScope::All); + self.local_settings = crate::local_settings::LocalSettings::from(&config); + // Other preferences have runtime caches and are adopted when the widget is replaced. + self.chat_widget + .local_settings + .tui + .terminal_resize_reflow_max_rows = + self.local_settings.tui.terminal_resize_reflow_max_rows; self.config = config; self.chat_widget.sync_plugin_mentions_config(&self.config); Ok(()) @@ -279,9 +286,12 @@ impl App { &mut self, current_cwd: &Path, resume_cwd: PathBuf, - ) -> Result { + ) -> Result<(Config, crate::local_settings::LocalSettings)> { match self.rebuild_config_for_cwd(resume_cwd.clone()).await { - Ok(config) => Ok(config), + Ok(config) => { + let local_settings = crate::local_settings::LocalSettings::from(&config); + Ok((config, local_settings)) + } Err(err) => { if crate::session_resume::cwds_differ(current_cwd, &resume_cwd) { Err(err) @@ -292,7 +302,7 @@ impl App { cwd = %resume_cwd_display, "failed to rebuild config for same-cwd resume; using current in-memory config" ); - Ok(self.config.clone()) + Ok((self.config.clone(), self.local_settings.clone())) } } } @@ -877,26 +887,28 @@ impl App { } pub(super) fn sync_tui_theme_selection(&mut self, name: String) { - self.config.tui_theme = Some(name.clone()); + self.local_settings.tui.theme = Some(name.clone()); self.chat_widget.set_tui_theme(Some(name)); } #[cfg(test)] pub(super) fn sync_tui_pet_selection(&mut self, pet: String) { - self.config.tui_pet = Some(pet.clone()); + self.local_settings.tui.pet = Some(pet.clone()); self.chat_widget.set_tui_pet(Some(pet)); } pub(super) fn sync_tui_pet_disabled(&mut self) { let pet = crate::pets::DISABLED_PET_ID.to_string(); - self.config.tui_pet = Some(pet.clone()); + self.local_settings.tui.pet = Some(pet.clone()); self.chat_widget.set_tui_pet(Some(pet)); } pub(super) fn restore_runtime_theme_from_config(&self) { - if let Some(name) = self.config.tui_theme.as_deref() - && let Some(theme) = - crate::render::highlight::resolve_theme_by_name(name, Some(&self.config.codex_home)) + if let Some(name) = self.local_settings.tui.theme.as_deref() + && let Some(theme) = crate::render::highlight::resolve_theme_by_name( + name, + Some(&self.local_settings.codex_home), + ) { crate::render::highlight::set_syntax_theme(theme); return; @@ -905,7 +917,7 @@ impl App { let auto_theme_name = crate::render::highlight::adaptive_default_theme_name(); if let Some(theme) = crate::render::highlight::resolve_theme_by_name( auto_theme_name, - Some(&self.config.codex_home), + Some(&self.local_settings.codex_home), ) { crate::render::highlight::set_syntax_theme(theme); } @@ -1625,6 +1637,8 @@ enabled = false #[tokio::test] async fn refresh_in_memory_config_from_disk_updates_resize_reflow_config() -> Result<()> { let mut app = make_test_app().await; + let mut expected_widget_settings = app.chat_widget.local_settings.clone(); + expected_widget_settings.tui.terminal_resize_reflow_max_rows = Some(9000); let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); std::fs::write( @@ -1632,15 +1646,18 @@ enabled = false r#" [tui] terminal_resize_reflow_max_rows = 9000 +theme = "dracula" "#, )?; app.refresh_in_memory_config_from_disk().await?; assert_eq!( - app.config.terminal_resize_reflow.max_rows, + app.local_settings.terminal_resize_reflow().max_rows, crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(9000) ); + assert_eq!(app.local_settings.tui.theme.as_deref(), Some("dracula")); + assert_eq!(app.chat_widget.local_settings, expected_widget_settings); Ok(()) } @@ -1688,6 +1705,7 @@ terminal_resize_reflow_max_rows = 9000 async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> { let mut app = make_test_app().await; + app.sync_tui_theme_selection("dracula".to_string()); let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); std::fs::write(codex_home.path().join("config.toml"), "[broken")?; @@ -1698,7 +1716,7 @@ terminal_resize_reflow_max_rows = 9000 .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) .await?; - assert_eq!(resume_config, current_config); + assert_eq!(resume_config, (current_config, app.local_settings.clone())); Ok(()) } @@ -1726,22 +1744,42 @@ terminal_resize_reflow_max_rows = 9000 app.sync_tui_theme_selection("dracula".to_string()); - assert_eq!(app.config.tui_theme.as_deref(), Some("dracula")); + assert_eq!(app.local_settings.tui.theme.as_deref(), Some("dracula")); assert_eq!( - app.chat_widget.config_ref().tui_theme.as_deref(), + app.chat_widget.local_settings.tui.theme.as_deref(), Some("dracula") ); } + #[tokio::test] + async fn replacement_preserves_live_local_settings_and_server_auth_requirement() -> Result<()> { + let mut app = make_test_app().await; + let mut tui = crate::tui::test_support::make_test_tui()?; + app.sync_tui_theme_selection("dracula".to_string()); + app.chat_widget.requires_openai_auth = false; + let mut legacy_config = app.config.clone(); + legacy_config.tui_theme = Some("nord".to_string()); + legacy_config.model_provider.requires_openai_auth = true; + let init = app.chatwidget_init_for_forked_or_resumed_thread( + &mut tui, + legacy_config, + /*initial_user_message*/ None, + ); + let replacement = ChatWidget::new_with_app_event(init); + assert_eq!(replacement.local_settings, app.local_settings); + assert!(!replacement.requires_openai_auth); + Ok(()) + } + #[tokio::test] async fn sync_tui_pet_selection_updates_chat_widget_config_copy() { let mut app = make_test_app().await; app.sync_tui_pet_selection("chefito".to_string()); - assert_eq!(app.config.tui_pet.as_deref(), Some("chefito")); + assert_eq!(app.local_settings.tui.pet.as_deref(), Some("chefito")); assert_eq!( - app.chat_widget.config_ref().tui_pet.as_deref(), + app.chat_widget.local_settings.tui.pet.as_deref(), Some("chefito") ); } @@ -1753,11 +1791,11 @@ terminal_resize_reflow_max_rows = 9000 app.sync_tui_pet_disabled(); assert_eq!( - app.config.tui_pet.as_deref(), + app.local_settings.tui.pet.as_deref(), Some(crate::pets::DISABLED_PET_ID) ); assert_eq!( - app.chat_widget.config_ref().tui_pet.as_deref(), + app.chat_widget.local_settings.tui.pet.as_deref(), Some(crate::pets::DISABLED_PET_ID) ); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index d2ea7c5204..f2379a0bae 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -251,6 +251,7 @@ impl App { match crate::resume_picker::run_resume_picker_from_existing_session_with_app_server( tui, &self.config, + &self.local_settings, /*show_all*/ false, /*include_non_interactive*/ false, picker_app_server, @@ -361,7 +362,7 @@ impl App { fork_config.model = Some(self.chat_widget.current_model().to_string()); fork_config.model_reasoning_effort = self.chat_widget.current_reasoning_effort(); - match app_server.fork_thread(fork_config, thread_id).await { + match app_server.fork_thread(&self.local_settings, fork_config, thread_id).await { Ok(mut forked) => { let name_error = if let Some(name) = name { match app_server @@ -514,8 +515,7 @@ impl App { let before_turn_id = before_turn_id .or_else(|| turns.first().map(|turn| turn.id.clone())); app_server - .fork_thread_at( - config.clone(), + .fork_thread_at(&self.local_settings, config.clone(), thread_id, /*last_turn_id*/ None, before_turn_id, @@ -2433,7 +2433,8 @@ impl App { .await; } AppEvent::PersistWorldWritableWarningAcknowledged => { - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) + self.local_settings.notices.hide_world_writable_warning = Some(true); + if let Err(err) = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .set_hide_world_writable_warning(/*acknowledged*/ true) .apply() .await @@ -2448,7 +2449,8 @@ impl App { } } AppEvent::PersistRateLimitSwitchPromptHidden => { - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) + self.local_settings.notices.hide_rate_limit_model_nudge = Some(true); + if let Err(err) = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .set_hide_rate_limit_model_nudge(/*acknowledged*/ true) .apply() .await @@ -2491,7 +2493,7 @@ impl App { from_model, to_model, } => { - if let Err(err) = ConfigEditsBuilder::for_config(&self.config) + if let Err(err) = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .record_model_migration_seen(from_model.as_str(), to_model.as_str()) .apply() .await @@ -2836,14 +2838,14 @@ impl App { let items_edit = crate::legacy_core::config::edit::status_line_items_edit(&ids); let colors_edit = crate::legacy_core::config::edit::status_line_use_colors_edit(use_theme_colors); - let apply_result = ConfigEditsBuilder::for_config(&self.config) + let apply_result = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([items_edit, colors_edit]) .apply() .await; match apply_result { Ok(()) => { - self.config.tui_status_line = Some(ids.clone()); - self.config.tui_status_line_use_colors = use_theme_colors; + self.local_settings.tui.status_line = Some(ids.clone()); + self.local_settings.tui.status_line_use_colors = use_theme_colors; self.chat_widget.setup_status_line(items, use_theme_colors); } Err(err) => { @@ -2877,13 +2879,13 @@ impl App { AppEvent::TerminalTitleSetup { items } => { let ids = items.iter().map(ToString::to_string).collect::>(); let edit = crate::legacy_core::config::edit::terminal_title_items_edit(&ids); - let apply_result = ConfigEditsBuilder::for_config(&self.config) + let apply_result = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await; match apply_result { Ok(()) => { - self.config.tui_terminal_title = Some(ids.clone()); + self.local_settings.tui.terminal_title = Some(ids.clone()); self.chat_widget.setup_terminal_title(items); } Err(err) => { @@ -2903,7 +2905,7 @@ impl App { } AppEvent::SyntaxThemeSelected { name } => { let edit = crate::legacy_core::config::edit::syntax_theme_edit(&name); - let apply_result = ConfigEditsBuilder::for_config(&self.config) + let apply_result = ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await; @@ -2915,7 +2917,7 @@ impl App { // navigating, the runtime theme must still be applied. if let Some(theme) = crate::render::highlight::resolve_theme_by_name( &name, - Some(&self.config.codex_home), + Some(&self.local_settings.codex_home), ) { crate::render::highlight::set_syntax_theme(theme); } @@ -3050,7 +3052,7 @@ impl App { intent: crate::app_event::KeymapEditIntent, ) { let outcome = match crate::keymap_setup::keymap_with_edit( - &self.config.tui_keymap, + &self.local_settings.tui.keymap, &self.keymap, &context, &action, @@ -3088,14 +3090,14 @@ impl App { let edit = crate::legacy_core::config::edit::keymap_bindings_edit(&context, &action, &bindings); - match ConfigEditsBuilder::for_config(&self.config) + match ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await { Ok(()) => { self.cancel_pending_key_chord(); - self.config.tui_keymap = keymap_config.clone(); + self.local_settings.tui.keymap = keymap_config.clone(); self.keymap = runtime_keymap.clone(); self.chat_widget .apply_keymap_update(keymap_config, &runtime_keymap); @@ -3121,7 +3123,7 @@ impl App { async fn apply_keymap_clear(&mut self, context: String, action: String) { let keymap_config = match crate::keymap_setup::keymap_without_custom_binding( - &self.config.tui_keymap, + &self.local_settings.tui.keymap, &context, &action, ) { @@ -3142,14 +3144,14 @@ impl App { }; let edit = crate::legacy_core::config::edit::keymap_binding_clear_edit(&context, &action); - match ConfigEditsBuilder::for_config(&self.config) + match ConfigEditsBuilder::for_config_path(self.local_settings.user_config_path.as_path()) .with_edits([edit]) .apply() .await { Ok(()) => { self.cancel_pending_key_chord(); - self.config.tui_keymap = keymap_config.clone(); + self.local_settings.tui.keymap = keymap_config.clone(); self.keymap = runtime_keymap.clone(); self.chat_widget .apply_keymap_update(keymap_config, &runtime_keymap); diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs index 89e6a6e2a7..a21fe1a7f1 100644 --- a/codex-rs/tui/src/app/pets.rs +++ b/codex-rs/tui/src/app/pets.rs @@ -78,9 +78,9 @@ impl App { pub(super) fn handle_pet_selected(&mut self, tui: &mut tui::Tui, pet_id: String) { let request_id = self.chat_widget.show_pet_selection_loading_popup(); tui.frame_requester().schedule_frame(); - let codex_home = self.config.codex_home.clone(); + let codex_home = self.local_settings.codex_home.clone(); let frame_requester = tui.frame_requester(); - let animations_enabled = self.config.animations; + let animations_enabled = self.local_settings.tui.animations; let tx = self.app_event_tx.clone(); let pet_http_client = self.chat_widget.pet_http_client.clone(); std::mem::drop(tokio::spawn(async move { @@ -104,7 +104,7 @@ impl App { pub(super) async fn handle_pet_disabled(&mut self, tui: &mut tui::Tui) { let edit = crate::legacy_core::config::edit::tui_pet_edit(crate::pets::DISABLED_PET_ID); - let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + let apply_result = ConfigEditsBuilder::new(&self.local_settings.codex_home) .with_edits([edit]) .apply() .await; @@ -147,13 +147,13 @@ impl App { match result { Ok(ambient_pet) => { let edit = crate::legacy_core::config::edit::tui_pet_edit(&pet_id); - match ConfigEditsBuilder::new(&self.config.codex_home) + match ConfigEditsBuilder::new(&self.local_settings.codex_home) .with_edits([edit]) .apply() .await { Ok(()) => { - self.config.tui_pet = Some(pet_id.clone()); + self.local_settings.tui.pet = Some(pet_id.clone()); self.chat_widget .set_tui_pet_loaded(Some(pet_id), ambient_pet); } @@ -178,7 +178,7 @@ impl App { pet_id: String, result: Result, String>, ) { - if self.config.tui_pet.as_deref() != Some(pet_id.as_str()) { + if self.local_settings.tui.pet.as_deref() != Some(pet_id.as_str()) { return; } diff --git a/codex-rs/tui/src/app/recap.rs b/codex-rs/tui/src/app/recap.rs index 2fd05a83af..d7d50b3e51 100644 --- a/codex-rs/tui/src/app/recap.rs +++ b/codex-rs/tui/src/app/recap.rs @@ -179,7 +179,7 @@ pub(super) struct RecapRequest { impl App { fn recap_trigger_enabled(&self, trigger: RecapTrigger) -> bool { match trigger { - RecapTrigger::Automatic => self.config.tui_auto_recap, + RecapTrigger::Automatic => self.local_settings.tui.auto_recap, RecapTrigger::Manual => true, } } @@ -189,7 +189,7 @@ impl App { task.abort(); } - if !self.config.tui_auto_recap { + if !self.local_settings.tui.auto_recap { return; } let Some(deadline) = self.recap.next_check_deadline() else { diff --git a/codex-rs/tui/src/app/recap_tests.rs b/codex-rs/tui/src/app/recap_tests.rs index 51615f4169..4fecb1fc47 100644 --- a/codex-rs/tui/src/app/recap_tests.rs +++ b/codex-rs/tui/src/app/recap_tests.rs @@ -397,7 +397,7 @@ async fn auto_recap_opt_out_cancels_scheduling_after_restored_progress() { app.schedule_recap_check(thread_id, now); tokio::task::yield_now().await; - app.config.tui_auto_recap = false; + app.local_settings.tui.auto_recap = false; app.schedule_recap_check(thread_id, now); tokio::time::advance(RECAP_DELAY).await; tokio::task::yield_now().await; @@ -658,7 +658,7 @@ async fn auto_recap_opt_out_discards_results_without_retrying() { Err("temporary failure".to_string()), ] { let (request, temporary_thread_id) = track_in_flight_recap(&mut app, thread_id); - app.config.tui_auto_recap = false; + app.local_settings.tui.auto_recap = false; assert!( app.handle_generated_recap(request, temporary_thread_id, result) diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index f10c1352cb..4be0cc49c0 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -29,6 +29,7 @@ pub(super) struct Reconnected { pub(super) async fn reconnect( target: AppServerTarget, config: Config, + local_settings: crate::local_settings::LocalSettings, thread_id: Option, remote_cwd: Option, task_tools: ThreadToolTransport, @@ -66,6 +67,7 @@ pub(super) async fn reconnect( let thread = if let Some(thread_id) = thread_id { match session .resume_thread( + &local_settings, config.clone(), thread_id, ResumeModelSettings::PreserveExistingThread, @@ -233,6 +235,7 @@ impl App { self.rate_limit_refresh_state.invalidate_recovery(); session.inherit_task_tool_capabilities(app_server); *app_server = session; + self.chat_widget.requires_openai_auth = bootstrap.requires_openai_auth; self.chat_widget.remote_connection = crate::status::remote_connection::remote_connection_status_value( &self.app_server_target, diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 9de5ebc0c3..b94273b974 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -208,8 +208,9 @@ impl App { return; } - let max_rows = - crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow); + let max_rows = crate::resize_reflow_cap::resize_reflow_max_rows( + self.local_settings.terminal_resize_reflow(), + ); if let Some(buffer) = &mut self.initial_history_replay_buffer { if let Some(max_rows) = max_rows { Self::buffer_initial_history_replay_display_lines(buffer, display, max_rows); @@ -254,7 +255,9 @@ impl App { } fn resize_reflow_max_rows(&self) -> Option { - crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow) + crate::resize_reflow_cap::resize_reflow_max_rows( + self.local_settings.terminal_resize_reflow(), + ) } pub(super) fn update_visible_history_rows(&mut self, screen_size: Size) { diff --git a/codex-rs/tui/src/app/resize_reflow_tests.rs b/codex-rs/tui/src/app/resize_reflow_tests.rs index b3e989bab9..56b6d2b81a 100644 --- a/codex-rs/tui/src/app/resize_reflow_tests.rs +++ b/codex-rs/tui/src/app/resize_reflow_tests.rs @@ -1,7 +1,6 @@ use super::*; use crate::app::test_support::make_test_app; use crate::history_cell::PlainHistoryCell; -use crate::legacy_core::config::TerminalResizeReflowMaxRows; use pretty_assertions::assert_eq; fn plain_history_cells(count: usize) -> Vec> { @@ -25,7 +24,7 @@ fn rendered_line_text(line: &HyperlinkLine) -> String { #[tokio::test] async fn resize_reflow_preserves_configured_scrollback_beyond_the_visible_viewport() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(32); app.transcript_cells = plain_history_cells(/*count*/ 64); let screen_size = Size::new(/*width*/ 80, /*height*/ 24); let chat_height = app.with_chat_widget_frame(screen_size.width, |height, _| height); @@ -49,7 +48,7 @@ async fn resize_reflow_preserves_configured_scrollback_beyond_the_visible_viewpo #[tokio::test] async fn initial_resume_replay_retains_scrollback_beyond_the_visible_viewport() -> Result<()> { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(32); let screen_size = Size::new(/*width*/ 80, /*height*/ 24); app.update_visible_history_rows(screen_size); let visible_history_rows = app @@ -99,7 +98,7 @@ async fn initial_resume_replay_retains_scrollback_beyond_the_visible_viewport() #[tokio::test] async fn resize_reflow_preserves_configured_scrollback_when_the_terminal_height_changes() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(48); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(48); app.transcript_cells = plain_history_cells(/*count*/ 64); app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 24)); @@ -130,7 +129,7 @@ async fn resize_reflow_preserves_configured_scrollback_when_the_terminal_height_ #[tokio::test] async fn resize_reflow_preserves_explicitly_unlimited_history() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.transcript_cells = plain_history_cells(/*count*/ 20); app.update_visible_history_rows(Size::new(/*width*/ 80, /*height*/ 24)); @@ -151,7 +150,7 @@ async fn resize_reflow_preserves_explicitly_unlimited_history() { #[tokio::test] async fn capped_resize_reflow_prepends_transcript_notice_without_changing_transcript() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(8); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(8); app.transcript_cells = plain_history_cells(/*count*/ 12); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); @@ -181,7 +180,7 @@ async fn capped_resize_reflow_prepends_transcript_notice_without_changing_transc #[tokio::test] async fn capped_resize_reflow_counts_wrapped_notice_rows() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(8); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(8); app.transcript_cells = plain_history_cells(/*count*/ 12); let rendered = app.render_transcript_lines_for_reflow(/*width*/ 28); @@ -210,7 +209,7 @@ async fn capped_resize_reflow_counts_wrapped_notice_rows() { #[tokio::test] async fn one_row_history_cap_preserves_conversation_instead_of_notice() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(1); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(1); app.scrollback_has_older_history = true; app.transcript_cells = plain_history_cells(/*count*/ 2); @@ -231,7 +230,7 @@ async fn one_row_history_cap_preserves_conversation_instead_of_notice() { #[tokio::test] async fn paginated_resize_reflow_prepends_transcript_notice_for_unloaded_history() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(32); app.scrollback_has_older_history = true; app.transcript_cells = plain_history_cells(/*count*/ 2); @@ -256,7 +255,7 @@ async fn paginated_resize_reflow_prepends_transcript_notice_for_unloaded_history #[tokio::test] async fn scrollback_refill_only_loads_older_pages_for_an_underfilled_row_cap() { let mut app = make_test_app().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(32); app.scrollback_has_older_history = true; assert!(app.scrollback_history_needs_top_up(/*rendered_rows*/ 31)); @@ -266,6 +265,6 @@ async fn scrollback_refill_only_loads_older_pages_for_an_underfilled_row_cap() { assert!(!app.scrollback_history_needs_top_up(/*rendered_rows*/ 31)); app.scrollback_has_older_history = true; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); assert!(!app.scrollback_history_needs_top_up(/*rendered_rows*/ 31)); } diff --git a/codex-rs/tui/src/app/resume_config.rs b/codex-rs/tui/src/app/resume_config.rs index a4fed62540..96a735e7e7 100644 --- a/codex-rs/tui/src/app/resume_config.rs +++ b/codex-rs/tui/src/app/resume_config.rs @@ -1,5 +1,6 @@ //! Shared configuration and working-directory resolution for ordinary and overview cold resumes. //! Keeps CLI/runtime cwd precedence, remote-workspace checks, and interactive prompts aligned. +//! Carries local preferences alongside the resolved configuration for session replacement. use super::*; use codex_config::types::ResumeCwdMode; @@ -10,7 +11,7 @@ impl App { tui: &mut tui::Tui, app_server: &AppServerSession, target_session: &SessionTarget, - ) -> std::result::Result { + ) -> std::result::Result<(Config, crate::local_settings::LocalSettings), AppRunControl> { self.refresh_in_memory_config_from_disk_best_effort("resuming a thread") .await; let cwd_override = self @@ -19,7 +20,7 @@ impl App { .or(self.harness_overrides.cwd.as_deref()) .or_else(|| app_server.remote_cwd_override()); let resume_cwd_mode = crate::session_resume::effective_resume_cwd_mode( - self.config.tui_resume_cwd, + self.local_settings.tui.resume_cwd, cwd_override, ); let remembered_current_cwd = cwd_override.unwrap_or(self.launch_cwd.as_path()); diff --git a/codex-rs/tui/src/app/safety_buffering.rs b/codex-rs/tui/src/app/safety_buffering.rs index 132b8cbe62..0325f185db 100644 --- a/codex-rs/tui/src/app/safety_buffering.rs +++ b/codex-rs/tui/src/app/safety_buffering.rs @@ -105,6 +105,7 @@ impl App { /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, + /*local_settings*/ None, crate::app_server_session::HistoryHydrationScope::Initial, ) .await?; @@ -176,6 +177,7 @@ impl App { self.config = retry_config.clone(); let started = app_server .fork_thread_at( + &self.local_settings, retry_config, thread_id, /*last_turn_id*/ None, diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index 8d78551255..b006a8ec2d 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -376,6 +376,7 @@ impl App { let (session, turns, live_attached) = match app_server .resume_thread( + &self.local_settings, self.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -411,6 +412,7 @@ impl App { /*turn_cursor*/ None, /*item_cursor*/ None, Some(&self.config), + Some(&self.local_settings), crate::app_server_session::HistoryHydrationScope::Initial, ) .await @@ -1086,7 +1088,7 @@ impl App { return Ok(AppRunControl::Continue); } - let mut resume_config = match self + let (mut resume_config, local_settings) = match self .resume_config_for_target(tui, app_server, &target_session) .await { @@ -1106,6 +1108,7 @@ impl App { } match app_server .resume_thread( + &local_settings, resume_config.clone(), target_session.thread_id, self.resume_model_settings(), @@ -1115,10 +1118,11 @@ impl App { Ok(resumed) => { let resumed_thread_id = resumed.session.thread_id; self.shutdown_current_thread(app_server).await; + self.local_settings = local_settings; self.config = resume_config; tui.set_notification_settings( - self.config.tui_notifications.method, - self.config.tui_notifications.condition, + self.local_settings.tui.notification_settings.method, + self.local_settings.tui.notification_settings.condition, ); self.file_search .update_search_dir(self.config.cwd.to_path_buf()); diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index ba084038ab..dcbc7b9773 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -702,7 +702,7 @@ impl App { let fork_config = self.side_fork_config(); match app_server - .fork_side_thread(fork_config, parent_thread_id) + .fork_side_thread(&self.local_settings, fork_config, parent_thread_id) .await { Ok(forked) => { diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs index 06c9402ce6..29bcd42b41 100644 --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs @@ -123,14 +123,15 @@ impl App { Ok(()) } + let mut local_settings = crate::local_settings::LocalSettings::from(&config); let startup_started_at = Instant::now(); let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); emit_project_config_warnings(&app_event_tx, &config); emit_system_bwrap_warning(&app_event_tx, &config); tui.set_notification_settings( - config.tui_notifications.method, - config.tui_notifications.condition, + local_settings.tui.notification_settings.method, + local_settings.tui.notification_settings.condition, ); let harness_overrides = @@ -145,6 +146,11 @@ impl App { Err(err) => return shutdown_on_startup_error(app_server, err).await, }, }; + tracing::debug!( + has_platform_family = app_server.app_server_platform_family().is_some(), + has_platform_os = app_server.app_server_platform_os().is_some(), + "connected app-server platform" + ); let bootstrap_ms = bootstrap.duration.as_millis(); if matches!( &session_selection, @@ -171,6 +177,7 @@ impl App { let exit_info = handle_model_migration_prompt_if_needed( tui, &mut config, + &local_settings, model.as_str(), &app_event_tx, &available_models, @@ -226,8 +233,9 @@ impl App { serde_json::from_value(serde_json::json!("cli")) .unwrap_or_else(|err| panic!("cli session source should deserialize: {err}")), ); - if config - .tui_status_line + if local_settings + .tui + .status_line .as_ref() .is_some_and(|cmd| !cmd.is_empty()) { @@ -283,7 +291,7 @@ impl App { .run_until( tui, prepare_startup_tooltip_override( - &mut config, + &mut local_settings, &available_models, is_first_run, ), @@ -295,6 +303,7 @@ impl App { } }; let init = crate::chatwidget::ChatWidgetInit { + local_settings: local_settings.clone(), config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), @@ -307,6 +316,7 @@ impl App { ), enhanced_keys_supported, has_chatgpt_account, + requires_openai_auth, has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), @@ -339,6 +349,7 @@ impl App { .run_until( tui, app_server.resume_thread( + &local_settings, config.clone(), target_session.thread_id, model_settings, @@ -366,6 +377,7 @@ impl App { return Ok(cancel_session_start(app_server).await); }; let init = crate::chatwidget::ChatWidgetInit { + local_settings: local_settings.clone(), config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), @@ -378,6 +390,7 @@ impl App { ), enhanced_keys_supported, has_chatgpt_account, + requires_openai_auth, has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), @@ -403,7 +416,11 @@ impl App { let forked = match startup_draft .run_until( tui, - app_server.fork_thread(config.clone(), target_session.thread_id), + app_server.fork_thread( + &local_settings, + config.clone(), + target_session.thread_id, + ), ) .await { @@ -427,6 +444,7 @@ impl App { return Ok(cancel_session_start(app_server).await); }; let init = crate::chatwidget::ChatWidgetInit { + local_settings: local_settings.clone(), config: config.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), @@ -439,6 +457,7 @@ impl App { ), enhanced_keys_supported, has_chatgpt_account, + requires_openai_auth, has_codex_backend_auth, model_catalog: model_catalog.clone(), feedback: feedback.clone(), @@ -467,13 +486,14 @@ impl App { .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); - let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| { - color_eyre::eyre::eyre!( - "Invalid `tui.keymap` configuration: {err}\n\ + let runtime_keymap = + RuntimeKeymap::from_config(&local_settings.tui.keymap).map_err(|err| { + color_eyre::eyre::eyre!( + "Invalid `tui.keymap` configuration: {err}\n\ Fix the config and retry.\n\ See the Codex keymap documentation for supported actions and examples." - ) - })?; + ) + })?; #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -484,6 +504,7 @@ See the Codex keymap documentation for supported actions and examples." chat_widget, workspace_command_runner: Some(workspace_command_runner), config, + local_settings, launch_cwd, runtime_working_directory_override: None, state_db, @@ -601,7 +622,7 @@ See the Codex keymap documentation for supported actions and examples." != WindowsSandboxLevel::Disabled && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) && !app - .config + .local_settings .notices .hide_world_writable_warning .unwrap_or(false); @@ -722,6 +743,7 @@ See the Codex keymap documentation for supported actions and examples." reconnect = Some(Box::pin(reconnect::reconnect( app.app_server_target.clone(), app.config.clone(), + app.local_settings.clone(), app.current_displayed_thread_id(), app_server.remote_cwd_override().map(Path::to_path_buf), app_server.thread_tool_transport(), diff --git a/codex-rs/tui/src/app/startup_prompts.rs b/codex-rs/tui/src/app/startup_prompts.rs index 5ecc7a9dfc..3014bebade 100644 --- a/codex-rs/tui/src/app/startup_prompts.rs +++ b/codex-rs/tui/src/app/startup_prompts.rs @@ -154,7 +154,10 @@ pub(super) fn should_show_model_migration_prompt( false } -pub(super) fn migration_prompt_hidden(config: &Config, migration_config_key: &str) -> bool { +pub(super) fn migration_prompt_hidden( + config: &crate::local_settings::LocalSettings, + migration_config_key: &str, +) -> bool { match migration_config_key { HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG => config .notices @@ -228,28 +231,29 @@ pub(super) fn select_model_availability_nux( } pub(super) async fn prepare_startup_tooltip_override( - config: &mut Config, + config: &mut crate::local_settings::LocalSettings, available_models: &[ModelPreset], is_first_run: bool, ) -> Option { - if is_first_run || !config.show_tooltips { + if is_first_run || !config.tui.show_tooltips { return None; } let tooltip_override = - select_model_availability_nux(available_models, &config.model_availability_nux)?; + select_model_availability_nux(available_models, &config.tui.model_availability_nux)?; let shown_count = config + .tui .model_availability_nux .shown_count .get(&tooltip_override.model_slug) .copied() .unwrap_or_default(); let next_count = shown_count.saturating_add(1); - let mut updated_shown_count = config.model_availability_nux.shown_count.clone(); + let mut updated_shown_count = config.tui.model_availability_nux.shown_count.clone(); updated_shown_count.insert(tooltip_override.model_slug.clone(), next_count); - if let Err(err) = ConfigEditsBuilder::for_config(config) + if let Err(err) = ConfigEditsBuilder::for_config_path(config.user_config_path.as_path()) .set_model_availability_nux_count(&updated_shown_count) .apply() .await @@ -262,13 +266,14 @@ pub(super) async fn prepare_startup_tooltip_override( return Some(tooltip_override.message); } - config.model_availability_nux.shown_count = updated_shown_count; + config.tui.model_availability_nux.shown_count = updated_shown_count; Some(tooltip_override.message) } pub(super) async fn handle_model_migration_prompt_if_needed( tui: &mut tui::Tui, config: &mut Config, + local_settings: &crate::local_settings::LocalSettings, model: &str, app_event_tx: &AppEventSender, available_models: &[ModelPreset], @@ -287,7 +292,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed( .. }) = upgrade { - if migration_prompt_hidden(config, migration_config_key.as_str()) { + if migration_prompt_hidden(local_settings, migration_config_key.as_str()) { return Ok(None); } @@ -295,7 +300,7 @@ pub(super) async fn handle_model_migration_prompt_if_needed( if !should_show_model_migration_prompt( model, &target_model, - &config.notices.model_migrations, + &local_settings.notices.model_migrations, available_models, ) { return Ok(None); diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 9ef42b70fc..ece72fa1dd 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -24,6 +24,7 @@ pub(super) async fn make_test_app() -> App { workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, + local_settings: crate::local_settings::LocalSettings::from(&config), config, state_db: None, cli_kv_overrides: Vec::new(), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6971dab720..b18e06a4b9 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -75,7 +75,6 @@ use crate::diff_model::FileChange; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::PermissionProfileSnapshot; -use crate::legacy_core::config::TerminalResizeReflowMaxRows; use codex_app_server_client::AppServerPath; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; @@ -563,6 +562,8 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm let config = app.config.clone(); let model = get_model_offline_for_tests(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { + requires_openai_auth: true, + local_settings: crate::local_settings::LocalSettings::from(&config), config, frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), @@ -2280,6 +2281,7 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() - crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; let root = app_server .resume_thread( + &app.local_settings, app.config.clone(), root_thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -2358,6 +2360,7 @@ fn selected_and_resumed_threads_use_server_capability_for_v1_and_v2_children() - let resumed = app_server .resume_thread( + &app.local_settings, app.config.clone(), child_thread_ids[1], app.resume_model_settings(), @@ -2539,6 +2542,7 @@ async fn handle_start_side_seeds_navigation_before_thread_started() -> Result<() let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; let started = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config, parent_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -5317,6 +5321,7 @@ async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { }; Arc::new(new_session_info( app.chat_widget.config_ref(), + &app.local_settings, app.chat_widget.current_model(), &session, is_first, @@ -5434,6 +5439,7 @@ async fn make_test_app() -> App { workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, + local_settings: crate::local_settings::LocalSettings::from(&config), config, state_db: None, cli_kv_overrides: Vec::new(), @@ -5517,6 +5523,7 @@ async fn make_test_app_with_channels() -> ( workspace_command_runner: None, launch_cwd: config.cwd.to_path_buf(), runtime_working_directory_override: None, + local_settings: crate::local_settings::LocalSettings::from(&config), config, state_db: None, cli_kv_overrides: Vec::new(), @@ -5843,7 +5850,7 @@ fn rendered_line_text(line: &crate::terminal_hyperlinks::HyperlinkLine) -> Strin #[tokio::test] async fn capped_resize_reflow_renders_recent_suffix_only() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(5); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(5); app.transcript_cells = (0..20) .map(|i| plain_line_cell(format!("cell {i}"))) .collect(); @@ -5871,7 +5878,7 @@ async fn capped_resize_reflow_renders_recent_suffix_only() { #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.transcript_cells = (0..20) .map(|i| plain_line_cell(format!("cell {i}"))) .collect(); @@ -5886,7 +5893,7 @@ async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() { #[tokio::test] async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new( "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(), Path::new("/tmp"), @@ -5983,7 +5990,7 @@ async fn copy_picker_opening_preserves_terminal_scrollback_without_reflow() { #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100); app.transcript_cells = (0..3) .map(|i| plain_line_cell(format!("cell {i}"))) .collect(); @@ -6009,7 +6016,7 @@ async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { #[tokio::test] async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3); app.begin_initial_history_replay_buffer(); for index in 0..5 { @@ -6043,7 +6050,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { #[tokio::test] async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tail() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(20); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20); app.transcript_cells = vec![ plain_line_cell("latest user question"), Arc::new(AgentMarkdownCell::new( @@ -6097,7 +6104,7 @@ async fn required_stream_reflow_during_capped_initial_replay_uses_transcript_tai #[tokio::test] async fn directive_only_completion_removes_streamed_directive() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(20); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(20); app.begin_initial_history_replay_buffer(); app.transcript_cells = vec![ plain_line_cell("before directive"), @@ -6134,7 +6141,7 @@ async fn directive_only_completion_removes_streamed_directive() -> Result<()> { async fn required_stream_reflow_during_capped_initial_replay_survives_transcript_overlay() -> Result<()> { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(7); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(7); app.transcript_cells = vec![ plain_line_cell("latest user question"), Arc::new(AgentMessageCell::new( @@ -6194,7 +6201,7 @@ async fn required_stream_reflow_during_capped_initial_replay_survives_transcript #[tokio::test] async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_present() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(3); app.begin_thread_switch_history_replay_buffer(); @@ -6209,7 +6216,7 @@ async fn thread_switch_replay_buffer_uses_transcript_tail_mode_when_row_cap_pres #[tokio::test] async fn thread_switch_replay_buffer_is_disabled_without_row_cap() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(0); app.begin_thread_switch_history_replay_buffer(); @@ -6665,6 +6672,7 @@ async fn backtrack_selection_preserves_selected_prompt_and_requests_branch() { }; Arc::new(new_session_info( app.chat_widget.config_ref(), + &app.local_settings, app.chat_widget.current_model(), &session, is_first, @@ -7319,6 +7327,7 @@ async fn prompt_edit_forks_before_selected_prompt_and_preserves_source() -> Resu let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; let started = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config.clone(), source_thread_id, crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, @@ -7458,6 +7467,7 @@ async fn prompt_edit_before_first_prompt_starts_fresh_thread() -> Result<()> { let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker(&config)).await?; let started = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config.clone(), source_thread_id, crate::app_server_session::ResumeModelSettings::OverrideFromCurrentConfig, @@ -7606,6 +7616,8 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { ); let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { + requires_openai_auth: true, + local_settings: crate::local_settings::LocalSettings::from(&app.config), config: app.config.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), diff --git a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs index c1a81a9e07..41e2cf6775 100644 --- a/codex-rs/tui/src/app/tests/active_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/active_reconnect_tests.rs @@ -17,7 +17,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> let cwd = app.config.cwd.clone(); app.config.model = Some("gpt-test".into()); // Avoid platform-specific path widths in the mode-preservation snapshot. - app.config.tui_status_line = Some(vec!["model-with-reasoning".into()]); + app.local_settings.tui.status_line = Some(vec!["model-with-reasoning".into()]); app.config .permissions .set_permission_profile(PermissionProfile::read_only())?; @@ -187,6 +187,7 @@ async fn reconnect_restores_history_permissions_and_keeps_old_input_paused() -> let connected = reconnect( app.app_server_target.clone(), app.config.clone(), + app.local_settings.clone(), Some(id), /*remote_cwd*/ None, transport, @@ -315,6 +316,7 @@ async fn reconnect_exhaustion_and_unknown_initial_thread_stay_offline() -> Resul reconnect( app.app_server_target.clone(), app.config.clone(), + app.local_settings.clone(), id, /*remote_cwd*/ None, crate::dynamic_tools_mcp::ThreadToolTransport::Dynamic, @@ -366,6 +368,7 @@ async fn reconnect_allows_slow_hydration_but_bounds_a_stalled_server() -> Result endpoint: endpoint.clone(), }, app.config.clone(), + app.local_settings.clone(), Some(id), /*remote_cwd*/ None, crate::dynamic_tools_mcp::ThreadToolTransport::Dynamic, diff --git a/codex-rs/tui/src/app/tests/model_catalog.rs b/codex-rs/tui/src/app/tests/model_catalog.rs index 88e1c125f4..a671c167a9 100644 --- a/codex-rs/tui/src/app/tests/model_catalog.rs +++ b/codex-rs/tui/src/app/tests/model_catalog.rs @@ -293,7 +293,7 @@ fn select_model_availability_nux_returns_none_when_all_models_are_exhausted() { #[tokio::test] async fn prepare_startup_tooltip_override_persists_model_availability_nux_count() { let codex_home = tempdir().expect("temp codex home"); - let mut config = ConfigBuilder::default() + let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .build() .await @@ -310,12 +310,17 @@ async fn prepare_startup_tooltip_override_persists_model_availability_nux_count( message: "gpt-5.4 is available".to_string(), }); - let tooltip = - prepare_startup_tooltip_override(&mut config, &presets, /*is_first_run*/ false).await; + let mut local_settings = crate::local_settings::LocalSettings::from(&config); + let tooltip = prepare_startup_tooltip_override( + &mut local_settings, + &presets, + /*is_first_run*/ false, + ) + .await; assert_eq!(tooltip.as_deref(), Some("gpt-5.4 is available")); assert_eq!( - config.model_availability_nux.shown_count, + local_settings.tui.model_availability_nux.shown_count, HashMap::from([("gpt-5.4".to_string(), 1)]) ); diff --git a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs index 84f3728d96..614aebfa0f 100644 --- a/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs +++ b/codex-rs/tui/src/app/tests/navigation_reconnect_tests.rs @@ -285,6 +285,7 @@ async fn reconnect_daemon_command_center_after_socket_replacement_without_a_conv let connected = reconnect( app.app_server_target.clone(), app.config.clone(), + app.local_settings.clone(), previous_thread, /*remote_cwd*/ None, session.thread_tool_transport(), diff --git a/codex-rs/tui/src/app/tests/recap_generation_tests.rs b/codex-rs/tui/src/app/tests/recap_generation_tests.rs index 6c5f75cf90..1988ccc99d 100644 --- a/codex-rs/tui/src/app/tests/recap_generation_tests.rs +++ b/codex-rs/tui/src/app/tests/recap_generation_tests.rs @@ -193,7 +193,7 @@ stream_max_retries = 0 #[tokio::test] async fn manual_recap_works_when_auto_recap_disabled() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; - app.config.tui_auto_recap = false; + app.local_settings.tui.auto_recap = false; let thread_id = ThreadId::new(); app.active_thread_id = Some(thread_id); app.transcript_cells @@ -305,7 +305,7 @@ async fn auto_recap_opt_out_blocks_requests_and_cleans_up_pending_start() -> Res prepare_eligible_recap(&mut app, thread_id); let (mut app_server, requests, proxy) = start_recording_remote_app_server(&app.config).await?; let mut tui = crate::tui::test_support::make_test_tui()?; - app.config.tui_auto_recap = false; + app.local_settings.tui.auto_recap = false; app.schedule_recap_check(thread_id, Instant::now()); app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); app.handle_event( @@ -327,13 +327,13 @@ async fn auto_recap_opt_out_blocks_requests_and_cleans_up_pending_start() -> Res .flat_map(|cell| cell.display_lines(/*width*/ 80)) .collect::>(); - app.config.tui_auto_recap = true; + app.local_settings.tui.auto_recap = true; app.request_recap(&app_server, thread_id, RecapTrigger::Automatic); let started_event = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), app_event_rx.recv()) .await? .expect("recap start event"); assert!(matches!(started_event, AppEvent::RecapStarted { .. })); - app.config.tui_auto_recap = false; + app.local_settings.tui.auto_recap = false; app.handle_event(&mut tui, &mut app_server, started_event) .await?; tokio::time::timeout(Duration::from_secs(/*secs*/ 5), async { diff --git a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs index 93298bc562..ca0b17b3a6 100644 --- a/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs +++ b/codex-rs/tui/src/app/tests/session_lifecycle_requests.rs @@ -411,6 +411,7 @@ async fn removing_remote_thread_omits_disconnect_guidance() -> Result<()> { .await?; let resumed = server .resume_thread( + &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -549,6 +550,7 @@ async fn external_transport_registers_dynamic_tools_and_finds_task_mentions() -> .await?; let resumed = restarted_app_server .resume_thread( + &app.local_settings, app.config.clone(), target_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -557,7 +559,7 @@ async fn external_transport_registers_dynamic_tools_and_finds_task_mentions() -> assert!(resumed.task_tools_available); assert!(restarted_app_server.task_tools_available(target_id)); let forked = restarted_app_server - .fork_thread(app.config.clone(), target_id) + .fork_thread(&app.local_settings, app.config.clone(), target_id) .await?; assert!(forked.task_tools_available); assert!(restarted_app_server.task_tools_available(forked.session.thread_id)); @@ -738,6 +740,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() )?; app_server .resume_thread( + &app.local_settings, app.config.clone(), delegation_source, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -752,6 +755,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() ); app_server .resume_thread( + &app.local_settings, app.config.clone(), delegation_source, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, @@ -765,7 +769,7 @@ async fn local_daemon_registers_approval_gated_mcp_tools_for_both_start_paths() starts[0]["config"]["mcp_servers.codex_tui"] ); app_server - .fork_thread(app.config.clone(), delegation_source) + .fork_thread(&app.local_settings, app.config.clone(), delegation_source) .await?; let forked = recorded_params(&requests, "thread/fork") .pop() @@ -1245,6 +1249,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac )?; app_server .resume_thread( + &app.local_settings, app.config.clone(), creation_source, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -1467,7 +1472,7 @@ async fn dynamic_tool_requests_ignore_other_namespaces_and_dispatch_tui_namespac #[tokio::test] async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> Result<()> { let (mut app, codex_home) = make_history_test_app().await?; - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(100); let thread_id = create_fake_paginated_rollout( codex_home.path(), "2026-01-02T00-00-00", @@ -1563,6 +1568,7 @@ async fn older_pagination_reconciles_review_prompts_across_page_boundaries() -> .await?; let started = app_server .resume_thread( + &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -1666,7 +1672,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(2); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(2); let thread_id = create_fake_paginated_rollout( codex_home.path(), "2026-01-02T00-00-00", @@ -1734,6 +1740,7 @@ async fn transcript_home_loads_every_older_history_page() -> Result<()> { .await?; let started = app_server .resume_thread( + &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -1858,13 +1865,14 @@ async fn remote_legacy_history_start_negotiates_once_for_resume_and_fork() -> Re let started = app_server.start_thread(&app.config).await?; let resumed = app_server .resume_thread( + &app.local_settings, app.config.clone(), legacy_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, ) .await?; let forked = app_server - .fork_thread(app.config.clone(), legacy_thread_id) + .fork_thread(&app.local_settings, app.config.clone(), legacy_thread_id) .await?; assert_ne!(started.session.thread_id, legacy_thread_id); @@ -1981,6 +1989,7 @@ async fn assert_remote_legacy_history_retry(request: LegacyHistoryRequest) -> Re LegacyHistoryRequest::Resume => { let resumed = app_server .resume_thread( + &app.local_settings, app.config.clone(), legacy_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -1991,7 +2000,7 @@ async fn assert_remote_legacy_history_retry(request: LegacyHistoryRequest) -> Re } LegacyHistoryRequest::Fork => { let forked = app_server - .fork_thread(app.config.clone(), legacy_thread_id) + .fork_thread(&app.local_settings, app.config.clone(), legacy_thread_id) .await?; assert_ne!(forked.session.thread_id, legacy_thread_id); "thread/fork" @@ -2045,6 +2054,7 @@ async fn paginated_fork_survives_post_response_hydration_failure() -> Result<()> let started = app_server .resume_thread( + &app.local_settings, app.config.clone(), parent_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -2053,7 +2063,7 @@ async fn paginated_fork_survives_post_response_hydration_failure() -> Result<()> assert_eq!(started.session.thread_id, parent_thread_id); let forked = app_server - .fork_thread(app.config.clone(), parent_thread_id) + .fork_thread(&app.local_settings, app.config.clone(), parent_thread_id) .await?; assert_ne!(forked.session.thread_id, parent_thread_id); @@ -2070,7 +2080,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf().abs(); app.config.sqlite = SqliteConfig::new_for_testing(codex_home.path().abs()); - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(8); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(8); let thread_id = create_history_rollout( &app.config, ThreadHistoryMode::Paginated, @@ -2133,6 +2143,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri .await?; let started = app_server .resume_thread( + &app.local_settings, app.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -2149,6 +2160,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri /*index*/ 0, Arc::new(crate::history_cell::new_session_info( &app.config, + &app.local_settings, started.session.model.as_str(), &started.session, /*is_first_event*/ false, @@ -2161,7 +2173,7 @@ async fn underfilled_scrollback_fetches_older_pages_without_opening_the_transcri .await?; app.transcript_cells = initial_cells; app.scrollback_has_older_history = app_server.has_older_history(thread_id); - app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(32); + app.local_settings.tui.terminal_resize_reflow_max_rows = Some(32); let initial_cell_count = app.transcript_cells.len(); let initial_page_requests = recorded_params(&requests, "thread/items/list").len(); let mut tui = crate::tui::test_support::make_test_tui()?; @@ -2270,6 +2282,7 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { app_server.remember_thread_history_mode(paginated_thread_id, ThreadHistoryMode::Legacy); let resumed = app_server .resume_thread( + &app.local_settings, app.config.clone(), paginated_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -2289,12 +2302,16 @@ async fn paginated_workflows_never_request_full_thread_history() -> Result<()> { .await?; assert!(!cells.is_empty()); app_server - .fork_thread(app.config.clone(), paginated_thread_id) + .fork_thread(&app.local_settings, app.config.clone(), paginated_thread_id) .await?; let mut side_config = app.config.clone(); side_config.ephemeral = true; app_server - .fork_side_thread(side_config, paginated_thread_id) + .fork_side_thread( + &crate::local_settings::LocalSettings::from(&side_config), + side_config, + paginated_thread_id, + ) .await?; let paginated_reads = recorded_params(&requests, "thread/read"); @@ -2631,6 +2648,7 @@ async fn cold_paginated_subagent_transcript_excludes_inherited_parent_history() let resumed = app_server .resume_thread( + &app.local_settings, app.config.clone(), child_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, @@ -3132,6 +3150,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { .await?; let root = app_server .resume_thread( + &app.local_settings, app.config.clone(), root_thread_id, app.resume_model_settings(), @@ -3141,6 +3160,7 @@ fn session_lifecycle_avoids_redundant_subagent_metadata_reads() -> Result<()> { .await?; app_server .resume_thread( + &app.local_settings, app.config.clone(), child_thread_id, app.resume_model_settings(), diff --git a/codex-rs/tui/src/app/tests/startup.rs b/codex-rs/tui/src/app/tests/startup.rs index b596683df8..07c0b04d50 100644 --- a/codex-rs/tui/src/app/tests/startup.rs +++ b/codex-rs/tui/src/app/tests/startup.rs @@ -1207,6 +1207,7 @@ async fn owned_subagent_approval_before_thread_started_is_preserved() -> Result< )?; app_server .resume_thread( + &app.local_settings, app.config.clone(), child_thread_id, crate::app_server_session::ResumeModelSettings::RestoreFromThread, diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index c0b15fe886..5ba12650d3 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -482,8 +482,8 @@ impl App { /// Persist prompt text in the local cross-session message history. pub(super) fn append_message_history_entry(&self, thread_id: ThreadId, text: String) { let history_config = codex_message_history::HistoryConfig::new( - self.chat_widget.config_ref().codex_home.clone(), - &self.chat_widget.config_ref().history, + self.local_settings.codex_home.clone(), + &self.local_settings.history, ); tokio::spawn(async move { if let Err(err) = @@ -506,8 +506,8 @@ impl App { log_id: u64, ) -> Result<()> { let history_config = codex_message_history::HistoryConfig::new( - self.chat_widget.config_ref().codex_home.clone(), - &self.chat_widget.config_ref().history, + self.local_settings.codex_home.clone(), + &self.local_settings.history, ); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { @@ -540,8 +540,8 @@ impl App { log_id: u64, ) -> Result<()> { let history_config = codex_message_history::HistoryConfig::new( - self.chat_widget.config_ref().codex_home.clone(), - &self.chat_widget.config_ref().history, + self.local_settings.codex_home.clone(), + &self.local_settings.history, ); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { @@ -1417,6 +1417,7 @@ impl App { match app_server .resume_thread( + &self.local_settings, self.config.clone(), thread_id, crate::app_server_session::ResumeModelSettings::PreserveExistingThread, diff --git a/codex-rs/tui/src/app/transcript_export.rs b/codex-rs/tui/src/app/transcript_export.rs index c5dfbde9db..67365721cc 100644 --- a/codex-rs/tui/src/app/transcript_export.rs +++ b/codex-rs/tui/src/app/transcript_export.rs @@ -93,6 +93,7 @@ pub(super) async fn load_export_transcript( /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, + /*local_settings*/ None, HistoryHydrationScope::Complete, ) .await diff --git a/codex-rs/tui/src/app/working_directory.rs b/codex-rs/tui/src/app/working_directory.rs index 18bff26695..5e6d7a97d6 100644 --- a/codex-rs/tui/src/app/working_directory.rs +++ b/codex-rs/tui/src/app/working_directory.rs @@ -92,7 +92,8 @@ impl App { { return; } - let keymap = match RuntimeKeymap::from_config(&config.tui_keymap) { + let local_settings = crate::local_settings::LocalSettings::from(&config); + let keymap = match RuntimeKeymap::from_config(&local_settings.tui.keymap) { Ok(keymap) => keymap, Err(error) => return self.chat_widget.add_error_message(error), }; @@ -144,6 +145,7 @@ impl App { let transitioned = if has_rollout { app_server .fork_thread_at( + &local_settings, config.clone(), thread_id, /*last_turn_id*/ None, @@ -191,10 +193,11 @@ impl App { tracing::warn!("failed to unsubscribe tracked thread {tracked_id}: {error}"); } } + self.local_settings = local_settings; self.config = config; self.file_search .update_search_dir(self.config.cwd.to_path_buf()); - let notify = &self.config.tui_notifications; + let notify = &self.local_settings.tui.notification_settings; tui.set_notification_settings(notify.method, notify.condition); if let Err(error) = tui.clear_ambient_pet_image() { tracing::warn!(%error, "failed to clear ambient pet image"); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index a29cc9a647..13a5a42674 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -18,6 +18,7 @@ use crate::bottom_pane::FeedbackAudience; use crate::dynamic_tools_mcp::DynamicToolMcpServer; use crate::dynamic_tools_mcp::ThreadToolTransport; use crate::legacy_core::config::Config; +use crate::local_settings::LocalSettings; use crate::service_tier_resolution; use crate::session_state::MessageHistoryMetadata; use crate::session_state::ThreadSessionState; @@ -378,6 +379,15 @@ pub(crate) enum TurnPermissionsOverride { pub(crate) struct UnsupportedLegacyPermissionProfile; impl AppServerSession { + /// Platform of the app-server process, not necessarily its executor. + pub(crate) fn app_server_platform_family(&self) -> Option<&str> { + self.client.platform_family() + } + + pub(crate) fn app_server_platform_os(&self) -> Option<&str> { + self.client.platform_os() + } + pub(crate) fn new(client: AppServerClient, thread_params_mode: ThreadParamsMode) -> Self { Self { client, @@ -796,10 +806,12 @@ impl AppServerSession { pub(crate) async fn fork_thread( &mut self, + local_settings: &LocalSettings, config: Config, thread_id: ThreadId, ) -> Result { self.fork_thread_at( + local_settings, config, thread_id, /*last_turn_id*/ None, @@ -811,6 +823,7 @@ impl AppServerSession { pub(crate) async fn fork_thread_at( &mut self, + local_settings: &LocalSettings, config: Config, thread_id: ThreadId, last_turn_id: Option, @@ -818,6 +831,7 @@ impl AppServerSession { goal_continuation: ForkGoalContinuation, ) -> Result { self.fork_thread_at_with_presentation( + local_settings, config, thread_id, last_turn_id, @@ -830,10 +844,12 @@ impl AppServerSession { pub(crate) async fn fork_side_thread( &mut self, + local_settings: &LocalSettings, config: Config, thread_id: ThreadId, ) -> Result { self.fork_thread_at_with_presentation( + local_settings, config, thread_id, /*last_turn_id*/ None, @@ -844,8 +860,13 @@ impl AppServerSession { .await } + #[expect( + clippy::too_many_arguments, + reason = "keep local preferences separate while the legacy Config parameter is still required" + )] async fn fork_thread_at_with_presentation( &mut self, + local_settings: &LocalSettings, config: Config, thread_id: ThreadId, last_turn_id: Option, @@ -919,6 +940,7 @@ impl AppServerSession { /*turn_cursor*/ None, /*item_cursor*/ None, Some(&config), + Some(local_settings), HistoryHydrationScope::Initial, ) .await @@ -947,9 +969,11 @@ impl AppServerSession { let Some(model) = config.model.as_deref().or(self.default_model.as_deref()) else { return config.clone(); }; + let local_settings = LocalSettings::from(config); let mut session_config = config.clone(); match service_tier_resolution::service_tier_update_for_core( config, + &local_settings.notices, model, &self.available_models, ) { @@ -1063,6 +1087,7 @@ impl AppServerSession { /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, + /*local_settings*/ None, HistoryHydrationScope::Initial, ) .await?; @@ -1773,8 +1798,9 @@ fn config_request_overrides_from_config( } fn service_tier_override_from_config(config: &Config) -> Option> { + let local_settings = LocalSettings::from(config); config.service_tier.clone().map(Some).or_else(|| { - (config.notices.fast_default_opt_out == Some(true)) + (local_settings.notices.fast_default_opt_out == Some(true)) .then(|| Some(SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string())) }) } @@ -2245,8 +2271,11 @@ async fn thread_session_state_from_thread_response( .map(ThreadId::from_string) .transpose() .map_err(|err| format!("forked_from_id is invalid: {err}"))?; - let history_config = - codex_message_history::HistoryConfig::new(config.codex_home.clone(), &config.history); + let local_settings = LocalSettings::from(config); + let history_config = codex_message_history::HistoryConfig::new( + local_settings.codex_home, + &local_settings.history, + ); let (log_id, entry_count) = codex_message_history::history_metadata(&history_config).await; Ok(ThreadSessionState { thread_id, @@ -3277,7 +3306,12 @@ mod tests { app_server.available_models = vec![preset]; let resumed = app_server - .resume_thread(config, thread_id, ResumeModelSettings::RestoreFromThread) + .resume_thread( + &LocalSettings::from(&config), + config, + thread_id, + ResumeModelSettings::RestoreFromThread, + ) .await?; assert_eq!(resumed.session.service_tier, None); @@ -3304,6 +3338,7 @@ mod tests { let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; app_server .resume_thread( + &LocalSettings::from(&config), config.clone(), source_thread_id, ResumeModelSettings::RestoreFromThread, @@ -3316,10 +3351,18 @@ mod tests { let mut ephemeral_config = config; ephemeral_config.ephemeral = true; let normal_ephemeral_fork = app_server - .fork_thread(ephemeral_config.clone(), source_thread_id) + .fork_thread( + &LocalSettings::from(&ephemeral_config), + ephemeral_config.clone(), + source_thread_id, + ) .await?; let side_fork = app_server - .fork_side_thread(ephemeral_config, source_thread_id) + .fork_side_thread( + &LocalSettings::from(&ephemeral_config), + ephemeral_config, + source_thread_id, + ) .await?; assert_eq!( @@ -3353,7 +3396,11 @@ mod tests { ephemeral_config.ephemeral = true; let fork = app_server - .fork_thread(ephemeral_config, source_thread_id) + .fork_thread( + &LocalSettings::from(&ephemeral_config), + ephemeral_config, + source_thread_id, + ) .await?; assert_eq!(fork.session.forked_from_id, Some(source_thread_id)); @@ -3423,6 +3470,7 @@ mod tests { let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; let resumed = app_server .resume_thread( + &LocalSettings::from(&config), config.clone(), source_thread_id, ResumeModelSettings::RestoreFromThread, @@ -3436,7 +3484,11 @@ mod tests { side_config.ephemeral = true; let next_request_id = app_server.next_request_id; let side = app_server - .fork_side_thread(side_config, source_thread_id) + .fork_side_thread( + &LocalSettings::from(&side_config), + side_config, + source_thread_id, + ) .await?; assert_eq!(app_server.next_request_id, next_request_id + 1); @@ -3527,8 +3579,12 @@ mod tests { )?; let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; - let regular = app_server.fork_thread(config.clone(), thread_id).await?; - let side = app_server.fork_side_thread(config, thread_id).await?; + let regular = app_server + .fork_thread(&LocalSettings::from(&config), config.clone(), thread_id) + .await?; + let side = app_server + .fork_side_thread(&LocalSettings::from(&config), config, thread_id) + .await?; assert_eq!(regular.turns.len(), 1); assert!(matches!( diff --git a/codex-rs/tui/src/app_server_session/history.rs b/codex-rs/tui/src/app_server_session/history.rs index aef301bf4b..d545a85f50 100644 --- a/codex-rs/tui/src/app_server_session/history.rs +++ b/codex-rs/tui/src/app_server_session/history.rs @@ -201,6 +201,7 @@ impl AppServerSession { turn_cursor: Option, item_cursor: Option, config: Option<&Config>, + local_settings: Option<&crate::local_settings::LocalSettings>, scope: HistoryHydrationScope, ) -> Result<()> { let thread_id = ThreadId::from_string(&thread.id) @@ -226,8 +227,8 @@ impl AppServerSession { let width = crossterm::terminal::size() .map(|(width, _)| width.max(/*other*/ 1)) .unwrap_or(/*default*/ 80); - let row_budget = - config.and_then(|config| resize_reflow_max_rows(config.terminal_resize_reflow)); + let row_budget = local_settings + .and_then(|settings| resize_reflow_max_rows(settings.terminal_resize_reflow())); let item_budget = match (scope, config, row_budget) { (HistoryHydrationScope::Complete, _, _) | (HistoryHydrationScope::Initial, Some(_), None) => None, @@ -268,9 +269,16 @@ impl AppServerSession { let items = self .merge_thread_item_page(thread_id, page, &mut state, &mut thread.turns) .await?; - if let Some(config) = config { - rendered_rows = - rendered_history_rows(thread_id, thread, items, config, width, rendered_rows); + if let Some((config, local_settings)) = config.zip(local_settings) { + rendered_rows = rendered_history_rows( + thread_id, + thread, + items, + config, + local_settings, + width, + rendered_rows, + ); } else { rendered_rows = rendered_rows.saturating_add(items.len()); } @@ -290,6 +298,7 @@ fn rendered_history_rows( thread: &Thread, items: Vec, config: &Config, + local_settings: &crate::local_settings::LocalSettings, width: u16, rendered_rows: usize, ) -> usize { @@ -298,7 +307,7 @@ fn rendered_history_rows( } else { RawReasoningVisibility::Hidden }; - let mode = if config.tui_raw_output_mode { + let mode = if local_settings.tui.raw_output_mode { HistoryRenderMode::Raw } else { HistoryRenderMode::Rich diff --git a/codex-rs/tui/src/app_server_session/rollout_history.rs b/codex-rs/tui/src/app_server_session/rollout_history.rs index ee10be486e..719c7ce4e6 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history.rs @@ -50,6 +50,7 @@ impl AppServerSession { pub(crate) async fn resume_thread( &mut self, + local_settings: &crate::local_settings::LocalSettings, config: Config, thread_id: ThreadId, model_settings: ResumeModelSettings, @@ -138,6 +139,7 @@ impl AppServerSession { response.turns_backwards_cursor.clone(), response.items_backwards_cursor.clone(), Some(&config), + Some(local_settings), HistoryHydrationScope::Initial, ) .await?; diff --git a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs index 0fac8e9ac8..18c2f46580 100644 --- a/codex-rs/tui/src/app_server_session/rollout_history_tests.rs +++ b/codex-rs/tui/src/app_server_session/rollout_history_tests.rs @@ -44,7 +44,12 @@ async fn legacy_resume_preserves_history_mode_after_picker_server_replacement() app_server.remember_thread_history_mode(thread_id, history_mode); let next_request_id = app_server.next_request_id; let resumed = app_server - .resume_thread(config, thread_id, ResumeModelSettings::RestoreFromThread) + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config, + thread_id, + ResumeModelSettings::RestoreFromThread, + ) .await?; assert_eq!(app_server.next_request_id, next_request_id + 2); @@ -95,6 +100,7 @@ async fn background_migration_disables_cached_legacy_resume_shortcut() -> Result let next_request_id = app_server.next_request_id; let legacy = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(resume_config), resume_config.clone(), legacy_thread_id, ResumeModelSettings::RestoreFromThread, @@ -108,6 +114,7 @@ async fn background_migration_disables_cached_legacy_resume_shortcut() -> Result let resumed = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(resume_config), resume_config.clone(), thread_id, ResumeModelSettings::RestoreFromThread, @@ -152,7 +159,12 @@ async fn rollout_maintenance_contention_disables_cached_legacy_resume_shortcut() let next_request_id = app_server.next_request_id; let resumed = app_server - .resume_thread(config, thread_id, ResumeModelSettings::RestoreFromThread) + .resume_thread( + &crate::local_settings::LocalSettings::from(&config), + config, + thread_id, + ResumeModelSettings::RestoreFromThread, + ) .await?; assert_eq!(app_server.next_request_id, next_request_id + 3); @@ -190,6 +202,7 @@ async fn stale_legacy_history_mode_is_revalidated_before_resume() -> Result<()> let resumed = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config.clone(), thread_id, ResumeModelSettings::RestoreFromThread, @@ -211,6 +224,7 @@ async fn stale_legacy_history_mode_is_revalidated_before_resume() -> Result<()> assert!( app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config, missing_thread_id, ResumeModelSettings::RestoreFromThread diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 40eadfaf58..c7562326e1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -504,6 +504,7 @@ const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-d /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, + pub(crate) local_settings: crate::local_settings::LocalSettings, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, /// App-server-backed runner used by status surfaces for workspace metadata probes. @@ -514,6 +515,7 @@ pub(crate) struct ChatWidgetInit { pub(crate) initial_user_message: Option, pub(crate) enhanced_keys_supported: bool, pub(crate) has_chatgpt_account: bool, + pub(crate) requires_openai_auth: bool, pub(crate) has_codex_backend_auth: bool, pub(crate) model_catalog: Arc, pub(crate) feedback: codex_feedback::CodexFeedback, @@ -556,6 +558,7 @@ pub(crate) struct ChatWidget { bottom_pane: BottomPane, transcript: TranscriptState, config: Config, + pub(crate) local_settings: crate::local_settings::LocalSettings, raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, @@ -566,6 +569,7 @@ pub(crate) struct ChatWidget { /// The currently active collaboration mask, if any. active_collaboration_mask: Option, has_chatgpt_account: bool, + pub(crate) requires_openai_auth: bool, has_codex_backend_auth: bool, model_catalog: Arc, model_popup_request_id: Option, @@ -1572,7 +1576,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.transcript.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading( - self.config.animations, + self.local_settings.tui.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -1636,7 +1640,7 @@ impl ChatWidget { pub(crate) fn set_raw_output_mode(&mut self, enabled: bool) { self.raw_output_mode = enabled; - self.config.tui_raw_output_mode = enabled; + self.local_settings.tui.raw_output_mode = enabled; let render_mode = self.history_render_mode(); if let Some(controller) = self.stream_controller.as_mut() { controller.set_render_mode(render_mode); @@ -1871,7 +1875,6 @@ impl ChatWidget { self.config.features = config.features.clone(); self.config.config_layer_stack = config.config_layer_stack.clone(); self.config.memories = config.memories.clone(); - self.config.terminal_resize_reflow = config.terminal_resize_reflow; self.sync_mentions_v2_enabled(); } diff --git a/codex-rs/tui/src/chatwidget/backend_banners.rs b/codex-rs/tui/src/chatwidget/backend_banners.rs index c362658030..a989f477b9 100644 --- a/codex-rs/tui/src/chatwidget/backend_banners.rs +++ b/codex-rs/tui/src/chatwidget/backend_banners.rs @@ -23,7 +23,7 @@ impl ChatWidget { pub(crate) fn backend_banner_fallback(&self) -> Option { let banner = self.backend_banner_state.banner.as_ref()?; if !self.has_chatgpt_account - || !self.config.model_provider.requires_openai_auth + || !self.requires_openai_auth || banner.blocked_model_slug.as_deref() != Some(self.current_model()) { return None; diff --git a/codex-rs/tui/src/chatwidget/command_lifecycle.rs b/codex-rs/tui/src/chatwidget/command_lifecycle.rs index da9bd9a89a..5959669a99 100644 --- a/codex-rs/tui/src/chatwidget/command_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/command_lifecycle.rs @@ -302,7 +302,7 @@ impl ChatWidget { parsed_cmd, source, /*interaction_input*/ None, - self.config.animations, + self.local_settings.tui.animations, ))); self.bump_active_cell_revision(); } @@ -418,7 +418,7 @@ impl ChatWidget { parsed, source, /*interaction_input*/ None, - self.config.animations, + self.local_settings.tui.animations, ); let completed = orphan.complete_call(&id, output, duration); debug_assert!(completed, "new orphan exec cell should contain {id}"); @@ -435,7 +435,7 @@ impl ChatWidget { parsed, source, /*interaction_input*/ None, - self.config.animations, + self.local_settings.tui.animations, ); let completed = cell.complete_call(&id, output, duration); debug_assert!(completed, "new exec cell should contain {id}"); diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index a1079ac7c4..c04a210d94 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -13,12 +13,14 @@ impl ChatWidget { ) -> Self { let ChatWidgetInit { config, + local_settings, frame_requester, app_event_tx, workspace_command_runner, initial_user_message, enhanced_keys_supported, has_chatgpt_account, + requires_openai_auth, has_codex_backend_auth, model_catalog, feedback, @@ -65,11 +67,12 @@ impl ChatWidget { let current_cwd = Some(config.cwd.to_path_buf()); let effective_service_tier = crate::service_tier_resolution::effective_service_tier( &config, + &local_settings.notices, &header_model, &model_catalog.try_list_models().unwrap_or_default(), ); let current_terminal_info = terminal_info(); - let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).ok(); + let runtime_keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap).ok(); let default_keymap = RuntimeKeymap::defaults(); let copy_last_response_binding = runtime_keymap .as_ref() @@ -88,7 +91,7 @@ impl ChatWidget { codex_http_client::ClientRouteClass::Other, ); pets::start_configured_pet_load_if_needed( - &config, + &local_settings, /*ambient_pet_missing*/ true, frame_requester.clone(), app_event_tx.clone(), @@ -104,19 +107,21 @@ impl ChatWidget { has_input_focus: true, enhanced_keys_supported, placeholder_text: placeholder.clone(), - disable_paste_burst: config.disable_paste_burst, - animations_enabled: config.animations, + disable_paste_burst: local_settings.tui.disable_paste_burst.unwrap_or(false), + animations_enabled: local_settings.tui.animations, skills: None, }), transcript: TranscriptState::new(active_cell), - raw_output_mode: config.tui_raw_output_mode, + raw_output_mode: local_settings.tui.raw_output_mode, config, + local_settings, effective_service_tier, skills_all: Vec::new(), skills_initial_state: None, current_collaboration_mode, active_collaboration_mask, has_chatgpt_account, + requires_openai_auth, has_codex_backend_auth, model_catalog, model_popup_request_id: None, @@ -265,7 +270,7 @@ impl ChatWidget { if let Some(keymap) = runtime_keymap { widget.bottom_pane.set_keymap_bindings(&keymap); } - if widget.config.tui_vim_mode_default { + if widget.local_settings.tui.vim_mode_default { widget.bottom_pane.enable_vim_in_insert_mode(); } else { widget.bottom_pane.set_vim_enabled(/*enabled*/ false); diff --git a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs index 3f98599846..e85cb7aa5a 100644 --- a/codex-rs/tui/src/chatwidget/hook_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/hook_lifecycle.rs @@ -129,7 +129,7 @@ impl ChatWidget { } pub(super) fn schedule_hook_timer_if_needed(&self) { - if self.config.animations + if self.local_settings.tui.animations && self .active_hook_cell .as_ref() diff --git a/codex-rs/tui/src/chatwidget/input_restore.rs b/codex-rs/tui/src/chatwidget/input_restore.rs index ae97d92eab..8928dc4ff5 100644 --- a/codex-rs/tui/src/chatwidget/input_restore.rs +++ b/codex-rs/tui/src/chatwidget/input_restore.rs @@ -83,7 +83,7 @@ impl ChatWidget { draft.last_composer_activity_at, draft.startup_local_history, ); - if startup_has_content && self.config.tui_vim_mode_default { + if startup_has_content && self.local_settings.tui.vim_mode_default { self.bottom_pane.enable_vim_in_insert_mode(); } } diff --git a/codex-rs/tui/src/chatwidget/keymap_picker.rs b/codex-rs/tui/src/chatwidget/keymap_picker.rs index 25f802cb8f..0d08afb225 100644 --- a/codex-rs/tui/src/chatwidget/keymap_picker.rs +++ b/codex-rs/tui/src/chatwidget/keymap_picker.rs @@ -29,11 +29,11 @@ impl ChatWidget { /// overrides. If the config is invalid, the user sees the parse error instead of a partial /// picker that could commit edits against stale runtime state. pub(crate) fn open_keymap_picker(&mut self) { - match RuntimeKeymap::from_config(&self.config.tui_keymap) { + match RuntimeKeymap::from_config(&self.local_settings.tui.keymap) { Ok(runtime_keymap) => { let params = keymap_setup::build_keymap_picker_params_with_filter( &runtime_keymap, - &self.config.tui_keymap, + &self.local_settings.tui.keymap, self.keymap_action_filter(), ); self.bottom_pane.show_selection_view(params); @@ -59,7 +59,7 @@ impl ChatWidget { context, action, runtime_keymap, - &self.config.tui_keymap, + &self.local_settings.tui.keymap, ); self.bottom_pane.show_selection_view(params); } @@ -91,7 +91,8 @@ impl ChatWidget { /// Opens the keypress inspector with the current runtime bindings. pub(crate) fn open_keymap_debug(&mut self, runtime_keymap: &RuntimeKeymap) { - let view = keymap_setup::build_keymap_debug_view(runtime_keymap, &self.config.tui_keymap); + let view = + keymap_setup::build_keymap_debug_view(runtime_keymap, &self.local_settings.tui.keymap); self.bottom_pane.show_view(Box::new(view)); self.request_redraw(); } @@ -126,7 +127,7 @@ impl ChatWidget { ) { let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter( runtime_keymap, - &self.config.tui_keymap, + &self.local_settings.tui.keymap, self.keymap_action_filter(), context, action, @@ -142,7 +143,7 @@ impl ChatWidget { if !replaced { let params = keymap_setup::build_keymap_picker_params_for_selected_action_with_filter( runtime_keymap, - &self.config.tui_keymap, + &self.local_settings.tui.keymap, self.keymap_action_filter(), context, action, @@ -162,14 +163,14 @@ impl ChatWidget { /// /// The caller is responsible for persisting the config file before invoking this method. This /// method updates the in-memory config, app-level copy binding cache, and bottom-pane keymap - /// bindings as one unit; callers that update only `self.config.tui_keymap` would leave visible + /// bindings as one unit; callers that update only `self.local_settings.tui.keymap` would leave visible /// picker state and active key handlers disagreeing until the next restart. pub(crate) fn apply_keymap_update( &mut self, keymap_config: TuiKeymap, runtime_keymap: &RuntimeKeymap, ) { - self.config.tui_keymap = keymap_config; + self.local_settings.tui.keymap = keymap_config; self.copy_last_response_binding = runtime_keymap.app.copy.clone(); self.chat_keymap = runtime_keymap.chat.clone(); self.queued_message_edit_hint_binding = diff --git a/codex-rs/tui/src/chatwidget/notifications.rs b/codex-rs/tui/src/chatwidget/notifications.rs index 2084956b2c..75506213bc 100644 --- a/codex-rs/tui/src/chatwidget/notifications.rs +++ b/codex-rs/tui/src/chatwidget/notifications.rs @@ -4,7 +4,7 @@ use super::*; impl ChatWidget { pub(super) fn notify(&mut self, notification: Notification) { - if !notification.allowed_for(&self.config.tui_notifications.notifications) { + if !notification.allowed_for(&self.local_settings.tui.notification_settings.notifications) { return; } if let Some(existing) = self.pending_notification.as_ref() diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs index 5bfed3d202..c38ffc32f1 100644 --- a/codex-rs/tui/src/chatwidget/pets.rs +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -4,10 +4,10 @@ use super::*; use codex_config::types::TuiPetAnchor; pub(super) fn load_ambient_pet( - config: &Config, + config: &crate::local_settings::LocalSettings, frame_requester: FrameRequester, ) -> Option { - let selected_pet = config.tui_pet.as_deref()?; + let selected_pet = config.tui.pet.as_deref()?; if selected_pet == crate::pets::DISABLED_PET_ID { return None; } @@ -16,19 +16,19 @@ pub(super) fn load_ambient_pet( Some(selected_pet), &config.codex_home, frame_requester, - config.animations, + config.tui.animations, ) .ok() } pub(super) fn start_configured_pet_load_if_needed( - config: &Config, + config: &crate::local_settings::LocalSettings, ambient_pet_missing: bool, frame_requester: FrameRequester, app_event_tx: AppEventSender, pet_http_client: codex_http_client::RouteAwareClientPool, ) { - let Some(pet_id) = config.tui_pet.clone() else { + let Some(pet_id) = config.tui.pet.clone() else { return; }; if pet_id == crate::pets::DISABLED_PET_ID || !ambient_pet_missing { @@ -36,7 +36,7 @@ pub(super) fn start_configured_pet_load_if_needed( } let codex_home = config.codex_home.clone(); - let animations_enabled = config.animations; + let animations_enabled = config.tui.animations; let event_pet_id = pet_id.clone(); spawn_pet_load( async move { @@ -90,7 +90,7 @@ impl ChatWidget { return None; } - let anchor_bottom_y = match self.config.tui_pet_anchor { + let anchor_bottom_y = match self.local_settings.tui.pet_anchor { TuiPetAnchor::Composer => composer_bottom_y, TuiPetAnchor::ScreenBottom => area.bottom(), }; @@ -146,14 +146,15 @@ impl ChatWidget { self.pet_picker_preview_state.clear(); self.pet_picker_preview_pet = None; let params = crate::pets::build_pet_picker_params( - self.config.tui_pet.as_deref(), - &self.config.codex_home, + self.local_settings.tui.pet.as_deref(), + &self.local_settings.codex_home, self.pet_picker_preview_state.clone(), ); self.bottom_pane.show_selection_view(params); let initial_pet_id = self - .config - .tui_pet + .local_settings + .tui + .pet .as_deref() .unwrap_or(crate::pets::DEFAULT_PET_ID) .to_string(); @@ -193,10 +194,10 @@ impl ChatWidget { crate::pets::detect_pet_image_support() } - /// Set the pet preselected by the TUI picker in the widget's config copy. + /// Set the pet preselected by the TUI picker in the widget's local settings. pub(crate) fn set_tui_pet(&mut self, pet: Option) { - self.config.tui_pet = pet; - self.ambient_pet = load_ambient_pet(&self.config, self.frame_requester.clone()); + self.local_settings.tui.pet = pet; + self.ambient_pet = load_ambient_pet(&self.local_settings, self.frame_requester.clone()); self.apply_ambient_pet_image_support_override_for_tests(); self.request_redraw(); } @@ -206,7 +207,7 @@ impl ChatWidget { pet: Option, ambient_pet: Option, ) { - self.config.tui_pet = pet; + self.local_settings.tui.pet = pet; self.ambient_pet = ambient_pet; self.apply_ambient_pet_image_support_override_for_tests(); self.request_redraw(); @@ -238,7 +239,7 @@ impl ChatWidget { self.pet_picker_preview_state.set_loading(); self.request_redraw(); - let codex_home = self.config.codex_home.clone(); + let codex_home = self.local_settings.codex_home.clone(); let frame_requester = self.frame_requester.clone(); let tx = self.app_event_tx.clone(); let pet_http_client = self.pet_http_client.clone(); diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index f8d61ad42a..c46d32e0c2 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -343,7 +343,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.config.animations, + self.local_settings.tui.animations, "Loading available plugins...".to_string(), Some("This updates when the marketplace list is ready.".to_string()), )), @@ -362,7 +362,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.config.animations, + self.local_settings.tui.animations, "Adding marketplace...".to_string(), /*note*/ None, )), @@ -486,7 +486,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.config.animations, + self.local_settings.tui.animations, loading_text.clone(), /*note*/ None, )), @@ -508,7 +508,7 @@ impl ChatWidget { view_id: Some(PLUGINS_SELECTION_VIEW_ID), header: Box::new(DelayedLoadingHeader::new( self.frame_requester.clone(), - self.config.animations, + self.local_settings.tui.animations, format!("Loading details for {plugin_display_name}..."), /*note*/ None, )), diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 8533fd72cd..70c54e1a72 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -372,7 +372,7 @@ impl ChatWidget { #[cfg_attr(not(test), allow(dead_code))] pub(super) fn should_prefetch_rate_limits(&self) -> bool { - self.config.model_provider.requires_openai_auth && self.has_chatgpt_account + self.requires_openai_auth && self.has_chatgpt_account } fn lower_cost_preset(&self) -> Option { @@ -384,7 +384,7 @@ impl ChatWidget { } fn rate_limit_switch_prompt_hidden(&self) -> bool { - self.config + self.local_settings .notices .hide_rate_limit_model_nudge .unwrap_or(false) @@ -598,7 +598,7 @@ impl ChatWidget { } pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { - self.config.notices.hide_rate_limit_model_nudge = Some(hidden); + self.local_settings.notices.hide_rate_limit_model_nudge = Some(hidden); if hidden { self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; } diff --git a/codex-rs/tui/src/chatwidget/recap.rs b/codex-rs/tui/src/chatwidget/recap.rs index ac003930a8..9b2f57c55b 100644 --- a/codex-rs/tui/src/chatwidget/recap.rs +++ b/codex-rs/tui/src/chatwidget/recap.rs @@ -7,7 +7,7 @@ impl ChatWidget { self.flush_answer_stream_with_separator(); self.flush_active_cell(); self.transcript.active_cell = Some(Box::new(history_cell::ThreadRecapLoadingCell::new( - self.config.animations, + self.local_settings.tui.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index 7338673aa3..d898ce88f5 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -28,6 +28,7 @@ impl ChatWidget { pub(crate) fn service_tier_update_for_core(&self) -> Option> { service_tier_resolution::service_tier_update_for_core( &self.config, + &self.local_settings.notices, self.current_model(), &self.model_catalog.try_list_models().unwrap_or_default(), ) @@ -151,6 +152,7 @@ impl ChatWidget { pub(super) fn refresh_effective_service_tier(&mut self) { self.effective_service_tier = service_tier_resolution::effective_service_tier( &self.config, + &self.local_settings.notices, self.current_model(), &self.model_catalog.try_list_models().unwrap_or_default(), ); diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs index ce996c784a..f45df6992e 100644 --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs @@ -129,6 +129,7 @@ impl ChatWidget { .should_show_fast_status(&model_for_header, self.effective_service_tier.as_deref()); let session_info_cell = history_cell::new_session_info( &self.config, + &self.local_settings, &model_for_header, &session, self.show_welcome_banner, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 74d3e7f15d..6422f6f7c8 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -123,12 +123,12 @@ impl ChatWidget { } pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_world_writable_warning = Some(acknowledged); + self.local_settings.notices.hide_world_writable_warning = Some(acknowledged); } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn world_writable_warning_hidden(&self) -> bool { - self.config + self.local_settings .notices .hide_world_writable_warning .unwrap_or(false) @@ -256,7 +256,7 @@ impl ChatWidget { /// Set the syntax theme override in the widget's config copy. pub(crate) fn set_tui_theme(&mut self, theme: Option) { - self.config.tui_theme = theme; + self.local_settings.tui.theme = theme; } /// Set the model in the widget's config copy and stored collaboration mode. diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index c8277fca76..6ea0047d15 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -9,7 +9,7 @@ impl ChatWidget { pub(super) fn open_theme_picker(&mut self) { let codex_home = codex_utils_home_dir::find_codex_home().ok(); let params = crate::theme_picker::build_theme_picker_params( - self.config.tui_theme.as_deref(), + self.local_settings.tui.theme.as_deref(), codex_home.as_deref(), self.last_rendered_width.get(), ); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 7f2028f333..8bd59824ce 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -743,7 +743,8 @@ impl ChatWidget { SlashCommand::Keymap => match trimmed.to_ascii_lowercase().as_str() { "" => self.open_keymap_picker(), "debug" => { - match crate::keymap::RuntimeKeymap::from_config(&self.config.tui_keymap) { + match crate::keymap::RuntimeKeymap::from_config(&self.local_settings.tui.keymap) + { Ok(runtime_keymap) => self.open_keymap_debug(&runtime_keymap), Err(err) => { self.add_error_message(format!( diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index 8ebdf0fab8..607fbf8588 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -40,15 +40,16 @@ impl ChatWidget { StatusDetailsCapitalization::Preserve, details_max_lines, ); - let title_uses_status = self - .config - .tui_terminal_title - .as_ref() - .is_some_and(|items| { - items - .iter() - .any(|item| item == "run-state" || item == "status") - }); + let title_uses_status = + self.local_settings + .tui + .terminal_title + .as_ref() + .is_some_and(|items| { + items + .iter() + .any(|item| item == "run-state" || item == "status") + }); if title_uses_status { self.refresh_status_surfaces(); } @@ -115,19 +116,20 @@ impl ChatWidget { "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" ); let ids = items.iter().map(ToString::to_string).collect::>(); - self.config.tui_status_line = Some(ids); - self.config.tui_status_line_use_colors = use_theme_colors; + self.local_settings.tui.status_line = Some(ids); + self.local_settings.tui.status_line_use_colors = use_theme_colors; self.refresh_status_line(); } /// Applies a temporary terminal-title selection while the setup UI is open. pub(crate) fn preview_terminal_title(&mut self, items: Vec) { if self.terminal_title_setup_original_items.is_none() { - self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + self.terminal_title_setup_original_items = + Some(self.local_settings.tui.terminal_title.clone()); } let ids = items.iter().map(ToString::to_string).collect::>(); - self.config.tui_terminal_title = Some(ids); + self.local_settings.tui.terminal_title = Some(ids); self.refresh_terminal_title(); } @@ -138,7 +140,7 @@ impl ChatWidget { return; }; - self.config.tui_terminal_title = original_items; + self.local_settings.tui.terminal_title = original_items; self.refresh_terminal_title(); } @@ -156,7 +158,7 @@ impl ChatWidget { tracing::info!("terminal title setup confirmed with items: {items:#?}"); let ids = items.iter().map(ToString::to_string).collect::>(); self.terminal_title_setup_original_items = None; - self.config.tui_terminal_title = Some(ids); + self.local_settings.tui.terminal_title = Some(ids); self.refresh_terminal_title(); } @@ -227,6 +229,7 @@ impl ChatWidget { crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( &self.config, + self.requires_openai_auth, self.runtime_model_provider_base_url.as_deref(), self.remote_connection.as_ref(), self.status_account_display.as_ref(), @@ -301,7 +304,7 @@ impl ChatWidget { let configured_status_line_items = self.configured_status_line_items(); let view = StatusLineSetupView::new( Some(configured_status_line_items.as_slice()), - self.config.tui_status_line_use_colors, + self.local_settings.tui.status_line_use_colors, self.status_surface_preview_data(), self.app_event_tx.clone(), self.bottom_pane.list_keymap(), @@ -311,7 +314,8 @@ impl ChatWidget { pub(super) fn open_terminal_title_setup(&mut self) { let configured_terminal_title_items = self.configured_terminal_title_items(); - self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + self.terminal_title_setup_original_items = + Some(self.local_settings.tui.terminal_title.clone()); let view = TerminalTitleSetupView::new( Some(configured_terminal_title_items.as_slice()), self.terminal_title_preview_data(), diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index e79c0d3ca6..1b5402c4cf 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -215,7 +215,7 @@ impl ChatWidget { self.set_status_line(status_line_from_segments( segments, - self.config.tui_status_line_use_colors, + self.local_settings.tui.status_line_use_colors, )); let hyperlink_url = selections .status_line_items @@ -358,7 +358,7 @@ impl ChatWidget { } fn action_required_terminal_title_prefix_at(&self, now: Instant) -> &'static str { - if !self.config.animations { + if !self.local_settings.tui.animations { return TERMINAL_TITLE_ACTION_REQUIRED_PREFIX; } @@ -385,7 +385,7 @@ impl ChatWidget { &self, selections: &StatusSurfaceSelections, ) -> Option { - if self.config.animations + if self.local_settings.tui.animations && self.terminal_title_shows_action_required_with_selections(selections) { return Some(TERMINAL_TITLE_ACTION_REQUIRED_INTERVAL); @@ -423,12 +423,16 @@ impl ChatWidget { } pub(super) fn configured_status_line_items(&self) -> Vec { - self.config.tui_status_line.clone().unwrap_or_else(|| { - DEFAULT_STATUS_LINE_ITEMS - .iter() - .map(ToString::to_string) - .collect() - }) + self.local_settings + .tui + .status_line + .clone() + .unwrap_or_else(|| { + DEFAULT_STATUS_LINE_ITEMS + .iter() + .map(ToString::to_string) + .collect() + }) } /// Parses configured terminal-title ids into known items and collects unknown ids. @@ -440,12 +444,16 @@ impl ChatWidget { /// Returns the configured terminal-title ids, or the default ordering when unset. pub(super) fn configured_terminal_title_items(&self) -> Vec { - self.config.tui_terminal_title.clone().unwrap_or_else(|| { - DEFAULT_TERMINAL_TITLE_ITEMS - .iter() - .map(ToString::to_string) - .collect() - }) + self.local_settings + .tui + .terminal_title + .clone() + .unwrap_or_else(|| { + DEFAULT_TERMINAL_TITLE_ITEMS + .iter() + .map(ToString::to_string) + .collect() + }) } fn status_line_cwd(&self) -> &Path { @@ -960,7 +968,7 @@ impl ChatWidget { } pub(super) fn terminal_title_spinner_text_at(&self, now: Instant) -> Option { - if !self.config.animations { + if !self.local_settings.tui.animations { return None; } @@ -979,11 +987,15 @@ impl ChatWidget { } fn terminal_title_uses_activity(&self) -> bool { - self.config.tui_terminal_title.as_ref().is_none_or(|items| { - items - .iter() - .any(|item| item == "activity" || item == "spinner") - }) + self.local_settings + .tui + .terminal_title + .as_ref() + .is_none_or(|items| { + items + .iter() + .any(|item| item == "activity" || item == "spinner") + }) } fn terminal_title_has_active_progress(&self) -> bool { @@ -995,20 +1007,20 @@ impl ChatWidget { } pub(super) fn should_animate_terminal_title_spinner(&self) -> bool { - self.config.animations + self.local_settings.tui.animations && self.terminal_title_uses_activity() && self.terminal_title_has_active_progress() } pub(super) fn should_animate_terminal_title_action_required(&self) -> bool { - self.config.animations && self.terminal_title_shows_action_required() + self.local_settings.tui.animations && self.terminal_title_shows_action_required() } fn should_animate_terminal_title_spinner_with_selections( &self, selections: &StatusSurfaceSelections, ) -> bool { - self.config.animations + self.local_settings.tui.animations && selections .terminal_title_items .contains(&TerminalTitleItem::Spinner) diff --git a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs index b3ed272fb9..2d3592f07b 100644 --- a/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/backend_banners_tests.rs @@ -319,10 +319,10 @@ async fn backend_banner_fallback_candidates_and_notice_follow_selected_model() { json!(["hidden-model", "test-model-c", "test-model-b"]); chat.update_backend_banner(&response); assert_eq!(chat.backend_banner_fallback(), Some(models[2].clone())); - // A retained ChatGPT login must not change a task using a custom provider. - chat.config.model_provider.requires_openai_auth = false; + // A retained ChatGPT login must not change a task whose server does not require auth. + chat.requires_openai_auth = false; assert_eq!(chat.backend_banner_fallback(), None); - chat.config.model_provider.requires_openai_auth = true; + chat.requires_openai_auth = true; let mut mode = chat.effective_collaboration_mode(); mode.settings.model = "test-model-c".into(); chat.finish_backend_banner_fallback(mode); diff --git a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs index c6c0fd0b63..40b917aed5 100644 --- a/codex-rs/tui/src/chatwidget/tests/composer_submission.rs +++ b/codex-rs/tui/src/chatwidget/tests/composer_submission.rs @@ -1243,7 +1243,7 @@ async fn startup_draft_handoff_keeps_vim_insert_mode_for_nonempty_drafts() { let startup_draft = startup_chat.bottom_pane.composer_draft_snapshot(); let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_vim_mode_default = true; + chat.local_settings.tui.vim_mode_default = true; chat.bottom_pane.set_vim_enabled(/*enabled*/ true); chat.restore_startup_draft(startup_draft); chat.bottom_pane diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index ad00a51153..e0dc255ae4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -184,6 +184,8 @@ pub(super) async fn make_chatwidget_manual_with_auth( let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let model_catalog = test_model_catalog(&cfg); let common = ChatWidgetInit { + requires_openai_auth: cfg.model_provider.requires_openai_auth, + local_settings: crate::local_settings::LocalSettings::from(&cfg), config: cfg, frame_requester, app_event_tx, diff --git a/codex-rs/tui/src/chatwidget/tests/permission_shortcuts_tests.rs b/codex-rs/tui/src/chatwidget/tests/permission_shortcuts_tests.rs index 76b7eb5472..d4124662ac 100644 --- a/codex-rs/tui/src/chatwidget/tests/permission_shortcuts_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/permission_shortcuts_tests.rs @@ -14,7 +14,7 @@ async fn permission_shortcuts_cycle_builtin_modes() { chat.chat_keymap.previous_permission_mode = vec![crate::key_hint::plain(KeyCode::F(7))]; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } for (current, reviewer, key, expected, next_reviewer) in [ diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 9ac937db61..8397347be1 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -271,7 +271,7 @@ async fn profile_permissions_full_access_always_opens_confirmation() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); - chat.config.notices.hide_full_access_warning = Some(true); + chat.local_settings.notices.hide_full_access_warning = Some(true); chat.open_permissions_popup(); chat.handle_key_event(KeyEvent::from(KeyCode::Up)); @@ -852,7 +852,7 @@ async fn permissions_selection_emits_history_cell_when_selection_changes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); @@ -878,7 +878,7 @@ async fn permissions_selection_history_snapshot_after_mode_switch() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); @@ -915,7 +915,7 @@ async fn permissions_selection_history_snapshot_full_access_to_default() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.config @@ -957,7 +957,7 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.config @@ -991,7 +991,7 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); @@ -1010,7 +1010,7 @@ async fn permissions_selection_hides_auto_review_when_feature_disabled_even_if_a let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); @@ -1039,7 +1039,7 @@ async fn permissions_selection_marks_auto_review_current_after_session_configure let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } let _ = chat @@ -1084,7 +1084,7 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } let _ = chat @@ -1133,7 +1133,7 @@ async fn permissions_selection_can_disable_auto_review() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); @@ -1173,7 +1173,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ true); @@ -1254,7 +1254,7 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; #[cfg(target_os = "windows")] { - chat.config.notices.hide_world_writable_warning = Some(true); + chat.local_settings.notices.hide_world_writable_warning = Some(true); chat.set_windows_sandbox_mode(Some(WindowsSandboxModeToml::Unelevated)); } chat.set_feature_enabled(Feature::GuardianApproval, /*enabled*/ false); diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index 3ed5478ecb..bdd23d1638 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -549,7 +549,7 @@ fn plan_mode_prompt_notification_uses_dedicated_type_name() { #[tokio::test] async fn open_plan_implementation_prompt_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.tui_notifications.notifications = + chat.local_settings.tui.notification_settings.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.open_plan_implementation_prompt(); @@ -563,7 +563,7 @@ async fn open_plan_implementation_prompt_sets_pending_notification() { #[tokio::test] async fn open_plan_reasoning_scope_prompt_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.tui_notifications.notifications = + chat.local_settings.tui.notification_settings.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.open_plan_reasoning_scope_prompt("gpt-5.4".to_string(), Some(ReasoningEffortConfig::High)); @@ -624,7 +624,7 @@ async fn request_user_input_notification_overrides_pending_agent_turn_complete_n #[tokio::test] async fn handle_request_user_input_sets_pending_notification() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.tui_notifications.notifications = + chat.local_settings.tui.notification_settings.notifications = Notifications::Custom(vec!["plan-mode-prompt".to_string()]); chat.handle_request_user_input_now(ToolRequestUserInputParams { @@ -1630,6 +1630,8 @@ async fn make_startup_chat_with_cli_overrides( let resolved_model = get_model_offline_for_tests(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { + requires_openai_auth: true, + local_settings: crate::local_settings::LocalSettings::from(&cfg), config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index f0cb0be02a..7d8d9db5c8 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -35,6 +35,8 @@ async fn experimental_mode_plan_is_ignored_on_startup() { let resolved_model = get_model_offline_for_tests(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { + requires_openai_auth: true, + local_settings: crate::local_settings::LocalSettings::from(&cfg), config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), diff --git a/codex-rs/tui/src/chatwidget/tests/side.rs b/codex-rs/tui/src/chatwidget/tests/side.rs index 3b5863d2d6..c4261a3aef 100644 --- a/codex-rs/tui/src/chatwidget/tests/side.rs +++ b/codex-rs/tui/src/chatwidget/tests/side.rs @@ -302,7 +302,7 @@ async fn slash_side_requests_forked_side_question_while_task_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let parent_thread_id = ThreadId::new(); chat.thread_id = Some(parent_thread_id); - chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-with-reasoning".to_string()]); chat.refresh_status_line(); chat.on_task_started(); chat.show_welcome_banner = false; @@ -385,7 +385,7 @@ async fn slash_btw_requests_forked_side_question_while_task_running() { async fn side_context_label_preserves_status_line_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-name".to_string()]); chat.refresh_status_line(); chat.set_side_conversation_active(/*active*/ true); chat.set_side_conversation_context_label(Some( @@ -427,7 +427,7 @@ async fn side_context_label_shows_parent_status_snapshot() { async fn side_context_label_shows_hidden_side_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-name".to_string()]); chat.refresh_status_line(); chat.set_side_conversation_context_label(Some("ctrl + / for side".to_string())); diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 762e7019ba..300dcb55e2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -67,7 +67,7 @@ async fn resumed_session_hides_unknown_token_usage_until_an_update_arrives() { @"(hidden)" ); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "context-remaining".to_string(), "context-used".to_string(), "total-input-tokens".to_string(), @@ -489,6 +489,8 @@ async fn configured_pet_load_is_deferred_until_after_construction() { let resolved_model = get_model_offline_for_tests(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { + requires_openai_auth: true, + local_settings: crate::local_settings::LocalSettings::from(&cfg), config: cfg.clone(), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, @@ -532,10 +534,14 @@ async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() { assert!(!chat.should_prefetch_rate_limits()); + // The server requires auth even if the local provider does not. + chat.config.model_provider.requires_openai_auth = false; set_chatgpt_auth(&mut chat); assert!(chat.should_prefetch_rate_limits()); - chat.config.model_provider.requires_openai_auth = false; + // Conversely, a local OpenAI provider cannot enable usage for a different server. + chat.config.model_provider.requires_openai_auth = true; + chat.requires_openai_auth = false; assert!(!chat.should_prefetch_rate_limits()); chat.prefetch_rate_limits(); @@ -1699,7 +1705,7 @@ async fn account_update_clears_derived_usage_limit_state_and_prompt() { async fn rate_limit_switch_prompt_respects_hidden_notice() { let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; chat.has_chatgpt_account = true; - chat.config.notices.hide_rate_limit_model_nudge = Some(true); + chat.local_settings.notices.hide_rate_limit_model_nudge = Some(true); chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0))); @@ -2474,7 +2480,7 @@ async fn ambient_pet_screen_bottom_anchor_uses_terminal_bottom() { .expect("composer-anchored pet draw request"); assert_eq!(default_draw.y, 14); - chat.config.tui_pet_anchor = TuiPetAnchor::ScreenBottom; + chat.local_settings.tui.pet_anchor = TuiPetAnchor::ScreenBottom; let screen_bottom_draw = chat .ambient_pet_draw(terminal_area, composer_bottom_y) .expect("screen-bottom anchored pet draw request"); @@ -2861,7 +2867,7 @@ async fn repeated_generic_warning_is_not_hidden() { #[tokio::test] async fn status_line_invalid_items_warn_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model_name".to_string(), "bogus_item".to_string(), "lines_changed".to_string(), @@ -2890,7 +2896,7 @@ async fn status_line_invalid_items_warn_once() { async fn status_line_hostname_renders_current_machine_hostname() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.config.tui_status_line = Some(vec!["hostname".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["hostname".to_string()]); chat.refresh_status_line(); @@ -2905,7 +2911,7 @@ async fn status_line_hostname_renders_current_machine_hostname() { async fn status_line_context_used_renders_labeled_percent() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.config.tui_status_line = Some(vec!["context-used".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["context-used".to_string()]); chat.refresh_status_line(); @@ -2920,7 +2926,7 @@ async fn status_line_context_used_renders_labeled_percent() { async fn status_line_context_remaining_renders_labeled_percent() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.config.tui_status_line = Some(vec!["context-remaining".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["context-remaining".to_string()]); chat.refresh_status_line(); @@ -2938,7 +2944,7 @@ async fn status_line_context_remaining_renders_labeled_percent() { async fn status_line_legacy_context_usage_renders_context_used_percent() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.config.tui_status_line = Some(vec!["context-usage".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["context-usage".to_string()]); chat.refresh_status_line(); @@ -2956,7 +2962,7 @@ async fn status_line_estimated_thread_cost_fetches_and_renders_backend_estimate( chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), None); @@ -2992,7 +2998,7 @@ async fn status_line_thread_credits_fetches_and_renders_fractional_credits() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["thread-credits".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["thread-credits".to_string()]); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), None); @@ -3028,7 +3034,7 @@ async fn status_line_thread_credits_and_cost_share_one_backend_request() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); @@ -3064,7 +3070,7 @@ async fn status_line_thread_credits_remain_visible_when_usd_is_unavailable() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); @@ -3099,8 +3105,8 @@ async fn terminal_title_thread_usage_fetches_and_renders_without_status_line() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(Vec::new()); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.status_line = Some(Vec::new()); + chat.local_settings.tui.terminal_title = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); @@ -3142,8 +3148,8 @@ async fn terminal_title_thread_credits_remain_visible_when_usd_is_unavailable() chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(Vec::new()); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.status_line = Some(Vec::new()); + chat.local_settings.tui.terminal_title = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); @@ -3178,8 +3184,8 @@ async fn terminal_title_and_status_line_share_one_thread_usage_request() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); - chat.config.tui_terminal_title = Some(vec!["thread-credits".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.terminal_title = Some(vec!["thread-credits".to_string()]); chat.refresh_status_surfaces(); let request_id = match rx.try_recv() { @@ -3211,7 +3217,7 @@ async fn status_line_estimated_thread_cost_avoids_unsupported_plan_requests() { chat.thread_id = Some(ThreadId::new()); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Pro); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "estimated-thread-cost".to_string(), "run-state".to_string(), ]); @@ -3232,7 +3238,7 @@ async fn status_line_estimated_thread_cost_stops_after_backend_disables_feature( chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::EnterpriseCbpAutomation); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let request_id = match rx.try_recv() { Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id, @@ -3257,7 +3263,7 @@ async fn status_line_estimated_thread_cost_preserves_cached_amount_during_settle chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id, @@ -3304,7 +3310,7 @@ async fn completed_turn_refreshes_estimated_thread_cost() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { @@ -3355,8 +3361,8 @@ async fn completed_turn_refreshes_credits_only_terminal_title() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(Vec::new()); - chat.config.tui_terminal_title = Some(vec!["thread-credits".to_string()]); + chat.local_settings.tui.status_line = Some(Vec::new()); + chat.local_settings.tui.terminal_title = Some(vec!["thread-credits".to_string()]); chat.refresh_terminal_title(); let initial_request_id = match rx.try_recv() { @@ -3407,7 +3413,7 @@ async fn interrupted_turn_refreshes_estimated_thread_usage() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { @@ -3448,7 +3454,7 @@ async fn failed_turn_refreshes_estimated_thread_usage() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { @@ -3493,7 +3499,7 @@ async fn status_line_estimated_thread_cost_rejects_stale_thread_completions() { chat.thread_id = Some(previous_thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let previous_request_id = match rx.try_recv() { Ok(AppEvent::RefreshThreadUsage { request_id, .. }) => request_id, @@ -3542,7 +3548,7 @@ async fn status_line_estimated_thread_cost_footer_snapshot() { chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model-with-reasoning".to_string(), "thread-credits".to_string(), "estimated-thread-cost".to_string(), @@ -3579,7 +3585,7 @@ async fn status_line_estimated_thread_cost_footer_snapshot() { async fn status_line_workspace_headline_renders_cached_value() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); chat.refresh_status_line(); @@ -3598,7 +3604,7 @@ async fn status_line_workspace_headline_renders_cached_value() { async fn status_line_workspace_headline_omits_when_unavailable() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "workspace-headline".to_string(), "run-state".to_string(), ]); @@ -3615,7 +3621,7 @@ async fn status_line_workspace_headline_omits_when_unavailable() { #[tokio::test] async fn workspace_headline_update_applies_feature_disabled_result() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Old headline".to_string()); let request_id = 3; chat.status_line_workspace_headline_pending_request_id = Some(request_id); @@ -3632,7 +3638,7 @@ async fn workspace_headline_update_applies_feature_disabled_result() { #[tokio::test] async fn workspace_headline_update_applies_available_headline() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); let request_id = 4; chat.status_line_workspace_headline_pending_request_id = Some(request_id); @@ -3655,7 +3661,7 @@ async fn workspace_headline_update_applies_available_headline() { #[tokio::test] async fn account_update_clears_workspace_headline_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); chat.status_line_workspace_headline_pending_request_id = Some(5); chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); @@ -3680,7 +3686,7 @@ async fn account_update_clears_workspace_headline_state() { #[tokio::test] async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.update_account_state( /*status_account_display*/ None, /*plan_type*/ None, @@ -3697,7 +3703,7 @@ async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() #[tokio::test] async fn account_update_discards_stale_workspace_headline_results() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); chat.update_account_state( Some(StatusAccountDisplay::ChatGpt { @@ -3767,7 +3773,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() { chat.status_line_branch = Some("main".to_string()); chat.status_line_branch_pending = true; chat.status_line_branch_lookup_complete = true; - chat.config.tui_status_line = Some(vec!["model_name".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model_name".to_string()]); chat.refresh_status_line(); @@ -3780,7 +3786,7 @@ async fn status_line_branch_state_resets_when_git_branch_disabled() { async fn status_line_branch_refreshes_after_turn_complete() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; install_noop_workspace_command_runner(&mut chat); - chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["git-branch".to_string()]); chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; @@ -3793,7 +3799,7 @@ async fn status_line_branch_refreshes_after_turn_complete() { async fn status_line_branch_refreshes_after_interrupt() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; install_noop_workspace_command_runner(&mut chat); - chat.config.tui_status_line = Some(vec!["git-branch".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["git-branch".to_string()]); chat.status_line_branch_lookup_complete = true; chat.status_line_branch_pending = false; @@ -3891,7 +3897,7 @@ async fn completed_turn_clears_visible_running_hook() { async fn status_line_fast_mode_renders_on_and_off() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["fast-mode".to_string()]); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), Some("Fast off".to_string())); @@ -3905,7 +3911,7 @@ async fn status_line_fast_mode_renders_on_and_off() { async fn status_line_fast_mode_updates_visibility_on_model_change() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); - chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["fast-mode".to_string()]); chat.refresh_status_line(); assert_eq!(status_line_text(&chat), Some("Fast off".to_string())); @@ -3930,7 +3936,7 @@ async fn status_line_fast_mode_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; set_fast_mode_test_catalog(&mut chat); chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model-with-reasoning".to_string(), "fast-mode".to_string(), ]); @@ -3955,7 +3961,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( set_fast_mode_test_catalog(&mut chat); assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); chat.config.cwd = test_project_path().abs(); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model-with-reasoning".to_string(), "context-used".to_string(), "current-dir".to_string(), @@ -3985,7 +3991,7 @@ async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models( #[tokio::test] async fn terminal_title_model_updates_on_model_change_without_manual_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.tui_terminal_title = Some(vec!["model".to_string()]); + chat.local_settings.tui.terminal_title = Some(vec!["model".to_string()]); chat.refresh_terminal_title(); assert_eq!(chat.last_terminal_title, Some("gpt-5.4".to_string())); @@ -3998,8 +4004,8 @@ async fn terminal_title_model_updates_on_model_change_without_manual_refresh() { #[tokio::test] async fn status_line_and_terminal_title_reasoning_render_only_effort() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.tui_status_line = Some(vec!["reasoning".to_string()]); - chat.config.tui_terminal_title = Some(vec!["reasoning".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["reasoning".to_string()]); + chat.local_settings.tui.terminal_title = Some(vec!["reasoning".to_string()]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); @@ -4014,7 +4020,7 @@ async fn status_line_and_terminal_title_reasoning_render_only_effort() { async fn status_line_reasoning_updates_on_mode_switch_without_manual_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); - chat.config.tui_status_line = Some(vec!["reasoning".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["reasoning".to_string()]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!(status_line_text(&chat), Some("high".to_string())); @@ -4030,7 +4036,7 @@ async fn status_line_reasoning_updates_on_mode_switch_without_manual_refresh() { async fn status_line_model_with_reasoning_updates_on_mode_switch_without_manual_refresh() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); - chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-with-reasoning".to_string()]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!(status_line_text(&chat), Some("gpt-5.2 high".to_string())); @@ -4056,7 +4062,7 @@ async fn status_line_model_with_reasoning_plan_mode_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.show_welcome_banner = false; chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); - chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-with-reasoning".to_string()]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) @@ -4082,7 +4088,7 @@ async fn renamed_thread_footer_title_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model-with-reasoning".to_string(), "thread-title".to_string(), ]); @@ -4123,7 +4129,7 @@ async fn status_line_model_with_reasoning_fast_footer_snapshot() { assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); chat.show_welcome_banner = false; chat.config.cwd = test_project_path().abs(); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model-with-reasoning".to_string(), "context-used".to_string(), "current-dir".to_string(), @@ -4157,7 +4163,7 @@ async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() { assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode()); chat.show_welcome_banner = false; chat.config.cwd = test_project_path().abs(); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), "current-dir".to_string(), @@ -4189,7 +4195,7 @@ async fn status_line_goal_active_token_budget_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-name".to_string()]); chat.refresh_status_line(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( @@ -4226,7 +4232,7 @@ async fn status_line_goal_complete_elapsed_footer_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.show_welcome_banner = false; - chat.config.tui_status_line = Some(vec!["model-name".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["model-name".to_string()]); chat.refresh_status_line(); let mut goal = test_thread_goal( codex_app_server_protocol::ThreadGoalStatus::Complete, diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 0a830ff9f7..ef7e122ff8 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -127,7 +127,7 @@ async fn status_line_setup_popup_live_only_snapshot() { cache_project_root(&mut chat, "preview-live-root"); chat.status_line_branch = Some("feature/live-preview-branch".to_string()); chat.thread_name = Some("Live preview thread".to_string()); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "project-name".to_string(), "git-branch".to_string(), "thread-title".to_string(), @@ -181,7 +181,7 @@ async fn thread_title_falls_back_to_thread_id_when_unnamed() { #[tokio::test] async fn status_line_setup_popup_hardcoded_only_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "project-name".to_string(), "git-branch".to_string(), "thread-title".to_string(), @@ -197,7 +197,7 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { async fn status_line_setup_popup_workspace_headline_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); - chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["workspace-headline".to_string()]); assert_chatwidget_snapshot!( "status_line_setup_popup_workspace_headline", @@ -252,11 +252,11 @@ async fn status_surface_preview_lines_thread_usage_snapshot() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); @@ -299,11 +299,11 @@ async fn status_surface_thread_usage_previews_omit_unavailable_usd_estimates() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); @@ -393,7 +393,7 @@ async fn status_surface_preview_omits_unavailable_rate_limit_items() { async fn status_line_setup_popup_rate_limits_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; cache_rate_limit_snapshot(&mut chat); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "five-hour-limit".to_string(), "weekly-limit".to_string(), ]); @@ -409,7 +409,7 @@ async fn status_line_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.status_line_branch = Some("feature/mixed-preview".to_string()); chat.thread_name = Some("Mixed preview thread".to_string()); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "project-name".to_string(), "git-branch".to_string(), "thread-title".to_string(), @@ -428,7 +428,7 @@ async fn terminal_title_setup_popup_live_only_snapshot() { chat.status_line_branch = Some("feature/live-preview-branch".to_string()); chat.thread_name = Some("Live preview thread".to_string()); chat.transcript.last_plan_progress = Some((2, 5)); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "project-name".to_string(), "thread-title".to_string(), "git-branch".to_string(), @@ -444,7 +444,7 @@ async fn terminal_title_setup_popup_live_only_snapshot() { #[tokio::test] async fn terminal_title_setup_popup_hardcoded_only_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "thread-title".to_string(), "git-branch".to_string(), "task-progress".to_string(), @@ -460,7 +460,7 @@ async fn terminal_title_setup_popup_hardcoded_only_snapshot() { async fn terminal_title_setup_popup_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_name = Some("Mixed preview thread".to_string()); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "project-name".to_string(), "thread-title".to_string(), "task-progress".to_string(), @@ -476,7 +476,7 @@ async fn terminal_title_setup_popup_mixed_snapshot() { async fn terminal_title_setup_popup_rate_limits_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; cache_rate_limit_snapshot(&mut chat); - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "five-hour-limit".to_string(), "weekly-limit".to_string(), ]); @@ -490,7 +490,7 @@ async fn terminal_title_setup_popup_rate_limits_snapshot() { #[tokio::test] async fn terminal_title_setup_popup_thread_usage_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_terminal_title = Some(vec![ + chat.local_settings.tui.terminal_title = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); diff --git a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs index 0d7d089001..69dc5379e5 100644 --- a/codex-rs/tui/src/chatwidget/tests/terminal_title.rs +++ b/codex-rs/tui/src/chatwidget/tests/terminal_title.rs @@ -127,13 +127,13 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { assert!(chat.should_animate_terminal_title_spinner()); for (animations, title_items) in [(false, None), (true, Some(Vec::new()))] { - chat.config.animations = true; - chat.config.tui_terminal_title = None; + chat.local_settings.tui.animations = true; + chat.local_settings.tui.terminal_title = None; chat.refresh_terminal_title(); assert!(chat.terminal_title_next_refresh.is_some()); - chat.config.animations = animations; - chat.config.tui_terminal_title = title_items; + chat.local_settings.tui.animations = animations; + chat.local_settings.tui.terminal_title = title_items; chat.refresh_terminal_title(); assert!(chat.terminal_title_next_refresh.is_none()); } @@ -142,7 +142,7 @@ async fn terminal_title_shows_action_required_while_exec_approval_is_pending() { #[tokio::test] async fn terminal_title_action_required_respects_spinner_setting() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.tui_terminal_title = Some(vec!["project".to_string()]); + chat.local_settings.tui.terminal_title = Some(vec!["project".to_string()]); chat.bottom_pane.set_task_running(/*running*/ true); chat.refresh_terminal_title(); @@ -206,7 +206,7 @@ async fn terminal_title_action_required_blinks_when_animations_are_enabled() { #[tokio::test] async fn terminal_title_activity_indicators_do_not_animate_when_animations_are_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.config.animations = false; + chat.local_settings.tui.animations = false; chat.bottom_pane.set_task_running(/*running*/ true); chat.terminal_title_animation_origin = Instant::now() - std::time::Duration::from_millis(1500); chat.refresh_terminal_title(); diff --git a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs index 7fb80eb5a1..8389f75cf0 100644 --- a/codex-rs/tui/src/chatwidget/thread_usage_tests.rs +++ b/codex-rs/tui/src/chatwidget/thread_usage_tests.rs @@ -74,7 +74,7 @@ async fn temporary_thread_usage_failures_have_bounded_retries() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); for attempt in 0..=THREAD_USAGE_RETRY_DELAYS.len() { @@ -106,7 +106,7 @@ async fn status_history_survives_exhausted_billing_retries() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let mut request_id = thread_usage_request_id(&mut rx); @@ -176,7 +176,7 @@ async fn status_history_updates_again_after_billing_settles() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![selected_item.to_string()]); + chat.local_settings.tui.status_line = Some(vec![selected_item.to_string()]); chat.refresh_status_line(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -258,7 +258,7 @@ async fn replayed_turn_completions_do_not_start_live_billing_refreshes() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -309,7 +309,7 @@ async fn replayed_errors_do_not_start_live_billing_refreshes() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -357,11 +357,11 @@ async fn transient_zero_cost_preserves_fresh_credits_and_breakdowns() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); - chat.config.tui_terminal_title = chat.config.tui_status_line.clone(); + chat.local_settings.tui.terminal_title = chat.local_settings.tui.status_line.clone(); chat.refresh_status_surfaces(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -425,8 +425,8 @@ async fn transient_zero_credits_preserves_credits_only_estimates() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["thread-credits".to_string()]); - chat.config.tui_terminal_title = chat.config.tui_status_line.clone(); + chat.local_settings.tui.status_line = Some(vec!["thread-credits".to_string()]); + chat.local_settings.tui.terminal_title = chat.local_settings.tui.status_line.clone(); chat.refresh_status_surfaces(); let request_id = thread_usage_request_id(&mut rx); let previous_usage = ThreadUsage { @@ -481,7 +481,7 @@ async fn cost_settlement_waits_for_new_or_missing_usd_estimates() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -542,7 +542,7 @@ async fn credits_only_settlement_completes_without_usd_estimates() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_terminal_title = Some(vec!["thread-credits".to_string()]); + chat.local_settings.tui.terminal_title = Some(vec!["thread-credits".to_string()]); chat.refresh_terminal_title(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -582,8 +582,8 @@ async fn combined_billing_surfaces_wait_for_credits_and_cost() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec![status_line_item.to_string()]); - chat.config.tui_terminal_title = Some(vec![title_item.to_string()]); + chat.local_settings.tui.status_line = Some(vec![status_line_item.to_string()]); + chat.local_settings.tui.terminal_title = Some(vec![title_item.to_string()]); chat.refresh_status_surfaces(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -640,11 +640,11 @@ async fn billing_surfaces_render_for_every_supported_enterprise_plan() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(plan_type); - chat.config.tui_status_line = Some(vec![ + chat.local_settings.tui.status_line = Some(vec![ "thread-credits".to_string(), "estimated-thread-cost".to_string(), ]); - chat.config.tui_terminal_title = chat.config.tui_status_line.clone(); + chat.local_settings.tui.terminal_title = chat.local_settings.tui.status_line.clone(); chat.refresh_status_surfaces(); let request_id = thread_usage_request_id(&mut rx); assert!(chat.finish_thread_usage_refresh( @@ -676,7 +676,7 @@ async fn early_draw_rearms_thread_usage_retry_after_immediate_redraw() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let request_id = match rx.try_recv() { @@ -714,7 +714,7 @@ async fn unchanged_thread_usage_has_bounded_settlement_refreshes() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { @@ -772,7 +772,7 @@ async fn early_draw_rearms_thread_usage_settlement_after_immediate_redraw() { chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { @@ -834,7 +834,7 @@ async fn thread_usage_settlement_requires_request_started_after_turn_completion( chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); chat.refresh_status_line(); let initial_request_id = match rx.try_recv() { @@ -924,8 +924,8 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache chat.thread_id = Some(thread_id); chat.has_codex_backend_auth = true; chat.plan_type = Some(PlanType::Business); - chat.config.tui_status_line = Some(vec!["estimated-thread-cost".to_string()]); - chat.config.tui_terminal_title = Some(vec!["thread-credits".to_string()]); + chat.local_settings.tui.status_line = Some(vec!["estimated-thread-cost".to_string()]); + chat.local_settings.tui.terminal_title = Some(vec!["thread-credits".to_string()]); chat.refresh_status_surfaces(); let initial_request_id = match rx.try_recv() { @@ -965,8 +965,8 @@ async fn deselecting_billing_surfaces_cancels_polling_and_preserves_status_cache }; assert!(chat.thread_usage.settlement_refresh_due_at.is_some()); - chat.config.tui_status_line = Some(Vec::new()); - chat.config.tui_terminal_title = Some(Vec::new()); + chat.local_settings.tui.status_line = Some(Vec::new()); + chat.local_settings.tui.terminal_title = Some(Vec::new()); chat.refresh_status_surfaces(); assert!(!chat.thread_usage.status_requested); diff --git a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs index c43edfbba4..acc0e97523 100644 --- a/codex-rs/tui/src/chatwidget/tool_lifecycle.rs +++ b/codex-rs/tui/src/chatwidget/tool_lifecycle.rs @@ -74,7 +74,7 @@ impl ChatWidget { self.transcript.active_cell = Some(Box::new(history_cell::new_active_web_search_call( call_id, String::new(), - self.config.animations, + self.local_settings.tui.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -184,7 +184,7 @@ impl ChatWidget { tool, arguments: Some(arguments), }, - self.config.animations, + self.local_settings.tui.animations, ))); self.bump_active_cell_revision(); self.request_redraw(); @@ -236,8 +236,11 @@ impl ChatWidget { Some(cell) if cell.call_id() == id => cell.complete(duration, result), _ => { self.flush_active_cell(); - let mut cell = - history_cell::new_active_mcp_tool_call(id, invocation, self.config.animations); + let mut cell = history_cell::new_active_mcp_tool_call( + id, + invocation, + self.local_settings.tui.animations, + ); let extra_cell = cell.complete(duration, result); self.transcript.active_cell = Some(Box::new(cell)); extra_cell diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 68fcf51c72..77b459ad3d 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -29,7 +29,7 @@ impl ChatWidget { #[cfg(target_os = "windows")] pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { if self - .config + .local_settings .notices .hide_world_writable_warning .unwrap_or(false) diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 9d54384479..b0d6205602 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -3,6 +3,7 @@ use std::path::Path; use crate::key_hint; use crate::legacy_core::config::Config; use crate::legacy_core::config::edit::ConfigEditsBuilder; +use crate::local_settings::LocalSettings; use crate::render::Insets; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; @@ -149,7 +150,9 @@ pub(crate) async fn run_cwd_selection_prompt( Ok(CwdPromptOutcome::Exit) } else { let selection = screen.selection().unwrap_or(CwdSelection::Session); - if let Some(error_line) = persist_remembered_cwd_selection(config, selection).await { + if let Some(error_line) = + persist_remembered_cwd_selection(&LocalSettings::from(config), selection).await + { tui.insert_history_lines(vec![error_line]); } Ok(CwdPromptOutcome::Selection(selection)) @@ -157,11 +160,11 @@ pub(crate) async fn run_cwd_selection_prompt( } async fn persist_remembered_cwd_selection( - config: &Config, + config: &LocalSettings, selection: CwdSelection, ) -> Option> { let mode = selection.remembered_mode()?; - match ConfigEditsBuilder::for_config(config) + match ConfigEditsBuilder::for_config_path(config.user_config_path.as_path()) .set_resume_cwd(mode) .apply() .await @@ -521,7 +524,7 @@ mod tests { expected_cwd ); assert_eq!( - persist_remembered_cwd_selection(&config, selection).await, + persist_remembered_cwd_selection(&LocalSettings::from(&config), selection).await, None ); let persisted: toml::Value = toml::from_str(&std::fs::read_to_string( @@ -543,10 +546,12 @@ mod tests { let config_path = temp_dir.path().join("config.toml"); std::fs::create_dir(&config_path)?; - let error_line = - persist_remembered_cwd_selection(&config, CwdSelection::CurrentAndRemember) - .await - .expect("saving to a directory should fail"); + let error_line = persist_remembered_cwd_selection( + &LocalSettings::from(&config), + CwdSelection::CurrentAndRemember, + ) + .await + .expect("saving to a directory should fail"); let mut terminal = Terminal::new(VT100Backend::new(/*width*/ 100, /*height*/ 1)).expect("terminal"); terminal diff --git a/codex-rs/tui/src/dynamic_tools_tests.rs b/codex-rs/tui/src/dynamic_tools_tests.rs index b3d502ac6a..ec034c88b9 100644 --- a/codex-rs/tui/src/dynamic_tools_tests.rs +++ b/codex-rs/tui/src/dynamic_tools_tests.rs @@ -90,6 +90,7 @@ async fn test_server() -> color_eyre::Result<(TempDir, AppServerSession, String, .to_string(); server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config, ThreadId::from_string(&target)?, ResumeModelSettings::RestoreFromThread, diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs index 03df0141f7..87e0c7326c 100644 --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs @@ -122,8 +122,13 @@ impl HistoryCell for SessionInfoCell { } } +#[expect( + clippy::too_many_arguments, + reason = "keep local preferences separate while the legacy Config parameter is still required" +)] pub(crate) fn new_session_info( config: &Config, + local_settings: &crate::local_settings::LocalSettings, requested_model: &str, session: &ThreadSessionState, is_first_event: bool, @@ -181,7 +186,7 @@ pub(crate) fn new_session_info( parts.push(Box::new(PlainHistoryCell { lines: help_lines })); } else { - if config.show_tooltips + if local_settings.tui.show_tooltips && let Some(tooltips) = tooltip_override .or_else(|| tooltips::get_tooltip(auth_plan, show_fast_status)) .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index ae781fc3a9..f4a009e958 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -677,6 +677,7 @@ async fn session_info_uses_availability_nux_tooltip_override() { let config = test_config().await; let cell = new_session_info( &config, + &crate::local_settings::LocalSettings::from(&config), "gpt-5", &session_configured_event("gpt-5"), /*is_first_event*/ false, @@ -699,6 +700,7 @@ async fn session_info_availability_nux_tooltip_snapshot() { config.cwd = test_path_buf("/tmp/project").abs(); let cell = new_session_info( &config, + &crate::local_settings::LocalSettings::from(&config), "gpt-5", &session_configured_event("gpt-5"), /*is_first_event*/ false, @@ -716,6 +718,7 @@ async fn session_info_first_event_suppresses_tooltips_and_nux() { let config = test_config().await; let cell = new_session_info( &config, + &crate::local_settings::LocalSettings::from(&config), "gpt-5", &session_configured_event("gpt-5"), /*is_first_event*/ true, @@ -735,6 +738,7 @@ async fn session_info_hides_tooltips_when_disabled() { config.show_tooltips = false; let cell = new_session_info( &config, + &crate::local_settings::LocalSettings::from(&config), "gpt-5", &session_configured_event("gpt-5"), /*is_first_event*/ false, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 36852ab013..34cee7f2ee 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -148,6 +148,7 @@ mod keymap; mod keymap_setup; mod line_truncation; pub(crate) mod live_wrap; +mod local_settings; pub use live_wrap::RowBuilder; mod local_chatgpt_auth; mod managed_new_thread_defaults; @@ -804,7 +805,8 @@ async fn resolve_startup_resume_or_fork_cwd( }) else { return Ok(ResolveCwdOutcome::Continue(None)); }; - let resume_cwd_mode = effective_resume_cwd_mode(config.tui_resume_cwd, cwd_override); + let local_settings = crate::local_settings::LocalSettings::from(config); + let resume_cwd_mode = effective_resume_cwd_mode(local_settings.tui.resume_cwd, cwd_override); if uses_remote_workspace_or_environment && cwd_override.is_none() && matches!(resume_cwd_mode, Some(ResumeCwdMode::Current)) @@ -1122,7 +1124,7 @@ async fn run_ratatui_app( let startup_model_provider = initial_config.model_provider_id.clone(); let (login_status, mut startup_account) = if workload_identity_selected { (LoginStatus::AuthMode(AuthMode::Chatgpt), None) - } else if initial_config.model_provider.requires_openai_auth { + } else { let Some(active_app_server) = app_server.as_mut() else { unreachable!("app server should exist when auth is required"); }; @@ -1140,11 +1142,16 @@ async fn run_ratatui_app( return Err(err.into()); } } - } else { - (LoginStatus::NotAuthenticated, None) }; - let should_show_onboarding = - should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); + // Workload identity bypasses interactive login; every other provider uses account/read. + let requires_openai_auth = startup_account + .as_ref() + .is_some_and(|account| account.requires_openai_auth); + let should_show_onboarding = should_show_onboarding( + login_status, + requires_openai_auth, + should_show_trust_screen_flag, + ); let config = if should_show_onboarding { if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { @@ -1153,9 +1160,13 @@ async fn run_ratatui_app( } // Authentication can change while any interactive onboarding screen is open. startup_account = None; - let show_login_screen = should_show_login_screen(login_status, &initial_config); - let bedrock_setup_enabled = - should_show_bedrock_setup_wizard(login_status, &initial_config, &app_server_target); + let show_login_screen = should_show_login_screen(login_status, requires_openai_auth); + let bedrock_setup_enabled = should_show_bedrock_setup_wizard( + login_status, + requires_openai_auth, + &initial_config, + &app_server_target, + ); let onboarding_result = run_onboarding_app( OnboardingScreenArgs { show_login_screen, @@ -1347,6 +1358,7 @@ async fn run_ratatui_app( match resume_picker::run_fork_picker_with_app_server( &mut tui, &config, + &crate::local_settings::LocalSettings::from(&config), cli.fork_show_all, app_server, ) @@ -1441,6 +1453,7 @@ async fn run_ratatui_app( match resume_picker::run_resume_picker_with_app_server( &mut tui, &config, + &crate::local_settings::LocalSettings::from(&config), cli.resume_show_all, cli.resume_include_non_interactive, app_server, @@ -1569,11 +1582,12 @@ async fn run_ratatui_app( }; startup_draft.apply_config(&config); + let local_settings = crate::local_settings::LocalSettings::from(&config); // Configure syntax highlighting theme from the final config — onboarding // and resume/fork can both reload config with a different tui_theme, so // this must happen after the last possible reload. if let Some(w) = crate::render::highlight::set_theme_override( - config.tui_theme.clone(), + local_settings.tui.theme.clone(), find_codex_home().ok().map(AbsolutePathBuf::into_path_buf), ) { config.startup_warnings.push(w); @@ -1608,7 +1622,8 @@ async fn run_ratatui_app( } = cli; let images = shared.into_inner().images; - let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen); + let use_alt_screen = + determine_alt_screen_mode(no_alt_screen, local_settings.tui.alternate_screen); tui.set_alt_screen_enabled(use_alt_screen); if config.model_provider_id != startup_model_provider { startup_account = None; @@ -1923,20 +1938,20 @@ fn should_show_trust_screen(config: &Config) -> bool { fn should_show_onboarding( login_status: LoginStatus, - config: &Config, + requires_openai_auth: bool, show_trust_screen: bool, ) -> bool { if show_trust_screen { return true; } - should_show_login_screen(login_status, config) + should_show_login_screen(login_status, requires_openai_auth) } -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { +fn should_show_login_screen(login_status: LoginStatus, requires_openai_auth: bool) -> bool { // Only show the login screen for providers that actually require OpenAI auth // (OpenAI or equivalents). For OSS/other providers, skip login entirely. - if !config.model_provider.requires_openai_auth { + if !requires_openai_auth { return false; } @@ -1945,11 +1960,12 @@ fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool fn should_show_bedrock_setup_wizard( login_status: LoginStatus, + requires_openai_auth: bool, config: &Config, app_server_target: &AppServerTarget, ) -> bool { matches!(app_server_target, AppServerTarget::Embedded) - && should_show_login_screen(login_status, config) + && should_show_login_screen(login_status, requires_openai_auth) && config.features.enabled(Feature::BedrockSetupWizard) && config.model_provider_id == "openai" && config @@ -1985,6 +2001,43 @@ mod tests { .await } + #[tokio::test] + async fn server_account_requirement_controls_login_screen() -> color_eyre::Result<()> { + for requires_openai_auth in [false, true] { + let home = TempDir::new()?; + std::fs::write( + home.path().join("config.toml"), + format!( + r#" +model_provider = "test-provider" +[model_providers.test-provider] +name = "Test provider" +base_url = "http://example.test/v1" +wire_api = "responses" +requires_openai_auth = {requires_openai_auth} +"# + ), + )?; + let server_config = build_config(&home).await?; + let mut server = AppServerSession::new( + AppServerClient::InProcess(start_test_embedded_app_server(server_config).await?), + ThreadParamsMode::Embedded, + ); + let (login_status, account) = get_login_status(&mut server).await?; + assert_eq!(account.requires_openai_auth, requires_openai_auth); + assert_eq!( + should_show_login_screen(login_status, account.requires_openai_auth), + requires_openai_auth + ); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::Chatgpt), + account.requires_openai_auth + )); + server.shutdown().await?; + } + Ok(()) + } + #[tokio::test] async fn bedrock_setup_wizard_requires_eligible_onboarding() -> color_eyre::Result<()> { let shared_endpoint = RemoteAppServerEndpoint::WebSocket { @@ -2053,7 +2106,12 @@ mod tests { let config = build_config(&codex_home).await?; assert_eq!( - should_show_bedrock_setup_wizard(login_status, &config, &target), + should_show_bedrock_setup_wizard( + login_status, + /*requires_openai_auth*/ true, + &config, + &target + ), expected, "{label}" ); @@ -2348,13 +2406,22 @@ mod tests { CwdPromptAction::Resume => { app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&final_config), final_config, thread_id, app_server_session::ResumeModelSettings::RestoreFromThread, ) .await? } - CwdPromptAction::Fork => app_server.fork_thread(final_config, thread_id).await?, + CwdPromptAction::Fork => { + app_server + .fork_thread( + &crate::local_settings::LocalSettings::from(&final_config), + final_config, + thread_id, + ) + .await? + } }; assert!(!session_resume::cwds_differ( @@ -3246,6 +3313,7 @@ mod tests { let thread_id = ThreadId::from_string(&thread_id)?; let started = app_server .resume_thread( + &crate::local_settings::LocalSettings::from(&config), config.clone(), thread_id, app_server_session::ResumeModelSettings::RestoreFromThread, diff --git a/codex-rs/tui/src/local_settings.rs b/codex-rs/tui/src/local_settings.rs new file mode 100644 index 0000000000..4a593350a0 --- /dev/null +++ b/codex-rs/tui/src/local_settings.rs @@ -0,0 +1,78 @@ +//! Client-owned preferences and persistence paths, independent of active server settings. +//! +//! The resolved core config is a temporary input at local load/reload boundaries. Server thread +//! responses must never refresh these values; live preference changes belong here. The remaining +//! Config-based lifecycle adapters also use this conversion until their interfaces are migrated. + +use crate::legacy_core::config::Config; +use crate::legacy_core::config::TerminalResizeReflowConfig; +use crate::legacy_core::config::TerminalResizeReflowMaxRows; +use codex_config::types::History; +use codex_config::types::Notice; +use codex_config::types::Tui; +use codex_utils_absolute_path::AbsolutePathBuf; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct LocalSettings { + pub(crate) tui: Tui, + pub(crate) history: History, + pub(crate) notices: Notice, + pub(crate) codex_home: AbsolutePathBuf, + pub(crate) user_config_path: AbsolutePathBuf, +} + +impl From<&Config> for LocalSettings { + fn from(config: &Config) -> Self { + Self { + tui: Tui { + notification_settings: config.tui_notifications.clone(), + animations: config.animations, + show_tooltips: config.show_tooltips, + auto_recap: config.tui_auto_recap, + disable_paste_burst: Some(config.disable_paste_burst), + vim_mode_default: config.tui_vim_mode_default, + raw_output_mode: config.tui_raw_output_mode, + alternate_screen: config.tui_alternate_screen, + status_line: config.tui_status_line.clone(), + status_line_use_colors: config.tui_status_line_use_colors, + terminal_title: config.tui_terminal_title.clone(), + theme: config.tui_theme.clone(), + pet: config.tui_pet.clone(), + pet_anchor: config.tui_pet_anchor, + session_picker_view: Some(config.tui_session_picker_view), + resume_cwd: config.tui_resume_cwd, + keymap: config.tui_keymap.clone(), + model_availability_nux: config.model_availability_nux.clone(), + terminal_resize_reflow_max_rows: match config.terminal_resize_reflow.max_rows { + TerminalResizeReflowMaxRows::Auto => None, + TerminalResizeReflowMaxRows::Disabled => Some(0), + TerminalResizeReflowMaxRows::Limit(rows) => Some(rows), + }, + }, + history: config.history.clone(), + notices: config.notices.clone(), + codex_home: config.codex_home.clone(), + user_config_path: config + .config_layer_stack + .get_user_config_file() + .cloned() + .unwrap_or_else(|| config.codex_home.join("config.toml")), + } + } +} + +impl LocalSettings { + pub(crate) fn terminal_resize_reflow(&self) -> TerminalResizeReflowConfig { + TerminalResizeReflowConfig { + max_rows: match self.tui.terminal_resize_reflow_max_rows { + None => TerminalResizeReflowMaxRows::Auto, + Some(0) => TerminalResizeReflowMaxRows::Disabled, + Some(rows) => TerminalResizeReflowMaxRows::Limit(rows), + }, + } + } +} + +#[cfg(test)] +#[path = "local_settings_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/local_settings_tests.rs b/codex-rs/tui/src/local_settings_tests.rs new file mode 100644 index 0000000000..f676f8dbd5 --- /dev/null +++ b/codex-rs/tui/src/local_settings_tests.rs @@ -0,0 +1,106 @@ +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use crate::legacy_core::config::edit::ConfigEditsBuilder; +use codex_config::LoaderOverrides; +use codex_config::types::SessionPickerViewMode; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn local_load_preserves_defaults_and_resolved_overrides() -> anyhow::Result<()> { + for config_text in [ + "", + r#" +[tui] +animations = false +show_tooltips = false +auto_recap = false +vim_mode_default = true +terminal_resize_reflow_max_rows = 0 +session_picker_view = "comfortable" +[history] +persistence = "none" +max_bytes = 4096 +[notice] +fast_default_opt_out = true +"#, + ] { + let home = tempfile::tempdir()?; + std::fs::write(home.path().join("config.toml"), config_text)?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(LoaderOverrides { + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }) + .cli_overrides(vec![("tui.disable_paste_burst".into(), true.into())]) + .build() + .await?; + let local = LocalSettings::from(&config); + let mut expected: Tui = toml::from_str("")?; + expected.disable_paste_burst = Some(true); + expected.session_picker_view = Some(SessionPickerViewMode::Dense); + if !config_text.is_empty() { + expected.animations = false; + expected.show_tooltips = false; + expected.auto_recap = false; + expected.vim_mode_default = true; + expected.terminal_resize_reflow_max_rows = Some(0); + expected.session_picker_view = Some(SessionPickerViewMode::Comfortable); + } + assert_eq!(local.tui, expected); + assert_eq!( + local.terminal_resize_reflow(), + config.terminal_resize_reflow + ); + assert_eq!( + (&local.history, &local.notices), + (&config.history, &config.notices) + ); + } + Ok(()) +} + +#[tokio::test] +async fn local_writes_preserve_selected_user_file_and_home_destinations() -> anyhow::Result<()> { + let home = tempfile::tempdir()?; + let selected = AbsolutePathBuf::from_absolute_path(home.path().join("work.config.toml"))?; + std::fs::write(&selected, "[tui]\ntheme = \"dracula\"\n")?; + let overrides = LoaderOverrides { + user_config_path: Some(selected.clone()), + user_config_profile: Some("work".parse()?), + ignore_project_config: true, + ..LoaderOverrides::without_managed_config_for_tests() + }; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(overrides.clone()) + .build() + .await?; + let local = LocalSettings::from(&config); + assert_eq!(local.user_config_path, selected); + ConfigEditsBuilder::for_config_path(local.user_config_path.as_path()) + .with_edits([crate::legacy_core::config::edit::syntax_theme_edit("nord")]) + .apply() + .await?; + ConfigEditsBuilder::new(local.codex_home.as_path()) + .set_session_picker_view(SessionPickerViewMode::Comfortable) + .apply() + .await?; + let reloaded = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .loader_overrides(overrides) + .build() + .await?; + assert_eq!( + LocalSettings::from(&reloaded).tui.theme.as_deref(), + Some("nord") + ); + let home_config: toml::Value = + toml::from_str(&std::fs::read_to_string(home.path().join("config.toml"))?)?; + assert_eq!( + home_config["tui"]["session_picker_view"].as_str(), + Some("comfortable") + ); + assert_eq!(home_config["tui"].get("theme"), None); + Ok(()) +} diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 39e06b8b1b..756e231b50 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -122,6 +122,7 @@ impl OnboardingScreen { app_server_request_handle, config, } = args; + let local_settings = crate::local_settings::LocalSettings::from(&config); let cwd = config.cwd.to_path_buf(); let remote_trust_key = remote_project_trust .as_ref() @@ -131,7 +132,7 @@ impl OnboardingScreen { steps.push(Step::Welcome(WelcomeWidget::new( !matches!(login_status, LoginStatus::NotAuthenticated), tui.frame_requester(), - config.animations, + local_settings.tui.animations, ))); if show_login_screen { let highlighted_mode = @@ -150,7 +151,7 @@ impl OnboardingScreen { app_server_request_handle, auth_config, bedrock_setup_enabled, - animations_enabled: config.animations, + animations_enabled: local_settings.tui.animations, animations_suppressed: std::cell::Cell::new(false), })); } else { diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 8885451703..3c03a43eb3 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -362,6 +362,7 @@ struct SessionPickerRunOptions { pub async fn run_resume_picker_with_app_server( tui: &mut Tui, config: &Config, + local_settings: &crate::local_settings::LocalSettings, show_all: bool, include_non_interactive: bool, app_server: AppServerSession, @@ -370,6 +371,7 @@ pub async fn run_resume_picker_with_app_server( run_resume_picker_with_launch_context( tui, config, + local_settings, show_all, include_non_interactive, app_server, @@ -379,9 +381,14 @@ pub async fn run_resume_picker_with_app_server( .await } +#[expect( + clippy::too_many_arguments, + reason = "keep local preferences separate while the legacy Config parameter is still required" +)] pub async fn run_resume_picker_from_existing_session_with_app_server( tui: &mut Tui, config: &Config, + local_settings: &crate::local_settings::LocalSettings, show_all: bool, include_non_interactive: bool, app_server: AppServerSession, @@ -391,6 +398,7 @@ pub async fn run_resume_picker_from_existing_session_with_app_server( run_resume_picker_with_launch_context( tui, config, + local_settings, show_all, include_non_interactive, app_server, @@ -400,9 +408,14 @@ pub async fn run_resume_picker_from_existing_session_with_app_server( .await } +#[expect( + clippy::too_many_arguments, + reason = "keep local preferences separate while the legacy Config parameter is still required" +)] async fn run_resume_picker_with_launch_context( tui: &mut Tui, config: &Config, + local_settings: &crate::local_settings::LocalSettings, show_all: bool, include_non_interactive: bool, app_server: AppServerSession, @@ -419,7 +432,7 @@ async fn run_resume_picker_with_launch_context( ); let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_workspace); let provider_filter = picker_provider_filter(config, uses_remote_workspace); - let runtime_keymap = picker_runtime_keymap(config)?; + let runtime_keymap = picker_runtime_keymap(local_settings)?; let options = SessionPickerRunOptions { show_all, filter_cwd: cwd_filter, @@ -427,9 +440,11 @@ async fn run_resume_picker_with_launch_context( action: SessionPickerAction::Resume, launch_context, provider_filter, - initial_density: SessionListDensity::from(config.tui_session_picker_view), + initial_density: SessionListDensity::from( + local_settings.tui.session_picker_view.unwrap_or_default(), + ), view_persistence: Some(SessionPickerViewPersistence { - codex_home: config.codex_home.to_path_buf(), + codex_home: local_settings.codex_home.to_path_buf(), }), pager_keymap: runtime_keymap.pager, list_keymap: runtime_keymap.list, @@ -459,6 +474,7 @@ async fn run_resume_picker_with_launch_context( pub async fn run_fork_picker_with_app_server( tui: &mut Tui, config: &Config, + local_settings: &crate::local_settings::LocalSettings, show_all: bool, app_server: AppServerSession, ) -> Result { @@ -473,7 +489,7 @@ pub async fn run_fork_picker_with_app_server( ); let local_filter_cwd = local_picker_cwd_filter(&cwd_filter, uses_remote_workspace); let provider_filter = picker_provider_filter(config, uses_remote_workspace); - let runtime_keymap = picker_runtime_keymap(config)?; + let runtime_keymap = picker_runtime_keymap(local_settings)?; let options = SessionPickerRunOptions { show_all, filter_cwd: cwd_filter, @@ -481,9 +497,11 @@ pub async fn run_fork_picker_with_app_server( action: SessionPickerAction::Fork, launch_context: SessionPickerLaunchContext::Startup, provider_filter, - initial_density: SessionListDensity::from(config.tui_session_picker_view), + initial_density: SessionListDensity::from( + local_settings.tui.session_picker_view.unwrap_or_default(), + ), view_persistence: Some(SessionPickerViewPersistence { - codex_home: config.codex_home.to_path_buf(), + codex_home: local_settings.codex_home.to_path_buf(), }), pager_keymap: runtime_keymap.pager, list_keymap: runtime_keymap.list, @@ -630,8 +648,8 @@ fn picker_provider_filter(config: &Config, uses_remote_workspace: bool) -> Provi } } -fn picker_runtime_keymap(config: &Config) -> Result { - RuntimeKeymap::from_config(&config.tui_keymap) +fn picker_runtime_keymap(config: &crate::local_settings::LocalSettings) -> Result { + RuntimeKeymap::from_config(&config.tui.keymap) .map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}")) } diff --git a/codex-rs/tui/src/resume_picker_transcript_preview.rs b/codex-rs/tui/src/resume_picker_transcript_preview.rs index a1044f02fb..8ef505c0a5 100644 --- a/codex-rs/tui/src/resume_picker_transcript_preview.rs +++ b/codex-rs/tui/src/resume_picker_transcript_preview.rs @@ -77,6 +77,7 @@ pub(crate) async fn load_transcript_preview( /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, + /*local_settings*/ None, HistoryHydrationScope::Initial, ) .await diff --git a/codex-rs/tui/src/service_tier_resolution.rs b/codex-rs/tui/src/service_tier_resolution.rs index 1e65fd3fb0..de57dc7e9b 100644 --- a/codex-rs/tui/src/service_tier_resolution.rs +++ b/codex-rs/tui/src/service_tier_resolution.rs @@ -3,15 +3,19 @@ use codex_features::Feature; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::openai_models::ModelPreset; -pub(crate) fn configured_service_tier(config: &Config) -> Option { +pub(crate) fn configured_service_tier( + config: &Config, + notices: &codex_config::types::Notice, +) -> Option { config.service_tier.clone().or_else(|| { - (config.notices.fast_default_opt_out == Some(true)) + (notices.fast_default_opt_out == Some(true)) .then(|| SERVICE_TIER_DEFAULT_REQUEST_VALUE.to_string()) }) } pub(crate) fn effective_service_tier( config: &Config, + notices: &codex_config::types::Notice, model: &str, models: &[ModelPreset], ) -> Option { @@ -19,7 +23,7 @@ pub(crate) fn effective_service_tier( return None; } - let configured = configured_service_tier(config); + let configured = configured_service_tier(config, notices); let Some(preset) = models.iter().find(|preset| preset.model == model) else { return configured; }; @@ -37,6 +41,7 @@ pub(crate) fn effective_service_tier( pub(crate) fn service_tier_update_for_core( config: &Config, + notices: &codex_config::types::Notice, model: &str, models: &[ModelPreset], ) -> Option> { @@ -44,7 +49,7 @@ pub(crate) fn service_tier_update_for_core( return None; } - let effective = effective_service_tier(config, model, models); + let effective = effective_service_tier(config, notices, model, models); if let Some(service_tier) = effective { return Some(Some(service_tier)); } diff --git a/codex-rs/tui/src/session_start.rs b/codex-rs/tui/src/session_start.rs index b5e6f15aba..bbeecb5b74 100644 --- a/codex-rs/tui/src/session_start.rs +++ b/codex-rs/tui/src/session_start.rs @@ -31,15 +31,16 @@ impl SessionStartAction { config: &Config, target: &SessionTarget, ) -> Result { + let local_settings = crate::local_settings::LocalSettings::from(config); match self { Self::Resume(settings) => { app_server - .resume_thread(config.clone(), target.thread_id, settings) + .resume_thread(&local_settings, config.clone(), target.thread_id, settings) .await } Self::Fork => { app_server - .fork_thread(config.clone(), target.thread_id) + .fork_thread(&local_settings, config.clone(), target.thread_id) .await } } diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs index e4284a5812..06852d6819 100644 --- a/codex-rs/tui/src/startup_draft.rs +++ b/codex-rs/tui/src/startup_draft.rs @@ -167,11 +167,12 @@ impl StartupDraft { impl StartupDraftPump { /// Refresh the session header and safe editor shortcuts without enabling modal editing. pub(crate) fn apply_config(&mut self, config: &Config) { + let local_settings = crate::local_settings::LocalSettings::from(config); self.header = startup_session_header(Some(config)); self.bottom_pane - .set_disable_paste_burst(config.disable_paste_burst); + .set_disable_paste_burst(local_settings.tui.disable_paste_burst.unwrap_or(false)); self.bottom_pane.request_redraw(); - if let Ok(keymap) = RuntimeKeymap::from_config(&config.tui_keymap) { + if let Ok(keymap) = RuntimeKeymap::from_config(&local_settings.tui.keymap) { self.bottom_pane.set_keymap_bindings(&keymap); } } diff --git a/codex-rs/tui/src/startup_hooks_review.rs b/codex-rs/tui/src/startup_hooks_review.rs index a4b40a008b..002e58b144 100644 --- a/codex-rs/tui/src/startup_hooks_review.rs +++ b/codex-rs/tui/src/startup_hooks_review.rs @@ -86,7 +86,8 @@ async fn run_startup_hooks_review_app( config: &Config, entry: HooksListEntry, ) -> Result { - let keymap = RuntimeKeymap::from_config(&config.tui_keymap) + let local_settings = crate::local_settings::LocalSettings::from(config); + let keymap = RuntimeKeymap::from_config(&local_settings.tui.keymap) .map_err(|err| color_eyre::eyre::eyre!(err))?; let (tx_raw, _rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index 93f959c8db..34dc548d51 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -191,6 +191,7 @@ pub(crate) fn new_status_output_with_rate_limits( ) -> CompositeHistoryCell { new_status_output_with_rate_limits_handle( config, + config.model_provider.requires_openai_auth, /*runtime_model_provider_base_url*/ None, /*remote_connection*/ None, account_display, @@ -214,6 +215,7 @@ pub(crate) fn new_status_output_with_rate_limits( #[allow(clippy::too_many_arguments)] pub(crate) fn new_status_output_with_rate_limits_handle( config: &Config, + requires_openai_auth: bool, runtime_model_provider_base_url: Option<&str>, remote_connection: Option<&RemoteConnectionStatus>, account_display: Option<&StatusAccountDisplay>, @@ -234,6 +236,7 @@ pub(crate) fn new_status_output_with_rate_limits_handle( let command = PlainHistoryCell::new(vec!["/status".magenta().into()]); let (card, handle) = StatusHistoryCell::new( config, + requires_openai_auth, runtime_model_provider_base_url, remote_connection, account_display, @@ -262,6 +265,7 @@ impl StatusHistoryCell { #[allow(clippy::too_many_arguments)] fn new( config: &Config, + requires_openai_auth: bool, runtime_model_provider_base_url: Option<&str>, remote_connection: Option<&RemoteConnectionStatus>, account_display: Option<&StatusAccountDisplay>, @@ -333,7 +337,7 @@ impl StatusHistoryCell { workspace_root_suffix.as_deref(), ); let model_provider = format_model_provider(config, runtime_model_provider_base_url); - let show_chatgpt_usage_link = config.model_provider.requires_openai_auth; + let show_chatgpt_usage_link = requires_openai_auth; let account = compose_account_display(account_display); let session_id = session_id.as_ref().map(std::string::ToString::to_string); let forked_from = forked_from.map(|id| id.to_string()); diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_not_required.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_not_required.snap new file mode 100644 index 0000000000..bd805b72ef --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_not_required.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/status/tests.rs +assertion_line: 776 +expression: rendered +--- +/status + +╭────────────────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Model: gpt-5.6-sol (reasoning none, summaries auto) │ +│ Model provider: Amazon Bedrock - https://bedrock-mantle.eu-west-1.api.aws/openai/v1 │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Limits: data not available yet │ +╰────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_required.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_required.snap new file mode 100644 index 0000000000..d5560f032f --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_server_auth_required.snap @@ -0,0 +1,22 @@ +--- +source: tui/src/status/tests.rs +assertion_line: 808 +expression: rendered +--- +/status + +╭─────────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.6-sol (reasoning none, summaries auto) │ +│ Model provider: OpenAI Proxy │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ │ +│ Token usage: 0 total (0 input + 0 output) │ +│ Limits: data not available yet │ +╰─────────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 161bbf4c74..f8e23fd730 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -733,6 +733,7 @@ async fn status_snapshot_shows_active_user_defined_profile() { async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_link() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); config.model_provider_id = "amazon-bedrock".to_string(); config.model_provider = ModelProviderInfo::create_amazon_bedrock_provider(Some(ModelProviderAwsAuthInfo { @@ -750,8 +751,10 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin let model_slug = get_model_offline_for_tests(config.model.as_deref()); let runtime_base_url = "https://bedrock-mantle.eu-west-1.api.aws/openai/v1"; + config.model_provider.requires_openai_auth = true; let (composite, _handle) = new_status_output_with_rate_limits_handle( &config, + /*requires_openai_auth*/ false, Some(runtime_base_url), /*remote_connection*/ None, test_status_account_display().as_ref(), @@ -769,30 +772,20 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin "".to_string(), /*refreshing_rate_limits*/ false, ); - let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); - - assert!( - rendered.contains(&format!("Amazon Bedrock - {runtime_base_url}")), - "expected /status to render runtime Bedrock URL, got: {rendered}" - ); - assert!( - !rendered.contains("bedrock-mantle.us-east-1"), - "expected /status to ignore configured Bedrock base URL, got: {rendered}" - ); - assert!( - !rendered.contains("https://chatgpt.com/codex/settings/usage"), - "expected /status to hide ChatGPT usage link for Bedrock, got: {rendered}" - ); + let rendered = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 120))).join("\n"); + assert_snapshot!("status_server_auth_not_required", rendered); config.model_provider_id = "openai-proxy".to_string(); config.model_provider = ModelProviderInfo { name: "OpenAI Proxy".to_string(), base_url: Some("https://openai-proxy.example/v1".to_string()), - requires_openai_auth: true, + requires_openai_auth: false, ..ModelProviderInfo::default() }; let (composite, _handle) = new_status_output_with_rate_limits_handle( &config, + /*requires_openai_auth*/ true, /*runtime_model_provider_base_url*/ None, /*remote_connection*/ None, test_status_account_display().as_ref(), @@ -810,12 +803,9 @@ async fn status_model_provider_uses_bedrock_runtime_base_url_and_gates_usage_lin "".to_string(), /*refreshing_rate_limits*/ false, ); - let rendered = render_lines(&composite.display_lines(/*width*/ 120)).join("\n"); - - assert!( - rendered.contains("https://chatgpt.com/codex/settings/usage"), - "expected /status to show ChatGPT usage link for OpenAI-auth proxy, got: {rendered}" - ); + let rendered = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 120))).join("\n"); + assert_snapshot!("status_server_auth_required", rendered); let wide_destinations: Vec = composite .display_hyperlink_lines(/*width*/ 120) @@ -1639,6 +1629,7 @@ async fn status_snapshot_uses_default_reasoning_when_config_empty() { let token_info = token_info_for(&model_slug, &config, &usage); let (composite, _) = new_status_output_with_rate_limits_handle( &config, + /*requires_openai_auth*/ true, /*runtime_model_provider_base_url*/ None, Some(&remote_connection), account_display.as_ref(), @@ -1748,6 +1739,7 @@ async fn transcript_overlay_remeasures_status_after_rate_limit_refresh() { let (status, handle) = new_status_output_with_rate_limits_handle( &config, + /*requires_openai_auth*/ true, /*runtime_model_provider_base_url*/ None, /*remote_connection*/ None, /*account_display*/ None, diff --git a/codex-rs/tui/src/thread_transcript.rs b/codex-rs/tui/src/thread_transcript.rs index 2c5f44edf3..d47f9fa3e4 100644 --- a/codex-rs/tui/src/thread_transcript.rs +++ b/codex-rs/tui/src/thread_transcript.rs @@ -48,6 +48,7 @@ pub(crate) async fn load_session_transcript( /*turn_cursor*/ None, /*item_cursor*/ None, /*config*/ None, + /*local_settings*/ None, HistoryHydrationScope::Complete, ) .await