Separate TUI preferences from server configuration (#42202)

## Why

TUI preferences and persistence paths belong to the local client, while thread
configuration and account requirements may come from the active app server.
Keeping both in the same resolved `Config` can replace live local preferences
when a thread is resumed, forked, reconnected, or switched.

## What changed

- Add `LocalSettings` as the TUI-owned source for UI, history, notice, and local
  persistence settings, and preserve it across session lifecycle transitions.
- Write preference changes to the selected user config file and reload local
  settings from disk when changing roots.
- Use the app server's account response for authentication-dependent onboarding
  and status UI instead of the local model-provider configuration.
- Retain platform family and OS metadata reported by remote app servers.

## Testing

- Cover local setting defaults, overrides, persistence destinations, reloads,
  and preservation across widget replacement and root switching.
- Cover remote platform metadata and server-controlled authentication UI.

GitOrigin-RevId: dbc2965fb1d6cbecb3f973f6133f3dcd4467b753
This commit is contained in:
Eric Traut
2026-09-02 04:20:38 +00:00
committed by copyberry
parent 0b509e930e
commit 9d57be71ba
87 changed files with 1188 additions and 459 deletions
+78 -5
View File
@@ -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<AppServerPath> {
match self {
Self::InProcess(_) => Some(AppServerPath::from_app_server(
@@ -930,6 +947,22 @@ mod tests {
async fn expect_remote_initialize<S>(websocket: &mut tokio_tungstenite::WebSocketStream<S>)
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<S>(
websocket: &mut tokio_tungstenite::WebSocketStream<S>,
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 {
+32 -17
View File
@@ -148,12 +148,19 @@ enum RemoteClientCommand {
},
}
#[derive(Default)]
struct RemoteServerMetadata {
server_version: Option<String>,
codex_home: Option<String>,
platform_family: Option<String>,
platform_os: Option<String>,
}
pub struct RemoteAppServerClient {
command_tx: mpsc::Sender<RemoteClientCommand>,
event_rx: mpsc::UnboundedReceiver<AppServerEvent>,
pending_events: VecDeque<AppServerEvent>,
server_version: Option<String>,
codex_home: Option<String>,
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<S>(
@@ -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<S>(
endpoint: &str,
params: InitializeParams,
initialize_timeout: Duration,
) -> IoResult<(Vec<AppServerEvent>, Option<String>, Option<String>)>
) -> IoResult<(Vec<AppServerEvent>, 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<AppServerEvent> {
@@ -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,
};
+4 -1
View File
@@ -536,8 +536,9 @@ pub(crate) struct App {
pub(crate) app_event_tx: AppEventSender,
pub(crate) chat_widget: ChatWidget,
workspace_command_runner: Option<WorkspaceCommandRunner>,
/// 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<PathBuf>,
@@ -779,6 +780,7 @@ impl App {
initial_user_message: Option<crate::chatwidget::UserMessage>,
) -> 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(),
+10 -4
View File
@@ -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());
@@ -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,
@@ -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(),
),
+56 -18
View File
@@ -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<Config> {
) -> 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(&current_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)
);
}
+21 -19
View File
@@ -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::<Vec<_>>();
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);
+6 -6
View File
@@ -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<Option<crate::pets::AmbientPet>, 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;
}
+2 -2
View File
@@ -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 {
+2 -2
View File
@@ -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)
+3
View File
@@ -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<ThreadId>,
remote_cwd: Option<PathBuf>,
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,
+6 -3
View File
@@ -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<usize> {
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) {
+10 -11
View File
@@ -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<Arc<dyn HistoryCell>> {
@@ -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));
}
+3 -2
View File
@@ -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<Config, AppRunControl> {
) -> 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());
+2
View File
@@ -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,
+7 -3
View File
@@ -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());
+1 -1
View File
@@ -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) => {
+34 -12
View File
@@ -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(),
+14 -9
View File
@@ -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<String> {
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);
+1
View File
@@ -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(),
+23 -11
View File
@@ -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(),
@@ -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,
+9 -4
View File
@@ -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)])
);
@@ -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(),
@@ -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::<Vec<_>>();
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 {
@@ -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(),
+1
View File
@@ -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,
+7 -6
View File
@@ -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,
@@ -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
+5 -2
View File
@@ -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");
+66 -10
View File
@@ -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<AppServerStartedThread> {
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<String>,
@@ -818,6 +831,7 @@ impl AppServerSession {
goal_continuation: ForkGoalContinuation,
) -> Result<AppServerStartedThread> {
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<AppServerStartedThread> {
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<String>,
@@ -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<Option<String>> {
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!(
+15 -6
View File
@@ -201,6 +201,7 @@ impl AppServerSession {
turn_cursor: Option<String>,
item_cursor: Option<String>,
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<ThreadItem>,
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
@@ -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?;
@@ -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
+6 -3
View File
@@ -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<UserMessage>,
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<ModelCatalog>,
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<String>,
@@ -566,6 +569,7 @@ pub(crate) struct ChatWidget {
/// The currently active collaboration mask, if any.
active_collaboration_mask: Option<CollaborationModeMask>,
has_chatgpt_account: bool,
pub(crate) requires_openai_auth: bool,
has_codex_backend_auth: bool,
model_catalog: Arc<ModelCatalog>,
model_popup_request_id: Option<uuid::Uuid>,
@@ -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();
}
@@ -23,7 +23,7 @@ impl ChatWidget {
pub(crate) fn backend_banner_fallback(&self) -> Option<ModelPreset> {
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;
@@ -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}");
+11 -6
View File
@@ -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);
@@ -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()
+1 -1
View File
@@ -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();
}
}
+9 -8
View File
@@ -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 =
+1 -1
View File
@@ -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()
+17 -16
View File
@@ -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<crate::pets::AmbientPet> {
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<String>) {
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<String>,
ambient_pet: Option<crate::pets::AmbientPet>,
) {
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();
@@ -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,
)),
+3 -3
View File
@@ -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<ModelPreset> {
@@ -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;
}
+1 -1
View File
@@ -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();
@@ -28,6 +28,7 @@ impl ChatWidget {
pub(crate) fn service_tier_update_for_core(&self) -> Option<Option<String>> {
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(),
);
@@ -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,
+3 -3
View File
@@ -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<String>) {
self.config.tui_theme = theme;
self.local_settings.tui.theme = theme;
}
/// Set the model in the widget's config copy and stored collaboration mode.
@@ -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(),
);
@@ -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!(
+21 -17
View File
@@ -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::<Vec<_>>();
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<TerminalTitleItem>) {
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::<Vec<_>>();
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::<Vec<_>>();
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(),
+36 -24
View File
@@ -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<Duration> {
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<String> {
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<String> {
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<String> {
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)
@@ -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);
@@ -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
@@ -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,
@@ -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 [
@@ -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);
@@ -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::<AppEvent>().0),
@@ -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::<AppEvent>().0),
+3 -3
View File
@@ -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()));
@@ -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,
@@ -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(),
]);
@@ -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();
@@ -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);
@@ -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
@@ -29,7 +29,7 @@ impl ChatWidget {
#[cfg(target_os = "windows")]
pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec<String>, usize, bool)> {
if self
.config
.local_settings
.notices
.hide_world_writable_warning
.unwrap_or(false)
+13 -8
View File
@@ -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<Line<'static>> {
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
+1
View File
@@ -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,
+6 -1
View File
@@ -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))
+4
View File
@@ -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,
+86 -18
View File
@@ -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,
+78
View File
@@ -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;
+106
View File
@@ -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(())
}
@@ -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 {
+26 -8
View File
@@ -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<SessionSelection> {
@@ -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> {
RuntimeKeymap::from_config(&config.tui_keymap)
fn picker_runtime_keymap(config: &crate::local_settings::LocalSettings) -> Result<RuntimeKeymap> {
RuntimeKeymap::from_config(&config.tui.keymap)
.map_err(|err| color_eyre::eyre::eyre!("invalid keymap configuration: {err}"))
}
@@ -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
+9 -4
View File
@@ -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<String> {
pub(crate) fn configured_service_tier(
config: &Config,
notices: &codex_config::types::Notice,
) -> Option<String> {
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<String> {
@@ -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<Option<String>> {
@@ -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));
}
+3 -2
View File
@@ -31,15 +31,16 @@ impl SessionStartAction {
config: &Config,
target: &SessionTarget,
) -> Result<AppServerStartedThread> {
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
}
}
+3 -2
View File
@@ -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);
}
}
+2 -1
View File
@@ -86,7 +86,8 @@ async fn run_startup_hooks_review_app(
config: &Config,
entry: HooksListEntry,
) -> Result<StartupHooksReviewOutcome> {
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::<AppEvent>();
let app_event_tx = AppEventSender::new(tx_raw);
+5 -1
View File
@@ -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());
@@ -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: <none> │
│ │
│ Token usage: 0 total (0 input + 0 output) │
│ Limits: data not available yet │
╰────────────────────────────────────────────────────────────────────────────────────────╯
@@ -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: <none> │
│ │
│ Token usage: 0 total (0 input + 0 output) │
│ Limits: data not available yet │
╰─────────────────────────────────────────────────────────────────────────────╯
+13 -21
View File
@@ -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
"<none>".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
"<none>".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<String> = 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,
+1
View File
@@ -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