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
+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