From c22dcc1ad9349bc27baf4e9bdd4847f5d5dfb66b Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 01/28] feat(desktop): add switchyard-desktop --print for recorded costs and savings Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/main.rs | 76 +++++-- crates/switchyard-desktop/src/pricing.rs | 97 ++++++++ crates/switchyard-desktop/src/rollup.rs | 17 ++ crates/switchyard-desktop/src/summary.rs | 270 +++++++++++++++++++++++ 4 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 crates/switchyard-desktop/src/pricing.rs create mode 100644 crates/switchyard-desktop/src/summary.rs diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index e1ba58f87b..acc6182c7d 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -1,13 +1,18 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The desktop window shows local server health and recorded usage. +//! The desktop app reports a local Switchyard server's recorded usage. +//! +//! The printed summary shows recorded traffic and compares list-price estimates with the +//! configured baseline model. #![cfg_attr(windows, windows_subsystem = "windows")] -use switchyard_desktop::config; +use switchyard_desktop::{config, health, rollup}; #[cfg(any(target_os = "macos", windows))] mod gui; +mod pricing; +mod summary; use std::path::PathBuf; use std::process::ExitCode; @@ -15,12 +20,12 @@ use std::process::ExitCode; use config::Config; const USAGE: &str = "\ -Usage: switchyard-desktop [--validate-toml] [FILE] +Usage: switchyard-desktop [--print | --validate-toml] [FILE] -This app opens a window with server status and recorded usage. -The desktop window supports macOS and Windows. +This app opens the desktop window. Use --print for a usage summary. FILE The app reads settings from FILE. + --print Print the current summary and exit. --validate-toml Parse FILE as TOML and exit. Without FILE, the app uses bundled installation metadata to find desktop.toml. @@ -30,7 +35,11 @@ when SY_HOME is unset. Missing settings use the directory and port from adjacent install.toml, or the settings file's directory and port 4123 when that metadata is absent. The default server URL includes the port, such as http://127.0.0.1:4123. -A configured HTTP URL without a port uses port 80."; +A configured HTTP URL without a port uses port 80. + +Savings compare spending on all calls with the cost of the answers at +baseline_model's bundled rates or the table selected by pricing_file. +Recorded spending remains visible when savings are unknown."; /// The command checks TOML syntax or opens the desktop window. fn main() -> ExitCode { @@ -43,12 +52,14 @@ fn main() -> ExitCode { }; let mut settings = None; let mut validate_toml = false; + let mut print_only = false; for arg in std::env::args().skip(1) { match arg.as_str() { "-h" | "--help" => { println!("{USAGE}"); return ExitCode::SUCCESS; } + "--print" => print_only = true, "--validate-toml" => validate_toml = true, _ if arg.starts_with('-') => { eprintln!("switchyard-desktop: unknown option {arg}\n\n{USAGE}"); @@ -81,18 +92,53 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - #[cfg(any(target_os = "macos", windows))] - match gui::run(settings) { - Ok(()) => ExitCode::SUCCESS, + if !print_only { + #[cfg(any(target_os = "macos", windows))] + return match gui::run(settings) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + ExitCode::FAILURE + } + }; + #[cfg(not(any(target_os = "macos", windows)))] + { + eprintln!( + "The desktop window supports macOS and Windows. Use --print for a usage summary." + ); + return ExitCode::FAILURE; + } + } + + let config = match Config::load(&settings) { + Ok(config) => config, Err(error) => { eprintln!("switchyard-desktop: {error}"); - ExitCode::FAILURE + return ExitCode::FAILURE; } - } - #[cfg(not(any(target_os = "macos", windows)))] + }; + let usage = match rollup::Reader::default() + .read(&config.routing_log, chrono::Local::now().date_naive()) { - let _ = settings; - eprintln!("The desktop window supports macOS and Windows."); - ExitCode::FAILURE + Ok(usage) => usage, + Err(error) => { + eprintln!( + "Usage unavailable. Could not read {}: {error}", + config.routing_log.display() + ); + return ExitCode::FAILURE; + } + }; + let rates = match pricing::load(config.prices_file.as_deref()) { + Ok(rates) => rates, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + }; + let status = health::Probe::default().probe(&config.server_url); + for row in summary::build(status, &usage, &config, &rates) { + println!("{row}"); } + ExitCode::SUCCESS } diff --git a/crates/switchyard-desktop/src/pricing.rs b/crates/switchyard-desktop/src/pricing.rs new file mode 100644 index 0000000000..6a56d70dce --- /dev/null +++ b/crates/switchyard-desktop/src/pricing.rs @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module compares the cost that the server recorded with the cost of the same answers on +//! the baseline model. The server prices each call when it finishes. The app uses bundled +//! rates or a configured override only to price the baseline. + +use std::path::Path; + +use serde::Serialize; +use switchyard_costs::Pricing; + +use crate::rollup::Totals; + +/// This function loads bundled rates when no override is selected. +/// An explicit file replaces the entire table; this function returns an error if it cannot load it. +pub fn load(path: Option<&Path>) -> Result { + match path { + Some(path) => Pricing::load(path), + None => Pricing::bundled(), + } +} + +/// Estimate stores one period's recorded cost and the baseline model's cost for its answers. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct Estimate { + /// This field stores the recorded cost of every call, including routing calls. It is + /// `None` when any call has no recorded cost. + pub actual: Option, + /// This field adds up the calls that have a recorded cost. + pub known: f64, + /// This flag distinguishes a recorded zero cost from missing costs. + pub has_known_cost: bool, + /// This field counts calls that the server recorded without a cost. + pub unknown_calls: u64, + /// This field prices the answer calls at the baseline model's rates. It is `None` when + /// the selected price table has no rates for the baseline model. + pub baseline: Option, + /// This field counts answer calls recorded without token counts. The baseline cannot + /// price them, so they hide the savings. + pub uncounted_answers: u64, +} + +impl Estimate { + /// This function totals recorded costs and prices only answer tokens for the baseline. + pub fn new(totals: &Totals, rates: &Pricing, baseline_model: &str) -> Self { + let cost = totals.cost(); + // Routing calls would not exist without Switchyard, so the baseline prices only answers. + let baseline = rates + .contains(baseline_model) + .then(|| { + totals.routed.values().try_fold(0.0, |sum, tokens| { + Some(sum + rates.estimate(baseline_model, &tokens.usage())?.usd) + }) + }) + .flatten(); + Self { + actual: cost.total_usd, + known: cost.known_usd, + has_known_cost: cost.estimated_calls > 0 || cost.provider_reported_calls > 0, + unknown_calls: cost.unknown_calls, + baseline, + uncounted_answers: totals.routed.values().map(|tokens| tokens.uncounted).sum(), + } + } + + /// This function returns the savings when every call has a cost, every answer has token + /// counts, and the baseline has rates. + pub fn savings(&self) -> Option { + if self.uncounted_answers > 0 { + return None; + } + Some(Savings { + actual: self.actual?, + baseline: self.baseline?, + }) + } +} + +/// Savings stores a period's recorded cost and the baseline cost of the same answers. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Savings { + pub actual: f64, + pub baseline: f64, +} + +impl Savings { + /// This function returns estimated dollars saved; routing overhead can make the value negative. + pub fn saved(&self) -> f64 { + self.baseline - self.actual + } + + /// This function returns the percentage saved, or `None` when the baseline cost is zero. + pub fn percent(&self) -> Option { + (self.baseline > 0.0).then(|| self.saved() / self.baseline * 100.0) + } +} diff --git a/crates/switchyard-desktop/src/rollup.rs b/crates/switchyard-desktop/src/rollup.rs index 73175ca8f6..2a87e00673 100644 --- a/crates/switchyard-desktop/src/rollup.rs +++ b/crates/switchyard-desktop/src/rollup.rs @@ -52,6 +52,23 @@ impl ModelTokens { self.cost.merge(&other.cost); self.uncounted += other.uncounted; } + + /// This function returns the token counts as usage that a price table can price. + pub fn usage(&self) -> switchyard_protocol::Usage { + let mut cache = switchyard_protocol::Usage::cache_details( + Some(self.cached_input), + Some(self.cache_write), + ); + if let Some(cache) = cache.as_mut() { + cache.cache_creation_1h_input_tokens = Some(self.cache_write_1h); + } + switchyard_protocol::Usage { + input_tokens: Some(self.input), + output_tokens: Some(self.output), + cache, + ..Default::default() + } + } } /// Totals separates caller-facing calls from classifier calls for a period. diff --git a/crates/switchyard-desktop/src/summary.rs b/crates/switchyard-desktop/src/summary.rs new file mode 100644 index 0000000000..2e143bdcf5 --- /dev/null +++ b/crates/switchyard-desktop/src/summary.rs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module turns totals and recorded costs into usage summary rows. + +use switchyard_costs::Pricing; + +use crate::pricing::{Estimate, Savings}; +use crate::rollup::{Totals, Usage}; +use switchyard_desktop::config::Config; +use switchyard_desktop::health::ServerStatus; + +const MAX_MODEL_ROWS: usize = 5; + +/// This function builds the menu body for the current day and trailing week. +pub fn build(status: ServerStatus, usage: &Usage, config: &Config, rates: &Pricing) -> Vec { + let state = match status { + ServerStatus::Running => "running", + ServerStatus::Stopped => "not responding", + }; + let mut rows = vec![ + format!("Server: {state} · {}", config.server_url), + String::new(), + ]; + + if usage.week.is_empty() { + rows.push(if usage.all.is_empty() { + "No requests recorded yet".into() + } else { + "No requests recorded in the last 7 days".into() + }); + return rows; + } + + rows.extend(period("Today", &usage.today, config, rates)); + rows.push(String::new()); + rows.extend(period("This week", &usage.week, config, rates)); + + let models = model_shares(&usage.week); + if !models.is_empty() { + rows.push(String::new()); + rows.push("This week by model".into()); + rows.extend(models); + } + + let unknown = usage.week.cost().unknown_calls; + let uncounted: u64 = usage + .week + .routed + .values() + .map(|tokens| tokens.uncounted) + .sum(); + if !rates.contains(&config.baseline_model) { + rows.push(String::new()); + rows.push(match config.prices_file.as_ref() { + Some(path) => format!("Add {} to {} to see savings", config.baseline_model, path.display()), + None => format!("Bundled prices have no rates for {}. Set pricing_file to a custom table to see savings.", config.baseline_model), + }); + } else if unknown > 0 { + rows.push(String::new()); + rows.push(format!( + "Savings hidden for this week: {unknown} {} no recorded cost", + if unknown == 1 { + "call has" + } else { + "calls have" + } + )); + } else if uncounted > 0 { + rows.push(String::new()); + let answers = if uncounted == 1 { + "answer has" + } else { + "answers have" + }; + rows.push(format!( + "Savings hidden for this week: {uncounted} {answers} no token counts" + )); + } + rows +} + +/// Each period shows its complete cost or labels its known subtotal as incomplete. +fn period(name: &str, totals: &Totals, config: &Config, rates: &Pricing) -> Vec { + let requests = totals.requests(); + let mut rows = vec![format!( + "{name} — {requests} request{} · {} tokens", + if requests == 1 { "" } else { "s" }, + tokens(totals.tokens()) + )]; + rows.extend(cost_rows(&Estimate::new( + totals, + rates, + &config.baseline_model, + ))); + rows +} + +/// This function formats a period's recorded spending and, when it is known, the savings. +pub fn cost_rows(estimate: &Estimate) -> Vec { + let mut rows = vec![match estimate.actual { + Some(actual) => format!(" Spent {}", money(actual)), + None if estimate.has_known_cost => format!( + " Known spending {} · total unknown ({} {} no recorded cost)", + money(estimate.known), + estimate.unknown_calls, + if estimate.unknown_calls == 1 { + "call has" + } else { + "calls have" + }, + ), + None => " Spending unknown".into(), + }]; + if let Some(savings) = estimate.savings() { + rows.push(format!(" Saved {}", saved(&savings))); + } + rows +} + +fn saved(savings: &Savings) -> String { + let amount = money(savings.saved()); + match savings.percent() { + Some(percent) => format!("{amount} ({percent:.0}% of {})", money(savings.baseline)), + None => amount, + } +} + +/// The summary ranks up to five answer models by token share and excludes classifier calls. +fn model_shares(week: &Totals) -> Vec { + let total: u64 = week.routed.values().map(|tokens| tokens.total()).sum(); + if total == 0 { + return Vec::new(); + } + let mut models: Vec<_> = week + .routed + .iter() + .map(|(model, tokens)| (model, tokens.total())) + .collect(); + // Models appear by decreasing share; the model ID breaks ties for a stable order. + models.sort_by(|left, right| right.1.cmp(&left.1).then_with(|| left.0.cmp(right.0))); + models + .into_iter() + .take(MAX_MODEL_ROWS) + .map(|(model, count)| format!(" {model} — {:.0}%", count as f64 / total as f64 * 100.0)) + .collect() +} + +/// This function formats a token count compactly, since exact totals are not useful here. +pub(crate) fn tokens(value: u64) -> String { + match value { + 0..=9_999 => value.to_string(), + 10_000..=999_999 => format!("{:.0}K", value as f64 / 1_000.0), + 1_000_000..=999_999_999 => format!("{:.1}M", value as f64 / 1_000_000.0), + _ => format!("{:.1}B", value as f64 / 1_000_000_000.0), + } +} + +pub(crate) fn money(value: f64) -> String { + if value < 0.0 { + format!("-${:.2}", -value) + } else { + format!("${value:.2}") + } +} + +#[cfg(test)] +mod tests { + use super::build; + use crate::rollup::{ModelTokens, Totals, Usage}; + use switchyard_costs::{CostSource, Pricing, RecordedCost}; + use switchyard_desktop::config::Config; + use switchyard_desktop::health::ServerStatus; + + /// The fixture uses both cost sources so each can contribute a partial subtotal. + fn usage_with_costs(answer_usd: Option, classifier_usd: Option) -> Usage { + let mut totals = Totals::default(); + for (bucket, model, usd, source) in [ + ( + &mut totals.routed, + "answer", + answer_usd, + CostSource::Estimated, + ), + ( + &mut totals.classifier, + "classifier", + classifier_usd, + CostSource::ProviderReported, + ), + ] { + let mut tokens = ModelTokens { + requests: 1, + input: 100, + output: 10, + ..ModelTokens::default() + }; + tokens.cost.add(usd.map(|usd| RecordedCost { usd, source })); + bucket.insert(model.into(), tokens); + } + Usage { + today: totals.clone(), + week: totals, + ..Usage::default() + } + } + + /// The test checks complete, partial, missing, and zero costs in both summary periods. + #[test] + fn summary_shows_known_subtotals_and_missing_cost_warnings() { + let directory = tempfile::tempdir().expect("settings directory"); + let config = + Config::defaults_for(&directory.path().join("desktop.toml")).expect("default settings"); + let rates = Pricing::bundled().expect("bundled prices"); + for (answer_usd, classifier_usd, expected, unknown) in [ + ( + Some(1.25), + None, + " Known spending $1.25 · total unknown (1 call has no recorded cost)", + 1, + ), + ( + None, + Some(1.25), + " Known spending $1.25 · total unknown (1 call has no recorded cost)", + 1, + ), + (Some(1.25), Some(0.25), " Spent $1.50", 0), + (None, None, " Spending unknown", 2), + ( + Some(0.0), + None, + " Known spending $0.00 · total unknown (1 call has no recorded cost)", + 1, + ), + ] { + let usage = usage_with_costs(answer_usd, classifier_usd); + let rows = build(ServerStatus::Running, &usage, &config, &rates); + let spending: Vec<_> = rows + .iter() + .filter(|row| { + row.starts_with(" Spent ") + || row.starts_with(" Known spending ") + || row.starts_with(" Spending ") + }) + .map(String::as_str) + .collect(); + assert_eq!( + spending, + [expected, expected], + "each period should show the expected spending row" + ); + let warnings: Vec<_> = rows + .iter() + .filter(|row| row.starts_with("Savings hidden for this week:")) + .map(String::as_str) + .collect(); + let expected_warnings = match unknown { + 0 => vec![], + 1 => vec!["Savings hidden for this week: 1 call has no recorded cost"], + 2 => vec!["Savings hidden for this week: 2 calls have no recorded cost"], + _ => unreachable!(), + }; + assert_eq!( + warnings, expected_warnings, + "the weekly summary should report the expected missing-cost warning" + ); + } + } +} From 8c30afb8a8928948db0b833b98e2873b9e97c3ad Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 02/28] feat(desktop): add the terminal app shell and Overview server status Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 132 +++++ crates/switchyard-desktop/src/main.rs | 99 ++-- crates/switchyard-desktop/src/tui.rs | 517 ++++++++++++++++++++ crates/switchyard-desktop/src/windows.rs | 31 ++ 4 files changed, 742 insertions(+), 37 deletions(-) create mode 100644 crates/switchyard-desktop/src/controller.rs create mode 100644 crates/switchyard-desktop/src/tui.rs create mode 100644 crates/switchyard-desktop/src/windows.rs diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs new file mode 100644 index 0000000000..0b468d84f0 --- /dev/null +++ b/crates/switchyard-desktop/src/controller.rs @@ -0,0 +1,132 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Both frontends call these operations so validation stays independent of widgets. +//! Every change that an app makes to the server or to saved settings is an `Action`. The +//! terminal app matches `ActionKind` without a wildcard arm, so adding an action breaks the +//! build until the terminal app can run it. + +use crate::{config::Config, rollup}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::path::PathBuf; + +/// Snapshot holds everything that the apps show. The terminal app destructures it without +/// `..`, so adding a field breaks the build until the terminal app shows it. +#[derive(Clone, Default, Serialize)] +pub struct Snapshot { + pub data_state: DataState, + pub metrics: Metrics, + pub errors: Vec, + pub refresh_seconds: u64, +} + +#[derive(Clone, Default, Serialize)] +pub struct DataState { + pub usage: Source, + /// This field holds the `/health` result with `state`, `reason`, `checked_at`, and `url`. + pub health: Value, +} + +/// Source records whether the app could read one data file. +#[derive(Clone, Default, Serialize)] +pub struct Source { + pub state: Availability, + pub checked_at: String, + pub path: PathBuf, +} + +/// The default is unavailable, so a data file that the app has not read shows no usage. +#[derive(Clone, Copy, Default, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Availability { + Available, + #[default] + Unavailable, +} + +#[derive(Clone, Default, Serialize)] +pub struct Metrics { + pub server_url: String, + pub running: bool, + pub today: Counts, + pub week: Counts, +} + +#[derive(Clone, Default, Serialize)] +pub struct Counts { + pub requests: u64, + pub tokens: u64, +} + +pub struct Controller { + settings: PathBuf, + config: Config, + reader: rollup::Reader, + health_probe: crate::health::Probe, +} +impl Controller { + pub fn new(config: Config, settings: PathBuf) -> Self { + Self { + config, + settings, + reader: rollup::Reader::default(), + health_probe: crate::health::Probe::default(), + } + } + fn reload(&mut self) -> Result<(), String> { + self.config = Config::load(&self.settings)?; + Ok(()) + } + pub fn snapshot(&mut self) -> Result { + self.reload()?; + let mut errors = Vec::new(); + // One local date keeps aggregate totals and frontend filters aligned across midnight. + let today = chrono::Local::now().date_naive(); + let mut usage_state = Availability::Available; + let usage = self + .reader + .read(&self.config.routing_log, today) + .unwrap_or_else(|error| { + usage_state = Availability::Unavailable; + errors.push(format!("Could not read usage: {error}")); + rollup::Usage::default() + }); + let checked_at = chrono::Utc::now().to_rfc3339(); + let mut health = self.health_probe.details(&self.config.server_url); + health["checked_at"] = json!(checked_at); + health["url"] = json!(self.config.server_url); + let server_status = if health["state"] == "running" { + crate::health::ServerStatus::Running + } else { + crate::health::ServerStatus::Stopped + }; + let server_url = self.config.server_url.clone(); + let routing_log = self.config.routing_log.clone(); + let refresh_seconds = self.config.refresh_seconds; + Ok(Snapshot { + data_state: DataState { + usage: Source { + state: usage_state, + checked_at: checked_at.clone(), + path: routing_log.clone(), + }, + health, + }, + metrics: Metrics { + server_url: server_url.clone(), + running: server_status == crate::health::ServerStatus::Running, + today: Counts { + requests: usage.today.requests(), + tokens: usage.today.tokens(), + }, + week: Counts { + requests: usage.week.requests(), + tokens: usage.week.tokens(), + }, + }, + errors, + refresh_seconds: refresh_seconds.max(1), + }) + } +} diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index acc6182c7d..29d0ad563b 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -1,18 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The desktop app reports a local Switchyard server's recorded usage. +//! The terminal app manages a local Switchyard server. //! -//! The printed summary shows recorded traffic and compares list-price estimates with the +//! It shows recorded traffic and compares list-price estimates with the //! configured baseline model. #![cfg_attr(windows, windows_subsystem = "windows")] use switchyard_desktop::{config, health, rollup}; +mod controller; #[cfg(any(target_os = "macos", windows))] mod gui; mod pricing; mod summary; +mod tui; +#[cfg(windows)] +mod windows; use std::path::PathBuf; use std::process::ExitCode; @@ -20,11 +24,12 @@ use std::process::ExitCode; use config::Config; const USAGE: &str = "\ -Usage: switchyard-desktop [--print | --validate-toml] [FILE] +Usage: switchyard-desktop [--tui | --print | --validate-toml] [FILE] -This app opens the desktop window. Use --print for a usage summary. +This app shows usage and manages the server. FILE The app reads settings from FILE. + --tui Run the terminal app. --print Print the current summary and exit. --validate-toml Parse FILE as TOML and exit. @@ -43,16 +48,12 @@ Recorded spending remains visible when savings are unknown."; /// The command checks TOML syntax or opens the desktop window. fn main() -> ExitCode { - // A desktop launch may have no parent console, so attachment failure does not stop the app. #[cfg(windows)] - let _ = unsafe { - windows_sys::Win32::System::Console::AttachConsole( - windows_sys::Win32::System::Console::ATTACH_PARENT_PROCESS, - ) - }; + windows::attach_console(); let mut settings = None; - let mut validate_toml = false; let mut print_only = false; + let mut validate_toml = false; + let mut terminal = false; for arg in std::env::args().skip(1) { match arg.as_str() { "-h" | "--help" => { @@ -60,6 +61,7 @@ fn main() -> ExitCode { return ExitCode::SUCCESS; } "--print" => print_only = true, + "--tui" => terminal = true, "--validate-toml" => validate_toml = true, _ if arg.starts_with('-') => { eprintln!("switchyard-desktop: unknown option {arg}\n\n{USAGE}"); @@ -73,6 +75,11 @@ fn main() -> ExitCode { } } + if terminal && (print_only || validate_toml) { + eprintln!("{USAGE}"); + return ExitCode::FAILURE; + } + if validate_toml { let Some(path) = settings else { eprintln!("switchyard-desktop: --validate-toml requires a file path"); @@ -92,7 +99,7 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - if !print_only { + if !terminal && !print_only { #[cfg(any(target_os = "macos", windows))] return match gui::run(settings) { Ok(()) => ExitCode::SUCCESS, @@ -103,9 +110,7 @@ fn main() -> ExitCode { }; #[cfg(not(any(target_os = "macos", windows)))] { - eprintln!( - "The desktop window supports macOS and Windows. Use --print for a usage summary." - ); + eprintln!("The desktop window supports macOS and Windows. Use --print or --tui."); return ExitCode::FAILURE; } } @@ -114,31 +119,51 @@ fn main() -> ExitCode { Ok(config) => config, Err(error) => { eprintln!("switchyard-desktop: {error}"); - return ExitCode::FAILURE; - } - }; - let usage = match rollup::Reader::default() - .read(&config.routing_log, chrono::Local::now().date_naive()) - { - Ok(usage) => usage, - Err(error) => { - eprintln!( - "Usage unavailable. Could not read {}: {error}", - config.routing_log.display() - ); - return ExitCode::FAILURE; + if !terminal { + return ExitCode::FAILURE; + } + match Config::defaults_for(&settings) { + Ok(config) => config, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + } } }; - let rates = match pricing::load(config.prices_file.as_deref()) { - Ok(rates) => rates, - Err(error) => { - eprintln!("switchyard-desktop: {error}"); - return ExitCode::FAILURE; + if print_only { + let usage = match rollup::Reader::default() + .read(&config.routing_log, chrono::Local::now().date_naive()) + { + Ok(usage) => usage, + Err(error) => { + eprintln!( + "Usage unavailable. Could not read {}: {error}", + config.routing_log.display() + ); + return ExitCode::FAILURE; + } + }; + let rates = match pricing::load(config.prices_file.as_deref()) { + Ok(rates) => rates, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; + } + }; + let status = health::Probe::default().probe(&config.server_url); + for row in summary::build(status, &usage, &config, &rates) { + println!("{row}"); } - }; - let status = health::Probe::default().probe(&config.server_url); - for row in summary::build(status, &usage, &config, &rates) { - println!("{row}"); + return ExitCode::SUCCESS; + } + + let controller = controller::Controller::new(config, settings); + #[cfg(windows)] + windows::allocate_console(); + if let Err(error) = tui::run(controller) { + eprintln!("switchyard-desktop: {error}"); + return ExitCode::FAILURE; } ExitCode::SUCCESS } diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs new file mode 100644 index 0000000000..2b2fba39f2 --- /dev/null +++ b/crates/switchyard-desktop/src/tui.rs @@ -0,0 +1,517 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! The terminal app shows usage, recent calls, and server actions from controller snapshots. +//! +//! The UI thread owns every view setting. One worker thread owns the controller and runs +//! refreshes and actions, so moving around and filtering never wait for the log or a service. + +use crate::controller::{Availability, Controller, Snapshot}; +use ratatui::{ + Frame, + crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, BorderType, Padding, Paragraph, Tabs, Wrap}, +}; +use std::{ + sync::mpsc, + thread, + time::{Duration, Instant}, +}; + +/// Smaller terminals show a resize message instead of clipped panels. +const MIN_WIDTH: u16 = 60; +const MIN_HEIGHT: u16 = 16; +const TOAST_TIME: Duration = Duration::from_secs(4); +const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾']; + +/// Each key list has the key, a short label for the status bar, and a sentence for the help +/// window. Both read these lists, so the status bar and the help window always agree. +type Key = (&'static str, &'static str, &'static str); +const GLOBAL_KEYS: &[Key] = &[ + ( + "1", + "page", + "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", + ), + ( + "r", + "refresh", + "Reload usage, calls, and server health now. F5 also refreshes.", + ), + ( + "q", + "quit", + "Quit the app. The server keeps running. Ctrl-C also quits.", + ), +]; + +/// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. +#[derive(Clone, Copy)] +struct Theme { + muted: Style, + key: Style, + ok: Style, + error: Style, + selected: Style, + border: Style, +} +impl Theme { + fn new(color: bool) -> Self { + let plain = Style::new(); + if !color { + return Self { + muted: plain.add_modifier(Modifier::DIM), + key: plain.add_modifier(Modifier::BOLD), + ok: plain, + error: plain.add_modifier(Modifier::BOLD | Modifier::UNDERLINED), + selected: plain.add_modifier(Modifier::REVERSED), + border: plain, + }; + } + // Named ANSI colors follow the terminal's palette, so they work on light and dark themes. + Self { + muted: plain.fg(Color::DarkGray), + key: plain.fg(Color::Cyan).add_modifier(Modifier::BOLD), + ok: plain.fg(Color::Green), + error: plain.fg(Color::Red), + // A fixed foreground keeps dim cells readable on the selected row. + selected: plain.fg(Color::Black).bg(Color::Cyan), + border: plain.fg(Color::DarkGray), + } + } +} +/// This function follows https://no-color.org: any non-empty NO_COLOR value turns color off. +fn color_enabled() -> bool { + std::env::var_os("NO_COLOR").is_none_or(|value| value.is_empty()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Tab { + Overview, +} +const TABS: [Tab; 1] = [Tab::Overview]; +impl Tab { + fn name(self) -> &'static str { + match self { + Tab::Overview => "Overview", + } + } + fn index(self) -> usize { + self as usize + } +} + +enum Job { + Refresh, +} +enum Done { + Finished { + /// The box keeps this message small; a snapshot holds every model and route total. + snapshot: Result, String>, + }, +} +/// Request is a job for the worker plus the label that the status bar shows while it runs. +struct Request { + job: Job, + label: &'static str, +} +impl Request { + fn refresh() -> Self { + Self { + job: Job::Refresh, + label: "Refreshing", + } + } +} +struct Busy { + label: &'static str, + started: Instant, +} +struct Toast { + text: String, + error: bool, + shown: Instant, +} + +struct App { + theme: Theme, + data: Snapshot, + tab: Tab, + toast: Option, + busy: Option, + /// A quit request waits for the running job, so a restart or update is never cut off. + quit: bool, + refreshed: Option, + last_refresh: Instant, + width: u16, +} + +impl App { + fn new(theme: Theme, data: Snapshot) -> Self { + let mut app = Self { + theme, + data: Snapshot::default(), + tab: Tab::Overview, + toast: None, + busy: None, + quit: false, + refreshed: None, + last_refresh: Instant::now(), + width: 80, + }; + app.apply_snapshot(Ok(data)); + app + } + + fn apply_snapshot(&mut self, snapshot: Result) { + match snapshot { + Ok(data) => { + self.data = data; + self.refreshed = Some(Instant::now()); + } + Err(error) => self.notify(error, true), + } + } + + /// Messages appear in the status bar. + fn notify(&mut self, text: String, error: bool) { + if !text.is_empty() { + self.toast = Some(Toast { + text, + error, + shown: Instant::now(), + }); + } + } + + fn finish(&mut self, snapshot: Result, String>) { + self.busy = None; + self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); + } + + fn refresh_due(&self) -> bool { + // The placeholder snapshot after a failed start has no interval; 30 seconds is the default. + let seconds = match self.data.refresh_seconds { + 0 => 30, + seconds => seconds, + }; + self.busy.is_none() && self.last_refresh.elapsed() >= Duration::from_secs(seconds) + } + + fn start(&mut self, jobs: &mpsc::Sender, request: Request) -> Result<(), String> { + if let Some(busy) = &self.busy { + let text = format!("Wait for \"{}\" to finish.", busy.label); + self.notify(text, true); + return Ok(()); + } + self.last_refresh = Instant::now(); + jobs.send(request.job).map_err(|error| error.to_string())?; + self.busy = Some(Busy { + label: request.label, + started: Instant::now(), + }); + Ok(()) + } + + fn keys(&self) -> &'static [Key] { + &[] + } + + fn key(&mut self, key: KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.quit = true; + return None; + } + match key.code { + KeyCode::Char('q') => self.quit = true, + KeyCode::Char(digit @ '1'..='1') => { + self.tab = TABS[digit as usize - '1' as usize]; + } + KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], + KeyCode::BackTab | KeyCode::Left => { + self.tab = TABS[(self.tab.index() + TABS.len() - 1) % TABS.len()]; + } + KeyCode::Char('r') | KeyCode::F(5) => return Some(Request::refresh()), + KeyCode::Esc => self.toast = None, + _ => {} + } + None + } +} + +fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { + [ + Span::styled("● ", if running { theme.ok } else { theme.error }), + Span::raw(if running { "running" } else { "not responding" }), + ] +} + +fn block(theme: Theme, title: &str) -> Block<'static> { + Block::bordered() + .border_type(BorderType::Rounded) + .border_style(theme.border) + .title(Span::styled(format!(" {title} "), Modifier::BOLD)) +} + +fn draw(frame: &mut Frame, app: &mut App) { + // This destructuring has no `..`, so adding a snapshot field breaks the build until a page + // shows it. Each comment names the place that shows the field. + let Snapshot { + data_state: _, + metrics: _, + errors: _, + refresh_seconds: _, + } = &app.data; + let area = frame.area(); + app.width = area.width; + if area.width < MIN_WIDTH || area.height < MIN_HEIGHT { + draw_too_small(frame, app.theme, area); + return; + } + let [header, body, status] = Layout::vertical([ + Constraint::Length(1), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .areas(area); + draw_header(frame, app, header); + match app.tab { + Tab::Overview => draw_overview(frame, app, body), + } + draw_status(frame, app, status); +} + +fn draw_too_small(frame: &mut Frame, theme: Theme, area: Rect) { + let size = |value: u16, needed: u16| { + let style = if value < needed { + theme.error + } else { + theme.ok + }; + Span::styled(value.to_string(), style) + }; + let text = vec![ + Line::from("Terminal too small"), + Line::from(vec![ + size(area.width, MIN_WIDTH), + Span::raw(" × "), + size(area.height, MIN_HEIGHT), + ]), + Line::styled(format!("Needs {MIN_WIDTH} × {MIN_HEIGHT}"), theme.muted), + ]; + let height = text.len() as u16; + frame.render_widget( + Paragraph::new(text).centered(), + area.centered_vertically(Constraint::Length(height)), + ); +} + +fn draw_header(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let titles = TABS.map(|tab| { + Line::from(vec![ + Span::styled(format!("{} ", tab.index() + 1), theme.key), + Span::raw(tab.name()), + ]) + }); + // Tabs pads each title by one space on both sides and puts a divider between titles. + let tabs_width: usize = titles.iter().map(|title| title.width() + 3).sum(); + let running = app.data.metrics.running; + let mut status = server_state(theme, running).to_vec(); + let updated = match app.refreshed { + Some(time) => format!(" · updated {}s ago ", time.elapsed().as_secs()), + None => " · no data yet ".into(), + }; + let url = format!(" · {}", app.data.metrics.server_url); + // Narrow terminals drop the URL first, then the update time, so the tabs stay readable. + let room = usize::from(area.width).saturating_sub(tabs_width); + let base = status.iter().map(Span::width).sum::(); + if base > room { + status[1] = Span::raw(if running { "up" } else { "down" }); + } + if !app.data.metrics.server_url.is_empty() + && base + url.chars().count() + updated.chars().count() <= room + { + status.push(Span::styled(url, theme.muted)); + } + if base + updated.chars().count() <= room { + status.push(Span::styled(updated, theme.muted)); + } + let status = Line::from(status).right_aligned(); + let [tabs, status_area] = Layout::horizontal([ + Constraint::Fill(1), + Constraint::Length(status.width() as u16), + ]) + .areas(area); + frame.render_widget( + Tabs::new(titles) + .select(app.tab.index()) + .highlight_style(theme.selected) + .divider(Span::styled("│", theme.border)), + tabs, + ); + frame.render_widget(Paragraph::new(status), status_area); +} + +fn draw_overview(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let data = &app.data; + let mut server = vec![ + Line::from(server_state(theme, data.metrics.running).to_vec()), + Line::styled(data.metrics.server_url.as_str(), theme.muted), + ]; + if let Some(reason) = data.data_state.health["reason"].as_str() { + server.push(Line::styled(reason.to_string(), theme.muted)); + } + for error in &data.errors { + server.push(Line::styled(error.as_str(), theme.error)); + } + if data.data_state.usage.state == Availability::Available { + server.push(Line::from(format!( + "Today: {} calls · {} tokens", + data.metrics.today.requests, data.metrics.today.tokens + ))); + server.push(Line::from(format!( + "Past 7 days: {} calls · {} tokens", + data.metrics.week.requests, data.metrics.week.tokens + ))); + } + frame.render_widget( + Paragraph::new(server) + .wrap(Wrap { trim: false }) + .block(block(theme, "Server").padding(Padding::horizontal(1))), + area, + ); +} + +fn draw_status(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let left = if let Some(busy) = &app.busy { + let frame_index = busy.started.elapsed().as_millis() / 100 % SPINNER.len() as u128; + let mut spans = vec![ + Span::styled(format!(" {} ", SPINNER[frame_index as usize]), theme.key), + Span::raw(format!("{}…", busy.label)), + ]; + if app.quit { + spans.push(Span::styled(" quitting when done", theme.muted)); + } + Line::from(spans) + } else if let Some(toast) = &app.toast { + let style = if toast.error { theme.error } else { theme.ok }; + Line::from(vec![ + Span::styled(if toast.error { " ✗ " } else { " ✓ " }, style), + Span::styled(toast.text.clone(), style), + ]) + } else { + Line::default() + }; + let room = usize::from(area.width).saturating_sub(left.width() + 2); + // Page keys do nothing while a window is open or the filter takes the keyboard. + let global = true; + let hints = fit_keys(theme, app.keys(), global, room); + let [left_area, right_area] = + Layout::horizontal([Constraint::Length(left.width() as u16), Constraint::Fill(1)]) + .areas(area); + frame.render_widget(Paragraph::new(left), left_area); + frame.render_widget(Paragraph::new(hints.right_aligned()), right_area); +} + +/// This function lists the page's keys, then the global keys, and stops before a hint would be +/// cut. +fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'static> { + let hint = |key: &'static str, label: &str| { + [ + Span::styled(key, theme.key), + Span::styled(format!(" {label} "), theme.muted), + ] + }; + let mut spans = Vec::new(); + let mut used = 0; + let globals = if global { GLOBAL_KEYS } else { &[] }; + for (key, label, _) in keys.iter().chain(globals) { + let width = key.chars().count() + label.chars().count() + 3; + if used + width > room { + break; + } + used += width; + spans.extend(hint(key, label)); + } + Line::from(spans) +} + +struct PasteMode; +impl Drop for PasteMode { + fn drop(&mut self) { + let _ = ratatui::crossterm::execute!(std::io::stdout(), event::DisableBracketedPaste); + } +} + +pub fn run(mut controller: Controller) -> Result<(), String> { + let snapshot = controller.snapshot().unwrap_or_else(|error| Snapshot { + errors: vec![error], + ..Snapshot::default() + }); + let mut app = App::new(Theme::new(color_enabled()), snapshot); + ratatui::crossterm::execute!(std::io::stdout(), event::EnableBracketedPaste) + .map_err(|e| e.to_string())?; + let _paste_mode = PasteMode; + let (jobs, inbox) = mpsc::channel::(); + let (done, results) = mpsc::channel::(); + // One worker owns the controller while the terminal continues to draw and read input. + let worker = thread::spawn(move || { + while let Ok(Job::Refresh) = inbox.recv() { + let finished = Done::Finished { + snapshot: controller.snapshot().map(Box::new), + }; + if done.send(finished).is_err() { + break; + } + } + }); + let result = ratatui::run(|terminal| -> Result<(), String> { + loop { + while let Ok(Done::Finished { snapshot }) = results.try_recv() { + app.finish(snapshot); + } + if app.quit && app.busy.is_none() { + return Ok(()); + } + if app + .toast + .as_ref() + .is_some_and(|toast| !toast.error && toast.shown.elapsed() > TOAST_TIME) + { + app.toast = None; + } + terminal + .draw(|frame| draw(frame, &mut app)) + .map_err(|e| e.to_string())?; + // The spinner needs a faster redraw only while a job runs. + let wait = Duration::from_millis(if app.busy.is_some() { 100 } else { 250 }); + if event::poll(wait).map_err(|e| e.to_string())? { + match event::read().map_err(|e| e.to_string())? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if let Some(request) = app.key(key) { + app.start(&jobs, request)?; + } + } + _ => {} + } + } + if !app.quit && app.refresh_due() { + app.start(&jobs, Request::refresh())?; + } + } + }); + drop(jobs); + worker + .join() + .map_err(|_| "The terminal operation worker stopped unexpectedly.".to_string())?; + if result.is_err() { + let _ = ratatui::try_restore(); + } + result +} diff --git a/crates/switchyard-desktop/src/windows.rs b/crates/switchyard-desktop/src/windows.rs new file mode 100644 index 0000000000..8e0d2d0003 --- /dev/null +++ b/crates/switchyard-desktop/src/windows.rs @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub fn attach_console() { + unsafe { + windows_console(false); + } +} +pub fn allocate_console() { + unsafe { + windows_console(true); + } +} +unsafe fn windows_console(allocate: bool) { + // The GUI has no console; terminal mode and harness jobs allocate one only when needed. + unsafe extern "system" { + fn AllocConsole() -> i32; + fn FreeConsole() -> i32; + fn AttachConsole(pid: u32) -> i32; + } + unsafe { + if allocate { + // Detach from a parent console first; AllocConsole fails while one is attached, + // and a shared console lets the parent shell read the terminal app's input. + FreeConsole(); + AllocConsole(); + } else { + AttachConsole(u32::MAX); + } + } +} From ac7f5b037dcc9b875f9d566f9da707f0bc839aa6 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 03/28] feat(desktop): add Overview usage cards, the hourly chart, and the help window Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 35 +- crates/switchyard-desktop/src/history.rs | 126 +++++- crates/switchyard-desktop/src/main.rs | 2 +- crates/switchyard-desktop/src/tui.rs | 410 ++++++++++++++++++-- 4 files changed, 546 insertions(+), 27 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 0b468d84f0..0d3c738218 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -6,10 +6,10 @@ //! terminal app matches `ActionKind` without a wildcard arm, so adding an action breaks the //! build until the terminal app can run it. -use crate::{config::Config, rollup}; +use crate::{config::Config, history, pricing, rollup}; use serde::Serialize; use serde_json::{Value, json}; -use std::path::PathBuf; +use std::{collections::BTreeMap, path::PathBuf}; /// Snapshot holds everything that the apps show. The terminal app destructures it without /// `..`, so adding a field breaks the build until the terminal app shows it. @@ -17,6 +17,11 @@ use std::path::PathBuf; pub struct Snapshot { pub data_state: DataState, pub metrics: Metrics, + pub today: chrono::NaiveDate, + /// The keys are `today`, `week`, and `all`. A value is null when usage could not be read. + pub estimates: BTreeMap<&'static str, Option>, + pub activity: [u64; 24], + pub limited: bool, pub errors: Vec, pub refresh_seconds: u64, } @@ -92,6 +97,23 @@ impl Controller { errors.push(format!("Could not read usage: {error}")); rollup::Usage::default() }); + // Invalid overrides leave baseline rates unknown and add an error to the snapshot. + let rates = pricing::load(self.config.prices_file.as_deref()).unwrap_or_else(|error| { + errors.push(format!("Could not read model prices: {error}")); + Default::default() + }); + let estimates = [ + ("today", &usage.today), + ("week", &usage.week), + ("all", &usage.all), + ] + .into_iter() + .map(|(period, totals)| { + let estimate = (usage_state == Availability::Available) + .then(|| pricing::Estimate::new(totals, &rates, &self.config.baseline_model)); + (period, estimate) + }) + .collect(); let checked_at = chrono::Utc::now().to_rfc3339(); let mut health = self.health_probe.details(&self.config.server_url); health["checked_at"] = json!(checked_at); @@ -101,6 +123,11 @@ impl Controller { } else { crate::health::ServerStatus::Stopped }; + let history = history::load(&self.config.routing_log).unwrap_or_else(|e| { + errors.push(e); + history::History::default() + }); + let activity = history::hourly_calls(&history.entries, chrono::Utc::now()); let server_url = self.config.server_url.clone(); let routing_log = self.config.routing_log.clone(); let refresh_seconds = self.config.refresh_seconds; @@ -125,6 +152,10 @@ impl Controller { tokens: usage.week.tokens(), }, }, + today, + estimates, + activity, + limited: history.limited, errors, refresh_seconds: refresh_seconds.max(1), }) diff --git a/crates/switchyard-desktop/src/history.rs b/crates/switchyard-desktop/src/history.rs index feebe62950..6db8722b1b 100644 --- a/crates/switchyard-desktop/src/history.rs +++ b/crates/switchyard-desktop/src/history.rs @@ -1,11 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! This module describes one completed model call in the routing log. +//! This module reads recent completed model calls and filters them by recorded IDs. use serde::Deserialize; +use std::collections::VecDeque; +use std::io::{Read, Seek, SeekFrom}; +use std::path::Path; -#[derive(Debug, Deserialize, serde::Serialize)] +const MAX_BYTES: u64 = 8 * 1024 * 1024; +const MAX_RECORDS: usize = 5000; + +#[derive(Clone, Debug, Deserialize, serde::Serialize)] pub struct Entry { pub ts: String, pub model: String, @@ -31,3 +37,119 @@ pub struct Entry { #[serde(default)] pub cost: Option, } + +#[derive(Default)] +pub struct History { + pub entries: Vec, + pub limited: bool, +} + +// `hourly_calls` uses bounded recent history, so its buckets may omit calls from a busy day. +pub fn hourly_calls(entries: &[Entry], now: chrono::DateTime) -> [u64; 24] { + let mut buckets = [0; 24]; + for entry in entries { + if let Ok(time) = chrono::DateTime::parse_from_rfc3339(&entry.ts) { + let age = now.signed_duration_since(time); + if (chrono::Duration::zero()..chrono::Duration::hours(24)).contains(&age) { + buckets[23 - age.num_seconds() as usize / 3600] += 1; + } + } + } + buckets +} + +pub fn load(path: &Path) -> Result { + let mut file = match std::fs::File::open(path) { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(History::default()), + Err(e) => return Err(e.to_string()), + }; + let size = file.metadata().map_err(|e| e.to_string())?.len(); + let offset = size.saturating_sub(MAX_BYTES); + // The preceding byte distinguishes a partial record from a complete first + // record when the byte limit starts immediately after a newline. + let mut previous = *b"\n"; + if offset > 0 { + file.seek(SeekFrom::Start(offset - 1)) + .map_err(|e| e.to_string())?; + file.read_exact(&mut previous).map_err(|e| e.to_string())?; + } + file.seek(SeekFrom::Start(offset)) + .map_err(|e| e.to_string())?; + let mut bytes = Vec::new(); + file.take(MAX_BYTES) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + let mut history = parse(&bytes, offset > 0 && previous[0] != b'\n'); + history.limited |= offset > 0; + Ok(history) +} + +fn parse(bytes: &[u8], truncated: bool) -> History { + let mut result = History { + limited: truncated, + ..History::default() + }; + let mut lines = bytes.split_inclusive(|b| *b == b'\n'); + if truncated { + lines.next(); + } + let mut entries = VecDeque::new(); + for line in lines { + if !line.ends_with(b"\n") { + break; + } + match serde_json::from_slice::(line) { + Ok(entry) + if !entry.model.trim().is_empty() + && chrono::DateTime::parse_from_rfc3339(&entry.ts).is_ok() => + { + if entries.len() == MAX_RECORDS { + entries.pop_front(); + result.limited = true; + } + entries.push_back(entry); + } + _ => {} + } + } + result.entries = entries.into(); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn retains_recent_records_and_complete_byte_limited_records() { + let directory = tempfile::tempdir().expect("directory"); + let path = directory.path().join("log"); + let line = b"{\"ts\":\"2026-10-08T12:00:00Z\",\"model\":\"recent\"}\n"; + let mut contents = b"{\"ts\":\"2026-10-08T11:00:00Z\",\"model\":\"old\"}\n".to_vec(); + contents.extend(line.repeat(MAX_RECORDS)); + std::fs::write(&path, contents).expect("record-limited log"); + let history = load(&path).expect("record-limited history"); + assert_eq!(history.entries.len(), MAX_RECORDS); + assert!(history.entries.iter().all(|entry| entry.model == "recent")); + assert!(history.limited); + + for prefix in [ + b"discarded prefix\n".as_slice(), + b"partial prefix".as_slice(), + ] { + let mut contents = prefix.to_vec(); + contents.extend_from_slice(line); + contents.resize(prefix.len() + MAX_BYTES as usize, b' '); + std::fs::write(&path, contents).expect("byte-limited log"); + let history = load(&path).expect("byte-limited history"); + assert!(history.limited); + if prefix.ends_with(b"\n") { + assert_eq!(history.entries.len(), 1); + assert_eq!(history.entries[0].model, "recent"); + } else { + assert!(history.entries.is_empty()); + } + } + } +} diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 29d0ad563b..d1e61fbb57 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -8,7 +8,7 @@ #![cfg_attr(windows, windows_subsystem = "windows")] -use switchyard_desktop::{config, health, rollup}; +use switchyard_desktop::{config, health, history, rollup}; mod controller; #[cfg(any(target_os = "macos", windows))] mod gui; diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 2b2fba39f2..1600113ca6 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -7,13 +7,15 @@ //! refreshes and actions, so moving around and filtering never wait for the log or a service. use crate::controller::{Availability, Controller, Snapshot}; +use crate::pricing::Estimate; +use crate::summary::{money, tokens}; use ratatui::{ Frame, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, layout::{Constraint, Layout, Rect}, - style::{Color, Modifier, Style}, + style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, - widgets::{Block, BorderType, Padding, Paragraph, Tabs, Wrap}, + widgets::{Bar, BarChart, Block, BorderType, Clear, Padding, Paragraph, Tabs, Wrap}, }; use std::{ sync::mpsc, @@ -41,12 +43,14 @@ const GLOBAL_KEYS: &[Key] = &[ "refresh", "Reload usage, calls, and server health now. F5 also refreshes.", ), + ("?", "help", "Show or hide this help."), ( "q", "quit", "Quit the app. The server keeps running. Ctrl-C also quits.", ), ]; +const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -54,9 +58,11 @@ struct Theme { muted: Style, key: Style, ok: Style, + warn: Style, error: Style, selected: Style, border: Style, + bar: Style, } impl Theme { fn new(color: bool) -> Self { @@ -66,9 +72,11 @@ impl Theme { muted: plain.add_modifier(Modifier::DIM), key: plain.add_modifier(Modifier::BOLD), ok: plain, + warn: plain.add_modifier(Modifier::BOLD), error: plain.add_modifier(Modifier::BOLD | Modifier::UNDERLINED), selected: plain.add_modifier(Modifier::REVERSED), border: plain, + bar: plain, }; } // Named ANSI colors follow the terminal's palette, so they work on light and dark themes. @@ -76,10 +84,12 @@ impl Theme { muted: plain.fg(Color::DarkGray), key: plain.fg(Color::Cyan).add_modifier(Modifier::BOLD), ok: plain.fg(Color::Green), + warn: plain.fg(Color::Yellow), error: plain.fg(Color::Red), // A fixed foreground keeps dim cells readable on the selected row. selected: plain.fg(Color::Black).bg(Color::Cyan), border: plain.fg(Color::DarkGray), + bar: plain.fg(Color::Cyan), } } } @@ -104,6 +114,47 @@ impl Tab { } } +#[derive(Clone, Copy, PartialEq, Eq)] +enum Period { + Today, + Week, +} +impl Period { + fn name(self) -> &'static str { + match self { + Period::Today => "Today", + Period::Week => "Past 7 days", + } + } + /// The estimate is `None` when the usage log could not be read. + fn estimate(self, snapshot: &Snapshot) -> Option { + let key = match self { + Period::Today => "today", + Period::Week => "week", + }; + snapshot.estimates.get(key).copied().flatten() + } +} + +enum Popup { + Help { + scroll: u16, + }, + Message { + title: &'static str, + text: String, + error: bool, + scroll: u16, + }, +} +impl Popup { + fn scroll_mut(&mut self) -> &mut u16 { + match self { + Popup::Help { scroll } | Popup::Message { scroll, .. } => scroll, + } + } +} + enum Job { Refresh, } @@ -140,6 +191,7 @@ struct App { theme: Theme, data: Snapshot, tab: Tab, + popup: Option, toast: Option, busy: Option, /// A quit request waits for the running job, so a restart or update is never cut off. @@ -155,6 +207,7 @@ impl App { theme, data: Snapshot::default(), tab: Tab::Overview, + popup: None, toast: None, busy: None, quit: false, @@ -176,9 +229,19 @@ impl App { } } - /// Messages appear in the status bar. + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, error: bool) { - if !text.is_empty() { + if text.is_empty() { + return; + } + if text.contains('\n') || text.chars().count() + 8 > usize::from(self.width) * 2 / 3 { + self.popup = Some(Popup::Message { + title: if error { "Error" } else { "Done" }, + text, + error, + scroll: 0, + }); + } else { self.toast = Some(Toast { text, error, @@ -217,7 +280,10 @@ impl App { } fn keys(&self) -> &'static [Key] { - &[] + match (&self.popup, self.tab) { + (Some(_), _) => POPUP_KEYS, + (None, Tab::Overview) => &[], + } } fn key(&mut self, key: KeyEvent) -> Option { @@ -225,8 +291,12 @@ impl App { self.quit = true; return None; } + if self.popup.is_some() { + return self.popup_key(key); + } match key.code { KeyCode::Char('q') => self.quit = true, + KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), KeyCode::Char(digit @ '1'..='1') => { self.tab = TABS[digit as usize - '1' as usize]; } @@ -240,6 +310,79 @@ impl App { } None } + + fn popup_key(&mut self, key: KeyEvent) -> Option { + let popup = self.popup.as_mut()?; + match key.code { + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | '?') => self.popup = None, + // Drawing clamps the scroll to the text, so the last line here has no fixed value. + code => { + let scroll = popup.scroll_mut(); + if let Some(next) = step(code, usize::from(*scroll), usize::from(u16::MAX)) { + *scroll = next as u16; + } + } + } + None + } +} + +/// This function moves a list position for the arrow, vim, page, and jump keys. +fn step(code: KeyCode, current: usize, last: usize) -> Option { + Some(match code { + KeyCode::Down | KeyCode::Char('j') => current.saturating_add(1).min(last), + KeyCode::Up | KeyCode::Char('k') => current.saturating_sub(1), + KeyCode::PageDown => current.saturating_add(10).min(last), + KeyCode::PageUp => current.saturating_sub(10), + KeyCode::Home | KeyCode::Char('g') => 0, + KeyCode::End | KeyCode::Char('G') => last, + _ => return None, + }) +} + +fn count(value: u64) -> String { + let digits = value.to_string(); + let mut text = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + text.push(','); + } + text.push(digit); + } + text +} + +/// This function shows a total only when every call has a cost. Otherwise it shows the known +/// part as a lower bound, or "unknown", so a missing cost never reads as $0. +fn cost(theme: Theme, total: Option, known: f64) -> Span<'static> { + match total { + Some(total) => Span::raw(money(total)), + None if known > 0.0 => Span::styled(format!("≥ {}", money(known)), theme.warn), + None => Span::styled("unknown", theme.warn), + } +} + +/// This function shows the savings and their share of the baseline, or "hidden" when +/// `Estimate::savings` has no figure. With `baseline`, it also shows the baseline cost. +fn saved(theme: Theme, estimate: &Estimate, baseline: bool) -> Vec> { + let Some(savings) = estimate.savings() else { + return vec![Span::styled("hidden", theme.warn)]; + }; + let style = if savings.saved() < 0.0 { + theme.error + } else { + theme.ok + }; + let mut spans = vec![Span::styled(money(savings.saved()), style.bold())]; + if let Some(percent) = savings.percent() { + let text = if baseline { + format!(" · {percent:.0}% of {}", money(savings.baseline)) + } else { + format!(" · {percent:.0}%") + }; + spans.push(Span::styled(text, theme.muted)); + } + spans } fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { @@ -262,6 +405,10 @@ fn draw(frame: &mut Frame, app: &mut App) { let Snapshot { data_state: _, metrics: _, + today: _, + estimates: _, + activity: _, + limited: _, errors: _, refresh_seconds: _, } = &app.data; @@ -282,6 +429,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Overview => draw_overview(frame, app, body), } draw_status(frame, app, status); + draw_popup(frame, app, body); } fn draw_too_small(frame: &mut Frame, theme: Theme, area: Rect) { @@ -356,9 +504,42 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(status), status_area); } +fn draw_errors(frame: &mut Frame, app: &App, area: Rect) { + let lines: Vec = app + .data + .errors + .iter() + .map(|error| Line::styled(error.as_str(), app.theme.error)) + .collect(); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(block(app.theme, "Data errors").border_style(app.theme.error)), + area, + ); +} + fn draw_overview(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let data = &app.data; + let errors = if data.errors.is_empty() { + 0 + } else { + (data.errors.len() as u16 + 2).min(area.height / 2) + }; + let [errors_area, rest] = + Layout::vertical([Constraint::Length(errors), Constraint::Fill(1)]).areas(area); + if errors > 0 { + draw_errors(frame, app, errors_area); + } + let usage = data.data_state.usage.state == Availability::Available; + let chart = if usage && rest.height >= 24 { 9 } else { 0 }; + let [cards, chart_area, _] = Layout::vertical([ + Constraint::Length(5), + Constraint::Length(chart), + Constraint::Fill(1), + ]) + .areas(rest); let mut server = vec![ Line::from(server_state(theme, data.metrics.running).to_vec()), Line::styled(data.metrics.server_url.as_str(), theme.muted), @@ -366,27 +547,98 @@ fn draw_overview(frame: &mut Frame, app: &App, area: Rect) { if let Some(reason) = data.data_state.health["reason"].as_str() { server.push(Line::styled(reason.to_string(), theme.muted)); } - for error in &data.errors { - server.push(Line::styled(error.as_str(), theme.error)); + // Without usage data, the page shows only the connection so nothing reads as zero usage. + if !usage { + let [card, _] = Layout::horizontal([Constraint::Fill(1), Constraint::Fill(2)]).areas(cards); + draw_card(frame, theme, "Server", server, card); + return; + } + // The header already shows the server state, so narrow terminals give its card to usage. + let periods = if cards.width >= 100 { + let [server_card, rest] = + Layout::horizontal([Constraint::Fill(1), Constraint::Fill(2)]).areas(cards); + draw_card(frame, theme, "Server", server, server_card); + rest + } else { + cards + }; + let [today, week] = Layout::horizontal([Constraint::Fill(1); 2]).areas(periods); + let today_title = format!("Today · {}", data.today.format("%b %-d")); + for (period, title, card) in [ + (Period::Today, today_title.as_str(), today), + (Period::Week, Period::Week.name(), week), + ] { + let lines = period_lines(app, period, card.width >= 36); + draw_card(frame, theme, title, lines, card); } - if data.data_state.usage.state == Availability::Available { - server.push(Line::from(format!( - "Today: {} calls · {} tokens", - data.metrics.today.requests, data.metrics.today.tokens - ))); - server.push(Line::from(format!( - "Past 7 days: {} calls · {} tokens", - data.metrics.week.requests, data.metrics.week.tokens - ))); + if chart > 0 { + draw_activity(frame, app, chart_area); } +} + +fn draw_card(frame: &mut Frame, theme: Theme, title: &str, lines: Vec, area: Rect) { frame.render_widget( - Paragraph::new(server) - .wrap(Wrap { trim: false }) - .block(block(theme, "Server").padding(Padding::horizontal(1))), + Paragraph::new(lines).block(block(theme, title).padding(Padding::horizontal(1))), area, ); } +/// This function draws one period's calls, spending, and savings for an Overview card. +fn period_lines(app: &App, period: Period, baseline: bool) -> Vec> { + let theme = app.theme; + let counts = match period { + Period::Today => &app.data.metrics.today, + _ => &app.data.metrics.week, + }; + let mut lines = vec![Line::from(vec![ + Span::styled(count(counts.requests), Modifier::BOLD), + Span::styled(" calls · ", theme.muted), + Span::styled(tokens(counts.tokens), Modifier::BOLD), + Span::styled(" tokens", theme.muted), + ])]; + if let Some(estimate) = period.estimate(&app.data) { + lines.push(Line::from(vec![ + Span::styled("Spent ", theme.muted), + cost(theme, estimate.actual, estimate.known).bold(), + ])); + let mut savings = vec![Span::styled("Saved ", theme.muted)]; + savings.extend(saved(theme, &estimate, baseline)); + lines.push(Line::from(savings)); + } + lines +} + +fn draw_activity(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let data = &app.data.activity; + let peak = data.iter().copied().max().unwrap_or_default(); + let mut outer = block(theme, "Calls per hour · past 24h") + .title_top(Line::styled(format!(" peak {} ", count(peak)), theme.muted).right_aligned()) + .title_bottom(Line::styled(" 24h ago ", theme.muted)) + .title_bottom(Line::styled(" now ", theme.muted).right_aligned()); + if app.data.limited { + outer = outer + .title_bottom(Line::styled(" from the latest 5,000 calls ", theme.muted).centered()); + } + let inner = outer.inner(area); + frame.render_widget(outer, area); + let bars = data.len() as u16; + let gap = u16::from(inner.width >= bars * 3); + let width = ((inner.width + gap) / bars).saturating_sub(gap).max(1); + let chart = inner.centered_horizontally(Constraint::Length(bars * (width + gap) - gap)); + let bars: Vec = data + .iter() + .map(|&value| Bar::new(value).text_value(String::new())) + .collect(); + frame.render_widget( + BarChart::new(bars) + .bar_width(width) + .bar_gap(gap) + .bar_style(theme.bar), + chart, + ); +} + fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if let Some(busy) = &app.busy { @@ -410,7 +662,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { }; let room = usize::from(area.width).saturating_sub(left.width() + 2); // Page keys do nothing while a window is open or the filter takes the keyboard. - let global = true; + let global = app.popup.is_none(); let hints = fit_keys(theme, app.keys(), global, room); let [left_area, right_area] = Layout::horizontal([Constraint::Length(left.width() as u16), Constraint::Fill(1)]) @@ -420,7 +672,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { } /// This function lists the page's keys, then the global keys, and stops before a hint would be -/// cut. +/// cut. It keeps room for the help key, so the status bar always says how to find every key. fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'static> { let hint = |key: &'static str, label: &str| { [ @@ -428,10 +680,14 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat Span::styled(format!(" {label} "), theme.muted), ] }; + const HELP_WIDTH: usize = "? help ".len(); let mut spans = Vec::new(); - let mut used = 0; + let mut used = if global { HELP_WIDTH } else { 0 }; let globals = if global { GLOBAL_KEYS } else { &[] }; for (key, label, _) in keys.iter().chain(globals) { + if *key == "?" { + continue; + } let width = key.chars().count() + label.chars().count() + 3; if used + width > room { break; @@ -439,9 +695,119 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat used += width; spans.extend(hint(key, label)); } + if global && room >= HELP_WIDTH { + spans.extend(hint("?", "help")); + } Line::from(spans) } +fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { + let theme = app.theme; + let Some(popup) = &app.popup else { + return; + }; + let footer = |pairs: &[(&'static str, &'static str)]| { + let mut spans = Vec::new(); + for (key, label) in pairs { + spans.push(Span::styled(format!(" {key} "), theme.key)); + spans.push(Span::raw(*label)); + } + spans.push(Span::raw(" ")); + Line::from(spans) + }; + let close = footer(&[("esc", "close")]); + // A window that asks for a decision keeps its keys on the border and its text fields below + // the scrolling text, so both stay visible when the text is long. + let (title, lines, pinned, border, preferred, footer) = match popup { + Popup::Help { .. } => { + let width = 76; + let text_width = width.min(area.width.saturating_sub(2)).saturating_sub(4); + let lines = help_lines(theme, usize::from(text_width)); + ("Help", lines, Vec::new(), theme.key, width, close) + } + Popup::Message { + title, text, error, .. + } => ( + *title, + text.lines() + .map(|line| Line::from(line.to_string())) + .collect(), + Vec::new(), + if *error { theme.error } else { theme.key }, + 76, + close, + ), + }; + let width = preferred.min(area.width.saturating_sub(2)); + // The text area is inside the two border columns and the two padding columns. + let text_width = width.saturating_sub(4); + let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false }); + let pinned = Paragraph::new(pinned).wrap(Wrap { trim: false }); + let rows = paragraph.line_count(text_width) as u16; + let pinned_rows = pinned.line_count(text_width) as u16; + let rect = area.centered( + Constraint::Length(width), + Constraint::Length((rows + pinned_rows + 2).min(area.height.saturating_sub(2))), + ); + let outer = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(border) + .title(Span::styled(format!(" {title} "), border.bold())) + .title_bottom(footer.right_aligned()) + .padding(Padding::horizontal(1)); + let [text_area, pinned_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(pinned_rows)]) + .areas(outer.inner(rect)); + // Scrolling stops at the last line, so scrolling back up responds at once. + let last = rows.saturating_sub(text_area.height); + let scroll = match app.popup.as_mut().map(Popup::scroll_mut) { + Some(scroll) => { + *scroll = (*scroll).min(last); + *scroll + } + None => 0, + }; + frame.render_widget(Clear, rect); + frame.render_widget(outer, rect); + frame.render_widget(paragraph.scroll((scroll, 0)), text_area); + frame.render_widget(pinned, pinned_area); +} + +/// The help window indents wrapped descriptions under their first line, so keys stay in one column. +fn help_lines(theme: Theme, width: usize) -> Vec> { + const KEY_WIDTH: usize = 8; + let room = width.saturating_sub(KEY_WIDTH).max(20); + let mut lines = Vec::new(); + for (section, keys) in [("Every page", GLOBAL_KEYS)] { + if !lines.is_empty() { + lines.push(Line::default()); + } + lines.push(Line::styled(format!(" {section} "), theme.selected.bold())); + for (key, _, about) in keys { + let mut rows = Vec::new(); + let mut row = String::new(); + for word in about.split_whitespace() { + if !row.is_empty() && row.chars().count() + 1 + word.chars().count() > room { + rows.push(std::mem::take(&mut row)); + } + if !row.is_empty() { + row.push(' '); + } + row.push_str(word); + } + rows.push(row); + for (index, row) in rows.into_iter().enumerate() { + let key = if index == 0 { *key } else { "" }; + lines.push(Line::from(vec![ + Span::styled(format!("{key: Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 04/28] feat(desktop): add model share tables and the Models page to the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/Cargo.toml | 3 +- crates/switchyard-desktop/src/controller.rs | 80 +++- crates/switchyard-desktop/src/history.rs | 16 +- crates/switchyard-desktop/src/tui.rs | 453 +++++++++++++++++++- 4 files changed, 532 insertions(+), 20 deletions(-) diff --git a/crates/switchyard-desktop/Cargo.toml b/crates/switchyard-desktop/Cargo.toml index b59b0bc3ae..42fb4c95c9 100644 --- a/crates/switchyard-desktop/Cargo.toml +++ b/crates/switchyard-desktop/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "switchyard-desktop" version.workspace = true -description = "Installer and desktop app for a local Switchyard server" +description = "Installer, desktop app, terminal app, and usage summary for a local Switchyard server" authors.workspace = true edition.workspace = true homepage = "https://github.com/NVIDIA-NeMo/Switchyard" @@ -26,6 +26,7 @@ toml = "0.9" toml_edit = "0.25" url = "2" uuid.workspace = true +# Popups use Paragraph::line_count to size and scroll wrapped text. ratatui = { version = "0.30.2", features = ["unstable-rendered-line-info"] } shlex = "2" strum = { version = "0.28", features = ["derive"] } diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 0d3c738218..fae3f71d3b 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -15,20 +15,46 @@ use std::{collections::BTreeMap, path::PathBuf}; /// `..`, so adding a field breaks the build until the terminal app shows it. #[derive(Clone, Default, Serialize)] pub struct Snapshot { + pub update_status: Value, + /// This field is `None` only in the terminal app's placeholder snapshot, which it shows + /// when the app settings cannot be loaded. + pub settings: Option, pub data_state: DataState, + pub summary: Vec, pub metrics: Metrics, pub today: chrono::NaiveDate, + pub analytics: rollup::Usage, /// The keys are `today`, `week`, and `all`. A value is null when usage could not be read. pub estimates: BTreeMap<&'static str, Option>, + pub baseline_model: String, pub activity: [u64; 24], + pub sessions: Vec, + pub entries: Vec, pub limited: bool, + pub skipped: usize, pub errors: Vec, pub refresh_seconds: u64, } +/// AppSettings holds every app setting and the file that stores them. `Controller::snapshot` +/// destructures `Config` without `..`, so adding a setting to `Config` breaks the build until +/// it is added here too. +#[derive(Clone, Default, Serialize)] +pub struct AppSettings { + pub path: PathBuf, + pub server_url: String, + pub routing_log: PathBuf, + pub config_file: PathBuf, + pub launchd_label: String, + pub refresh_seconds: u64, + pub baseline_model: String, + pub prices_file: Option, +} + #[derive(Clone, Default, Serialize)] pub struct DataState { pub usage: Source, + pub history: Source, /// This field holds the `/health` result with `state`, `reason`, `checked_at`, and `url`. pub health: Value, } @@ -123,23 +149,43 @@ impl Controller { } else { crate::health::ServerStatus::Stopped }; + let summary = crate::summary::build(server_status, &usage, &self.config, &rates); + let mut history_state = Availability::Available; let history = history::load(&self.config.routing_log).unwrap_or_else(|e| { + history_state = Availability::Unavailable; errors.push(e); history::History::default() }); let activity = history::hourly_calls(&history.entries, chrono::Utc::now()); - let server_url = self.config.server_url.clone(); - let routing_log = self.config.routing_log.clone(); - let refresh_seconds = self.config.refresh_seconds; + let update_status = install_metadata(&self.settings) + .and_then(|path| switchyard_desktop::install::update_status(&path)) + .unwrap_or_else(|error| json!({"state":"unavailable","message":error})); + let sessions = history.sessions(); + let Config { + server_url, + routing_log, + config_file, + launchd_label, + refresh_seconds, + baseline_model, + prices_file, + } = self.config.clone(); Ok(Snapshot { + update_status, data_state: DataState { usage: Source { state: usage_state, checked_at: checked_at.clone(), path: routing_log.clone(), }, + history: Source { + state: history_state, + checked_at, + path: routing_log.clone(), + }, health, }, + summary, metrics: Metrics { server_url: server_url.clone(), running: server_status == crate::health::ServerStatus::Running, @@ -153,11 +199,39 @@ impl Controller { }, }, today, + analytics: usage, estimates, + baseline_model: baseline_model.clone(), activity, + sessions, + entries: history.entries, limited: history.limited, + skipped: history.skipped, errors, refresh_seconds: refresh_seconds.max(1), + settings: Some(AppSettings { + path: self.settings.clone(), + server_url, + routing_log, + config_file, + launchd_label, + refresh_seconds, + baseline_model, + prices_file, + }), }) } } +fn install_metadata(settings: &std::path::Path) -> Result { + let executable = std::env::current_exe().map_err(|e| e.to_string())?; + let bundle = switchyard_desktop::install::bundle_metadata(&executable); + let metadata = if bundle.is_file() { + bundle + } else { + settings + .parent() + .ok_or("The settings file has no parent directory.")? + .join("install.toml") + }; + Ok(metadata) +} diff --git a/crates/switchyard-desktop/src/history.rs b/crates/switchyard-desktop/src/history.rs index 6db8722b1b..c58cfca6e6 100644 --- a/crates/switchyard-desktop/src/history.rs +++ b/crates/switchyard-desktop/src/history.rs @@ -42,6 +42,7 @@ pub struct Entry { pub struct History { pub entries: Vec, pub limited: bool, + pub skipped: usize, } // `hourly_calls` uses bounded recent history, so its buckets may omit calls from a busy day. @@ -110,13 +111,26 @@ fn parse(bytes: &[u8], truncated: bool) -> History { } entries.push_back(entry); } - _ => {} + _ => result.skipped += 1, } } result.entries = entries.into(); result } +impl History { + pub fn sessions(&self) -> Vec { + self.entries + .iter() + .filter_map(|e| e.session_id.as_deref()) + .filter(|s| !s.trim().is_empty()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 1600113ca6..ca2c26d636 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -8,6 +8,7 @@ use crate::controller::{Availability, Controller, Snapshot}; use crate::pricing::Estimate; +use crate::rollup::{ModelTokens, Totals, Usage}; use crate::summary::{money, tokens}; use ratatui::{ Frame, @@ -15,9 +16,12 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, - widgets::{Bar, BarChart, Block, BorderType, Clear, Padding, Paragraph, Tabs, Wrap}, + widgets::{ + Bar, BarChart, Block, BorderType, Cell, Clear, Padding, Paragraph, Row, Table, Tabs, Wrap, + }, }; use std::{ + collections::BTreeMap, sync::mpsc, thread, time::{Duration, Instant}, @@ -34,7 +38,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1", + "1-2", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -50,8 +54,16 @@ const GLOBAL_KEYS: &[Key] = &[ "Quit the app. The server keeps running. Ctrl-C also quits.", ), ]; +const MODELS_KEYS: &[Key] = &[( + "p", + "period", + "Switch the period: today, the past 7 days, or the whole log. The [ and ] keys also work.", +)]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; +/// Column is a table heading, its width, and whether its values are right-aligned. +type Column = (&'static str, Constraint, bool); + /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] struct Theme { @@ -101,12 +113,14 @@ fn color_enabled() -> bool { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Tab { Overview, + Models, } -const TABS: [Tab; 1] = [Tab::Overview]; +const TABS: [Tab; 2] = [Tab::Overview, Tab::Models]; impl Tab { fn name(self) -> &'static str { match self { Tab::Overview => "Overview", + Tab::Models => "Models", } } fn index(self) -> usize { @@ -118,12 +132,31 @@ impl Tab { enum Period { Today, Week, + All, } impl Period { fn name(self) -> &'static str { match self { Period::Today => "Today", Period::Week => "Past 7 days", + Period::All => "Whole log", + } + } + fn next(self) -> Self { + match self { + Period::Today => Period::Week, + Period::Week => Period::All, + Period::All => Period::Today, + } + } + fn previous(self) -> Self { + self.next().next() + } + fn totals(self, usage: &Usage) -> &Totals { + match self { + Period::Today => &usage.today, + Period::Week => &usage.week, + Period::All => &usage.all, } } /// The estimate is `None` when the usage log could not be read. @@ -131,6 +164,7 @@ impl Period { let key = match self { Period::Today => "today", Period::Week => "week", + Period::All => "all", }; snapshot.estimates.get(key).copied().flatten() } @@ -191,6 +225,7 @@ struct App { theme: Theme, data: Snapshot, tab: Tab, + period: Period, popup: Option, toast: Option, busy: Option, @@ -207,6 +242,7 @@ impl App { theme, data: Snapshot::default(), tab: Tab::Overview, + period: Period::Week, popup: None, toast: None, busy: None, @@ -283,6 +319,7 @@ impl App { match (&self.popup, self.tab) { (Some(_), _) => POPUP_KEYS, (None, Tab::Overview) => &[], + (None, Tab::Models) => MODELS_KEYS, } } @@ -297,7 +334,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='1') => { + KeyCode::Char(digit @ '1'..='2') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -306,7 +343,14 @@ impl App { } KeyCode::Char('r') | KeyCode::F(5) => return Some(Request::refresh()), KeyCode::Esc => self.toast = None, - _ => {} + code => match self.tab { + Tab::Overview => {} + Tab::Models => match code { + KeyCode::Char('p' | ']') => self.period = self.period.next(), + KeyCode::Char('[') => self.period = self.period.previous(), + _ => {} + }, + }, } None } @@ -352,6 +396,10 @@ fn count(value: u64) -> String { text } +fn plural(value: u64, one: &str, many: &str) -> String { + format!("{} {}", count(value), if value == 1 { one } else { many }) +} + /// This function shows a total only when every call has a cost. Otherwise it shows the known /// part as a lower bound, or "unknown", so a missing cost never reads as $0. fn cost(theme: Theme, total: Option, known: f64) -> Span<'static> { @@ -385,6 +433,24 @@ fn saved(theme: Theme, estimate: &Estimate, baseline: bool) -> Vec spans } +/// This function explains why `Estimate::savings` has no figure, in the order that +/// `summary::build` checks the reasons. Call it only when the savings are hidden. +fn hidden_savings(estimate: &Estimate, baseline: &str) -> String { + if estimate.baseline.is_none() { + format!("the price table has no rates for {baseline}") + } else if estimate.unknown_calls > 0 { + format!( + "{} no recorded cost", + plural(estimate.unknown_calls, "call has", "calls have") + ) + } else { + format!( + "{} no token counts", + plural(estimate.uncounted_answers, "answer has", "answers have") + ) + } +} + fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { [ Span::styled("● ", if running { theme.ok } else { theme.error }), @@ -392,6 +458,33 @@ fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { ] } +fn right<'a>(line: impl Into>) -> Cell<'a> { + Cell::from(line.into().right_aligned()) +} + +/// Model tables end with the share column. Narrow terminals drop it so model IDs stay readable; +/// `Table` ignores the extra cell in each row. +fn share_columns(columns: &[Column], area: Rect) -> &[Column] { + let keep = if area.width >= 80 { + columns.len() + } else { + columns.len() - 1 + }; + &columns[..keep] +} + +fn table<'a>(theme: Theme, columns: &[Column], rows: Vec>) -> Table<'a> { + let header = columns.iter().map(|&(title, _, align_right)| { + if align_right { + right(title) + } else { + Cell::from(title) + } + }); + Table::new(rows, columns.iter().map(|column| column.1)) + .header(Row::new(header).style(theme.muted)) +} + fn block(theme: Theme, title: &str) -> Block<'static> { Block::bordered() .border_type(BorderType::Rounded) @@ -403,14 +496,22 @@ fn draw(frame: &mut Frame, app: &mut App) { // This destructuring has no `..`, so adding a snapshot field breaks the build until a page // shows it. Each comment names the place that shows the field. let Snapshot { - data_state: _, - metrics: _, - today: _, - estimates: _, - activity: _, - limited: _, - errors: _, - refresh_seconds: _, + update_status: _, // draw_settings + settings: _, // draw_settings + data_state: _, // every page checks availability; draw_overview shows health + summary: _, // --print shows these rows; draw_top_models builds the same notes + metrics: _, // draw_header and the Overview cards + today: _, // the Overview "Today" card title + analytics: _, // draw_top_models and draw_models + estimates: _, // the Overview cards and draw_models + baseline_model: _, // draw_top_models; draw_settings shows the saved setting + activity: _, // draw_activity + sessions: _, // the calls table title and the `s` key + entries: _, // draw_call_table and the call details + limited: _, // calls_block and draw_activity + skipped: _, // calls_block + errors: _, // draw_errors and draw_settings + refresh_seconds: _, // App::refresh_due } = &app.data; let area = frame.area(); app.width = area.width; @@ -427,6 +528,7 @@ fn draw(frame: &mut Frame, app: &mut App) { draw_header(frame, app, header); match app.tab { Tab::Overview => draw_overview(frame, app, body), + Tab::Models => draw_models(frame, app, body), } draw_status(frame, app, status); draw_popup(frame, app, body); @@ -534,7 +636,7 @@ fn draw_overview(frame: &mut Frame, app: &App, area: Rect) { } let usage = data.data_state.usage.state == Availability::Available; let chart = if usage && rest.height >= 24 { 9 } else { 0 }; - let [cards, chart_area, _] = Layout::vertical([ + let [cards, chart_area, models] = Layout::vertical([ Constraint::Length(5), Constraint::Length(chart), Constraint::Fill(1), @@ -574,6 +676,7 @@ fn draw_overview(frame: &mut Frame, app: &App, area: Rect) { if chart > 0 { draw_activity(frame, app, chart_area); } + draw_top_models(frame, app, models); } fn draw_card(frame: &mut Frame, theme: Theme, title: &str, lines: Vec, area: Rect) { @@ -639,6 +742,251 @@ fn draw_activity(frame: &mut Frame, app: &App, area: Rect) { ); } +/// Models are ordered by their share of the period's tokens; the model ID breaks ties. +fn by_tokens(models: &BTreeMap) -> Vec<(&str, &ModelTokens)> { + let mut rows: Vec<_> = models + .iter() + .map(|(name, usage)| (name.as_str(), usage)) + .collect(); + rows.sort_by(|left, right| { + right + .1 + .total() + .cmp(&left.1.total()) + .then(left.0.cmp(right.0)) + }); + rows +} + +/// This function builds a model row. With `kind`, the row also names the answer or routing kind. +fn model_row( + theme: Theme, + model: &str, + usage: &ModelTokens, + total: f64, + routing: bool, + kind: bool, +) -> Row<'static> { + let share = usage.total() as f64 / total; + let filled = ((share * 10.0).round() as usize).min(10); + let mut cells = vec![Cell::from(Span::styled( + model.to_string(), + if routing { theme.muted } else { Style::new() }, + ))]; + if kind { + cells.push(Cell::from(Span::styled( + if routing { "routing" } else { "answer" }, + theme.muted, + ))); + } + cells.extend([ + right(count(usage.requests)), + right(tokens(usage.total())), + right(cost(theme, usage.cost.total_usd, usage.cost.known_usd)), + Cell::from(Line::from(vec![ + Span::styled( + format!("{}{}", "█".repeat(filled), "░".repeat(10 - filled)), + theme.bar, + ), + Span::styled(format!(" {:>3.0}%", share * 100.0), theme.muted), + ])), + ]); + Row::new(cells) +} + +fn draw_top_models(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let week = &app.data.analytics.week; + let mut notes = Vec::new(); + if !app.data.baseline_model.is_empty() { + notes.push(Line::styled( + format!( + "Savings compare answer calls with {} prices.", + app.data.baseline_model + ), + theme.muted, + )); + } + for period in [Period::Today, Period::Week] { + if let Some(estimate) = period.estimate(&app.data) + && estimate.savings().is_none() + { + notes.push(Line::styled( + format!( + "{}: savings hidden because {}.", + period.name(), + hidden_savings(&estimate, &app.data.baseline_model) + ), + theme.warn, + )); + } + } + let outer = block(theme, "Models · past 7 days"); + let inner = outer.inner(area); + frame.render_widget(outer, area); + let total = week.tokens().max(1) as f64; + let mut rows: Vec = by_tokens(&week.routed) + .into_iter() + .map(|(model, usage)| model_row(theme, model, usage, total, false, false)) + .collect(); + if !week.classifier.is_empty() { + let mut routing = ModelTokens::default(); + for usage in week.classifier.values() { + routing.add(usage); + } + rows.push(model_row( + theme, + "routing calls", + &routing, + total, + true, + false, + )); + } + let height = if week.is_empty() { 1 } else { rows.len() + 1 }; + let [table_area, _, notes_area] = Layout::vertical([ + Constraint::Length(height as u16), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(inner); + frame.render_widget(Paragraph::new(notes).wrap(Wrap { trim: false }), notes_area); + if week.is_empty() { + let text = if app.data.analytics.all.is_empty() { + "No calls recorded yet." + } else { + "No calls recorded in the past 7 days." + }; + frame.render_widget(Paragraph::new(text).style(theme.muted), table_area); + return; + } + let columns = [ + ("MODEL", Constraint::Fill(1), false), + ("CALLS", Constraint::Length(8), true), + ("TOKENS", Constraint::Length(8), true), + ("COST", Constraint::Length(10), true), + ("SHARE", Constraint::Length(17), false), + ]; + frame.render_widget( + table(theme, share_columns(&columns, inner), rows), + table_area, + ); +} + +fn draw_models(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + if app.data.data_state.usage.state == Availability::Unavailable { + draw_errors(frame, app, area); + return; + } + let totals = app.period.totals(&app.data.analytics); + let mut summary = vec![ + Span::styled(count(totals.requests()), Modifier::BOLD), + Span::styled(" answer calls · ", theme.muted), + Span::styled(tokens(totals.tokens()), Modifier::BOLD), + Span::styled(" tokens", theme.muted), + ]; + if let Some(estimate) = app.period.estimate(&app.data) { + summary.push(Span::styled(" · spent ", theme.muted)); + summary.push(cost(theme, estimate.actual, estimate.known).bold()); + summary.push(Span::styled(" · saved ", theme.muted)); + summary.extend(saved(theme, &estimate, true)); + if estimate.savings().is_none() { + summary.push(Span::styled( + format!( + " because {}", + hidden_savings(&estimate, &app.data.baseline_model) + ), + theme.warn, + )); + } + } + let outer = block(theme, &format!("Models · {}", app.period.name())).title_top( + Line::from(vec![ + Span::styled(" p ", theme.key), + Span::styled("period ", theme.muted), + ]) + .right_aligned(), + ); + let inner = outer.inner(area); + frame.render_widget(outer, area); + let total = totals.tokens().max(1) as f64; + let rows: Vec = by_tokens(&totals.routed) + .into_iter() + .map(|(model, usage)| model_row(theme, model, usage, total, false, true)) + .chain( + by_tokens(&totals.classifier) + .into_iter() + .map(|(model, usage)| model_row(theme, model, usage, total, true, true)), + ) + .collect(); + // The models table stops early enough to leave the routes heading and one route visible. + let routes_height = (totals.routes.len() as u16 + 1).min(2); + let models_height = (rows.len() as u16 + 1).min(inner.height.saturating_sub(3 + routes_height)); + let [summary_area, models_area, _, routes_area] = Layout::vertical([ + Constraint::Length(2), + Constraint::Length(models_height), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .areas(inner); + frame.render_widget( + Paragraph::new(Line::from(summary)).wrap(Wrap { trim: false }), + summary_area, + ); + if totals.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled( + "No calls recorded in this period.", + theme.muted, + )), + models_area, + ); + return; + } + let columns = [ + ("MODEL", Constraint::Fill(1), false), + ("KIND", Constraint::Length(8), false), + ("CALLS", Constraint::Length(8), true), + ("TOKENS", Constraint::Length(8), true), + ("COST", Constraint::Length(10), true), + ("SHARE OF TOKENS", Constraint::Length(17), false), + ]; + frame.render_widget( + table(theme, share_columns(&columns, inner), rows), + models_area, + ); + let routes: Vec = by_tokens(&totals.routes) + .into_iter() + .map(|(route, usage)| { + Row::new(vec![ + Cell::from( + if route.is_empty() { + "Not recorded" + } else { + route + } + .to_string(), + ), + right(count(usage.requests)), + right(tokens(usage.total())), + right(cost(theme, usage.cost.total_usd, usage.cost.known_usd)), + ]) + }) + .collect(); + let columns = [ + ( + "ROUTE (answer and routing calls)", + Constraint::Fill(1), + false, + ), + ("CALLS", Constraint::Length(8), true), + ("TOKENS", Constraint::Length(8), true), + ("COST", Constraint::Length(10), true), + ]; + frame.render_widget(table(theme, &columns, routes), routes_area); +} + fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if let Some(busy) = &app.busy { @@ -778,7 +1126,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { const KEY_WIDTH: usize = 8; let room = width.saturating_sub(KEY_WIDTH).max(20); let mut lines = Vec::new(); - for (section, keys) in [("Every page", GLOBAL_KEYS)] { + for (section, keys) in [("Every page", GLOBAL_KEYS), ("Models", MODELS_KEYS)] { if !lines.is_empty() { lines.push(Line::default()); } @@ -881,3 +1229,78 @@ pub fn run(mut controller: Controller) -> Result<(), String> { } result } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{controller::Source, history::Entry}; + use ratatui::{Terminal, backend::TestBackend}; + use serde_json::json; + + fn screen(app: &mut App, tab: Tab) -> String { + app.tab = tab; + let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap(); + terminal.draw(|frame| draw(frame, app)).unwrap(); + let buffer = terminal.backend().buffer(); + buffer + .content() + .chunks(usize::from(buffer.area.width)) + .map(|row| row.iter().map(|cell| cell.symbol()).collect::()) + .collect::>() + .join("\n") + } + + #[test] + // A call without a recorded cost must never add up to, or display as, $0. + fn unknown_costs_do_not_render_as_zero() { + let entry: Entry = serde_json::from_value(json!({"ts": "2026-10-21T12:00:00Z", + "model": "gemini-4-argon", "route_id": "answer", "prompt_tokens": 10, + "completion_tokens": 2})) + .unwrap(); + let usage = ModelTokens::from(&entry); + let totals = Totals { + routed: [("gemini-4-argon".to_string(), usage.clone())].into(), + classifier: BTreeMap::new(), + routes: [("answer".to_string(), usage)].into(), + }; + let estimate = Estimate { + actual: None, + known: 0.0, + has_known_cost: false, + unknown_calls: 1, + baseline: Some(0.00004), + uncounted_answers: 0, + }; + let available = Source { + state: Availability::Available, + ..Source::default() + }; + let mut snapshot = Snapshot { + analytics: Usage { + today: totals.clone(), + week: totals.clone(), + all: totals, + ..Usage::default() + }, + estimates: [ + ("today", Some(estimate)), + ("week", Some(estimate)), + ("all", Some(estimate)), + ] + .into(), + baseline_model: "gemini-4-argon".into(), + entries: vec![entry], + ..Snapshot::default() + }; + snapshot.data_state.usage = available.clone(); + snapshot.data_state.history = available; + let mut app = App::new(Theme::new(false), snapshot); + for tab in [Tab::Overview, Tab::Models] { + let text = screen(&mut app, tab); + assert!(!text.contains("$0"), "{tab:?} shows a zero cost:\n{text}"); + } + let overview = screen(&mut app, Tab::Overview); + assert!(overview.contains("Spent unknown")); + assert!(overview.contains("no recorded cost")); + } +} From 302d9b82b3050ce69f1d1ff87177d4a414bd7642 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 05/28] feat(desktop): add the Calls page and live call filter to the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/tui.rs | 565 +++++++++++++++++++++++++-- 1 file changed, 543 insertions(+), 22 deletions(-) diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index ca2c26d636..e193139413 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -7,9 +7,11 @@ //! refreshes and actions, so moving around and filtering never wait for the log or a service. use crate::controller::{Availability, Controller, Snapshot}; +use crate::history::Entry; use crate::pricing::Estimate; -use crate::rollup::{ModelTokens, Totals, Usage}; +use crate::rollup::{CLASSIFIER_TIER, ModelTokens, Totals, Usage}; use crate::summary::{money, tokens}; +use chrono::{DateTime, Utc}; use ratatui::{ Frame, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, @@ -17,7 +19,8 @@ use ratatui::{ style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{ - Bar, BarChart, Block, BorderType, Cell, Clear, Padding, Paragraph, Row, Table, Tabs, Wrap, + Bar, BarChart, Block, BorderType, Cell, Clear, Padding, Paragraph, Row, Scrollbar, + ScrollbarOrientation, ScrollbarState, Table, TableState, Tabs, Wrap, }, }; use std::{ @@ -26,10 +29,13 @@ use std::{ thread, time::{Duration, Instant}, }; +use switchyard_costs::{CostSource, RecordedCost}; /// Smaller terminals show a resize message instead of clipped panels. const MIN_WIDTH: u16 = 60; const MIN_HEIGHT: u16 = 16; +/// The calls page puts the detail pane beside the table at this width or wider. +const SIDE_DETAIL_WIDTH: u16 = 120; const TOAST_TIME: Duration = Duration::from_secs(4); const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾']; @@ -38,7 +44,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-2", + "1-3", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -54,15 +60,51 @@ const GLOBAL_KEYS: &[Key] = &[ "Quit the app. The server keeps running. Ctrl-C also quits.", ), ]; +const CALLS_KEYS: &[Key] = &[ + ( + "↑↓", + "select", + "Move the selection. j, k, PgUp, PgDn, g, and G also move it.", + ), + ( + "/", + "filter", + "Filter as you type. Every word must appear in the model, route, session, or turn. \ + A word with a capital letter must match case exactly.", + ), + ( + "s", + "session", + "Show one session at a time, then all sessions again.", + ), + ("⏎", "details", "Open the selected call's details."), + ("esc", "clear", "Clear the filter and the session."), +]; const MODELS_KEYS: &[Key] = &[( "p", "period", "Switch the period: today, the past 7 days, or the whole log. The [ and ] keys also work.", )]; +const FILTER_KEYS: &[Key] = &[ + ("⏎", "keep", "Keep the filter and go back to the list."), + ("esc", "clear", "Clear the filter."), + ("ctrl-u", "erase", "Erase the filter text."), +]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; /// Column is a table heading, its width, and whether its values are right-aligned. type Column = (&'static str, Constraint, bool); +/// The calls table drops the columns marked `true` below 90 columns. +const CALL_COLUMNS: [(Column, bool); 8] = [ + (("TIME", Constraint::Length(8), true), false), + (("MODEL", Constraint::Fill(2), false), false), + (("ROUTE", Constraint::Fill(1), false), true), + (("KIND", Constraint::Length(7), false), false), + (("IN", Constraint::Length(7), true), false), + (("CACHED", Constraint::Length(7), true), true), + (("OUT", Constraint::Length(7), true), false), + (("COST", Constraint::Length(9), true), false), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -113,13 +155,15 @@ fn color_enabled() -> bool { #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Tab { Overview, + Calls, Models, } -const TABS: [Tab; 2] = [Tab::Overview, Tab::Models]; +const TABS: [Tab; 3] = [Tab::Overview, Tab::Calls, Tab::Models]; impl Tab { fn name(self) -> &'static str { match self { Tab::Overview => "Overview", + Tab::Calls => "Calls", Tab::Models => "Models", } } @@ -174,6 +218,9 @@ enum Popup { Help { scroll: u16, }, + Detail { + scroll: u16, + }, Message { title: &'static str, text: String, @@ -184,7 +231,9 @@ enum Popup { impl Popup { fn scroll_mut(&mut self) -> &mut u16 { match self { - Popup::Help { scroll } | Popup::Message { scroll, .. } => scroll, + Popup::Help { scroll } | Popup::Detail { scroll } | Popup::Message { scroll, .. } => { + scroll + } } } } @@ -226,6 +275,13 @@ struct App { data: Snapshot, tab: Tab, period: Period, + filter: String, + filtering: bool, + session: Option, + /// These indexes point into `data.entries`, newest first, after the session and filter. + visible: Vec, + selected: usize, + offset: usize, popup: Option, toast: Option, busy: Option, @@ -243,6 +299,12 @@ impl App { data: Snapshot::default(), tab: Tab::Overview, period: Period::Week, + filter: String::new(), + filtering: false, + session: None, + visible: Vec::new(), + selected: 0, + offset: 0, popup: None, toast: None, busy: None, @@ -256,15 +318,52 @@ impl App { } fn apply_snapshot(&mut self, snapshot: Result) { - match snapshot { - Ok(data) => { - self.data = data; - self.refreshed = Some(Instant::now()); - } - Err(error) => self.notify(error, true), + let data = match snapshot { + Ok(data) => data, + Err(error) => return self.notify(error, true), + }; + // A refresh keeps the same call selected unless the newest call was selected. + let anchor = self + .selected_entry() + .filter(|_| self.selected > 0) + .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); + self.data = data; + self.refreshed = Some(Instant::now()); + self.rebuild_calls(); + if let Some((ts, model, turn)) = anchor + && let Some(position) = self.visible.iter().position(|&index| { + let entry = &self.data.entries[index]; + entry.ts == ts && entry.model == model && entry.turn_id == turn + }) + { + self.selected = position; } } + fn rebuild_calls(&mut self) { + let session = self.session.as_deref(); + let filter = self.filter.as_str(); + self.visible = self + .data + .entries + .iter() + .enumerate() + .rev() + .filter(|(_, entry)| { + session.is_none_or(|session| entry.session_id.as_deref() == Some(session)) + && matches_filter(entry, filter) + }) + .map(|(index, _)| index) + .collect(); + self.selected = self.selected.min(self.visible.len().saturating_sub(1)); + } + + fn selected_entry(&self) -> Option<&Entry> { + self.visible + .get(self.selected) + .map(|&index| &self.data.entries[index]) + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, error: bool) { if text.is_empty() { @@ -316,10 +415,20 @@ impl App { } fn keys(&self) -> &'static [Key] { - match (&self.popup, self.tab) { - (Some(_), _) => POPUP_KEYS, - (None, Tab::Overview) => &[], - (None, Tab::Models) => MODELS_KEYS, + match (&self.popup, self.filtering, self.tab) { + (Some(_), _, _) => POPUP_KEYS, + (None, true, _) => FILTER_KEYS, + (None, false, Tab::Overview) => &[], + (None, false, Tab::Calls) => CALLS_KEYS, + (None, false, Tab::Models) => MODELS_KEYS, + } + } + + fn paste(&mut self, text: &str) { + if self.filtering && self.popup.is_none() { + self.filter.extend(text.chars().filter(|c| !c.is_control())); + self.selected = 0; + self.rebuild_calls(); } } @@ -331,10 +440,14 @@ impl App { if self.popup.is_some() { return self.popup_key(key); } + if self.filtering { + self.filter_key(key); + return None; + } match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='2') => { + KeyCode::Char(digit @ '1'..='3') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -342,9 +455,17 @@ impl App { self.tab = TABS[(self.tab.index() + TABS.len() - 1) % TABS.len()]; } KeyCode::Char('r') | KeyCode::F(5) => return Some(Request::refresh()), - KeyCode::Esc => self.toast = None, + KeyCode::Esc => { + self.toast = None; + if self.tab == Tab::Calls { + self.filter.clear(); + self.session = None; + self.rebuild_calls(); + } + } code => match self.tab { Tab::Overview => {} + Tab::Calls => self.calls_key(code), Tab::Models => match code { KeyCode::Char('p' | ']') => self.period = self.period.next(), KeyCode::Char('[') => self.period = self.period.previous(), @@ -355,6 +476,62 @@ impl App { None } + fn calls_key(&mut self, code: KeyCode) { + if let Some(selected) = step(code, self.selected, self.visible.len().saturating_sub(1)) { + self.selected = selected; + return; + } + match code { + KeyCode::Char('/') => self.filtering = true, + KeyCode::Enter if self.selected_entry().is_some() => { + self.popup = Some(Popup::Detail { scroll: 0 }); + } + KeyCode::Char('s') => { + let sessions = &self.data.sessions; + self.session = match &self.session { + None => sessions.first().cloned(), + Some(current) => sessions + .iter() + .position(|session| session == current) + .and_then(|index| sessions.get(index + 1)) + .cloned(), + }; + self.selected = 0; + self.rebuild_calls(); + } + _ => {} + } + } + + fn filter_key(&mut self, key: KeyEvent) { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Enter => self.filtering = false, + KeyCode::Esc => { + self.filtering = false; + self.filter.clear(); + } + KeyCode::Backspace => { + self.filter.pop(); + } + KeyCode::Char('u') if control => self.filter.clear(), + KeyCode::Char('w') if control => { + let kept = self + .filter + .trim_end() + .rfind(' ') + .map_or(0, |index| index + 1); + self.filter.truncate(kept); + } + KeyCode::Char(c) if !control && !key.modifiers.contains(KeyModifiers::ALT) => { + self.filter.push(c); + } + _ => return, + } + self.selected = 0; + self.rebuild_calls(); + } + fn popup_key(&mut self, key: KeyEvent) -> Option { let popup = self.popup.as_mut()?; match key.code { @@ -384,6 +561,30 @@ fn step(code: KeyCode, current: usize, last: usize) -> Option { }) } +/// Every word must appear in one of the call's IDs. A word with a capital letter matches case +/// exactly; other words ignore ASCII case, which covers model, route, and session IDs. +fn matches_filter(entry: &Entry, filter: &str) -> bool { + let fields = [ + entry.model.as_str(), + entry.route_id.as_str(), + entry.session_id.as_deref().unwrap_or_default(), + entry.turn_id.as_deref().unwrap_or_default(), + ]; + filter.split_whitespace().all(|word| { + let exact = word.chars().any(char::is_uppercase); + fields.iter().any(|field| { + if exact { + field.contains(word) + } else { + field + .as_bytes() + .windows(word.len()) + .any(|window| window.eq_ignore_ascii_case(word.as_bytes())) + } + }) + }) +} + fn count(value: u64) -> String { let digits = value.to_string(); let mut text = String::with_capacity(digits.len() + digits.len() / 3); @@ -400,6 +601,19 @@ fn plural(value: u64, one: &str, many: &str) -> String { format!("{} {}", count(value), if value == 1 { one } else { many }) } +/// Single calls often cost fractions of a cent, so this shows more digits than `money`. +fn call_money(usd: f64) -> String { + if usd == 0.0 { + "$0".into() + } else if usd >= 0.01 { + money(usd) + } else if usd >= 0.0001 { + format!("${usd:.4}") + } else { + "<$0.0001".into() + } +} + /// This function shows a total only when every call has a cost. Otherwise it shows the known /// part as a lower bound, or "unknown", so a missing cost never reads as $0. fn cost(theme: Theme, total: Option, known: f64) -> Span<'static> { @@ -451,6 +665,19 @@ fn hidden_savings(estimate: &Estimate, baseline: &str) -> String { } } +fn age(ts: &str, now: DateTime) -> String { + let Ok(time) = DateTime::parse_from_rfc3339(ts) else { + return "—".into(); + }; + let seconds = now.signed_duration_since(time).num_seconds().max(0); + match seconds { + 0..60 => format!("{seconds}s ago"), + 60..3_600 => format!("{}m ago", seconds / 60), + 3_600..86_400 => format!("{}h ago", seconds / 3_600), + _ => format!("{}d ago", seconds / 86_400), + } +} + fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { [ Span::styled("● ", if running { theme.ok } else { theme.error }), @@ -458,6 +685,19 @@ fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { ] } +/// This function draws a muted label in a fixed-width column, then the value or "Not recorded". +fn labeled(theme: Theme, name: &str, width: usize, value: String) -> Line<'static> { + let value = if value.is_empty() { + "Not recorded".to_string() + } else { + value + }; + Line::from(vec![ + Span::styled(format!("{name:(line: impl Into>) -> Cell<'a> { Cell::from(line.into().right_aligned()) } @@ -528,6 +768,7 @@ fn draw(frame: &mut Frame, app: &mut App) { draw_header(frame, app, header); match app.tab { Tab::Overview => draw_overview(frame, app, body), + Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), } draw_status(frame, app, status); @@ -873,6 +1114,257 @@ fn draw_top_models(frame: &mut Frame, app: &App, area: Rect) { ); } +fn draw_calls(frame: &mut Frame, app: &mut App, area: Rect) { + if app.data.data_state.history.state == Availability::Unavailable { + draw_errors(frame, app, area); + return; + } + if area.width >= SIDE_DETAIL_WIDTH { + let [table, detail] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(48)]).areas(area); + draw_call_table(frame, app, table); + let lines = app + .selected_entry() + .map(|entry| detail_lines(app.theme, entry)) + .unwrap_or_else(|| vec![Line::styled("No call selected.", app.theme.muted)]); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(block(app.theme, "Call details").padding(Padding::horizontal(1))), + detail, + ); + } else { + draw_call_table(frame, app, area); + } +} + +fn calls_block(app: &App) -> Block<'static> { + let theme = app.theme; + let mut title = vec![ + Span::styled(" Calls ", Modifier::BOLD), + Span::styled( + format!("[{}] ", count(app.visible.len() as u64)), + theme.muted, + ), + ]; + if let Some(session) = &app.session { + let note = if app.data.sessions.contains(session) { + "" + } else { + " (not in recent calls)" + }; + title.push(Span::styled(format!("session {session}{note} "), theme.key)); + } + if !app.filter.is_empty() || app.filtering { + title.push(Span::styled(format!("/{} ", app.filter), theme.key)); + } + let mut outer = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(if app.filtering { + theme.key + } else { + theme.border + }) + .title(Line::from(title)); + if !app.visible.is_empty() { + outer = outer.title_bottom( + Line::styled( + format!( + " {} of {} ", + app.selected + 1, + count(app.visible.len() as u64) + ), + theme.muted, + ) + .right_aligned(), + ); + } + let mut notes = Vec::new(); + if app.data.limited { + notes.push("latest 5,000 calls".to_string()); + } + if app.data.skipped > 0 { + notes.push(format!( + "{} skipped", + plural( + app.data.skipped as u64, + "unreadable record", + "unreadable records" + ) + )); + } + if !notes.is_empty() { + outer = outer.title_bottom(Line::styled( + format!(" {} ", notes.join(" · ")), + theme.muted, + )); + } + outer +} + +fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { + // The table shows this many calls; the borders and the header row take three rows. + let height = usize::from(area.height.saturating_sub(3)).max(1); + if app.selected < app.offset { + app.offset = app.selected; + } else if app.selected >= app.offset + height { + app.offset = app.selected + 1 - height; + } + app.offset = app.offset.min(app.visible.len().saturating_sub(height)); + let app = &*app; + let theme = app.theme; + let outer = calls_block(app); + if app.visible.is_empty() { + let text = if app.data.entries.is_empty() { + "No calls recorded yet." + } else { + "No calls match the session and filter. Press Esc to clear them." + }; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer.padding(Padding::top(1))), + area, + ); + return; + } + let wide = area.width >= 90; + let shown = |column: &(Column, bool)| wide || !column.1; + let now = Utc::now(); + let rows: Vec = app.visible[app.offset..] + .iter() + .take(height) + .map(|&index| { + let entry = &app.data.entries[index]; + let usage = ModelTokens::from(entry); + let routing = entry.tier == CLASSIFIER_TIER; + let dim = if routing { theme.muted } else { Style::new() }; + let cells = [ + right(Span::styled(age(&entry.ts, now), theme.muted)), + Cell::from(Span::styled(entry.model.clone(), dim)), + Cell::from(entry.route_id.clone()), + Cell::from(Span::styled( + if routing { "routing" } else { "answer" }, + theme.muted, + )), + right(tokens(usage.input)), + right(tokens(usage.cached_input)), + right(tokens(usage.output)), + match entry.cost { + Some(cost) => right(call_money(cost.usd)), + None => right(Span::styled("—", theme.warn)), + }, + ]; + Row::new( + cells + .into_iter() + .zip(&CALL_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = CALL_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = TableState::default().with_selected(Some(app.selected - app.offset)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); + if app.visible.len() > height { + let mut scroll = ScrollbarState::new(app.visible.len()).position(app.selected); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .thumb_style(theme.key) + .track_symbol(Some("│")) + .track_style(theme.border), + // The scrollbar covers the data rows, below the top border and the header row. + Rect { + y: area.y + 2, + height: area.height.saturating_sub(3), + ..area + }, + &mut scroll, + ); + } +} + +fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { + let usage = ModelTokens::from(entry); + let field = |name: &str, value: String| labeled(theme, name, 9, value); + let time = DateTime::parse_from_rfc3339(&entry.ts).map_or_else( + |_| entry.ts.clone(), + |time| { + format!( + "{} ({})", + time.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S"), + age(&entry.ts, Utc::now()) + ) + }, + ); + let kind = if entry.tier == CLASSIFIER_TIER { + "Routing call (classifier or judge)" + } else { + "Answer call" + }; + let mut lines = vec![ + field("Time", time), + field("Model", entry.model.clone()), + field("Kind", kind.into()), + field("Route", entry.route_id.clone()), + field("Session", entry.session_id.clone().unwrap_or_default()), + field("Turn", entry.turn_id.clone().unwrap_or_default()), + Line::default(), + field( + "Input", + format!( + "{} uncached · {} cached", + count(usage.input), + count(usage.cached_input) + ), + ), + ]; + if usage.cache_write > 0 { + lines.push(field( + "Cache", + format!( + "{} written ({} at the 1-hour rate)", + count(usage.cache_write), + count(usage.cache_write_1h) + ), + )); + } + lines.push(field("Output", count(usage.output))); + lines.push(match entry.cost { + Some(RecordedCost { usd, source }) => field( + "Cost", + format!( + "{} · {}", + call_money(usd), + match source { + CostSource::ProviderReported => "reported by the provider", + _ => "estimated from token prices", + } + ), + ), + None => Line::from(vec![ + Span::styled(format!("{:<9}", "Cost"), theme.muted), + Span::styled("Not recorded", theme.warn), + ]), + }); + lines +} + fn draw_models(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; if app.data.data_state.usage.state == Availability::Unavailable { @@ -989,7 +1481,20 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; - let left = if let Some(busy) = &app.busy { + let left = if app.filtering { + Line::from(vec![ + Span::styled(" / ", theme.key), + Span::raw(app.filter.clone()), + Span::styled("▏", theme.key), + Span::styled( + format!( + " {} ", + plural(app.visible.len() as u64, "match", "matches") + ), + theme.muted, + ), + ]) + } else if let Some(busy) = &app.busy { let frame_index = busy.started.elapsed().as_millis() / 100 % SPINNER.len() as u128; let mut spans = vec![ Span::styled(format!(" {} ", SPINNER[frame_index as usize]), theme.key), @@ -1010,7 +1515,7 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { }; let room = usize::from(area.width).saturating_sub(left.width() + 2); // Page keys do nothing while a window is open or the filter takes the keyboard. - let global = app.popup.is_none(); + let global = app.popup.is_none() && !app.filtering; let hints = fit_keys(theme, app.keys(), global, room); let [left_area, right_area] = Layout::horizontal([Constraint::Length(left.width() as u16), Constraint::Fill(1)]) @@ -1073,6 +1578,16 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { let lines = help_lines(theme, usize::from(text_width)); ("Help", lines, Vec::new(), theme.key, width, close) } + Popup::Detail { .. } => ( + "Call details", + app.selected_entry() + .map(|entry| detail_lines(theme, entry)) + .unwrap_or_default(), + Vec::new(), + theme.key, + 72, + close, + ), Popup::Message { title, text, error, .. } => ( @@ -1126,7 +1641,12 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { const KEY_WIDTH: usize = 8; let room = width.saturating_sub(KEY_WIDTH).max(20); let mut lines = Vec::new(); - for (section, keys) in [("Every page", GLOBAL_KEYS), ("Models", MODELS_KEYS)] { + for (section, keys) in [ + ("Every page", GLOBAL_KEYS), + ("Calls", CALLS_KEYS), + ("While filtering calls", FILTER_KEYS), + ("Models", MODELS_KEYS), + ] { if !lines.is_empty() { lines.push(Line::default()); } @@ -1207,6 +1727,7 @@ pub fn run(mut controller: Controller) -> Result<(), String> { let wait = Duration::from_millis(if app.busy.is_some() { 100 } else { 250 }); if event::poll(wait).map_err(|e| e.to_string())? { match event::read().map_err(|e| e.to_string())? { + Event::Paste(text) => app.paste(&text), Event::Key(key) if key.kind == KeyEventKind::Press => { if let Some(request) = app.key(key) { app.start(&jobs, request)?; @@ -1233,7 +1754,7 @@ pub fn run(mut controller: Controller) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; - use crate::{controller::Source, history::Entry}; + use crate::controller::Source; use ratatui::{Terminal, backend::TestBackend}; use serde_json::json; @@ -1295,7 +1816,7 @@ mod tests { snapshot.data_state.usage = available.clone(); snapshot.data_state.history = available; let mut app = App::new(Theme::new(false), snapshot); - for tab in [Tab::Overview, Tab::Models] { + for tab in [Tab::Overview, Tab::Calls, Tab::Models] { let text = screen(&mut app, tab); assert!(!text.contains("$0"), "{tab:?} shows a zero cost:\n{text}"); } From 9564b6dc3785e84d8db10494d68f06d8c34bdba1 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 06/28] feat(desktop): add the Settings page, server actions, and editor handoff Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 100 +++- .../switchyard-desktop/src/install/windows.rs | 23 + crates/switchyard-desktop/src/main.rs | 1 + crates/switchyard-desktop/src/server.rs | 152 ++++++ crates/switchyard-desktop/src/tui.rs | 432 +++++++++++++++++- 5 files changed, 687 insertions(+), 21 deletions(-) create mode 100644 crates/switchyard-desktop/src/server.rs diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index fae3f71d3b..c0d5ac1cd6 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -6,11 +6,24 @@ //! terminal app matches `ActionKind` without a wildcard arm, so adding an action breaks the //! build until the terminal app can run it. -use crate::{config::Config, history, pricing, rollup}; -use serde::Serialize; +use crate::{config::Config, history, pricing, rollup, server}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::{collections::BTreeMap, path::PathBuf}; +#[derive(Clone, Debug, Deserialize, strum::EnumDiscriminants)] +#[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum Action { + OpenLogs {}, + OpenUpdateLog {}, + OpenConfig {}, + OpenPrices {}, + OpenSettings {}, + CheckConfig {}, + ServerKeys {}, +} + /// Snapshot holds everything that the apps show. The terminal app destructures it without /// `..`, so adding a field breaks the build until the terminal app shows it. #[derive(Clone, Default, Serialize)] @@ -90,6 +103,20 @@ pub struct Counts { pub tokens: u64, } +#[derive(Serialize)] +pub struct Reply { + pub message: String, + pub data: Value, +} +impl Reply { + fn message(message: String) -> Self { + Self { + message, + data: Value::Null, + } + } +} + pub struct Controller { settings: PathBuf, config: Config, @@ -221,6 +248,75 @@ impl Controller { }), }) } + /// The TUI uses these file paths to run an editor in the foreground. + pub fn editor_target(&mut self, action: &Action) -> Result, String> { + match action { + Action::OpenSettings {} => Ok(Some(self.settings.clone())), + Action::OpenConfig {} => { + self.reload()?; + Ok(Some(self.config.config_file.clone())) + } + // When `prices_file` is unset, this arm returns `None` and `dispatch` describes the + // bundled rates. + Action::OpenPrices {} => { + self.reload()?; + Ok(self.config.prices_file.clone()) + } + Action::OpenUpdateLog {} => self.update_log().map(Some), + Action::OpenLogs {} => logs_dir().map(Some), + _ => Ok(None), + } + } + fn update_log(&self) -> Result { + let metadata = + switchyard_desktop::install::Settings::load(&install_metadata(&self.settings)?)?; + Ok(metadata.sy_home.join("logs/update.log")) + } + pub fn dispatch(&mut self, action: Action) -> Result { + if matches!(action, Action::OpenSettings {}) { + server::command("open", &[&self.settings.display().to_string()])?; + return Ok(Reply::message("Opened app settings.".into())); + } + self.reload()?; + match action { + Action::OpenLogs {} => { + server::command("open", &[&logs_dir()?.display().to_string()])?; + Ok(Reply::message("Opened Switchyard logs.".into())) + } + Action::CheckConfig {} => server::check_config(&self.config) + .map(|()| Reply::message("Server config check passed.".into())), + Action::ServerKeys {} => Ok(Reply { + message: String::new(), + data: json!({"keys": server::server_keys(&self.config)?, "store": switchyard_desktop::install::credentials::STORE}), + }), + Action::OpenSettings {} => unreachable!(), + Action::OpenUpdateLog {} => { + let log = self.update_log()?; + server::command("open", &[&log.display().to_string()])?; + Ok(Reply::message("Opened the update log.".into())) + } + Action::OpenConfig {} => { + server::command("open", &[&self.config.config_file.display().to_string()])?; + Ok(Reply::message( + "Opened the server config in your default editor.".into(), + )) + } + Action::OpenPrices {} => { + let Some(path) = self.config.prices_file.as_ref() else { + return Ok(Reply::message("Bundled model prices are active. To use custom rates, set pricing_file in app settings and pass the same file to the server with --pricing-file.".into())); + }; + server::command("open", &[&path.display().to_string()])?; + Ok(Reply::message( + "Opened the model prices in your default editor. Restart the server after \ + you save them; it reads the prices when it starts." + .into(), + )) + } + } + } +} +fn logs_dir() -> Result { + crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index d5097cb39d..f2ed8a78b8 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -472,3 +472,26 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> { } Ok(()) } + +pub fn open(value: &str) -> Result<(), String> { + use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}; + let value = wide(std::ffi::OsStr::new(value)); + let verb = wide(std::ffi::OsStr::new("open")); + let status = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + verb.as_ptr(), + value.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ) + } as isize; + if status > 32 { + Ok(()) + } else { + Err(format!( + "Windows could not open the file or URL (error {status})." + )) + } +} diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index d1e61fbb57..d4c199a916 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -13,6 +13,7 @@ mod controller; #[cfg(any(target_os = "macos", windows))] mod gui; mod pricing; +mod server; mod summary; mod tui; #[cfg(windows)] diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs new file mode 100644 index 0000000000..7b57e62e1f --- /dev/null +++ b/crates/switchyard-desktop/src/server.rs @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module validates server configs and restarts the server service. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use switchyard_desktop::install::credentials::KeyStatus; + +use crate::config::Config; + +/// This function finds the installed helper, which is the only program that reads or writes +/// server keys. +fn helper() -> Result { + Ok( + server_binary()?.with_file_name(switchyard_desktop::install::executable_name( + "switchyard-desktop-install", + )), + ) +} + +/// This function runs one of the helper's `key` commands. A key goes to the helper's standard +/// input, never its command line, where other processes could read it. +fn key_command(args: &[&str], key: Option<&str>) -> Result { + let helper = helper()?; + let mut child = Command::new(&helper) + .arg("key") + .args(args) + .stdin(if key.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| { + format!( + "Could not run {}: {error}. \ + Run cargo desktop install from the Switchyard repository to install it.", + helper.display() + ) + })?; + if let (Some(key), Some(mut stdin)) = (key, child.stdin.take()) { + stdin + .write_all(key.as_bytes()) + .map_err(|error| format!("Could not pass the key to the helper: {error}"))?; + } + let output = child + .wait_with_output() + .map_err(|error| error.to_string())?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).into_owned()); + } + Err(String::from_utf8_lossy(&output.stderr) + .trim() + .trim_start_matches("switchyard-desktop-install: ") + .to_string()) +} + +/// This function reports which `api_key_env` variables in the server config have a saved key. +pub fn server_keys(settings: &Config) -> Result, String> { + let config = std::fs::canonicalize(&settings.config_file).map_err(|error| error.to_string())?; + let output = key_command(&["status", "--config", &config.display().to_string()], None)?; + serde_json::from_str(&output).map_err(|error| error.to_string()) +} + +/// This function finds the installed server from the app's installation metadata. +fn server_binary() -> Result { + let executable = std::env::current_exe().map_err(|error| { + format!( + "Could not find switchyard-server next to the desktop app: {error}. \ + Run cargo desktop install from the Switchyard repository to install it." + ) + })?; + let metadata = switchyard_desktop::install::bundle_metadata(&executable); + if metadata.try_exists().map_err(|error| error.to_string())? { + return switchyard_desktop::install::Settings::load(&metadata) + .map(|settings| settings.binary("switchyard-server")); + } + Ok( + executable.with_file_name(switchyard_desktop::install::executable_name( + "switchyard-server", + )), + ) +} + +/// This function asks the installed helper to run the server's dry-run on `config` with the +/// saved server keys, the same keys the installed server starts with. +fn check(binary: &Path, config: &Path) -> Result<(), String> { + let config = std::fs::canonicalize(config).map_err(|error| error.to_string())?; + let helper = binary.with_file_name(switchyard_desktop::install::executable_name( + "switchyard-desktop-install", + )); + let mut validator = Command::new(&helper); + validator + .arg("validate") + .arg("--server") + .arg(binary) + .arg("--config") + .arg(&config); + let output = validator.output().map_err(|error| { + format!( + "Could not run {}: {error}. \ + Apply needs the installed server and credential helper. \ + Run cargo desktop install from the Switchyard repository to install them.", + helper.display() + ) + })?; + if output.status.success() { + return Ok(()); + } + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { stdout } else { stderr }) +} + +pub fn check_config(settings: &Config) -> Result<(), String> { + check(&server_binary()?, &settings.config_file) +} + +/// This function runs a command, returning its stdout or a message naming what failed. +pub fn command(program: &str, args: &[&str]) -> Result { + if program == "open" { + #[cfg(windows)] + { + return switchyard_desktop::install::windows::open( + args.first().ok_or("Choose a file or URL.")?, + ) + .map(|()| String::new()); + } + #[cfg(target_os = "linux")] + { + return command("xdg-open", args); + } + } + + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("Could not run {program}: {error}"))?; + if !output.status.success() { + return Err(format!( + "{program} {} failed: {}", + args.join(" "), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index e193139413..699fb13411 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -6,25 +6,30 @@ //! The UI thread owns every view setting. One worker thread owns the controller and runs //! refreshes and actions, so moving around and filtering never wait for the log or a service. -use crate::controller::{Availability, Controller, Snapshot}; +use crate::controller::{ + Action, ActionKind, AppSettings, Availability, Controller, Reply, Snapshot, +}; use crate::history::Entry; use crate::pricing::Estimate; use crate::rollup::{CLASSIFIER_TIER, ModelTokens, Totals, Usage}; use crate::summary::{money, tokens}; use chrono::{DateTime, Utc}; use ratatui::{ - Frame, + DefaultTerminal, Frame, crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}, layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span}, widgets::{ - Bar, BarChart, Block, BorderType, Cell, Clear, Padding, Paragraph, Row, Scrollbar, - ScrollbarOrientation, ScrollbarState, Table, TableState, Tabs, Wrap, + Bar, BarChart, Block, BorderType, Cell, Clear, List, ListItem, ListState, Padding, + Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, Tabs, + Wrap, }, }; +use serde_json::Value; use std::{ collections::BTreeMap, + path::{Path, PathBuf}, sync::mpsc, thread, time::{Duration, Instant}, @@ -44,7 +49,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-3", + "1-4", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -85,6 +90,14 @@ const MODELS_KEYS: &[Key] = &[( "period", "Switch the period: today, the past 7 days, or the whole log. The [ and ] keys also work.", )]; +const SETTINGS_KEYS: &[Key] = &[ + ("↑↓", "select", "Select an action. j, g, and G also move."), + ( + "⏎", + "run", + "Run the selected action. The letter beside each action also runs it.", + ), +]; const FILTER_KEYS: &[Key] = &[ ("⏎", "keep", "Keep the filter and go back to the list."), ("esc", "clear", "Clear the filter."), @@ -92,6 +105,55 @@ const FILTER_KEYS: &[Key] = &[ ]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; +/// The Settings page lists one entry per controller action. A new controller action breaks the +/// build until `App::start_action` runs it, and `every_controller_action_has_a_settings_key` +/// fails until it has an entry here. +const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ + ( + 'o', + "Open server config", + "Opens the server config in $VISUAL or $EDITOR. Restart the server after you save it.", + ActionKind::OpenConfig, + ), + ( + 'c', + "Check server config", + "Runs the installed server's config check. The running server keeps running.", + ActionKind::CheckConfig, + ), + ( + 'a', + "Open app settings", + "Opens this app's settings file in $VISUAL or $EDITOR.", + ActionKind::OpenSettings, + ), + ( + 'p', + "Open model prices", + "Opens the custom price file in $VISUAL or $EDITOR. With bundled prices, this explains \ + how to set a custom file.", + ActionKind::OpenPrices, + ), + ( + 'k', + "Check server keys", + "Lists the API key variables that the server config names and whether each is saved.", + ActionKind::ServerKeys, + ), + ( + 'l', + "Open update log", + "Opens the log of the last source update in $VISUAL or $EDITOR.", + ActionKind::OpenUpdateLog, + ), + ( + 'v', + "Open server logs", + "Opens the Switchyard server logs folder in $VISUAL or $EDITOR.", + ActionKind::OpenLogs, + ), +]; + /// Column is a table heading, its width, and whether its values are right-aligned. type Column = (&'static str, Constraint, bool); /// The calls table drops the columns marked `true` below 90 columns. @@ -157,14 +219,16 @@ enum Tab { Overview, Calls, Models, + Settings, } -const TABS: [Tab; 3] = [Tab::Overview, Tab::Calls, Tab::Models]; +const TABS: [Tab; 4] = [Tab::Overview, Tab::Calls, Tab::Models, Tab::Settings]; impl Tab { fn name(self) -> &'static str { match self { Tab::Overview => "Overview", Tab::Calls => "Calls", Tab::Models => "Models", + Tab::Settings => "Settings", } } fn index(self) -> usize { @@ -240,9 +304,12 @@ impl Popup { enum Job { Refresh, + Run(Action), } enum Done { + Editor(PathBuf), Finished { + reply: Option>, /// The box keeps this message small; a snapshot holds every model and route total. snapshot: Result, String>, }, @@ -253,6 +320,12 @@ struct Request { label: &'static str, } impl Request { + fn run(action: Action, label: &'static str) -> Self { + Self { + job: Job::Run(action), + label, + } + } fn refresh() -> Self { Self { job: Job::Refresh, @@ -262,6 +335,8 @@ impl Request { } struct Busy { label: &'static str, + /// This field names the running action, so its reply can update the right view. + kind: Option, started: Instant, } struct Toast { @@ -282,6 +357,10 @@ struct App { visible: Vec, selected: usize, offset: usize, + action: usize, + /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, + /// and a failed check clears it so the page never shows outdated key states. + server_keys: Option, popup: Option, toast: Option, busy: Option, @@ -305,6 +384,8 @@ impl App { visible: Vec::new(), selected: 0, offset: 0, + action: 0, + server_keys: None, popup: None, toast: None, busy: None, @@ -385,9 +466,42 @@ impl App { } } - fn finish(&mut self, snapshot: Result, String>) { - self.busy = None; + /// This function applies a finished job. It returns the follow-up request, if any. + fn finish( + &mut self, + reply: Option>, + snapshot: Result, String>, + ) -> Option { + let kind = self.busy.take().and_then(|busy| busy.kind); self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); + match (kind, reply?) { + (Some(ActionKind::ServerKeys), Err(error)) => { + self.server_keys = None; + self.notify(error, true); + } + (_, Err(error)) => self.notify(error, true), + (Some(ActionKind::ServerKeys), Ok(reply)) => { + self.server_keys = Some(reply.data); + self.notify("Checked the saved server keys.".into(), false); + } + (_, Ok(reply)) => { + let details: Vec<&str> = reply.data["details"] + .as_array() + .map(|items| items.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + if details.is_empty() { + self.notify(reply.message, false); + } else { + self.popup = Some(Popup::Message { + title: "Details", + text: format!("{}\n\n{}", reply.message, details.join("\n")), + error: false, + scroll: 0, + }); + } + } + } + None } fn refresh_due(&self) -> bool { @@ -405,10 +519,17 @@ impl App { self.notify(text, true); return Ok(()); } - self.last_refresh = Instant::now(); + let kind = match &request.job { + Job::Refresh => { + self.last_refresh = Instant::now(); + None + } + Job::Run(action) => Some(ActionKind::from(action)), + }; jobs.send(request.job).map_err(|error| error.to_string())?; self.busy = Some(Busy { label: request.label, + kind, started: Instant::now(), }); Ok(()) @@ -421,6 +542,7 @@ impl App { (None, false, Tab::Overview) => &[], (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, + (None, false, Tab::Settings) => SETTINGS_KEYS, } } @@ -447,7 +569,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='3') => { + KeyCode::Char(digit @ '1'..='4') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -471,6 +593,7 @@ impl App { KeyCode::Char('[') => self.period = self.period.previous(), _ => {} }, + Tab::Settings => return self.settings_key(code), }, } None @@ -532,6 +655,45 @@ impl App { self.rebuild_calls(); } + /// Action letters come first, so `k` checks the server keys here instead of moving up. + fn settings_key(&mut self, code: KeyCode) -> Option { + if let KeyCode::Char(letter) = code + && let Some(index) = ACTIONS.iter().position(|action| action.0 == letter) + { + self.action = index; + return self.start_action(ACTIONS[index].3); + } + if let Some(action) = step(code, self.action, ACTIONS.len() - 1) { + self.action = action; + return None; + } + if code == KeyCode::Enter { + return self.start_action(ACTIONS[self.action].3); + } + None + } + + /// This function starts a Settings action. The match has no wildcard arm, so adding a + /// controller action breaks the build until the terminal app can run it. + fn start_action(&mut self, kind: ActionKind) -> Option { + let request = |action, label| Some(Request::run(action, label)); + match kind { + ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), + ActionKind::CheckConfig => { + request(Action::CheckConfig {}, "Checking the server config") + } + ActionKind::OpenSettings => { + request(Action::OpenSettings {}, "Opening the app settings") + } + ActionKind::OpenPrices => request(Action::OpenPrices {}, "Opening the model prices"), + ActionKind::ServerKeys => request(Action::ServerKeys {}, "Checking the server keys"), + ActionKind::OpenUpdateLog => { + request(Action::OpenUpdateLog {}, "Opening the update log") + } + ActionKind::OpenLogs => request(Action::OpenLogs {}, "Opening the server logs"), + } + } + fn popup_key(&mut self, key: KeyEvent) -> Option { let popup = self.popup.as_mut()?; match key.code { @@ -770,6 +932,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Overview => draw_overview(frame, app, body), Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), + Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); draw_popup(frame, app, body); @@ -1479,6 +1642,138 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(table(theme, &columns, routes), routes_area); } +/// This function lists the server key variables from the last `ServerKeys` reply. +fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { + let Some(data) = keys else { + return vec![Line::styled( + "Press k to check which keys are saved.", + theme.muted, + )]; + }; + let keys = data["keys"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + if keys.is_empty() { + return vec![Line::from( + "The server config names no keys with api_key_env.", + )]; + } + let mut lines = vec![Line::styled( + format!( + "Stored in {}", + data["store"].as_str().unwrap_or("Not recorded") + ), + theme.muted, + )]; + lines.extend(keys.iter().map(|key| { + let saved = key["saved"] == true; + Line::from(vec![ + Span::raw(format!("{} ", key["name"].as_str().unwrap_or_default())), + if saved { + Span::styled("saved", theme.ok) + } else { + Span::styled("not saved", theme.warn) + }, + ]) + })); + lines +} + +fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let [list_area, info_area] = + Layout::horizontal([Constraint::Length(30), Constraint::Fill(1)]).areas(area); + let items: Vec = ACTIONS + .iter() + .map(|(letter, name, ..)| { + ListItem::new(Line::from(vec![ + Span::styled(format!("{letter} "), theme.key), + Span::raw(*name), + ])) + }) + .collect(); + let mut state = ListState::default().with_selected(Some(app.action)); + frame.render_stateful_widget( + List::new(items) + .highlight_style(theme.selected) + .highlight_symbol("› ") + .block(block(theme, "Actions")), + list_area, + &mut state, + ); + let row = |name: &str, value: String| labeled(theme, name, 15, value); + let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); + let mut lines = vec![Line::from(ACTIONS[app.action].2), Line::default()]; + if !app.data.errors.is_empty() { + lines.extend( + app.data + .errors + .iter() + .map(|error| Line::styled(error.clone(), theme.error)), + ); + lines.push(Line::default()); + } + // This destructuring has no `..`, so adding an app setting breaks the build until the + // Settings page shows it. + match &app.data.settings { + Some(AppSettings { + path, + server_url, + routing_log, + config_file, + launchd_label, + refresh_seconds, + baseline_model, + prices_file, + }) => lines.extend([ + heading("APP SETTINGS"), + row("Settings file", path.display().to_string()), + row("Server URL", server_url.clone()), + row("Server config", config_file.display().to_string()), + row("Routing log", routing_log.display().to_string()), + row("Service label", launchd_label.clone()), + row("Baseline model", baseline_model.clone()), + row( + "Refresh", + format!("every {}", plural(*refresh_seconds, "second", "seconds")), + ), + row( + "Model prices", + prices_file + .as_ref() + .map_or("Bundled rates".into(), |file| file.display().to_string()), + ), + row("Editor", editor()), + ]), + None => lines.push(Line::styled( + "App settings could not be loaded. Press a to open them, then r to check them again.", + theme.warn, + )), + } + let update = &app.data.update_status; + let update = ["state", "stage", "message"] + .into_iter() + .filter_map(|field| update[field].as_str()) + .filter(|part| !part.is_empty()) + .collect::>() + .join(" · "); + lines.extend([Line::default(), heading("UPDATE FROM SOURCE")]); + lines.push(Line::from(if update.is_empty() { + "No update has run".to_string() + } else { + update + })); + lines.extend([Line::default(), heading("SERVER KEYS")]); + lines.extend(key_lines(theme, app.server_keys.as_ref())); + frame.render_widget( + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .block(block(theme, ACTIONS[app.action].1).padding(Padding::horizontal(1))), + info_area, + ); +} + fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if app.filtering { @@ -1646,6 +1941,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("Calls", CALLS_KEYS), ("While filtering calls", FILTER_KEYS), ("Models", MODELS_KEYS), + ("Settings", SETTINGS_KEYS), ] { if !lines.is_empty() { lines.push(Line::default()); @@ -1683,12 +1979,79 @@ impl Drop for PasteMode { } } +/// This function returns $VISUAL, then $EDITOR, then the platform default. +fn editor() -> String { + ["VISUAL", "EDITOR"] + .into_iter() + .filter_map(|name| std::env::var(name).ok()) + .find(|value| !value.trim().is_empty()) + .unwrap_or_else(|| if cfg!(windows) { "notepad" } else { "vi" }.into()) +} + +/// This function runs the editor on an absolute path. The caller restores normal terminal input +/// first. The path stays one literal argument, even if its name starts with '-'. +fn edit_file(path: &Path) -> Result<(), String> { + let editor = editor(); + let parts = shlex::split(&editor).ok_or("The editor command has an unmatched quote.")?; + let (program, arguments) = parts + .split_first() + .ok_or("Set VISUAL or EDITOR to an editor command.")?; + let path = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|error| error.to_string())? + .join(path) + }; + let status = std::process::Command::new(program) + .args(arguments) + .arg(path) + .status() + .map_err(|error| format!("Could not start the editor {program}: {error}"))?; + if !status.success() { + return Err(format!("The editor {program} exited with {status}.")); + } + Ok(()) +} + +/// This function gives the terminal to the editor and takes it back afterward. The outer error +/// means the terminal could not be switched; the inner result comes from the editor. +fn edit_in_foreground( + terminal: &mut DefaultTerminal, + path: &Path, +) -> std::io::Result> { + use ratatui::crossterm::{ + execute, + terminal::{EnterAlternateScreen, enable_raw_mode}, + }; + execute!(std::io::stdout(), event::DisableBracketedPaste)?; + terminal.show_cursor()?; + ratatui::try_restore()?; + let edited = edit_file(path); + enable_raw_mode()?; + execute!( + std::io::stdout(), + EnterAlternateScreen, + event::EnableBracketedPaste + )?; + terminal.clear()?; + Ok(edited) +} + pub fn run(mut controller: Controller) -> Result<(), String> { - let snapshot = controller.snapshot().unwrap_or_else(|error| Snapshot { - errors: vec![error], - ..Snapshot::default() - }); + let (snapshot, tab) = match controller.snapshot() { + Ok(snapshot) => (snapshot, Tab::Overview), + // Settings shows the error and the actions that can fix the app settings. + Err(error) => ( + Snapshot { + errors: vec![error], + ..Snapshot::default() + }, + Tab::Settings, + ), + }; let mut app = App::new(Theme::new(color_enabled()), snapshot); + app.tab = tab; ratatui::crossterm::execute!(std::io::stdout(), event::EnableBracketedPaste) .map_err(|e| e.to_string())?; let _paste_mode = PasteMode; @@ -1696,9 +2059,19 @@ pub fn run(mut controller: Controller) -> Result<(), String> { let (done, results) = mpsc::channel::(); // One worker owns the controller while the terminal continues to draw and read input. let worker = thread::spawn(move || { - while let Ok(Job::Refresh) = inbox.recv() { - let finished = Done::Finished { - snapshot: controller.snapshot().map(Box::new), + while let Ok(job) = inbox.recv() { + let finished = match job { + Job::Refresh => Done::Finished { + reply: None, + snapshot: controller.snapshot().map(Box::new), + }, + Job::Run(action) => match controller.editor_target(&action) { + Ok(Some(path)) => Done::Editor(path), + target => Done::Finished { + reply: Some(target.and_then(|_| controller.dispatch(action))), + snapshot: controller.snapshot().map(Box::new), + }, + }, }; if done.send(finished).is_err() { break; @@ -1707,8 +2080,29 @@ pub fn run(mut controller: Controller) -> Result<(), String> { }); let result = ratatui::run(|terminal| -> Result<(), String> { loop { - while let Ok(Done::Finished { snapshot }) = results.try_recv() { - app.finish(snapshot); + while let Ok(finished) = results.try_recv() { + match finished { + Done::Finished { reply, snapshot } => { + if let Some(request) = app.finish(reply, snapshot) + && !app.quit + { + app.start(&jobs, request)?; + } + } + // A quit request made while the editor target loaded skips the editor. + Done::Editor(_) if app.quit => app.busy = None, + Done::Editor(path) => { + app.busy = None; + match edit_in_foreground(terminal, &path).map_err(|e| e.to_string())? { + Ok(()) => app.notify( + format!("Closed the editor for {}.", path.display()), + false, + ), + Err(error) => app.notify(error, true), + } + app.start(&jobs, Request::refresh())?; + } + } } if app.quit && app.busy.is_none() { return Ok(()); From b7057360ad46413d51036db4a65405fdbf088a97 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:57 -0700 Subject: [PATCH 07/28] feat(desktop): add Settings forms, source update review, and action coverage test Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 113 ++++ .../switchyard-desktop/src/install/windows.rs | 11 + crates/switchyard-desktop/src/server.rs | 66 +++ crates/switchyard-desktop/src/tui.rs | 510 +++++++++++++++++- 4 files changed, 691 insertions(+), 9 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index c0d5ac1cd6..390e1b7a45 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -15,6 +15,11 @@ use std::{collections::BTreeMap, path::PathBuf}; #[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Action { + Restart {}, + PreviewUpdate {}, + Update { + preview_token: Option, + }, OpenLogs {}, OpenUpdateLog {}, OpenConfig {}, @@ -22,6 +27,20 @@ pub enum Action { OpenSettings {}, CheckConfig {}, ServerKeys {}, + SaveServerKey { + name: String, + key: String, + }, + ImportServerKey { + name: String, + }, + RemoveServerKey { + name: String, + }, + SavePreferences { + baseline_model: String, + refresh_seconds: u64, + }, } /// Snapshot holds everything that the apps show. The terminal app destructures it without @@ -122,6 +141,7 @@ pub struct Controller { config: Config, reader: rollup::Reader, health_probe: crate::health::Probe, + update_review: Option<(String, Value)>, } impl Controller { pub fn new(config: Config, settings: PathBuf) -> Self { @@ -130,12 +150,25 @@ impl Controller { settings, reader: rollup::Reader::default(), health_probe: crate::health::Probe::default(), + update_review: None, } } fn reload(&mut self) -> Result<(), String> { self.config = Config::load(&self.settings)?; Ok(()) } + // Key actions accept only names that the server config declares. + fn declared_key(&self, name: &str) -> Result<(), String> { + if switchyard_desktop::install::credentials::variables(&self.config.config_file)? + .contains(name) + { + Ok(()) + } else { + Err(format!( + "The server config does not name {name} with api_key_env. Refresh and try again." + )) + } + } pub fn snapshot(&mut self) -> Result { self.reload()?; let mut errors = Vec::new(); @@ -283,13 +316,83 @@ impl Controller { server::command("open", &[&logs_dir()?.display().to_string()])?; Ok(Reply::message("Opened Switchyard logs.".into())) } + Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), Action::ServerKeys {} => Ok(Reply { message: String::new(), data: json!({"keys": server::server_keys(&self.config)?, "store": switchyard_desktop::install::credentials::STORE}), }), + Action::SaveServerKey { name, key } => { + self.declared_key(&name)?; + server::save_server_key(&name, &key)?; + Ok(Reply::message(format!( + "Saved the key for {name}. Restart the server to use it." + ))) + } + Action::ImportServerKey { name } => { + self.declared_key(&name)?; + server::import_server_key(&name)?; + Ok(Reply::message(format!( + "Saved {name} from your login shell. Restart the server to use it." + ))) + } + Action::RemoveServerKey { name } => { + self.declared_key(&name)?; + server::remove_server_key(&name)?; + Ok(Reply::message(format!( + "Removed the saved key for {name}. The server will not start until it has one." + ))) + } + Action::SavePreferences { + baseline_model, + refresh_seconds, + } => { + crate::config::save_preferences(&self.settings, &baseline_model, refresh_seconds)?; + self.reload()?; + Ok(Reply::message(format!( + "Changed {}.", + self.settings.display() + ))) + } Action::OpenSettings {} => unreachable!(), + Action::PreviewUpdate {} => { + let mut data = switchyard_desktop::install::preview_update(&install_metadata( + &self.settings, + )?)?; + let token = uuid::Uuid::now_v7().to_string(); + let mut reviewed = data.clone(); + reviewed + .as_object_mut() + .ok_or("Invalid update preview.")? + .remove("status"); + self.update_review = Some((token.clone(), reviewed)); + data["preview_token"] = json!(token); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Update { preview_token } => { + let (token, reviewed) = self + .update_review + .as_ref() + .ok_or("Review the source update first.")?; + let mut current = switchyard_desktop::install::preview_update(&install_metadata( + &self.settings, + )?)?; + current + .as_object_mut() + .ok_or("Invalid update preview.")? + .remove("status"); + if preview_token.as_deref() != Some(token.as_str()) || ¤t != reviewed { + return Err( + "The source checkout or installation changed. Review the update again." + .into(), + ); + } + update(&self.settings).map(Reply::message) + } Action::OpenUpdateLog {} => { let log = self.update_log()?; server::command("open", &[&log.display().to_string()])?; @@ -331,3 +434,13 @@ fn install_metadata(settings: &std::path::Path) -> Result { }; Ok(metadata) } +fn update(settings: &std::path::Path) -> Result { + let metadata = install_metadata(settings)?; + let log = switchyard_desktop::install::start_update(&metadata)?; + Ok(format!( + "Started rebuilding and reinstalling from the saved source checkout. \ + Progress and errors are recorded in {}. \ + The app restarts after a successful install.", + log.display() + )) +} diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index f2ed8a78b8..9a6429026a 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -473,6 +473,17 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> { Ok(()) } +pub fn restart(s: &Settings) -> Result<(), String> { + stop_task(s, false)?; + stop_images(&[s.binary("switchyard-server")])?; + // The terminal app calls this, so the scheduler command must not print onto its screen. + if task_succeeds(s, false, "/Run")? { + Ok(()) + } else { + Err(format!("schtasks /Run /TN {} failed.", task_name(s, false))) + } +} + pub fn open(value: &str) -> Result<(), String> { use windows_sys::Win32::UI::{Shell::ShellExecuteW, WindowsAndMessaging::SW_SHOWNORMAL}; let value = wide(std::ffi::OsStr::new(value)); diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index 7b57e62e1f..509360e399 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -6,10 +6,15 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; use switchyard_desktop::install::credentials::KeyStatus; use crate::config::Config; +use crate::health::{Probe, ServerStatus}; + +/// RESTART_WAIT limits how long the app waits for the restarted server to answer `/health`. +const RESTART_WAIT: Duration = Duration::from_secs(10); /// This function finds the installed helper, which is the only program that reads or writes /// server keys. @@ -67,6 +72,18 @@ pub fn server_keys(settings: &Config) -> Result, String> { serde_json::from_str(&output).map_err(|error| error.to_string()) } +pub fn save_server_key(name: &str, key: &str) -> Result<(), String> { + key_command(&["set", name], Some(key)).map(drop) +} + +pub fn import_server_key(name: &str) -> Result<(), String> { + key_command(&["import", name], None).map(drop) +} + +pub fn remove_server_key(name: &str) -> Result<(), String> { + key_command(&["remove", name], None).map(drop) +} + /// This function finds the installed server from the app's installation metadata. fn server_binary() -> Result { let executable = std::env::current_exe().map_err(|error| { @@ -121,6 +138,55 @@ pub fn check_config(settings: &Config) -> Result<(), String> { check(&server_binary()?, &settings.config_file) } +pub fn restart_checked(settings: &Config) -> Result { + check_config(settings)?; + restart(&settings.launchd_label)?; + if !wait_for_health(&settings.server_url) { + return Err(format!( + "Switchyard did not answer {}/health within {} seconds. Open the server log and retry.", + settings.server_url.trim_end_matches('/'), + RESTART_WAIT.as_secs() + )); + } + Ok("Restarted Switchyard.".into()) +} + +/// This function restarts the server's LaunchAgent, systemd user service, or Windows scheduled task. +pub fn restart(launchd_label: &str) -> Result<(), String> { + #[cfg(windows)] + { + let _ = launchd_label; + let exe = std::env::current_exe().map_err(|e| e.to_string())?; + let settings = switchyard_desktop::install::Settings::load( + &switchyard_desktop::install::bundle_metadata(&exe), + )?; + switchyard_desktop::install::windows::restart(&settings) + } + #[cfg(not(windows))] + { + if cfg!(target_os = "linux") { + return command("systemctl", &["--user", "restart", "switchyard.service"]).map(|_| ()); + } + let uid = command("id", &["-u"])?; + let target = format!("gui/{}/{launchd_label}", uid.trim()); + command("launchctl", &["kickstart", "-k", &target]).map(|_| ()) + } +} + +pub(crate) fn wait_for_health(server_url: &str) -> bool { + let deadline = Instant::now() + RESTART_WAIT; + let probe = Probe::default(); + loop { + if probe.probe(server_url) == ServerStatus::Running { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(250)); + } +} + /// This function runs a command, returning its stdout or a message naming what failed. pub fn command(program: &str, args: &[&str]) -> Result { if program == "open" { diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 699fb13411..ad2e2de413 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -103,7 +103,18 @@ const FILTER_KEYS: &[Key] = &[ ("esc", "clear", "Clear the filter."), ("ctrl-u", "erase", "Erase the filter text."), ]; +const FORM_KEYS: &[Key] = &[ + ("⏎", "submit", ""), + ("tab", "next field", ""), + ("esc", "cancel", ""), +]; +const UPDATE_KEYS: &[Key] = &[ + ("⏎", "start", ""), + ("↑↓", "scroll", ""), + ("esc", "cancel", ""), +]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; +const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; /// The Settings page lists one entry per controller action. A new controller action breaks the /// build until `App::start_action` runs it, and `every_controller_action_has_a_settings_key` @@ -121,12 +132,25 @@ const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ "Runs the installed server's config check. The running server keeps running.", ActionKind::CheckConfig, ), + ( + 'b', + "Restart server", + "Checks the server config, then restarts the Switchyard service after you confirm. \ + Requests in progress fail.", + ActionKind::Restart, + ), ( 'a', "Open app settings", "Opens this app's settings file in $VISUAL or $EDITOR.", ActionKind::OpenSettings, ), + ( + 'e', + "Change preferences", + "Changes the baseline model that prices savings and the refresh interval.", + ActionKind::SavePreferences, + ), ( 'p', "Open model prices", @@ -140,6 +164,31 @@ const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ "Lists the API key variables that the server config names and whether each is saved.", ActionKind::ServerKeys, ), + ( + 's', + "Save a server key", + "Saves an API key for a variable that the server config names. Restart the server to \ + use it.", + ActionKind::SaveServerKey, + ), + ( + 'i', + "Import a server key", + "Saves an API key from your login shell's environment. Restart the server to use it.", + ActionKind::ImportServerKey, + ), + ( + 'x', + "Remove a server key", + "Removes a saved API key. The server does not start until the key is saved again.", + ActionKind::RemoveServerKey, + ), + ( + 'u', + "Update from source", + "Shows what the saved source checkout would install. Type UPDATE to start it.", + ActionKind::PreviewUpdate, + ), ( 'l', "Open update log", @@ -278,6 +327,139 @@ impl Period { } } +#[derive(Clone, Copy, Debug, PartialEq)] +enum FormKind { + Preferences, + SaveKey, + ImportKey, + RemoveKey, + Update, +} +/// Form holds the text fields for an action that needs input. +struct Form { + kind: FormKind, + title: &'static str, + fields: Vec<(&'static str, String)>, + index: usize, + /// This field counts characters, not bytes, from the start of the selected field. + cursor: usize, + /// This field shows the last validation or controller error below the fields. + error: String, +} +impl Form { + fn new(kind: FormKind, title: &'static str, fields: Vec<(&'static str, String)>) -> Self { + let cursor = fields.first().map_or(0, |field| field.1.chars().count()); + Self { + kind, + title, + fields, + index: 0, + cursor, + error: String::new(), + } + } + fn byte_cursor(&self) -> usize { + let text = &self.fields[self.index].1; + text.char_indices() + .nth(self.cursor) + .map_or(text.len(), |(index, _)| index) + } + fn insert(&mut self, text: &str) { + let text = text.replace(['\r', '\n'], ""); + let index = self.byte_cursor(); + self.fields[self.index].1.insert_str(index, &text); + self.cursor += text.chars().count(); + } + fn edit(&mut self, key: KeyEvent) { + let length = self.fields[self.index].1.chars().count(); + match key.code { + KeyCode::Tab | KeyCode::BackTab => { + let count = self.fields.len(); + self.index = if key.code == KeyCode::Tab { + (self.index + 1) % count + } else { + (self.index + count - 1) % count + }; + self.cursor = self.fields[self.index].1.chars().count(); + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(length), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = length, + KeyCode::Backspace if self.cursor > 0 => { + self.cursor -= 1; + let index = self.byte_cursor(); + self.fields[self.index].1.remove(index); + } + KeyCode::Delete if self.cursor < length => { + let index = self.byte_cursor(); + self.fields[self.index].1.remove(index); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.fields[self.index].1.clear(); + self.cursor = 0; + } + KeyCode::Char(c) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + self.insert(&c.to_string()); + } + _ => {} + } + } + fn value(&self, index: usize) -> String { + self.fields[index].1.trim().to_string() + } + /// This function builds the controller action that the form submits. An update needs the + /// token from the preview that the user read. + fn action(&self, preview: Option<&Value>) -> Result { + Ok(match self.kind { + FormKind::Preferences => Action::SavePreferences { + baseline_model: self.value(0), + refresh_seconds: self.value(1).parse().map_err(|_| { + "Enter the refresh interval as a whole number of seconds.".to_string() + })?, + }, + FormKind::SaveKey => { + if self.value(1).is_empty() { + return Err("Enter a key first.".into()); + } + Action::SaveServerKey { + name: self.value(0), + key: self.value(1), + } + } + FormKind::ImportKey => Action::ImportServerKey { + name: self.value(0), + }, + FormKind::RemoveKey => Action::RemoveServerKey { + name: self.value(0), + }, + FormKind::Update => { + if self.value(0) != "UPDATE" { + return Err("Type UPDATE to start the reviewed source update.".into()); + } + Action::Update { + preview_token: preview + .and_then(|preview| preview["preview_token"].as_str()) + .map(str::to_owned), + } + } + }) + } + fn label(&self) -> &'static str { + match self.kind { + FormKind::Preferences => "Saving the preferences", + FormKind::SaveKey => "Saving the server key", + FormKind::ImportKey => "Importing the server key", + FormKind::RemoveKey => "Removing the server key", + FormKind::Update => "Starting the update", + } + } +} + enum Popup { Help { scroll: u16, @@ -285,6 +467,13 @@ enum Popup { Detail { scroll: u16, }, + Restart, + Form(Form), + Update { + preview: Value, + scroll: u16, + confirm: Form, + }, Message { title: &'static str, text: String, @@ -293,11 +482,19 @@ enum Popup { }, } impl Popup { - fn scroll_mut(&mut self) -> &mut u16 { + fn scroll_mut(&mut self) -> Option<&mut u16> { match self { - Popup::Help { scroll } | Popup::Detail { scroll } | Popup::Message { scroll, .. } => { - scroll - } + Popup::Help { scroll } + | Popup::Detail { scroll } + | Popup::Update { scroll, .. } + | Popup::Message { scroll, .. } => Some(scroll), + Popup::Restart | Popup::Form(_) => None, + } + } + fn form_mut(&mut self) -> Option<&mut Form> { + match self { + Popup::Form(form) | Popup::Update { confirm: form, .. } => Some(form), + _ => None, } } } @@ -479,12 +676,45 @@ impl App { self.server_keys = None; self.notify(error, true); } - (_, Err(error)) => self.notify(error, true), + // A form stays open with the error, so the user can fix the input and submit again. + (_, Err(error)) => match self.popup.as_mut().and_then(Popup::form_mut) { + Some(form) => form.error = error, + None => self.notify(error, true), + }, + (Some(ActionKind::PreviewUpdate), Ok(reply)) => { + self.popup = Some(Popup::Update { + preview: reply.data, + scroll: 0, + confirm: Form::new( + FormKind::Update, + "Update from source?", + vec![("Type UPDATE to start", String::new())], + ), + }); + } (Some(ActionKind::ServerKeys), Ok(reply)) => { self.server_keys = Some(reply.data); self.notify("Checked the saved server keys.".into(), false); } + ( + Some( + ActionKind::SaveServerKey + | ActionKind::ImportServerKey + | ActionKind::RemoveServerKey, + ), + Ok(reply), + ) => { + self.popup = None; + self.notify(reply.message, false); + return Some(Request::run( + Action::ServerKeys {}, + "Checking the server keys", + )); + } (_, Ok(reply)) => { + if matches!(self.popup, Some(Popup::Form(_) | Popup::Update { .. })) { + self.popup = None; + } let details: Vec<&str> = reply.data["details"] .as_array() .map(|items| items.iter().filter_map(Value::as_str).collect()) @@ -537,6 +767,9 @@ impl App { fn keys(&self) -> &'static [Key] { match (&self.popup, self.filtering, self.tab) { + (Some(Popup::Restart), _, _) => CONFIRM_KEYS, + (Some(Popup::Update { .. }), _, _) => UPDATE_KEYS, + (Some(Popup::Form(_)), _, _) => FORM_KEYS, (Some(_), _, _) => POPUP_KEYS, (None, true, _) => FILTER_KEYS, (None, false, Tab::Overview) => &[], @@ -547,7 +780,9 @@ impl App { } fn paste(&mut self, text: &str) { - if self.filtering && self.popup.is_none() { + if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { + form.insert(text); + } else if self.filtering && self.popup.is_none() { self.filter.extend(text.chars().filter(|c| !c.is_control())); self.selected = 0; self.rebuild_calls(); @@ -676,17 +911,67 @@ impl App { /// This function starts a Settings action. The match has no wildcard arm, so adding a /// controller action breaks the build until the terminal app can run it. fn start_action(&mut self, kind: ActionKind) -> Option { + let form = |kind, title, fields| Some(Popup::Form(Form::new(kind, title, fields))); let request = |action, label| Some(Request::run(action, label)); match kind { ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") } + ActionKind::Restart => { + self.popup = Some(Popup::Restart); + None + } ActionKind::OpenSettings => { request(Action::OpenSettings {}, "Opening the app settings") } + ActionKind::SavePreferences => { + let (baseline, refresh) = self + .data + .settings + .as_ref() + .map(|s| (s.baseline_model.clone(), s.refresh_seconds.to_string())) + .unwrap_or_default(); + self.popup = form( + FormKind::Preferences, + "Change preferences", + vec![ + ("Baseline model ID", baseline), + ("Refresh interval in seconds", refresh), + ], + ); + None + } ActionKind::OpenPrices => request(Action::OpenPrices {}, "Opening the model prices"), ActionKind::ServerKeys => request(Action::ServerKeys {}, "Checking the server keys"), + ActionKind::SaveServerKey => { + self.popup = form( + FormKind::SaveKey, + "Save a server key", + vec![("Key name", String::new()), ("Key", String::new())], + ); + None + } + ActionKind::ImportServerKey => { + self.popup = form( + FormKind::ImportKey, + "Import a server key from your login shell", + vec![("Key name", String::new())], + ); + None + } + ActionKind::RemoveServerKey => { + self.popup = form( + FormKind::RemoveKey, + "Remove a saved server key", + vec![("Key name", String::new())], + ); + None + } + // The update window submits Update after the user reads this preview. + ActionKind::PreviewUpdate | ActionKind::Update => { + request(Action::PreviewUpdate {}, "Reading the source checkout") + } ActionKind::OpenUpdateLog => { request(Action::OpenUpdateLog {}, "Opening the update log") } @@ -696,12 +981,63 @@ impl App { fn popup_key(&mut self, key: KeyEvent) -> Option { let popup = self.popup.as_mut()?; + match popup { + Popup::Restart => { + match key.code { + KeyCode::Char('y' | 'Y') => { + self.popup = None; + return Some(Request::run(Action::Restart {}, "Restarting the server")); + } + KeyCode::Char('n' | 'N' | 'q') | KeyCode::Esc | KeyCode::Enter => { + self.popup = None; + } + _ => {} + } + return None; + } + Popup::Form(form) | Popup::Update { confirm: form, .. } => { + match key.code { + KeyCode::Esc => self.popup = None, + KeyCode::Enter => { + let preview = match &self.popup { + Some(Popup::Update { preview, .. }) => Some(preview), + _ => None, + }; + let form = match &self.popup { + Some(Popup::Form(form) | Popup::Update { confirm: form, .. }) => form, + _ => return None, + }; + match form.action(preview) { + Ok(action) => return Some(Request::run(action, form.label())), + Err(error) => { + if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { + form.error = error; + } + } + } + } + // Only the update window scrolls; its text field ignores the arrow keys. + KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { + if let Some(scroll) = popup.scroll_mut() + && let Some(next) = + step(key.code, usize::from(*scroll), usize::from(u16::MAX)) + { + *scroll = next as u16; + } + } + _ => form.edit(key), + } + return None; + } + _ => {} + } match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q' | '?') => self.popup = None, // Drawing clamps the scroll to the text, so the last line here has no fixed value. code => { - let scroll = popup.scroll_mut(); - if let Some(next) = step(code, usize::from(*scroll), usize::from(u16::MAX)) { + if let Some(scroll) = popup.scroll_mut() + && let Some(next) = step(code, usize::from(*scroll), usize::from(u16::MAX)) + { *scroll = next as u16; } } @@ -1849,6 +2185,44 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat Line::from(spans) } +/// This function draws a form's fields with a cursor in the selected one, then its error. +fn form_lines(theme: Theme, form: &Form) -> Vec> { + let mut lines: Vec = form + .fields + .iter() + .enumerate() + .map(|(index, (label, value))| { + // The key field shows one bullet per character, so the key never appears on screen. + let mut value = if form.kind == FormKind::SaveKey && index == 1 { + "•".repeat(value.chars().count()) + } else { + value.clone() + }; + let selected = index == form.index; + if selected { + let cursor = value + .char_indices() + .nth(form.cursor) + .map_or(value.len(), |(byte, _)| byte); + value.insert(cursor, '▏'); + } + Line::from(vec![ + Span::styled( + if selected { "› " } else { " " }, + if selected { theme.key } else { theme.muted }, + ), + Span::styled(format!("{label}: "), theme.muted), + Span::raw(value), + ]) + }) + .collect(); + if !form.error.is_empty() { + lines.push(Line::default()); + lines.push(Line::styled(form.error.clone(), theme.error)); + } + lines +} + fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { let theme = app.theme; let Some(popup) = &app.popup else { @@ -1883,6 +2257,53 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { 72, close, ), + Popup::Restart => ( + "Restart the server?", + vec![ + Line::from(format!( + "This checks the server config, then restarts the Switchyard service at {}.", + app.data.metrics.server_url + )), + Line::from("Requests in progress fail."), + ], + Vec::new(), + theme.warn, + 64, + footer(&[("y", "Restart"), ("n", "Cancel")]), + ), + Popup::Form(form) => { + let mut lines = Vec::new(); + if matches!( + form.kind, + FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey + ) { + lines.push(Line::styled("Server keys", theme.muted)); + lines.extend(key_lines(theme, app.server_keys.as_ref())); + lines.push(Line::default()); + } + ( + form.title, + lines, + form_lines(theme, form), + theme.key, + 72, + footer(&[("⏎", "Submit"), ("esc", "Cancel")]), + ) + } + Popup::Update { + preview, confirm, .. + } => { + let mut pinned = vec![Line::default()]; + pinned.extend(form_lines(theme, confirm)); + ( + confirm.title, + update_lines(theme, preview), + pinned, + theme.warn, + 84, + footer(&[("⏎", "Start"), ("esc", "Cancel")]), + ) + } Popup::Message { title, text, error, .. } => ( @@ -1918,7 +2339,7 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { .areas(outer.inner(rect)); // Scrolling stops at the last line, so scrolling back up responds at once. let last = rows.saturating_sub(text_area.height); - let scroll = match app.popup.as_mut().map(Popup::scroll_mut) { + let scroll = match app.popup.as_mut().and_then(Popup::scroll_mut) { Some(scroll) => { *scroll = (*scroll).min(last); *scroll @@ -1931,6 +2352,45 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(pinned, pinned_area); } +fn update_lines(theme: Theme, preview: &Value) -> Vec> { + // `git status --short` starts some lines with a space, so only trailing space is removed. + let field = |name: &str| { + preview[name] + .as_str() + .unwrap_or_default() + .trim_end() + .to_string() + }; + let row = |name: &str| labeled(theme, name, 10, field(name.to_lowercase().as_str())); + let mut lines = vec![ + row("Source"), + row("Branch"), + row("Revision"), + Line::default(), + Line::styled("Uncommitted changes", theme.muted), + ]; + let changes = field("changes"); + if changes.is_empty() { + lines.push(Line::from("None")); + } else { + lines.extend(changes.lines().map(|line| Line::from(line.to_string()))); + } + lines.push(Line::default()); + lines.push(Line::styled("Plan", theme.muted)); + lines.extend( + field("plan") + .lines() + .map(|line| Line::from(line.to_string())), + ); + if let Some(message) = preview["status"]["message"].as_str() { + lines.push(Line::default()); + lines.push(Line::styled(format!("Last update: {message}"), theme.warn)); + } + lines.push(Line::default()); + lines.push(Line::from("The app restarts after a successful install.")); + lines +} + /// The help window indents wrapped descriptions under their first line, so keys stay in one column. fn help_lines(theme: Theme, width: usize) -> Vec> { const KEY_WIDTH: usize = 8; @@ -2151,6 +2611,7 @@ mod tests { use crate::controller::Source; use ratatui::{Terminal, backend::TestBackend}; use serde_json::json; + use strum::IntoEnumIterator; fn screen(app: &mut App, tab: Tab) -> String { app.tab = tab; @@ -2218,4 +2679,35 @@ mod tests { assert!(overview.contains("Spent unknown")); assert!(overview.contains("no recorded cost")); } + + #[test] + // Every controller action needs a Settings key, or the desktop app can do something + // that the terminal app cannot. + fn every_controller_action_has_a_settings_key() { + let mut keys: Vec = ACTIONS.iter().map(|&(key, ..)| key).collect(); + // The Settings page reads its letters before the global keys and the j, g, and G moves. + let reserved = ['q', 'r', '?', '1', '2', '3', '4', 'j', 'g', 'G']; + keys.extend(reserved); + keys.sort_unstable(); + keys.dedup(); + assert_eq!( + keys.len(), + ACTIONS.len() + reserved.len(), + "Settings keys must be unique and must not use a global or movement key" + ); + + // Update is the only action without its own key: the u key reviews the update + // first, and the review window submits Update. + let missing: Vec = ActionKind::iter() + .filter(|&kind| kind != ActionKind::Update && !ACTIONS.iter().any(|&(.., k)| k == kind)) + .collect(); + assert!(missing.is_empty(), "No Settings key runs {missing:?}"); + let mut review = Form::new(FormKind::Update, "", vec![("", "UPDATE".into())]); + assert!(matches!( + review.action(Some(&json!({"preview_token": "t"}))), + Ok(Action::Update { preview_token: Some(token) }) if token == "t" + )); + review.fields[0].1 = "update".into(); + assert!(review.action(None).is_err()); + } } From a2231349edd51a224b573843e0e080101f909855 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 08/28] chore(desktop): organize terminal actions by page and add review popups Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/tui.rs | 775 ++++++++++++++++----------- 1 file changed, 476 insertions(+), 299 deletions(-) diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index ad2e2de413..42b922ca41 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -1,7 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app shows usage, recent calls, and server actions from controller snapshots. +//! The terminal app shows usage, recent calls, and routes from controller snapshots, and it +//! runs server and route actions. //! //! The UI thread owns every view setting. One worker thread owns the controller and runs //! refreshes and actions, so moving around and filtering never wait for the log or a service. @@ -28,6 +29,7 @@ use ratatui::{ }; use serde_json::Value; use std::{ + borrow::Cow, collections::BTreeMap, path::{Path, PathBuf}, sync::mpsc, @@ -108,100 +110,120 @@ const FORM_KEYS: &[Key] = &[ ("tab", "next field", ""), ("esc", "cancel", ""), ]; -const UPDATE_KEYS: &[Key] = &[ - ("⏎", "start", ""), +const REVIEW_KEYS: &[Key] = &[ + ("⏎", "confirm", ""), ("↑↓", "scroll", ""), ("esc", "cancel", ""), ]; const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; -/// The Settings page lists one entry per controller action. A new controller action breaks the -/// build until `App::start_action` runs it, and `every_controller_action_has_a_settings_key` -/// fails until it has an entry here. -const ACTIONS: &[(char, &str, &str, ActionKind)] = &[ +/// Each row is a page, a key, a short label, a help sentence, and the actions that the key's +/// flow can run. `App::start_action` receives the first action. Settings lists its rows and +/// runs the selected one with Enter; other pages run a row's flow on the selected item when its +/// key is pressed. A new controller action breaks the build until `App::start_action` runs it, +/// and `every_controller_action_has_a_key` fails until a row's flow includes it. +type ActionRow = (Tab, char, &'static str, &'static str, &'static [ActionKind]); +const ACTIONS: &[ActionRow] = &[ ( + Tab::Settings, 'o', "Open server config", "Opens the server config in $VISUAL or $EDITOR. Restart the server after you save it.", - ActionKind::OpenConfig, + &[ActionKind::OpenConfig], ), ( + Tab::Settings, 'c', "Check server config", "Runs the installed server's config check. The running server keeps running.", - ActionKind::CheckConfig, + &[ActionKind::CheckConfig], ), ( + Tab::Settings, 'b', "Restart server", "Checks the server config, then restarts the Switchyard service after you confirm. \ Requests in progress fail.", - ActionKind::Restart, + &[ActionKind::Restart], ), ( + Tab::Settings, 'a', "Open app settings", "Opens this app's settings file in $VISUAL or $EDITOR.", - ActionKind::OpenSettings, + &[ActionKind::OpenSettings], ), ( + Tab::Settings, 'e', "Change preferences", "Changes the baseline model that prices savings and the refresh interval.", - ActionKind::SavePreferences, + &[ActionKind::SavePreferences], ), ( + Tab::Settings, 'p', "Open model prices", "Opens the custom price file in $VISUAL or $EDITOR. With bundled prices, this explains \ how to set a custom file.", - ActionKind::OpenPrices, + &[ActionKind::OpenPrices], ), ( + Tab::Settings, 'k', "Check server keys", "Lists the API key variables that the server config names and whether each is saved.", - ActionKind::ServerKeys, + &[ActionKind::ServerKeys], ), ( + Tab::Settings, 's', "Save a server key", "Saves an API key for a variable that the server config names. Restart the server to \ use it.", - ActionKind::SaveServerKey, + &[ActionKind::SaveServerKey], ), ( + Tab::Settings, 'i', "Import a server key", "Saves an API key from your login shell's environment. Restart the server to use it.", - ActionKind::ImportServerKey, + &[ActionKind::ImportServerKey], ), ( + Tab::Settings, 'x', "Remove a server key", "Removes a saved API key. The server does not start until the key is saved again.", - ActionKind::RemoveServerKey, + &[ActionKind::RemoveServerKey], ), ( + Tab::Settings, 'u', "Update from source", "Shows what the saved source checkout would install. Type UPDATE to start it.", - ActionKind::PreviewUpdate, + &[ActionKind::PreviewUpdate, ActionKind::Update], ), ( + Tab::Settings, 'l', "Open update log", "Opens the log of the last source update in $VISUAL or $EDITOR.", - ActionKind::OpenUpdateLog, + &[ActionKind::OpenUpdateLog], ), ( + Tab::Settings, 'v', "Open server logs", "Opens the Switchyard server logs folder in $VISUAL or $EDITOR.", - ActionKind::OpenLogs, + &[ActionKind::OpenLogs], ), ]; +/// This function lists one page's rows in `ACTIONS` order. +fn rows(tab: Tab) -> impl Iterator + Clone { + ACTIONS.iter().filter(move |row| row.0 == tab) +} /// Column is a table heading, its width, and whether its values are right-aligned. type Column = (&'static str, Constraint, bool); @@ -277,6 +299,7 @@ impl Tab { Tab::Overview => "Overview", Tab::Calls => "Calls", Tab::Models => "Models", + Tab::Settings => "Settings", } } @@ -327,19 +350,23 @@ impl Period { } } -#[derive(Clone, Copy, Debug, PartialEq)] enum FormKind { Preferences, SaveKey, ImportKey, RemoveKey, - Update, + /// The token comes from the update preview that the user read. + Update(Option), +} +/// Submit is what a form sends: a controller action. +enum Submit { + Run(Action), } /// Form holds the text fields for an action that needs input. struct Form { kind: FormKind, - title: &'static str, - fields: Vec<(&'static str, String)>, + title: Cow<'static, str>, + fields: Vec<(Cow<'static, str>, String)>, index: usize, /// This field counts characters, not bytes, from the start of the selected field. cursor: usize, @@ -347,11 +374,19 @@ struct Form { error: String, } impl Form { - fn new(kind: FormKind, title: &'static str, fields: Vec<(&'static str, String)>) -> Self { + fn new>>( + kind: FormKind, + title: impl Into>, + fields: Vec<(L, String)>, + ) -> Self { + let fields: Vec<_> = fields + .into_iter() + .map(|(label, value)| (label.into(), value)) + .collect(); let cursor = fields.first().map_or(0, |field| field.1.chars().count()); Self { kind, - title, + title: title.into(), fields, index: 0, cursor, @@ -412,10 +447,16 @@ impl Form { fn value(&self, index: usize) -> String { self.fields[index].1.trim().to_string() } - /// This function builds the controller action that the form submits. An update needs the - /// token from the preview that the user read. - fn action(&self, preview: Option<&Value>) -> Result { - Ok(match self.kind { + /// This function builds what the form submits. + fn action(&self) -> Result { + let confirm = |word: &str, error: &str| { + if self.value(0) == word { + Ok(()) + } else { + Err(error.to_string()) + } + }; + Ok(Submit::Run(match &self.kind { FormKind::Preferences => Action::SavePreferences { baseline_model: self.value(0), refresh_seconds: self.value(1).parse().map_err(|_| { @@ -437,25 +478,21 @@ impl Form { FormKind::RemoveKey => Action::RemoveServerKey { name: self.value(0), }, - FormKind::Update => { - if self.value(0) != "UPDATE" { - return Err("Type UPDATE to start the reviewed source update.".into()); - } + FormKind::Update(preview_token) => { + confirm("UPDATE", "Type UPDATE to start the reviewed source update.")?; Action::Update { - preview_token: preview - .and_then(|preview| preview["preview_token"].as_str()) - .map(str::to_owned), + preview_token: preview_token.clone(), } } - }) + })) } fn label(&self) -> &'static str { - match self.kind { + match &self.kind { FormKind::Preferences => "Saving the preferences", FormKind::SaveKey => "Saving the server key", FormKind::ImportKey => "Importing the server key", FormKind::RemoveKey => "Removing the server key", - FormKind::Update => "Starting the update", + FormKind::Update(_) => "Starting the update", } } } @@ -469,15 +506,18 @@ enum Popup { }, Restart, Form(Form), - Update { - preview: Value, + /// The user reads the preview lines, then types the confirmation word into `confirm`. + Review { + lines: Vec>, + /// This field names what Enter does, such as Save or Delete. + verb: &'static str, scroll: u16, confirm: Form, }, Message { title: &'static str, text: String, - error: bool, + tone: Tone, scroll: u16, }, } @@ -486,17 +526,32 @@ impl Popup { match self { Popup::Help { scroll } | Popup::Detail { scroll } - | Popup::Update { scroll, .. } + | Popup::Review { scroll, .. } | Popup::Message { scroll, .. } => Some(scroll), Popup::Restart | Popup::Form(_) => None, } } fn form_mut(&mut self) -> Option<&mut Form> { match self { - Popup::Form(form) | Popup::Update { confirm: form, .. } => Some(form), + Popup::Form(form) | Popup::Review { confirm: form, .. } => Some(form), _ => None, } } + /// This function opens a review window whose one field takes the confirmation word. + fn review( + lines: Vec>, + verb: &'static str, + kind: FormKind, + title: impl Into>, + field: &'static str, + ) -> Self { + Popup::Review { + lines, + verb, + scroll: 0, + confirm: Form::new(kind, title, vec![(field, String::new())]), + } + } } enum Job { @@ -532,15 +587,35 @@ impl Request { } struct Busy { label: &'static str, - /// This field names the running action, so its reply can update the right view. - kind: Option, + /// This field holds the running action, so its reply can update the right view and a + /// preview's review window can submit exactly what the controller reviewed. + action: Option, started: Instant, } struct Toast { text: String, - error: bool, + tone: Tone, shown: Instant, } +/// Tone tells the user how an action went. A warning means the action did only part of its +/// work, such as saving a route when the server could not restart. Warnings and errors stay in +/// the status bar until Esc, because the user must act on them. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Tone { + Done, + Warning, + Error, +} +impl Tone { + /// The controller marks a reply as a warning when the action did only part of its work. + fn of(reply: &Reply) -> Self { + if reply.data["warning"] == true { + Tone::Warning + } else { + Tone::Done + } + } +} struct App { theme: Theme, @@ -555,6 +630,7 @@ struct App { selected: usize, offset: usize, action: usize, + /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -582,6 +658,7 @@ impl App { selected: 0, offset: 0, action: 0, + server_keys: None, popup: None, toast: None, @@ -598,16 +675,18 @@ impl App { fn apply_snapshot(&mut self, snapshot: Result) { let data = match snapshot { Ok(data) => data, - Err(error) => return self.notify(error, true), + Err(error) => return self.notify(error, Tone::Error), }; // A refresh keeps the same call selected unless the newest call was selected. let anchor = self .selected_entry() .filter(|_| self.selected > 0) .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); + self.data = data; self.refreshed = Some(Instant::now()); self.rebuild_calls(); + if let Some((ts, model, turn)) = anchor && let Some(position) = self.visible.iter().position(|&index| { let entry = &self.data.entries[index]; @@ -643,21 +722,25 @@ impl App { } /// Short messages appear in the status bar; long ones open a window so nothing is cut off. - fn notify(&mut self, text: String, error: bool) { + fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { return; } if text.contains('\n') || text.chars().count() + 8 > usize::from(self.width) * 2 / 3 { self.popup = Some(Popup::Message { - title: if error { "Error" } else { "Done" }, + title: match tone { + Tone::Done => "Done", + Tone::Warning => "Attention", + Tone::Error => "Error", + }, text, - error, + tone, scroll: 0, }); } else { self.toast = Some(Toast { text, - error, + tone, shown: Instant::now(), }); } @@ -669,32 +752,31 @@ impl App { reply: Option>, snapshot: Result, String>, ) -> Option { - let kind = self.busy.take().and_then(|busy| busy.kind); + let action = self.busy.take().and_then(|busy| busy.action); self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); - match (kind, reply?) { + match (action.as_ref().map(ActionKind::from), reply?) { (Some(ActionKind::ServerKeys), Err(error)) => { self.server_keys = None; - self.notify(error, true); + self.notify(error, Tone::Error); } // A form stays open with the error, so the user can fix the input and submit again. (_, Err(error)) => match self.popup.as_mut().and_then(Popup::form_mut) { Some(form) => form.error = error, - None => self.notify(error, true), + None => self.notify(error, Tone::Error), }, (Some(ActionKind::PreviewUpdate), Ok(reply)) => { - self.popup = Some(Popup::Update { - preview: reply.data, - scroll: 0, - confirm: Form::new( - FormKind::Update, - "Update from source?", - vec![("Type UPDATE to start", String::new())], - ), - }); + let token = reply.data["preview_token"].as_str().map(str::to_owned); + self.popup = Some(Popup::review( + update_lines(self.theme, &reply.data), + "Start", + FormKind::Update(token), + "Update from source?", + "Type UPDATE to start", + )); } (Some(ActionKind::ServerKeys), Ok(reply)) => { self.server_keys = Some(reply.data); - self.notify("Checked the saved server keys.".into(), false); + self.notify("Checked the saved server keys.".into(), Tone::Done); } ( Some( @@ -705,27 +787,32 @@ impl App { Ok(reply), ) => { self.popup = None; - self.notify(reply.message, false); + self.notify(reply.message, Tone::Done); return Some(Request::run( Action::ServerKeys {}, "Checking the server keys", )); } (_, Ok(reply)) => { - if matches!(self.popup, Some(Popup::Form(_) | Popup::Update { .. })) { + if matches!(self.popup, Some(Popup::Form(_) | Popup::Review { .. })) { self.popup = None; } + let tone = Tone::of(&reply); let details: Vec<&str> = reply.data["details"] .as_array() .map(|items| items.iter().filter_map(Value::as_str).collect()) .unwrap_or_default(); if details.is_empty() { - self.notify(reply.message, false); + self.notify(reply.message, tone); } else { self.popup = Some(Popup::Message { - title: "Details", + title: if tone == Tone::Warning { + "Attention" + } else { + "Details" + }, text: format!("{}\n\n{}", reply.message, details.join("\n")), - error: false, + tone, scroll: 0, }); } @@ -746,20 +833,20 @@ impl App { fn start(&mut self, jobs: &mpsc::Sender, request: Request) -> Result<(), String> { if let Some(busy) = &self.busy { let text = format!("Wait for \"{}\" to finish.", busy.label); - self.notify(text, true); + self.notify(text, Tone::Error); return Ok(()); } - let kind = match &request.job { + let action = match &request.job { Job::Refresh => { self.last_refresh = Instant::now(); None } - Job::Run(action) => Some(ActionKind::from(action)), + Job::Run(action) => Some(action.clone()), }; jobs.send(request.job).map_err(|error| error.to_string())?; self.busy = Some(Busy { label: request.label, - kind, + action, started: Instant::now(), }); Ok(()) @@ -768,24 +855,36 @@ impl App { fn keys(&self) -> &'static [Key] { match (&self.popup, self.filtering, self.tab) { (Some(Popup::Restart), _, _) => CONFIRM_KEYS, - (Some(Popup::Update { .. }), _, _) => UPDATE_KEYS, + (Some(Popup::Review { .. }), _, _) => REVIEW_KEYS, + (Some(Popup::Form(_)), _, _) => FORM_KEYS, (Some(_), _, _) => POPUP_KEYS, (None, true, _) => FILTER_KEYS, (None, false, Tab::Overview) => &[], (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, + (None, false, Tab::Settings) => SETTINGS_KEYS, } } + /// The Calls and Routes pages each keep their own filter; `/` edits the current page's. + fn filter_mut(&mut self) -> &mut String { + &mut self.filter + } + + fn refilter(&mut self) { + self.selected = 0; + self.rebuild_calls(); + } + fn paste(&mut self, text: &str) { if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { form.insert(text); } else if self.filtering && self.popup.is_none() { - self.filter.extend(text.chars().filter(|c| !c.is_control())); - self.selected = 0; - self.rebuild_calls(); + self.filter_mut() + .extend(text.chars().filter(|c| !c.is_control())); + self.refilter(); } } @@ -814,22 +913,40 @@ impl App { KeyCode::Char('r') | KeyCode::F(5) => return Some(Request::refresh()), KeyCode::Esc => { self.toast = None; - if self.tab == Tab::Calls { - self.filter.clear(); - self.session = None; - self.rebuild_calls(); + match self.tab { + Tab::Calls => { + self.filter.clear(); + self.session = None; + self.rebuild_calls(); + } + + Tab::Overview | Tab::Models | Tab::Settings => {} + } + } + code => { + // Action letters come before the page's own keys, so `k` runs an action on + // Routes and Settings instead of moving up. + if let KeyCode::Char(letter) = code + && let Some((index, &(_, key, _, _, flow))) = + rows(self.tab).enumerate().find(|(_, row)| row.1 == letter) + { + if self.tab == Tab::Settings { + self.action = index; + } + return self.run_row(flow[0], key); + } + match self.tab { + Tab::Overview => {} + Tab::Calls => self.calls_key(code), + Tab::Models => match code { + KeyCode::Char('p' | ']') => self.period = self.period.next(), + KeyCode::Char('[') => self.period = self.period.previous(), + _ => {} + }, + + Tab::Settings => return self.settings_key(code), } } - code => match self.tab { - Tab::Overview => {} - Tab::Calls => self.calls_key(code), - Tab::Models => match code { - KeyCode::Char('p' | ']') => self.period = self.period.next(), - KeyCode::Char('[') => self.period = self.period.previous(), - _ => {} - }, - Tab::Settings => return self.settings_key(code), - }, } None } @@ -867,52 +984,57 @@ impl App { KeyCode::Enter => self.filtering = false, KeyCode::Esc => { self.filtering = false; - self.filter.clear(); + self.filter_mut().clear(); } KeyCode::Backspace => { - self.filter.pop(); + self.filter_mut().pop(); } - KeyCode::Char('u') if control => self.filter.clear(), + KeyCode::Char('u') if control => self.filter_mut().clear(), KeyCode::Char('w') if control => { - let kept = self - .filter - .trim_end() - .rfind(' ') - .map_or(0, |index| index + 1); - self.filter.truncate(kept); + let filter = self.filter_mut(); + let kept = filter.trim_end().rfind(' ').map_or(0, |index| index + 1); + filter.truncate(kept); } KeyCode::Char(c) if !control && !key.modifiers.contains(KeyModifiers::ALT) => { - self.filter.push(c); + self.filter_mut().push(c); } _ => return, } - self.selected = 0; - self.rebuild_calls(); + self.refilter(); } - /// Action letters come first, so `k` checks the server keys here instead of moving up. fn settings_key(&mut self, code: KeyCode) -> Option { - if let KeyCode::Char(letter) = code - && let Some(index) = ACTIONS.iter().position(|action| action.0 == letter) - { - self.action = index; - return self.start_action(ACTIONS[index].3); - } - if let Some(action) = step(code, self.action, ACTIONS.len() - 1) { + if let Some(action) = step(code, self.action, rows(Tab::Settings).count() - 1) { self.action = action; return None; } - if code == KeyCode::Enter { - return self.start_action(ACTIONS[self.action].3); + if code == KeyCode::Enter + && let Some(&(_, key, _, _, flow)) = rows(Tab::Settings).nth(self.action) + { + return self.run_row(flow[0], key); } None } - /// This function starts a Settings action. The match has no wildcard arm, so adding a - /// controller action breaks the build until the terminal app can run it. - fn start_action(&mut self, kind: ActionKind) -> Option { - let form = |kind, title, fields| Some(Popup::Form(Form::new(kind, title, fields))); - let request = |action, label| Some(Request::run(action, label)); + fn run_row(&mut self, kind: ActionKind, key: char) -> Option { + self.start_action(kind, key).unwrap_or_else(|error| { + self.notify(error, Tone::Error); + None + }) + } + + fn open(&mut self, form: Form) -> Result, String> { + self.popup = Some(Popup::Form(form)); + Ok(None) + } + + /// This function starts the flow of an `ACTIONS` row. The match has no wildcard arm, so + /// adding a controller action breaks the build until the terminal app can run it. Only the + /// arms of actions that several keys start read `key`, and they return an error for a key + /// that they do not know, so a new row must also be added to its arm. + fn start_action(&mut self, kind: ActionKind, _key: char) -> Result, String> { + let request = |action, label| Ok(Some(Request::run(action, label))); + match kind { ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { @@ -920,7 +1042,7 @@ impl App { } ActionKind::Restart => { self.popup = Some(Popup::Restart); - None + Ok(None) } ActionKind::OpenSettings => { request(Action::OpenSettings {}, "Opening the app settings") @@ -932,43 +1054,33 @@ impl App { .as_ref() .map(|s| (s.baseline_model.clone(), s.refresh_seconds.to_string())) .unwrap_or_default(); - self.popup = form( + self.open(Form::new( FormKind::Preferences, "Change preferences", vec![ ("Baseline model ID", baseline), ("Refresh interval in seconds", refresh), ], - ); - None + )) } ActionKind::OpenPrices => request(Action::OpenPrices {}, "Opening the model prices"), ActionKind::ServerKeys => request(Action::ServerKeys {}, "Checking the server keys"), - ActionKind::SaveServerKey => { - self.popup = form( - FormKind::SaveKey, - "Save a server key", - vec![("Key name", String::new()), ("Key", String::new())], - ); - None - } - ActionKind::ImportServerKey => { - self.popup = form( - FormKind::ImportKey, - "Import a server key from your login shell", - vec![("Key name", String::new())], - ); - None - } - ActionKind::RemoveServerKey => { - self.popup = form( - FormKind::RemoveKey, - "Remove a saved server key", - vec![("Key name", String::new())], - ); - None - } - // The update window submits Update after the user reads this preview. + ActionKind::SaveServerKey => self.open(Form::new( + FormKind::SaveKey, + "Save a server key", + vec![("Key name", String::new()), ("Key", String::new())], + )), + ActionKind::ImportServerKey => self.open(Form::new( + FormKind::ImportKey, + "Import a server key from your login shell", + vec![("Key name", String::new())], + )), + ActionKind::RemoveServerKey => self.open(Form::new( + FormKind::RemoveKey, + "Remove a saved server key", + vec![("Key name", String::new())], + )), + // The review window submits Update after the user reads this preview. ActionKind::PreviewUpdate | ActionKind::Update => { request(Action::PreviewUpdate {}, "Reading the source checkout") } @@ -995,28 +1107,14 @@ impl App { } return None; } - Popup::Form(form) | Popup::Update { confirm: form, .. } => { + Popup::Form(form) | Popup::Review { confirm: form, .. } => { match key.code { KeyCode::Esc => self.popup = None, - KeyCode::Enter => { - let preview = match &self.popup { - Some(Popup::Update { preview, .. }) => Some(preview), - _ => None, - }; - let form = match &self.popup { - Some(Popup::Form(form) | Popup::Update { confirm: form, .. }) => form, - _ => return None, - }; - match form.action(preview) { - Ok(action) => return Some(Request::run(action, form.label())), - Err(error) => { - if let Some(form) = self.popup.as_mut().and_then(Popup::form_mut) { - form.error = error; - } - } - } - } - // Only the update window scrolls; its text field ignores the arrow keys. + KeyCode::Enter => match form.action() { + Ok(Submit::Run(action)) => return Some(Request::run(action, form.label())), + Err(error) => form.error = error, + }, + // Only the review window scrolls; text fields ignore these keys. KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { if let Some(scroll) = popup.scroll_mut() && let Some(next) = @@ -1059,8 +1157,7 @@ fn step(code: KeyCode, current: usize, last: usize) -> Option { }) } -/// Every word must appear in one of the call's IDs. A word with a capital letter matches case -/// exactly; other words ignore ASCII case, which covers model, route, and session IDs. +/// Every word must appear in one of the call's IDs. fn matches_filter(entry: &Entry, filter: &str) -> bool { let fields = [ entry.model.as_str(), @@ -1068,6 +1165,12 @@ fn matches_filter(entry: &Entry, filter: &str) -> bool { entry.session_id.as_deref().unwrap_or_default(), entry.turn_id.as_deref().unwrap_or_default(), ]; + matches_words(&fields, filter) +} + +/// Every word must appear in one of the fields. A word with a capital letter matches case +/// exactly; other words ignore ASCII case, which covers model, route, and session IDs. +fn matches_words(fields: &[&str], filter: &str) -> bool { filter.split_whitespace().all(|word| { let exact = word.chars().any(char::is_uppercase); fields.iter().any(|field| { @@ -1234,21 +1337,22 @@ fn draw(frame: &mut Frame, app: &mut App) { // This destructuring has no `..`, so adding a snapshot field breaks the build until a page // shows it. Each comment names the place that shows the field. let Snapshot { - update_status: _, // draw_settings - settings: _, // draw_settings - data_state: _, // every page checks availability; draw_overview shows health - summary: _, // --print shows these rows; draw_top_models builds the same notes - metrics: _, // draw_header and the Overview cards - today: _, // the Overview "Today" card title - analytics: _, // draw_top_models and draw_models - estimates: _, // the Overview cards and draw_models - baseline_model: _, // draw_top_models; draw_settings shows the saved setting - activity: _, // draw_activity - sessions: _, // the calls table title and the `s` key - entries: _, // draw_call_table and the call details - limited: _, // calls_block and draw_activity - skipped: _, // calls_block - errors: _, // draw_errors and draw_settings + update_status: _, // draw_settings + settings: _, // draw_settings + data_state: _, // every page checks availability; draw_overview shows health + + summary: _, // --print shows these rows; draw_top_models builds the same notes + metrics: _, // draw_header and the Overview cards + today: _, // the Overview "Today" card title + analytics: _, // draw_top_models and draw_models + estimates: _, // the Overview cards and draw_models + baseline_model: _, // draw_top_models; draw_settings shows the saved setting + activity: _, // draw_activity + sessions: _, // the calls table title and the `s` key + entries: _, // draw_call_table and the call details + limited: _, // calls_block and draw_activity + skipped: _, // calls_block + errors: _, // draw_errors and draw_settings refresh_seconds: _, // App::refresh_due } = &app.data; let area = frame.area(); @@ -1268,6 +1372,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Overview => draw_overview(frame, app, body), Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), + Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -1316,11 +1421,16 @@ fn draw_header(frame: &mut Frame, app: &App, area: Rect) { None => " · no data yet ".into(), }; let url = format!(" · {}", app.data.metrics.server_url); - // Narrow terminals drop the URL first, then the update time, so the tabs stay readable. + // Narrow terminals drop the URL first, then the update time, then shorten the server state. + // The narrowest ones drop the server state too, which the Overview page still shows, so the + // tabs stay readable. let room = usize::from(area.width).saturating_sub(tabs_width); let base = status.iter().map(Span::width).sum::(); if base > room { status[1] = Span::raw(if running { "up" } else { "down" }); + if status.iter().map(Span::width).sum::() > room { + status.clear(); + } } if !app.data.metrics.server_url.is_empty() && base + url.chars().count() + updated.chars().count() <= room @@ -1701,15 +1811,46 @@ fn calls_block(app: &App) -> Block<'static> { outer } +/// This function returns the first shown row of a list, moved only as far as needed to keep +/// the selection inside the `height` rows that fit. +fn keep_visible(selected: usize, offset: usize, len: usize, height: usize) -> usize { + let offset = if selected < offset { + selected + } else if selected >= offset + height { + selected + 1 - height + } else { + offset + }; + offset.min(len.saturating_sub(height)) +} + +/// This function draws a scrollbar over a bordered table's data rows, below the top border +/// and the header row, when the rows do not fit. +fn draw_scrollbar(frame: &mut Frame, theme: Theme, area: Rect, len: usize, position: usize) { + if len <= usize::from(area.height.saturating_sub(3)) { + return; + } + let mut scroll = ScrollbarState::new(len).position(position); + frame.render_stateful_widget( + Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(None) + .end_symbol(None) + .thumb_style(theme.key) + .track_symbol(Some("│")) + .track_style(theme.border), + Rect { + y: area.y + 2, + height: area.height.saturating_sub(3), + ..area + }, + &mut scroll, + ); +} + fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { // The table shows this many calls; the borders and the header row take three rows. let height = usize::from(area.height.saturating_sub(3)).max(1); - if app.selected < app.offset { - app.offset = app.selected; - } else if app.selected >= app.offset + height { - app.offset = app.selected + 1 - height; - } - app.offset = app.offset.min(app.visible.len().saturating_sub(height)); + app.offset = keep_visible(app.selected, app.offset, app.visible.len(), height); let app = &*app; let theme = app.theme; let outer = calls_block(app); @@ -1777,24 +1918,7 @@ fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { area, &mut state, ); - if app.visible.len() > height { - let mut scroll = ScrollbarState::new(app.visible.len()).position(app.selected); - frame.render_stateful_widget( - Scrollbar::new(ScrollbarOrientation::VerticalRight) - .begin_symbol(None) - .end_symbol(None) - .thumb_style(theme.key) - .track_symbol(Some("│")) - .track_style(theme.border), - // The scrollbar covers the data rows, below the top border and the header row. - Rect { - y: area.y + 2, - height: area.height.saturating_sub(3), - ..area - }, - &mut scroll, - ); - } + draw_scrollbar(frame, theme, area, app.visible.len(), app.selected); } fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { @@ -2020,9 +2144,8 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let [list_area, info_area] = Layout::horizontal([Constraint::Length(30), Constraint::Fill(1)]).areas(area); - let items: Vec = ACTIONS - .iter() - .map(|(letter, name, ..)| { + let items: Vec = rows(Tab::Settings) + .map(|(_, letter, name, ..)| { ListItem::new(Line::from(vec![ Span::styled(format!("{letter} "), theme.key), Span::raw(*name), @@ -2040,7 +2163,8 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { ); let row = |name: &str, value: String| labeled(theme, name, 15, value); let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); - let mut lines = vec![Line::from(ACTIONS[app.action].2), Line::default()]; + let (_, _, name, about, _) = rows(Tab::Settings).nth(app.action).unwrap_or(&ACTIONS[0]); + let mut lines = vec![Line::from(*about), Line::default()]; if !app.data.errors.is_empty() { lines.extend( app.data @@ -2105,7 +2229,7 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget( Paragraph::new(lines) .wrap(Wrap { trim: false }) - .block(block(theme, ACTIONS[app.action].1).padding(Padding::horizontal(1))), + .block(block(theme, name).padding(Padding::horizontal(1))), info_area, ); } @@ -2113,15 +2237,13 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if app.filtering { + let (filter, matches) = (&app.filter, app.visible.len()); Line::from(vec![ Span::styled(" / ", theme.key), - Span::raw(app.filter.clone()), + Span::raw(filter.clone()), Span::styled("▏", theme.key), Span::styled( - format!( - " {} ", - plural(app.visible.len() as u64, "match", "matches") - ), + format!(" {} ", plural(matches as u64, "match", "matches")), theme.muted, ), ]) @@ -2136,18 +2258,24 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { } Line::from(spans) } else if let Some(toast) = &app.toast { - let style = if toast.error { theme.error } else { theme.ok }; + let (mark, style) = match toast.tone { + Tone::Done => (" ✓ ", theme.ok), + Tone::Warning => (" ! ", theme.warn), + Tone::Error => (" ✗ ", theme.error), + }; Line::from(vec![ - Span::styled(if toast.error { " ✗ " } else { " ✓ " }, style), + Span::styled(mark, style), Span::styled(toast.text.clone(), style), ]) } else { Line::default() }; let room = usize::from(area.width).saturating_sub(left.width() + 2); - // Page keys do nothing while a window is open or the filter takes the keyboard. + // Page keys do nothing while a window is open or the filter takes the keyboard. The + // Settings page lists its action letters beside its rows, so only other pages put them here. let global = app.popup.is_none() && !app.filtering; - let hints = fit_keys(theme, app.keys(), global, room); + let actions = rows(app.tab).filter(|_| global && app.tab != Tab::Settings); + let hints = fit_keys(theme, actions, app.keys(), global, room); let [left_area, right_area] = Layout::horizontal([Constraint::Length(left.width() as u16), Constraint::Fill(1)]) .areas(area); @@ -2155,10 +2283,17 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(Paragraph::new(hints.right_aligned()), right_area); } -/// This function lists the page's keys, then the global keys, and stops before a hint would be -/// cut. It keeps room for the help key, so the status bar always says how to find every key. -fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'static> { - let hint = |key: &'static str, label: &str| { +/// This function lists the page's action keys, its other keys, then the global keys, and stops +/// before a hint would be cut. It keeps room for the help key, so the status bar always says how +/// to find every key. +fn fit_keys( + theme: Theme, + actions: impl Iterator, + keys: &[Key], + global: bool, + room: usize, +) -> Line<'static> { + let hint = |key: Cow<'static, str>, label: &str| { [ Span::styled(key, theme.key), Span::styled(format!(" {label} "), theme.muted), @@ -2168,8 +2303,15 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat let mut spans = Vec::new(); let mut used = if global { HELP_WIDTH } else { 0 }; let globals = if global { GLOBAL_KEYS } else { &[] }; - for (key, label, _) in keys.iter().chain(globals) { - if *key == "?" { + let hints = actions + .map(|&(_, key, label, ..)| (Cow::Owned(key.to_string()), label)) + .chain( + keys.iter() + .chain(globals) + .map(|&(key, label, _)| (Cow::Borrowed(key), label)), + ); + for (key, label) in hints { + if key == "?" { continue; } let width = key.chars().count() + label.chars().count() + 3; @@ -2180,7 +2322,7 @@ fn fit_keys(theme: Theme, keys: &[Key], global: bool, room: usize) -> Line<'stat spans.extend(hint(key, label)); } if global && room >= HELP_WIDTH { - spans.extend(hint("?", "help")); + spans.extend(hint(Cow::Borrowed("?"), "help")); } Line::from(spans) } @@ -2193,7 +2335,8 @@ fn form_lines(theme: Theme, form: &Form) -> Vec> { .enumerate() .map(|(index, (label, value))| { // The key field shows one bullet per character, so the key never appears on screen. - let mut value = if form.kind == FormKind::SaveKey && index == 1 { + let masked = matches!(form.kind, FormKind::SaveKey); + let mut value = if masked && index == 1 { "•".repeat(value.chars().count()) } else { value.clone() @@ -2271,48 +2414,44 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { 64, footer(&[("y", "Restart"), ("n", "Cancel")]), ), - Popup::Form(form) => { - let mut lines = Vec::new(); - if matches!( - form.kind, - FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey - ) { - lines.push(Line::styled("Server keys", theme.muted)); - lines.extend(key_lines(theme, app.server_keys.as_ref())); - lines.push(Line::default()); - } - ( - form.title, - lines, - form_lines(theme, form), - theme.key, - 72, - footer(&[("⏎", "Submit"), ("esc", "Cancel")]), - ) - } - Popup::Update { - preview, confirm, .. + Popup::Form(form) => ( + &*form.title, + form_notes(app, form), + form_lines(theme, form), + theme.key, + 72, + footer(&[("⏎", "Submit"), ("esc", "Cancel")]), + ), + Popup::Review { + lines, + verb, + confirm, + .. } => { let mut pinned = vec![Line::default()]; pinned.extend(form_lines(theme, confirm)); ( - confirm.title, - update_lines(theme, preview), + &*confirm.title, + lines.clone(), pinned, theme.warn, 84, - footer(&[("⏎", "Start"), ("esc", "Cancel")]), + footer(&[("⏎", *verb), ("esc", "Cancel")]), ) } Popup::Message { - title, text, error, .. + title, text, tone, .. } => ( *title, text.lines() .map(|line| Line::from(line.to_string())) .collect(), Vec::new(), - if *error { theme.error } else { theme.key }, + match tone { + Tone::Done => theme.key, + Tone::Warning => theme.warn, + Tone::Error => theme.error, + }, 76, close, ), @@ -2352,6 +2491,22 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { frame.render_widget(pinned, pinned_area); } +/// This function returns the text above a form's fields: what the fields accept and the lists +/// that ↑ and ↓ cycle through. +fn form_notes(app: &App, form: &Form) -> Vec> { + let theme = app.theme; + match &form.kind { + FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey => { + let mut lines = vec![Line::styled("Server keys", theme.muted)]; + lines.extend(key_lines(theme, app.server_keys.as_ref())); + lines.push(Line::default()); + lines + } + + FormKind::Preferences | FormKind::Update(_) => Vec::new(), + } +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { @@ -2392,36 +2547,42 @@ fn update_lines(theme: Theme, preview: &Value) -> Vec> { } /// The help window indents wrapped descriptions under their first line, so keys stay in one column. +/// Each page lists its other keys first, then its action keys from `ACTIONS`. fn help_lines(theme: Theme, width: usize) -> Vec> { const KEY_WIDTH: usize = 8; let room = width.saturating_sub(KEY_WIDTH).max(20); let mut lines = Vec::new(); - for (section, keys) in [ - ("Every page", GLOBAL_KEYS), - ("Calls", CALLS_KEYS), - ("While filtering calls", FILTER_KEYS), - ("Models", MODELS_KEYS), - ("Settings", SETTINGS_KEYS), + for (section, keys, page) in [ + ("Every page", GLOBAL_KEYS, None), + ("Calls", CALLS_KEYS, Some(Tab::Calls)), + ("While filtering calls or routes", FILTER_KEYS, None), + ("Models", MODELS_KEYS, Some(Tab::Models)), + ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { lines.push(Line::default()); } lines.push(Line::styled(format!(" {section} "), theme.selected.bold())); - for (key, _, about) in keys { - let mut rows = Vec::new(); + let actions = page.into_iter().flat_map(rows); + let entries = keys + .iter() + .map(|&(key, _, about)| (Cow::Borrowed(key), about)) + .chain(actions.map(|&(_, key, _, about, _)| (Cow::Owned(key.to_string()), about))); + for (key, about) in entries { + let mut wrapped = Vec::new(); let mut row = String::new(); for word in about.split_whitespace() { if !row.is_empty() && row.chars().count() + 1 + word.chars().count() > room { - rows.push(std::mem::take(&mut row)); + wrapped.push(std::mem::take(&mut row)); } if !row.is_empty() { row.push(' '); } row.push_str(word); } - rows.push(row); - for (index, row) in rows.into_iter().enumerate() { - let key = if index == 0 { *key } else { "" }; + wrapped.push(row); + for (index, row) in wrapped.into_iter().enumerate() { + let key = if index == 0 { &*key } else { "" }; lines.push(Line::from(vec![ Span::styled(format!("{key: Result<(), String> { match edit_in_foreground(terminal, &path).map_err(|e| e.to_string())? { Ok(()) => app.notify( format!("Closed the editor for {}.", path.display()), - false, + Tone::Done, ), - Err(error) => app.notify(error, true), + Err(error) => app.notify(error, Tone::Error), } app.start(&jobs, Request::refresh())?; } @@ -2570,7 +2731,8 @@ pub fn run(mut controller: Controller) -> Result<(), String> { if app .toast .as_ref() - .is_some_and(|toast| !toast.error && toast.shown.elapsed() > TOAST_TIME) + .filter(|toast| toast.tone == Tone::Done) + .is_some_and(|toast| toast.shown.elapsed() > TOAST_TIME) { app.toast = None; } @@ -2608,6 +2770,7 @@ pub fn run(mut controller: Controller) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; + use crate::controller::Source; use ratatui::{Terminal, backend::TestBackend}; use serde_json::json; @@ -2681,33 +2844,47 @@ mod tests { } #[test] - // Every controller action needs a Settings key, or the desktop app can do something - // that the terminal app cannot. - fn every_controller_action_has_a_settings_key() { - let mut keys: Vec = ACTIONS.iter().map(|&(key, ..)| key).collect(); - // The Settings page reads its letters before the global keys and the j, g, and G moves. - let reserved = ['q', 'r', '?', '1', '2', '3', '4', 'j', 'g', 'G']; - keys.extend(reserved); - keys.sort_unstable(); - keys.dedup(); - assert_eq!( - keys.len(), - ACTIONS.len() + reserved.len(), - "Settings keys must be unique and must not use a global or movement key" + // Every controller action needs a key, or the desktop app can do something that the + // terminal app cannot. + fn every_controller_action_has_a_key() { + let mut pairs: Vec<(usize, char)> = ACTIONS + .iter() + .map(|&(tab, key, ..)| (tab.index(), key)) + .collect(); + pairs.sort_unstable(); + pairs.dedup(); + assert_eq!(pairs.len(), ACTIONS.len(), "A page uses one key twice"); + + // App::key handles the global keys and the page digits before action letters, and + // j, g, and G must keep moving the selection. Each page's own keys come after its + // action letters, so a row with one of them would hide it. + let global = ['q', 'r', '?', 'j', 'g', 'G']; + let digits: Vec = (1..=TABS.len() as u32) + .filter_map(|digit| char::from_digit(digit, 10)) + .collect(); + let page_keys = |tab| -> &[char] { + match tab { + Tab::Calls => &['/', 's'], + Tab::Models => &['p', '[', ']'], + + Tab::Overview | Tab::Settings => &[], + } + }; + let reserved: Vec<(Tab, char)> = ACTIONS + .iter() + .filter(|&&(tab, key, ..)| { + global.contains(&key) || digits.contains(&key) || page_keys(tab).contains(&key) + }) + .map(|&(tab, key, ..)| (tab, key)) + .collect(); + assert!( + reserved.is_empty(), + "These rows use a reserved key: {reserved:?}" ); - // Update is the only action without its own key: the u key reviews the update - // first, and the review window submits Update. let missing: Vec = ActionKind::iter() - .filter(|&kind| kind != ActionKind::Update && !ACTIONS.iter().any(|&(.., k)| k == kind)) + .filter(|kind| !ACTIONS.iter().any(|&(.., flow)| flow.contains(kind))) .collect(); - assert!(missing.is_empty(), "No Settings key runs {missing:?}"); - let mut review = Form::new(FormKind::Update, "", vec![("", "UPDATE".into())]); - assert!(matches!( - review.action(Some(&json!({"preview_token": "t"}))), - Ok(Action::Update { preview_token: Some(token) }) if token == "t" - )); - review.fields[0].1 = "update".into(); - assert!(review.action(None).is_err()); + assert!(missing.is_empty(), "No key runs {missing:?}"); } } From b779e7b12b344048d72f4b49d6bd09c7725c431c Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 09/28] feat(desktop): parse server routes and add the Routes table to the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 70 +++- crates/switchyard-desktop/src/main.rs | 7 +- .../switchyard-desktop/src/server_config.rs | 355 ++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 248 +++++++++++- 4 files changed, 656 insertions(+), 24 deletions(-) create mode 100644 crates/switchyard-desktop/src/server_config.rs diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 390e1b7a45..ba4b3ab19a 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -6,7 +6,11 @@ //! terminal app matches `ActionKind` without a wildcard arm, so adding an action breaks the //! build until the terminal app can run it. -use crate::{config::Config, history, pricing, rollup, server}; +use crate::{ + config::Config, + history, pricing, rollup, server, + server_config::{Choice, ServerConfig}, +}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::{collections::BTreeMap, path::PathBuf}; @@ -52,6 +56,9 @@ pub struct Snapshot { /// when the app settings cannot be loaded. pub settings: Option, pub data_state: DataState, + + pub routes: Vec, + pub summary: Vec, pub metrics: Metrics, pub today: chrono::NaiveDate, @@ -83,6 +90,19 @@ pub struct AppSettings { pub prices_file: Option, } +/// RouteInfo describes one entry under `[routes]` in the server config. +#[derive(Clone, Default, Serialize)] +pub struct RouteInfo { + /// This field names the route's table in the server config. + pub key: String, + /// This field holds the public model ID that harnesses send. + pub id: String, + pub kind: String, + + pub label: String, + pub choices: Vec, +} + #[derive(Clone, Default, Serialize)] pub struct DataState { pub usage: Source, @@ -137,10 +157,13 @@ impl Reply { } pub struct Controller { + health_probe: crate::health::Probe, settings: PathBuf, config: Config, reader: rollup::Reader, - health_probe: crate::health::Probe, + // The generation ties a draft to the server config file selected by the settings. + generation: u64, + update_review: Option<(String, Value)>, } impl Controller { @@ -148,13 +171,20 @@ impl Controller { Self { config, settings, - reader: rollup::Reader::default(), health_probe: crate::health::Probe::default(), + reader: rollup::Reader::default(), + generation: 0, + update_review: None, } } fn reload(&mut self) -> Result<(), String> { - self.config = Config::load(&self.settings)?; + let config = Config::load(&self.settings)?; + if config.config_file != self.config.config_file { + self.generation += 1; + } + self.config = config; + Ok(()) } // Key actions accept only names that the server config declares. @@ -210,6 +240,35 @@ impl Controller { crate::health::ServerStatus::Stopped }; let summary = crate::summary::build(server_status, &usage, &self.config, &rates); + let mut routes = Vec::new(); + match std::fs::read_to_string(&self.config.config_file) + .map_err(|e| format!("Read {}: {e}", self.config.config_file.display())) + .and_then(|text| { + ServerConfig::parse(&text).map_err(|_| { + "The server config contains invalid TOML. Open it in Settings to fix it." + .to_string() + }) + }) { + Ok(config) => { + for route in config.routes() { + let choices = config.choices(&route.key); + let models = choices + .iter() + .map(|c| format!("{} / {}", c.client, c.model)) + .collect::>() + .join(" → "); + routes.push(RouteInfo { + label: format!("{} · {} · {}", route.id, route.kind, models), + key: route.key, + id: route.id, + kind: route.kind, + choices, + }); + } + } + Err(e) => errors.push(e), + } + let mut history_state = Availability::Available; let history = history::load(&self.config.routing_log).unwrap_or_else(|e| { history_state = Availability::Unavailable; @@ -245,6 +304,8 @@ impl Controller { }, health, }, + + routes, summary, metrics: Metrics { server_url: server_url.clone(), @@ -421,6 +482,7 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } + fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; let bundle = switchyard_desktop::install::bundle_metadata(&executable); diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index d4c199a916..e0ea712bbb 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -4,16 +4,19 @@ //! The terminal app manages a local Switchyard server. //! //! It shows recorded traffic and compares list-price estimates with the -//! configured baseline model. +//! configured baseline model. Its Routes view edits the server config and +//! restarts the server. #![cfg_attr(windows, windows_subsystem = "windows")] use switchyard_desktop::{config, health, history, rollup}; mod controller; + #[cfg(any(target_os = "macos", windows))] mod gui; mod pricing; mod server; +mod server_config; mod summary; mod tui; #[cfg(windows)] @@ -27,7 +30,7 @@ use config::Config; const USAGE: &str = "\ Usage: switchyard-desktop [--tui | --print | --validate-toml] [FILE] -This app shows usage and manages the server. +This app manages routes and usage. FILE The app reads settings from FILE. --tui Run the terminal app. diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs new file mode 100644 index 0000000000..d12aea7510 --- /dev/null +++ b/crates/switchyard-desktop/src/server_config.rs @@ -0,0 +1,355 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module reads the server config and rewrites one route's algorithm and models. +//! +//! Edits go through `toml_edit`, so comments, formatting, and every table the +//! edit does not touch stay as the user wrote them. The server's own +//! `--dry-run` decides whether the result is valid; this module only builds it. + +use toml_edit::{DocumentMut, TableLike}; + +/// Tier identifies the part a model plays in a route. A choice made for one algorithm carries +/// over to the role with the same tier when the user switches algorithms. +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub enum Tier { + Judge, + Capable, + Efficient, +} + +/// Algorithm describes a route `type` the apps can write. +#[derive(Debug)] +pub struct Algorithm { + pub kind: &'static str, + /// This field lists roles in display order. The list is empty for + /// `random`, whose roles are the entries of its `targets` list. + roles: &'static [RoleSpec], +} + +/// RoleSpec describes one role of an algorithm. +#[derive(Debug)] +struct RoleSpec { + label: &'static str, + /// This field explains what the role does. + hint: &'static str, + tier: Tier, + /// This field names the key path to the role's target under the route. + path: &'static [&'static str], +} + +const RANDOM: &str = "random"; + +/// ALGORITHMS lists supported route types in display order. +/// `noop` and the experimental `prefill_router` are left out: one calls no +/// model, and the other needs a checkpoint file rather than models. +pub const ALGORITHMS: &[Algorithm] = &[ + Algorithm { + kind: "passthrough", + roles: &[RoleSpec { + label: "Model", + hint: "This model answers every request.", + tier: Tier::Capable, + path: &["target"], + }], + }, + Algorithm { + kind: RANDOM, + roles: &[], + }, + Algorithm { + kind: "llm_classifier", + roles: &[ + RoleSpec { + label: "Judge", + hint: "The judge reads each request and picks a model.", + tier: Tier::Judge, + path: &["classifier_target"], + }, + RoleSpec { + label: "Capable", + hint: "The capable model answers the hard requests.", + tier: Tier::Capable, + path: &["strong_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model answers the easy requests.", + tier: Tier::Efficient, + path: &["weak_target"], + }, + ], + }, + Algorithm { + kind: "composite", + roles: &[ + RoleSpec { + label: "Judge", + hint: "The judge picks the starting model on each user turn.", + tier: Tier::Judge, + path: &["classifier", "target"], + }, + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["stage", "capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes the easy steps.", + tier: Tier::Efficient, + path: &["stage", "efficient_target"], + }, + ], + }, + Algorithm { + kind: "stage_router", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes the easy steps.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + }, + Algorithm { + kind: "advisor", + roles: &[ + RoleSpec { + label: "Executor", + hint: "The executor answers every turn.", + tier: Tier::Efficient, + path: &["executor_target"], + }, + RoleSpec { + label: "Advisor", + hint: "The advisor reviews the executor's final answers.", + tier: Tier::Capable, + path: &["advisor_target"], + }, + ], + }, + Algorithm { + kind: "plan_execute", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model inspects the code and plans.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model takes over after the first edit.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + }, + Algorithm { + kind: "auto", + roles: &[ + RoleSpec { + label: "Capable", + hint: "The capable model takes the hard steps.", + tier: Tier::Capable, + path: &["capable_target"], + }, + RoleSpec { + label: "Efficient", + hint: "The efficient model starts each request.", + tier: Tier::Efficient, + path: &["efficient_target"], + }, + ], + }, +]; + +/// Route describes one entry under `[routes]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Route { + /// This field names the route's table in this file. Callers never send it. + pub key: String, + /// This field sets the public model ID that callers send. + pub id: String, + pub kind: String, +} + +/// Choice identifies a model and the client that serves it. +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Choice { + pub client: String, + pub model: String, +} + +/// Role describes a model the algorithm needs, such as the judge. +#[derive(Clone, Debug, PartialEq)] +pub struct Role { + pub label: String, + /// This field explains what the role does. + pub hint: &'static str, + pub tier: Option, + slot: Slot, +} + +/// Slot identifies where a route names a role's target. +#[derive(Clone, Debug, PartialEq)] +enum Slot { + /// Key identifies a key path, such as `stage.capable_target`. + Key(&'static [&'static str]), + /// Listed identifies an entry of a random route's `targets` list. + Listed(usize), +} + +impl Slot {} + +impl Algorithm { + /// This function returns the roles of this type. `listed` is the number of entries + /// in a random route's `targets` list; other types ignore it. + fn roles_for(&self, listed: usize) -> Vec { + if self.kind != RANDOM { + return self + .roles + .iter() + .map(|spec| Role { + label: spec.label.to_string(), + hint: spec.hint, + tier: Some(spec.tier), + slot: Slot::Key(spec.path), + }) + .collect(); + } + (0..listed.max(2)) + .map(|index| Role { + label: format!("Model {}", index + 1), + hint: "This model gets a share of the requests.", + tier: [Tier::Capable, Tier::Efficient].get(index).copied(), + slot: Slot::Listed(index), + }) + .collect() + } +} + +/// ServerConfig keeps the parsed config and the original text's layout. +#[derive(Clone, Debug)] +pub struct ServerConfig { + doc: DocumentMut, +} + +impl ServerConfig { + pub fn parse(text: &str) -> Result { + text.parse::() + .map(|doc| Self { doc }) + .map_err(|error| { + format!( + "Could not parse the server config. Fix this TOML error and try again:\n\ + {error}" + ) + }) + } + + pub fn routes(&self) -> Vec { + entries(self.table("routes")) + .map(|(key, route)| Route { + key: key.to_string(), + id: text(route, "id").unwrap_or(key).to_string(), + kind: text(route, "type").unwrap_or_default().to_string(), + }) + .collect() + } + + /// This function returns the algorithm a route uses now, or `None` when the picker + /// cannot show it, such as a custom-mode classifier whose groups are + /// free-form. + pub fn algorithm(&self, route: &str) -> Option<&'static Algorithm> { + let route = self.route(route)?; + if text(route, "mode") == Some("custom") { + return None; + } + let kind = text(route, "type")?; + ALGORITHMS.iter().find(|algorithm| algorithm.kind == kind) + } + + /// This function returns the roles that `algorithm` needs on `route`. A random route + /// keeps its number of targets, and has at least two. + pub fn roles(&self, route: &str, algorithm: &Algorithm) -> Vec { + let listed = self + .route(route) + .filter(|route| algorithm.kind == RANDOM && text(*route, "type") == Some(RANDOM)) + .and_then(|route| { + route + .get("targets")? + .as_array() + .map(|targets| targets.len()) + }) + .unwrap_or(0); + algorithm.roles_for(listed) + } + + /// This function returns the model that each role of the route's current algorithm + /// uses. A role whose target is missing gets an empty choice. + pub fn choices(&self, route: &str) -> Vec { + let Some(algorithm) = self.algorithm(route) else { + return Vec::new(); + }; + self.roles(route, algorithm) + .iter() + .map(|role| { + self.target_at(route, &role.slot) + .and_then(|name| self.target(name)) + .unwrap_or_default() + }) + .collect() + } + + fn table(&self, name: &str) -> Option<&dyn TableLike> { + self.doc.get(name)?.as_table_like() + } + + fn route(&self, route: &str) -> Option<&dyn TableLike> { + self.table("routes")?.get(route)?.as_table_like() + } + + fn target(&self, name: &str) -> Option { + let target = self.table("targets")?.get(name)?.as_table_like()?; + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + } + + fn target_at(&self, route: &str, slot: &Slot) -> Option<&str> { + let route = self.route(route)?; + match slot { + Slot::Key(path) => { + let (last, parents) = path.split_last()?; + let mut table = route; + for key in parents { + table = table.get(key)?.as_table_like()?; + } + text(table, last) + } + Slot::Listed(index) => route.get("targets")?.as_array()?.get(*index)?.as_str(), + } + } +} + +fn entries(table: Option<&dyn TableLike>) -> impl Iterator { + table + .into_iter() + .flat_map(|table| table.iter()) + .filter_map(|(key, item)| Some((key, item.as_table_like()?))) +} + +fn text<'a>(table: &'a dyn TableLike, key: &str) -> Option<&'a str> { + table.get(key)?.as_str() +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 42b922ca41..cc3c51cc11 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -8,7 +8,7 @@ //! refreshes and actions, so moving around and filtering never wait for the log or a service. use crate::controller::{ - Action, ActionKind, AppSettings, Availability, Controller, Reply, Snapshot, + Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, Snapshot, }; use crate::history::Entry; use crate::pricing::Estimate; @@ -51,7 +51,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-4", + "1-5", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -92,6 +92,21 @@ const MODELS_KEYS: &[Key] = &[( "period", "Switch the period: today, the past 7 days, or the whole log. The [ and ] keys also work.", )]; +const ROUTES_KEYS: &[Key] = &[ + ( + "↑↓", + "select", + "Select a route. j, PgUp, PgDn, g, and G also move it.", + ), + ( + "/", + "filter", + "Filter as you type. Every word must appear in the route's public model ID, config \ + name, algorithm, endpoints, or models. A word with a capital letter must match case \ + exactly.", + ), + ("esc", "clear", "Clear the filter."), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -238,6 +253,13 @@ const CALL_COLUMNS: [(Column, bool); 8] = [ (("OUT", Constraint::Length(7), true), false), (("COST", Constraint::Length(9), true), false), ]; +/// The routes table drops the columns marked `true` below 80 columns; the route details still +/// show every model. +const ROUTE_COLUMNS: [(Column, bool); 3] = [ + (("ROUTE", Constraint::Fill(1), false), false), + (("ALGORITHM", Constraint::Length(24), false), false), + (("MODELS", Constraint::Fill(2), false), true), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -290,16 +312,23 @@ enum Tab { Overview, Calls, Models, + Routes, Settings, } -const TABS: [Tab; 4] = [Tab::Overview, Tab::Calls, Tab::Models, Tab::Settings]; +const TABS: [Tab; 5] = [ + Tab::Overview, + Tab::Calls, + Tab::Models, + Tab::Routes, + Tab::Settings, +]; impl Tab { fn name(self) -> &'static str { match self { Tab::Overview => "Overview", Tab::Calls => "Calls", Tab::Models => "Models", - + Tab::Routes => "Routes", Tab::Settings => "Settings", } } @@ -630,7 +659,11 @@ struct App { selected: usize, offset: usize, action: usize, - + route_filter: String, + /// These indexes point into `data.routes`, in config order, after the route filter. + route_visible: Vec, + route_selected: usize, + route_offset: usize, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -658,7 +691,10 @@ impl App { selected: 0, offset: 0, action: 0, - + route_filter: String::new(), + route_visible: Vec::new(), + route_selected: 0, + route_offset: 0, server_keys: None, popup: None, toast: None, @@ -682,11 +718,11 @@ impl App { .selected_entry() .filter(|_| self.selected > 0) .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); - + let route = self.selected_route_key(); self.data = data; self.refreshed = Some(Instant::now()); self.rebuild_calls(); - + self.rebuild_routes(route); if let Some((ts, model, turn)) = anchor && let Some(position) = self.visible.iter().position(|&index| { let entry = &self.data.entries[index]; @@ -721,6 +757,48 @@ impl App { .map(|&index| &self.data.entries[index]) } + /// A route keeps its selection by config name, so a refresh or a rename keeps it selected. + fn selected_route_key(&self) -> Option { + self.selected_route().ok().map(|route| route.key.clone()) + } + + /// This function filters the routes and selects `anchor` if it is still listed. Otherwise + /// the selection keeps its position, so deleting a route selects the next one; `refilter` + /// starts from the first match instead. + fn rebuild_routes(&mut self, anchor: Option) { + let filter = self.route_filter.as_str(); + self.route_visible = self + .data + .routes + .iter() + .enumerate() + .filter(|(_, route)| { + let title = route.kind.as_str(); + matches_words(&[&route.label, &route.key, title], filter) + }) + .map(|(index, _)| index) + .collect(); + let routes = &self.data.routes; + self.route_selected = anchor + .and_then(|key| { + self.route_visible + .iter() + .position(|&index| routes[index].key == key) + }) + .unwrap_or(self.route_selected) + .min(self.route_visible.len().saturating_sub(1)); + } + + fn selected_route(&self) -> Result<&RouteInfo, String> { + match self.route_visible.get(self.route_selected) { + Some(&index) => Ok(&self.data.routes[index]), + None if !self.data.routes.is_empty() => { + Err("No route matches the filter. Clear or change the filter first.".into()) + } + None => Err("Choose a route first.".into()), + } + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { @@ -863,19 +941,29 @@ impl App { (None, false, Tab::Overview) => &[], (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, - + (None, false, Tab::Routes) => ROUTES_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } /// The Calls and Routes pages each keep their own filter; `/` edits the current page's. fn filter_mut(&mut self) -> &mut String { - &mut self.filter + if self.tab == Tab::Routes { + &mut self.route_filter + } else { + &mut self.filter + } } fn refilter(&mut self) { - self.selected = 0; - self.rebuild_calls(); + if self.tab == Tab::Routes { + let anchor = self.selected_route_key(); + self.route_selected = 0; + self.rebuild_routes(anchor); + } else { + self.selected = 0; + self.rebuild_calls(); + } } fn paste(&mut self, text: &str) { @@ -903,7 +991,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='4') => { + KeyCode::Char(digit @ '1'..='5') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -919,7 +1007,10 @@ impl App { self.session = None; self.rebuild_calls(); } - + Tab::Routes => { + self.route_filter.clear(); + self.refilter(); + } Tab::Overview | Tab::Models | Tab::Settings => {} } } @@ -943,7 +1034,7 @@ impl App { KeyCode::Char('[') => self.period = self.period.previous(), _ => {} }, - + Tab::Routes => self.routes_key(code), Tab::Settings => return self.settings_key(code), } } @@ -978,6 +1069,15 @@ impl App { } } + fn routes_key(&mut self, code: KeyCode) { + let last = self.route_visible.len().saturating_sub(1); + if let Some(selected) = step(code, self.route_selected, last) { + self.route_selected = selected; + } else if code == KeyCode::Char('/') { + self.filtering = true; + } + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { @@ -1340,6 +1440,7 @@ fn draw(frame: &mut Frame, app: &mut App) { update_status: _, // draw_settings settings: _, // draw_settings data_state: _, // every page checks availability; draw_overview shows health + routes: _, // draw_routes and the route details summary: _, // --print shows these rows; draw_top_models builds the same notes metrics: _, // draw_header and the Overview cards @@ -1372,7 +1473,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Overview => draw_overview(frame, app, body), Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), - + Tab::Routes => draw_routes(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -2102,6 +2203,113 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(table(theme, &columns, routes), routes_area); } +fn draw_routes(frame: &mut Frame, app: &mut App, area: Rect) { + // A server config that cannot be read lists no routes, so its error explains the empty list. + let errors = if app.data.routes.is_empty() && !app.data.errors.is_empty() { + (app.data.errors.len() as u16 + 2).min(area.height / 3) + } else { + 0 + }; + let [errors_area, list] = + Layout::vertical([Constraint::Length(errors), Constraint::Fill(1)]).areas(area); + if errors > 0 { + draw_errors(frame, app, errors_area); + } + draw_route_table(frame, app, list); +} + +fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { + // The table shows this many routes; the borders and the header row take three rows. + let height = usize::from(area.height.saturating_sub(3)).max(1); + let visible = app.route_visible.len(); + app.route_offset = keep_visible(app.route_selected, app.route_offset, visible, height); + let app = &*app; + let theme = app.theme; + let mut title = vec![ + Span::styled(" Routes ", Modifier::BOLD), + Span::styled(format!("[{}] ", count(visible as u64)), theme.muted), + ]; + if !app.route_filter.is_empty() || app.filtering { + title.push(Span::styled(format!("/{} ", app.route_filter), theme.key)); + } + let mut outer = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(if app.filtering { + theme.key + } else { + theme.border + }) + .title(Line::from(title)); + if visible == 0 { + let text = if app.data.routes.is_empty() { + "No routes in the server config." + } else { + "No routes match the filter. Press Esc to clear it." + }; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer.padding(Padding::top(1))), + area, + ); + return; + } + outer = outer.title_bottom( + Line::styled( + format!(" {} of {} ", app.route_selected + 1, count(visible as u64)), + theme.muted, + ) + .right_aligned(), + ); + let wide = area.width >= 80; + let shown = |column: &(Column, bool)| wide || !column.1; + let rows: Vec = app.route_visible[app.route_offset..] + .iter() + .take(height) + .map(|&index| { + let route = &app.data.routes[index]; + let models = if !route.choices.is_empty() { + let models: Vec = route + .choices + .iter() + .map(|choice| format!("{} / {}", choice.client, choice.model)) + .collect(); + Span::raw(models.join(", ")) + } else { + Span::styled("set in the server config", theme.muted) + }; + let cells = [ + Cell::from(route.id.clone()), + Cell::from(route.kind.clone()), + Cell::from(models), + ]; + Row::new( + cells + .into_iter() + .zip(&ROUTE_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = ROUTE_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = + TableState::default().with_selected(Some(app.route_selected - app.route_offset)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); + draw_scrollbar(frame, theme, area, visible, app.route_selected); +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { @@ -2237,7 +2445,10 @@ fn draw_settings(frame: &mut Frame, app: &App, area: Rect) { fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; let left = if app.filtering { - let (filter, matches) = (&app.filter, app.visible.len()); + let (filter, matches) = match app.tab { + Tab::Routes => (&app.route_filter, app.route_visible.len()), + _ => (&app.filter, app.visible.len()), + }; Line::from(vec![ Span::styled(" / ", theme.key), Span::raw(filter.clone()), @@ -2557,6 +2768,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("Calls", CALLS_KEYS, Some(Tab::Calls)), ("While filtering calls or routes", FILTER_KEYS, None), ("Models", MODELS_KEYS, Some(Tab::Models)), + ("Routes", ROUTES_KEYS, Some(Tab::Routes)), ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { @@ -2866,7 +3078,7 @@ mod tests { match tab { Tab::Calls => &['/', 's'], Tab::Models => &['p', '[', ']'], - + Tab::Routes => &['/'], Tab::Overview | Tab::Settings => &[], } }; From f4bff34db18d4c7b3b3605ecd0c8c06ced5d4678 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 10/28] feat(desktop): show route details and endpoint status in the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 171 +++++++++++++-- crates/switchyard-desktop/src/main.rs | 2 +- crates/switchyard-desktop/src/models.rs | 57 +++++ .../switchyard-desktop/src/server_config.rs | 137 +++++++++++- crates/switchyard-desktop/src/tui.rs | 205 ++++++++++++++++-- 5 files changed, 534 insertions(+), 38 deletions(-) create mode 100644 crates/switchyard-desktop/src/models.rs diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index ba4b3ab19a..073b7cb896 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -8,8 +8,8 @@ use crate::{ config::Config, - history, pricing, rollup, server, - server_config::{Choice, ServerConfig}, + history, models, pricing, rollup, server, + server_config::{ALGORITHMS, Choice, ServerConfig}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -58,7 +58,8 @@ pub struct Snapshot { pub data_state: DataState, pub routes: Vec, - + pub clients: Vec, + pub algorithms: Vec, pub summary: Vec, pub metrics: Metrics, pub today: chrono::NaiveDate, @@ -98,9 +99,48 @@ pub struct RouteInfo { /// This field holds the public model ID that harnesses send. pub id: String, pub kind: String, - + /// Route edits send this token back. The controller rejects it after the file changes. + pub revision: String, pub label: String, pub choices: Vec, + /// This field lists the tier of each role in role order. It is `None` when the apps + /// cannot edit the route's algorithm. + pub tiers: Option>>, + pub editable: bool, + /// This field holds the marker of the generated block that contains the route. The tool + /// that wrote the block replaces the route the next time it runs. + pub generated: Option, +} + +/// ClientInfo describes one endpoint under `[llm_clients]` in the server config. +#[derive(Clone, Default, Serialize)] +pub struct ClientInfo { + pub name: String, + pub host: String, + /// This field lists the model IDs that targets in the server config name on this endpoint. + pub models: Vec, + /// This field is true when the app cannot ask the endpoint for its model list. + pub unlisted: bool, + pub accepts_key: bool, + /// This field explains why the endpoint cannot list models without a saved key. + pub note: Option, +} + +/// AlgorithmInfo describes one routing algorithm that the apps can write and the roles +/// that a new route of that algorithm needs. +#[derive(Clone, Default, Serialize)] +pub struct AlgorithmInfo { + pub kind: &'static str, + pub title: &'static str, + pub summary: &'static str, + pub roles: Vec, +} + +#[derive(Clone, Default, Serialize)] +pub struct RoleInfo { + pub label: String, + pub hint: &'static str, + pub tier: Option, } #[derive(Clone, Default, Serialize)] @@ -156,6 +196,42 @@ impl Reply { } } +// Review keeps exact file bytes in the controller so later edits require a new review. +// Only the UUID token goes to the frontend; reviewed files can contain credentials. +struct Review { + token: String, + + files: Vec<(PathBuf, Option>)>, +} +impl Review { + fn capture(paths: Vec) -> Result { + let files = paths + .into_iter() + .map(|path| { + let bytes = match std::fs::read(&path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(format!("Read {}: {error}", path.display())), + }; + Ok((path, bytes)) + }) + .collect::, String>>()?; + Ok(Self { + token: uuid::Uuid::now_v7().to_string(), + files, + }) + } + fn original(&self) -> Result<&str, String> { + std::str::from_utf8( + self.files + .first() + .and_then(|(_, bytes)| bytes.as_deref()) + .ok_or("The reviewed server config is missing.")?, + ) + .map_err(|error| error.to_string()) + } +} + pub struct Controller { health_probe: crate::health::Probe, settings: PathBuf, @@ -163,7 +239,7 @@ pub struct Controller { reader: rollup::Reader, // The generation ties a draft to the server config file selected by the settings. generation: u64, - + route_reviews: std::collections::HashMap, update_review: Option<(String, Value)>, } impl Controller { @@ -174,7 +250,7 @@ impl Controller { health_probe: crate::health::Probe::default(), reader: rollup::Reader::default(), generation: 0, - + route_reviews: Default::default(), update_review: None, } } @@ -182,6 +258,7 @@ impl Controller { let config = Config::load(&self.settings)?; if config.config_file != self.config.config_file { self.generation += 1; + self.route_reviews.clear(); } self.config = config; @@ -199,6 +276,17 @@ impl Controller { )) } } + // Unchanged bytes keep the draft revision when the editor switches algorithms. + fn remember_route(&mut self, route: &str, mut review: Review) -> String { + if let Some(previous) = self.route_reviews.get(route) + && previous.files == review.files + { + review.token = previous.token.clone(); + } + let revision = review.token.clone(); + self.route_reviews.insert(route.to_string(), review); + revision + } pub fn snapshot(&mut self) -> Result { self.reload()?; let mut errors = Vec::new(); @@ -241,34 +329,74 @@ impl Controller { }; let summary = crate::summary::build(server_status, &usage, &self.config, &rates); let mut routes = Vec::new(); - match std::fs::read_to_string(&self.config.config_file) - .map_err(|e| format!("Read {}: {e}", self.config.config_file.display())) - .and_then(|text| { - ServerConfig::parse(&text).map_err(|_| { + let mut clients = Vec::new(); + let route_review = Review::capture(vec![self.config.config_file.clone()]); + let route_config = route_review + .as_ref() + .map_err(Clone::clone) + .and_then(|review| { + ServerConfig::parse(review.original()?).map_err(|_| { "The server config contains invalid TOML. Open it in Settings to fix it." .to_string() }) - }) { + }); + match route_config { Ok(config) => { - for route in config.routes() { + let listed = config.routes(); + self.route_reviews + .retain(|key, _| listed.iter().any(|route| &route.key == key)); + for route in listed { + let original = route_review.as_ref().map_err(Clone::clone)?; + let review = Review { + token: uuid::Uuid::now_v7().to_string(), + + files: original.files.clone(), + }; + let revision = self.remember_route(&route.key, review); let choices = config.choices(&route.key); let models = choices .iter() .map(|c| format!("{} / {}", c.client, c.model)) .collect::>() .join(" → "); + let algorithm = config.algorithm(&route.key); routes.push(RouteInfo { label: format!("{} · {} · {}", route.id, route.kind, models), + tiers: algorithm.map(|a| { + config + .roles(&route.key, a) + .iter() + .map(|r| r.tier.map(|t| format!("{t:?}"))) + .collect() + }), + editable: algorithm.is_some(), + generated: config + .generated_by("routes", &route.key) + .map(str::to_string), key: route.key, id: route.id, kind: route.kind, + revision, choices, }); } + clients = config + .clients() + .iter() + .map(|c| ClientInfo { + name: c.name.clone(), + host: c.host().to_string(), + models: config.models_on(&c.name), + unlisted: models::unlisted(c), + accepts_key: cfg!(any(target_os = "macos", windows)) + && !models::unlisted(c) + && (c.forward_auth || c.api_key_env.is_some()), + note: models::missing_env(c).map(models::missing_env_note), + }) + .collect(); } Err(e) => errors.push(e), } - let mut history_state = Availability::Available; let history = history::load(&self.config.routing_log).unwrap_or_else(|e| { history_state = Availability::Unavailable; @@ -306,6 +434,16 @@ impl Controller { }, routes, + clients, + algorithms: ALGORITHMS + .iter() + .map(|a| AlgorithmInfo { + kind: a.kind, + title: a.title, + summary: a.summary, + roles: a.new_route_roles().iter().map(role_info).collect(), + }) + .collect(), summary, metrics: Metrics { server_url: server_url.clone(), @@ -482,6 +620,13 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } +fn role_info(role: &crate::server_config::Role) -> RoleInfo { + RoleInfo { + label: role.label.clone(), + hint: role.hint, + tier: role.tier.map(|t| format!("{t:?}")), + } +} fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index e0ea712bbb..7a1dbf618c 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -11,9 +11,9 @@ use switchyard_desktop::{config, health, history, rollup}; mod controller; - #[cfg(any(target_os = "macos", windows))] mod gui; +mod models; mod pricing; mod server; mod server_config; diff --git a/crates/switchyard-desktop/src/models.rs b/crates/switchyard-desktop/src/models.rs new file mode 100644 index 0000000000..d643d8c71f --- /dev/null +++ b/crates/switchyard-desktop/src/models.rs @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module lists the models an LLM client offers from its `GET /models` endpoint, +//! and keeps each fetched list in a cache file. +//! +//! The request goes through the system `curl`, the same way the app runs +//! `launchctl`, so the app needs no HTTP or TLS stack of its own. The key is +//! written to curl's stdin, never to its command line, where other processes +//! could read it. +//! +//! The cache file holds model IDs and fetch times, never a key. The app uses +//! a list from the cache file until the user refreshes it, so it fetches a +//! list on its own only when the file does not have it. + +use crate::server_config::Client; + +/// This function explains why model suggestions lack a key. Model-list keys stay separate from +/// the server keys that the installed helper keeps. +pub fn missing_env_note(variable: &str) -> String { + if cfg!(any(target_os = "macos", windows)) { + format!( + "Switchyard does not read {variable} from your shell. \ + To load model suggestions, save a model-list key below. \ + The server's own key is under Settings → Server keys." + ) + } else { + format!( + "{variable} is not set in this terminal, so this endpoint cannot list models. \ + Type the model ID instead." + ) + } +} + +/// This function returns the client's `api_key_env` variable when this process does not +/// have it set. +pub fn missing_env(client: &Client) -> Option<&str> { + client + .api_key_env + .as_deref() + .filter(|_| env_key(client).is_none()) +} + +/// This function returns whether the client's endpoint has no model list that this app can +/// ask for. The ChatGPT Codex endpoint answers only a ChatGPT login, never an +/// API key, and the app does not hold the login. +pub fn unlisted(client: &Client) -> bool { + client.host() == "chatgpt.com" +} + +fn env_key(client: &Client) -> Option { + client + .api_key_env + .as_deref() + .and_then(|variable| std::env::var(variable).ok()) + .filter(|key| !key.trim().is_empty()) +} diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index d12aea7510..b2a7917751 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -7,7 +7,9 @@ //! edit does not touch stay as the user wrote them. The server's own //! `--dry-run` decides whether the result is valid; this module only builds it. -use toml_edit::{DocumentMut, TableLike}; +use std::collections::HashMap; + +use toml_edit::{DocumentMut, Item, TableLike}; /// Tier identifies the part a model plays in a route. A choice made for one algorithm carries /// over to the role with the same tier when the user switches algorithms. @@ -22,6 +24,9 @@ pub enum Tier { #[derive(Debug)] pub struct Algorithm { pub kind: &'static str, + /// This field sets the name shown in both apps. + pub title: &'static str, + pub summary: &'static str, /// This field lists roles in display order. The list is empty for /// `random`, whose roles are the entries of its `targets` list. roles: &'static [RoleSpec], @@ -46,6 +51,8 @@ const RANDOM: &str = "random"; pub const ALGORITHMS: &[Algorithm] = &[ Algorithm { kind: "passthrough", + title: "Single model", + summary: "This algorithm sends every request to one model.", roles: &[RoleSpec { label: "Model", hint: "This model answers every request.", @@ -55,10 +62,15 @@ pub const ALGORITHMS: &[Algorithm] = &[ }, Algorithm { kind: RANDOM, + title: "Random split", + summary: "This algorithm splits requests at random between models \ + for A/B tests and baselines.", roles: &[], }, Algorithm { kind: "llm_classifier", + title: "Judge per request", + summary: "A judge model reads each request and picks the capable or the efficient model.", roles: &[ RoleSpec { label: "Judge", @@ -82,6 +94,9 @@ pub const ALGORITHMS: &[Algorithm] = &[ }, Algorithm { kind: "composite", + title: "Judge plus stage router", + summary: "A judge picks the starting model on each user turn, and tool results move \ + requests between the capable and the efficient model.", roles: &[ RoleSpec { label: "Judge", @@ -105,6 +120,9 @@ pub const ALGORITHMS: &[Algorithm] = &[ }, Algorithm { kind: "stage_router", + title: "Stage router", + summary: "Tool results and agent progress move each request between the capable and \ + the efficient model.", roles: &[ RoleSpec { label: "Capable", @@ -122,6 +140,9 @@ pub const ALGORITHMS: &[Algorithm] = &[ }, Algorithm { kind: "advisor", + title: "Executor and advisor", + summary: "The executor answers every turn, and a stronger advisor reviews its final \ + answers.", roles: &[ RoleSpec { label: "Executor", @@ -139,6 +160,9 @@ pub const ALGORITHMS: &[Algorithm] = &[ }, Algorithm { kind: "plan_execute", + title: "Plan, then execute", + summary: "The capable model inspects and plans. The efficient model takes over after \ + the first file edit.", roles: &[ RoleSpec { label: "Capable", @@ -156,6 +180,8 @@ pub const ALGORITHMS: &[Algorithm] = &[ }, Algorithm { kind: "auto", + title: "Recommended preset", + summary: "This stage router starts each request on the efficient model.", roles: &[ RoleSpec { label: "Capable", @@ -183,6 +209,28 @@ pub struct Route { pub kind: String, } +/// Client describes one entry under `[llm_clients]`. +#[derive(Clone, Debug, PartialEq)] +pub struct Client { + pub name: String, + pub format: String, + pub base_url: String, + pub api_key_env: Option, + pub forward_auth: bool, +} + +impl Client { + /// This function returns the host in `base_url`, such as `chatgpt.com`. A port stays. + pub fn host(&self) -> &str { + let rest = self + .base_url + .split_once("://") + .map_or(self.base_url.as_str(), |(_, rest)| rest); + let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest); + authority.rsplit('@').next().unwrap_or(authority) + } +} + /// Choice identifies a model and the client that serves it. #[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] #[serde(deny_unknown_fields)] @@ -213,6 +261,12 @@ enum Slot { impl Slot {} impl Algorithm { + /// This function returns the roles that a new route of this type needs. A new + /// random route starts with two models. + pub fn new_route_roles(&self) -> Vec { + self.roles_for(0) + } + /// This function returns the roles of this type. `listed` is the number of entries /// in a random route's `targets` list; other types ignore it. fn roles_for(&self, listed: usize) -> Vec { @@ -243,12 +297,17 @@ impl Algorithm { #[derive(Clone, Debug)] pub struct ServerConfig { doc: DocumentMut, + /// This field maps tables inside generated blocks to each block's first marker. + generated: HashMap, } impl ServerConfig { pub fn parse(text: &str) -> Result { text.parse::() - .map(|doc| Self { doc }) + .map(|doc| Self { + doc, + generated: generated_tables(text), + }) .map_err(|error| { format!( "Could not parse the server config. Fix this TOML error and try again:\n\ @@ -267,6 +326,41 @@ impl ServerConfig { .collect() } + pub fn clients(&self) -> Vec { + entries(self.table("llm_clients")) + .map(|(name, client)| Client { + name: name.to_string(), + format: text(client, "format").unwrap_or_default().to_string(), + base_url: text(client, "base_url").unwrap_or_default().to_string(), + api_key_env: text(client, "api_key_env").map(str::to_string), + forward_auth: client + .get("forward_auth") + .and_then(Item::as_bool) + .unwrap_or(false), + }) + .collect() + } + + /// This function returns the text of the marker that opens the block another tool + /// writes around `[table.key]`, when the table sits inside one. That tool + /// overwrites the table the next time it runs. + pub fn generated_by(&self, table: &str, key: &str) -> Option<&str> { + self.generated + .get(&format!("{table}.{key}")) + .map(String::as_str) + } + + /// This function returns the model IDs that targets name on `client`, sorted, each once. + pub fn models_on(&self, client: &str) -> Vec { + let mut models: Vec = entries(self.table("targets")) + .filter(|(_, target)| text(*target, "llm_client") == Some(client)) + .filter_map(|(_, target)| text(target, "id").map(str::to_string)) + .collect(); + models.sort(); + models.dedup(); + models + } + /// This function returns the algorithm a route uses now, or `None` when the picker /// cannot show it, such as a custom-mode classifier whose groups are /// free-form. @@ -353,3 +447,42 @@ fn entries(table: Option<&dyn TableLike>) -> impl Iterator(table: &'a dyn TableLike, key: &str) -> Option<&'a str> { table.get(key)?.as_str() } + +/// This function finds the route and target tables that sit inside blocks another tool +/// writes. A block starts at a line that begins with `# >>>` and ends at the +/// next line that begins with `# <<<`, the markers that the installer scripts +/// use. Each table maps to the text of its block's first marker, with the `#` +/// and the `>>>` removed. +fn generated_tables(text: &str) -> HashMap { + let mut tables = HashMap::new(); + let mut block: Option = None; + for line in text.lines().map(str::trim) { + if line.starts_with("# >>>") { + block = Some( + line.trim_matches(|c: char| c == '#' || c == '>' || c.is_whitespace()) + .to_string(), + ); + } else if line.starts_with("# <<<") { + block = None; + } else if let Some(block) = &block + && let Some(header) = line.strip_prefix('[') + && let Some((table, rest)) = header.split_once('.') + && matches!(table, "routes" | "targets") + && let Some(key) = table_key(rest) + { + tables + .entry(format!("{table}.{key}")) + .or_insert_with(|| block.clone()); + } + } + tables +} + +/// This function returns the first key of a table header's dotted path, bare or in double +/// quotes: `name` for `name.child]`, and `a.b` for `"a.b"]`. +fn table_key(path: &str) -> Option<&str> { + if let Some(quoted) = path.strip_prefix('"') { + return quoted.split_once('"').map(|(key, _)| key); + } + path.split(['.', ']']).next().filter(|key| !key.is_empty()) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index cc3c51cc11..19c8e9c77b 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -766,6 +766,7 @@ impl App { /// the selection keeps its position, so deleting a route selects the next one; `refilter` /// starts from the first match instead. fn rebuild_routes(&mut self, anchor: Option) { + let algorithms = &self.data.algorithms; let filter = self.route_filter.as_str(); self.route_visible = self .data @@ -773,7 +774,10 @@ impl App { .iter() .enumerate() .filter(|(_, route)| { - let title = route.kind.as_str(); + let title = algorithms + .iter() + .find(|algorithm| algorithm.kind == route.kind) + .map_or("", |algorithm| algorithm.title); matches_words(&[&route.label, &route.key, title], filter) }) .map(|(index, _)| index) @@ -1437,23 +1441,24 @@ fn draw(frame: &mut Frame, app: &mut App) { // This destructuring has no `..`, so adding a snapshot field breaks the build until a page // shows it. Each comment names the place that shows the field. let Snapshot { - update_status: _, // draw_settings - settings: _, // draw_settings - data_state: _, // every page checks availability; draw_overview shows health - routes: _, // draw_routes and the route details - - summary: _, // --print shows these rows; draw_top_models builds the same notes - metrics: _, // draw_header and the Overview cards - today: _, // the Overview "Today" card title - analytics: _, // draw_top_models and draw_models - estimates: _, // the Overview cards and draw_models - baseline_model: _, // draw_top_models; draw_settings shows the saved setting - activity: _, // draw_activity - sessions: _, // the calls table title and the `s` key - entries: _, // draw_call_table and the call details - limited: _, // calls_block and draw_activity - skipped: _, // calls_block - errors: _, // draw_errors and draw_settings + update_status: _, // draw_settings + settings: _, // draw_settings + data_state: _, // every page checks availability; draw_overview shows health + routes: _, // draw_routes and the route details + clients: _, // draw_endpoints, the endpoint fields, and the endpoint forms + algorithms: _, // draw_routes, the route details, and the algorithm fields + summary: _, // --print shows these rows; draw_top_models builds the same notes + metrics: _, // draw_header and the Overview cards + today: _, // the Overview "Today" card title + analytics: _, // draw_top_models and draw_models + estimates: _, // the Overview cards and draw_models + baseline_model: _, // draw_top_models; draw_settings shows the saved setting + activity: _, // draw_activity + sessions: _, // the calls table title and the `s` key + entries: _, // draw_call_table and the call details + limited: _, // calls_block and draw_activity + skipped: _, // calls_block + errors: _, // draw_errors and draw_settings refresh_seconds: _, // App::refresh_due } = &app.data; let area = frame.area(); @@ -2203,19 +2208,63 @@ fn draw_models(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(table(theme, &columns, routes), routes_area); } +/// This function returns the algorithm's name, or the route type for a type the apps cannot +/// write. +fn algorithm_title<'a>(data: &'a Snapshot, kind: &'a str) -> &'a str { + data.algorithms + .iter() + .find(|algorithm| algorithm.kind == kind) + .map_or(kind, |algorithm| algorithm.title) +} + fn draw_routes(frame: &mut Frame, app: &mut App, area: Rect) { + let theme = app.theme; // A server config that cannot be read lists no routes, so its error explains the empty list. let errors = if app.data.routes.is_empty() && !app.data.errors.is_empty() { (app.data.errors.len() as u16 + 2).min(area.height / 3) } else { 0 }; - let [errors_area, list] = - Layout::vertical([Constraint::Length(errors), Constraint::Fill(1)]).areas(area); + let notes = endpoint_notes(theme, &app.data); + let endpoints = (app.data.clients.len().max(1) + notes.len() + 3) as u16; + let [errors_area, top, endpoints_area] = Layout::vertical([ + Constraint::Length(errors), + Constraint::Fill(1), + Constraint::Length(endpoints.min(area.height / 3)), + ]) + .areas(area); if errors > 0 { draw_errors(frame, app, errors_area); } + draw_endpoints(frame, app, notes, endpoints_area); + let (title, lines) = match app.selected_route() { + Ok(route) => (route.id.clone(), route_lines(theme, &app.data, route)), + Err(_) => ( + "Route".to_string(), + vec![Line::styled("No route selected.", theme.muted)], + ), + }; + let detail = Paragraph::new(lines).wrap(Wrap { trim: false }); + // Wide terminals put the details beside the list; others put them below it. + let (list, detail_area) = if top.width >= SIDE_DETAIL_WIDTH { + let [list, side] = + Layout::horizontal([Constraint::Fill(1), Constraint::Length(52)]).areas(top); + (list, side) + } else { + // The list keeps its borders, header, and the selected row; the details get the rest. + let rows = detail.line_count(top.width.saturating_sub(4)) as u16 + 2; + let [list, below] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(rows.min(top.height.saturating_sub(4))), + ]) + .areas(top); + (list, below) + }; draw_route_table(frame, app, list); + frame.render_widget( + detail.block(block(theme, &title).padding(Padding::horizontal(1))), + detail_area, + ); } fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { @@ -2268,7 +2317,7 @@ fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { .take(height) .map(|&index| { let route = &app.data.routes[index]; - let models = if !route.choices.is_empty() { + let models = if route.editable { let models: Vec = route .choices .iter() @@ -2280,7 +2329,7 @@ fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { }; let cells = [ Cell::from(route.id.clone()), - Cell::from(route.kind.clone()), + Cell::from(algorithm_title(&app.data, &route.kind).to_string()), Cell::from(models), ]; Row::new( @@ -2310,6 +2359,118 @@ fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { draw_scrollbar(frame, theme, area, visible, app.route_selected); } +/// This function shows the selected route's algorithm and the model of each role first, so a +/// short terminal still shows them, then the notes that limit editing it here. +fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec> { + const WIDTH: usize = 11; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let algorithm = data + .algorithms + .iter() + .find(|known| known.kind == route.kind); + let mut lines = vec![ + field("Config", format!("[routes.{}]", route.key)), + field( + "Algorithm", + algorithm.map_or_else( + || route.kind.clone(), + |known| format!("{} ({})", known.title, known.kind), + ), + ), + ]; + // A random route can list more models than a new one starts with, so later roles are + // numbered the same way. + for (index, choice) in route.choices.iter().enumerate() { + let role = algorithm + .and_then(|known| known.roles.get(index)) + .map_or_else(|| format!("Model {}", index + 1), |role| role.label.clone()); + lines.push(if choice.model.is_empty() { + Line::from(vec![ + Span::styled(format!("{role: Vec> { + let unlisted = "The app cannot list this endpoint's models. Type model IDs by hand."; + data.clients + .iter() + .flat_map(|client| { + let unlisted = client.unlisted.then(|| unlisted.to_string()); + unlisted.into_iter().chain(client.note.clone()).map(|note| { + Line::from(vec![ + Span::styled(format!("{}: ", client.name), theme.muted), + Span::styled(note, theme.warn), + ]) + }) + }) + .collect() +} + +fn draw_endpoints(frame: &mut Frame, app: &App, notes: Vec>, area: Rect) { + let theme = app.theme; + let clients = &app.data.clients; + let outer = block(theme, &format!("Endpoints [{}]", clients.len())); + let inner = outer.inner(area); + frame.render_widget(outer, area); + if clients.is_empty() { + frame.render_widget( + Paragraph::new(Line::styled( + "The server config names no endpoints.", + theme.muted, + )), + inner, + ); + return; + } + let [table_area, notes_area] = Layout::vertical([ + Constraint::Length(clients.len() as u16 + 1), + Constraint::Fill(1), + ]) + .areas(inner); + let rows: Vec = clients + .iter() + .map(|client| { + Row::new([ + client.name.clone(), + client.host.clone(), + client.models.join(", "), + ]) + }) + .collect(); + let columns = [ + ("NAME", Constraint::Fill(1), false), + ("HOST", Constraint::Fill(1), false), + ("MODELS IN THE SERVER CONFIG", Constraint::Fill(3), false), + ]; + frame.render_widget(table(theme, &columns, rows), table_area); + frame.render_widget(Paragraph::new(notes).wrap(Wrap { trim: false }), notes_area); +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { From 81d7838d8ec3951af58c130de70ba0f22773ee9e Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 11/28] feat(desktop): fetch and cache upstream model lists from the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 67 +++ .../switchyard-desktop/src/install/windows.rs | 9 + crates/switchyard-desktop/src/models.rs | 473 ++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 148 +++++- 4 files changed, 688 insertions(+), 9 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 073b7cb896..2b63003173 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -19,6 +19,11 @@ use std::{collections::BTreeMap, path::PathBuf}; #[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Action { + Models { + client: String, + refresh: bool, + key: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -276,6 +281,12 @@ impl Controller { )) } } + fn routes(&self) -> Result { + let text = std::fs::read_to_string(&self.config.config_file).map_err(|e| e.to_string())?; + ServerConfig::parse(&text).map_err(|_| { + "The server config contains invalid TOML. Open it in Settings to fix it.".into() + }) + } // Unchanged bytes keep the draft revision when the editor switches algorithms. fn remember_route(&mut self, route: &str, mut review: Review) -> String { if let Some(previous) = self.route_reviews.get(route) @@ -515,6 +526,62 @@ impl Controller { server::command("open", &[&logs_dir()?.display().to_string()])?; Ok(Reply::message("Opened Switchyard logs.".into())) } + Action::Models { + client, + refresh, + key, + } => { + let config = self.routes()?; + let client = config + .clients() + .into_iter() + .find(|c| c.name == client) + .ok_or("Unknown endpoint.")?; + if let Some(key) = &key { + if key.trim().is_empty() || models::has_line_break(key) { + return Err("Enter a nonempty key without line breaks.".into()); + } + if models::unlisted(&client) + || !(client.forward_auth || client.api_key_env.is_some()) + { + return Err("This endpoint does not accept a model-list key. \ + Choose an endpoint configured for model-list authentication." + .into()); + } + #[cfg(not(any(target_os = "macos", windows)))] + models::save_key(&client.base_url, key)?; + } + let cache = self.settings.with_file_name(models::CACHE_FILE); + let loaded = + models::load(&cache, &client, refresh || key.is_some(), key.as_deref()); + if let Some(key) = &key { + match &loaded.error { + None | Some(models::ListError::NotCached(_)) => { + models::save_key(&client.base_url, key)? + } + Some(error) => return Err(error.reason()), + } + } + // Fetch and cache-write failures need a warning even when a model list remains usable. + let warning = loaded.error.is_some(); + let fetched_at = loaded.list.as_ref().map(|list| list.fetched_at); + let source = loaded.list.as_ref().map_or("config", |_| loaded.source); + let message = loaded + .error + .map(|e| match e.advice() { + "" => e.reason(), + advice => format!("{} {advice}", e.reason()), + }) + .unwrap_or_else(|| "Model list loaded.".into()); + let models = loaded + .list + .map(|l| l.models) + .unwrap_or_else(|| config.models_on(&client.name)); + Ok(Reply { + message, + data: json!({"client":client.name,"count":models.len(),"models":models,"warning":warning,"source":source,"fetched_at":fetched_at}), + }) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), diff --git a/crates/switchyard-desktop/src/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index 9a6429026a..9cad57c992 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -473,6 +473,15 @@ pub(crate) fn delete_credential(target: &str) -> Result<(), String> { Ok(()) } +/// The app's model list saves each upstream base URL's key under its own target. +pub fn saved_key(base_url: &str) -> Result, String> { + read_credential(&format!("Switchyard:model-list:{base_url}")) +} + +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + write_credential(&format!("Switchyard:model-list:{base_url}"), key.trim()) +} + pub fn restart(s: &Settings) -> Result<(), String> { stop_task(s, false)?; stop_images(&[s.binary("switchyard-server")])?; diff --git a/crates/switchyard-desktop/src/models.rs b/crates/switchyard-desktop/src/models.rs index d643d8c71f..cbbc136fe8 100644 --- a/crates/switchyard-desktop/src/models.rs +++ b/crates/switchyard-desktop/src/models.rs @@ -13,8 +13,124 @@ //! a list from the cache file until the user refreshes it, so it fetches a //! list on its own only when the file does not have it. +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::{Mutex, PoisonError}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +use crate::config::expand_home; use crate::server_config::Client; +/// CACHE_FILE names the cache file. It sits next to the settings file that the +/// app was started with. +pub const CACHE_FILE: &str = "model-lists.json"; + +/// KEYCHAIN_SERVICE identifies keys saved from Routes. Each +/// item's account is the client's `base_url`, so clients that share a +/// gateway share its key. +#[cfg(target_os = "macos")] +const KEYCHAIN_SERVICE: &str = "Switchyard model list"; + +/// TIMEOUT_SECONDS limits how long curl may spend listing models. +const TIMEOUT_SECONDS: &str = "20"; + +/// CACHE_WRITE stays locked while a thread rewrites the cache file, so threads that +/// finish at the same time do not drop each other's lists. +static CACHE_WRITE: Mutex<()> = Mutex::new(()); + +/// ModelList stores one fetched model list in the cache file. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct ModelList { + /// This field stores sorted model IDs. + pub models: Vec, + /// This field records the fetch time in seconds since 1970-01-01 UTC. + pub fetched_at: u64, +} + +/// Loaded stores the model list and fetch result for one URL. +#[derive(Clone, Debug, PartialEq)] +pub struct Loaded { + /// This field identifies an endpoint list as `endpoint` or a Switchyard cache list as `cache`. + /// A Codex cache list has the source `local`; `error` separately reports fetch or cache-write failures. + pub source: &'static str, + /// This field names the URL that serves the list. + pub url: String, + /// This field stores the newest list fetched or read from the cache file. + pub list: Option, + /// This field records a fetch or cache-write error, if either occurred. + pub error: Option, +} + +/// ListError describes why a client's models could not be listed. +#[derive(Clone, Debug, PartialEq)] +pub enum ListError { + /// The client needs a key, and none is set or saved. + NoKey, + /// The client reads its key from this `api_key_env` variable, which this + /// process does not have, and no key is saved. + NoEnv(String), + /// The platform's credential store did not return the saved key. + Keychain(String), + /// The models endpoint answered 401 or 403. + Rejected(u16), + /// The request failed, or the answer was not a model list. + Failed(String), + /// The list was fetched, but the cache file could not be written. + NotCached(String), +} + +impl ListError { + /// This function says what went wrong, in one sentence. + pub fn reason(&self) -> String { + match self { + Self::NoKey => { + "This endpoint needs an API key to list models, and the app does not have one." + .to_string() + } + Self::NoEnv(variable) => missing_env_note(variable), + Self::Keychain(error) => { + format!("Could not read the saved key from secure credential storage: {error}.") + } + Self::Rejected(status) => { + format!("The models endpoint rejected the key (HTTP {status}).") + } + Self::Failed(error) => format!("Could not list models: {error}."), + Self::NotCached(error) => format!("Could not save the list: {error}."), + } + } + + /// This function says what the user can do while the window has no list to show. The + /// window accepts a typed model ID, so most answers offer that. A missing + /// variable also stops Apply, so that answer offers only the key field. + /// Only macOS and Windows can save a key, so elsewhere the answers offer + /// only a typed model ID. [`missing_env_note`] already says that, so a + /// missing variable gets no advice there. + pub fn advice(&self) -> &'static str { + let saves_keys = cfg!(any(target_os = "macos", windows)); + match self { + Self::NoEnv(_) if saves_keys => "Or paste the key below to list models.", + Self::NoEnv(_) => "", + _ if self.needs_key() && saves_keys => { + "Enter a valid API key and choose Save key and load models, or type a model ID." + } + _ if self.needs_key() => "Type a model ID instead.", + _ => "Click Refresh models to try again, or type a model ID.", + } + } + + /// This function returns whether saving a key could fix this error. + pub fn needs_key(&self) -> bool { + matches!( + self, + Self::NoKey | Self::NoEnv(_) | Self::Keychain(_) | Self::Rejected(_) + ) + } +} + /// This function explains why model suggestions lack a key. Model-list keys stay separate from /// the server keys that the installed helper keeps. pub fn missing_env_note(variable: &str) -> String { @@ -41,6 +157,59 @@ pub fn missing_env(client: &Client) -> Option<&str> { .filter(|_| env_key(client).is_none()) } +/// This function loads one endpoint's models and retains the cached list when refresh fails. +/// The selected endpoint alone receives the entered key. +pub fn load(cache: &Path, client: &Client, refresh: bool, typed_key: Option<&str>) -> Loaded { + let url = list_url(client); + let old = read_cache(cache).remove(&url); + if unlisted(client) { + // ChatGPT has no list to fetch, so nothing goes in the cache file. + return Loaded { + source: "local", + url, + list: codex_models(), + error: None, + }; + } + if old.is_some() && !refresh { + return Loaded { + source: "cache", + url, + list: old, + error: None, + }; + } + match key(client, typed_key).and_then(|key| list(client, key.as_deref())) { + Ok(models) => { + let new = ModelList { + models, + fetched_at: now(), + }; + let error = write_cache(cache, &url, &new) + .err() + .map(ListError::NotCached); + Loaded { + source: "endpoint", + url, + list: Some(new), + error, + } + } + Err(error) => Loaded { + source: "cache", + url, + list: old, + error: Some(error), + }, + } +} + +/// This function returns the URL that serves the client's model list. It is also the +/// list's key in the cache file and in [`Loaded::url`]. +pub fn list_url(client: &Client) -> String { + models_url(&client.format, &client.base_url) +} + /// This function returns whether the client's endpoint has no model list that this app can /// ask for. The ChatGPT Codex endpoint answers only a ChatGPT login, never an /// API key, and the app does not hold the login. @@ -48,6 +217,111 @@ pub fn unlisted(client: &Client) -> bool { client.host() == "chatgpt.com" } +/// This function reads the model list that Codex saved in `$CODEX_HOME/models_cache.json`, +/// or in `~/.codex/models_cache.json` when `CODEX_HOME` is unset. Codex +/// fetched that list with the user's ChatGPT login. For a ChatGPT client this +/// file is the only list of its models that the app can read. +fn codex_models() -> Option { + let home = std::env::var_os("CODEX_HOME") + .map(PathBuf::from) + .or_else(|| expand_home(Path::new("~/.codex")).ok())?; + read_codex_models(&home.join("models_cache.json")) +} + +/// This function parses a Codex model list file. Returns every model's `slug`, sorted and +/// without duplicates, and the time Codex fetched the file. +fn read_codex_models(path: &Path) -> Option { + #[derive(Deserialize)] + struct Saved { + fetched_at: String, + models: Vec, + } + #[derive(Deserialize)] + struct Entry { + slug: String, + } + + let saved: Saved = serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()?; + let fetched_at = chrono::DateTime::parse_from_rfc3339(&saved.fetched_at).ok()?; + let mut models: Vec = saved.models.into_iter().map(|entry| entry.slug).collect(); + models.sort(); + models.dedup(); + Some(ModelList { + models, + fetched_at: u64::try_from(fetched_at.timestamp()).ok()?, + }) +} + +/// This function lists the client's model IDs, sorted. When the client needs a key and +/// `key` is `None`, returns [`ListError::NoEnv`] or [`ListError::NoKey`] +/// without sending a request. +fn list(client: &Client, key: Option<&str>) -> Result, ListError> { + match (key, &client.api_key_env) { + (None, Some(variable)) => Err(ListError::NoEnv(variable.clone())), + (None, None) if client.forward_auth => Err(ListError::NoKey), + _ => fetch(&list_url(client), &client.format, key), + } +} + +/// This function returns the models endpoint for a client. It builds the URL the way the +/// server builds its request URLs. OpenAI clients drop a trailing +/// `/chat/completions` or `/responses`. Anthropic clients drop a trailing +/// `/v1/messages` to `/v1`, and add `/v1` to any other path. +/// Anthropic returns 20 models unless the URL sets `limit`, so the URL adds +/// `limit=1000`, the most that Anthropic allows. +fn models_url(format: &str, base_url: &str) -> String { + let (base, query) = match base_url.split_once('?') { + Some((base, query)) => (base, format!("?{query}")), + None => (base_url, String::new()), + }; + let base = base.trim_end_matches('/'); + let path = if format == "anthropic_messages" { + let root = base + .strip_suffix("/messages") + .filter(|root| root.ends_with("/v1")) + .unwrap_or(base); + if root.ends_with("/v1") { + format!("{root}/models") + } else { + format!("{root}/v1/models") + } + } else { + let root = base + .strip_suffix("/chat/completions") + .or_else(|| base.strip_suffix("/responses")) + .unwrap_or(base); + format!("{root}/models") + }; + let query = if format == "anthropic_messages" && query.is_empty() { + "?limit=1000".to_string() + } else if format == "anthropic_messages" { + format!("{query}&limit=1000") + } else { + query + }; + format!("{path}{query}") +} + +/// This function returns whether the server sends this client a key. If it does, listing +/// the client's models needs a key too. +fn sends_key(client: &Client) -> bool { + client.forward_auth || client.api_key_env.is_some() +} + +/// This function returns the key to list the client's models with, or `None` when the +/// client needs no key or none is available. +/// +/// The entered key takes precedence over the environment and saved credentials. +fn key(client: &Client, typed_key: Option<&str>) -> Result, ListError> { + if !sends_key(client) { + return Ok(None); + } + if let Some(key) = typed_key.map(str::to_string).or_else(|| env_key(client)) { + return Ok(Some(key)); + } + saved_key(&client.base_url).map_err(ListError::Keychain) +} + fn env_key(client: &Client) -> Option { client .api_key_env @@ -55,3 +329,202 @@ fn env_key(client: &Client) -> Option { .and_then(|variable| std::env::var(variable).ok()) .filter(|key| !key.trim().is_empty()) } + +/// This function returns the key saved in the Keychain for `base_url`, or `None` when no +/// key is saved. +#[cfg(target_os = "macos")] +fn saved_key(base_url: &str) -> Result, String> { + /// NOT_FOUND matches the Keychain's `errSecItemNotFound` status. + const NOT_FOUND: i32 = -25300; + match security_framework::passwords::get_generic_password(KEYCHAIN_SERVICE, base_url) { + Ok(bytes) => String::from_utf8(bytes) + .map(|key| Some(key).filter(|key| !key.trim().is_empty())) + .map_err(|_| "the saved key is not text".to_string()), + Err(error) if error.code() == NOT_FOUND => Ok(None), + Err(error) => Err(keychain_error(&error)), + } +} + +#[cfg(not(any(target_os = "macos", windows)))] +fn saved_key(_base_url: &str) -> Result, String> { + Ok(None) +} + +/// This function saves a key in the login Keychain for every client with this `base_url`. +#[cfg(target_os = "macos")] +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + security_framework::passwords::set_generic_password( + KEYCHAIN_SERVICE, + base_url, + key.trim().as_bytes(), + ) + .map_err(|error| keychain_error(&error)) +} + +#[cfg(not(any(target_os = "macos", windows)))] +/// This function rejects saved keys on platforms without Keychain or Credential Manager support. +pub fn save_key(_base_url: &str, _key: &str) -> Result<(), String> { + Err( + "Saving model-list keys requires macOS Keychain or Windows Credential Manager. \ + On this platform, set the environment variable named by api_key_env \ + and make it available to the app." + .into(), + ) +} + +#[cfg(windows)] +/// This function reads a saved key for this exact base_url from Windows Credential Manager. +fn saved_key(base_url: &str) -> Result, String> { + switchyard_desktop::install::windows::saved_key(base_url) +} +#[cfg(windows)] +/// This function saves the key for this exact base_url in Windows Credential Manager. +pub fn save_key(base_url: &str, key: &str) -> Result<(), String> { + switchyard_desktop::install::windows::save_key(base_url, key) +} + +/// This function returns the Keychain's message without its final period, so the window +/// can end its own sentence after it. +#[cfg(target_os = "macos")] +fn keychain_error(error: &security_framework::base::Error) -> String { + error.to_string().trim_end_matches('.').to_string() +} + +/// This function returns whether a key has a line break, which would end the header that +/// carries the key and start another one. +pub fn has_line_break(key: &str) -> bool { + key.contains(['\r', '\n']) +} + +/// This function reads the cache file. A missing or unreadable file reads as empty, so the +/// app fetches its lists again and writes a new file. +fn read_cache(path: &Path) -> BTreeMap { + std::fs::read_to_string(path) + .ok() + .and_then(|text| serde_json::from_str(&text).ok()) + .unwrap_or_default() +} + +/// This function adds the list at `url` to the cache file. The list replaces the one at +/// the same URL unless that one is newer: two loads can overlap, and the +/// load that fetched a list first can write it last. The text goes to a +/// temporary file that is renamed over the cache file, so a reader never +/// sees half a file. +fn write_cache(path: &Path, url: &str, list: &ModelList) -> Result<(), String> { + let _writing = CACHE_WRITE.lock().unwrap_or_else(PoisonError::into_inner); + let mut all = read_cache(path); + if all + .get(url) + .is_some_and(|cached| cached.fetched_at > list.fetched_at) + { + return Ok(()); + } + all.insert(url.to_string(), list.clone()); + let text = serde_json::to_string_pretty(&all) + .map_err(|error| format!("write {}: {error}", path.display()))?; + let dir = path + .parent() + .filter(|dir| !dir.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut file = tempfile::NamedTempFile::new_in(dir) + .map_err(|error| format!("create a file in {}: {error}", dir.display()))?; + file.write_all(text.as_bytes()) + .and_then(|()| file.as_file().sync_all()) + .map_err(|error| format!("write {}: {error}", path.display()))?; + file.persist(path) + .map_err(|error| format!("replace {}: {}", path.display(), error.error))?; + Ok(()) +} + +/// This function returns the current time in seconds since 1970-01-01 UTC. +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |since| since.as_secs()) +} + +fn fetch(url: &str, format: &str, key: Option<&str>) -> Result, ListError> { + let failed = |error: String| ListError::Failed(error); + if key.is_some_and(has_line_break) { + return Err(failed( + "the key has a line break, so the app did not send it".to_string(), + )); + } + let mut command = Command::new("curl"); + #[cfg(windows)] + switchyard_desktop::install::windows::hide_console(&mut command); + let mut child = command + // curl honors `-q` only as its first argument. `-q` makes curl ignore + // ~/.curlrc, where a `verbose` line would print the key into the + // error text that the window shows. + .args([ + "-q", + "--silent", + "--show-error", + "--max-time", + TIMEOUT_SECONDS, + ]) + // curl reads request headers from stdin, one per line. + .args(["--header", "@-"]) + // `--url` keeps a URL that starts with `-` from being read as an + // option. + .args(["--write-out", "\n%{http_code}", "--url", url]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|error| failed(format!("run curl: {error}")))?; + if let Some(mut stdin) = child.stdin.take() { + let headers = match (key, format) { + (Some(key), "anthropic_messages") => { + format!("x-api-key: {key}\nanthropic-version: 2023-06-01\n") + } + (Some(key), _) => format!("Authorization: Bearer {key}\n"), + (None, _) => String::new(), + }; + let sent = stdin.write_all(headers.as_bytes()); + drop(stdin); + if let Err(error) = sent { + let _ = child.kill(); + let _ = child.wait(); + return Err(failed(format!("send headers to curl: {error}"))); + } + } + let output = child + .wait_with_output() + .map_err(|error| failed(format!("run curl: {error}")))?; + if !output.status.success() { + return Err(failed( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let (body, status) = stdout.rsplit_once('\n').unwrap_or(("", &stdout)); + match status.trim().parse::() { + Ok(200) => parse_ids(body).map_err(failed), + Ok(status @ (401 | 403)) => Err(ListError::Rejected(status)), + Ok(status) => Err(failed(format!("{url} answered HTTP {status}"))), + Err(_) => Err(failed(format!("{url} sent no HTTP status"))), + } +} + +/// This function parses the model IDs. OpenAI and Anthropic both answer with +/// `{"data": [{"id": ...}, ...]}`. +fn parse_ids(body: &str) -> Result, String> { + #[derive(Deserialize)] + struct Listing { + data: Vec, + } + #[derive(Deserialize)] + struct Entry { + id: String, + } + + let listing: Listing = serde_json::from_str(body) + .map_err(|error| format!("the model list is not the expected JSON: {error}"))?; + let mut ids: Vec = listing.data.into_iter().map(|entry| entry.id).collect(); + ids.sort(); + ids.dedup(); + Ok(ids) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 19c8e9c77b..75a7e9ae4c 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -125,6 +125,13 @@ const FORM_KEYS: &[Key] = &[ ("tab", "next field", ""), ("esc", "cancel", ""), ]; +/// The status bar shows these keys while the selected form field has a list of choices. +const CHOICE_KEYS: &[Key] = &[ + ("⏎", "submit", ""), + ("↑↓", "choose", ""), + ("tab", "next field", ""), + ("esc", "cancel", ""), +]; const REVIEW_KEYS: &[Key] = &[ ("⏎", "confirm", ""), ("↑↓", "scroll", ""), @@ -140,6 +147,20 @@ const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; /// and `every_controller_action_has_a_key` fails until a row's flow includes it. type ActionRow = (Tab, char, &'static str, &'static str, &'static [ActionKind]); const ACTIONS: &[ActionRow] = &[ + ( + Tab::Routes, + 'm', + "models", + "Loads the model IDs that an endpoint offers.", + &[ActionKind::Models], + ), + ( + Tab::Routes, + 'k', + "model key", + "Saves an API key that lets the app load an endpoint's model IDs, then loads them.", + &[ActionKind::Models], + ), ( Tab::Settings, 'o', @@ -386,6 +407,12 @@ enum FormKind { RemoveKey, /// The token comes from the update preview that the user read. Update(Option), + Models, + ModelKey, +} +/// Choices names the list that ↑ and ↓ cycle through in a form field. +enum Choices { + Endpoints, } /// Submit is what a form sends: a controller action. enum Submit { @@ -476,6 +503,28 @@ impl Form { fn value(&self, index: usize) -> String { self.fields[index].1.trim().to_string() } + /// This function names the list that the selected field chooses from, if any. + fn options(&self) -> Option { + match self.kind { + FormKind::Models | FormKind::ModelKey if self.index == 0 => Some(Choices::Endpoints), + _ => None, + } + } + /// This function replaces the selected field with the next or previous name in `names`. + fn choose<'a>(&mut self, mut names: impl Iterator + Clone, forward: bool) { + let count = names.clone().count(); + if count == 0 { + return; + } + let field = &mut self.fields[self.index].1; + let next = match names.clone().position(|name| name == field.trim()) { + Some(index) if forward => (index + 1) % count, + Some(index) => (index + count - 1) % count, + None => 0, + }; + *field = names.nth(next).unwrap_or_default().to_string(); + self.cursor = field.chars().count(); + } /// This function builds what the form submits. fn action(&self) -> Result { let confirm = |word: &str, error: &str| { @@ -513,6 +562,16 @@ impl Form { preview_token: preview_token.clone(), } } + FormKind::Models => Action::Models { + client: self.value(0), + refresh: true, + key: None, + }, + FormKind::ModelKey => Action::Models { + client: self.value(0), + refresh: true, + key: Some(self.value(1)), + }, })) } fn label(&self) -> &'static str { @@ -522,6 +581,9 @@ impl Form { FormKind::ImportKey => "Importing the server key", FormKind::RemoveKey => "Removing the server key", FormKind::Update(_) => "Starting the update", + + FormKind::Models => "Loading models", + FormKind::ModelKey => "Saving the key and loading models", } } } @@ -856,6 +918,24 @@ impl App { "Type UPDATE to start", )); } + (Some(ActionKind::Models), Ok(reply)) => { + let models: Vec<&str> = reply.data["models"] + .as_array() + .map(|items| items.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let list = if models.is_empty() { + "No model IDs found.".to_string() + } else { + models.join("\n") + }; + let client = reply.data["client"].as_str().unwrap_or_default(); + self.popup = Some(Popup::Message { + title: "Models", + text: format!("{}\n\nModel IDs on {client}:\n{list}", reply.message), + tone: Tone::of(&reply), + scroll: 0, + }); + } (Some(ActionKind::ServerKeys), Ok(reply)) => { self.server_keys = Some(reply.data); self.notify("Checked the saved server keys.".into(), Tone::Done); @@ -938,7 +1018,7 @@ impl App { match (&self.popup, self.filtering, self.tab) { (Some(Popup::Restart), _, _) => CONFIRM_KEYS, (Some(Popup::Review { .. }), _, _) => REVIEW_KEYS, - + (Some(Popup::Form(form)), _, _) if form.options().is_some() => CHOICE_KEYS, (Some(Popup::Form(_)), _, _) => FORM_KEYS, (Some(_), _, _) => POPUP_KEYS, (None, true, _) => FILTER_KEYS, @@ -1136,10 +1216,28 @@ impl App { /// adding a controller action breaks the build until the terminal app can run it. Only the /// arms of actions that several keys start read `key`, and they return an error for a key /// that they do not know, so a new row must also be added to its arm. - fn start_action(&mut self, kind: ActionKind, _key: char) -> Result, String> { + fn start_action(&mut self, kind: ActionKind, key: char) -> Result, String> { let request = |action, label| Ok(Some(Request::run(action, label))); match kind { + ActionKind::Models => { + let client = self.data.clients.first(); + let client = client.map(|c| c.name.clone()).unwrap_or_default(); + let form = match key { + 'm' => Form::new( + FormKind::Models, + "Load an endpoint's models", + vec![("Endpoint name", client)], + ), + 'k' => Form::new( + FormKind::ModelKey, + "Save a model-list key", + vec![("Endpoint name", client), ("API key", String::new())], + ), + _ => return Err(format!("The {key} key does not load models.")), + }; + self.open(form) + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -1218,13 +1316,23 @@ impl App { Ok(Submit::Run(action)) => return Some(Request::run(action, form.label())), Err(error) => form.error = error, }, - // Only the review window scrolls; text fields ignore these keys. + // A field with choices cycles through them. Otherwise only the review + // window scrolls; text fields ignore these keys. KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { - if let Some(scroll) = popup.scroll_mut() - && let Some(next) = - step(key.code, usize::from(*scroll), usize::from(u16::MAX)) - { - *scroll = next as u16; + let forward = key.code == KeyCode::Down; + match (form.options(), key.code) { + (Some(Choices::Endpoints), KeyCode::Up | KeyCode::Down) => { + let names = self.data.clients.iter().map(|c| c.name.as_str()); + form.choose(names, forward); + } + _ => { + if let Some(scroll) = popup.scroll_mut() + && let Some(next) = + step(key.code, usize::from(*scroll), usize::from(u16::MAX)) + { + *scroll = next as u16; + } + } } } _ => form.edit(key), @@ -2707,7 +2815,7 @@ fn form_lines(theme: Theme, form: &Form) -> Vec> { .enumerate() .map(|(index, (label, value))| { // The key field shows one bullet per character, so the key never appears on screen. - let masked = matches!(form.kind, FormKind::SaveKey); + let masked = matches!(form.kind, FormKind::SaveKey | FormKind::ModelKey); let mut value = if masked && index == 1 { "•".repeat(value.chars().count()) } else { @@ -2867,6 +2975,7 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { /// that ↑ and ↓ cycle through. fn form_notes(app: &App, form: &Form) -> Vec> { let theme = app.theme; + let data = &app.data; match &form.kind { FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey => { let mut lines = vec![Line::styled("Server keys", theme.muted)]; @@ -2875,6 +2984,27 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines } + FormKind::Models | FormKind::ModelKey => { + let key = matches!(form.kind, FormKind::ModelKey); + let mut lines: Vec = data + .clients + .iter() + .map(|client| { + let mut spans = vec![ + Span::raw(client.name.clone()), + Span::styled(format!(" · {}", client.host), theme.muted), + ]; + if key && client.accepts_key { + spans.push(Span::styled(" · accepts a model-list key", theme.ok)); + } else if key { + spans.push(Span::styled(" · no model-list key", theme.warn)); + } + Line::from(spans) + }) + .collect(); + lines.push(Line::default()); + lines + } FormKind::Preferences | FormKind::Update(_) => Vec::new(), } } From b28bbd7f494a75601c1f956ef01ca70ca26522af Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 12/28] test(desktop): cover model-list key scoping, curl reaping, and error reporting Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/models.rs | 196 ++++++++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/crates/switchyard-desktop/src/models.rs b/crates/switchyard-desktop/src/models.rs index cbbc136fe8..688aa7a96f 100644 --- a/crates/switchyard-desktop/src/models.rs +++ b/crates/switchyard-desktop/src/models.rs @@ -528,3 +528,199 @@ fn parse_ids(body: &str) -> Result, String> { ids.dedup(); Ok(ids) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{BufRead, BufReader}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + /// Stub returns the configured response and records each request header. + /// Drop wakes its listener and joins the worker so a failed assertion cannot leave it running. + struct Stub { + /// This field sets the client base URL used to list models from this server. + url: String, + requests: Arc>>>, + address: SocketAddr, + stopping: Arc, + worker: Option>, + } + + impl Stub { + fn start(response: String) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); + let address = listener.local_addr().expect("addr"); + let url = format!("http://{address}/v1"); + let requests = Arc::new(Mutex::new(Vec::new())); + let stopping = Arc::new(AtomicBool::new(false)); + let stop = Arc::clone(&stopping); + let record = Arc::clone(&requests); + let worker = std::thread::spawn(move || { + for mut stream in listener.incoming().map_while(Result::ok) { + if stop.load(Ordering::Acquire) { + break; + } + let mut head = Vec::new(); + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut line = String::new(); + while reader.read_line(&mut line).is_ok_and(|read| read > 0) && line != "\r\n" { + head.push(line.trim_end().to_string()); + line.clear(); + } + record.lock().expect("lock").push(head); + let _ = stream.write_all(response.as_bytes()); + } + }); + Self { + url, + requests, + address, + stopping, + worker: Some(worker), + } + } + + fn requests(&self) -> Vec> { + self.requests.lock().expect("lock").clone() + } + } + + impl Drop for Stub { + fn drop(&mut self) { + self.stopping.store(true, Ordering::Release); + let _ = TcpStream::connect(self.address); + if let Some(worker) = self.worker.take() { + let _ = worker.join(); + } + } + } + + fn listing(models: &[&str]) -> String { + let data: Vec<_> = models + .iter() + .map(|id| serde_json::json!({ "id": id })) + .collect(); + format!( + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n{}", + serde_json::json!({ "data": data }) + ) + } + + fn client(name: &str, format: &str, base_url: &str) -> Client { + Client { + name: name.to_string(), + format: format.to_string(), + base_url: base_url.to_string(), + api_key_env: None, + forward_auth: true, + } + } + + // The subprocess isolates PATH; its curl fixture exits before reading the oversized header. + #[cfg(unix)] + #[test] + fn failed_header_write_reaps_the_curl_process() { + use std::os::unix::fs::PermissionsExt; + if let Ok(record) = std::env::var("SWITCHYARD_CURL_FIXTURE") { + let error = fetch( + "http://localhost/models", + "openai_chat", + Some(&"k".repeat(1024 * 1024)), + ) + .expect_err("header write fails"); + assert!( + matches!(error, ListError::Failed(message) if message.starts_with("send headers to curl:")) + ); + let pid = std::fs::read_to_string(record).expect("curl PID"); + assert!( + !Command::new("/bin/kill") + .args(["-0", pid.trim()]) + .status() + .expect("process status") + .success() + ); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let record = dir.path().join("pid"); + let curl = dir.path().join("curl"); + std::fs::write( + &curl, + "#!/bin/sh\nprintf '%s' \"$$\" > \"$SWITCHYARD_CURL_FIXTURE\"\nexit 1\n", + ) + .expect("curl fixture"); + std::fs::set_permissions(&curl, std::fs::Permissions::from_mode(0o700)) + .expect("permissions"); + let output = Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "models::tests::failed_header_write_reaps_the_curl_process", + ]) + .env("SWITCHYARD_CURL_FIXTURE", &record) + .env("PATH", dir.path()) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + } + + #[test] + fn entered_key_is_sent_only_to_its_selected_base_url() { + let selected = Stub::start(listing(&["selected"])); + let unrelated = Stub::start(listing(&["unrelated"])); + let unauthenticated = Stub::start(listing(&["public"])); + let dir = tempfile::tempdir().expect("fixture"); + let clients = [ + client("selected", "openai_chat", &selected.url), + Client { + api_key_env: Some("SWITCHYARD_MENUBAR_TEST_UNSET_KEY".into()), + ..client("unrelated", "openai_chat", &unrelated.url) + }, + Client { + forward_auth: false, + ..client("public", "openai_chat", &unauthenticated.url) + }, + ]; + let loaded: Vec<_> = clients + .iter() + .enumerate() + .map(|(index, client)| { + load( + &dir.path().join(CACHE_FILE), + client, + true, + (index == 0).then_some("selected-secret"), + ) + }) + .collect(); + assert_eq!(loaded[0].error, None); + assert_eq!(loaded[0].source, "endpoint"); + assert!( + !std::fs::read_to_string(dir.path().join(CACHE_FILE)) + .expect("cache") + .contains("selected-secret") + ); + assert_eq!(selected.requests().len(), 1); + assert!(selected.requests()[0].contains(&"Authorization: Bearer selected-secret".into())); + assert_eq!( + loaded[1].error, + Some(ListError::NoEnv("SWITCHYARD_MENUBAR_TEST_UNSET_KEY".into())) + ); + assert!(unrelated.requests().is_empty()); + assert_eq!(loaded[2].error, None); + assert_eq!(loaded[2].source, "endpoint"); + assert_eq!(unauthenticated.requests().len(), 1); + assert!( + unauthenticated.requests()[0] + .iter() + .all(|header| !header.starts_with("Authorization:")) + ); + } +} From 39b845e33791001a2dc34017fc7e0d6ba462244b Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 13/28] feat(desktop): preview route target and algorithm edits in server configs Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 74 +- .../switchyard-desktop/src/server_config.rs | 655 +++++++++++++++++- crates/switchyard-desktop/src/tui.rs | 37 +- 3 files changed, 757 insertions(+), 9 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 2b63003173..7f1ac8c912 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -19,6 +19,13 @@ use std::{collections::BTreeMap, path::PathBuf}; #[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Action { + PreviewRoute { + generation: u64, + route: String, + algorithm: String, + choices: Vec, + revision: Option, + }, Models { client: String, refresh: bool, @@ -61,7 +68,9 @@ pub struct Snapshot { /// when the app settings cannot be loaded. pub settings: Option, pub data_state: DataState, - + /// The controller increases this number when the app settings select another server + /// config file. Route actions send it back, so a draft cannot change the new file. + pub generation: u64, pub routes: Vec, pub clients: Vec, pub algorithms: Vec, @@ -205,11 +214,11 @@ impl Reply { // Only the UUID token goes to the frontend; reviewed files can contain credentials. struct Review { token: String, - + inputs: Value, files: Vec<(PathBuf, Option>)>, } impl Review { - fn capture(paths: Vec) -> Result { + fn capture(inputs: Value, paths: Vec) -> Result { let files = paths .into_iter() .map(|path| { @@ -223,6 +232,7 @@ impl Review { .collect::, String>>()?; Ok(Self { token: uuid::Uuid::now_v7().to_string(), + inputs, files, }) } @@ -235,6 +245,26 @@ impl Review { ) .map_err(|error| error.to_string()) } + // Decoded settings omit comments and unrelated fields, so this check compares raw bytes. + fn check(&self, token: Option<&str>, inputs: &Value) -> Result<(), String> { + if token != Some(self.token.as_str()) || inputs != &self.inputs { + return Err("Review these changes again before applying them.".into()); + } + for (path, bytes) in &self.files { + let current = match std::fs::read(path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + if ¤t != bytes { + return Err(format!( + "{} changed since your review. Review it again; your draft is preserved.", + path.display() + )); + } + } + Ok(()) + } } pub struct Controller { @@ -269,6 +299,12 @@ impl Controller { Ok(()) } + fn check_generation(&self, generation: u64) -> Result<(), String> { + if generation != self.generation { + return Err("The route changed or was removed. Refresh and choose it again.".into()); + } + Ok(()) + } // Key actions accept only names that the server config declares. fn declared_key(&self, name: &str) -> Result<(), String> { if switchyard_desktop::install::credentials::variables(&self.config.config_file)? @@ -341,7 +377,7 @@ impl Controller { let summary = crate::summary::build(server_status, &usage, &self.config, &rates); let mut routes = Vec::new(); let mut clients = Vec::new(); - let route_review = Review::capture(vec![self.config.config_file.clone()]); + let route_review = Review::capture(json!({}), vec![self.config.config_file.clone()]); let route_config = route_review .as_ref() .map_err(Clone::clone) @@ -360,7 +396,7 @@ impl Controller { let original = route_review.as_ref().map_err(Clone::clone)?; let review = Review { token: uuid::Uuid::now_v7().to_string(), - + inputs: json!({"route":route.key}), files: original.files.clone(), }; let revision = self.remember_route(&route.key, review); @@ -443,7 +479,7 @@ impl Controller { }, health, }, - + generation: self.generation, routes, clients, algorithms: ALGORITHMS @@ -582,6 +618,26 @@ impl Controller { data: json!({"client":client.name,"count":models.len(),"models":models,"warning":warning,"source":source,"fetched_at":fetched_at}), }) } + Action::PreviewRoute { + generation, + route, + algorithm, + choices, + revision, + } => { + self.check_generation(generation)?; + self.route_reviews + .get(&route) + .ok_or("Open the route editor before reviewing it.")? + .check(revision.as_deref(), &json!({"route":route}))?; + let edited = self + .routes()? + .edit(&route, algorithm_by_id(&algorithm)?, &choices)?; + Ok(Reply { + message: String::new(), + data: json!({"notes":edited.notes,"revision":revision,"file":self.config.config_file}), + }) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), @@ -695,6 +751,12 @@ fn role_info(role: &crate::server_config::Role) -> RoleInfo { } } +fn algorithm_by_id(kind: &str) -> Result<&'static crate::server_config::Algorithm, String> { + ALGORITHMS + .iter() + .find(|a| a.kind == kind) + .ok_or_else(|| "Unknown routing algorithm.".into()) +} fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; let bundle = switchyard_desktop::install::bundle_metadata(&executable); diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index b2a7917751..7cb8c70e5c 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -9,7 +9,7 @@ use std::collections::HashMap; -use toml_edit::{DocumentMut, Item, TableLike}; +use toml_edit::{DocumentMut, Item, Table, TableLike, Value}; /// Tier identifies the part a model plays in a route. A choice made for one algorithm carries /// over to the role with the same tier when the user switches algorithms. @@ -30,6 +30,11 @@ pub struct Algorithm { /// This field lists roles in display order. The list is empty for /// `random`, whose roles are the entries of its `targets` list. roles: &'static [RoleSpec], + /// This field lists settings written when a route switches to this type. They + /// match the values the routing docs use in their examples. + settings: &'static [(&'static [&'static str], Setting)], + /// This field records whether the type accepts a nested `subagents` policy. + subagents: bool, } /// RoleSpec describes one role of an algorithm. @@ -43,6 +48,21 @@ struct RoleSpec { path: &'static [&'static str], } +#[derive(Debug)] +enum Setting { + Text(&'static str), + Number(f64), +} + +impl Setting { + fn value(&self) -> Value { + match self { + Self::Text(text) => Value::from(*text), + Self::Number(number) => Value::from(*number), + } + } +} + const RANDOM: &str = "random"; /// ALGORITHMS lists supported route types in display order. @@ -59,6 +79,8 @@ pub const ALGORITHMS: &[Algorithm] = &[ tier: Tier::Capable, path: &["target"], }], + settings: &[], + subagents: true, }, Algorithm { kind: RANDOM, @@ -66,6 +88,8 @@ pub const ALGORITHMS: &[Algorithm] = &[ summary: "This algorithm splits requests at random between models \ for A/B tests and baselines.", roles: &[], + settings: &[], + subagents: false, }, Algorithm { kind: "llm_classifier", @@ -91,6 +115,11 @@ pub const ALGORITHMS: &[Algorithm] = &[ path: &["weak_target"], }, ], + settings: &[ + (&["mode"], Setting::Text("capability")), + (&["base_threshold"], Setting::Number(0.5)), + ], + subagents: false, }, Algorithm { kind: "composite", @@ -117,6 +146,15 @@ pub const ALGORITHMS: &[Algorithm] = &[ path: &["stage", "efficient_target"], }, ], + settings: &[ + (&["classifier", "base_threshold"], Setting::Number(0.5)), + ( + &["classifier", "classify_trigger"], + Setting::Text("user_turn"), + ), + (&["stage", "confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, }, Algorithm { kind: "stage_router", @@ -137,6 +175,11 @@ pub const ALGORITHMS: &[Algorithm] = &[ path: &["efficient_target"], }, ], + settings: &[ + (&["picker"], Setting::Text("efficient_first")), + (&["confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, }, Algorithm { kind: "advisor", @@ -157,6 +200,8 @@ pub const ALGORITHMS: &[Algorithm] = &[ path: &["advisor_target"], }, ], + settings: &[], + subagents: false, }, Algorithm { kind: "plan_execute", @@ -177,6 +222,8 @@ pub const ALGORITHMS: &[Algorithm] = &[ path: &["efficient_target"], }, ], + settings: &[], + subagents: false, }, Algorithm { kind: "auto", @@ -196,9 +243,35 @@ pub const ALGORITHMS: &[Algorithm] = &[ path: &["efficient_target"], }, ], + settings: &[], + subagents: false, }, ]; +/// COMMON_ROUTE_KEYS lists keys that every route type accepts. Switching types keeps these and removes +/// the rest, so settings of the old type cannot fail the new type's checks. +const COMMON_ROUTE_KEYS: [&str; 6] = [ + "id", + "type", + "context_window", + "tool_calling", + "reasoning", + "vision", +]; + +/// REQUEST_SETTINGS lists target settings that change the request body. The server keeps one +/// target per model on a client, so it rejects two targets that name the +/// same model on the same client with different values for these. +const REQUEST_SETTINGS: [&str; 3] = ["omit_body_fields", "reasoning_effort", "extra_body"]; + +/// TARGET_SETTINGS lists settings that a route takes on when it uses the target. +const TARGET_SETTINGS: [&str; 4] = [ + "system_prompt", + "reasoning_effort", + "extra_body", + "omit_body_fields", +]; + /// Route describes one entry under `[routes]`. #[derive(Clone, Debug, PartialEq)] pub struct Route { @@ -258,7 +331,16 @@ enum Slot { Listed(usize), } -impl Slot {} +impl Slot { + /// This function returns the slot's key path under the route, such as + /// `stage.capable_target`. + fn path(&self) -> String { + match self { + Self::Key(path) => path.join("."), + Self::Listed(_) => "targets".to_string(), + } + } +} impl Algorithm { /// This function returns the roles that a new route of this type needs. A new @@ -293,6 +375,13 @@ impl Algorithm { } } +/// Edited stores the result of [`ServerConfig::edit`]. +#[derive(Debug)] +pub struct Edited { + /// This field stores user-facing notes about the edit. + pub notes: Vec, +} + /// ServerConfig keeps the parsed config and the original text's layout. #[derive(Clone, Debug)] pub struct ServerConfig { @@ -405,6 +494,152 @@ impl ServerConfig { .collect() } + /// This function returns the config text with `route` switched to `algorithm`, using + /// one choice per role, and notes about the edit. + /// + /// Each role keeps its target if it already names the selected model. + /// Otherwise, this function reuses a matching target with equivalent settings, + /// or with request settings that prevent the server from accepting a duplicate. + /// If neither exists, this function edits an unshared target or copies a shared one. + /// Roles earlier in this edit also count as users of a target. A changed or copied target keeps settings + /// such as `extra_body` and `omit_body_fields`, including settings meant + /// for the old model. Targets no route uses any more are left in the file. + pub fn edit( + &self, + route: &str, + algorithm: &Algorithm, + choices: &[Choice], + ) -> Result { + if self.route(route).is_none() { + return Err(format!("The config has no [routes.{route}] table.")); + } + let current = self + .algorithm(route) + .ok_or("This route must be edited in the server config file.")?; + let roles = if algorithm.kind == RANDOM { + if choices.len() < 2 { + return Err("Random routing needs at least two models.".into()); + } + algorithm.roles_for(choices.len()) + } else { + self.roles(route, algorithm) + }; + if roles.len() != choices.len() { + return Err(format!( + "{} needs {} models, not {}.", + algorithm.kind, + roles.len(), + choices.len() + )); + } + let clients = self.clients(); + for (role, choice) in roles.iter().zip(choices) { + if choice.model.trim().is_empty() { + return Err(format!("Pick a model for {}.", role.label)); + } + if choice.client.is_empty() { + return Err(format!("Pick an endpoint for {}.", role.label)); + } + if !clients.iter().any(|client| client.name == choice.client) { + return Err(format!( + "The config has no LLM client named \"{}\".", + choice.client + )); + } + } + + let same_type = current.kind == algorithm.kind; + let old_roles = self.roles(route, current); + // When the type changes, each new role starts from the old role with + // the same tier, so a composite's capable target stays the capable one. + let by_tier: Vec<(Option, &str)> = old_roles + .iter() + .filter_map(|role| Some((role.tier, self.target_at(route, &role.slot)?))) + .collect(); + let mut users = self.target_users(); + let place = self.new_target_position(); + + let mut doc = self.doc.clone(); + let mut names = Vec::with_capacity(roles.len()); + let mut notes = Vec::new(); + for (role, choice) in roles.iter().zip(choices) { + let existing = if same_type { + self.target_at(route, &role.slot) + } else { + role.tier.and_then(|tier| { + by_tier + .iter() + .find(|(old, _)| *old == Some(tier)) + .map(|(_, name)| *name) + }) + }; + let choice = Choice { + client: choice.client.clone(), + model: choice.model.trim().to_string(), + }; + let (name, moved_from) = + choose_target(&mut doc, &mut users, route, role, &choice, existing, place)?; + if let Some(old) = moved_from + && let Some(note) = kept_settings_note(&doc, &clients, &name, &old, &choice) + { + notes.push(note); + } + let mut others: Vec<&str> = users + .get(name.as_str()) + .into_iter() + .flatten() + .copied() + .filter(|other| *other != route) + .collect(); + others.dedup(); + if Some(name.as_str()) != existing && !others.is_empty() { + let plural = if others.len() == 1 { "" } else { "s" }; + notes.push(format!( + "{} now shares [targets.{name}] with route{plural} {}.", + role.label, + others.join(", ") + )); + } + names.push(name); + } + notes.extend(self.login_note(route, choices)); + notes.extend(self.generated_note(route, &names)); + + let table = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .and_then(|routes| routes.get_mut(route)) + .and_then(Item::as_table_like_mut) + .ok_or_else(|| format!("The config has no [routes.{route}] table."))?; + if !same_type { + let old_slots: Vec = old_roles.iter().map(|role| role.slot.path()).collect(); + let removed = reset_route(table, algorithm, &old_slots); + if !removed.is_empty() { + notes.push(format!( + "Switching to {} removed these settings from [routes.{route}]: {}. The \ + backup still has them.", + algorithm.kind, + removed.join(", ") + )); + } + } + if algorithm.kind == RANDOM { + table.insert( + "targets", + Item::Value(toml_edit::Value::Array(toml_edit::Array::new())), + ); + } + for (role, name) in roles.iter().zip(&names) { + set_slot(table, &role.slot, name); + } + if !same_type { + for (path, setting) in algorithm.settings { + set_at(table, path, setting.value()); + } + } + Ok(Edited { notes }) + } + fn table(&self, name: &str) -> Option<&dyn TableLike> { self.doc.get(name)?.as_table_like() } @@ -435,6 +670,383 @@ impl ServerConfig { Slot::Listed(index) => route.get("targets")?.as_array()?.get(*index)?.as_str(), } } + + /// This function returns each target name with the routes that use it. + /// Each mention adds one entry, including mentions in nested policies. + /// A target with one entry is used in one role. + fn target_users(&self) -> HashMap<&str, Vec<&str>> { + let mut users: HashMap<&str, Vec<&str>> = entries(self.table("targets")) + .map(|(name, _)| (name, Vec::new())) + .collect(); + for (route, item) in self + .table("routes") + .into_iter() + .flat_map(|routes| routes.iter()) + { + visit_strings(item, &mut |text| { + if let Some(routes) = users.get_mut(text) { + routes.push(route); + } + }); + } + users + } + + /// This function returns the file position for a new `[targets.*]` table. The table + /// goes after the last target outside every generated block, so that the + /// next rewrite of a block cannot delete it. When every target sits + /// inside a block, any other place is next to a generated target, so the + /// table goes before the first table of the file. + fn new_target_position(&self) -> Option { + self.table("targets")? + .iter() + .filter(|(key, _)| self.generated_by("targets", key).is_none()) + .filter_map(|(_, item)| item.as_table()?.position()) + .max() + .or_else(|| { + self.generated + .keys() + .any(|key| key.starts_with("targets.")) + .then_some(-1) + }) + } + + /// This function says so when the edit sends the callers' logins to another host. A + /// client with `forward_auth` sends each caller's own login, and a caller + /// that has none for the new host gets HTTP 401. + fn login_note(&self, route: &str, choices: &[Choice]) -> Option { + let clients = self.clients(); + let before = login_hosts(&clients, &self.choices(route)); + let after = login_hosts(&clients, choices); + if after.iter().all(|host| before.contains(host)) { + return None; + } + let was = if before.is_empty() { + String::new() + } else { + format!(" It sent them to {} before.", before.join(" and ")) + }; + Some(format!( + "[routes.{route}] now sends each caller's own login to {}.{was} A caller that has \ + no login for the new host gets HTTP 401.", + after.join(" and ") + )) + } + + /// This function returns a warning when the route sits inside a block that another tool + /// writes, which overwrites the route, or when the route uses targets + /// inside such a block, which that tool can change or remove. + fn generated_note(&self, route: &str, targets: &[String]) -> Option { + if let Some(block) = self.generated_by("routes", route) { + return Some(format!( + "[routes.{route}] sits inside a block that another tool writes. That tool \ + overwrites this change the next time it runs. Block marker: \"{block}\"." + )); + } + let inside: Vec = targets + .iter() + .filter(|name| self.generated_by("targets", name).is_some()) + .map(|name| format!("[targets.{name}]")) + .collect(); + let (verb, pronoun) = if inside.len() == 1 { + ("sits", "it") + } else { + ("sit", "them") + }; + (!inside.is_empty()).then(|| { + format!( + "[routes.{route}] uses {}, which {verb} inside a block that another tool \ + writes. The next time that tool runs, it can change or remove {pronoun}.", + inside.join(", ") + ) + }) + } +} + +/// This function picks or writes the target that serves `choice` for one role. Returns its +/// name, and the model that the target named before when the edit changed +/// the target in place or copied it. Reusing a target adds the route to the +/// target's users, so a later role in the same edit copies that target +/// instead of changing it in place. +/// A new target goes at file position `place`, where the next rewrite of a +/// generated block cannot delete it. +fn choose_target<'a>( + doc: &mut DocumentMut, + users: &mut HashMap<&'a str, Vec<&'a str>>, + route: &'a str, + role: &Role, + choice: &Choice, + existing: Option<&str>, + place: Option, +) -> Result<(String, Option), String> { + if doc.get("targets").is_none() { + doc["targets"] = Item::Table(Table::new()); + } + let targets = doc + .get_mut("targets") + .and_then(Item::as_table_like_mut) + .ok_or("Targets must be a table.")?; + + let current = existing + .and_then(|name| targets.get(name)) + .and_then(Item::as_table_like); + if let Some(name) = existing + && current.is_some_and(|target| names_choice(target, choice)) + { + return Ok((name.to_string(), None)); + } + let old = current.and_then(|target| { + Some(Choice { + client: text(target, "llm_client")?.to_string(), + model: text(target, "id")?.to_string(), + }) + }); + if let Some(name) = reusable(&*targets, current, choice).map(str::to_string) { + if let Some(routes) = users.get_mut(name.as_str()) { + routes.push(route); + } + return Ok((name, None)); + } + if let Some(name) = existing + && users.get(name).is_some_and(|routes| routes.len() == 1) + && let Some(target) = targets.get_mut(name).and_then(Item::as_table_like_mut) + { + set_value(target, "id", Value::from(choice.model.as_str())); + set_value(target, "llm_client", Value::from(choice.client.as_str())); + return Ok((name.to_string(), old)); + } + + let mut table = Table::new(); + if let Some(source) = existing + .and_then(|name| targets.get(name)) + .and_then(Item::as_table_like) + { + for (key, item) in source.iter() { + let mut item = item.clone(); + unplace(&mut item); + table.insert(key, item); + } + } + set_value(&mut table, "id", Value::from(choice.model.as_str())); + set_value( + &mut table, + "llm_client", + Value::from(choice.client.as_str()), + ); + let base = format!("{route}_{}", role.label.to_lowercase().replace(' ', "_")); + let mut name = base.clone(); + let mut suffix = 1; + while targets.contains_key(&name) { + suffix += 1; + name = format!("{base}_{suffix}"); + } + // A table with no position is written after the last [targets.*] table in + // the file, which can sit inside a block that another tool writes. + table.set_position(place); + targets.insert(&name, Item::Table(table)); + Ok((name, old)) +} + +/// This function returns a note about the request settings that a target kept when the +/// edit moved it from `old` to `new`, a model of another family or on a +/// client with another request format. A setting meant for the old model can +/// make the provider reject the new model's requests, and `--dry-run` does +/// not catch that. So the note lists the kept settings, or says that the +/// target has none, and the user decides. +fn kept_settings_note( + doc: &DocumentMut, + clients: &[Client], + name: &str, + old: &Choice, + new: &Choice, +) -> Option { + let format = |name: &str| { + clients + .iter() + .find(|client| client.name == name) + .map(|client| client.format.as_str()) + }; + if family(&old.model) == family(&new.model) && format(&old.client) == format(&new.client) { + return None; + } + let target = doc + .get("targets")? + .as_table_like()? + .get(name)? + .as_table_like()?; + let kept: Vec = REQUEST_SETTINGS + .iter() + .filter_map(|key| Some(format!("{key} = {}", setting(target, key)?))) + .collect(); + Some(if kept.is_empty() { + format!( + "[targets.{name}] now names {} and has no omit_body_fields, reasoning_effort, or \ + extra_body. Check whether the new model needs one of them.", + new.model + ) + } else { + format!( + "[targets.{name}] now names {} and kept {} from {}. Check that the new model \ + accepts these settings.", + new.model, + kept.join(", "), + old.model + ) + }) +} + +/// This function returns a model's family: the first word of the last part of its ID, +/// such as `gpt` for `openai/gpt-5.6-sol`. +fn family(model: &str) -> String { + let name = model.rsplit('/').next().unwrap_or(model); + name.split(['-', '.', '_', ':']) + .next() + .unwrap_or(name) + .to_lowercase() +} + +/// This function returns another target that names `choice` and that a role whose target +/// is `current` may use. The role takes on that target's settings. So it +/// uses a target with the same settings as `current`, or else a target whose +/// request settings differ from `current`, because the server would reject a +/// second target for this model with other request settings. +fn reusable<'t>( + targets: &'t dyn TableLike, + current: Option<&dyn TableLike>, + choice: &Choice, +) -> Option<&'t str> { + let same = |target: &dyn TableLike, keys: &[&str]| { + keys.iter() + .all(|key| setting(target, key) == current.and_then(|current| setting(current, key))) + }; + let candidates: Vec<(&str, &dyn TableLike)> = targets + .iter() + .filter_map(|(name, target)| Some((name, target.as_table_like()?))) + .filter(|(_, target)| names_choice(*target, choice)) + .collect(); + candidates + .iter() + .find(|(_, target)| same(*target, &TARGET_SETTINGS)) + .or_else(|| { + candidates + .iter() + .find(|(_, target)| !same(*target, &REQUEST_SETTINGS)) + }) + .map(|(name, _)| *name) +} + +/// This function returns a target's setting as plain data, so the same value written in +/// another layout compares equal. +fn setting(target: &dyn TableLike, key: &str) -> Option { + let mut table = Table::new(); + table.insert(key, target.get(key)?.clone()); + DocumentMut::from(table) + .to_string() + .parse::() + .ok()? + .remove(key) +} + +/// This function clears the file position of every table in `item`. A copied sub-table +/// then follows the header of the table it is copied into, not the header +/// of the table it came from. +fn unplace(item: &mut Item) { + if let Some(table) = item.as_table_mut() { + table.set_position(None); + for (_, child) in table.iter_mut() { + unplace(child); + } + } +} + +/// This function returns whether a target names exactly this model on this client. +fn names_choice(target: &dyn TableLike, choice: &Choice) -> bool { + text(target, "llm_client") == Some(choice.client.as_str()) + && text(target, "id") == Some(choice.model.as_str()) +} + +/// This function removes the old type's settings, sets the new `type`, and returns the +/// removed settings as key paths, such as `classifier.base_threshold`. The +/// list leaves out `old_slots`, the old roles' targets, which the new roles +/// replace. +fn reset_route( + route: &mut dyn TableLike, + algorithm: &Algorithm, + old_slots: &[String], +) -> Vec { + let kept = + |key: &str| COMMON_ROUTE_KEYS.contains(&key) || (algorithm.subagents && key == "subagents"); + let stale: Vec = route + .iter() + .map(|(key, _)| key) + .filter(|key| !kept(key)) + .map(str::to_string) + .collect(); + let mut removed = Vec::new(); + for key in stale { + if let Some(item) = route.remove(&key) { + key_paths(&key, &item, &mut removed); + } + } + removed.retain(|path| !old_slots.contains(path)); + set_value(route, "type", Value::from(algorithm.kind)); + removed +} + +/// This function adds the key path of every value in `item` to `paths`. +fn key_paths(path: &str, item: &Item, paths: &mut Vec) { + match item.as_table_like() { + Some(table) => { + for (key, child) in table.iter() { + key_paths(&format!("{path}.{key}"), child, paths); + } + } + None => paths.push(path.to_string()), + } +} + +fn set_slot(route: &mut dyn TableLike, slot: &Slot, name: &str) { + match slot { + Slot::Key(path) => set_at(route, path, Value::from(name)), + Slot::Listed(index) => { + if !route.get("targets").is_some_and(Item::is_array) { + route.insert("targets", toml_edit::value(toml_edit::Array::new())); + } + if let Some(targets) = route.get_mut("targets").and_then(Item::as_array_mut) { + if *index < targets.len() { + targets.replace(*index, name); + } else { + targets.push(name); + } + } + } + } +} + +/// This function sets a value at a key path, creating missing tables on the way. +fn set_at(table: &mut dyn TableLike, path: &[&str], value: Value) { + match path { + [] => {} + [key] => set_value(table, key, value), + [key, rest @ ..] => { + if !table.get(key).is_some_and(Item::is_table_like) { + table.insert(key, Item::Table(Table::new())); + } + if let Some(child) = table.get_mut(key).and_then(Item::as_table_like_mut) { + set_at(child, rest, value); + } + } + } +} + +/// This function replaces a value, keeping the spacing and trailing comment around it. +fn set_value(table: &mut dyn TableLike, key: &str, mut value: Value) { + if let Some(old) = table.get_mut(key).and_then(Item::as_value_mut) { + *value.decor_mut() = old.decor().clone(); + *old = value; + } else { + table.insert(key, Item::Value(value)); + } } fn entries(table: Option<&dyn TableLike>) -> impl Iterator { @@ -448,6 +1060,45 @@ fn text<'a>(table: &'a dyn TableLike, key: &str) -> Option<&'a str> { table.get(key)?.as_str() } +fn visit_strings(item: &Item, visit: &mut dyn FnMut(&str)) { + match item { + Item::Value(value) => visit_value(value, visit), + Item::Table(table) => table + .iter() + .for_each(|(_, item)| visit_strings(item, visit)), + Item::ArrayOfTables(tables) => tables + .iter() + .flat_map(Table::iter) + .for_each(|(_, item)| visit_strings(item, visit)), + Item::None => {} + } +} + +fn visit_value(value: &Value, visit: &mut dyn FnMut(&str)) { + match value { + Value::String(text) => visit(text.value()), + Value::Array(array) => array.iter().for_each(|value| visit_value(value, visit)), + Value::InlineTable(table) => table + .iter() + .for_each(|(_, value)| visit_value(value, visit)), + _ => {} + } +} + +/// This function returns the hosts that get the callers' own logins: the hosts of the +/// `forward_auth` clients that `choices` name, sorted, each once. +fn login_hosts<'c>(clients: &'c [Client], choices: &[Choice]) -> Vec<&'c str> { + let mut hosts: Vec<&str> = choices + .iter() + .filter_map(|choice| clients.iter().find(|client| client.name == choice.client)) + .filter(|client| client.forward_auth) + .map(Client::host) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts +} + /// This function finds the route and target tables that sit inside blocks another tool /// writes. A block starts at a line that begins with `# >>>` and ends at the /// next line that begins with `# <<<`, the markers that the installer scripts diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 75a7e9ae4c..22560775db 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -147,6 +147,14 @@ const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; /// and `every_controller_action_has_a_key` fails until a row's flow includes it. type ActionRow = (Tab, char, &'static str, &'static str, &'static [ActionKind]); const ACTIONS: &[ActionRow] = &[ + ( + Tab::Routes, + 'e', + "preview", + "Checks the selected route's current algorithm and models against the server \ + config and shows the notes. Nothing is saved.", + &[ActionKind::PreviewRoute], + ), ( Tab::Routes, 'm', @@ -918,6 +926,19 @@ impl App { "Type UPDATE to start", )); } + (Some(ActionKind::PreviewRoute), Ok(reply)) => { + let notes: Vec<&str> = reply.data["notes"] + .as_array() + .map(|items| items.iter().filter_map(Value::as_str).collect()) + .unwrap_or_default(); + let file = reply.data["file"].as_str().unwrap_or_default(); + self.popup = Some(Popup::Message { + title: "Route preview", + text: format!("Nothing was saved to {file}.\n\n{}", notes.join("\n")), + tone: Tone::Done, + scroll: 0, + }); + } (Some(ActionKind::Models), Ok(reply)) => { let models: Vec<&str> = reply.data["models"] .as_array() @@ -1218,8 +1239,21 @@ impl App { /// that they do not know, so a new row must also be added to its arm. fn start_action(&mut self, kind: ActionKind, key: char) -> Result, String> { let request = |action, label| Ok(Some(Request::run(action, label))); - + let generation = self.data.generation; match kind { + ActionKind::PreviewRoute => { + let route = self.selected_route()?.clone(); + request( + Action::PreviewRoute { + generation, + route: route.key, + algorithm: route.kind, + choices: route.choices, + revision: Some(route.revision), + }, + "Previewing the route", + ) + } ActionKind::Models => { let client = self.data.clients.first(); let client = client.map(|c| c.name.clone()).unwrap_or_default(); @@ -1552,6 +1586,7 @@ fn draw(frame: &mut Frame, app: &mut App) { update_status: _, // draw_settings settings: _, // draw_settings data_state: _, // every page checks availability; draw_overview shows health + generation: _, // every route form sends it back with its action routes: _, // draw_routes and the route details clients: _, // draw_endpoints, the endpoint fields, and the endpoint forms algorithms: _, // draw_routes, the route details, and the algorithm fields From 15eed30fc53830835065da34da3fd874d4be6db4 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 14/28] feat(desktop): edit route algorithms and models in the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 80 +++++- crates/switchyard-desktop/src/server.rs | 222 +++++++++++++++- .../switchyard-desktop/src/server_config.rs | 7 +- crates/switchyard-desktop/src/tui.rs | 251 +++++++++++++++--- 4 files changed, 526 insertions(+), 34 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 7f1ac8c912..6f49f3b86d 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -9,7 +9,7 @@ use crate::{ config::Config, history, models, pricing, rollup, server, - server_config::{ALGORITHMS, Choice, ServerConfig}, + server_config::{ALGORITHMS, Choice, Route, ServerConfig}, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -19,6 +19,18 @@ use std::{collections::BTreeMap, path::PathBuf}; #[strum_discriminants(name(ActionKind), derive(strum::EnumIter))] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum Action { + Editor { + generation: u64, + route: String, + algorithm: String, + }, + Apply { + generation: u64, + route: String, + algorithm: String, + choices: Vec, + revision: Option, + }, PreviewRoute { generation: u64, route: String, @@ -202,6 +214,13 @@ pub struct Reply { pub data: Value, } impl Reply { + fn saved_edit(result: server::SaveResult) -> Self { + Self { + message: result.message, + data: json!({"warning": result.warning, "details": result.details}), + } + } + fn message(message: String) -> Self { Self { message, @@ -562,6 +581,58 @@ impl Controller { server::command("open", &[&logs_dir()?.display().to_string()])?; Ok(Reply::message("Opened Switchyard logs.".into())) } + Action::Editor { + generation, + route, + algorithm, + } => { + self.check_generation(generation)?; + let review = Review::capture( + json!({"route":route}), + vec![self.config.config_file.clone()], + )?; + let config = self.routes()?; + review.check(Some(&review.token), &review.inputs)?; + current_route(&config, &route, None)?; + config + .algorithm(&route) + .ok_or("This route must be edited in the server config file.")?; + let algorithm = algorithm_by_id(&algorithm)?; + let roles = config.roles(&route, algorithm); + let revision = self.remember_route(&route, review); + let data = json!({"revision":revision,"roles": roles.iter().map(role_info).collect::>(),"choices":config.choices(&route)}); + Ok(Reply { + message: algorithm.summary.into(), + data, + }) + } + Action::Apply { + generation, + route, + algorithm, + choices, + revision, + } => { + self.check_generation(generation)?; + let config = self.routes()?; + current_route(&config, &route, None)?; + config + .algorithm(&route) + .ok_or("This route must be edited in the server config file.")?; + let algorithm = algorithm_by_id(&algorithm)?; + self.route_reviews + .get(&route) + .ok_or("Open the route editor before saving.")? + .check(revision.as_deref(), &json!({"route":route}))?; + server::apply( + &self.config, + &route, + algorithm, + &choices, + self.route_reviews[&route].original()?, + ) + .map(Reply::saved_edit) + } Action::Models { client, refresh, @@ -757,6 +828,13 @@ fn algorithm_by_id(kind: &str) -> Result<&'static crate::server_config::Algorith .find(|a| a.kind == kind) .ok_or_else(|| "Unknown routing algorithm.".into()) } +fn current_route(config: &ServerConfig, key: &str, id: Option<&str>) -> Result { + config + .routes() + .into_iter() + .find(|r| r.key == key && id.is_none_or(|id| id == r.id)) + .ok_or_else(|| "The route changed or was removed. Refresh and choose it again.".into()) +} fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; let bundle = switchyard_desktop::install::bundle_metadata(&executable); diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index 509360e399..c39b55aace 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -1,21 +1,125 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! This module validates server configs and restarts the server service. +//! This module validates and saves server configs and restarts the server service. -use std::io::Write; +use std::fs::OpenOptions; +use std::io::{ErrorKind, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; +use chrono::Local; use switchyard_desktop::install::credentials::KeyStatus; use crate::config::Config; use crate::health::{Probe, ServerStatus}; +use crate::server_config::{Algorithm, Choice, ServerConfig}; /// RESTART_WAIT limits how long the app waits for the restarted server to answer `/health`. const RESTART_WAIT: Duration = Duration::from_secs(10); +/// BACKUPS_PER_SECOND limits the number of backups for one file within one second. +const BACKUPS_PER_SECOND: u32 = 100; + +/// SaveResult distinguishes a saved config from a failed restart or health check. +pub struct SaveResult { + pub message: String, + pub warning: bool, + pub details: Vec, +} + +/// This function switches a route to new models, checks the result with the server's +/// `--dry-run`, saves it, and restarts the server. +/// +/// The result names the changed file and reports whether the restart succeeded. +/// The details contain the backup path and edit notes. An error means the file was not changed. +pub fn apply( + settings: &Config, + route: &str, + algorithm: &Algorithm, + choices: &[Choice], + expected: &str, +) -> Result { + let path = &settings.config_file; + // Read the file again so an edit made while the window was open is kept. + let text = std::fs::read_to_string(path).map_err(|error| { + format!( + "Could not read {}: {error}. Check config_file in the app settings.", + path.display() + ) + })?; + check_original(&text, expected)?; + let config = ServerConfig::parse(&text)?; + let edited = config.edit(route, algorithm, choices)?; + save_edit(settings, &text, edited, "Updated the route.") +} + +fn check_original(text: &str, expected: &str) -> Result<(), String> { + if text != expected { + return Err("The server config changed since the editor loaded. \ + Review it again; your draft is preserved." + .into()); + } + Ok(()) +} + +fn save_edit( + settings: &Config, + text: &str, + edited: crate::server_config::Edited, + summary: &str, +) -> Result { + let path = &settings.config_file; + if edited.text == text { + return Ok(SaveResult { + message: "Nothing to save. The route already uses this algorithm and these models." + .into(), + warning: false, + details: Vec::new(), + }); + } + + let binary = server_binary()?; + let backup = save_checked(&binary, path, text, &edited.text)?; + let saved = format!("{summary} Changed {}.", path.display()); + let health = format!("{}/health", settings.server_url); + let (message, warning) = match restart(&settings.launchd_label) { + Err(error) => ( + format!( + "{saved} Switchyard could not restart: {error}. \ + Use Restart server in Settings to try again." + ), + true, + ), + Ok(()) if wait_for_health(&settings.server_url) => { + (format!("{saved} Restarted Switchyard."), false) + } + Ok(()) => ( + format!( + "{saved} Switchyard restarted but did not answer {health} within {} seconds. \ + Check the server log in ~/.switchyard/logs/.", + RESTART_WAIT.as_secs() + ), + true, + ), + }; + let mut details = vec![format!("Backup: {}", backup.display())]; + details.extend(edited.notes); + if warning { + details.push(format!( + "To undo, copy {} over {} and use Restart server in Settings.", + backup.display(), + path.display() + )); + } + Ok(SaveResult { + message, + warning, + details, + }) +} + /// This function finds the installed helper, which is the only program that reads or writes /// server keys. fn helper() -> Result { @@ -104,6 +208,120 @@ fn server_binary() -> Result { ) } +/// This function checks `text` with `switchyard-server --dry-run`, then replaces `path` +/// with it, keeping `original`, the text that `path` held, as a timestamped +/// backup. Returns the backup's path. +/// +/// The real file is `path`, or the file it points to when `path` is a +/// symlink. The new text goes to a temporary file next to the real file. The +/// temporary file is synced to disk and then renamed over the real file, so a +/// reader never sees a half-written config. On Unix, the directory is synced +/// after the rename, so a power loss keeps both the new file and the backup. +/// When the check fails, or when the real file no longer holds `original` +/// after the backup is written, the temporary file and the new backup are +/// removed, `path` does not change, and the error is returned. +fn save_checked(binary: &Path, path: &Path, original: &str, text: &str) -> Result { + // Resolve a symlink and replace the file it points to, so the link stays. + let file = std::fs::canonicalize(path) + .map_err(|error| format!("Could not find {}: {error}", path.display()))?; + let dir = file.parent().unwrap_or(Path::new("/")); + let mut candidate = tempfile::Builder::new() + .prefix(".switchyard-picker-") + .suffix(".toml") + .tempfile_in(dir) + .map_err(|error| format!("Could not create a file in {}: {error}", dir.display()))?; + candidate + .write_all(text.as_bytes()) + .and_then(|()| candidate.as_file().sync_all()) + .map_err(|error| format!("Could not write {}: {error}", candidate.path().display()))?; + if let Ok(metadata) = std::fs::metadata(&file) { + candidate + .as_file() + .set_permissions(metadata.permissions()) + .map_err(|error| { + format!( + "Could not copy the permissions of {}: {error}", + path.display() + ) + })?; + } + + // Name the real file in errors, not the temporary one. + check(binary, candidate.path()).map_err(|error| { + error.replace( + &candidate.path().display().to_string(), + &path.display().to_string(), + ) + })?; + + let backup = back_up(&file, original) + .map_err(|error| format!("Could not back up {}: {error}", path.display()))?; + // The check and the backup take a moment. Keep an edit that another + // program saved in the meantime. Compare right before the rename, because + // the rename replaces the file whatever it holds. + if std::fs::read_to_string(&file).ok().as_deref() != Some(original) { + let _ = std::fs::remove_file(&backup); + return Err(format!( + "Could not replace {}, because it changed on disk while Apply was checking it. \ + Click Apply again to apply your choices to the new file.", + path.display() + )); + } + candidate + .persist(&file) + .map_err(|error| format!("Could not replace {}: {}", path.display(), error.error))?; + // The rename has already saved the file, so a failed directory sync does not + // return an error. Windows has no directory sync. + #[cfg(unix)] + if let Ok(directory) = std::fs::File::open(dir) { + let _ = directory.sync_all(); + } + Ok(backup) +} + +/// This function writes `text` to a new backup of `file` and returns its path. The backup +/// is `.switchyard-backup.`, the name the installer uses, +/// with `-2`, `-3`, and so on added when that name is taken, so a backup +/// never replaces another one. The backup gets the file's permissions before +/// it gets the text. +fn back_up(file: &Path, text: &str) -> Result { + let permissions = std::fs::metadata(file)?.permissions(); + let stamp = Local::now().format("%Y%m%d%H%M%S"); + for number in 1..=BACKUPS_PER_SECOND { + let suffix = if number == 1 { + String::new() + } else { + format!("-{number}") + }; + let backup = PathBuf::from(format!( + "{}.switchyard-backup.{stamp}{suffix}", + file.display() + )); + let mut out = match OpenOptions::new() + .write(true) + .create_new(true) + .open(&backup) + { + Ok(out) => out, + Err(error) if error.kind() == ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + let written = out + .set_permissions(permissions.clone()) + .and_then(|()| out.write_all(text.as_bytes())) + .and_then(|()| out.sync_all()); + if let Err(error) = written { + let _ = std::fs::remove_file(&backup); + return Err(error); + } + return Ok(backup); + } + Err(std::io::Error::new( + ErrorKind::AlreadyExists, + format!("{BACKUPS_PER_SECOND} backups from this second already exist"), + )) +} + /// This function asks the installed helper to run the server's dry-run on `config` with the /// saved server keys, the same keys the installed server starts with. fn check(binary: &Path, config: &Path) -> Result<(), String> { diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index 7cb8c70e5c..055d2b262a 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -378,6 +378,8 @@ impl Algorithm { /// Edited stores the result of [`ServerConfig::edit`]. #[derive(Debug)] pub struct Edited { + /// This field stores the new config text. + pub text: String, /// This field stores user-facing notes about the edit. pub notes: Vec, } @@ -637,7 +639,10 @@ impl ServerConfig { set_at(table, path, setting.value()); } } - Ok(Edited { notes }) + Ok(Edited { + text: doc.to_string(), + notes, + }) } fn table(&self, name: &str) -> Option<&dyn TableLike> { diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 22560775db..cce1427172 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -13,6 +13,7 @@ use crate::controller::{ use crate::history::Entry; use crate::pricing::Estimate; use crate::rollup::{CLASSIFIER_TIER, ModelTokens, Totals, Usage}; +use crate::server_config::Choice; use crate::summary::{money, tokens}; use chrono::{DateTime, Utc}; use ratatui::{ @@ -150,10 +151,14 @@ const ACTIONS: &[ActionRow] = &[ ( Tab::Routes, 'e', - "preview", - "Checks the selected route's current algorithm and models against the server \ - config and shows the notes. Nothing is saved.", - &[ActionKind::PreviewRoute], + "edit", + "Changes the selected route's algorithm and models. Enter reviews the change, and \ + typing SAVE saves it and restarts the server.", + &[ + ActionKind::Editor, + ActionKind::PreviewRoute, + ActionKind::Apply, + ], ), ( Tab::Routes, @@ -415,11 +420,27 @@ enum FormKind { RemoveKey, /// The token comes from the update preview that the user read. Update(Option), + /// The route is the snapshot's copy from when the user opened the editor. The models form + /// keeps its revision and choices, so a server config that changed since then fails the + /// review instead of mixing old choices into the new file. + Algorithm { + generation: u64, + route: Box, + }, + RouteModels { + generation: u64, + route: String, + algorithm: String, + revision: Option, + }, + /// The action is the one that the user reviewed. Typing SAVE submits it unchanged. + Save(Box), Models, ModelKey, } /// Choices names the list that ↑ and ↓ cycle through in a form field. enum Choices { + Algorithms, Endpoints, } /// Submit is what a form sends: a controller action. @@ -511,9 +532,25 @@ impl Form { fn value(&self, index: usize) -> String { self.fields[index].1.trim().to_string() } + /// This function reads the choices from fields that alternate endpoint and model. + fn choices(&self) -> Vec { + self.fields + .as_chunks::<2>() + .0 + .iter() + .map(|pair| Choice { + client: pair[0].1.trim().into(), + model: pair[1].1.trim().into(), + }) + .collect() + } /// This function names the list that the selected field chooses from, if any. fn options(&self) -> Option { match self.kind { + FormKind::Algorithm { .. } => Some(Choices::Algorithms), + FormKind::RouteModels { .. } if self.index.is_multiple_of(2) => { + Some(Choices::Endpoints) + } FormKind::Models | FormKind::ModelKey if self.index == 0 => Some(Choices::Endpoints), _ => None, } @@ -570,6 +607,27 @@ impl Form { preview_token: preview_token.clone(), } } + FormKind::Algorithm { generation, route } => Action::Editor { + generation: *generation, + route: route.key.clone(), + algorithm: self.value(0), + }, + FormKind::RouteModels { + generation, + route, + algorithm, + revision, + } => Action::PreviewRoute { + generation: *generation, + route: route.clone(), + algorithm: algorithm.clone(), + choices: self.choices(), + revision: revision.clone(), + }, + FormKind::Save(action) => { + confirm("SAVE", "Type SAVE to apply the reviewed changes.")?; + (**action).clone() + } FormKind::Models => Action::Models { client: self.value(0), refresh: true, @@ -589,13 +647,84 @@ impl Form { FormKind::ImportKey => "Importing the server key", FormKind::RemoveKey => "Removing the server key", FormKind::Update(_) => "Starting the update", - + FormKind::Algorithm { .. } => "Opening the route editor", + FormKind::RouteModels { .. } => "Reviewing the route", + FormKind::Save(_) => "Saving the route", FormKind::Models => "Loading models", FormKind::ModelKey => "Saving the key and loading models", } } } +/// This function returns an endpoint field and a model field for each role and its choice. +fn role_fields<'a>(roles: impl Iterator) -> Vec<(String, String)> { + roles + .flat_map(|(label, choice)| { + let model = if label == "Model" { + label.to_string() + } else { + format!("{label} model") + }; + [ + (format!("{label} endpoint"), choice.client), + (model, choice.model), + ] + }) + .collect() +} + +/// This function opens the models form after the controller returns the roles of `algorithm`. +fn route_models_form( + generation: u64, + route: &RouteInfo, + algorithm: String, + editor: &Value, +) -> Result { + let roles = editor["roles"].as_array().ok_or("No roles returned.")?; + let fields = role_fields(roles.iter().enumerate().map(|(index, role)| { + // A role keeps its model by tier when the new algorithm changes the role order. + let matching = route.tiers.as_ref().and_then(|tiers| { + let tier = role["tier"].as_str()?; + tiers.iter().position(|t| t.as_deref() == Some(tier)) + }); + let choice = matching + .and_then(|index| route.choices.get(index)) + .or_else(|| (algorithm == route.kind).then(|| route.choices.get(index))?) + .or_else(|| route.choices.first()) + .cloned() + .unwrap_or_default(); + (role["label"].as_str().unwrap_or_default(), choice) + })); + let kind = FormKind::RouteModels { + generation, + route: route.key.clone(), + algorithm, + revision: Some(route.revision.clone()), + }; + Ok(Form::new(kind, format!("Models for {}", route.id), fields)) +} + +/// This function returns the action that a preview's review window submits after the user +/// types SAVE: the previewed inputs, plus the token for a route change. +fn reviewed(previewed: Action) -> Option { + Some(match previewed { + Action::PreviewRoute { + generation, + route, + algorithm, + choices, + revision, + } => Action::Apply { + generation, + route, + algorithm, + choices, + revision, + }, + _ => return None, + }) +} + enum Popup { Help { scroll: u16, @@ -926,18 +1055,38 @@ impl App { "Type UPDATE to start", )); } + // The reply lists the roles of the chosen algorithm. The models form replaces the + // algorithm form only while that form is still open for the same route. + (Some(ActionKind::Editor), Ok(reply)) => { + if let Some(Action::Editor { + route: key, + algorithm, + .. + }) = action + && let Some(Popup::Form(form)) = &self.popup + && let FormKind::Algorithm { generation, route } = &form.kind + && route.key == key + { + match route_models_form(*generation, route, algorithm, &reply.data) { + Ok(next) => self.popup = Some(Popup::Form(next)), + Err(error) => self.notify(error, Tone::Error), + } + } + } + // The review window replaces the form that asked for the preview, so a form that + // the user closed meanwhile stays closed. (Some(ActionKind::PreviewRoute), Ok(reply)) => { - let notes: Vec<&str> = reply.data["notes"] - .as_array() - .map(|items| items.iter().filter_map(Value::as_str).collect()) - .unwrap_or_default(); - let file = reply.data["file"].as_str().unwrap_or_default(); - self.popup = Some(Popup::Message { - title: "Route preview", - text: format!("Nothing was saved to {file}.\n\n{}", notes.join("\n")), - tone: Tone::Done, - scroll: 0, - }); + if matches!(self.popup, Some(Popup::Form(_))) + && let Some(reviewed) = action.and_then(reviewed) + { + self.popup = Some(Popup::review( + change_lines(self.theme, &reply.data), + "Save", + FormKind::Save(Box::new(reviewed)), + "Review route changes", + "Type SAVE to apply", + )); + } } (Some(ActionKind::Models), Ok(reply)) => { let models: Vec<&str> = reply.data["models"] @@ -1241,18 +1390,17 @@ impl App { let request = |action, label| Ok(Some(Request::run(action, label))); let generation = self.data.generation; match kind { - ActionKind::PreviewRoute => { - let route = self.selected_route()?.clone(); - request( - Action::PreviewRoute { - generation, - route: route.key, - algorithm: route.kind, - choices: route.choices, - revision: Some(route.revision), - }, - "Previewing the route", - ) + // The algorithm form submits Editor, the models form submits PreviewRoute, and the + // review window submits Apply. + ActionKind::Editor | ActionKind::PreviewRoute | ActionKind::Apply => { + let route = Box::new(self.selected_route()?.clone()); + let title = format!("Routing algorithm for {}", route.id); + let fields = vec![("Algorithm", route.kind.clone())]; + self.open(Form::new( + FormKind::Algorithm { generation, route }, + title, + fields, + )) } ActionKind::Models => { let client = self.data.clients.first(); @@ -1355,6 +1503,9 @@ impl App { KeyCode::Up | KeyCode::Down | KeyCode::PageUp | KeyCode::PageDown => { let forward = key.code == KeyCode::Down; match (form.options(), key.code) { + (Some(Choices::Algorithms), KeyCode::Up | KeyCode::Down) => { + form.choose(self.data.algorithms.iter().map(|a| a.kind), forward); + } (Some(Choices::Endpoints), KeyCode::Up | KeyCode::Down) => { let names = self.data.clients.iter().map(|c| c.name.as_str()); form.choose(names, forward); @@ -3011,6 +3162,22 @@ fn draw_popup(frame: &mut Frame, app: &mut App, area: Rect) { fn form_notes(app: &App, form: &Form) -> Vec> { let theme = app.theme; let data = &app.data; + let algorithms = |selected: &str| { + let names: Vec = data + .algorithms + .iter() + .map(|known| format!("{} ({})", known.title, known.kind)) + .collect(); + let mut lines = vec![Line::styled( + format!("Algorithms: {}", names.join(" · ")), + theme.muted, + )]; + if let Some(known) = data.algorithms.iter().find(|a| a.kind == selected.trim()) { + lines.push(Line::from(known.summary)); + } + lines.push(Line::default()); + lines + }; match &form.kind { FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey => { let mut lines = vec![Line::styled("Server keys", theme.muted)]; @@ -3018,7 +3185,15 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines.push(Line::default()); lines } - + FormKind::Algorithm { .. } => algorithms(&form.fields[0].1), + FormKind::RouteModels { .. } => { + let names: Vec<&str> = data.clients.iter().map(|c| c.name.as_str()).collect(); + vec![ + Line::styled(format!("Endpoints: {}", names.join(", ")), theme.muted), + Line::from("Use the endpoint name and a model ID. Unlisted model IDs are allowed."), + Line::default(), + ] + } FormKind::Models | FormKind::ModelKey => { let key = matches!(form.kind, FormKind::ModelKey); let mut lines: Vec = data @@ -3040,10 +3215,26 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines.push(Line::default()); lines } - FormKind::Preferences | FormKind::Update(_) => Vec::new(), + FormKind::Preferences | FormKind::Update(_) | FormKind::Save(_) => Vec::new(), } } +/// This function shows the file and notes of a route preview before the user types SAVE. +fn change_lines(theme: Theme, preview: &Value) -> Vec> { + let file = preview["file"].as_str().unwrap_or_default().to_string(); + let mut lines = vec![labeled(theme, "Changed file", 13, file)]; + let notes = preview["notes"].as_array().map(Vec::as_slice); + for note in notes.unwrap_or_default().iter().filter_map(Value::as_str) { + lines.push(Line::default()); + lines.push(Line::from(note.to_string())); + } + lines.push(Line::default()); + lines.push(Line::from( + "Switchyard validates the config, saves a backup, and restarts the server.", + )); + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { From 4d188e9725a13896ab5ced72a9bdf1dceb345879 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 15/28] feat(desktop): create, rename, duplicate, and delete routes in the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 104 ++++++ crates/switchyard-desktop/src/server.rs | 26 ++ .../switchyard-desktop/src/server_config.rs | 182 ++++++++++ crates/switchyard-desktop/src/tui.rs | 320 +++++++++++++++++- 4 files changed, 621 insertions(+), 11 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 6f49f3b86d..2dd478edcf 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -31,6 +31,12 @@ pub enum Action { choices: Vec, revision: Option, }, + Remove { + generation: u64, + route: String, + id: String, + revision: Option, + }, PreviewRoute { generation: u64, route: String, @@ -38,6 +44,25 @@ pub enum Action { choices: Vec, revision: Option, }, + PreviewRouteChange { + generation: u64, + operation: String, + route: String, + name: String, + id: String, + algorithm: Option, + choices: Vec, + }, + RouteChange { + generation: u64, + operation: String, + route: String, + name: String, + id: String, + algorithm: Option, + choices: Vec, + preview_token: Option, + }, Models { client: String, refresh: bool, @@ -294,6 +319,7 @@ pub struct Controller { // The generation ties a draft to the server config file selected by the settings. generation: u64, route_reviews: std::collections::HashMap, + route_change: Option, update_review: Option<(String, Value)>, } impl Controller { @@ -305,6 +331,7 @@ impl Controller { reader: rollup::Reader::default(), generation: 0, route_reviews: Default::default(), + route_change: None, update_review: None, } } @@ -313,6 +340,7 @@ impl Controller { if config.config_file != self.config.config_file { self.generation += 1; self.route_reviews.clear(); + self.route_change = None; } self.config = config; @@ -633,6 +661,82 @@ impl Controller { ) .map(Reply::saved_edit) } + Action::Remove { + generation, + route, + id, + revision, + } => { + self.check_generation(generation)?; + current_route(&self.routes()?, &route, Some(&id))?; + self.route_reviews + .get(&route) + .ok_or("Open the route editor before removing it.")? + .check(revision.as_deref(), &json!({"route":route}))?; + server::remove( + &self.config, + &route, + &id, + self.route_reviews[&route].original()?, + ) + .map(Reply::saved_edit) + } + Action::PreviewRouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + } => { + self.check_generation(generation)?; + let inputs = json!({"operation":operation,"route":route,"name":name,"id":id,"algorithm":algorithm,"choices":choices}); + let review = Review::capture(inputs, vec![self.config.config_file.clone()])?; + let edited = self.routes()?.change_route( + &operation, + &route, + &name, + &id, + algorithm.as_deref().map(algorithm_by_id).transpose()?, + &choices, + )?; + review.check(Some(&review.token), &review.inputs)?; + let data = json!({"notes":edited.notes,"file":self.config.config_file,"preview_token":review.token}); + self.route_change = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::RouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + preview_token, + } => { + self.check_generation(generation)?; + let inputs = json!({"operation":operation,"route":route,"name":name,"id":id,"algorithm":algorithm,"choices":choices}); + let review = self + .route_change + .as_ref() + .ok_or("Review the route change first.")?; + review.check(preview_token.as_deref(), &inputs)?; + let edited = self.routes()?.change_route( + &operation, + &route, + &name, + &id, + algorithm.as_deref().map(algorithm_by_id).transpose()?, + &choices, + )?; + server::save_route_change(&self.config, review.original()?, edited) + .map(Reply::saved_edit) + } Action::Models { client, refresh, diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index c39b55aace..4fdbe0de0c 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -55,6 +55,32 @@ pub fn apply( save_edit(settings, &text, edited, "Updated the route.") } +pub fn remove( + settings: &Config, + route: &str, + id: &str, + expected: &str, +) -> Result { + let text = std::fs::read_to_string(&settings.config_file).map_err(|error| error.to_string())?; + check_original(&text, expected)?; + let config = ServerConfig::parse(&text)?; + if !config.routes().iter().any(|r| r.key == route && r.id == id) { + return Err("The route changed or was removed. Refresh and choose it again.".into()); + } + let edited = config.remove(route)?; + save_edit(settings, &text, edited, "Removed the route.") +} + +pub fn save_route_change( + settings: &Config, + expected: &str, + edited: crate::server_config::Edited, +) -> Result { + let text = std::fs::read_to_string(&settings.config_file).map_err(|error| error.to_string())?; + check_original(&text, expected)?; + save_edit(settings, &text, edited, "Changed the routes.") +} + fn check_original(text: &str, expected: &str) -> Result<(), String> { if text != expected { return Err("The server config changed since the editor loaded. \ diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index 055d2b262a..da1817407b 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -417,6 +417,132 @@ impl ServerConfig { .collect() } + pub fn remove(&self, route: &str) -> Result { + let mut doc = self.doc.clone(); + let routes = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .ok_or("No routes are configured.")?; + let removed = routes + .remove(route) + .ok_or("The route was already removed. Refresh and choose it again.")?; + retain_block_markers(&mut doc, &removed); + Ok(Edited { + text: doc.to_string(), + notes: vec![ + "Removed the route. Its targets and endpoint settings are preserved.".into(), + ], + }) + } + + pub fn change_route( + &self, + operation: &str, + route: &str, + name: &str, + id: &str, + algorithm: Option<&Algorithm>, + choices: &[Choice], + ) -> Result { + if id.trim().is_empty() || id.contains(['\n', '\r']) { + return Err("Enter a nonempty public route ID without line breaks.".into()); + } + if self + .routes() + .iter() + .any(|existing| existing.id == id && !(operation == "rename" && existing.key == route)) + { + return Err("That public route ID is already configured.".into()); + } + let mut doc = self.doc.clone(); + if operation == "rename" { + let table = doc + .get_mut("routes") + .and_then(Item::as_table_like_mut) + .and_then(|routes| routes.get_mut(route)) + .and_then(Item::as_table_like_mut) + .ok_or("Choose an existing route.")?; + table.insert("id", toml_edit::value(id)); + let mut notes = vec![ + "Harnesses that select the old public route ID must choose the new ID.".into(), + ]; + notes.extend(self.generated_note(route, &[])); + return Ok(Edited { + text: doc.to_string(), + notes, + }); + } + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err("Route names need 1–64 letters, numbers, hyphens, or underscores.".into()); + } + if self.route(name).is_some() { + return Err("That route name is already configured.".into()); + } + let mut table = match operation { + "duplicate" => self + .table("routes") + .and_then(|routes| routes.get(route)) + .cloned() + .ok_or("Choose an existing route.")?, + "create" => { + let mut table = toml_edit::Table::new(); + table.insert("type", toml_edit::value("passthrough")); + table.insert("target", toml_edit::value("")); + Item::Table(table) + } + _ => return Err("Choose Create, Duplicate, or Rename.".into()), + }; + let mut copied = vec![&mut table]; + while let Some(item) = copied.pop() { + if let Some(table) = item.as_table_mut() { + table.set_position(Some(-1)); + if let Some(prefix) = table.decor().prefix().and_then(|prefix| prefix.as_str()) { + let prefix = prefix + .split_inclusive('\n') + .filter(|line| { + !line.trim().starts_with("# >>>") && !line.trim().starts_with("# <<<") + }) + .collect::(); + table.decor_mut().set_prefix(prefix); + } + copied.extend(table.iter_mut().map(|(_, item)| item)); + } + } + table + .as_table_like_mut() + .ok_or("Route must be a table.")? + .insert("id", toml_edit::value(id)); + if doc.get("routes").is_none() { + doc["routes"] = Item::Table(toml_edit::Table::new()); + } + doc["routes"] + .as_table_like_mut() + .ok_or("Routes must be a table.")? + .insert(name, table); + let candidate = ServerConfig::parse(&doc.to_string())?; + if operation == "create" { + candidate.edit( + name, + algorithm.ok_or("Choose a routing algorithm.")?, + choices, + ) + } else { + Ok(Edited { + text: doc.to_string(), + notes: vec![ + "The duplicate initially shares targets and endpoint settings \ + with the source route." + .into(), + ], + }) + } + } + pub fn clients(&self) -> Vec { entries(self.table("llm_clients")) .map(|(name, client)| Client { @@ -1104,6 +1230,62 @@ fn login_hosts<'c>(clients: &'c [Client], choices: &[Choice]) -> Vec<&'c str> { hosts } +// Generated-block markers can occur on nested route tables and still apply to retained targets. +fn retain_block_markers(doc: &mut DocumentMut, removed: &Item) { + let mut markers = Vec::new(); + let mut tables: Vec<_> = removed.as_table().into_iter().collect(); + while let Some(table) = tables.pop() { + if let Some(position) = table.position() + && let Some(prefix) = table.decor().prefix().and_then(|value| value.as_str()) + && prefix + .lines() + .any(|line| line.trim().starts_with("# >>>") || line.trim().starts_with("# <<<")) + { + markers.push((position, prefix.to_string())); + } + tables.extend(table.iter().filter_map(|(_, item)| item.as_table())); + } + markers.sort_by_key(|(position, _)| *position); + let mut positions = Vec::new(); + let mut tables = vec![doc.as_table()]; + while let Some(table) = tables.pop() { + positions.extend(table.position()); + tables.extend(table.iter().filter_map(|(_, item)| item.as_table())); + } + let mut prefixes: HashMap = HashMap::new(); + let mut trailing = String::new(); + for (removed_position, prefix) in markers { + if let Some(next) = positions + .iter() + .copied() + .filter(|position| *position > removed_position) + .min() + { + prefixes.entry(next).or_default().push_str(&prefix); + } else { + trailing.push_str(&prefix); + } + } + let mut tables = vec![doc.as_table_mut()]; + while let Some(table) = tables.pop() { + if let Some(position) = table.position() + && let Some(mut prefix) = prefixes.remove(&position) + { + prefix.push_str( + table + .decor() + .prefix() + .and_then(|value| value.as_str()) + .unwrap_or_default(), + ); + table.decor_mut().set_prefix(prefix); + } + tables.extend(table.iter_mut().filter_map(|(_, item)| item.as_table_mut())); + } + trailing.push_str(doc.trailing().as_str().unwrap_or_default()); + doc.set_trailing(trailing); +} + /// This function finds the route and target tables that sit inside blocks another tool /// writes. A block starts at a line that begins with `# >>>` and ends at the /// next line that begins with `# <<<`, the markers that the installer scripts diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index cce1427172..512e424394 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -160,6 +160,38 @@ const ACTIONS: &[ActionRow] = &[ ActionKind::Apply, ], ), + ( + Tab::Routes, + 'a', + "create", + "Creates a route from a public model ID, an algorithm, and its models. Type SAVE after \ + the review.", + &[ActionKind::PreviewRouteChange, ActionKind::RouteChange], + ), + ( + Tab::Routes, + 'n', + "rename", + "Changes the selected route's public model ID. Harnesses that send the old ID must \ + change too. Type SAVE after the review.", + &[ActionKind::PreviewRouteChange, ActionKind::RouteChange], + ), + ( + Tab::Routes, + 'c', + "duplicate", + "Copies the selected route under a new public model ID. The copy shares its targets. \ + Type SAVE after the review.", + &[ActionKind::PreviewRouteChange, ActionKind::RouteChange], + ), + ( + Tab::Routes, + 'd', + "delete", + "Deletes the selected route after you type its public model ID. Its targets and \ + endpoints stay, and the server config is backed up.", + &[ActionKind::Remove], + ), ( Tab::Routes, 'm', @@ -433,8 +465,30 @@ enum FormKind { algorithm: String, revision: Option, }, + /// The fields hold a new route's public model ID, config name, and algorithm. + NewRoute { + generation: u64, + }, + NewRouteModels { + generation: u64, + name: String, + id: String, + algorithm: String, + }, + /// The operation is `rename` or `duplicate`, and `route` is the selected route's config name. + RouteIdentity { + generation: u64, + operation: &'static str, + route: String, + }, /// The action is the one that the user reviewed. Typing SAVE submits it unchanged. Save(Box), + Remove { + generation: u64, + route: String, + id: String, + revision: String, + }, Models, ModelKey, } @@ -443,9 +497,11 @@ enum Choices { Algorithms, Endpoints, } -/// Submit is what a form sends: a controller action. +/// Submit is what a form sends: a controller action, or the next form of a flow that needs +/// more input before the controller can review it. enum Submit { Run(Action), + Next(Form), } /// Form holds the text fields for an action that needs input. struct Form { @@ -548,7 +604,10 @@ impl Form { fn options(&self) -> Option { match self.kind { FormKind::Algorithm { .. } => Some(Choices::Algorithms), - FormKind::RouteModels { .. } if self.index.is_multiple_of(2) => { + FormKind::NewRoute { .. } if self.index == 2 => Some(Choices::Algorithms), + FormKind::RouteModels { .. } | FormKind::NewRouteModels { .. } + if self.index.is_multiple_of(2) => + { Some(Choices::Endpoints) } FormKind::Models | FormKind::ModelKey if self.index == 0 => Some(Choices::Endpoints), @@ -570,8 +629,9 @@ impl Form { *field = names.nth(next).unwrap_or_default().to_string(); self.cursor = field.chars().count(); } - /// This function builds what the form submits. - fn action(&self) -> Result { + /// This function builds what the form submits. The snapshot supplies the roles and + /// endpoints that a new route's models form starts with. + fn action(&self, data: &Snapshot) -> Result { let confirm = |word: &str, error: &str| { if self.value(0) == word { Ok(()) @@ -624,10 +684,86 @@ impl Form { choices: self.choices(), revision: revision.clone(), }, + FormKind::NewRoute { generation } => { + let id = self.value(0); + let algorithm = self.value(2); + let roles = &data + .algorithms + .iter() + .find(|known| known.kind == algorithm) + .ok_or("Unknown routing algorithm. Press ↑ or ↓ to choose one.")? + .roles; + let choice = Choice { + client: data + .clients + .first() + .map(|client| client.name.clone()) + .unwrap_or_default(), + model: String::new(), + }; + let fields = role_fields(roles.iter().map(|role| (&*role.label, choice.clone()))); + let title = format!("Models for the new route {id}"); + let kind = FormKind::NewRouteModels { + generation: *generation, + name: config_name(&id, &self.value(1)), + id, + algorithm, + }; + return Ok(Submit::Next(Form::new(kind, title, fields))); + } + FormKind::NewRouteModels { + generation, + name, + id, + algorithm, + } => Action::PreviewRouteChange { + generation: *generation, + operation: "create".into(), + route: String::new(), + name: name.clone(), + id: id.clone(), + algorithm: Some(algorithm.clone()), + choices: self.choices(), + }, + FormKind::RouteIdentity { + generation, + operation, + route, + } => { + let id = self.value(0); + // A rename keeps the config name, so its form has no config name field. + let name = match self.fields.get(1) { + Some((_, typed)) => config_name(&id, typed.trim()), + None => String::new(), + }; + Action::PreviewRouteChange { + generation: *generation, + operation: operation.to_string(), + route: route.clone(), + name, + id, + algorithm: None, + choices: Vec::new(), + } + } FormKind::Save(action) => { confirm("SAVE", "Type SAVE to apply the reviewed changes.")?; (**action).clone() } + FormKind::Remove { + generation, + route, + id, + revision, + } => { + confirm(id, "Route name did not match. Nothing was deleted.")?; + Action::Remove { + generation: *generation, + route: route.clone(), + id: id.clone(), + revision: Some(revision.clone()), + } + } FormKind::Models => Action::Models { client: self.value(0), refresh: true, @@ -649,7 +785,11 @@ impl Form { FormKind::Update(_) => "Starting the update", FormKind::Algorithm { .. } => "Opening the route editor", FormKind::RouteModels { .. } => "Reviewing the route", + FormKind::NewRoute { .. } + | FormKind::NewRouteModels { .. } + | FormKind::RouteIdentity { .. } => "Reviewing the route change", FormKind::Save(_) => "Saving the route", + FormKind::Remove { .. } => "Deleting the route", FormKind::Models => "Loading models", FormKind::ModelKey => "Saving the key and loading models", } @@ -673,6 +813,22 @@ fn role_fields<'a>(roles: impl Iterator) -> Vec<(Strin .collect() } +/// This function returns the typed config name, or one made from the public model ID the same +/// way the desktop app fills a blank config name. +fn config_name(id: &str, typed: &str) -> String { + if !typed.is_empty() { + return typed.to_string(); + } + id.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .filter(|part| !part.is_empty()) + .collect::>() + .join("-") + .trim_matches('-') + .chars() + .take(64) + .collect() +} + /// This function opens the models form after the controller returns the roles of `algorithm`. fn route_models_form( generation: u64, @@ -706,7 +862,7 @@ fn route_models_form( /// This function returns the action that a preview's review window submits after the user /// types SAVE: the previewed inputs, plus the token for a route change. -fn reviewed(previewed: Action) -> Option { +fn reviewed(previewed: Action, preview: &Value) -> Option { Some(match previewed { Action::PreviewRoute { generation, @@ -721,6 +877,24 @@ fn reviewed(previewed: Action) -> Option { choices, revision, }, + Action::PreviewRouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + } => Action::RouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + preview_token: preview["preview_token"].as_str().map(str::to_owned), + }, _ => return None, }) } @@ -1075,9 +1249,9 @@ impl App { } // The review window replaces the form that asked for the preview, so a form that // the user closed meanwhile stays closed. - (Some(ActionKind::PreviewRoute), Ok(reply)) => { + (Some(ActionKind::PreviewRoute | ActionKind::PreviewRouteChange), Ok(reply)) => { if matches!(self.popup, Some(Popup::Form(_))) - && let Some(reviewed) = action.and_then(reviewed) + && let Some(reviewed) = action.and_then(|action| reviewed(action, &reply.data)) { self.popup = Some(Popup::review( change_lines(self.theme, &reply.data), @@ -1402,6 +1576,73 @@ impl App { fields, )) } + // The route form submits PreviewRouteChange, and the review window submits + // RouteChange. A new route also needs a models form before the review. + ActionKind::PreviewRouteChange | ActionKind::RouteChange => { + const CONFIG_NAME: &str = "Config name (blank uses the public model ID)"; + let form = match key { + 'a' => { + let algorithm = self.data.algorithms.first().map(|a| a.kind); + Form::new( + FormKind::NewRoute { generation }, + "Create a route", + vec![ + ("Public model ID", String::new()), + (CONFIG_NAME, String::new()), + ("Algorithm", algorithm.unwrap_or_default().to_string()), + ], + ) + } + 'n' => { + let route = self.selected_route()?; + Form::new( + FormKind::RouteIdentity { + generation, + operation: "rename", + route: route.key.clone(), + }, + format!("Rename {}", route.id), + vec![("Public model ID", route.id.clone())], + ) + } + 'c' => { + let route = self.selected_route()?; + Form::new( + FormKind::RouteIdentity { + generation, + operation: "duplicate", + route: route.key.clone(), + }, + format!("Duplicate {}", route.id), + vec![ + ("Public model ID", String::new()), + (CONFIG_NAME, String::new()), + ], + ) + } + _ => return Err(format!("The {key} key does not start a route change.")), + }; + self.open(form) + } + ActionKind::Remove => { + let route = self.selected_route()?; + let lines = remove_lines(self.theme, route, self.data.settings.as_ref()); + let title = format!("Delete route {}?", route.id); + let kind = FormKind::Remove { + generation, + route: route.key.clone(), + id: route.id.clone(), + revision: route.revision.clone(), + }; + self.popup = Some(Popup::review( + lines, + "Delete", + kind, + title, + "Type the route name to delete", + )); + Ok(None) + } ActionKind::Models => { let client = self.data.clients.first(); let client = client.map(|c| c.name.clone()).unwrap_or_default(); @@ -1494,8 +1735,9 @@ impl App { Popup::Form(form) | Popup::Review { confirm: form, .. } => { match key.code { KeyCode::Esc => self.popup = None, - KeyCode::Enter => match form.action() { + KeyCode::Enter => match form.action(&self.data) { Ok(Submit::Run(action)) => return Some(Request::run(action, form.label())), + Ok(Submit::Next(next)) => self.popup = Some(Popup::Form(next)), Err(error) => form.error = error, }, // A field with choices cycles through them. Otherwise only the review @@ -2585,7 +2827,7 @@ fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { .title(Line::from(title)); if visible == 0 { let text = if app.data.routes.is_empty() { - "No routes in the server config." + "No routes in the server config. Press a to create one." } else { "No routes match the filter. Press Esc to clear it." }; @@ -3178,6 +3420,19 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines.push(Line::default()); lines }; + let identity = |config_name: bool| { + let mut lines = vec![Line::from( + "Harnesses send the public model ID as the model. Each route needs its own ID.", + )]; + if config_name { + lines.push(Line::from( + "The config name is the route's table name in the server config, as in \ + [routes.team-router]. It uses 1–64 letters, digits, hyphens, or underscores.", + )); + } + lines.push(Line::default()); + lines + }; match &form.kind { FormKind::SaveKey | FormKind::ImportKey | FormKind::RemoveKey => { let mut lines = vec![Line::styled("Server keys", theme.muted)]; @@ -3186,7 +3441,13 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines } FormKind::Algorithm { .. } => algorithms(&form.fields[0].1), - FormKind::RouteModels { .. } => { + FormKind::NewRoute { .. } => { + let mut lines = identity(true); + lines.extend(algorithms(&form.fields[2].1)); + lines + } + FormKind::RouteIdentity { operation, .. } => identity(*operation != "rename"), + FormKind::RouteModels { .. } | FormKind::NewRouteModels { .. } => { let names: Vec<&str> = data.clients.iter().map(|c| c.name.as_str()).collect(); vec![ Line::styled(format!("Endpoints: {}", names.join(", ")), theme.muted), @@ -3215,7 +3476,10 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines.push(Line::default()); lines } - FormKind::Preferences | FormKind::Update(_) | FormKind::Save(_) => Vec::new(), + FormKind::Preferences + | FormKind::Update(_) + | FormKind::Save(_) + | FormKind::Remove { .. } => Vec::new(), } } @@ -3235,6 +3499,40 @@ fn change_lines(theme: Theme, preview: &Value) -> Vec> { lines } +/// This function explains what deleting a route keeps and who must change. +fn remove_lines( + theme: Theme, + route: &RouteInfo, + settings: Option<&AppSettings>, +) -> Vec> { + let mut lines = vec![Line::from(format!( + "This removes the public model ID from the server. Targets and harness settings are \ + kept. Any harness using {} must choose another route.", + route.id + ))]; + if let Some(generated) = &route.generated { + lines.push(Line::default()); + lines.push(Line::styled( + format!("Generated by {generated}. That tool can add the route back."), + theme.warn, + )); + } + if let Some(settings) = settings { + lines.push(Line::default()); + lines.push(labeled( + theme, + "Changed file", + 13, + settings.config_file.display().to_string(), + )); + } + lines.push(Line::default()); + lines.push(Line::from( + "Switchyard validates the config, saves a backup, and restarts the server.", + )); + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { From 15a94df3d981ded6f4c074d7d60f8f268a25fcc0 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 16/28] test(desktop): verify route editing preserves shared targets and backups Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/server.rs | 120 ++++++++++ .../switchyard-desktop/src/server_config.rs | 221 ++++++++++++++++++ 2 files changed, 341 insertions(+) diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index 4fdbe0de0c..93b8869521 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -460,3 +460,123 @@ pub fn command(program: &str, args: &[&str]) -> Result { } Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + const OLD: &str = "schema_version=1\n[targets]\n[routes]\n#old\n"; + const NEW: &str = "schema_version=1\n[targets]\n[routes]\n#new\n"; + const NEWER: &str = "schema_version=1\n[targets]\n[routes]\n#newer\n"; + use std::os::unix::fs::PermissionsExt; + + /// The fixture forwards the helper's validation command to a stand-in server that runs `body`. + fn fake_server(dir: &Path, body: &str) -> PathBuf { + let path = dir.join("switchyard-server"); + std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)) + .expect("make script executable"); + let helper = dir.join("switchyard-desktop-install"); + std::fs::write(&helper, r#"#!/bin/sh +test "$#" -eq 5 && test "$1" = validate && test "$2" = --server && test "$4" = --config && test -f "$5" || exit 1 +exec "$3" --config "$5" --dry-run +"#).expect("write helper"); + std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)) + .expect("make helper executable"); + path + } + + fn backups(dir: &Path) -> Vec { + std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").path()) + .filter(|path| path.display().to_string().contains(".switchyard-backup.")) + .collect() + } + + #[test] + fn a_config_that_fails_the_check_is_not_saved() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, OLD).expect("write config"); + // The real server names the file it was given; the error must name + // the user's file instead of the temporary copy. + let binary = fake_server( + dir.path(), + r#"echo "invalid server config $2: route gateway cannot forward both" >&2; exit 1"#, + ); + + let error = save_checked(&binary, &config, OLD, NEW).expect_err("check fails"); + + assert_eq!( + error, + format!( + "invalid server config {}: route gateway cannot forward both", + config.display() + ) + ); + assert_eq!(std::fs::read_to_string(&config).expect("read"), OLD); + assert!(backups(dir.path()).is_empty()); + assert_eq!( + std::fs::read_dir(dir.path()).expect("read dir").count(), + 3, + "the temporary file is removed" + ); + } + + #[test] + fn a_config_that_passes_the_check_replaces_the_file_and_keeps_a_backup() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, OLD).expect("write config"); + std::fs::set_permissions(&config, std::fs::Permissions::from_mode(0o640)) + .expect("set permissions"); + let binary = fake_server( + dir.path(), + r#"grep -q new "$2" && echo "server OK: gateway""#, + ); + + let backup = save_checked(&binary, &config, OLD, NEW).expect("saved"); + + assert_eq!(std::fs::read_to_string(&config).expect("read"), NEW); + let mode = |path: &Path| { + std::fs::metadata(path) + .expect("metadata") + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode(&config), 0o640, "the file keeps its permissions"); + assert_eq!(backups(dir.path()).len(), 1); + assert_eq!(std::fs::read_to_string(&backup).expect("read"), OLD); + assert_eq!(mode(&backup), 0o640, "the backup gets the same permissions"); + save_checked(&binary, &config, NEW, NEWER).expect("saved again"); + let mut kept: Vec<_> = backups(dir.path()) + .iter() + .map(|path| std::fs::read_to_string(path).expect("backup")) + .collect(); + kept.sort(); + assert_eq!(kept, [NEW, OLD]); + assert_eq!(std::fs::read_to_string(&config).expect("read"), NEWER); + } + + #[test] + fn a_file_that_changes_during_the_check_is_not_replaced() { + let dir = tempfile::tempdir().expect("tempdir"); + let config = dir.path().join("composite.toml"); + std::fs::write(&config, OLD).expect("write config"); + // Another program saves the file while the check runs. + let binary = fake_server( + dir.path(), + &format!("echo edited-elsewhere > '{}'", config.display()), + ); + + let error = save_checked(&binary, &config, OLD, NEW).expect_err("file changed"); + + assert!(error.contains(&config.display().to_string()), "{error}"); + assert_eq!( + std::fs::read_to_string(&config).expect("read"), + "edited-elsewhere\n" + ); + assert!(backups(dir.path()).is_empty()); + } +} diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index da1817407b..b952f4b495 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -1324,3 +1324,224 @@ fn table_key(path: &str) -> Option<&str> { } path.split(['.', ']']).next().filter(|key| !key.is_empty()) } + +#[cfg(test)] +mod tests { + use super::*; + + /// This fixture uses a GPT judge and Claude on one gateway with different API formats. + const GATEWAY: &str = r#"schema_version = 1 + +[llm_clients.gateway] +format = "openai_responses" +base_url = "https://gateway.example.com/v1" +forward_auth = true + +# Claude caches prompts on the chat endpoint. +[llm_clients.gateway_chat] +format = "openai_chat" +base_url = "https://gateway.example.com/v1" +forward_auth = true + +# The gateway rejects any thinking setting for these models. +[targets.gateway_capable] +id = "claude-opus-5-5" +llm_client = "gateway_chat" # chat, for caching +omit_body_fields = ["reasoning_effort"] + +[targets.gateway_efficient] +id = "claude-sonnet-5" +llm_client = "gateway_chat" +omit_body_fields = ["reasoning_effort"] + +[targets.gateway_judge] +id = "gpt-5.6-terra" +llm_client = "gateway" + +[routes.gateway] +id = "switchyard-gateway" +type = "composite" +context_window = 200000 + +[routes.gateway.classifier] +target = "gateway_judge" +base_threshold = 0.5 +classify_trigger = "user_turn" +message_hash_fallback = true + +[routes.gateway.stage] +capable_target = "gateway_capable" +efficient_target = "gateway_efficient" +confidence_threshold = 0.5 +"#; + + fn choice(client: &str, model: &str) -> Choice { + Choice { + client: client.to_string(), + model: model.to_string(), + } + } + + fn algorithm(kind: &str) -> &'static Algorithm { + ALGORITHMS + .iter() + .find(|algorithm| algorithm.kind == kind) + .expect("known algorithm") + } + + /// This function edits the config and checks the result with the server's parser. + fn edit(text: &str, route: &str, kind: &str, choices: &[Choice]) -> Edited { + let edited = ServerConfig::parse(text) + .expect("parse") + .edit(route, algorithm(kind), choices) + .expect("edit"); + if let Err(error) = switchyard_runner::Runner::from_toml(&edited.text) { + panic!( + "the server rejects the edited config: {error}\n{}", + edited.text + ); + } + edited + } + + fn capable_target(config: &ServerConfig) -> Option<&str> { + config.target_at("gateway", &Slot::Key(&["stage", "capable_target"])) + } + + #[test] + fn removing_a_route_keeps_generated_block_markers_for_retained_targets() { + for (suffix, target, owned) in [ + ( + "# >>> generated >>>\n\ + [routes.generated]\n\ + id='generated'\n\ + type='passthrough'\n\ + target='owned'\n\ + [targets.owned]\n\ + id='m'\n\ + llm_client='gateway'\n\ + # <<< generated <<<\n", + "owned", + true, + ), + ( + "# >>> generated >>>\n\ + [targets.owned]\n\ + id='m'\n\ + llm_client='gateway'\n\ + # <<< generated <<<\n\ + [routes.generated]\n\ + id='generated'\n\ + type='passthrough'\n\ + target='owned'\n\ + [targets.unowned]\n\ + id='other'\n\ + llm_client='gateway'\n", + "unowned", + false, + ), + ( + "[routes.generated]\n\ + id='generated'\n\ + type='composite'\n\ + # >>> generated >>>\n\ + [routes.generated.classifier]\n\ + target='owned'\n\ + [targets.owned]\n\ + id='m'\n\ + llm_client='gateway'\n\ + # <<< generated <<<\n", + "owned", + true, + ), + ] { + let config = ServerConfig::parse(&format!("{GATEWAY}\n{suffix}")).expect("config"); + assert_eq!(config.generated_by("targets", target).is_some(), owned); + let edited = config.remove("generated").expect("remove"); + let remaining = ServerConfig::parse(&edited.text).expect("config"); + assert_eq!( + remaining.generated_by("targets", target).is_some(), + owned, + "{}", + edited.text + ); + } + } + + #[test] + fn every_algorithm_writes_a_config_the_server_accepts() { + let gateway = ServerConfig::parse(GATEWAY).expect("parse"); + let models = [ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ]; + for algorithm in ALGORITHMS { + let roles = gateway.roles("gateway", algorithm).len(); + + let edited = edit(GATEWAY, "gateway", algorithm.kind, &models[3 - roles..]); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.algorithm("gateway").map(|a| a.kind), + Some(algorithm.kind) + ); + assert_eq!(config.choices("gateway"), models[3 - roles..]); + } + } + + #[test] + fn a_target_another_route_uses_is_copied_not_changed() { + // The shared target has a sub-table, which the copy must keep under + // its own header. + let shared = GATEWAY.replace( + "omit_body_fields = [\"reasoning_effort\"]\n\n[targets.gateway_efficient]", + "omit_body_fields = [\"reasoning_effort\"]\n\n[targets.gateway_capable.extra_body]\n\ + top_k = 5\n\n[targets.gateway_efficient]", + ) + "\n[routes.direct]\n\ + id = \"direct\"\n\ + type = \"passthrough\"\n\ + target = \"gateway_capable\"\n"; + + let edited = edit( + &shared, + "gateway", + "composite", + &[ + choice("gateway", "gpt-5.6-terra"), + choice("gateway", "gpt-5.6-sol"), + choice("gateway_chat", "claude-sonnet-5"), + ], + ); + + let config = ServerConfig::parse(&edited.text).expect("parse"); + assert_eq!( + config.choices("direct"), + [choice("gateway_chat", "claude-opus-5-5")] + ); + assert_eq!( + config.choices("gateway")[1], + choice("gateway", "gpt-5.6-sol") + ); + let copy = capable_target(&config).expect("capable target"); + assert_ne!(copy, "gateway_capable"); + let targets = config.table("targets").expect("targets"); + let (source, copied) = ( + targets.get("gateway_capable").and_then(Item::as_table_like), + targets.get(copy).and_then(Item::as_table_like), + ); + for key in TARGET_SETTINGS { + assert_eq!( + source.and_then(|target| setting(target, key)), + copied.and_then(|target| setting(target, key)), + "the copy keeps {key}" + ); + } + let header = |name: &str| edited.text.find(&format!("[{name}]")).expect(name); + assert!( + header(&format!("targets.{copy}")) < header(&format!("targets.{copy}.extra_body")), + "the copied sub-table follows the copy's header:\n{}", + edited.text + ); + } +} From 9d38181c0e3ec97fb3eae5190593a30bac6c91af Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 17/28] test(desktop): verify route review tokens, staleness, and terminal route flows Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 324 ++++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 68 +++- 2 files changed, 391 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 2dd478edcf..94c0d4faab 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -962,3 +962,327 @@ fn update(settings: &std::path::Path) -> Result { log.display() )) } + +#[cfg(test)] +mod tests { + use super::*; + #[test] + // An algorithm switch must not give stale choices a revision for externally edited bytes. + fn algorithm_switches_keep_the_first_revision_and_external_edits_reject_it() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let config = dir.path().join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&config, text).expect("config"); + std::fs::write( + &settings, + format!( + "config_file={config:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("load"), settings); + let first = controller + .dispatch(Action::Editor { + generation: 0, + route: "r".into(), + algorithm: "passthrough".into(), + }) + .expect("editor"); + let second = controller + .dispatch(Action::Editor { + generation: 0, + route: "r".into(), + algorithm: "random".into(), + }) + .expect("switch roles"); + assert_eq!(first.data["revision"], second.data["revision"]); + assert_eq!( + controller.snapshot().expect("snapshot").routes[0].revision, + first.data["revision"] + ); + std::fs::write(&config, format!("{text}# external edit\n")).expect("external edit"); + let latest = controller + .dispatch(Action::Editor { + generation: 0, + route: "r".into(), + algorithm: "passthrough".into(), + }) + .expect("new editor"); + assert_ne!(latest.data["revision"], first.data["revision"]); + let rejected = controller.dispatch(Action::PreviewRoute { + generation: 0, + route: "r".into(), + algorithm: "passthrough".into(), + choices: vec![Choice { + client: "c".into(), + model: "replacement".into(), + }], + revision: first.data["revision"].as_str().map(str::to_string), + }); + assert!(rejected.is_err()); + assert_eq!( + std::fs::read_to_string(&config).expect("config"), + format!("{text}# external edit\n") + ); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } + + #[cfg(any(target_os = "macos", windows))] + #[test] + // A cached list must not let an untested key reach Keychain. + fn submitting_a_key_checks_upstream_even_when_refresh_is_false() { + use std::io::{Read, Write}; + let dir = tempfile::tempdir().expect("directory"); + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("listener"); + listener.set_nonblocking(true).expect("nonblocking"); + let url = format!("http://{}/v1", listener.local_addr().expect("address")); + #[cfg(windows)] + assert_eq!( + switchyard_desktop::install::windows::saved_key(&url).expect("no saved key"), + None + ); + let worker = std::thread::spawn(move || { + for _ in 0..200 { + if let Ok((mut stream, _)) = listener.accept() { + stream.set_nonblocking(false).expect("blocking request"); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(1))) + .expect("timeout"); + let mut request = Vec::new(); + let mut chunk = [0; 1024]; + for _ in 0..4 { + let n = stream.read(&mut chunk).expect("request"); + request.extend_from_slice(&chunk[..n]); + if n == 0 || request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + break; + } + } + stream + .write_all( + b"HTTP/1.1 401 Unauthorized\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n", + ) + .expect("response"); + return String::from_utf8_lossy(&request).into_owned(); + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + String::new() + }); + let settings = dir.path().join("settings.toml"); + let routes = dir.path().join("routes.toml"); + std::fs::write( + &routes, + format!( + "[llm_clients.c]\n\ + base_url={url:?}\n\ + format='openai_responses'\n\ + forward_auth=true\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n" + ), + ) + .expect("routes"); + std::fs::write( + &settings, + format!( + "config_file={routes:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let cached = + json!({format!("{url}/models"): {"models":["cached"],"fetched_at":models::now()}}) + .to_string(); + let cache = dir.path().join(models::CACHE_FILE); + std::fs::write(&cache, &cached).expect("cache"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + let cached_reply = controller + .dispatch(Action::Models { + client: "c".into(), + refresh: false, + key: None, + }) + .expect("cached list"); + assert_eq!(cached_reply.data["warning"], false); + assert_eq!(cached_reply.data["source"], "cache"); + // A failed refresh must not report the cached list as a successful upstream fetch. + let failed_refresh = controller + .dispatch(Action::Models { + client: "c".into(), + refresh: true, + key: None, + }) + .expect("cached fallback"); + assert_eq!(failed_refresh.data["warning"], true); + assert_eq!(failed_refresh.data["source"], "cache"); + assert_eq!(failed_refresh.data["models"], json!(["cached"])); + let result = controller.dispatch(Action::Models { + client: "c".into(), + refresh: false, + key: Some("rejected-fixture-key".into()), + }); + let request = worker.join().expect("worker"); + assert!(result.err().expect("rejected key").contains("401")); + assert!(request.starts_with("GET /v1/models ")); + assert!(request.contains("Authorization: Bearer rejected-fixture-key")); + assert_eq!(std::fs::read_to_string(cache).expect("cache"), cached); + #[cfg(windows)] + assert_eq!( + switchyard_desktop::install::windows::saved_key(&url).expect("rejected key not saved"), + None + ); + std::fs::write( + &routes, + "[llm_clients.c]\n\ + base_url='http://127.0.0.1:9/v1'\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("routes"); + let result = controller.dispatch(Action::Models { + client: "c".into(), + refresh: false, + key: Some("unused-fixture-key".into()), + }); + assert!( + result + .err() + .expect("unused key") + .contains("does not accept a model-list key") + ); + } + #[test] + // Matching route names in two files must not let an old draft edit the newly selected file. + fn stale_editor_rejects_after_settings_change_without_writes() { + let dir = tempfile::tempdir().expect("directory"); + let settings = dir.path().join("settings.toml"); + let first = dir.path().join("first.toml"); + let second = dir.path().join("second.toml"); + let text = "[llm_clients.c]\n\ + base_url='https://example.com/v1'\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&first, text).expect("first"); + std::fs::write(&second, text).expect("second"); + let write_settings = |path: &std::path::Path| { + std::fs::write( + &settings, + format!( + "config_file={path:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + }; + write_settings(&first); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let snapshot = controller.snapshot().expect("snapshot"); + assert_eq!( + snapshot.routes[0].tiers, + Some(vec![Some("Capable".to_string())]) + ); + let generation = snapshot.generation; + write_settings(&second); + for action in [ + Action::Editor { + generation, + route: "r".into(), + algorithm: "passthrough".into(), + }, + Action::Apply { + generation, + route: "r".into(), + algorithm: "passthrough".into(), + choices: vec![Choice { + client: "c".into(), + model: "changed".into(), + }], + revision: None, + }, + ] { + assert!(controller.dispatch(action).is_err()); + } + assert_eq!(std::fs::read_to_string(&second).expect("second"), text); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 3); + assert_eq!( + controller.snapshot().expect("snapshot").generation, + generation + 1 + ); + } + + #[test] + fn unsupported_routes_are_read_only_before_any_write() { + let dir = tempfile::tempdir().expect("directory"); + let config = dir.path().join("server.toml"); + let settings = dir.path().join("app.toml"); + std::fs::write( + &settings, + format!("config_file={config:?}\nserver_url='http://127.0.0.1:0'"), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + // These fixtures use valid server algorithms that the app cannot edit. + for fields in [ + "type='noop'", + "type='prefill_router'\ntargets=['answer']\ncheckpoint='router.pt'", + "type='llm_classifier'\n\ + mode='custom'\n\ + models={judge=['judge'],any=['answer']}\n\ + default_target='any'\n\ + prompt='Select a target.'\n\ + response_schema='{\"type\":\"object\"}'\n\ + policy={type='target_selector',selector='/target'}", + ] { + let _: switchyard_runner::AlgorithmSpec = + toml::from_str(fields).expect("server route schema"); + let original = format!("[routes.r]\nid='public'\n{fields}\n"); + std::fs::write(&config, &original).expect("config"); + assert!(!controller.snapshot().expect("snapshot").routes[0].editable); + for kind in ["editor", "apply"] { + let mut raw = + json!({"kind":kind,"generation":0,"route":"r","algorithm":"passthrough"}); + if kind == "apply" { + raw["choices"] = json!([]); + } + let action = serde_json::from_value(raw).expect("action"); + assert_eq!( + controller.dispatch(action).err().expect("read only"), + "This route must be edited in the server config file." + ); + } + assert_eq!(std::fs::read_to_string(&config).expect("config"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } + } +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 512e424394..65fe76e229 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -3797,12 +3797,29 @@ pub fn run(mut controller: Controller) -> Result<(), String> { #[cfg(test)] mod tests { use super::*; - + use crate::config::Config; use crate::controller::Source; use ratatui::{Terminal, backend::TestBackend}; use serde_json::json; use strum::IntoEnumIterator; + fn press(app: &mut App, code: KeyCode) -> Option { + app.key(KeyEvent::from(code)) + } + + /// This function runs one request the way the worker thread does. + fn work(app: &mut App, controller: &mut Controller, request: Option) { + let (jobs, inbox) = mpsc::channel(); + app.start(&jobs, request.expect("a request")) + .expect("start"); + let Ok(Job::Run(action)) = inbox.try_recv() else { + panic!("the request runs no action"); + }; + let reply = controller.dispatch(action); + let next = app.finish(Some(reply), controller.snapshot().map(Box::new)); + assert!(next.is_none()); + } + fn screen(app: &mut App, tab: Tab) -> String { app.tab = tab; let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap(); @@ -3914,4 +3931,53 @@ mod tests { .collect(); assert!(missing.is_empty(), "No key runs {missing:?}"); } + + #[test] + // The models form must keep the revision from when the editor opened, so a server config + // that changed since then fails the review and keeps the draft instead of saving old + // choices into the new file. + fn externally_changed_routes_cannot_bless_snapshot_choices() { + let dir = tempfile::tempdir().expect("fixture"); + let settings = dir.path().join("desktop.toml"); + let routes = dir.path().join("server.toml"); + let original = "[llm_clients.c]\n\ + base_url='http://localhost:1'\n\ + format='openai_responses'\n\ + [targets.m]\n\ + id='original'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + std::fs::write(&routes, original).expect("routes"); + std::fs::write( + &settings, + format!( + "config_file={routes:?}\nrouting_log={:?}\nserver_url='http://127.0.0.1:0'", + dir.path().join("log") + ), + ) + .expect("settings"); + let mut controller = Controller::new(Config::load(&settings).expect("config"), settings); + let mut app = App::new(Theme::new(false), controller.snapshot().expect("snapshot")); + app.tab = Tab::Routes; + assert!(press(&mut app, KeyCode::Char('e')).is_none()); + let external = original.replace("id='original'", "id='external'"); + std::fs::write(&routes, &external).expect("external edit"); + let editor = press(&mut app, KeyCode::Enter); + work(&mut app, &mut controller, editor); + let preview = press(&mut app, KeyCode::Enter); + work(&mut app, &mut controller, preview); + let Some(Popup::Form(draft)) = &app.popup else { + panic!("the draft closed"); + }; + assert!(matches!(draft.kind, FormKind::RouteModels { .. })); + assert!(draft.error.contains("Review"), "{}", draft.error); + assert_eq!( + std::fs::read_to_string(&routes).expect("preserved"), + external + ); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); + } } From 33c493ead7fd5ce5e101cad15d7a27fb26384540 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 18/28] feat(desktop): inspect harness settings and add the Harnesses page Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/accounts.rs | 73 ++++ crates/switchyard-desktop/src/controller.rs | 77 +++- crates/switchyard-desktop/src/harness.rs | 238 ++++++++++ crates/switchyard-desktop/src/main.rs | 4 +- crates/switchyard-desktop/src/tui.rs | 460 ++++++++++++++++++-- 5 files changed, 806 insertions(+), 46 deletions(-) create mode 100644 crates/switchyard-desktop/src/accounts.rs create mode 100644 crates/switchyard-desktop/src/harness.rs diff --git a/crates/switchyard-desktop/src/accounts.rs b/crates/switchyard-desktop/src/accounts.rs new file mode 100644 index 0000000000..e5e14c9502 --- /dev/null +++ b/crates/switchyard-desktop/src/accounts.rs @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Named login directories use the harness's own authentication commands. + +use crate::harness::{self, Harness}; +use std::path::{Path, PathBuf}; + +fn root(tool: Harness) -> Result { + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + _ => return Err("Named accounts are available for Codex CLI and Claude Code.".into()), + }; + let home = crate::config::expand_home(Path::new("~"))?; + let mut path = home; + for component in [".switchyard", "accounts", name] { + path.push(component); + if std::fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink()) { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + } + Ok(path) +} + +pub fn discover(tool: Harness) -> Result, String> { + if !matches!(tool, Harness::CodexCli | Harness::Claude) { + return Ok(Vec::new()); + } + let root = root(tool)?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "Could not list accounts in {}: {error}", + root.display() + )); + } + }; + let mut accounts = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + if !entry + .file_name() + .to_string_lossy() + .starts_with(".archived-") + && entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + && let Ok(name) = entry.file_name().into_string() + { + accounts.push((name, entry.path())); + } + } + accounts.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(accounts) +} + +pub fn config_paths(tool: Harness, account: Option<&Path>) -> Result, String> { + let Some(account) = account else { + return harness::paths(tool); + }; + match tool { + Harness::CodexCli => Ok(vec![account.join("sy.config.toml")]), + Harness::Claude => Ok(vec![account.join("settings.json")]), + _ => harness::paths(tool), + } +} diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 94c0d4faab..fe83603d73 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -7,7 +7,9 @@ //! build until the terminal app can run it. use crate::{ + accounts, config::Config, + harness::{self, Harness}, history, models, pricing, rollup, server, server_config::{ALGORITHMS, Choice, Route, ServerConfig}, }; @@ -110,6 +112,7 @@ pub struct Snapshot { pub generation: u64, pub routes: Vec, pub clients: Vec, + pub tools: Vec, pub algorithms: Vec, pub summary: Vec, pub metrics: Metrics, @@ -163,6 +166,32 @@ pub struct RouteInfo { pub generated: Option, } +/// ToolInfo describes one harness whose settings the apps can change. +#[derive(Clone, Serialize)] +pub struct ToolInfo { + pub tool: Harness, + pub label: &'static str, + /// This field is true when the settings files resolve and the harness program is installed. + pub available: bool, + pub files: Vec, + /// This field summarizes the current settings, or holds the error that stopped the read. + pub status: String, + pub accounts: Vec, + pub account_details: Vec, + pub discovery_error: Option, +} + +/// AccountInfo describes one saved account folder of a harness. +#[derive(Clone, Serialize)] +pub struct AccountInfo { + pub name: String, + pub path: PathBuf, + /// The apps cannot ask a harness whether a login finished, so this field is `unknown`. + pub login_status: &'static str, + /// This field summarizes the account's settings, or holds the error that stopped the read. + pub status: String, +} + /// ClientInfo describes one endpoint under `[llm_clients]` in the server config. #[derive(Clone, Default, Serialize)] pub struct ClientInfo { @@ -259,7 +288,7 @@ impl Reply { struct Review { token: String, inputs: Value, - files: Vec<(PathBuf, Option>)>, + files: Vec, } impl Review { fn capture(inputs: Value, paths: Vec) -> Result { @@ -319,6 +348,7 @@ pub struct Controller { // The generation ties a draft to the server config file selected by the settings. generation: u64, route_reviews: std::collections::HashMap, + route_change: Option, update_review: Option<(String, Value)>, } @@ -331,6 +361,7 @@ impl Controller { reader: rollup::Reader::default(), generation: 0, route_reviews: Default::default(), + route_change: None, update_review: None, } @@ -340,6 +371,7 @@ impl Controller { if config.config_file != self.config.config_file { self.generation += 1; self.route_reviews.clear(); + self.route_change = None; } self.config = config; @@ -463,6 +495,7 @@ impl Controller { .map(|r| r.tier.map(|t| format!("{t:?}"))) .collect() }), + editable: algorithm.is_some(), generated: config .generated_by("routes", &route.key) @@ -498,6 +531,42 @@ impl Controller { history::History::default() }); let activity = history::hourly_calls(&history.entries, chrono::Utc::now()); + let tools = harness::HARNESSES + .iter() + .map(|&(tool, label)| { + let discovered = accounts::discover(tool); + let discovery_error = discovered.as_ref().err().cloned(); + let discovered = discovered.unwrap_or_default(); + let account_details = discovered + .iter() + .map(|(name, path)| AccountInfo { + name: name.clone(), + path: path.clone(), + login_status: "unknown", + status: accounts::config_paths(tool, Some(path)) + .and_then(|files| harness::inspect(tool, &files)) + .unwrap_or_else(|error| error), + }) + .collect(); + // A path error stays in status; files remains an array and the tool is unavailable. + let files = harness::paths(tool); + let status = files + .as_ref() + .map_err(Clone::clone) + .and_then(|files| harness::inspect(tool, files)) + .unwrap_or_else(|error| error); + ToolInfo { + tool, + label, + available: files.is_ok() && harness::binary(tool).is_some(), + files: files.unwrap_or_default(), + status, + accounts: discovered.into_iter().map(|(name, _)| name).collect(), + account_details, + discovery_error, + } + }) + .collect(); let update_status = install_metadata(&self.settings) .and_then(|path| switchyard_desktop::install::update_status(&path)) .unwrap_or_else(|error| json!({"state":"unavailable","message":error})); @@ -529,6 +598,7 @@ impl Controller { generation: self.generation, routes, clients, + tools, algorithms: ALGORITHMS .iter() .map(|a| AlgorithmInfo { @@ -574,7 +644,7 @@ impl Controller { }), }) } - /// The TUI uses these file paths to run an editor in the foreground. + /// The TUI uses these file and folder paths to run an editor in the foreground. pub fn editor_target(&mut self, action: &Action) -> Result, String> { match action { Action::OpenSettings {} => Ok(Some(self.settings.clone())), @@ -590,6 +660,7 @@ impl Controller { } Action::OpenUpdateLog {} => self.update_log().map(Some), Action::OpenLogs {} => logs_dir().map(Some), + _ => Ok(None), } } @@ -918,6 +989,7 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } + fn role_info(role: &crate::server_config::Role) -> RoleInfo { RoleInfo { label: role.label.clone(), @@ -939,6 +1011,7 @@ fn current_route(config: &ServerConfig, key: &str, id: Option<&str>) -> Result Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; let bundle = switchyard_desktop::install::bundle_metadata(&executable); diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs new file mode 100644 index 0000000000..206c6c1592 --- /dev/null +++ b/crates/switchyard-desktop/src/harness.rs @@ -0,0 +1,238 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module installs and restores user settings for the supported harnesses. + +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use toml_edit::DocumentMut; + +pub type ReviewedFile = (PathBuf, Option>); + +#[derive( + Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Harness { + CodexCli, + CodexApp, + Claude, + Pi, +} + +pub const HARNESSES: &[(Harness, &str)] = &[ + (Harness::CodexCli, "Codex CLI profile"), + (Harness::CodexApp, "Codex app and CLI defaults"), + (Harness::Claude, "Claude Code"), + (Harness::Pi, "Pi"), +]; + +/// This function uses the selected harness's environment variable when it is present. +/// This function resolves the home directory only when that variable is absent. +pub fn paths(tool: Harness) -> Result, String> { + let (variable, default, files): (&str, &str, &[&str]) = match tool { + Harness::CodexCli => ("CODEX_HOME", "~/.codex", &["sy.config.toml"]), + Harness::CodexApp => ("CODEX_HOME", "~/.codex", &["config.toml"]), + Harness::Claude => ("CLAUDE_CONFIG_DIR", "~/.claude", &["settings.json"]), + Harness::Pi => ( + "PI_CODING_AGENT_DIR", + "~/.pi/agent", + &["models.json", "settings.json"], + ), + }; + let directory = match std::env::var_os(variable) { + Some(path) => PathBuf::from(path), + None => crate::config::expand_home(Path::new(default))?, + }; + Ok(files.iter().map(|file| directory.join(file)).collect()) +} + +pub fn inspect(tool: Harness, files: &[PathBuf]) -> Result { + let texts = files + .iter() + .map(|path| read(path)) + .collect::, _>>()?; + inspect_settings(tool, files, &texts) +} + +fn inspect_settings(tool: Harness, files: &[PathBuf], texts: &[String]) -> Result { + let text = &texts[0]; + let model; + let endpoint; + match tool { + Harness::CodexCli | Harness::CodexApp => { + let doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + model = doc + .get("model") + .and_then(|v| v.as_str()) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("model_provider") + .and_then(|v| v.as_str()) + .unwrap_or("openai"); + endpoint = doc + .get("model_providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("base_url")) + .and_then(|v| v.as_str()) + .unwrap_or("Provider default") + .to_string(); + } + Harness::Claude => { + let doc = object(text)?; + model = doc + .pointer("/env/ANTHROPIC_MODEL") + .and_then(Value::as_str) + .or_else(|| doc.get("model").and_then(Value::as_str)) + .unwrap_or("Default") + .to_string(); + endpoint = doc + .pointer("/env/ANTHROPIC_BASE_URL") + .and_then(Value::as_str) + .unwrap_or("Anthropic default") + .to_string(); + } + Harness::Pi => { + let doc = object(&texts[1])?; + model = doc + .get("defaultModel") + .and_then(Value::as_str) + .unwrap_or("Default") + .to_string(); + let provider = doc + .get("defaultProvider") + .and_then(Value::as_str) + .unwrap_or("Default"); + let models = object(text)?; + endpoint = models + .get("providers") + .and_then(|v| v.get(provider)) + .and_then(|v| v.get("baseUrl")) + .and_then(Value::as_str) + .unwrap_or("Provider default") + .to_string(); + } + } + // The display keeps the URL scheme, host, and port to omit credentials and queries. + let endpoint = url::Url::parse(&endpoint) + .ok() + .map(|u| { + format!( + "{}://{}{}", + u.scheme(), + u.host_str().unwrap_or("?"), + u.port().map(|p| format!(":{p}")).unwrap_or_default() + ) + }) + .unwrap_or_else(|| "Provider default".into()); + Ok(format!( + "Model: {model}\nEndpoint: {endpoint}\nSettings: {}", + files + .iter() + .map(|file| file.display().to_string()) + .collect::>() + .join("\n ") + )) +} + +fn read_file(path: &Path) -> Result, String> { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(format!( + "{} is a symlink. Edit its target yourself.", + path.display() + )); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.to_string()), + _ => {} + } + std::fs::read_to_string(path) + .map(Some) + .map_err(|e| format!("Read {}: {e}", path.display())) +} + +fn read(path: &Path) -> Result { + Ok(read_file(path)?.unwrap_or_default()) +} + +fn object(text: &str) -> Result { + let doc: Value = if text.is_empty() { + json!({}) + } else { + serde_json::from_str(text).map_err(|e| e.to_string())? + }; + if !doc.is_object() { + return Err("Settings must be a JSON object.".into()); + } + Ok(doc) +} + +pub fn binary(tool: Harness) -> Option { + if tool == Harness::CodexApp { + #[cfg(windows)] + return crate::windows::codex_app(); + #[cfg(not(windows))] + { + #[cfg(target_os = "macos")] + if let Some(path) = objc2_app_kit::NSWorkspace::sharedWorkspace() + .URLForApplicationWithBundleIdentifier(&objc2_foundation::NSString::from_str( + "com.openai.codex", + )) + .and_then(|url| url.path()) + .map(|path| PathBuf::from(path.to_string())) + .filter(|path| path.is_dir()) + { + return Some(path); + } + return [ + Some(PathBuf::from("/Applications/Codex.app")), + crate::config::expand_home(Path::new("~/Applications/Codex.app")).ok(), + ] + .into_iter() + .flatten() + .find(|p| p.exists()); + } + } + let name = match tool { + Harness::CodexCli => "codex", + Harness::Claude => "claude", + Harness::Pi => "pi", + Harness::CodexApp => return None, + }; + let mut dirs: Vec<_> = std::env::var_os("PATH") + .map(|s| std::env::split_paths(&s).collect()) + .unwrap_or_default(); + dirs.extend([ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]); + dirs.extend( + [ + crate::config::expand_home(Path::new("~/.local/bin")).ok(), + crate::config::expand_home(Path::new("~/.npm-global/bin")).ok(), + ] + .into_iter() + .flatten(), + ); + #[cfg(windows)] + { + dirs.extend( + [ + crate::config::expand_home(Path::new("~/AppData/Roaming/npm")).ok(), + crate::config::expand_home(Path::new("~/.cargo/bin")).ok(), + crate::config::expand_home(Path::new("~/.local/bin")).ok(), + ] + .into_iter() + .flatten(), + ); + dirs.into_iter() + .flat_map(|p| [p.join(format!("{name}.exe")), p.join(format!("{name}.cmd"))]) + .find(|p| p.is_file()) + } + #[cfg(not(windows))] + dirs.into_iter().map(|p| p.join(name)).find(|p| p.is_file()) +} diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 7a1dbf618c..7b1960b606 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -10,9 +10,11 @@ #![cfg_attr(windows, windows_subsystem = "windows")] use switchyard_desktop::{config, health, history, rollup}; +mod accounts; mod controller; #[cfg(any(target_os = "macos", windows))] mod gui; +mod harness; mod models; mod pricing; mod server; @@ -30,7 +32,7 @@ use config::Config; const USAGE: &str = "\ Usage: switchyard-desktop [--tui | --print | --validate-toml] [FILE] -This app manages routes and usage. +This app manages routes, harness settings, and usage. FILE The app reads settings from FILE. --tui Run the terminal app. diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 65fe76e229..6aee30ddd4 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -1,14 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app shows usage, recent calls, and routes from controller snapshots, and it -//! runs server and route actions. +//! The terminal app shows usage, recent calls, routes, and harness settings from controller +//! snapshots, and it runs server, route, harness, and account actions. //! //! The UI thread owns every view setting. One worker thread owns the controller and runs //! refreshes and actions, so moving around and filtering never wait for the log or a service. use crate::controller::{ - Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, Snapshot, + AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, + Snapshot, ToolInfo, }; use crate::history::Entry; use crate::pricing::Estimate; @@ -52,7 +53,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-5", + "1-6", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -108,6 +109,27 @@ const ROUTES_KEYS: &[Key] = &[ ), ("esc", "clear", "Clear the filter."), ]; +const HARNESSES_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes page \ + selects the same route, and its filter limits the choices.", + ), + ("t", "harness", "Select the next harness."), + ( + "a", + "account", + "Select the next saved account, then the harness's own settings again. Install, \ + restore, and the account keys use the selection.", + ), + ( + "x", + "custom file", + "Install to or restore a custom settings file instead. Leave it empty to use the \ + detected settings again.", + ), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -326,6 +348,14 @@ const ROUTE_COLUMNS: [(Column, bool); 3] = [ (("ALGORITHM", Constraint::Length(24), false), false), (("MODELS", Constraint::Fill(2), false), true), ]; +/// The harnesses table drops the columns marked `true` below 80 columns; the details still +/// show the current settings. +const HARNESS_COLUMNS: [(Column, bool); 4] = [ + (("HARNESS", Constraint::Fill(2), false), false), + (("STATE", Constraint::Length(13), false), false), + (("CURRENT SETTINGS", Constraint::Fill(3), false), true), + (("ACCOUNTS", Constraint::Length(8), true), false), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -379,13 +409,15 @@ enum Tab { Calls, Models, Routes, + Harnesses, Settings, } -const TABS: [Tab; 5] = [ +const TABS: [Tab; 6] = [ Tab::Overview, Tab::Calls, Tab::Models, Tab::Routes, + Tab::Harnesses, Tab::Settings, ]; impl Tab { @@ -395,6 +427,7 @@ impl Tab { Tab::Calls => "Calls", Tab::Models => "Models", Tab::Routes => "Routes", + Tab::Harnesses => "Harnesses", Tab::Settings => "Settings", } } @@ -481,8 +514,9 @@ enum FormKind { operation: &'static str, route: String, }, - /// The action is the one that the user reviewed. Typing SAVE submits it unchanged. - Save(Box), + /// The fields hold the word that the user must type and the reviewed action, which the + /// form submits unchanged. + Confirm(Cow<'static, str>, Box), Remove { generation: u64, route: String, @@ -491,17 +525,20 @@ enum FormKind { }, Models, ModelKey, + SettingsFile, } /// Choices names the list that ↑ and ↓ cycle through in a form field. enum Choices { Algorithms, Endpoints, } -/// Submit is what a form sends: a controller action, or the next form of a flow that needs -/// more input before the controller can review it. +/// Submit is what a form sends: a controller action, the next form of a flow that needs more +/// input before the controller can review it, or a custom settings file, which changes only +/// where the Harnesses page installs. enum Submit { Run(Action), Next(Form), + SettingsFile(String), } /// Form holds the text fields for an action that needs input. struct Form { @@ -746,8 +783,8 @@ impl Form { choices: Vec::new(), } } - FormKind::Save(action) => { - confirm("SAVE", "Type SAVE to apply the reviewed changes.")?; + FormKind::Confirm(word, action) => { + confirm(word, &format!("Type {word} to apply the reviewed changes."))?; (**action).clone() } FormKind::Remove { @@ -774,6 +811,7 @@ impl Form { refresh: true, key: Some(self.value(1)), }, + FormKind::SettingsFile => return Ok(Submit::SettingsFile(self.value(0))), })) } fn label(&self) -> &'static str { @@ -788,10 +826,12 @@ impl Form { FormKind::NewRoute { .. } | FormKind::NewRouteModels { .. } | FormKind::RouteIdentity { .. } => "Reviewing the route change", - FormKind::Save(_) => "Saving the route", + FormKind::Confirm(_, _action) => "Saving the route", FormKind::Remove { .. } => "Deleting the route", FormKind::Models => "Loading models", FormKind::ModelKey => "Saving the key and loading models", + + FormKind::SettingsFile => "Choosing the settings file", } } } @@ -861,8 +901,10 @@ fn route_models_form( } /// This function returns the action that a preview's review window submits after the user -/// types SAVE: the previewed inputs, plus the token for a route change. +/// types the confirmation word: the previewed inputs, plus the preview's token for every flow +/// except a route edit, which sends its revision instead. fn reviewed(previewed: Action, preview: &Value) -> Option { + let preview_token = preview["preview_token"].as_str().map(str::to_owned); Some(match previewed { Action::PreviewRoute { generation, @@ -893,8 +935,9 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { id, algorithm, choices, - preview_token: preview["preview_token"].as_str().map(str::to_owned), + preview_token, }, + _ => return None, }) } @@ -972,18 +1015,23 @@ enum Done { struct Request { job: Job, label: &'static str, + /// This field holds the `ACTIONS` key that started the job. Keys that share a first action + /// continue differently when the reply arrives, such as `p` and `i` on the Harnesses page. + key: Option, } impl Request { fn run(action: Action, label: &'static str) -> Self { Self { job: Job::Run(action), label, + key: None, } } fn refresh() -> Self { Self { job: Job::Refresh, label: "Refreshing", + key: None, } } } @@ -992,6 +1040,7 @@ struct Busy { /// This field holds the running action, so its reply can update the right view and a /// preview's review window can submit exactly what the controller reviewed. action: Option, + key: Option, started: Instant, } struct Toast { @@ -1019,6 +1068,18 @@ impl Tone { } } +/// Destination is where the Harnesses page installs and restores settings. +enum Destination { + /// The harness's own settings files. + Harness, + /// A saved account, by name. The name stays after a refresh stops listing the account, so + /// its actions still send it and the controller rejects them instead of changing other + /// settings. + Account(String), + /// A custom settings file path. + File(String), +} + struct App { theme: Theme, data: Snapshot, @@ -1037,6 +1098,9 @@ struct App { route_visible: Vec, route_selected: usize, route_offset: usize, + /// This field indexes `data.tools`, which lists every harness in a fixed order. + harness: usize, + destination: Destination, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -1068,6 +1132,9 @@ impl App { route_visible: Vec::new(), route_selected: 0, route_offset: 0, + harness: 0, + destination: Destination::Harness, + server_keys: None, popup: None, toast: None, @@ -1176,6 +1243,20 @@ impl App { } } + fn harness(&self) -> Result<&ToolInfo, String> { + self.data + .tools + .get(self.harness) + .ok_or_else(|| "No harness is listed. Fix the app settings, then press r.".into()) + } + + fn custom_file(&self) -> Option { + match &self.destination { + Destination::File(path) => Some(path.clone()), + Destination::Harness | Destination::Account(_) => None, + } + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { @@ -1207,9 +1288,14 @@ impl App { reply: Option>, snapshot: Result, String>, ) -> Option { - let action = self.busy.take().and_then(|busy| busy.action); + let (action, _key) = match self.busy.take() { + Some(busy) => (busy.action, busy.key), + None => (None, None), + }; self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); - match (action.as_ref().map(ActionKind::from), reply?) { + let reply = reply?; + + match (action.as_ref().map(ActionKind::from), reply) { (Some(ActionKind::ServerKeys), Err(error)) => { self.server_keys = None; self.notify(error, Tone::Error); @@ -1229,8 +1315,6 @@ impl App { "Type UPDATE to start", )); } - // The reply lists the roles of the chosen algorithm. The models form replaces the - // algorithm form only while that form is still open for the same route. (Some(ActionKind::Editor), Ok(reply)) => { if let Some(Action::Editor { route: key, @@ -1256,7 +1340,7 @@ impl App { self.popup = Some(Popup::review( change_lines(self.theme, &reply.data), "Save", - FormKind::Save(Box::new(reviewed)), + FormKind::Confirm("SAVE".into(), Box::new(reviewed)), "Review route changes", "Type SAVE to apply", )); @@ -1353,6 +1437,7 @@ impl App { self.busy = Some(Busy { label: request.label, action, + key: request.key, started: Instant::now(), }); Ok(()) @@ -1370,6 +1455,7 @@ impl App { (None, false, Tab::Calls) => CALLS_KEYS, (None, false, Tab::Models) => MODELS_KEYS, (None, false, Tab::Routes) => ROUTES_KEYS, + (None, false, Tab::Harnesses) => HARNESSES_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } @@ -1419,7 +1505,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='5') => { + KeyCode::Char(digit @ '1'..='6') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -1439,7 +1525,7 @@ impl App { self.route_filter.clear(); self.refilter(); } - Tab::Overview | Tab::Models | Tab::Settings => {} + Tab::Overview | Tab::Models | Tab::Harnesses | Tab::Settings => {} } } code => { @@ -1463,6 +1549,7 @@ impl App { _ => {} }, Tab::Routes => self.routes_key(code), + Tab::Harnesses => self.harnesses_key(code), Tab::Settings => return self.settings_key(code), } } @@ -1506,6 +1593,44 @@ impl App { } } + /// The arrows choose the route, which is the longer list; `t` cycles through the few + /// harnesses. A new harness starts at its own settings, because accounts belong to one + /// harness. + fn harnesses_key(&mut self, code: KeyCode) { + let last = self.route_visible.len().saturating_sub(1); + if let Some(selected) = step(code, self.route_selected, last) { + self.route_selected = selected; + return; + } + match code { + KeyCode::Char('t') => { + self.harness = (self.harness + 1) % self.data.tools.len().max(1); + self.destination = Destination::Harness; + } + // The cycle runs through the harness's own settings and then each saved account. + KeyCode::Char('a') => { + let names = self.harness().map_or(&[][..], |tool| &tool.accounts); + let current = match &self.destination { + Destination::Account(name) => names.iter().position(|known| known == name), + Destination::Harness | Destination::File(_) => None, + }; + let next = names.get(current.map_or(0, |index| index + 1)).cloned(); + self.destination = next.map_or(Destination::Harness, Destination::Account); + } + KeyCode::Char('x') => { + self.popup = Some(Popup::Form(Form::new( + FormKind::SettingsFile, + "Custom settings file · leave empty to use the detected settings", + vec![( + "Absolute file path (Pi: models.json)", + self.custom_file().unwrap_or_default(), + )], + ))); + } + _ => {} + } + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { @@ -1544,11 +1669,19 @@ impl App { None } + /// This function runs a row's flow and records its key on the request, so the reply can + /// continue that key's flow. fn run_row(&mut self, kind: ActionKind, key: char) -> Option { - self.start_action(kind, key).unwrap_or_else(|error| { - self.notify(error, Tone::Error); - None - }) + match self.start_action(kind, key) { + Ok(request) => request.map(|request| Request { + key: Some(key), + ..request + }), + Err(error) => { + self.notify(error, Tone::Error); + None + } + } } fn open(&mut self, form: Form) -> Result, String> { @@ -1661,6 +1794,7 @@ impl App { }; self.open(form) } + ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -1738,6 +1872,16 @@ impl App { KeyCode::Enter => match form.action(&self.data) { Ok(Submit::Run(action)) => return Some(Request::run(action, form.label())), Ok(Submit::Next(next)) => self.popup = Some(Popup::Form(next)), + Ok(Submit::SettingsFile(path)) => { + self.popup = None; + self.destination = if path.is_empty() { + Destination::Harness + } else { + Destination::File(path) + }; + let text = "Press p to preview or i to install."; + self.notify(text.into(), Tone::Done); + } Err(error) => form.error = error, }, // A field with choices cycles through them. Otherwise only the review @@ -1982,6 +2126,7 @@ fn draw(frame: &mut Frame, app: &mut App) { generation: _, // every route form sends it back with its action routes: _, // draw_routes and the route details clients: _, // draw_endpoints, the endpoint fields, and the endpoint forms + tools: _, // draw_harnesses and harness_lines algorithms: _, // draw_routes, the route details, and the algorithm fields summary: _, // --print shows these rows; draw_top_models builds the same notes metrics: _, // draw_header and the Overview cards @@ -2015,6 +2160,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Calls => draw_calls(frame, app, body), Tab::Models => draw_models(frame, app, body), Tab::Routes => draw_routes(frame, app, body), + Tab::Harnesses => draw_harnesses(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -2048,14 +2194,27 @@ fn draw_too_small(frame: &mut Frame, theme: Theme, area: Rect) { fn draw_header(frame: &mut Frame, app: &App, area: Rect) { let theme = app.theme; - let titles = TABS.map(|tab| { - Line::from(vec![ - Span::styled(format!("{} ", tab.index() + 1), theme.key), - Span::raw(tab.name()), - ]) - }); + // Each title is the tab's digit and at most `letters` letters of its name. + let titles = |letters: usize| { + TABS.map(|tab| { + let name = tab.name().get(..letters).unwrap_or(tab.name()); + Line::from(vec![ + Span::styled((tab.index() + 1).to_string(), theme.key), + Span::raw(if name.is_empty() { "" } else { " " }), + Span::raw(name), + ]) + }) + }; // Tabs pads each title by one space on both sides and puts a divider between titles. - let tabs_width: usize = titles.iter().map(|title| title.width() + 3).sum(); + let width = |titles: &[Line]| titles.iter().map(|title| title.width() + 3).sum::(); + // Narrow terminals shorten the names to three letters, then show the digits alone, so no + // tab is cut off. + let titles = [usize::MAX, 3] + .into_iter() + .map(titles) + .find(|titles| width(titles) <= usize::from(area.width)) + .unwrap_or_else(|| titles(0)); + let tabs_width = width(&titles); let running = app.data.metrics.running; let mut status = server_state(theme, running).to_vec(); let updated = match app.refreshed { @@ -2899,24 +3058,34 @@ fn draw_route_table(frame: &mut Frame, app: &mut App, area: Rect) { /// short terminal still shows them, then the notes that limit editing it here. fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec> { const WIDTH: usize = 11; + // This destructuring has no `..`, so adding a route field breaks the build until the + // terminal app shows it. Each comment names the place that shows the field. + let RouteInfo { + key, + id: _, // draw_routes titles these details with it + kind, + revision: _, // the edit and delete flows send it back + label: _, // harness_lines names the route with it, and the filter reads it + choices, + tiers: _, // route_models_form keeps each role's model by tier + editable, + generated, + } = route; let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); - let algorithm = data - .algorithms - .iter() - .find(|known| known.kind == route.kind); + let algorithm = data.algorithms.iter().find(|known| known.kind == *kind); let mut lines = vec![ - field("Config", format!("[routes.{}]", route.key)), + field("Config", format!("[routes.{key}]")), field( "Algorithm", algorithm.map_or_else( - || route.kind.clone(), + || kind.clone(), |known| format!("{} ({})", known.title, known.kind), ), ), ]; // A random route can list more models than a new one starts with, so later roles are // numbered the same way. - for (index, choice) in route.choices.iter().enumerate() { + for (index, choice) in choices.iter().enumerate() { let role = algorithm .and_then(|known| known.roles.get(index)) .map_or_else(|| format!("Model {}", index + 1), |role| role.label.clone()); @@ -2929,14 +3098,14 @@ fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec>, area: frame.render_widget(Paragraph::new(notes).wrap(Wrap { trim: false }), notes_area); } +fn draw_harnesses(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + // The borders and the header row take three rows. + let rows = app.data.tools.len().max(1) as u16 + 3; + let [list, rest] = Layout::vertical([ + Constraint::Length(rows.min(area.height / 2)), + Constraint::Fill(1), + ]) + .areas(area); + draw_harness_table(frame, app, list); + let Ok(harness) = app.harness() else { + return; + }; + let detail = Paragraph::new(harness_lines(app, harness)) + .wrap(Wrap { trim: false }) + .block(block(theme, harness.label).padding(Padding::horizontal(1))); + frame.render_widget(detail, rest); +} + +fn draw_harness_table(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let tools = &app.data.tools; + let outer = block(theme, &format!("Harnesses [{}]", tools.len())); + if tools.is_empty() { + let text = "No harness is listed. Fix the app settings, then press r."; + frame.render_widget( + Paragraph::new(Line::styled(text, theme.muted)) + .centered() + .block(outer), + area, + ); + return; + } + let wide = area.width >= 80; + let shown = |column: &(Column, bool)| wide || !column.1; + let rows: Vec = tools + .iter() + .map(|tool| { + let state = if tool.available { + Span::styled("Detected", theme.ok) + } else { + Span::styled("Not detected", theme.warn) + }; + // The first two lines name the model and the endpoint, or hold the read error. + let settings: Vec<&str> = tool.status.lines().take(2).collect(); + let cells = [ + Cell::from(tool.label), + Cell::from(state), + Cell::from(settings.join(" · ")), + right(count(tool.accounts.len() as u64)), + ]; + Row::new( + cells + .into_iter() + .zip(&HARNESS_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = HARNESS_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let mut state = TableState::default().with_selected(Some(app.harness)); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); +} + +/// This function shows the route to install and whether the harness can use it, then where +/// the settings go and what they hold now, then the saved accounts. +fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { + const WIDTH: usize = 12; + let theme = app.theme; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let heading = |text: &'static str| Line::styled(text, theme.muted.bold()); + // This destructuring has no `..`, so adding a harness field breaks the build until this + // page shows it. + let ToolInfo { + tool: _, + label: _, // draw_harnesses titles these details with it + available, + files, + status, + accounts: _, // the a key cycles through them, and account_details has the same names + account_details, + discovery_error, + } = harness; + let mut lines = Vec::new(); + match app.selected_route() { + Ok(route) => { + lines.push(field("Route", route.label.clone())); + } + Err(error) => lines.push(Line::from(vec![ + Span::styled(format!("{: { + let files: Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(field("Install to", "The harness's own settings".into())); + lines.push(field("Files", files.join(", "))); + (status.clone(), Style::new()) + } + Destination::Account(name) => { + match account_details.iter().find(|account| account.name == *name) { + Some(account) => { + lines.push(field("Install to", format!("Account {name}"))); + lines.push(field("Folder", account.path.display().to_string())); + (account.status.clone(), Style::new()) + } + None => ( + format!( + "Account {name} is no longer listed. Press a to choose another \ + destination." + ), + theme.warn, + ), + } + } + Destination::File(path) => { + lines.push(field("Install to", "Custom file".into())); + lines.push(field("File", path.clone())); + ("Press p to read it.".into(), theme.muted) + } + }; + lines.extend([Line::default(), heading("CURRENT SETTINGS")]); + // A summary spans several lines, and Paragraph does not start a new line at '\n'. + lines.extend( + current + .lines() + .map(|line| Line::styled(line.to_string(), style)), + ); + lines.extend([ + Line::default(), + heading("SAVED ACCOUNTS · a folder does not confirm a completed login"), + ]); + if let Some(error) = discovery_error { + lines.push(Line::styled(error.clone(), theme.error)); + } + for account in account_details { + // This destructuring has no `..`, so adding an account field breaks the build until + // this page shows it. + let AccountInfo { + name, + path, + login_status, + status: _, // CURRENT SETTINGS shows it for the selected account + } = account; + let selected = matches!(&app.destination, Destination::Account(chosen) if chosen == name); + lines.push(Line::from(vec![ + Span::styled(if selected { "› " } else { " " }, theme.key), + Span::raw(name.clone()), + Span::styled( + format!(" · login status {login_status} · {}", path.display()), + theme.muted, + ), + ])); + } + if account_details.is_empty() { + lines.push(Line::styled( + "None. Press n to sign in to another account.", + theme.muted, + )); + } + lines.extend([ + Line::default(), + Line::styled( + "Login opens in a new terminal window, and account folders open in $VISUAL or \ + $EDITOR. Native account limits still apply. Switchyard does not rotate login \ + tokens.", + theme.muted, + ), + ]); + lines +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { @@ -3476,9 +3837,16 @@ fn form_notes(app: &App, form: &Form) -> Vec> { lines.push(Line::default()); lines } + FormKind::SettingsFile => vec![ + Line::from( + "The harness must load a custom file itself. Pi reads settings.json from the \ + folder of models.json.", + ), + Line::default(), + ], FormKind::Preferences | FormKind::Update(_) - | FormKind::Save(_) + | FormKind::Confirm(..) | FormKind::Remove { .. } => Vec::new(), } } @@ -3584,6 +3952,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("While filtering calls or routes", FILTER_KEYS, None), ("Models", MODELS_KEYS, Some(Tab::Models)), ("Routes", ROUTES_KEYS, Some(Tab::Routes)), + ("Harnesses", HARNESSES_KEYS, Some(Tab::Harnesses)), ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { @@ -3821,8 +4190,12 @@ mod tests { } fn screen(app: &mut App, tab: Tab) -> String { + screen_sized(app, tab, 140, 40) + } + + fn screen_sized(app: &mut App, tab: Tab, width: u16, height: u16) -> String { app.tab = tab; - let mut terminal = Terminal::new(TestBackend::new(140, 40)).unwrap(); + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal.draw(|frame| draw(frame, app)).unwrap(); let buffer = terminal.backend().buffer(); buffer @@ -3911,6 +4284,7 @@ mod tests { Tab::Calls => &['/', 's'], Tab::Models => &['p', '[', ']'], Tab::Routes => &['/'], + Tab::Harnesses => &['t', 'a', 'x'], Tab::Overview | Tab::Settings => &[], } }; From ba840e9c94d782eb676227623312798fae43057e Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 19/28] feat(desktop): preview harness settings for Codex, Claude Code, and Pi Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/accounts.rs | 14 + crates/switchyard-desktop/src/controller.rs | 131 +++++- crates/switchyard-desktop/src/harness.rs | 389 +++++++++++++++++- .../switchyard-desktop/src/server_config.rs | 9 + crates/switchyard-desktop/src/tui.rs | 158 ++++++- 5 files changed, 695 insertions(+), 6 deletions(-) diff --git a/crates/switchyard-desktop/src/accounts.rs b/crates/switchyard-desktop/src/accounts.rs index e5e14c9502..ae7bfc5737 100644 --- a/crates/switchyard-desktop/src/accounts.rs +++ b/crates/switchyard-desktop/src/accounts.rs @@ -26,6 +26,16 @@ fn root(tool: Harness) -> Result { Ok(path) } +pub fn validate(tool: Harness, path: &Path) -> Result<(), String> { + let root = root(tool)?; + if path.parent() != Some(root.as_path()) + || !std::fs::symlink_metadata(path).is_ok_and(|m| m.is_dir() && !m.file_type().is_symlink()) + { + return Err("Choose a supported harness.".into()); + } + Ok(()) +} + pub fn discover(tool: Harness) -> Result, String> { if !matches!(tool, Harness::CodexCli | Harness::Claude) { return Ok(Vec::new()); @@ -61,6 +71,10 @@ pub fn discover(tool: Harness) -> Result, String> { Ok(accounts) } +pub fn list(tool: Harness) -> Vec<(String, PathBuf)> { + discover(tool).unwrap_or_default() +} + pub fn config_paths(tool: Harness, account: Option<&Path>) -> Result, String> { let Some(account) = account else { return harness::paths(tool); diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index fe83603d73..46366bc5c1 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -70,6 +70,13 @@ pub enum Action { refresh: bool, key: Option, }, + PreviewInstall { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -164,6 +171,9 @@ pub struct RouteInfo { /// This field holds the marker of the generated block that contains the route. The tool /// that wrote the block replaces the route the next time it runs. pub generated: Option, + /// This field maps each harness to the reason it cannot use the route, or to `None` + /// when it can. + pub compatibility: BTreeMap>, } /// ToolInfo describes one harness whose settings the apps can change. @@ -348,6 +358,7 @@ pub struct Controller { // The generation ties a draft to the server config file selected by the settings. generation: u64, route_reviews: std::collections::HashMap, + tool_review: Option, route_change: Option, update_review: Option<(String, Value)>, @@ -361,6 +372,7 @@ impl Controller { reader: rollup::Reader::default(), generation: 0, route_reviews: Default::default(), + tool_review: None, route_change: None, update_review: None, @@ -371,7 +383,7 @@ impl Controller { if config.config_file != self.config.config_file { self.generation += 1; self.route_reviews.clear(); - + self.tool_review = None; self.route_change = None; } self.config = config; @@ -495,6 +507,10 @@ impl Controller { .map(|r| r.tier.map(|t| format!("{t:?}"))) .collect() }), + compatibility: harness::HARNESSES + .iter() + .map(|&(tool, _)| (tool, login_mode(tool, &config, &route).err())) + .collect(), editable: algorithm.is_some(), generated: config @@ -884,6 +900,36 @@ impl Controller { data: json!({"notes":edited.notes,"revision":revision,"file":self.config.config_file}), }) } + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + let mut paths = harness::review_files(&files); + paths.push(self.config.config_file.clone()); + let review = Review::capture(inputs, paths)?; + let config = self.routes()?; + let selected = current_route(&config, &route, Some(&id))?; + let mut data = harness::preview( + tool, + &files, + &self.config.server_url, + &selected.id, + config.context_window(&selected.key), + login_mode(tool, &config, &selected)?, + )?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), @@ -1011,6 +1057,89 @@ fn current_route(config: &ServerConfig, key: &str, id: Option<&str>) -> Result, + file: Option<&str>, +) -> Result, String> { + if let Some(file) = file { + if account.is_some() { + return Err("Choose a saved account or a custom settings file, not both.".into()); + } + let path = PathBuf::from(file); + if !path.is_absolute() || file.trim().is_empty() || file.contains(['\n', '\r']) { + return Err("Enter an absolute settings file path without line breaks.".into()); + } + if path.is_dir() { + return Err("Choose a settings file, not a folder.".into()); + } + if tool == Harness::Pi { + if path.file_name().is_none_or(|name| name != "models.json") { + return Err("Choose Pi’s models.json; settings.json is read beside it.".into()); + } + return Ok(vec![path.clone(), path.with_file_name("settings.json")]); + } + return Ok(vec![path]); + } + let account = account_path(tool, account)?; + accounts::config_paths(tool, account.as_deref()) +} + +fn account_path(tool: Harness, name: Option<&str>) -> Result, String> { + let Some(name) = name else { + return Ok(None); + }; + let path = accounts::list(tool) + .into_iter() + .find(|(n, _)| n == name) + .map(|(_, p)| p) + .ok_or("Unknown account. Refresh and choose an existing account.")?; + accounts::validate(tool, &path)?; + Ok(Some(path)) +} +fn login_mode( + tool: Harness, + config: &ServerConfig, + route: &crate::server_config::Route, +) -> Result { + let clients = config.clients(); + let choices = config.choices(&route.key); + if choices.is_empty() { + return Err( + "This route cannot be installed by the app. Choose a route with models.".into(), + ); + } + let mut login = false; + for choice in choices { + let client = clients + .iter() + .find(|c| c.name == choice.client) + .ok_or("The route has an unknown endpoint.")?; + if client.forward_auth { + let url = url::Url::parse(&client.base_url) + .map_err(|_| "The route has an invalid endpoint URL.")?; + let host = url + .host_str() + .ok_or("The route has an invalid endpoint URL.")?; + let supported = url.scheme() == "https" + && url.port().is_none_or(|port| port == 443) + && match tool { + Harness::CodexCli | Harness::CodexApp => host == "chatgpt.com", + Harness::Claude => host == "api.anthropic.com", + Harness::Pi => false, + }; + if !supported { + return Err(format!( + "This route forwards the caller's login to {host}. \ + Pick a matching harness or a route with server API credentials." + )); + } + login = true; + } + } + Ok(login) +} fn install_metadata(settings: &std::path::Path) -> Result { let executable = std::env::current_exe().map_err(|e| e.to_string())?; diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs index 206c6c1592..54ce6c2c3d 100644 --- a/crates/switchyard-desktop/src/harness.rs +++ b/crates/switchyard-desktop/src/harness.rs @@ -5,7 +5,7 @@ use serde_json::{Value, json}; use std::path::{Path, PathBuf}; -use toml_edit::DocumentMut; +use toml_edit::{DocumentMut, value}; pub type ReviewedFile = (PathBuf, Option>); @@ -171,6 +171,393 @@ fn object(text: &str) -> Result { Ok(doc) } +fn map<'a>(doc: &'a mut Value, key: &str) -> Result<&'a mut Value, String> { + if doc.get(key).is_none() { + doc[key] = json!({}); + } + if !doc[key].is_object() { + return Err(format!("{key} must be a JSON object.")); + } + Ok(&mut doc[key]) +} + +pub fn validate(tool: Harness, root: &str, model: &str, login: bool) -> Result { + let url = url::Url::parse(root).map_err(|e| e.to_string())?; + if url.scheme() != "http" + || !matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")) + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + return Err( + "Install requires a local HTTP server URL, such as http://127.0.0.1:4123.".into(), + ); + } + if model.trim().is_empty() { + return Err("Pick a route first.".into()); + } + if tool == Harness::Pi && login { + return Err( + "Pi's custom provider needs a route with server API credentials. \ + Use Codex or Claude for subscription login forwarding." + .into(), + ); + } + // validate returns the parsed URL without a trailing slash so callers can append API paths. + Ok(url.to_string().trim_end_matches('/').to_owned()) +} + +fn codex_settings( + text: &str, + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + let mut doc = text + .parse::() + .map_err(|e| e.message().to_owned())?; + if doc + .get("model_providers") + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("model_providers must be a TOML table.".into()); + } + if doc + .get("model_providers") + .and_then(|v| v.get("sy")) + .is_some_and(|item| item.as_table_like().is_none()) + { + return Err("sy must be a TOML provider table.".into()); + } + doc["model"] = value(model); + doc["model_provider"] = value("sy"); + // Codex does not know a route ID, so it compacts only when the settings name the + // route's window. A window kept from another route would compact at the wrong size. + match context_window.and_then(|window| i64::try_from(window).ok()) { + Some(window) => doc["model_context_window"] = value(window), + None => { + doc.remove("model_context_window"); + } + } + doc["model_providers"]["sy"]["name"] = value("Switchyard"); + doc["model_providers"]["sy"]["base_url"] = value(format!("{root}/v1")); + doc["model_providers"]["sy"]["wire_api"] = value("responses"); + doc["model_providers"]["sy"]["requires_openai_auth"] = value(login); + // A stale env_key would make Codex require a local API key even when the + // server owns the credentials or forwards the harness’s subscription login. + doc["model_providers"]["sy"] + .as_table_like_mut() + .ok_or("sy must be a provider table")? + .remove("env_key"); + Ok(doc.to_string()) +} + +fn claude_settings(text: &str, root: &str, model: &str, login: bool) -> Result { + let mut doc = object(text)?; + let env = map(&mut doc, "env")?; + let role_models = [ + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ]; + // The complete installation signature distinguishes our placeholder from a user credential. + let prior_switchyard = env["ANTHROPIC_BASE_URL"].as_str() == Some(root) + && env["ANTHROPIC_AUTH_TOKEN"].as_str() == Some("switchyard-local") + && env["ANTHROPIC_API_KEY"].as_str() == Some("") + && env["ANTHROPIC_MODEL"] + .as_str() + .is_some_and(|model| !model.is_empty()) + && role_models + .iter() + .all(|key| env[*key] == env["ANTHROPIC_MODEL"]); + env["ANTHROPIC_BASE_URL"] = json!(root); + env["ANTHROPIC_MODEL"] = json!(model); + // All model roles use the selected public route, including subagents. + for key in role_models { + env[key] = json!(model); + } + if login { + if prior_switchyard { + let values = env.as_object_mut().ok_or("env must be a JSON object.")?; + values.remove("ANTHROPIC_AUTH_TOKEN"); + values.remove("ANTHROPIC_API_KEY"); + } + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + if env.get(key).is_some_and(|value| value.as_str() != Some("")) { + return Err(format!( + "{key} overrides subscription login. \ + Remove it from Claude settings before installing this route." + )); + } + } + } else { + env["ANTHROPIC_AUTH_TOKEN"] = json!("switchyard-local"); + env["ANTHROPIC_API_KEY"] = json!(""); + } + Ok(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n") +} + +// The preview uses the installation transformations without creating files or backups. +pub fn preview( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore still needs backup cleanup. \ + Retry Restore before connecting another route." + .into()); + } + } + let originals = files + .iter() + .map(|path| read_file(path)) + .collect::, _>>()?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let current = originals + .iter() + .map(|text| text.clone().unwrap_or_default()) + .collect::>(); + Ok(json!({ + "changes": settings_changes(tool, files, ¤t, &incoming)?, + "current": inspect_settings(tool, files, ¤t)?, + "proposed": inspect_settings(tool, files, &incoming)?, + "authentication": if login { + "This route uses the harness’s subscription login." + } else { + "This route uses the API credentials configured on the Switchyard server." + }, + "files": files.iter().map(|path| path.display().to_string()).collect::>(), + "file_actions": files.iter().zip(originals.iter().zip(&incoming)).map(|(path,(before,after))|json!({"file":path,"action":if before.as_deref()==Some(after.as_str()) {"preserve"} else if before.is_none() {"create"} else {"update"}})).collect::>() + })) +} + +// PreviewModel and its nested types omit unknown fields that could contain credentials. +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewModel { + id: Option, + name: Option, + reasoning: Option, + input: Option>, + context_window: Option, + max_tokens: Option, + cost: Option, +} +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "lowercase")] +enum PreviewInput { + Text, + Image, +} +#[derive(serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct PreviewCosts { + input: Option, + output: Option, + cache_read: Option, + cache_write: Option, +} + +// settings_changes compares supported routing fields and hides credential values before display. +fn settings_changes( + tool: Harness, + files: &[PathBuf], + before: &[String], + after: &[String], +) -> Result, String> { + let mut changes = Vec::new(); + for (index, file) in files.iter().enumerate() { + let decode = |text: &str| -> Result { + if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + let doc: toml::Value = toml::from_str(text).map_err(|e| e.to_string())?; + serde_json::to_value(doc).map_err(|e| e.to_string()) + } else { + object(text) + } + }; + let old = decode(&before[index])?; + let new = decode(&after[index])?; + let keys: &[&str] = match tool { + Harness::CodexCli | Harness::CodexApp => &[ + "/model", + "/model_provider", + "/model_context_window", + "/model_providers/sy/name", + "/model_providers/sy/env_key", + "/model_providers/sy/base_url", + "/model_providers/sy/requires_openai_auth", + "/model_providers/sy/wire_api", + ], + Harness::Claude => &[ + "/model", + "/env/ANTHROPIC_MODEL", + "/env/ANTHROPIC_DEFAULT_OPUS_MODEL", + "/env/ANTHROPIC_DEFAULT_SONNET_MODEL", + "/env/ANTHROPIC_DEFAULT_HAIKU_MODEL", + "/env/ANTHROPIC_BASE_URL", + "/env/ANTHROPIC_AUTH_TOKEN", + "/env/ANTHROPIC_API_KEY", + ], + Harness::Pi if index == 0 => &[ + "/providers/switchyard/baseUrl", + "/providers/switchyard/api", + "/providers/switchyard/apiKey", + "/providers/switchyard/headers", + "/providers/switchyard/models", + ], + Harness::Pi => &["/defaultModel", "/defaultProvider"], + }; + for key in keys { + let left = old.pointer(key); + let right = new.pointer(key); + if left == right { + continue; + } + let display = |value: Option<&Value>| -> String { + let Some(value) = value else { + return "(unset)".into(); + }; + match *key { + "/env/ANTHROPIC_AUTH_TOKEN" + | "/env/ANTHROPIC_API_KEY" + | "/providers/switchyard/apiKey" => if value.as_str() == Some("") { + "(empty)" + } else { + "(set; hidden)" + } + .into(), + "/providers/switchyard/headers" => value + .as_object() + .map(|headers| { + headers + .keys() + .map(|name| format!("{name}: (set; hidden)")) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "(configured headers; values hidden)".into()), + "/providers/switchyard/models" => value + .as_array() + .map(|models| { + models + .iter() + .map(|model| { + serde_json::from_value::(model.clone()) + .and_then(serde_json::to_value) + .unwrap_or_else(|_| { + json!("(unsupported model metadata; hidden)") + }) + }) + .collect::>() + }) + .map(|models| Value::Array(models).to_string()) + .unwrap_or_default(), + "/env/ANTHROPIC_BASE_URL" + | "/model_providers/sy/base_url" + | "/providers/switchyard/baseUrl" => value + .as_str() + .and_then(|v| url::Url::parse(v).ok()) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|| "(configured endpoint)".into()), + _ => match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => "(set; hidden)".into(), + }, + } + }; + changes.push(json!({"file":file.display().to_string(),"key":key.trim_start_matches('/').replace('/',"."),"before":display(left),"after":display(right)})); + } + } + Ok(changes) +} + +// Both preview and installation use these transformations so the proposed settings match the saved settings. +fn configured( + tool: Harness, + originals: &[Option], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result, String> { + let mut incoming = Vec::new(); + match tool { + Harness::CodexCli | Harness::CodexApp => incoming.push(codex_settings( + originals[0].as_deref().unwrap_or_default(), + root, + model, + context_window, + login, + )?), + Harness::Claude => incoming.push(claude_settings( + originals[0].as_deref().unwrap_or_default(), + root, + model, + login, + )?), + Harness::Pi => { + let mut doc = object(originals[0].as_deref().unwrap_or_default())?; + let providers = map(&mut doc, "providers")?; + providers["switchyard"] = json!({"baseUrl":format!("{root}/v1"), "api":"openai-completions", "apiKey":"switchyard-local", "headers":{"x-switchyard-origin":"pi"}, "models":[{"id":model,"name":model,"reasoning":true,"input":["text"],"contextWindow":context_window.unwrap_or(128000),"maxTokens":16384,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0}}]}); + incoming.push(serde_json::to_string_pretty(&doc).map_err(|e| e.to_string())? + "\n"); + let mut settings = object(originals[1].as_deref().unwrap_or_default())?; + settings["defaultProvider"] = json!("switchyard"); + settings["defaultModel"] = json!(model); + incoming + .push(serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())? + "\n"); + } + } + Ok(incoming) +} + +fn backup_path(path: &Path) -> PathBuf { + path.with_file_name(format!( + "{}.switchyard-original", + path.file_name().unwrap_or_default().to_string_lossy() + )) +} + +// The marker distinguishes an originally missing file from an empty file. +// Restore removes installed settings only when the original file was missing. +fn absent_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-original-missing") +} + +fn restore_pending_path(path: &Path) -> PathBuf { + backup_path(path).with_extension("switchyard-restore-pending") +} + +pub fn review_files(files: &[PathBuf]) -> Vec { + files + .iter() + .flat_map(|path| { + [ + path.clone(), + backup_path(path), + absent_path(path), + restore_pending_path(path), + ] + }) + .collect() +} + pub fn binary(tool: Harness) -> Option { if tool == Harness::CodexApp { #[cfg(windows)] diff --git a/crates/switchyard-desktop/src/server_config.rs b/crates/switchyard-desktop/src/server_config.rs index b952f4b495..a944f71d29 100644 --- a/crates/switchyard-desktop/src/server_config.rs +++ b/crates/switchyard-desktop/src/server_config.rs @@ -590,6 +590,15 @@ impl ServerConfig { ALGORITHMS.iter().find(|algorithm| algorithm.kind == kind) } + /// This function returns the route's `context_window`, the largest request in tokens + /// that the route accepts, or `None` when the route does not set it. + pub fn context_window(&self, route: &str) -> Option { + self.route(route)? + .get("context_window")? + .as_integer() + .and_then(|window| u64::try_from(window).ok()) + } + /// This function returns the roles that `algorithm` needs on `route`. A random route /// keeps its number of targets, and has at least two. pub fn roles(&self, route: &str, algorithm: &Algorithm) -> Vec { diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 6aee30ddd4..a06a801340 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -228,6 +228,14 @@ const ACTIONS: &[ActionRow] = &[ "Saves an API key that lets the app load an endpoint's model IDs, then loads them.", &[ActionKind::Models], ), + ( + Tab::Harnesses, + 'p', + "preview", + "Shows the settings diff that installing the selected route would make. Nothing is \ + saved.", + &[ActionKind::PreviewInstall], + ), ( Tab::Settings, 'o', @@ -1101,6 +1109,9 @@ struct App { /// This field indexes `data.tools`, which lists every harness in a fixed order. harness: usize, destination: Destination, + /// This field holds the last install preview and the action that produced it. The + /// Harnesses page shows it only while that action matches the selection. + install_preview: Option<(Action, Value)>, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -1134,7 +1145,7 @@ impl App { route_offset: 0, harness: 0, destination: Destination::Harness, - + install_preview: None, server_keys: None, popup: None, toast: None, @@ -1250,6 +1261,15 @@ impl App { .ok_or_else(|| "No harness is listed. Fix the app settings, then press r.".into()) } + /// This function returns the selected account's name, or `None` when the harness's own + /// settings or a custom settings file is the destination. + fn account(&self) -> Option { + match &self.destination { + Destination::Account(name) => Some(name.clone()), + Destination::Harness | Destination::File(_) => None, + } + } + fn custom_file(&self) -> Option { match &self.destination { Destination::File(path) => Some(path.clone()), @@ -1257,6 +1277,49 @@ impl App { } } + /// This function builds the install preview of the selected route for the selected + /// harness and destination. + fn install_action(&self) -> Result { + let tool = self.harness()?.tool; + let route = self.selected_route()?; + Ok(Action::PreviewInstall { + tool, + account: self.account(), + route: route.key.clone(), + id: route.id.clone(), + settings_file: self.custom_file(), + }) + } + + /// This function returns the last install preview while it still describes the selection. + fn install_preview(&self) -> Option<&Value> { + let ( + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + }, + preview, + ) = self.install_preview.as_ref()? + else { + return None; + }; + let selected = self.selected_route().ok()?; + let destination = match (&self.destination, account, settings_file) { + (Destination::Harness, None, None) => true, + (Destination::Account(name), Some(previewed), None) => name == previewed, + (Destination::File(path), None, Some(previewed)) => path == previewed, + _ => false, + }; + let current = destination + && self.harness().is_ok_and(|harness| harness.tool == *tool) + && selected.key == *route + && selected.id == *id; + current.then_some(preview) + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { @@ -1294,7 +1357,12 @@ impl App { }; self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); let reply = reply?; - + if matches!( + (&action, reply.is_ok()), + (Some(Action::PreviewInstall { .. }), false) + ) { + self.install_preview = None; + } match (action.as_ref().map(ActionKind::from), reply) { (Some(ActionKind::ServerKeys), Err(error)) => { self.server_keys = None; @@ -1315,6 +1383,17 @@ impl App { "Type UPDATE to start", )); } + // The Harnesses page shows the diff while the selection still matches it. + (Some(ActionKind::PreviewInstall), Ok(reply)) => { + let previewed = action?; + let changed = reply.data["changes"] + .as_array() + .is_some_and(|changes| !changes.is_empty()); + if !changed { + self.notify("The settings already match this route.".into(), Tone::Done); + } + self.install_preview = Some((previewed, reply.data)); + } (Some(ActionKind::Editor), Ok(reply)) => { if let Some(Action::Editor { route: key, @@ -1794,6 +1873,13 @@ impl App { }; self.open(form) } + ActionKind::PreviewInstall => { + let label = match key { + 'p' => "Previewing the settings", + _ => return Err(format!("The {key} key does not preview harness settings.")), + }; + request(self.install_action()?, label) + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { @@ -3070,6 +3156,7 @@ fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec= SIDE_DETAIL_WIDTH { + Layout::horizontal([Constraint::Fill(1); 2]).areas(rest) + } else { + Layout::vertical([Constraint::Fill(1); 2]).areas(rest) + }; + frame.render_widget(detail, details_area); + frame.render_widget(diff, diff_area); } fn draw_harness_table(frame: &mut Frame, app: &App, area: Rect) { @@ -3262,7 +3363,7 @@ fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { // This destructuring has no `..`, so adding a harness field breaks the build until this // page shows it. let ToolInfo { - tool: _, + tool, label: _, // draw_harnesses titles these details with it available, files, @@ -3275,6 +3376,11 @@ fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { match app.selected_route() { Ok(route) => { lines.push(field("Route", route.label.clone())); + match route.compatibility.get(tool) { + Some(Some(reason)) => lines.push(Line::styled(reason.clone(), theme.warn)), + Some(None) => lines.push(Line::styled("This harness can use the route.", theme.ok)), + None => {} + } } Err(error) => lines.push(Line::from(vec![ Span::styled(format!("{: String { + value[key].as_str().unwrap_or_default().to_string() +} + +/// This function lists each setting that a harness preview changes: the file and setting, the +/// current value, and the value afterward. +fn diff_lines(theme: Theme, preview: &Value, after: &str) -> Vec> { + let mut lines = vec![Line::styled( + format!("SETTINGS DIFF · − current / + {after}"), + theme.muted.bold(), + )]; + for change in preview["changes"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::styled( + format!("{} · {}", text(change, "file"), text(change, "key")), + theme.muted, + ), + Line::styled(format!("− {}", text(change, "before")), theme.error), + Line::styled(format!("+ {}", text(change, "after")), theme.ok), + ]); + } + lines +} + +/// This function shows an install preview: the settings afterward, each changed setting, what +/// happens to each file, and how the harness signs in. +fn install_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::styled("AFTER APPLYING ROUTING", theme.muted.bold())]; + let proposed = text(preview, "proposed"); + lines.extend(proposed.lines().map(|line| Line::from(line.to_string()))); + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "proposed")); + lines.push(Line::default()); + for file in preview["file_actions"].as_array().into_iter().flatten() { + let (action, file) = (text(file, "action"), text(file, "file")); + lines.push(Line::from(format!("Will {action}: {file}"))); + } + lines.push(Line::default()); + lines.push(Line::from(text(preview, "authentication"))); + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { From 683a717eeb6bc6c3af99e9f4da7892378dea908b Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 20/28] feat(desktop): install and restore reviewed harness settings Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 130 ++++++++- crates/switchyard-desktop/src/harness.rs | 286 ++++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 165 ++++++++++- 3 files changed, 567 insertions(+), 14 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 46366bc5c1..0d3a6cc0f0 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -77,6 +77,25 @@ pub enum Action { id: String, settings_file: Option, }, + Install { + tool: Harness, + account: Option, + route: String, + id: String, + settings_file: Option, + preview_token: Option, + }, + PreviewRestore { + tool: Harness, + account: Option, + settings_file: Option, + }, + Restore { + tool: Harness, + account: Option, + settings_file: Option, + preview_token: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -174,6 +193,18 @@ pub struct RouteInfo { /// This field maps each harness to the reason it cannot use the route, or to `None` /// when it can. pub compatibility: BTreeMap>, + /// This field lists the harness settings files that select the route's public model ID. + pub dependents: Vec, +} + +/// Dependent names harness settings that select a route, so deleting the route breaks them. +#[derive(Clone, Serialize)] +pub struct Dependent { + pub tool: Harness, + pub label: &'static str, + /// This field is `None` for the harness's own settings and names a saved account otherwise. + pub account: Option, + pub files: Vec, } /// ToolInfo describes one harness whose settings the apps can change. @@ -511,7 +542,7 @@ impl Controller { .iter() .map(|&(tool, _)| (tool, login_mode(tool, &config, &route).err())) .collect(), - + dependents: known_dependents(&route.id), editable: algorithm.is_some(), generated: config .generated_by("routes", &route.key) @@ -930,6 +961,76 @@ impl Controller { data, }) } + Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let inputs = json!({"tool":tool,"account":account,"route":route,"id":id,"settings_file":settings_file,"server_url":self.config.server_url}); + self.tool_review + .as_ref() + .ok_or("Preview these settings before applying them.")? + .check(preview_token.as_deref(), &inputs)?; + let config = self.routes()?; + let route = current_route(&config, &route, Some(&id))?; + harness::install_reviewed( + tool, + &files, + &self.config.server_url, + &route.id, + config.context_window(&route.key), + login_mode(tool, &config, &route)?, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) + .map(Reply::message) + } + Action::PreviewRestore { + tool, + account, + settings_file, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + let review = Review::capture( + json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}), + harness::review_files(&files), + )?; + let mut data = harness::preview_restore(tool, &files)?; + review.check(Some(&review.token), &review.inputs)?; + data["preview_token"] = json!(review.token); + self.tool_review = Some(review); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::Restore { + tool, + account, + settings_file, + preview_token, + } => { + let files = install_paths(tool, account.as_deref(), settings_file.as_deref())?; + self.tool_review.as_ref().ok_or("Preview the restore before applying it.")?.check(preview_token.as_deref(),&json!({"restore":true,"tool":tool,"account":account,"settings_file":settings_file}))?; + match harness::restore_reviewed( + &files, + self.tool_review + .as_ref() + .map(|review| review.files.as_slice()), + ) { + Ok(message) => Ok(Reply::message(message)), + Err(error) if error.starts_with("Restored settings, but") => Ok(Reply { + message: error, + data: json!({"warning":true}), + }), + Err(error) => Err(error), + } + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), @@ -1035,6 +1136,33 @@ impl Controller { fn logs_dir() -> Result { crate::config::expand_home(std::path::Path::new("~/.switchyard/logs")) } +fn known_dependents(id: &str) -> Vec { + let mut dependents = Vec::new(); + for &(tool, label) in harness::HARNESSES { + let mut destinations = vec![(None, harness::paths(tool))]; + destinations.extend( + accounts::list(tool) + .into_iter() + .map(|(name, path)| (Some(name), accounts::config_paths(tool, Some(&path)))), + ); + for (account, files) in destinations { + let Ok(files) = files else { + continue; + }; + if harness::inspect(tool, &files) + .is_ok_and(|status| status.lines().next() == Some(format!("Model: {id}").as_str())) + { + dependents.push(Dependent { + tool, + label, + account, + files, + }); + } + } + } + dependents +} fn role_info(role: &crate::server_config::Role) -> RoleInfo { RoleInfo { diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs index 54ce6c2c3d..0643a53f1c 100644 --- a/crates/switchyard-desktop/src/harness.rs +++ b/crates/switchyard-desktop/src/harness.rs @@ -4,6 +4,7 @@ //! This module installs and restores user settings for the supported harnesses. use serde_json::{Value, json}; +use std::io::Write; use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, value}; @@ -527,6 +528,78 @@ fn configured( Ok(incoming) } +pub fn install_reviewed( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, + expected: Option<&[ReviewedFile]>, +) -> Result { + let root = validate(tool, root, model, login)?; + for path in files { + if read_file(&restore_pending_path(path))?.is_some() { + return Err("A previous restore did not finish retiring its backups. \ + Retry Restore before installing another route." + .into()); + } + } + let originals = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + let incoming = configured(tool, &originals, &root, model, context_window, login)?; + let incoming: Vec<_> = incoming.into_iter().map(Some).collect(); + for (path, original) in files.iter().zip(&originals) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err("Harness settings changed after the preview. Review them again.".into()); + } + } + for (path, before) in files.iter().zip(&originals) { + let backup = backup_path(path); + let absent = absent_path(path); + if read_file(&backup)?.is_some() || read_file(&absent)?.is_some() { + continue; + } + let (destination, text) = match before { + Some(text) => (&backup, text.as_str()), + None => (&absent, ""), + }; + prepare(destination, text)? + .persist_noclobber(destination) + .map_err(|e| e.to_string())?; + } + replace(files, &originals, &incoming)?; + let next = match tool { + Harness::CodexCli => { + let profile = files[0] + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".config.toml")); + profile + .map(|name| format!(" Start Codex CLI with codex -p {name}.")) + .unwrap_or_else(|| " Restart Codex CLI.".into()) + } + Harness::Pi => " Reload Pi models with /model.".into(), + Harness::CodexApp => " Restart Codex app.".into(), + Harness::Claude => " Restart Claude Code.".into(), + }; + Ok(format!( + "Connected route {model}. Changed {}.{next}", + files + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join(", ") + )) +} + fn backup_path(path: &Path) -> PathBuf { path.with_file_name(format!( "{}.switchyard-original", @@ -558,6 +631,219 @@ pub fn review_files(files: &[PathBuf]) -> Vec { .collect() } +pub fn preview_restore(tool: Harness, files: &[PathBuf]) -> Result { + let resuming = files.iter().any(|path| restore_pending_path(path).exists()); + let mut plan = Vec::new(); + let mut before = Vec::new(); + let mut after = Vec::new(); + for path in files { + let current = read_file(path)?; + let pending = read_file(&restore_pending_path(path))?; + let original = match pending { + Some(text) => { + serde_json::from_str::>(&text).map_err(|error| error.to_string())? + } + None => match read_file(&backup_path(path))? { + Some(text) => Some(text), + None if read_file(&absent_path(path))?.is_some() => None, + None if resuming => current.clone(), + None => return Err("No original backup is available for this destination.".into()), + }, + }; + let action = if current == original { + "unchanged" + } else { + match (¤t, &original) { + (_, None) => "remove", + (None, _) => "create", + _ => "replace", + } + }; + before.push(current.unwrap_or_default()); + after.push(original.unwrap_or_default()); + plan.push(json!({"file":path,"action":action,"backup":backup_path(path)})); + } + Ok(json!({ + "files": plan, + "changes": settings_changes(tool, files, &before, &after).ok(), + "warning": "Restore replaces each listed file with its original version. \ + Later edits are kept in a recovery copy before replacement." + })) +} + +pub fn restore_reviewed( + files: &[PathBuf], + expected: Option<&[ReviewedFile]>, +) -> Result { + let pending = files + .iter() + .map(|path| read_file(&restore_pending_path(path))) + .collect::, _>>()?; + let resuming = pending.iter().any(Option::is_some); + let mut restored = Vec::new(); + for (path, pending) in files.iter().zip(pending) { + let original = read_file(&backup_path(path))?; + let absent = read_file(&absent_path(path))?.is_some(); + let original = match pending { + Some(text) => serde_json::from_str::>(&text) + .map_err(|e| format!("Read pending restore: {e}"))?, + None if original.is_none() && !absent => { + if !resuming { + return Err("No original backup is available for this installation.".into()); + } + // A cleared receipt means this file finished restoring, so a retry preserves later edits. + read_file(path)? + } + None => original, + }; + restored.push(original); + } + let current = files + .iter() + .map(|p| read_file(p)) + .collect::, _>>()?; + check_review(expected)?; + for (path, original) in files.iter().zip(¤t) { + if let Some(expected) = expected + .and_then(|files| files.iter().find(|(file, _)| file == path)) + .map(|(_, bytes)| bytes) + && original.as_ref().map(|text| text.as_bytes()) != expected.as_deref() + { + return Err( + "Harness settings changed after the restore preview. Review them again.".into(), + ); + } + } + let mut kept_paths = Vec::new(); + for (path, before) in files.iter().zip(¤t) { + let Some(before) = before else { + continue; + }; + let mut recovery = tempfile::Builder::new() + .prefix("switchyard-before-restore-") + .tempfile_in(path.parent().ok_or("Settings need a parent directory")?) + .map_err(|e| e.to_string())?; + recovery + .write_all(before.as_bytes()) + .map_err(|e| e.to_string())?; + let (_, kept) = recovery.keep().map_err(|e| e.to_string())?; + kept_paths.push(kept.display().to_string()); + } + // Each private receipt stores the original contents or absence and blocks installation during cleanup. + for (path, original) in files.iter().zip(&restored) { + let pending = restore_pending_path(path); + if read_file(&pending)?.is_none() { + let text = serde_json::to_string(original).map_err(|e| e.to_string())?; + prepare(&pending, &text)? + .persist_noclobber(&pending) + .map_err(|e| e.to_string())?; + } + } + let recovery = format!("Previous settings: {}", kept_paths.join(", ")); + replace(files, ¤t, &restored).map_err(|error| format!("{error}. {recovery}"))?; + for path in files { + for retired in [backup_path(path), absent_path(path)] { + match std::fs::remove_file(&retired) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "Restored settings, but could not retire {}: {error}. \ + Retry Restore before installing another route. {recovery}", + retired.display() + )); + } + } + } + } + for path in files { + let pending = restore_pending_path(path); + std::fs::remove_file(&pending).map_err(|error| { + format!( + "Restored settings, but could not clear {}: {error}. {recovery}", + pending.display() + ) + })?; + } + Ok(format!( + "Restored original settings. Restart the harness. {recovery}" + )) +} + +fn check_review(expected: Option<&[ReviewedFile]>) -> Result<(), String> { + if let Some(files) = expected { + for (path, expected) in files { + let current = match std::fs::read(path) { + Ok(bytes) => Some(std::sync::Arc::<[u8]>::from(bytes)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(error) => return Err(error.to_string()), + }; + if ¤t != expected { + return Err(format!( + "{} changed after the preview. Review it again.", + path.display() + )); + } + } + } + Ok(()) +} + +fn prepare(path: &Path, text: &str) -> Result { + let parent = path.parent().ok_or("Settings need a parent directory")?; + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(|e| e.to_string())?; + temp.write_all(text.as_bytes()) + .and_then(|_| temp.as_file().sync_all()) + .map_err(|e| e.to_string())?; + Ok(temp) +} + +// Each file replacement is atomic, but the set of replacements is not. +// A later failure triggers an attempt to restore files already replaced. +fn replace( + files: &[PathBuf], + before: &[Option], + after: &[Option], +) -> Result<(), String> { + let staged = files + .iter() + .zip(after) + .map(|(path, text)| text.as_deref().map(|text| prepare(path, text)).transpose()) + .collect::, _>>()?; + for (path, expected) in files.iter().zip(before) { + if read_file(path)? != *expected { + return Err(format!( + "{} changed while installing. Refresh and try again.", + path.display() + )); + } + } + for (index, (path, temp)) in files.iter().zip(staged).enumerate() { + let result = match temp { + Some(temp) => temp.persist(path).map(|_| ()).map_err(|e| e.to_string()), + None if before[index].is_some() => { + std::fs::remove_file(path).map_err(|e| e.to_string()) + } + None => Ok(()), + }; + if let Err(error) = result { + for (path, original) in files[..index].iter().zip(before) { + match original { + Some(text) => { + prepare(path, text)? + .persist(path) + .map_err(|e| e.to_string())?; + } + None => std::fs::remove_file(path).map_err(|e| e.to_string())?, + } + } + return Err(error); + } + } + Ok(()) +} + pub fn binary(tool: Harness) -> Option { if tool == Harness::CodexApp { #[cfg(windows)] diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index a06a801340..584d2a1e50 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -8,8 +8,8 @@ //! refreshes and actions, so moving around and filtering never wait for the log or a service. use crate::controller::{ - AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Reply, RouteInfo, - Snapshot, ToolInfo, + AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Dependent, Reply, + RouteInfo, Snapshot, ToolInfo, }; use crate::history::Entry; use crate::pricing::Estimate; @@ -236,6 +236,22 @@ const ACTIONS: &[ActionRow] = &[ saved.", &[ActionKind::PreviewInstall], ), + ( + Tab::Harnesses, + 'i', + "install", + "Shows the settings diff, then saves it after you type INSTALL. A backup keeps the \ + original settings.", + &[ActionKind::PreviewInstall, ActionKind::Install], + ), + ( + Tab::Harnesses, + 'u', + "restore", + "Shows which files return to their original versions, then restores them after you \ + type RESTORE.", + &[ActionKind::PreviewRestore, ActionKind::Restore], + ), ( Tab::Settings, 'o', @@ -834,7 +850,13 @@ impl Form { FormKind::NewRoute { .. } | FormKind::NewRouteModels { .. } | FormKind::RouteIdentity { .. } => "Reviewing the route change", - FormKind::Confirm(_, _action) => "Saving the route", + // The reviewed action names what typing the confirmation word saves. + FormKind::Confirm(_, action) => match **action { + Action::Install { .. } => "Installing the harness settings", + Action::Restore { .. } => "Restoring the harness settings", + + _ => "Saving the route", + }, FormKind::Remove { .. } => "Deleting the route", FormKind::Models => "Loading models", FormKind::ModelKey => "Saving the key and loading models", @@ -945,7 +967,30 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { choices, preview_token, }, - + Action::PreviewInstall { + tool, + account, + route, + id, + settings_file, + } => Action::Install { + tool, + account, + route, + id, + settings_file, + preview_token, + }, + Action::PreviewRestore { + tool, + account, + settings_file, + } => Action::Restore { + tool, + account, + settings_file, + preview_token, + }, _ => return None, }) } @@ -1351,17 +1396,17 @@ impl App { reply: Option>, snapshot: Result, String>, ) -> Option { - let (action, _key) = match self.busy.take() { + let (action, key) = match self.busy.take() { Some(busy) => (busy.action, busy.key), None => (None, None), }; self.apply_snapshot(snapshot.map(|snapshot| *snapshot)); let reply = reply?; - if matches!( - (&action, reply.is_ok()), - (Some(Action::PreviewInstall { .. }), false) - ) { - self.install_preview = None; + match (&action, reply.is_ok()) { + // Saved settings no longer match an earlier diff, and a failed preview has none. + (Some(Action::Install { .. } | Action::Restore { .. }), true) + | (Some(Action::PreviewInstall { .. }), false) => self.install_preview = None, + _ => {} } match (action.as_ref().map(ActionKind::from), reply) { (Some(ActionKind::ServerKeys), Err(error)) => { @@ -1383,7 +1428,8 @@ impl App { "Type UPDATE to start", )); } - // The Harnesses page shows the diff while the selection still matches it. + // The p key shows the diff on the page. The i key also opens the review, unless the + // settings already match the route. (Some(ActionKind::PreviewInstall), Ok(reply)) => { let previewed = action?; let changed = reply.data["changes"] @@ -1391,9 +1437,39 @@ impl App { .is_some_and(|changes| !changes.is_empty()); if !changed { self.notify("The settings already match this route.".into(), Tone::Done); + } else if key == Some('i') + && let Some(install @ Action::Install { tool, .. }) = + reviewed(previewed.clone(), &reply.data) + { + let known = self.data.tools.iter().find(|known| known.tool == tool); + let label = known.map_or("the harness", |known| known.label); + let title = format!("Apply routing to {label}?"); + self.popup = Some(Popup::review( + install_lines(self.theme, &reply.data), + "Install", + FormKind::Confirm("INSTALL".into(), Box::new(install)), + title, + "Type INSTALL to save the settings", + )); + } else { + let text = "Press i to apply this diff."; + self.notify(text.into(), Tone::Done); } self.install_preview = Some((previewed, reply.data)); } + (Some(ActionKind::PreviewRestore), Ok(reply)) => { + if let Some(restore) = action.and_then(|action| reviewed(action, &reply.data)) { + self.popup = Some(Popup::review( + restore_lines(self.theme, &reply.data), + "Restore", + FormKind::Confirm("RESTORE".into(), Box::new(restore)), + "Restore harness settings?", + "Type RESTORE to replace the files", + )); + } + } + // The reply lists the roles of the chosen algorithm. The models form replaces the + // algorithm form only while that form is still open for the same route. (Some(ActionKind::Editor), Ok(reply)) => { if let Some(Action::Editor { route: key, @@ -1873,14 +1949,27 @@ impl App { }; self.open(form) } - ActionKind::PreviewInstall => { + // Both keys preview the install. App::finish shows the diff for p and opens the + // review, which submits Install, for i. + ActionKind::PreviewInstall | ActionKind::Install => { let label = match key { 'p' => "Previewing the settings", + 'i' => "Reviewing the settings", _ => return Err(format!("The {key} key does not preview harness settings.")), }; request(self.install_action()?, label) } - + // The review window lists the files that return to their backups and submits + // Restore. + ActionKind::PreviewRestore | ActionKind::Restore => { + let tool = self.harness()?.tool; + let restore = Action::PreviewRestore { + tool, + account: self.account(), + settings_file: self.custom_file(), + }; + request(restore, "Reading the original settings") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -3157,6 +3246,7 @@ fn route_lines(theme: Theme, data: &Snapshot, route: &RouteInfo) -> Vec = files.iter().map(|f| f.display().to_string()).collect(); + lines.push(Line::from(format!( + "{label}{}: {}", + account.unwrap_or_default(), + files.join(", ") + ))); + } + } if let Some(generated) = &route.generated { lines.push(Line::default()); lines.push(Line::styled( @@ -4051,6 +4168,28 @@ fn install_lines(theme: Theme, preview: &Value) -> Vec> { lines } +/// This function shows a restore preview: what restoring does to each file, the backup it +/// uses, and the settings that change. +fn restore_lines(theme: Theme, preview: &Value) -> Vec> { + let mut lines = vec![Line::from(text(preview, "warning"))]; + for file in preview["files"].as_array().into_iter().flatten() { + lines.extend([ + Line::default(), + Line::from(format!("{}: {}", text(file, "action"), text(file, "file"))), + Line::styled( + format!("Original backup: {}", text(file, "backup")), + theme.muted, + ), + ]); + } + // The controller leaves the diff out when a file cannot be compared. + if preview["changes"].is_array() { + lines.push(Line::default()); + lines.extend(diff_lines(theme, preview, "restored")); + } + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { From 71aac00d32a2802d321064719c01a5a2820fe631 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 21/28] feat(desktop): add named harness accounts to the Harnesses page Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/accounts.rs | 104 +++++++++++++++++ crates/switchyard-desktop/src/controller.rs | 75 ++++++++++++- crates/switchyard-desktop/src/main.rs | 12 ++ crates/switchyard-desktop/src/server.rs | 71 ++++++++++++ crates/switchyard-desktop/src/sessions.rs | 6 + crates/switchyard-desktop/src/tui.rs | 117 +++++++++++++++++++- crates/switchyard-desktop/src/windows.rs | 98 ++++++++++++++++ 7 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 crates/switchyard-desktop/src/sessions.rs diff --git a/crates/switchyard-desktop/src/accounts.rs b/crates/switchyard-desktop/src/accounts.rs index ae7bfc5737..96ee972c64 100644 --- a/crates/switchyard-desktop/src/accounts.rs +++ b/crates/switchyard-desktop/src/accounts.rs @@ -6,6 +6,18 @@ use crate::harness::{self, Harness}; use std::path::{Path, PathBuf}; +// PendingAccount removes only an empty directory if login setup fails. +// The harness may already have saved login files, which must stay. +struct PendingAccount(Option); + +impl Drop for PendingAccount { + fn drop(&mut self) { + if let Some(path) = &self.0 { + let _ = std::fs::remove_dir(path); + } + } +} + fn root(tool: Harness) -> Result { let name = match tool { Harness::CodexCli => "codex", @@ -85,3 +97,95 @@ pub fn config_paths(tool: Harness, account: Option<&Path>) -> Result harness::paths(tool), } } + +pub fn add(tool: Harness, name: &str) -> Result { + if name.is_empty() + || name.len() > 64 + || !name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err("Account names need 1–64 letters, numbers, hyphens, or underscores.".into()); + } + let root = root(tool)?; + harness::binary(tool).ok_or("Install the selected harness first.")?; + std::fs::create_dir_all(&root).map_err(|e| e.to_string())?; + let path = root.join(name); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .map_err(|e| { + format!( + "Create account: {e}. \ + Pick an existing account after Refresh, or choose a new name." + ) + })?; + } + #[cfg(not(unix))] + std::fs::create_dir(&path).map_err(|e| e.to_string())?; + let mut pending = PendingAccount(Some(path.clone())); + login(tool, &path, name)?; + pending.0 = None; + Ok(format!( + "Opened the harness's login in Terminal for account {name}. Complete login, \ + click Refresh, and select this account. Switchyard does not read the login tokens. \ + The selected account applies to new sessions only. Account directory: {}", + path.display() + )) +} + +pub fn login(tool: Harness, path: &Path, name: &str) -> Result { + validate(tool, path)?; + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + let variable = match tool { + Harness::CodexCli => "CODEX_HOME", + Harness::Claude => "CLAUDE_CONFIG_DIR", + _ => return Err("Choose a supported harness.".into()), + }; + #[cfg(windows)] + crate::windows::open_terminal(crate::windows::TerminalJob { + binary: binary.clone(), + args: match tool { + Harness::CodexCli => vec!["login".into()], + _ => vec!["auth".into(), "login".into()], + }, + directory: None, + environment: vec![(variable.into(), path.display().to_string())], + remove_environment: vec![], + })?; + #[cfg(not(windows))] + { + let login = match tool { + Harness::CodexCli => "login", + _ => "auth login", + }; + let script = format!( + "#!/usr/bin/env bash\n\ + rm -- \"$0\"\n\ + export PATH=\"/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\ + export {variable}={}\n\ + {} {login}", + crate::sessions::quote(&path.display().to_string()), + crate::sessions::quote(&binary.display().to_string()) + ); + crate::server::open_terminal_script("switchyard-login-", &script)?; + } + Ok(format!( + "Opened login in Terminal for account {name}. Finish login in the harness. \ + Login status remains unknown until the harness confirms it." + )) +} + +pub fn archive(tool: Harness, path: &Path) -> Result { + validate(tool, path)?; + let archived = path.with_file_name(format!(".archived-{}", uuid::Uuid::now_v7())); + std::fs::rename(path, &archived).map_err(|error| error.to_string())?; + Ok(format!( + "Archived the account folder at {}. Login files are preserved. \ + Existing sessions may still use the old directory and need a new login.", + archived.display() + )) +} diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 0d3a6cc0f0..8818869d76 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -96,6 +96,27 @@ pub enum Action { settings_file: Option, preview_token: Option, }, + AddAccount { + tool: Harness, + name: String, + }, + LoginAccount { + tool: Harness, + name: String, + }, + PreviewAccountArchive { + tool: Harness, + name: String, + }, + ArchiveAccount { + tool: Harness, + name: String, + preview_token: Option, + }, + OpenAccount { + tool: Harness, + name: String, + }, Restart {}, PreviewUpdate {}, Update { @@ -390,7 +411,7 @@ pub struct Controller { generation: u64, route_reviews: std::collections::HashMap, tool_review: Option, - + account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, } @@ -404,7 +425,7 @@ impl Controller { generation: 0, route_reviews: Default::default(), tool_review: None, - + account_archive: None, route_change: None, update_review: None, } @@ -707,7 +728,9 @@ impl Controller { } Action::OpenUpdateLog {} => self.update_log().map(Some), Action::OpenLogs {} => logs_dir().map(Some), - + // An account folder opens in the editor too, because a terminal may run over SSH, + // where `open` would start a file manager on another screen. + Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), _ => Ok(None), } } @@ -1031,6 +1054,52 @@ impl Controller { Err(error) => Err(error), } } + Action::AddAccount { tool, name } => { + accounts::add(tool, name.trim()).map(Reply::message) + } + Action::LoginAccount { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + accounts::login(tool, &path, &name).map(Reply::message) + } + Action::PreviewAccountArchive { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + let token = uuid::Uuid::now_v7().to_string(); + self.account_archive = Some((tool, name.clone(), token.clone())); + Ok(Reply { + message: String::new(), + data: json!({ + "name": name, + "path": path, + "preview_token": token, + "notice": "Archive moves this account out of the picker and preserves all \ + login files. Existing sessions using its old directory may \ + need a new login." + }), + }) + } + Action::ArchiveAccount { + tool, + name, + preview_token, + } => { + let (expected_tool, expected_name, token) = self + .account_archive + .as_ref() + .ok_or("Review account archive first.")?; + if tool != *expected_tool + || name != *expected_name + || preview_token.as_deref() != Some(token.as_str()) + { + return Err("Review account archive again.".into()); + } + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + accounts::archive(tool, &path).map(Reply::message) + } + Action::OpenAccount { tool, name } => { + let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; + server::command("open", &[&path.display().to_string()])?; + Ok(Reply::message("Opened the account folder.".into())) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), diff --git a/crates/switchyard-desktop/src/main.rs b/crates/switchyard-desktop/src/main.rs index 7b1960b606..cbed7a1fa7 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -19,6 +19,7 @@ mod models; mod pricing; mod server; mod server_config; +mod sessions; mod summary; mod tui; #[cfg(windows)] @@ -54,6 +55,17 @@ Recorded spending remains visible when savings are unknown."; /// The command checks TOML syntax or opens the desktop window. fn main() -> ExitCode { + #[cfg(windows)] + if let Some(result) = windows::terminal_job() { + return match result { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("switchyard-desktop: {error}"); + ExitCode::FAILURE + } + }; + } + #[cfg(windows)] windows::attach_console(); let mut settings = None; diff --git a/crates/switchyard-desktop/src/server.rs b/crates/switchyard-desktop/src/server.rs index 93b8869521..9c30c0ce29 100644 --- a/crates/switchyard-desktop/src/server.rs +++ b/crates/switchyard-desktop/src/server.rs @@ -461,6 +461,77 @@ pub fn command(program: &str, args: &[&str]) -> Result { Ok(String::from_utf8_lossy(&output.stdout).into_owned()) } +// This function saves the script before opening a terminal so a save error cannot launch it. +// If no terminal starts, this function removes the script; callers supply scripts that remove themselves on startup. +#[cfg(not(windows))] +pub fn open_terminal_script(prefix: &str, text: &str) -> Result<(), String> { + let mut script = tempfile::Builder::new() + .prefix(prefix) + .suffix(".command") + .tempfile() + .map_err(|e| e.to_string())?; + writeln!(script, "{text}").map_err(|e| e.to_string())?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + script + .as_file() + .set_permissions(std::fs::Permissions::from_mode(0o700)) + .map_err(|e| e.to_string())?; + } + let (_, path) = script.keep().map_err(|e| e.to_string())?; + if let Err(error) = run_in_terminal(&path) { + let _ = std::fs::remove_file(path); + return Err(error); + } + Ok(()) +} + +// macOS `open` runs a `.command` file in Terminal. +#[cfg(not(any(windows, target_os = "linux")))] +fn run_in_terminal(path: &Path) -> Result<(), String> { + command("open", &[&path.display().to_string()]).map(drop) +} + +// xdg-open may open the script in an editor, so this function starts a terminal program that runs it. +// It tries $TERMINAL first and then common terminal programs, and uses the first one that starts. +#[cfg(target_os = "linux")] +fn run_in_terminal(path: &Path) -> Result<(), String> { + let preferred = std::env::var("TERMINAL") + .ok() + .filter(|name| !name.is_empty()); + let terminals = preferred + .as_deref() + .map(|name| (name, "-e")) + .into_iter() + .chain([ + ("x-terminal-emulator", "-e"), + ("gnome-terminal", "--"), + ("konsole", "-e"), + ("xterm", "-e"), + ]); + for (program, flag) in terminals { + let started = Command::new(program) + .arg(flag) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + if let Ok(mut terminal) = started { + // A thread waits for the terminal so the exited process does not stay a zombie. + std::thread::spawn(move || terminal.wait()); + return Ok(()); + } + } + Err( + "Could not start a terminal. Switchyard tried $TERMINAL, x-terminal-emulator, \ + gnome-terminal, konsole, and xterm. Set the TERMINAL environment variable to your \ + terminal program, for example TERMINAL=xterm, and try again." + .into(), + ) +} + #[cfg(all(test, unix))] mod tests { use super::*; diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs new file mode 100644 index 0000000000..ebdd585853 --- /dev/null +++ b/crates/switchyard-desktop/src/sessions.rs @@ -0,0 +1,6 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) fn quote(text: &str) -> String { + format!("'{}'", text.replace('\'', "'\\''")) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 584d2a1e50..ca25ad3894 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -11,6 +11,7 @@ use crate::controller::{ AccountInfo, Action, ActionKind, AppSettings, Availability, Controller, Dependent, Reply, RouteInfo, Snapshot, ToolInfo, }; +use crate::harness::Harness; use crate::history::Entry; use crate::pricing::Estimate; use crate::rollup::{CLASSIFIER_TIER, ModelTokens, Totals, Usage}; @@ -252,6 +253,38 @@ const ACTIONS: &[ActionRow] = &[ type RESTORE.", &[ActionKind::PreviewRestore, ActionKind::Restore], ), + ( + Tab::Harnesses, + 'n', + "add account", + "Creates a saved account and opens the harness's login in a new terminal window.", + &[ActionKind::AddAccount], + ), + ( + Tab::Harnesses, + 'l', + "login", + "Opens the selected account's login again in a new terminal window.", + &[ActionKind::LoginAccount], + ), + ( + Tab::Harnesses, + 'o', + "open account", + "Opens the selected account folder in $VISUAL or $EDITOR.", + &[ActionKind::OpenAccount], + ), + ( + Tab::Harnesses, + 'd', + "archive", + "Moves the selected account out of the list after you type its name. Its login files \ + are kept.", + &[ + ActionKind::PreviewAccountArchive, + ActionKind::ArchiveAccount, + ], + ), ( Tab::Settings, 'o', @@ -549,6 +582,8 @@ enum FormKind { }, Models, ModelKey, + /// The harness is the one selected when the form opened. + Account(Harness), SettingsFile, } /// Choices names the list that ↑ and ↓ cycle through in a form field. @@ -835,6 +870,10 @@ impl Form { refresh: true, key: Some(self.value(1)), }, + FormKind::Account(tool) => Action::AddAccount { + tool: *tool, + name: self.value(0), + }, FormKind::SettingsFile => return Ok(Submit::SettingsFile(self.value(0))), })) } @@ -854,13 +893,13 @@ impl Form { FormKind::Confirm(_, action) => match **action { Action::Install { .. } => "Installing the harness settings", Action::Restore { .. } => "Restoring the harness settings", - + Action::ArchiveAccount { .. } => "Archiving the account", _ => "Saving the route", }, FormKind::Remove { .. } => "Deleting the route", FormKind::Models => "Loading models", FormKind::ModelKey => "Saving the key and loading models", - + FormKind::Account(_) => "Adding the account", FormKind::SettingsFile => "Choosing the settings file", } } @@ -991,6 +1030,11 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { settings_file, preview_token, }, + Action::PreviewAccountArchive { tool, name } => Action::ArchiveAccount { + tool, + name, + preview_token, + }, _ => return None, }) } @@ -1322,6 +1366,14 @@ impl App { } } + /// Account actions change one saved account, so this function rejects other destinations. + fn selected_account(&self) -> Result<(Harness, String), String> { + let name = self + .account() + .ok_or("Press a to select a saved account first.")?; + Ok((self.harness()?.tool, name)) + } + /// This function builds the install preview of the selected route for the selected /// harness and destination. fn install_action(&self) -> Result { @@ -1406,6 +1458,8 @@ impl App { // Saved settings no longer match an earlier diff, and a failed preview has none. (Some(Action::Install { .. } | Action::Restore { .. }), true) | (Some(Action::PreviewInstall { .. }), false) => self.install_preview = None, + // The archived account leaves the list, so the harness's own settings are next. + (Some(Action::ArchiveAccount { .. }), true) => self.destination = Destination::Harness, _ => {} } match (action.as_ref().map(ActionKind::from), reply) { @@ -1468,6 +1522,20 @@ impl App { )); } } + (Some(ActionKind::PreviewAccountArchive), Ok(reply)) => { + if let Some(archive) = action.and_then(|action| reviewed(action, &reply.data)) + && let Action::ArchiveAccount { name, .. } = &archive + { + let name = name.clone(); + self.popup = Some(Popup::review( + archive_lines(self.theme, &reply.data), + "Archive", + FormKind::Confirm(name.clone().into(), Box::new(archive)), + format!("Archive the account {name}?"), + "Type the account name to archive", + )); + } + } // The reply lists the roles of the chosen algorithm. The models form replaces the // algorithm form only while that form is still open for the same route. (Some(ActionKind::Editor), Ok(reply)) => { @@ -1970,6 +2038,34 @@ impl App { }; request(restore, "Reading the original settings") } + ActionKind::AddAccount => { + let harness = self.harness()?; + let title = format!("Sign in to another {} account", harness.label); + let kind = FormKind::Account(harness.tool); + self.open(Form::new( + kind, + title, + vec![("Account name", String::new())], + )) + } + ActionKind::LoginAccount => { + let (tool, name) = self.selected_account()?; + request(Action::LoginAccount { tool, name }, "Opening the login") + } + // Controller::editor_target returns the account folder, so it opens in the editor. + ActionKind::OpenAccount => { + let (tool, name) = self.selected_account()?; + request( + Action::OpenAccount { tool, name }, + "Opening the account folder", + ) + } + // The review window names the account folder and submits ArchiveAccount. + ActionKind::PreviewAccountArchive | ActionKind::ArchiveAccount => { + let (tool, name) = self.selected_account()?; + let archive = Action::PreviewAccountArchive { tool, name }; + request(archive, "Reviewing the account archive") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -4040,6 +4136,14 @@ fn form_notes(app: &App, form: &Form) -> Vec> { ), Line::default(), ], + FormKind::Account(_) => vec![ + Line::from( + "Use 1–64 ASCII letters, digits, hyphens, or underscores. Login opens in a new \ + terminal window; finish it there. API routes use credentials configured on \ + the server. The Codex app manages its own account.", + ), + Line::default(), + ], FormKind::Preferences | FormKind::Update(_) | FormKind::Confirm(..) @@ -4190,6 +4294,15 @@ fn restore_lines(theme: Theme, preview: &Value) -> Vec> { lines } +fn archive_lines(theme: Theme, preview: &Value) -> Vec> { + vec![ + Line::from(text(preview, "notice")), + Line::default(), + labeled(theme, "Account", 11, text(preview, "name")), + labeled(theme, "Directory", 11, text(preview, "path")), + ] +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { diff --git a/crates/switchyard-desktop/src/windows.rs b/crates/switchyard-desktop/src/windows.rs index 8e0d2d0003..7f768cfd84 100644 --- a/crates/switchyard-desktop/src/windows.rs +++ b/crates/switchyard-desktop/src/windows.rs @@ -1,6 +1,65 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +//! Harnesses run in their own Windows console with explicit arguments and account directories. + +use serde::{Deserialize, Serialize}; +use std::{ + path::{Path, PathBuf}, + process::Command, +}; + +/// This function reads current-user MSIX registrations without launching or changing the Codex app. +pub fn codex_app() -> Option { + use windows::{Management::Deployment::PackageManager, core::HSTRING}; + // An empty SID restricts discovery to packages registered for the current user. + let packages = PackageManager::new() + .ok()? + .FindPackagesByUserSecurityId(&HSTRING::new()) + .ok()?; + for package in packages { + let Some(name) = package.Id().ok().and_then(|id| id.Name().ok()) else { + continue; + }; + if name == "OpenAI.Codex" { + return Some(PathBuf::from(package.InstalledPath().ok()?.to_os_string())); + } + } + None +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TerminalJob { + pub binary: PathBuf, + pub args: Vec, + pub directory: Option, + pub environment: Vec<(String, String)>, + pub remove_environment: Vec, +} +/// The child reads structured launch settings so arguments and account directories remain separate. +pub fn open_terminal(job: TerminalJob) -> Result<(), String> { + use std::io::Write; + let mut file = tempfile::Builder::new() + .prefix("switchyard-terminal-") + .suffix(".json") + .tempfile() + .map_err(|e| e.to_string())?; + file.write_all(&serde_json::to_vec(&job).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + let mut child = Command::new(std::env::current_exe().map_err(|e| e.to_string())?); + // The child deletes the file, so the file must be kept before the child starts. + let path = file + .into_temp_path() + .keep() + .map_err(|error| error.to_string())?; + child.arg("--terminal-job").arg(&path); + if let Err(error) = child.spawn() { + let _ = std::fs::remove_file(&path); + return Err(error.to_string()); + } + Ok(()) +} pub fn attach_console() { unsafe { windows_console(false); @@ -29,3 +88,42 @@ unsafe fn windows_console(allocate: bool) { } } } +pub fn terminal_job() -> Option> { + use std::io::IsTerminal; + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if args.first().is_none_or(|v| v != "--terminal-job") { + return None; + } + Some((|| { + if args.len() != 2 { + return Err("The terminal job requires one saved launch file.".into()); + } + let path = Path::new(&args[1]); + let job: TerminalJob = + serde_json::from_slice(&std::fs::read(path).map_err(|e| e.to_string())?) + .map_err(|e| e.to_string())?; + std::fs::remove_file(path).map_err(|e| e.to_string())?; + allocate_console(); + let mut child = Command::new(&job.binary); + child.args(&job.args).envs(job.environment); + for name in job.remove_environment { + child.env_remove(name); + } + if let Some(directory) = job.directory { + child.current_dir(directory); + } + let status = child + .status() + .map_err(|e| format!("Could not start the harness: {e}"))?; + println!("Harness exited with {status}. Press Enter to close this window."); + if std::io::stdin().is_terminal() { + let mut line = String::new(); + let _ = std::io::stdin().read_line(&mut line); + } + if status.success() { + Ok(()) + } else { + Err(format!("Harness exited with {status}.")) + } + })()) +} From aac8ca95310db23fb4a7d1ae4e1c3cf6078d5a21 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 22/28] test(desktop): verify harness preview, install, and restore edge cases Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/harness.rs | 299 +++++++++++++++++++++++ 1 file changed, 299 insertions(+) diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs index 0643a53f1c..0f1b2695e4 100644 --- a/crates/switchyard-desktop/src/harness.rs +++ b/crates/switchyard-desktop/src/harness.rs @@ -528,6 +528,18 @@ fn configured( Ok(incoming) } +#[cfg(test)] +pub fn install( + tool: Harness, + files: &[PathBuf], + root: &str, + model: &str, + context_window: Option, + login: bool, +) -> Result { + install_reviewed(tool, files, root, model, context_window, login, None) +} + pub fn install_reviewed( tool: Harness, files: &[PathBuf], @@ -671,6 +683,12 @@ pub fn preview_restore(tool: Harness, files: &[PathBuf]) -> Result Result { + restore_reviewed(files, None) +} + pub fn restore_reviewed( files: &[PathBuf], expected: Option<&[ReviewedFile]>, @@ -909,3 +927,284 @@ pub fn binary(tool: Harness) -> Option { #[cfg(not(windows))] dirs.into_iter().map(|p| p.join(name)).find(|p| p.is_file()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + // Preview must match the installed settings without creating files or backups. + fn preview_matches_installation_without_writes() { + for (tool, _) in HARNESSES { + let dir = tempfile::tempdir().expect("directory"); + let files = if *tool == Harness::Pi { + vec![ + dir.path().join("models.json"), + dir.path().join("settings.json"), + ] + } else { + vec![dir.path().join("config")] + }; + let original = if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "# user comment\nmodel='before'\nsandbox_mode='workspace-write'\n" + } else { + "{\"custom\":true}" + }; + std::fs::write(&files[0], original).expect("settings"); + let result = preview( + *tool, + &files, + "http://localhost:4123", + "new-route", + None, + false, + ) + .expect("preview"); + assert!( + result["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["after"] + .as_str() + .is_some_and(|value| value.contains("new-route"))) + ); + assert_eq!(read(&files[0]).expect("unchanged"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); + install( + *tool, + &files, + "http://localhost:4123", + "new-route", + None, + false, + ) + .expect("install"); + assert_eq!( + result["proposed"], + inspect(*tool, &files).expect("installed") + ); + install( + *tool, + &files, + "http://localhost:4123", + "refreshed-route", + None, + false, + ) + .expect("refresh"); + let installed = read(&files[0]).expect("settings"); + assert!(installed.contains("refreshed-route")); + assert!( + installed.contains(if matches!(tool, Harness::CodexCli | Harness::CodexApp) { + "sandbox_mode" + } else { + "custom" + }) + ); + assert_eq!(read(&backup_path(&files[0])).expect("backup"), original); + restore(&files).expect("restore"); + assert_eq!(read(&files[0]).expect("original"), original); + if *tool == Harness::Pi { + assert!(!files[1].exists()); + } + std::fs::write(&files[0], original.replace("before", "new-user-model")) + .expect("user edit"); + install(*tool, &files, "http://localhost:4123", "again", None, false) + .expect("reinstall"); + restore(&files).expect("restore again"); + assert_eq!( + read(&files[0]).expect("new original"), + original.replace("before", "new-user-model") + ); + assert!( + result["authentication"] + .as_str() + .expect("authentication") + .contains("API credentials") + ); + } + let dir = tempfile::tempdir().expect("directory"); + let files = vec![dir.path().join("settings.json")]; + let original = + r#"{"env":{"ANTHROPIC_API_KEY":"SECRET","ANTHROPIC_MODEL":{"key":"SECRET"}}}"#; + std::fs::write(&files[0], original).expect("settings"); + assert!( + preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + true + ) + .is_err() + ); + assert_eq!(read(&files[0]).expect("unchanged"), original); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 1); + let safe = preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + false, + ) + .expect("API preview"); + assert!(!safe.to_string().contains("SECRET")); + assert!( + safe["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["key"] == "env.ANTHROPIC_API_KEY" + && change["before"] == "(set; hidden)") + ); + std::fs::write(&files[0], "{}").expect("settings"); + let result = preview( + Harness::Claude, + &files, + "http://localhost:4123", + "route", + None, + true, + ) + .expect("login preview"); + assert!( + result["authentication"] + .as_str() + .expect("authentication") + .contains("subscription login") + ); + } + + #[test] + fn a_pending_restore_blocks_install_and_retires_stale_backups_on_retry() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + let files = std::slice::from_ref(&path); + std::fs::write(&path, "{}").expect("settings"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "api", + None, + false, + ) + .expect("install"); + let pending = restore_pending_path(&path); + std::fs::write( + &pending, + serde_json::to_string(&Some("{}".to_string())).expect("receipt"), + ) + .expect("pending retirement"); + let installed = read(&path).expect("installed"); + assert!( + install( + Harness::Claude, + files, + "http://localhost:4123", + "another", + None, + false + ) + .is_err() + ); + assert_eq!(read(&path).expect("unchanged"), installed); + // The backup was already retired, so retry uses the saved restoration receipt. + std::fs::remove_file(backup_path(&path)).expect("retired backup"); + restore(files).expect("retry restore"); + assert_eq!(read(&path).expect("restored"), "{}"); + assert!(!pending.exists()); + } + + #[test] + fn subscription_install_removes_only_switchyard_credentials() { + let dir = tempfile::tempdir().expect("directory"); + let path = dir.path().join("settings.json"); + let files = std::slice::from_ref(&path); + std::fs::write(&path, "{}").expect("settings"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "api-route", + None, + false, + ) + .expect("API install"); + install( + Harness::Claude, + files, + "http://localhost:4123", + "subscription", + None, + true, + ) + .expect("subscription install"); + let doc = object(&read(&path).expect("settings")).expect("JSON"); + assert!(doc["env"].get("ANTHROPIC_AUTH_TOKEN").is_none()); + assert!(doc["env"].get("ANTHROPIC_API_KEY").is_none()); + assert_eq!(doc["env"]["ANTHROPIC_MODEL"], "subscription"); + for key in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY"] { + for value in [ + json!("user-secret"), + json!("switchyard-local"), + json!(null), + json!(false), + json!(123), + json!([]), + json!({}), + ] { + let original = json!({"env":{key:value}}).to_string(); + std::fs::write(&path, &original).expect("settings"); + let before = std::fs::read_dir(dir.path()).expect("files").count(); + assert!( + install( + Harness::Claude, + files, + "http://localhost:4123", + "subscription", + None, + true + ) + .is_err() + ); + assert_eq!(read(&path).expect("unchanged"), original); + assert_eq!( + std::fs::read_dir(dir.path()).expect("files").count(), + before + ); + } + } + } + + #[test] + fn replacement_prepares_every_file_and_rejects_changed_inputs() { + let dir = tempfile::tempdir().expect("directory"); + let first = dir.path().join("first"); + let blocked = dir.path().join("blocked"); + std::fs::write(&first, "original").expect("first"); + std::fs::write(&blocked, "not a directory").expect("blocked"); + let files = vec![first.clone(), blocked.join("second")]; + assert!( + replace( + &files, + &[Some("original".into()), None], + &[Some("new".into()), Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + assert!( + replace( + std::slice::from_ref(&first), + &[Some("stale".into())], + &[Some("new".into())] + ) + .is_err() + ); + assert_eq!(read(&first).expect("read"), "original"); + } +} From 864177831996dfe28e269807bd36ac74973ed01c Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 23/28] test(desktop): cover named accounts and the Windows terminal launcher Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/accounts.rs | 41 ++++++ crates/switchyard-desktop/src/controller.rs | 127 ++++++++++++++++++ .../tests/windows_terminal.rs | 70 ++++++++++ 3 files changed, 238 insertions(+) create mode 100644 crates/switchyard-desktop/tests/windows_terminal.rs diff --git a/crates/switchyard-desktop/src/accounts.rs b/crates/switchyard-desktop/src/accounts.rs index 96ee972c64..3ec510baba 100644 --- a/crates/switchyard-desktop/src/accounts.rs +++ b/crates/switchyard-desktop/src/accounts.rs @@ -189,3 +189,44 @@ pub fn archive(tool: Harness, path: &Path) -> Result { archived.display() )) } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + // The subprocess isolates HOME so parallel tests keep their own settings. + #[cfg(unix)] + #[test] + fn symlinked_account_roots_are_not_listed_or_used_for_login() { + if std::env::var_os("SWITCHYARD_ACCOUNT_FIXTURE").is_some() { + assert!(list(Harness::CodexCli).is_empty()); + assert!(discover(Harness::CodexCli).is_err()); + assert!(add(Harness::CodexCli, "new").is_err()); + let path = crate::config::expand_home(Path::new("~/.switchyard/accounts/codex/named")) + .expect("home"); + assert!(validate(Harness::CodexCli, &path).is_err()); + return; + } + let dir = tempfile::tempdir().expect("fixture"); + let home = dir.path().join("home"); + let target = dir.path().join("target"); + std::fs::create_dir(&home).expect("home"); + std::fs::create_dir_all(target.join("accounts/codex/named")).expect("accounts"); + std::os::unix::fs::symlink(&target, home.join(".switchyard")).expect("symlink"); + let output = std::process::Command::new(std::env::current_exe().expect("runner")) + .args([ + "--exact", + "accounts::tests::symlinked_account_roots_are_not_listed_or_used_for_login", + ]) + .env("SWITCHYARD_ACCOUNT_FIXTURE", "1") + .env("HOME", &home) + .output() + .expect("child"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(!target.join("accounts/codex/new").exists()); + } +} diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 8818869d76..88f3ca5525 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -1436,6 +1436,133 @@ mod tests { assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 2); } + #[test] + // A custom destination must use the same files for preview, installation, backup, and restore. + fn custom_settings_are_validated_and_restored() { + let dir = tempfile::tempdir().expect("directory"); + for (tool, _) in harness::HARNESSES { + let file = dir.path().join(if *tool == Harness::Pi { + "models.json" + } else { + "custom-settings" + }); + assert_eq!( + install_paths(*tool, None, Some(file.to_str().expect("path"))).expect("custom")[0], + file + ); + } + for (account, file) in [ + (None, "relative.json"), + (None, ""), + (Some("account"), "/tmp/settings.json"), + (None, "/tmp/settings\n.json"), + ] { + assert!(install_paths(Harness::Claude, account, Some(file)).is_err()); + } + assert!(install_paths(Harness::Pi, None, Some("/tmp/settings.json")).is_err()); + assert!(install_paths(Harness::Claude, None, dir.path().to_str()).is_err()); + assert_eq!(std::fs::read_dir(dir.path()).expect("files").count(), 0); + let settings = dir.path().join("app.toml"); + let config = dir.path().join("server.toml"); + std::fs::write( + &config, + "[llm_clients.c]\n\ + base_url='https://example.com/v1'\n\ + format='openai_chat'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n", + ) + .expect("config"); + std::fs::write( + &settings, + format!( + "config_file={config:?}\nrouting_log={:?}", + dir.path().join("log") + ), + ) + .expect("settings"); + let custom = dir.path().join("custom.json"); + std::fs::write(&custom, "{\"user\":true}").expect("custom"); + let mut controller = Controller::new(Config::load(&settings).expect("settings"), settings); + let file = Some(custom.display().to_string()); + let preview = controller + .dispatch(Action::PreviewInstall { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + }) + .expect("preview"); + assert!( + preview.data["changes"] + .as_array() + .expect("diff") + .iter() + .any(|change| change["file"] == custom.display().to_string()) + ); + std::fs::write(&custom, "{\"external\":true}").expect("external edit"); + let rejected = controller.dispatch(Action::Install { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + preview_token: preview.data["preview_token"].as_str().map(str::to_string), + }); + assert!(rejected.is_err()); + assert_eq!( + std::fs::read_to_string(&custom).expect("custom"), + "{\"external\":true}" + ); + assert!( + !custom + .with_file_name("custom.json.switchyard-original") + .exists() + ); + std::fs::write(&custom, "{\"user\":true}").expect("restore fixture"); + controller + .dispatch(Action::Install { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + route: "r".into(), + id: "public".into(), + preview_token: preview.data["preview_token"].as_str().map(str::to_string), + }) + .expect("install"); + assert_eq!( + preview.data["proposed"], + harness::inspect(Harness::Claude, std::slice::from_ref(&custom)).expect("inspect") + ); + let restore_preview = controller + .dispatch(Action::PreviewRestore { + tool: Harness::Claude, + account: None, + settings_file: file.clone(), + }) + .expect("restore preview"); + controller + .dispatch(Action::Restore { + tool: Harness::Claude, + account: None, + settings_file: file, + preview_token: restore_preview.data["preview_token"] + .as_str() + .map(str::to_string), + }) + .expect("restore"); + assert_eq!( + std::fs::read_to_string(custom).expect("custom"), + "{\"user\":true}" + ); + } + #[cfg(any(target_os = "macos", windows))] #[test] // A cached list must not let an untested key reach Keychain. diff --git a/crates/switchyard-desktop/tests/windows_terminal.rs b/crates/switchyard-desktop/tests/windows_terminal.rs new file mode 100644 index 0000000000..2242174fdf --- /dev/null +++ b/crates/switchyard-desktop/tests/windows_terminal.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#![cfg(windows)] + +#[test] +fn native_and_npm_launchers_preserve_arguments_accounts_and_working_directory() { + use std::{ + fs, + process::{Command, Stdio}, + }; + let root = tempfile::Builder::new() + .prefix("Terminal équipe space ") + .tempdir() + .expect("fixture"); + let source = root.path().join("tool.rs"); + fs::write(&source,r#"fn main() { + let output=std::env::var_os("FIXTURE_OUTPUT").unwrap(); + let text=format!("{:?}\n{:?}\n{:?}\n{:?}",std::env::args().skip(1).collect::>(),std::env::current_dir().unwrap(),std::env::var("CODEX_HOME"),std::env::var("ANTHROPIC_AUTH_TOKEN")); + std::fs::write(output,text).unwrap(); + }"#).expect("tool source"); + let tool = root.path().join("tool.exe"); + assert!( + Command::new("rustc") + .arg(&source) + .arg("-o") + .arg(&tool) + .status() + .expect("compile fixture") + .success() + ); + let shim = root.path().join("tool.cmd"); + fs::write(&shim, "@echo off\r\n@\"%~dp0tool.exe\" %*\r\n").expect("npm shim"); + let args = vec![ + "space équipe".to_string(), + "a\"b".into(), + "%PATH%".into(), + "& echo injected > forbidden-file".into(), + "C:\\space équipe\\".into(), + "|output^".into(), + ]; + let output = root.path().join("arguments.txt"); + let account = root.path().join("account équipe space"); + for binary in [tool, shim] { + let launch = root.path().join("launch.json"); + fs::write(&launch,serde_json::to_vec(&serde_json::json!({"binary":binary,"args":args,"directory":root.path(),"environment":[["CODEX_HOME",account.display().to_string()],["FIXTURE_OUTPUT",output.display().to_string()]],"remove_environment":["ANTHROPIC_AUTH_TOKEN"]})).expect("launch JSON")).expect("launch record"); + let result = Command::new(env!("CARGO_BIN_EXE_switchyard-desktop")) + .arg("--terminal-job") + .arg(&launch) + .env("ANTHROPIC_AUTH_TOKEN", "must-be-removed") + .stdin(Stdio::null()) + .output() + .expect("terminal job"); + assert!( + result.status.success(), + "terminal launch failed for {binary:?}: status={:?}; stdout={}; stderr={}", + result.status, + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + let captured = fs::read_to_string(&output).expect("captured tool launch"); + assert!(captured.starts_with(&format!("{args:?}\n")), "{captured}"); + assert!(captured.contains(&format!("Ok({:?})", account.display().to_string()))); + assert!(captured.contains("NotPresent")); + assert!(captured.contains(&format!("{:?}", root.path()))); + assert!(!launch.exists()); + assert!(!root.path().join("forbidden-file").exists()); + fs::remove_file(&output).expect("reset output"); + } +} From a84d5092fb1d1c57a267575d6df553f058d77015 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 24/28] feat(desktop): add the Sessions page and recorded session inventory Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 20 +- crates/switchyard-desktop/src/sessions.rs | 46 +++ crates/switchyard-desktop/src/tui.rs | 374 ++++++++++++++++++-- 3 files changed, 405 insertions(+), 35 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 88f3ca5525..028a1be54d 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -12,6 +12,7 @@ use crate::{ harness::{self, Harness}, history, models, pricing, rollup, server, server_config::{ALGORITHMS, Choice, Route, ServerConfig}, + sessions, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -150,6 +151,12 @@ pub enum Action { #[derive(Clone, Default, Serialize)] pub struct Snapshot { pub update_status: Value, + /// Each value is the `session.json` file that `sessions::launch` wrote for a managed + /// worktree session, plus `process_status`. The snapshot passes these files through as JSON. + pub session_inventory: Vec, + /// This field holds the error that stopped the inventory read. `session_inventory` is empty + /// when this field is set. + pub session_inventory_error: Option, /// This field is `None` only in the terminal app's placeholder snapshot, which it shows /// when the app settings cannot be loaded. pub settings: Option, @@ -411,6 +418,7 @@ pub struct Controller { generation: u64, route_reviews: std::collections::HashMap, tool_review: Option, + account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, @@ -425,6 +433,7 @@ impl Controller { generation: 0, route_reviews: Default::default(), tool_review: None, + account_archive: None, route_change: None, update_review: None, @@ -638,6 +647,10 @@ impl Controller { let update_status = install_metadata(&self.settings) .and_then(|path| switchyard_desktop::install::update_status(&path)) .unwrap_or_else(|error| json!({"state":"unavailable","message":error})); + let (session_inventory, session_inventory_error) = match sessions::inventory() { + Ok(inventory) => (inventory, None), + Err(error) => (Vec::new(), Some(error)), + }; let sessions = history.sessions(); let Config { server_url, @@ -650,6 +663,8 @@ impl Controller { } = self.config.clone(); Ok(Snapshot { update_status, + session_inventory, + session_inventory_error, data_state: DataState { usage: Source { state: usage_state, @@ -728,9 +743,10 @@ impl Controller { } Action::OpenUpdateLog {} => self.update_log().map(Some), Action::OpenLogs {} => logs_dir().map(Some), - // An account folder opens in the editor too, because a terminal may run over SSH, - // where `open` would start a file manager on another screen. + // Account and session folders open in the editor too, because a terminal may run + // over SSH, where `open` would start a file manager on another screen. Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), + _ => Ok(None), } } diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index ebdd585853..f1ecc7e8e4 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -1,6 +1,52 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +//! This module starts CLI sessions with separate worktrees and model settings. + +use std::path::Path; + pub(crate) fn quote(text: &str) -> String { format!("'{}'", text.replace('\'', "'\\''")) } + +fn session_root() -> Result { + crate::config::expand_home(Path::new("~/.switchyard/worktrees")) +} + +pub fn inventory() -> Result, String> { + let root = session_root()?; + let entries = match std::fs::read_dir(&root) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => return Err(format!("Read session inventory: {error}")), + }; + let mut sessions = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| error.to_string())?; + if entry + .file_type() + .map_err(|error| error.to_string())? + .is_dir() + && entry.file_name().to_string_lossy().ends_with("-settings") + { + let receipt = entry.path().join("session.json"); + if receipt.exists() { + let mut value: serde_json::Value = serde_json::from_slice( + &std::fs::read(&receipt).map_err(|error| error.to_string())?, + ) + .map_err(|error| format!("Read {}: {error}", receipt.display()))?; + value + .as_object_mut() + .ok_or("The session receipt does not match this worktree.")? + .insert("process_status".into(), serde_json::json!("unknown")); + sessions.push(value); + } + } + } + sessions.sort_by(|left, right| { + right["created_at"] + .as_str() + .cmp(&left["created_at"].as_str()) + }); + Ok(sessions) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index ca25ad3894..4518ba85c5 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app shows usage, recent calls, routes, and harness settings from controller -//! snapshots, and it runs server, route, harness, and account actions. +//! The terminal app shows usage, recent calls, routes, harness settings, and worktree sessions +//! from controller snapshots, and it runs server, route, harness, account, and session actions. //! //! The UI thread owns every view setting. One worker thread owns the controller and runs //! refreshes and actions, so moving around and filtering never wait for the log or a service. @@ -54,7 +54,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-6", + "1-7", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -114,8 +114,8 @@ const HARNESSES_KEYS: &[Key] = &[ ( "↑↓", "route", - "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes page \ - selects the same route, and its filter limits the choices.", + "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes and \ + Sessions pages select the same route, and the Routes filter limits the choices.", ), ("t", "harness", "Select the next harness."), ( @@ -131,6 +131,29 @@ const HARNESSES_KEYS: &[Key] = &[ detected settings again.", ), ]; +const SESSIONS_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "Choose the route for a new session. j, k, PgUp, PgDn, g, and G also move it. The \ + Routes and Harnesses pages select the same route.", + ), + ( + "t", + "harness", + "Select the next harness for a new session. The Harnesses page selects the same one.", + ), + ( + "a", + "account", + "Select the next saved account for a new session, then the harness's own login again.", + ), + ( + "s", + "session", + "Select the next recorded session. The open and remove keys use the selection.", + ), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -285,6 +308,13 @@ const ACTIONS: &[ActionRow] = &[ ActionKind::ArchiveAccount, ], ), + ( + Tab::Sessions, + 'n', + "add account", + "Creates a saved account and opens the harness's login in a new terminal window.", + &[ActionKind::AddAccount], + ), ( Tab::Settings, 'o', @@ -413,6 +443,15 @@ const HARNESS_COLUMNS: [(Column, bool); 4] = [ (("CURRENT SETTINGS", Constraint::Fill(3), false), true), (("ACCOUNTS", Constraint::Length(8), true), false), ]; +/// The sessions table drops the columns marked `true` below 100 columns; the details still +/// show them. +const SESSION_COLUMNS: [(Column, bool); 5] = [ + (("SESSION", Constraint::Length(39), false), false), + (("REPOSITORY", Constraint::Fill(2), false), true), + (("ROUTE", Constraint::Fill(1), false), false), + (("CREATED", Constraint::Length(8), true), true), + (("LAST CALL", Constraint::Length(9), true), false), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -467,14 +506,16 @@ enum Tab { Models, Routes, Harnesses, + Sessions, Settings, } -const TABS: [Tab; 6] = [ +const TABS: [Tab; 7] = [ Tab::Overview, Tab::Calls, Tab::Models, Tab::Routes, Tab::Harnesses, + Tab::Sessions, Tab::Settings, ]; impl Tab { @@ -485,6 +526,7 @@ impl Tab { Tab::Models => "Models", Tab::Routes => "Routes", Tab::Harnesses => "Harnesses", + Tab::Sessions => "Sessions", Tab::Settings => "Settings", } } @@ -894,6 +936,7 @@ impl Form { Action::Install { .. } => "Installing the harness settings", Action::Restore { .. } => "Restoring the harness settings", Action::ArchiveAccount { .. } => "Archiving the account", + _ => "Saving the route", }, FormKind::Remove { .. } => "Deleting the route", @@ -1035,6 +1078,7 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { name, preview_token, }, + _ => return None, }) } @@ -1165,7 +1209,8 @@ impl Tone { } } -/// Destination is where the Harnesses page installs and restores settings. +/// Destination is where the Harnesses page installs and restores settings. The Sessions page +/// launches with its account, and with the harness's own login otherwise. enum Destination { /// The harness's own settings files. Harness, @@ -1201,6 +1246,10 @@ struct App { /// This field holds the last install preview and the action that produced it. The /// Harnesses page shows it only while that action matches the selection. install_preview: Option<(Action, Value)>, + /// This field indexes `data.session_inventory`, newest first. It starts at the first + /// session, and it is `None` after a refresh stops listing the selected session, so no + /// action reaches another session. + managed: Option, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -1235,6 +1284,7 @@ impl App { harness: 0, destination: Destination::Harness, install_preview: None, + managed: Some(0), server_keys: None, popup: None, toast: None, @@ -1259,7 +1309,15 @@ impl App { .filter(|_| self.selected > 0) .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); let route = self.selected_route_key(); + let managed = self.session_id().ok(); self.data = data; + if let Some(id) = managed { + self.managed = self + .data + .session_inventory + .iter() + .position(|receipt| receipt["id"] == id); + } self.refreshed = Some(Instant::now()); self.rebuild_calls(); self.rebuild_routes(route); @@ -1417,6 +1475,18 @@ impl App { current.then_some(preview) } + fn selected_session(&self) -> Option<&Value> { + self.data.session_inventory.get(self.managed?) + } + + /// This function returns the ID of the session that the open and remove keys use. + fn session_id(&self) -> Result { + self.selected_session() + .and_then(|receipt| receipt["id"].as_str()) + .map(str::to_owned) + .ok_or_else(|| "Press s to select a recorded session first.".into()) + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { @@ -1679,6 +1749,7 @@ impl App { (None, false, Tab::Models) => MODELS_KEYS, (None, false, Tab::Routes) => ROUTES_KEYS, (None, false, Tab::Harnesses) => HARNESSES_KEYS, + (None, false, Tab::Sessions) => SESSIONS_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } @@ -1728,7 +1799,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='6') => { + KeyCode::Char(digit @ '1'..='7') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -1748,7 +1819,11 @@ impl App { self.route_filter.clear(); self.refilter(); } - Tab::Overview | Tab::Models | Tab::Harnesses | Tab::Settings => {} + Tab::Overview + | Tab::Models + | Tab::Harnesses + | Tab::Sessions + | Tab::Settings => {} } } code => { @@ -1773,6 +1848,7 @@ impl App { }, Tab::Routes => self.routes_key(code), Tab::Harnesses => self.harnesses_key(code), + Tab::Sessions => self.sessions_key(code), Tab::Settings => return self.settings_key(code), } } @@ -1818,8 +1894,8 @@ impl App { /// The arrows choose the route, which is the longer list; `t` cycles through the few /// harnesses. A new harness starts at its own settings, because accounts belong to one - /// harness. - fn harnesses_key(&mut self, code: KeyCode) { + /// harness. The Harnesses and Sessions pages share these keys and the selection. + fn selection_key(&mut self, code: KeyCode) { let last = self.route_visible.len().saturating_sub(1); if let Some(selected) = step(code, self.route_selected, last) { self.route_selected = selected; @@ -1840,20 +1916,34 @@ impl App { let next = names.get(current.map_or(0, |index| index + 1)).cloned(); self.destination = next.map_or(Destination::Harness, Destination::Account); } - KeyCode::Char('x') => { - self.popup = Some(Popup::Form(Form::new( - FormKind::SettingsFile, - "Custom settings file · leave empty to use the detected settings", - vec![( - "Absolute file path (Pi: models.json)", - self.custom_file().unwrap_or_default(), - )], - ))); - } _ => {} } } + fn harnesses_key(&mut self, code: KeyCode) { + if code != KeyCode::Char('x') { + return self.selection_key(code); + } + self.popup = Some(Popup::Form(Form::new( + FormKind::SettingsFile, + "Custom settings file · leave empty to use the detected settings", + vec![( + "Absolute file path (Pi: models.json)", + self.custom_file().unwrap_or_default(), + )], + ))); + } + + /// The `s` key selects the next recorded session. Without a selection, it selects the + /// newest one. + fn sessions_key(&mut self, code: KeyCode) { + if code != KeyCode::Char('s') { + return self.selection_key(code); + } + let count = self.data.session_inventory.len().max(1); + self.managed = Some(self.managed.map_or(0, |index| (index + 1) % count)); + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { @@ -2333,6 +2423,22 @@ fn age(ts: &str, now: DateTime) -> String { } } +/// This function shows a recorded time in local time with its age, or the recorded text when it +/// is not an RFC 3339 time. +fn local_time(ts: &str) -> String { + DateTime::parse_from_rfc3339(ts).map_or_else( + |_| ts.to_string(), + |time| { + format!( + "{} ({})", + time.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S"), + age(ts, Utc::now()) + ) + }, + ) +} + fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { [ Span::styled("● ", if running { theme.ok } else { theme.error }), @@ -2412,6 +2518,10 @@ fn draw(frame: &mut Frame, app: &mut App) { skipped: _, // calls_block errors: _, // draw_errors and draw_settings refresh_seconds: _, // App::refresh_due + // draw_session_table lists the inventory or shows the error that stopped reading it, + // and session_lines shows the selected session. + session_inventory: _, + session_inventory_error: _, } = &app.data; let area = frame.area(); app.width = area.width; @@ -2432,6 +2542,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Models => draw_models(frame, app, body), Tab::Routes => draw_routes(frame, app, body), Tab::Harnesses => draw_harnesses(frame, app, body), + Tab::Sessions => draw_sessions(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -2996,24 +3107,13 @@ fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { let usage = ModelTokens::from(entry); let field = |name: &str, value: String| labeled(theme, name, 9, value); - let time = DateTime::parse_from_rfc3339(&entry.ts).map_or_else( - |_| entry.ts.clone(), - |time| { - format!( - "{} ({})", - time.with_timezone(&chrono::Local) - .format("%Y-%m-%d %H:%M:%S"), - age(&entry.ts, Utc::now()) - ) - }, - ); let kind = if entry.tier == CLASSIFIER_TIER { "Routing call (classifier or judge)" } else { "Answer call" }; let mut lines = vec![ - field("Time", time), + field("Time", local_time(&entry.ts)), field("Model", entry.model.clone()), field("Kind", kind.into()), field("Route", entry.route_id.clone()), @@ -3660,6 +3760,211 @@ fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { lines } +fn draw_sessions(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let launch = Paragraph::new(new_session_lines(app)).wrap(Wrap { trim: false }); + // The borders take two rows. + let rows = launch.line_count(area.width.saturating_sub(4)) as u16 + 2; + let [launch_area, inventory] = Layout::vertical([ + Constraint::Length(rows.min(area.height / 2)), + Constraint::Fill(1), + ]) + .areas(area); + frame.render_widget( + launch.block(block(theme, "New session").padding(Padding::horizontal(1))), + launch_area, + ); + let Some(receipt) = app.selected_session() else { + draw_session_table(frame, app, inventory); + return; + }; + let detail = Paragraph::new(session_lines(app, receipt)).wrap(Wrap { trim: false }); + // The details go below the list at full width, because worktree paths are long. The list + // keeps its borders, header, and the selected row; the details get the rest. + let rows = detail.line_count(inventory.width.saturating_sub(4)) as u16 + 2; + let [list, detail_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(rows.min(inventory.height.saturating_sub(4))), + ]) + .areas(inventory); + draw_session_table(frame, app, list); + let title = text(receipt, "id"); + frame.render_widget( + detail.block(block(theme, &title).padding(Padding::horizontal(1))), + detail_area, + ); +} + +fn draw_session_table(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let sessions = &app.data.session_inventory; + let outer = block(theme, &format!("Recorded sessions [{}]", sessions.len())); + // A failed read lists no sessions, so its error replaces the empty-list text. + let empty = match &app.data.session_inventory_error { + Some(error) => Some(Line::styled(error.clone(), theme.error)), + None if sessions.is_empty() => Some(Line::styled( + "Switchyard has not recorded any managed sessions.", + theme.muted, + )), + None => None, + }; + if let Some(line) = empty { + let paragraph = Paragraph::new(line).centered().wrap(Wrap { trim: false }); + frame.render_widget(paragraph.block(outer), area); + return; + } + let wide = area.width >= 100; + let shown = |column: &(Column, bool)| wide || !column.1; + let now = Utc::now(); + let rows: Vec = sessions + .iter() + .map(|receipt| { + let id = text(receipt, "id"); + let last = last_call(&app.data, &id).map_or_else(|| "—".into(), |e| age(&e.ts, now)); + let created = age(&text(receipt, "created_at"), now); + let cells = [ + Cell::from(id), + Cell::from(Span::styled(text(receipt, "repository"), theme.muted)), + Cell::from(text(receipt, "route")), + right(Span::styled(created, theme.muted)), + right(last), + ]; + Row::new( + cells + .into_iter() + .zip(&SESSION_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = SESSION_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let selected = app.managed.filter(|&index| index < sessions.len()); + let mut state = TableState::default().with_selected(selected); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); +} + +/// This function shows what the l key launches: the harness, the account, the route, and +/// whether the harness can use the route. +fn new_session_lines(app: &App) -> Vec> { + const WIDTH: usize = 9; + let theme = app.theme; + let field = |name: &str, value: Span<'static>| { + Line::from(vec![ + Span::styled(format!("{name: { + let (state, style) = if harness.available { + ("Detected", theme.ok) + } else { + ("Not detected", theme.warn) + }; + lines.push(Line::from(vec![ + Span::styled(format!("{: { + match harness.account_details.iter().find(|a| a.name == *name) { + Some(account) => Span::raw(format!( + "{name} · login status {} · {}", + account.login_status, + account.path.display() + )), + None => Span::styled( + format!("{name} is no longer listed. Press a to choose another."), + theme.warn, + ), + } + } + Destination::Harness | Destination::File(_) => Span::raw("Current login"), + }; + lines.push(field("Account", account)); + match app.selected_route() { + Ok(route) => { + lines.push(field("Route", Span::raw(route.label.clone()))); + match route.compatibility.get(&harness.tool) { + Some(Some(reason)) => lines.push(Line::styled(reason.clone(), theme.warn)), + Some(None) => { + lines.push(Line::styled("This harness can use the route.", theme.ok)); + } + None => {} + } + } + Err(error) => lines.push(field("Route", Span::styled(error, theme.warn))), + } + } + Err(error) => lines.push(Line::styled(error, theme.warn)), + } + lines.extend([ + Line::default(), + Line::styled( + "Each session is a new Git worktree from the project's committed HEAD, on its own \ + branch. Uncommitted changes stay in the original checkout.", + theme.muted, + ), + ]); + lines +} + +/// This function shows where the selected session lives and its newest recorded call. +fn session_lines(app: &App, receipt: &Value) -> Vec> { + let theme = app.theme; + let field = |name: &str, value: String| labeled(theme, name, 11, value); + let last = last_call(&app.data, receipt["id"].as_str().unwrap_or_default()); + vec![ + field("Repository", text(receipt, "repository")), + field("Branch", text(receipt, "branch")), + field("Route", text(receipt, "route")), + field("Worktree", text(receipt, "worktree")), + field("Created", local_time(&text(receipt, "created_at"))), + field( + "Last call", + last.map_or_else( + || "No recent recorded call".into(), + |entry| local_time(&entry.ts), + ), + ), + Line::default(), + Line::styled( + "Recorded calls do not establish that a process is running.", + theme.muted, + ), + ] +} + +/// This function returns the session's newest call among the calls that the app loaded. +fn last_call<'a>(data: &'a Snapshot, id: &str) -> Option<&'a Entry> { + let session = Some(id); + data.entries + .iter() + .rev() + .find(|entry| entry.session_id.as_deref() == session) +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { @@ -4355,6 +4660,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("Models", MODELS_KEYS, Some(Tab::Models)), ("Routes", ROUTES_KEYS, Some(Tab::Routes)), ("Harnesses", HARNESSES_KEYS, Some(Tab::Harnesses)), + ("Sessions", SESSIONS_KEYS, Some(Tab::Sessions)), ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { @@ -4687,6 +4993,8 @@ mod tests { Tab::Models => &['p', '[', ']'], Tab::Routes => &['/'], Tab::Harnesses => &['t', 'a', 'x'], + // The arrows choose the route here, so k must keep moving it. + Tab::Sessions => &['t', 'a', 's', 'k'], Tab::Overview | Tab::Settings => &[], } }; From 4ec7571bd0b2dc200b11cbbc101a6b5bf0a92969 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:58 -0700 Subject: [PATCH 25/28] feat(desktop): launch isolated worktree sessions from the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 125 ++++++++ crates/switchyard-desktop/src/harness.rs | 1 - crates/switchyard-desktop/src/sessions.rs | 317 ++++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 132 ++++++++ 4 files changed, 574 insertions(+), 1 deletion(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 028a1be54d..80df9393f3 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -101,6 +101,21 @@ pub enum Action { tool: Harness, name: String, }, + Launch { + preview_token: Option, + tool: Harness, + account: Option, + route: String, + id: String, + project: String, + }, + PreviewLaunch { + tool: Harness, + account: Option, + route: String, + id: String, + project: String, + }, LoginAccount { tool: Harness, name: String, @@ -409,6 +424,17 @@ impl Review { } } +// Each Controller retains one launch review until the next preview or a selected config change. +// Review keeps raw settings private; Launch uses these values and the reviewed Git commit. +struct ReviewedLaunch { + review: Review, + project: Value, + model: String, + server_url: String, + login: bool, + account: Option, +} + pub struct Controller { health_probe: crate::health::Probe, settings: PathBuf, @@ -422,6 +448,7 @@ pub struct Controller { account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, + launch_review: Option, } impl Controller { pub fn new(config: Config, settings: PathBuf) -> Self { @@ -437,6 +464,7 @@ impl Controller { account_archive: None, route_change: None, update_review: None, + launch_review: None, } } fn reload(&mut self) -> Result<(), String> { @@ -446,6 +474,7 @@ impl Controller { self.route_reviews.clear(); self.tool_review = None; self.route_change = None; + self.launch_review = None; } self.config = config; @@ -1073,6 +1102,102 @@ impl Controller { Action::AddAccount { tool, name } => { accounts::add(tool, name.trim()).map(Reply::message) } + Action::Launch { + tool, + account, + route, + id, + project, + preview_token, + } => { + let inputs = + json!({"tool":tool,"account":account,"route":route,"id":id,"project":project}); + let plan = self + .launch_review + .as_ref() + .ok_or("Review the session before opening Terminal.")?; + plan.review.check(preview_token.as_deref(), &inputs)?; + let current = sessions::preview(std::path::Path::new(&project))?; + if current != plan.project { + return Err( + "The project changed since review. Review the session again.".into(), + ); + } + sessions::launch( + tool, + std::path::Path::new( + plan.project["project"] + .as_str() + .ok_or("Missing reviewed project.")?, + ), + &plan.model, + &plan.server_url, + plan.login, + plan.account.as_deref(), + plan.project["revision"] + .as_str() + .ok_or("Missing reviewed commit.")?, + ) + .map(Reply::message) + } + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => { + if tool == Harness::CodexApp { + return Err("Use Codex app’s workspace controls or choose Codex CLI.".into()); + } + if harness::binary(tool).is_none() { + return Err("Install the selected harness first.".into()); + } + let inputs = + json!({"tool":tool,"account":account,"route":route,"id":id,"project":project}); + let review = Review::capture( + inputs.clone(), + vec![self.config.config_file.clone(), self.settings.clone()], + )?; + let app_config = Config::load(&self.settings)?; + review.check(Some(&review.token), &inputs)?; + if app_config.config_file != self.config.config_file { + return Err("Review these changes again before applying them.".into()); + } + let config = ServerConfig::parse(review.original()?).map_err( + |_| "The server config contains invalid TOML. Open it in Settings to fix it.", + )?; + let route = current_route(&config, &route, Some(&id))?; + let login = login_mode(tool, &config, &route)?; + let account = account_path(tool, account.as_deref())?; + let project_review = sessions::preview(std::path::Path::new(&project))?; + let mut data = project_review.clone(); + data["tool"] = json!(tool); + data["route"] = json!(route.id); + data["account_directory"] = json!(account); + data["server_url"] = json!(app_config.server_url); + data["login"] = json!(login); + if tool == Harness::Pi { + data["settings_notice"] = json!( + "Pi uses isolated session settings. \ + Personal settings and extensions are not copied." + ); + } + review.check(Some(&review.token), &inputs)?; + data["preview_token"] = json!(review.token); + self.launch_review = Some(ReviewedLaunch { + review, + project: project_review, + model: route.id, + server_url: app_config.server_url, + login, + account, + }); + Ok(Reply { + message: String::new(), + data, + }) + } Action::LoginAccount { tool, name } => { let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; accounts::login(tool, &path, &name).map(Reply::message) diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs index 0f1b2695e4..885c3e25ee 100644 --- a/crates/switchyard-desktop/src/harness.rs +++ b/crates/switchyard-desktop/src/harness.rs @@ -528,7 +528,6 @@ fn configured( Ok(incoming) } -#[cfg(test)] pub fn install( tool: Harness, files: &[PathBuf], diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index f1ecc7e8e4..3f7538d94d 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -3,12 +3,95 @@ //! This module starts CLI sessions with separate worktrees and model settings. +use crate::harness::{self, Harness}; use std::path::Path; +// PendingSession attempts cleanup until open accepts the launch request. +// A successful launch keeps the worktree and settings for the coding session. +struct PendingSession { + project: std::path::PathBuf, + worktree: std::path::PathBuf, + private: std::path::PathBuf, + branch: String, + revision: String, + opened: bool, +} + +impl Drop for PendingSession { + fn drop(&mut self) { + if self.opened { + return; + } + let _ = std::process::Command::new("git") + .arg("-C") + .arg(&self.project) + .args(["worktree", "remove"]) + .arg(&self.worktree) + .output(); + delete_session_branch(&self.project, &self.branch, &self.revision); + let _ = std::fs::remove_dir_all(&self.private); + } +} + +// This deletes the session branch only while it still points at the session's starting commit. +// The commit check makes `branch -D` safe. `branch -d` also requires a merge into the original +// checkout's HEAD, so it fails after the user switches that checkout to an unrelated branch. +fn delete_session_branch(repository: &Path, branch: &str, revision: &str) -> bool { + let tip = std::process::Command::new("git") + .arg("-C") + .arg(repository) + .args(["rev-parse", "--verify", "--quiet"]) + .arg(format!("refs/heads/{branch}")) + .output(); + tip.is_ok_and(|tip| { + tip.status.success() && String::from_utf8_lossy(&tip.stdout).trim() == revision + }) && std::process::Command::new("git") + .arg("-C") + .arg(repository) + .args(["branch", "-D", branch]) + .output() + .is_ok_and(|output| output.status.success()) +} + pub(crate) fn quote(text: &str) -> String { format!("'{}'", text.replace('\'', "'\\''")) } +pub fn preview(project: &Path) -> Result { + if project.as_os_str().is_empty() { + return Err("Choose a Git checkout directory.".into()); + } + let project = std::fs::canonicalize(crate::config::expand_home(project)?) + .map_err(|error| format!("Open project: {error}"))?; + let git = |args: &[&str]| -> Result { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err("Choose a Git checkout with a committed HEAD.".into()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + let repository = git(&["rev-parse", "--show-toplevel"])?; + let repository_path = std::fs::canonicalize(&repository).map_err(|error| error.to_string())?; + let relative = project + .strip_prefix(&repository_path) + .map_err(|error| error.to_string())?; + Ok(serde_json::json!({ + "repository": repository, + "project": project, + "relative_directory": relative, + "revision": git(&["rev-parse", "HEAD"])?, + "branch": git(&["branch", "--show-current"])?, + "dirty": !git(&["status", "--porcelain"])?.is_empty(), + "notice": "The session starts from committed HEAD. \ + Uncommitted changes stay in the original checkout." + })) +} + fn session_root() -> Result { crate::config::expand_home(Path::new("~/.switchyard/worktrees")) } @@ -50,3 +133,237 @@ pub fn inventory() -> Result, String> { }); Ok(sessions) } + +pub fn launch( + tool: Harness, + project: &Path, + model: &str, + root: &str, + login: bool, + account: Option<&Path>, + revision: &str, +) -> Result { + let root = harness::validate(tool, root, model, login)?; + if let Some(account) = account { + crate::accounts::validate(tool, account)?; + } + if tool == Harness::CodexApp { + return Err("Choose Codex CLI to launch a worktree session. \ + Codex app uses its own workspace controls." + .into()); + } + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + if project.as_os_str().is_empty() { + return Err("Choose a Git checkout directory.".into()); + } + let project = crate::config::expand_home(project)?; + let project = std::fs::canonicalize(&project).map_err(|e| format!("Open project: {e}"))?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["rev-parse", "--show-toplevel"]) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err("Choose a Git checkout directory.".into()); + } + let repository = Path::new( + std::str::from_utf8(&output.stdout) + .map_err(|error| error.to_string())? + .trim(), + ) + .to_path_buf(); + let repository = std::fs::canonicalize(repository).map_err(|error| error.to_string())?; + let relative = project + .strip_prefix(&repository) + .map_err(|error| error.to_string())? + .to_path_buf(); + let id = format!("sy-{}", uuid::Uuid::now_v7()); + // Path resolution must succeed before launch creates directories or a worktree. + let default_files = harness::paths(tool)?; + let worktree = session_root()?.join(&id); + std::fs::create_dir_all( + worktree + .parent() + .ok_or("Worktree needs a parent directory")?, + ) + .map_err(|e| e.to_string())?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["worktree", "add", "-b"]) + .arg(&id) + .arg(&worktree) + .arg(revision) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "Could not create worktree: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let private = worktree + .parent() + .ok_or("Worktree needs a parent directory")? + .join(format!("{id}-settings")); + let mut pending = PendingSession { + project, + worktree: worktree.clone(), + private: private.clone(), + branch: id.clone(), + revision: revision.into(), + opened: false, + }; + let files = match tool { + Harness::Pi => vec![private.join("models.json"), private.join("settings.json")], + _ => vec![private.join("settings.json")], + }; + std::fs::create_dir_all(&private).map_err(|error| error.to_string())?; + if matches!(tool, Harness::Claude | Harness::Pi) { + harness::install(tool, &files, &root, model, None, login)?; + } + let receipt = serde_json::json!({"id":id,"tool":tool,"route":model,"repository":repository,"revision":preview(&worktree)?["revision"],"worktree":worktree,"branch":id,"created_at":chrono::Utc::now().to_rfc3339(),"settings_directory":private,"account_directory":account}); + std::fs::write( + private.join("session.json"), + serde_json::to_vec_pretty(&receipt).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let account = account.or_else(|| match tool { + Harness::Claude if std::env::var_os("CLAUDE_CONFIG_DIR").is_none() => None, + _ => default_files[0].parent(), + }); + let codex_args = [ + "-c".to_string(), + format!("model={}", toml_edit::Value::from(model)), + "-c".into(), + "model_provider=\"sy\"".into(), + "-c".into(), + format!( + "model_providers.sy={{name=\"Switchyard\",base_url={},wire_api=\"responses\",\ + requires_openai_auth={login},http_headers={{\"x-switchyard-session-id\"={},\ + \"x-switchyard-origin\"=\"codex\"}}}}", + toml_edit::Value::from(format!("{}/v1", root.trim_end_matches('/'))), + toml_edit::Value::from(id.as_str()) + ), + ]; + #[cfg(windows)] + { + let mut environment = Vec::new(); + let mut remove_environment = Vec::new(); + let args = match tool { + Harness::CodexCli => codex_args.into(), + Harness::Claude => { + environment.push(( + "ANTHROPIC_CUSTOM_HEADERS".into(), + format!("x-switchyard-session-id: {id}\nx-switchyard-origin: claude"), + )); + if login { + remove_environment.extend( + [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + ] + .map(String::from), + ); + } + vec![ + "--settings".into(), + files[0].display().to_string(), + "--model".into(), + model.into(), + ] + } + Harness::Pi => { + environment.push(("PI_CODING_AGENT_DIR".into(), private.display().to_string())); + vec![ + "--provider".into(), + "switchyard".into(), + "--model".into(), + model.into(), + ] + } + Harness::CodexApp => return Err("Choose a CLI harness.".into()), + }; + if let Some(path) = account { + match tool { + Harness::CodexCli => { + environment.push(("CODEX_HOME".into(), path.display().to_string())) + } + Harness::Claude => { + environment.push(("CLAUDE_CONFIG_DIR".into(), path.display().to_string())) + } + _ => {} + } + } + crate::windows::open_terminal(crate::windows::TerminalJob { + binary, + args, + directory: Some(worktree.join(&relative)), + environment, + remove_environment, + })?; + } + #[cfg(not(windows))] + { + let program = quote(&binary.display().to_string()); + let command = match tool { + Harness::CodexCli => format!( + "{program} {}", + codex_args + .iter() + .map(|arg| quote(arg)) + .collect::>() + .join(" ") + ), + Harness::Claude => format!( + "env ANTHROPIC_CUSTOM_HEADERS={} {program} --settings {} --model {}", + quote(&format!( + "x-switchyard-session-id: {id}\nx-switchyard-origin: claude" + )), + quote(&files[0].display().to_string()), + quote(model) + ), + Harness::Pi => format!( + "env PI_CODING_AGENT_DIR={} {program} --provider switchyard --model {}", + quote(&private.display().to_string()), + quote(model) + ), + Harness::CodexApp => return Err("Choose a CLI harness.".into()), + }; + let account_env = match (tool, account) { + (Harness::CodexCli, Some(path)) => { + format!("export CODEX_HOME={}\n", quote(&path.display().to_string())) + } + (Harness::Claude, Some(path)) => format!( + "export CLAUDE_CONFIG_DIR={}\n", + quote(&path.display().to_string()) + ), + _ => String::new(), + }; + let subscription_env = if tool == Harness::Claude && login { + "unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN\n" + } else { + "" + }; + let script = format!( + "#!/bin/bash\n\ + export PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\ + rm -- \"$0\"\n\ + cd {} || exit 1\n\ + {account_env}{subscription_env}{command}", + quote(&worktree.join(relative).display().to_string()) + ); + crate::server::open_terminal_script("switchyard-session-", &script)?; + } + pending.opened = true; + Ok(format!( + "Opened Terminal to start the session.\n\ + Worktree: {}\n\ + Branch: {id}\n\ + Model route: {model}\n\ + Remove the worktree with git worktree remove after saving or discarding its changes.", + worktree.display() + )) +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 4518ba85c5..e165fecfc4 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -308,6 +308,15 @@ const ACTIONS: &[ActionRow] = &[ ActionKind::ArchiveAccount, ], ), + ( + Tab::Sessions, + 'l', + "launch", + "Asks for a Git project folder and shows the session review. After you type LAUNCH, \ + it creates a worktree from committed HEAD and opens the harness there in a new \ + terminal window.", + &[ActionKind::PreviewLaunch, ActionKind::Launch], + ), ( Tab::Sessions, 'n', @@ -627,6 +636,13 @@ enum FormKind { /// The harness is the one selected when the form opened. Account(Harness), SettingsFile, + /// The harness, account, and route are the ones selected when the form opened. + Project { + tool: Harness, + account: Option, + route: String, + id: String, + }, } /// Choices names the list that ↑ and ↓ cycle through in a form field. enum Choices { @@ -917,6 +933,18 @@ impl Form { name: self.value(0), }, FormKind::SettingsFile => return Ok(Submit::SettingsFile(self.value(0))), + FormKind::Project { + tool, + account, + route, + id, + } => Action::PreviewLaunch { + tool: *tool, + account: account.clone(), + route: route.clone(), + id: id.clone(), + project: self.value(0), + }, })) } fn label(&self) -> &'static str { @@ -936,6 +964,7 @@ impl Form { Action::Install { .. } => "Installing the harness settings", Action::Restore { .. } => "Restoring the harness settings", Action::ArchiveAccount { .. } => "Archiving the account", + Action::Launch { .. } => "Launching the session", _ => "Saving the route", }, @@ -944,6 +973,7 @@ impl Form { FormKind::ModelKey => "Saving the key and loading models", FormKind::Account(_) => "Adding the account", FormKind::SettingsFile => "Choosing the settings file", + FormKind::Project { .. } => "Reviewing the session", } } } @@ -1078,6 +1108,20 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { name, preview_token, }, + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => Action::Launch { + preview_token, + tool, + account, + route, + id, + project, + }, _ => return None, }) @@ -1639,6 +1683,21 @@ impl App { )); } } + // Like a route review, the launch review replaces the project form that asked for + // it, so a form that the user closed meanwhile stays closed. + (Some(ActionKind::PreviewLaunch), Ok(reply)) => { + if matches!(self.popup, Some(Popup::Form(_))) + && let Some(launch) = action.and_then(|action| reviewed(action, &reply.data)) + { + self.popup = Some(Popup::review( + launch_lines(self.theme, &reply.data), + "Launch", + FormKind::Confirm("LAUNCH".into(), Box::new(launch)), + "Start this session?", + "Type LAUNCH to start it", + )); + } + } (Some(ActionKind::Models), Ok(reply)) => { let models: Vec<&str> = reply.data["models"] .as_array() @@ -2156,6 +2215,26 @@ impl App { let archive = Action::PreviewAccountArchive { tool, name }; request(archive, "Reviewing the account archive") } + // The project form submits PreviewLaunch, and the review window submits Launch. + ActionKind::PreviewLaunch | ActionKind::Launch => { + let harness = self.harness()?; + let route = self.selected_route()?; + let title = format!( + "Launch a worktree session · {} · {}", + harness.label, route.label + ); + let kind = FormKind::Project { + tool: harness.tool, + account: self.account(), + route: route.key.clone(), + id: route.id.clone(), + }; + self.open(Form::new( + kind, + title, + vec![("Git project folder", String::new())], + )) + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -4449,6 +4528,14 @@ fn form_notes(app: &App, form: &Form) -> Vec> { ), Line::default(), ], + FormKind::Project { .. } => vec![ + Line::from( + "Switchyard creates a Git worktree from the folder's committed HEAD and opens \ + the harness there in a new terminal window. Uncommitted changes stay in the \ + original checkout. The next step shows the review.", + ), + Line::default(), + ], FormKind::Preferences | FormKind::Update(_) | FormKind::Confirm(..) @@ -4608,6 +4695,51 @@ fn archive_lines(theme: Theme, preview: &Value) -> Vec> { ] } +/// This function returns a text field of a controller reply, or `fallback` when it is empty +/// or missing. +fn text_or(value: &Value, key: &str, fallback: &str) -> String { + match value[key].as_str() { + Some(text) if !text.is_empty() => text.into(), + _ => fallback.into(), + } +} + +/// This function shows a launch preview: where the session starts, what it uses, and what +/// stays in the original checkout. +fn launch_lines(theme: Theme, preview: &Value) -> Vec> { + let field = |name: &str, value: String| labeled(theme, name, 17, value); + let folder = text(preview, "relative_directory"); + let folder = if folder.is_empty() { + "New worktree root".into() + } else { + format!("{folder} within the new worktree") + }; + let mut lines = vec![ + field("Repository", text(preview, "repository")), + field("Starting folder", folder), + field("Branch", text_or(preview, "branch", "Detached HEAD")), + field("Revision", text(preview, "revision")), + field("Route", text(preview, "route")), + field( + "Account", + text_or(preview, "account_directory", "Current login"), + ), + Line::default(), + ]; + if preview["dirty"] == true { + lines.push(Line::styled( + "Your checkout has uncommitted changes.", + theme.warn, + )); + } + lines.push(Line::from(text(preview, "notice"))); + let settings = text(preview, "settings_notice"); + if !settings.is_empty() { + lines.extend([Line::default(), Line::from(settings)]); + } + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { From 366bc7612411c84341c2cef018277efae99a4e68 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:59 -0700 Subject: [PATCH 26/28] feat(desktop): open and clean up worktree sessions in the terminal app Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 53 +++++++++- crates/switchyard-desktop/src/sessions.rs | 108 ++++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 91 ++++++++++++++++- 3 files changed, 247 insertions(+), 5 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 80df9393f3..a199a03e5f 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -133,6 +133,16 @@ pub enum Action { tool: Harness, name: String, }, + OpenSession { + id: String, + }, + PreviewSessionCleanup { + id: String, + }, + RemoveSession { + id: String, + preview_token: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -444,7 +454,7 @@ pub struct Controller { generation: u64, route_reviews: std::collections::HashMap, tool_review: Option, - + session_cleanup: Option<(String, String, Value)>, account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, @@ -460,7 +470,7 @@ impl Controller { generation: 0, route_reviews: Default::default(), tool_review: None, - + session_cleanup: None, account_archive: None, route_change: None, update_review: None, @@ -775,7 +785,13 @@ impl Controller { // Account and session folders open in the editor too, because a terminal may run // over SSH, where `open` would start a file manager on another screen. Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), - + Action::OpenSession { id } => { + let receipt = sessions::managed_session(id)?; + let worktree = receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?; + Ok(Some(PathBuf::from(worktree))) + } _ => Ok(None), } } @@ -1241,6 +1257,37 @@ impl Controller { server::command("open", &[&path.display().to_string()])?; Ok(Reply::message("Opened the account folder.".into())) } + Action::OpenSession { id } => { + let receipt = sessions::managed_session(&id)?; + server::command( + "open", + &[receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?], + )?; + Ok(Reply::message("Opened the session folder.".into())) + } + Action::PreviewSessionCleanup { id } => { + let data = sessions::cleanup_preview(&id)?; + let token = uuid::Uuid::now_v7().to_string(); + self.session_cleanup = Some((id, token.clone(), data.clone())); + let mut data = data; + data["preview_token"] = json!(token); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::RemoveSession { id, preview_token } => { + let (expected_id, token, data) = self + .session_cleanup + .as_ref() + .ok_or("Review session cleanup first.")?; + if &id != expected_id || preview_token.as_deref() != Some(token.as_str()) { + return Err("Review session cleanup again.".into()); + } + sessions::cleanup(&id, data).map(Reply::message) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index 3f7538d94d..9f9455fa65 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -134,6 +134,114 @@ pub fn inventory() -> Result, String> { Ok(sessions) } +pub fn managed_session(id: &str) -> Result { + let suffix = id.strip_prefix("sy-").ok_or("Choose a managed session.")?; + uuid::Uuid::parse_str(suffix).map_err(|_| "Choose a managed session.")?; + let root = session_root()?; + let private = root.join(format!("{id}-settings")); + let worktree = root.join(id); + for path in [&root, &private, &worktree] { + if std::fs::symlink_metadata(path) + .map_err(|error| error.to_string())? + .file_type() + .is_symlink() + { + return Err("Session directories must not be symlinks.".into()); + } + } + let receipt: serde_json::Value = serde_json::from_slice( + &std::fs::read(private.join("session.json")).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + if receipt["id"] != id || receipt["worktree"] != worktree.display().to_string() { + return Err("The session receipt does not match this worktree.".into()); + } + Ok(receipt) +} + +pub fn cleanup_preview(id: &str) -> Result { + let mut receipt = managed_session(id)?; + let worktree = receipt["worktree"] + .as_str() + .ok_or("Missing session worktree.")? + .to_string(); + let run = |args: &[&str]| -> Result { + let output = std::process::Command::new("git") + .args(["-C", &worktree]) + .args(args) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + }; + // Plain `git worktree remove` deletes ignored files, so the review lists them. + let status = run(&["status", "--porcelain", "--ignored=matching"])?; + receipt["dirty"] = serde_json::json!(status.lines().any(|line| !line.starts_with("!! "))); + receipt["ignored"] = serde_json::json!( + status + .lines() + .filter_map(|line| line.strip_prefix("!! ")) + .collect::>() + ); + receipt["current_revision"] = serde_json::json!(run(&["rev-parse", "HEAD"])?); + receipt["current_branch"] = serde_json::json!(run(&["branch", "--show-current"])?); + receipt["cleanup_notice"] = serde_json::json!( + "Cleanup removes a clean worktree and its unchanged session branch. \ + It also deletes the Git-ignored files in the worktree, which the ignored list names. \ + Recorded usage stays in the routing log. Unknown private files are preserved." + ); + Ok(receipt) +} + +pub fn cleanup(id: &str, reviewed: &serde_json::Value) -> Result { + let current = cleanup_preview(id)?; + if ¤t != reviewed { + return Err("The session changed since review. Review cleanup again.".into()); + } + if current["dirty"] != false + || current["current_revision"] != current["revision"] + || current["current_branch"] != id + { + return Err("Save or move the session’s work before cleanup. \ + Cleanup requires a clean worktree with its original commit and branch." + .into()); + } + let repository = current["repository"] + .as_str() + .ok_or("Missing session repository.")?; + let worktree = current["worktree"] + .as_str() + .ok_or("Missing session worktree.")?; + let revision = current["revision"] + .as_str() + .ok_or("Missing session commit.")?; + // Receipt path resolution must succeed before cleanup removes the worktree. + let private = session_root()?.join(format!("{id}-settings")); + let output = std::process::Command::new("git") + .args(["-C", repository, "worktree", "remove", worktree]) + .output() + .map_err(|error| error.to_string())?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + + // The worktree is gone, so the receipt goes too; otherwise the inventory keeps the session. + // Cleanup removes only the receipt because settings can contain later user edits. + std::fs::remove_file(private.join("session.json")).map_err(|error| error.to_string())?; + let _ = std::fs::remove_dir(&private); + if !delete_session_branch(Path::new(repository), id, revision) { + return Ok(format!( + "Removed the clean session worktree. Git kept the branch {id}; \ + inspect it before deleting it. Retained private settings and recorded usage." + )); + } + Ok("Removed the clean session worktree and branch. \ + Retained private settings and recorded usage." + .into()) +} + pub fn launch( tool: Harness, project: &Path, diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index e165fecfc4..e414638607 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -324,6 +324,21 @@ const ACTIONS: &[ActionRow] = &[ "Creates a saved account and opens the harness's login in a new terminal window.", &[ActionKind::AddAccount], ), + ( + Tab::Sessions, + 'o', + "open session", + "Opens the selected session folder in $VISUAL or $EDITOR.", + &[ActionKind::OpenSession], + ), + ( + Tab::Sessions, + 'd', + "remove", + "Shows what cleanup deletes, including Git-ignored files, then removes the clean \ + worktree and its session branch after you type REMOVE.", + &[ActionKind::PreviewSessionCleanup, ActionKind::RemoveSession], + ), ( Tab::Settings, 'o', @@ -965,7 +980,7 @@ impl Form { Action::Restore { .. } => "Restoring the harness settings", Action::ArchiveAccount { .. } => "Archiving the account", Action::Launch { .. } => "Launching the session", - + Action::RemoveSession { .. } => "Removing the session", _ => "Saving the route", }, FormKind::Remove { .. } => "Deleting the route", @@ -1122,7 +1137,7 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { id, project, }, - + Action::PreviewSessionCleanup { id } => Action::RemoveSession { id, preview_token }, _ => return None, }) } @@ -1698,6 +1713,17 @@ impl App { )); } } + (Some(ActionKind::PreviewSessionCleanup), Ok(reply)) => { + if let Some(remove) = action.and_then(|action| reviewed(action, &reply.data)) { + self.popup = Some(Popup::review( + cleanup_lines(self.theme, &reply.data), + "Remove", + FormKind::Confirm("REMOVE".into(), Box::new(remove)), + "Remove the session worktree?", + "Type REMOVE to delete it", + )); + } + } (Some(ActionKind::Models), Ok(reply)) => { let models: Vec<&str> = reply.data["models"] .as_array() @@ -2235,6 +2261,19 @@ impl App { vec![("Git project folder", String::new())], )) } + // Controller::editor_target returns the session worktree, so it opens in the editor. + ActionKind::OpenSession => { + let id = self.session_id()?; + request(Action::OpenSession { id }, "Opening the session folder") + } + // The review window lists the Git-ignored files that cleanup deletes and submits + // RemoveSession. The controller rejects the removal when the worktree changed after + // the review. + ActionKind::PreviewSessionCleanup | ActionKind::RemoveSession => { + let id = self.session_id()?; + let cleanup = Action::PreviewSessionCleanup { id }; + request(cleanup, "Reviewing the session cleanup") + } ActionKind::OpenConfig => request(Action::OpenConfig {}, "Opening the server config"), ActionKind::CheckConfig => { request(Action::CheckConfig {}, "Checking the server config") @@ -4740,6 +4779,54 @@ fn launch_lines(theme: Theme, preview: &Value) -> Vec> { lines } +/// This function shows a cleanup preview: the worktree, whether it has uncommitted changes, +/// and the Git-ignored files that cleanup deletes. Like the desktop app, it names at most 20 +/// files and counts the rest. +fn cleanup_lines(theme: Theme, preview: &Value) -> Vec> { + const NAMED: usize = 20; + let field = |name: &str, value: String| labeled(theme, name, 11, value); + let mut lines = vec![ + Line::from(text(preview, "cleanup_notice")), + Line::default(), + field("Directory", text(preview, "worktree")), + field( + "Branch", + text_or(preview, "current_branch", "Detached HEAD"), + ), + field("Revision", text(preview, "current_revision")), + Line::default(), + if preview["dirty"] == true { + Line::styled( + "This worktree has uncommitted changes. Cleanup will refuse to remove it.", + theme.warn, + ) + } else { + Line::styled("The worktree has no uncommitted changes.", theme.ok) + }, + ]; + let ignored: Vec<&str> = preview["ignored"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect(); + if !ignored.is_empty() { + lines.push(Line::default()); + lines.push(Line::from("Cleanup also deletes these Git-ignored files:")); + lines.extend( + ignored + .iter() + .take(NAMED) + .map(|file| Line::from(file.to_string())), + ); + if ignored.len() > NAMED { + let more = format!("…and {} more", ignored.len() - NAMED); + lines.push(Line::styled(more, theme.muted)); + } + } + lines +} + fn update_lines(theme: Theme, preview: &Value) -> Vec> { // `git status --short` starts some lines with a space, so only trailing space is removed. let field = |name: &str| { From f44fbb9c85d0350ce9aa4a7dce6b0ade561e8ba4 Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Fri, 23 Oct 2026 15:00:59 -0700 Subject: [PATCH 27/28] test(desktop): verify session launch reviews, cleanup, and account isolation Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 163 +++++++++ crates/switchyard-desktop/src/sessions.rs | 354 ++++++++++++++++++++ 2 files changed, 517 insertions(+) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index a199a03e5f..2233ffcec7 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -1553,6 +1553,169 @@ fn update(settings: &std::path::Path) -> Result { #[cfg(test)] mod tests { use super::*; + // The subprocess isolates harness discovery and every launch destination. + #[cfg(unix)] + #[test] + fn session_launch_requires_an_unchanged_review_before_creating_files() { + use std::os::unix::fs::PermissionsExt; + use std::{fs, process::Command}; + if let Some(home) = std::env::var_os("SWITCHYARD_LAUNCH_REVIEW_FIXTURE") { + let home = PathBuf::from(home); + let project = home.join("project"); + let git = |args: &[&str]| { + let result = Command::new("/usr/bin/git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + }; + fs::create_dir(&project).expect("project"); + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + let settings = home.join("desktop.toml"); + let server = home.join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + fs::write(&server, text).expect("server"); + fs::write( + &settings, + format!( + "config_file={server:?}\nrouting_log={:?}\n", + home.join("routing.jsonl") + ), + ) + .expect("settings"); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let preview = |controller: &mut Controller| { + controller + .dispatch(Action::PreviewLaunch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: project.display().to_string(), + }) + .expect("preview") + }; + let launch = |token: Option, path: String| Action::Launch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: path, + preview_token: token, + }; + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + for rejected in [ + launch(None, project.display().to_string()), + launch(Some("wrong".into()), project.display().to_string()), + launch(Some(token.clone()), home.display().to_string()), + ] { + assert!(controller.dispatch(rejected).is_err()); + } + fs::write(&server, format!("{text}# external edit\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + assert_eq!( + fs::read_to_string(&server).expect("preserved"), + format!("{text}# external edit\n") + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + let original = fs::read_to_string(&settings).expect("settings"); + fs::write(&settings, format!("{original}# external settings\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "changed", + ]); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .err() + .expect("changed HEAD") + .contains("project changed") + ); + assert!(!home.join(".switchyard/worktrees").exists()); + assert!(!home.join("terminal-called").exists()); + assert_eq!(fs::read_dir(&home).expect("files").count(), 4); + return; + } + let home = tempfile::tempdir().expect("fixture"); + let bin = home.path().join("bin"); + fs::create_dir(&bin).expect("bin"); + for name in ["codex", "open", "osascript"] { + let path = bin.join(name); + fs::write( + &path, + "#!/bin/sh\ntouch \"$HOME/terminal-called\"\nexit 0\n", + ) + .expect("stub"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable"); + } + let output=Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", "controller::tests::session_launch_requires_an_unchanged_review_before_creating_files"]) + .env("SWITCHYARD_LAUNCH_REVIEW_FIXTURE",home.path()).env("HOME",home.path()) + .env("PATH",format!("{}:/usr/bin:/bin",bin.display())).output().expect("child"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } #[test] // An algorithm switch must not give stale choices a revision for externally edited bytes. fn algorithm_switches_keep_the_first_revision_and_external_edits_reject_it() { diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index 9f9455fa65..076d0c32b5 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -475,3 +475,357 @@ pub fn launch( worktree.display() )) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_session_setup_removes_its_worktree_branch_and_private_settings() { + let dir = tempfile::tempdir().expect("directory"); + let project = dir.path().join("project"); + std::fs::create_dir(&project).expect("project"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output + }; + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ]); + let worktree = dir.path().join("worktree"); + let private = dir.path().join("settings"); + git(&[ + "worktree", + "add", + "-b", + "session-fixture", + worktree.to_str().expect("path"), + ]); + std::fs::create_dir(&private).expect("private"); + std::fs::write(private.join("settings.json"), "{}").expect("settings"); + let revision = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the session branch. + git(&["checkout", "--orphan", "unrelated"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + drop(PendingSession { + project: project.clone(), + worktree: worktree.clone(), + private: private.clone(), + branch: "session-fixture".into(), + revision, + opened: false, + }); + assert!(!worktree.exists()); + assert!(!private.exists()); + assert!( + !String::from_utf8_lossy(&git(&["branch", "--list", "session-fixture"]).stdout) + .contains("session-fixture") + ); + } + // The subprocess isolates HOME and PATH. + // The Terminal stub injects a conflicting directory when the script must select one. + // Claude's default case requires an unset CLAUDE_CONFIG_DIR, not an explicit ~/.claude. + #[cfg(unix)] + #[test] + fn generated_sessions_use_explicit_accounts_without_changing_user_defaults() { + use std::os::unix::fs::PermissionsExt; + if let Ok(fixture) = std::env::var("SWITCHYARD_SESSION_FIXTURE") { + let fixture = std::path::PathBuf::from(fixture); + let tool = if std::env::var("SWITCHYARD_SESSION_TOOL").expect("tool") == "codex" { + Harness::CodexCli + } else { + Harness::Claude + }; + let account = + std::env::var_os("SWITCHYARD_SESSION_ACCOUNT").map(std::path::PathBuf::from); + let revision = preview(&fixture.join("project")).expect("preview")["revision"] + .as_str() + .expect("revision") + .to_string(); + // Advancing the original checkout makes a launch from HEAD differ from the review. + if tool == Harness::CodexCli && account.is_none() { + let committed = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "advanced", + ]) + .output() + .expect("advance HEAD"); + assert!(committed.status.success()); + } + launch( + tool, + &fixture.join("project"), + "route'$(literal)", + "http://localhost:4123", + tool == Harness::Claude, + account.as_deref(), + &revision, + ) + .expect("launch"); + if tool == Harness::CodexCli && account.is_none() { + let records = inventory().expect("inventory"); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["revision"], revision); + assert_ne!( + preview(&fixture.join("project")).expect("advanced project")["revision"], + revision + ); + let id = records[0]["id"].as_str().expect("session id"); + let receipt = session_root() + .expect("session root") + .join(format!("{id}-settings/session.json")); + let original = std::fs::read(&receipt).expect("receipt"); + // Valid JSON roots must still be objects before inventory adds process_status. + for malformed in ["null", "[]", "false", "42"] { + std::fs::write(&receipt, malformed).expect("malformed receipt"); + assert!(inventory().is_err()); + assert!(cleanup_preview(id).is_err()); + } + std::fs::write(&receipt, original).expect("restore receipt"); + let clean = cleanup_preview(id).expect("preview cleanup"); + let worktree = Path::new(records[0]["worktree"].as_str().expect("worktree")); + let unsaved = worktree.join("unsaved-user-work.txt"); + std::fs::write(&unsaved, "keep this work").expect("user work"); + assert!(cleanup(id, &clean).is_err()); + assert!(unsaved.exists()); + let dirty = cleanup_preview(id).expect("dirty preview"); + assert!(cleanup(id, &dirty).is_err()); + assert!(worktree.exists()); + std::fs::remove_file(&unsaved).expect("save fixture work"); + let clean = cleanup_preview(id).expect("clean preview"); + // Cleanup deletes ignored files, so a new ignored file requires a new review. + let info = fixture.join("project/.git/info"); + std::fs::create_dir_all(&info).expect("git info"); + std::fs::write(info.join("exclude"), "ignored-notes.txt\n").expect("exclude"); + std::fs::write(worktree.join("ignored-notes.txt"), "notes").expect("notes"); + assert!(cleanup(id, &clean).is_err()); + assert!(worktree.join("ignored-notes.txt").exists()); + let reviewed = cleanup_preview(id).expect("ignored preview"); + assert_eq!( + reviewed["ignored"], + serde_json::json!(["ignored-notes.txt"]) + ); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the branch. + let project_git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args(args) + .output() + .expect("git"); + assert!(output.status.success()); + output + }; + project_git(&["checkout", "--orphan", "unrelated"]); + project_git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + cleanup(id, &reviewed).expect("safe cleanup"); + assert!(!worktree.exists()); + assert!(inventory().expect("inventory after cleanup").is_empty()); + assert!(project_git(&["branch", "--list", id]).stdout.is_empty()); + } + return; + } + for (name, named, inherited_dir) in [ + ("codex", false, true), + ("codex", true, true), + ("claude", false, true), + ("claude", true, true), + ("claude", false, false), + ("claude", true, false), + ] { + let fixture = tempfile::tempdir().expect("fixture"); + let home = fixture.path().join("home"); + let bin = fixture.path().join("bin"); + let project = fixture.path().join("project"); + for path in [&home, &bin, &project] { + std::fs::create_dir(path).expect("directory"); + } + for args in [ + vec!["init"], + vec![ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + ] { + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git") + .status + .success() + ); + } + let default = home.join("default-login"); + std::fs::create_dir(&default).expect("default"); + std::fs::write(default.join("settings.json"), "{\"user\":true}").expect("settings"); + let account = home.join(".switchyard/accounts").join(name).join("named"); + if named { + std::fs::create_dir_all(&account).expect("account"); + } + let output = fixture.path().join("arguments"); + let captured = fixture.path().join("session.command"); + // macOS runs the script through `open`; Linux runs it through the program in + // TERMINAL, which receives `-e` before the script path. + let open = bin.join(if cfg!(target_os = "linux") { + "terminal" + } else { + "open" + }); + let terminal_claude = if inherited_dir || named { + "export CLAUDE_CONFIG_DIR=wrong-terminal-login" + } else { + "unset CLAUDE_CONFIG_DIR" + }; + std::fs::write( + &open, + format!( + "#!/bin/bash\n\ + [ \"$1\" = -e ] && shift\n\ + cp -- \"$1\" {}\n\ + export CODEX_HOME=wrong-terminal-login ANTHROPIC_AUTH_TOKEN=wrong-token\n\ + {terminal_claude}\n\ + bash \"$1\"\n", + quote(&captured.display().to_string()) + ), + ) + .expect("open"); + let tool = bin.join(name); + std::fs::write( + &tool, + format!( + "#!/bin/bash\n\ + printf '%s\\n' \"${{CODEX_HOME:-}}\" \"${{CLAUDE_CONFIG_DIR:-}}\" \ + \"${{ANTHROPIC_AUTH_TOKEN:-}}\" \"$@\" > {0}.partial && mv {0}.partial {0}\n", + quote(&output.display().to_string()) + ), + ) + .expect("tool"); + for path in [&open, &tool] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("executable"); + } + let mut child = std::process::Command::new(std::env::current_exe().expect("runner")); + child + .args([ + "--exact", + "sessions::tests::\ + generated_sessions_use_explicit_accounts_without_changing_user_defaults", + ]) + .env("SWITCHYARD_SESSION_FIXTURE", fixture.path()) + .env("SWITCHYARD_SESSION_TOOL", name) + .env("HOME", &home) + .env("CODEX_HOME", &default) + .env("CLAUDE_CONFIG_DIR", &default) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .env("TERMINAL", &open); + if named { + child.env("SWITCHYARD_SESSION_ACCOUNT", &account); + } + if !inherited_dir { + child.env_remove("CLAUDE_CONFIG_DIR"); + } + let result = child.output().expect("child"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + // A Linux terminal starts apart from the launch, so the arguments can arrive + // after the child exits. The tool stub renames the file into place when done. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !output.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let arguments = std::fs::read_to_string(output).expect("arguments"); + let lines: Vec<_> = arguments.lines().collect(); + let expected = if named { + account.to_str().expect("path") + } else if inherited_dir { + default.to_str().expect("path") + } else { + "" + }; + assert_eq!(lines[usize::from(name == "claude")], expected); + if name == "claude" { + assert_eq!(lines[2], ""); + } + assert!(arguments.contains("route'$(literal)")); + let script = std::fs::read_to_string(captured).expect("script"); + assert!(script.contains("x-switchyard-session-id")); + if name == "claude" && !named && !inherited_dir { + assert!(!script.contains("export CLAUDE_CONFIG_DIR=")); + } + assert_eq!( + std::fs::read_to_string(default.join("settings.json")).expect("settings"), + "{\"user\":true}" + ); + } + } +} From a335cc0823fa51eddc136fc3cd24fbe9dc9f0114 Mon Sep 17 00:00:00 2001 From: Zengyuan Liu Date: Mon, 26 Oct 2026 16:29:15 +0000 Subject: [PATCH 28/28] feat(desktop): record session launches and open harnesses in existing worktrees Signed-off-by: Zengyuan Liu Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/sessions.rs | 263 +++++++++++++++++----- 1 file changed, 204 insertions(+), 59 deletions(-) diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index 076d0c32b5..2e239e6933 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -242,6 +242,80 @@ pub fn cleanup(id: &str, reviewed: &serde_json::Value) -> Result .into()) } +/// A harness opened in a directory that already exists. +/// +/// `launch` creates a worktree and then opens the harness in it. A hand-off later opens +/// another harness in the same directory, so the two steps stay separate. +pub struct OpenRequest<'a> { + pub tool: Harness, + pub binary: std::path::PathBuf, + /// The directory the harness starts in. + pub directory: std::path::PathBuf, + /// The public route ID the harness uses as its model. + pub model: &'a str, + /// The validated server URL. + pub root: &'a str, + pub login: bool, + /// The login directory the script exports, when the harness must select one. + pub account: Option<&'a Path>, + /// The value of the `x-switchyard-session-id` header. + pub session_id: &'a str, + /// The private settings files written for Claude and Pi. + pub files: &'a [std::path::PathBuf], + /// Pi's private agent directory. + pub private: &'a Path, + /// Claude's session UUID. Choosing it here makes the transcript path known before the + /// session starts. + pub native_session_id: Option<&'a str>, +} + +/// Claude Code accepts a UUID for `--session-id`. Its own session IDs are version 4, so the +/// bytes of a time-ordered UUID are relabeled as version 4 rather than enabling another +/// `uuid` feature. +pub fn claude_session_id() -> String { + uuid::Builder::from_random_bytes(*uuid::Uuid::now_v7().as_bytes()) + .into_uuid() + .to_string() +} + +fn create_worktree( + project: &Path, + id: &str, + revision: &str, +) -> Result<(std::path::PathBuf, std::path::PathBuf, PendingSession), String> { + let worktree = session_root()?.join(id); + let parent = worktree + .parent() + .ok_or("Worktree needs a parent directory")? + .to_path_buf(); + std::fs::create_dir_all(&parent).map_err(|e| e.to_string())?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(project) + .args(["worktree", "add", "-b"]) + .arg(id) + .arg(&worktree) + .arg(revision) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "Could not create worktree: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let private = parent.join(format!("{id}-settings")); + let pending = PendingSession { + project: project.to_path_buf(), + worktree: worktree.clone(), + private: private.clone(), + branch: id.to_string(), + revision: revision.into(), + opened: false, + }; + Ok((worktree, private, pending)) +} + pub fn launch( tool: Harness, project: &Path, @@ -289,40 +363,7 @@ pub fn launch( let id = format!("sy-{}", uuid::Uuid::now_v7()); // Path resolution must succeed before launch creates directories or a worktree. let default_files = harness::paths(tool)?; - let worktree = session_root()?.join(&id); - std::fs::create_dir_all( - worktree - .parent() - .ok_or("Worktree needs a parent directory")?, - ) - .map_err(|e| e.to_string())?; - let output = std::process::Command::new("git") - .arg("-C") - .arg(&project) - .args(["worktree", "add", "-b"]) - .arg(&id) - .arg(&worktree) - .arg(revision) - .output() - .map_err(|e| e.to_string())?; - if !output.status.success() { - return Err(format!( - "Could not create worktree: {}", - String::from_utf8_lossy(&output.stderr) - )); - } - let private = worktree - .parent() - .ok_or("Worktree needs a parent directory")? - .join(format!("{id}-settings")); - let mut pending = PendingSession { - project, - worktree: worktree.clone(), - private: private.clone(), - branch: id.clone(), - revision: revision.into(), - opened: false, - }; + let (worktree, private, mut pending) = create_worktree(&project, &id, revision)?; let files = match tool { Harness::Pi => vec![private.join("models.json"), private.join("settings.json")], _ => vec![private.join("settings.json")], @@ -331,7 +372,32 @@ pub fn launch( if matches!(tool, Harness::Claude | Harness::Pi) { harness::install(tool, &files, &root, model, None, login)?; } - let receipt = serde_json::json!({"id":id,"tool":tool,"route":model,"repository":repository,"revision":preview(&worktree)?["revision"],"worktree":worktree,"branch":id,"created_at":chrono::Utc::now().to_rfc3339(),"settings_directory":private,"account_directory":account}); + let native_session_id = (tool == Harness::Claude).then(claude_session_id); + let created_at = chrono::Utc::now().to_rfc3339(); + // `launches` records every harness opened in this worktree. The top-level `tool`, + // `route`, `account_directory`, and `settings_directory` mirror the latest launch. + let launched = serde_json::json!({ + "tool": tool, + "route": model, + "account_directory": account, + "settings_directory": private, + "started_at": created_at, + "native_session_id": native_session_id, + }); + let receipt = serde_json::json!({ + "id": id, + "tool": tool, + "route": model, + "repository": repository, + "revision": preview(&worktree)?["revision"], + "worktree": worktree, + "branch": id, + "relative_directory": relative, + "created_at": created_at, + "settings_directory": private, + "account_directory": account, + "launches": [launched], + }); std::fs::write( private.join("session.json"), serde_json::to_vec_pretty(&receipt).map_err(|error| error.to_string())?, @@ -341,6 +407,46 @@ pub fn launch( Harness::Claude if std::env::var_os("CLAUDE_CONFIG_DIR").is_none() => None, _ => default_files[0].parent(), }); + open_harness(&OpenRequest { + tool, + binary, + directory: worktree.join(&relative), + model, + root: &root, + login, + account, + session_id: &id, + files: &files, + private: &private, + native_session_id: native_session_id.as_deref(), + })?; + pending.opened = true; + Ok(format!( + "Opened Terminal to start the session.\n\ + Worktree: {}\n\ + Branch: {id}\n\ + Model route: {model}\n\ + Remove the worktree with git worktree remove after saving or discarding its changes.", + worktree.display() + )) +} + +/// This opens the harness in `request.directory` through the user's terminal. +pub fn open_harness(request: &OpenRequest) -> Result<(), String> { + let OpenRequest { + tool, + binary, + directory, + model, + root, + login, + account, + session_id, + files, + private, + native_session_id, + } = request; + let (tool, model, login, session_id) = (*tool, *model, *login, *session_id); let codex_args = [ "-c".to_string(), format!("model={}", toml_edit::Value::from(model)), @@ -352,9 +458,18 @@ pub fn launch( requires_openai_auth={login},http_headers={{\"x-switchyard-session-id\"={},\ \"x-switchyard-origin\"=\"codex\"}}}}", toml_edit::Value::from(format!("{}/v1", root.trim_end_matches('/'))), - toml_edit::Value::from(id.as_str()) + toml_edit::Value::from(session_id) ), ]; + let mut claude_args = vec![ + "--settings".to_string(), + files[0].display().to_string(), + "--model".into(), + model.into(), + ]; + if let Some(native) = native_session_id { + claude_args.extend(["--session-id".to_string(), (*native).to_string()]); + } #[cfg(windows)] { let mut environment = Vec::new(); @@ -364,7 +479,7 @@ pub fn launch( Harness::Claude => { environment.push(( "ANTHROPIC_CUSTOM_HEADERS".into(), - format!("x-switchyard-session-id: {id}\nx-switchyard-origin: claude"), + format!("x-switchyard-session-id: {session_id}\nx-switchyard-origin: claude"), )); if login { remove_environment.extend( @@ -376,12 +491,7 @@ pub fn launch( .map(String::from), ); } - vec![ - "--settings".into(), - files[0].display().to_string(), - "--model".into(), - model.into(), - ] + claude_args } Harness::Pi => { environment.push(("PI_CODING_AGENT_DIR".into(), private.display().to_string())); @@ -406,9 +516,9 @@ pub fn launch( } } crate::windows::open_terminal(crate::windows::TerminalJob { - binary, + binary: binary.clone(), args, - directory: Some(worktree.join(&relative)), + directory: Some(directory.clone()), environment, remove_environment, })?; @@ -426,12 +536,15 @@ pub fn launch( .join(" ") ), Harness::Claude => format!( - "env ANTHROPIC_CUSTOM_HEADERS={} {program} --settings {} --model {}", + "env ANTHROPIC_CUSTOM_HEADERS={} {program} {}", quote(&format!( - "x-switchyard-session-id: {id}\nx-switchyard-origin: claude" + "x-switchyard-session-id: {session_id}\nx-switchyard-origin: claude" )), - quote(&files[0].display().to_string()), - quote(model) + claude_args + .iter() + .map(|arg| quote(arg)) + .collect::>() + .join(" ") ), Harness::Pi => format!( "env PI_CODING_AGENT_DIR={} {program} --provider switchyard --model {}", @@ -461,19 +574,11 @@ pub fn launch( rm -- \"$0\"\n\ cd {} || exit 1\n\ {account_env}{subscription_env}{command}", - quote(&worktree.join(relative).display().to_string()) + quote(&directory.display().to_string()) ); crate::server::open_terminal_script("switchyard-session-", &script)?; } - pending.opened = true; - Ok(format!( - "Opened Terminal to start the session.\n\ - Worktree: {}\n\ - Branch: {id}\n\ - Model route: {model}\n\ - Remove the worktree with git worktree remove after saving or discarding its changes.", - worktree.display() - )) + Ok(()) } #[cfg(test)] @@ -610,6 +715,13 @@ mod tests { let records = inventory().expect("inventory"); assert_eq!(records.len(), 1); assert_eq!(records[0]["revision"], revision); + assert_eq!(records[0]["relative_directory"], ""); + let launches = records[0]["launches"].as_array().expect("launches"); + assert_eq!(launches.len(), 1); + assert_eq!(launches[0]["tool"], "codex_cli"); + assert_eq!(launches[0]["route"], records[0]["route"]); + assert_eq!(launches[0]["started_at"], records[0]["created_at"]); + assert!(launches[0]["native_session_id"].is_null()); assert_ne!( preview(&fixture.join("project")).expect("advanced project")["revision"], revision @@ -819,6 +931,39 @@ mod tests { assert!(arguments.contains("route'$(literal)")); let script = std::fs::read_to_string(captured).expect("script"); assert!(script.contains("x-switchyard-session-id")); + // The receipt names the Claude session UUID that the launch passed on the command + // line, so the transcript path is known without reading any transcript. + let receipts: Vec<_> = std::fs::read_dir(home.join(".switchyard/worktrees")) + .expect("worktrees") + .filter_map(|entry| { + let path = entry.expect("entry").path().join("session.json"); + std::fs::read(&path).ok().map(|bytes| { + serde_json::from_slice::(&bytes).expect("json") + }) + }) + .collect(); + if name == "claude" { + assert_eq!(receipts.len(), 1); + let native = receipts[0]["launches"][0]["native_session_id"] + .as_str() + .expect("claude session id"); + assert_eq!( + uuid::Uuid::parse_str(native) + .expect("uuid") + .get_version_num(), + 4 + ); + assert!(arguments.contains(&format!("--session-id\n{native}\n"))); + } else { + // The Codex case without an account cleans its session up in the child. + assert_eq!(receipts.len(), usize::from(named)); + assert!( + receipts + .iter() + .all(|receipt| { receipt["launches"][0]["native_session_id"].is_null() }) + ); + assert!(!arguments.contains("--session-id")); + } if name == "claude" && !named && !inherited_dir { assert!(!script.contains("export CLAUDE_CONFIG_DIR=")); }