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/accounts.rs b/crates/switchyard-desktop/src/accounts.rs new file mode 100644 index 0000000000..3ec510baba --- /dev/null +++ b/crates/switchyard-desktop/src/accounts.rs @@ -0,0 +1,232 @@ +// 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}; + +// 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", + 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 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()); + } + 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 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); + }; + match tool { + Harness::CodexCli => Ok(vec![account.join("sy.config.toml")]), + Harness::Claude => Ok(vec![account.join("settings.json")]), + _ => 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() + )) +} + +#[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 new file mode 100644 index 0000000000..2233ffcec7 --- /dev/null +++ b/crates/switchyard-desktop/src/controller.rs @@ -0,0 +1,2165 @@ +// 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::{ + accounts, + config::Config, + harness::{self, Harness}, + history, models, pricing, rollup, server, + server_config::{ALGORITHMS, Choice, Route, ServerConfig}, + sessions, +}; +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 { + Editor { + generation: u64, + route: String, + algorithm: String, + }, + Apply { + generation: u64, + route: String, + algorithm: String, + choices: Vec, + revision: Option, + }, + Remove { + generation: u64, + route: String, + id: String, + revision: Option, + }, + PreviewRoute { + generation: u64, + route: String, + algorithm: String, + 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, + key: Option, + }, + PreviewInstall { + tool: Harness, + account: Option, + route: String, + 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, + }, + AddAccount { + 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, + }, + PreviewAccountArchive { + tool: Harness, + name: String, + }, + ArchiveAccount { + tool: Harness, + name: String, + preview_token: Option, + }, + OpenAccount { + tool: Harness, + name: String, + }, + OpenSession { + id: String, + }, + PreviewSessionCleanup { + id: String, + }, + RemoveSession { + id: String, + preview_token: Option, + }, + Restart {}, + PreviewUpdate {}, + Update { + preview_token: Option, + }, + OpenLogs {}, + OpenUpdateLog {}, + OpenConfig {}, + OpenPrices {}, + 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 +/// `..`, so adding a field breaks the build until the terminal app shows it. +#[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, + 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 tools: Vec, + pub algorithms: Vec, + 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, +} + +/// 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, + /// 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, + /// 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. +#[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 { + 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)] +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, +} + +/// 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, +} + +#[derive(Serialize)] +pub struct Reply { + pub message: String, + 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, + data: Value::Null, + } + } +} + +// 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, + inputs: Value, + files: Vec, +} +impl Review { + fn capture(inputs: Value, 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(), + inputs, + 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()) + } + // 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(()) + } +} + +// 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, + config: Config, + reader: rollup::Reader, + // The generation ties a draft to the server config file selected by the settings. + 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)>, + launch_review: Option, +} +impl Controller { + pub fn new(config: Config, settings: PathBuf) -> Self { + Self { + config, + settings, + health_probe: crate::health::Probe::default(), + reader: rollup::Reader::default(), + generation: 0, + route_reviews: Default::default(), + tool_review: None, + session_cleanup: None, + account_archive: None, + route_change: None, + update_review: None, + launch_review: None, + } + } + fn reload(&mut self) -> Result<(), String> { + let config = Config::load(&self.settings)?; + if config.config_file != self.config.config_file { + self.generation += 1; + self.route_reviews.clear(); + self.tool_review = None; + self.route_change = None; + self.launch_review = None; + } + self.config = config; + + 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)? + .contains(name) + { + Ok(()) + } else { + Err(format!( + "The server config does not name {name} with api_key_env. Refresh and try again." + )) + } + } + 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) + && 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(); + // 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() + }); + // 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); + health["url"] = json!(self.config.server_url); + let server_status = if health["state"] == "running" { + crate::health::ServerStatus::Running + } else { + crate::health::ServerStatus::Stopped + }; + 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(json!({}), 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) => { + 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(), + inputs: json!({"route":route.key}), + 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() + }), + compatibility: harness::HARNESSES + .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) + .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; + errors.push(e); + 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})); + 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, + routing_log, + config_file, + launchd_label, + refresh_seconds, + baseline_model, + prices_file, + } = self.config.clone(); + Ok(Snapshot { + update_status, + session_inventory, + session_inventory_error, + 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, + }, + generation: self.generation, + routes, + clients, + tools, + 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(), + 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(), + }, + }, + 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, + }), + }) + } + /// 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())), + 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), + // 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), + } + } + 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::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::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, + 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::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::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::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::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) + } + 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::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())), + 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()])?; + 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 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 { + label: role.label.clone(), + hint: role.hint, + tier: role.tier.map(|t| format!("{t:?}")), + } +} + +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 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()) +} +// A custom file selects one settings location; saved accounts select their own folders. +fn install_paths( + tool: Harness, + account: Option<&str>, + 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())?; + 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) +} +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() + )) +} + +#[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() { + 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); + } + + #[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. + 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/harness.rs b/crates/switchyard-desktop/src/harness.rs new file mode 100644 index 0000000000..885c3e25ee --- /dev/null +++ b/crates/switchyard-desktop/src/harness.rs @@ -0,0 +1,1209 @@ +// 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::io::Write; +use std::path::{Path, PathBuf}; +use toml_edit::{DocumentMut, value}; + +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) +} + +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) +} + +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], + 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", + 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 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." + })) +} + +// Restore receipts keep the original contents or absence available until backup cleanup finishes. +#[cfg(test)] +pub fn restore(files: &[PathBuf]) -> Result { + restore_reviewed(files, None) +} + +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)] + 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()) +} + +#[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"); + } +} diff --git a/crates/switchyard-desktop/src/history.rs b/crates/switchyard-desktop/src/history.rs index feebe62950..c58cfca6e6 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,133 @@ pub struct Entry { #[serde(default)] pub cost: Option, } + +#[derive(Default)] +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. +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.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::*; + + #[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/install/windows.rs b/crates/switchyard-desktop/src/install/windows.rs index d5097cb39d..9cad57c992 100644 --- a/crates/switchyard-desktop/src/install/windows.rs +++ b/crates/switchyard-desktop/src/install/windows.rs @@ -472,3 +472,46 @@ 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")])?; + // 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)); + 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 e1ba58f87b..cbed7a1fa7 100644 --- a/crates/switchyard-desktop/src/main.rs +++ b/crates/switchyard-desktop/src/main.rs @@ -1,13 +1,29 @@ // 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 terminal app manages a local Switchyard server. +//! +//! It shows recorded traffic and compares list-price estimates with the +//! configured baseline model. Its Routes view edits the server config and +//! restarts the server. #![cfg_attr(windows, windows_subsystem = "windows")] -use switchyard_desktop::config; +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; +mod server_config; +mod sessions; +mod summary; +mod tui; +#[cfg(windows)] +mod windows; use std::path::PathBuf; use std::process::ExitCode; @@ -15,12 +31,13 @@ use std::process::ExitCode; use config::Config; const USAGE: &str = "\ -Usage: switchyard-desktop [--validate-toml] [FILE] +Usage: switchyard-desktop [--tui | --print | --validate-toml] [FILE] -This app opens a window with server status and recorded usage. -The desktop window supports macOS and Windows. +This app manages routes, harness settings, and usage. 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. Without FILE, the app uses bundled installation metadata to find desktop.toml. @@ -30,25 +47,39 @@ 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 { - // 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, - ) - }; + 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; + 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" => { println!("{USAGE}"); 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}"); @@ -62,6 +93,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"); @@ -81,18 +117,71 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - #[cfg(any(target_os = "macos", windows))] - match gui::run(settings) { - Ok(()) => ExitCode::SUCCESS, + if !terminal && !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 or --tui."); + return ExitCode::FAILURE; + } + } + + let config = match Config::load(&settings) { + Ok(config) => config, Err(error) => { eprintln!("switchyard-desktop: {error}"); - ExitCode::FAILURE + if !terminal { + return ExitCode::FAILURE; + } + match Config::defaults_for(&settings) { + Ok(config) => config, + 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}"); } + return ExitCode::SUCCESS; } - #[cfg(not(any(target_os = "macos", windows)))] - { - let _ = settings; - eprintln!("The desktop window supports macOS and Windows."); - ExitCode::FAILURE + + 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/models.rs b/crates/switchyard-desktop/src/models.rs new file mode 100644 index 0000000000..688aa7a96f --- /dev/null +++ b/crates/switchyard-desktop/src/models.rs @@ -0,0 +1,726 @@ +// 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 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 { + 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 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. +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 + .as_deref() + .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) +} + +#[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:")) + ); + } +} 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/server.rs b/crates/switchyard-desktop/src/server.rs new file mode 100644 index 0000000000..9c30c0ce29 --- /dev/null +++ b/crates/switchyard-desktop/src/server.rs @@ -0,0 +1,653 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! This module validates and saves server configs and restarts the server service. + +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.") +} + +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. \ + 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 { + 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()) +} + +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| { + 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 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> { + 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) +} + +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" { + #[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()) +} + +// 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::*; + 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 new file mode 100644 index 0000000000..a944f71d29 --- /dev/null +++ b/crates/switchyard-desktop/src/server_config.rs @@ -0,0 +1,1556 @@ +// 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 std::collections::HashMap; + +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. +#[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 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], + /// 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. +#[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], +} + +#[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. +/// `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", + title: "Single model", + summary: "This algorithm sends every request to one model.", + roles: &[RoleSpec { + label: "Model", + hint: "This model answers every request.", + tier: Tier::Capable, + path: &["target"], + }], + settings: &[], + subagents: true, + }, + Algorithm { + kind: RANDOM, + title: "Random split", + summary: "This algorithm splits requests at random between models \ + for A/B tests and baselines.", + roles: &[], + settings: &[], + subagents: false, + }, + 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", + 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"], + }, + ], + settings: &[ + (&["mode"], Setting::Text("capability")), + (&["base_threshold"], Setting::Number(0.5)), + ], + subagents: false, + }, + 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", + 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"], + }, + ], + 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", + title: "Stage router", + summary: "Tool results and agent progress move each request between the capable and \ + the efficient model.", + 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"], + }, + ], + settings: &[ + (&["picker"], Setting::Text("efficient_first")), + (&["confidence_threshold"], Setting::Number(0.5)), + ], + subagents: true, + }, + 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", + 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"], + }, + ], + settings: &[], + subagents: false, + }, + 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", + 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"], + }, + ], + settings: &[], + subagents: false, + }, + Algorithm { + kind: "auto", + title: "Recommended preset", + summary: "This stage router starts each request on the efficient model.", + 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"], + }, + ], + 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 { + /// 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, +} + +/// 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)] +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 { + /// 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 + /// 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 { + 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() + } +} + +/// 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, +} + +/// ServerConfig keeps the parsed config and the original text's layout. +#[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, + generated: generated_tables(text), + }) + .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() + } + + 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 { + 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. + 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 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 { + 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() + } + + /// 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 { + text: doc.to_string(), + notes, + }) + } + + 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(), + } + } + + /// 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 { + 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() +} + +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 +} + +// 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 +/// 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()) +} + +#[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 + ); + } +} diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs new file mode 100644 index 0000000000..2e239e6933 --- /dev/null +++ b/crates/switchyard-desktop/src/sessions.rs @@ -0,0 +1,976 @@ +// 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 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")) +} + +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) +} + +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()) +} + +/// 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, + 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, 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")], + }; + 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 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())?, + ) + .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(), + }); + 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)), + "-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(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(); + 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: {session_id}\nx-switchyard-origin: claude"), + )); + if login { + remove_environment.extend( + [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + ] + .map(String::from), + ); + } + claude_args + } + 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: binary.clone(), + args, + directory: Some(directory.clone()), + 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} {}", + quote(&format!( + "x-switchyard-session-id: {session_id}\nx-switchyard-origin: claude" + )), + claude_args + .iter() + .map(|arg| quote(arg)) + .collect::>() + .join(" ") + ), + 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(&directory.display().to_string()) + ); + crate::server::open_terminal_script("switchyard-session-", &script)?; + } + Ok(()) +} + +#[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_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 + ); + 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")); + // 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=")); + } + assert_eq!( + std::fs::read_to_string(default.join("settings.json")).expect("settings"), + "{\"user\":true}" + ); + } + } +} 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" + ); + } + } +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs new file mode 100644 index 0000000000..e414638607 --- /dev/null +++ b/crates/switchyard-desktop/src/tui.rs @@ -0,0 +1,5286 @@ +// 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, 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. + +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}; +use crate::server_config::Choice; +use crate::summary::{money, tokens}; +use chrono::{DateTime, Utc}; +use ratatui::{ + 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, List, ListItem, ListState, Padding, + Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, Tabs, + Wrap, + }, +}; +use serde_json::Value; +use std::{ + borrow::Cow, + collections::BTreeMap, + path::{Path, PathBuf}, + sync::mpsc, + 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] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', '⣾']; + +/// 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-7", + "page", + "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", + ), + ( + "r", + "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 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 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 HARNESSES_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "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."), + ( + "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 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."), + ( + "⏎", + "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."), + ("ctrl-u", "erase", "Erase the filter text."), +]; +const FORM_KEYS: &[Key] = &[ + ("⏎", "submit", ""), + ("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", ""), + ("esc", "cancel", ""), +]; +const POPUP_KEYS: &[Key] = &[("↑↓", "scroll", ""), ("esc", "close", "")]; +const CONFIRM_KEYS: &[Key] = &[("y", "confirm", ""), ("n", "cancel", "")]; + +/// 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::Routes, + 'e', + "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, + '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', + "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::Harnesses, + 'p', + "preview", + "Shows the settings diff that installing the selected route would make. Nothing is \ + 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::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::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', + "add account", + "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', + "Open server config", + "Opens the server config in $VISUAL or $EDITOR. Restart the server after you save it.", + &[ActionKind::OpenConfig], + ), + ( + Tab::Settings, + 'c', + "Check server config", + "Runs the installed server's config check. The running server keeps running.", + &[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], + ), + ( + Tab::Settings, + 'a', + "Open app settings", + "Opens this app's settings file in $VISUAL or $EDITOR.", + &[ActionKind::OpenSettings], + ), + ( + Tab::Settings, + 'e', + "Change preferences", + "Changes the baseline model that prices savings and the refresh interval.", + &[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], + ), + ( + Tab::Settings, + 'k', + "Check server keys", + "Lists the API key variables that the server config names and whether each is saved.", + &[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], + ), + ( + 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], + ), + ( + 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], + ), + ( + Tab::Settings, + 'u', + "Update from source", + "Shows what the saved source checkout would install. Type UPDATE to start it.", + &[ActionKind::PreviewUpdate, ActionKind::Update], + ), + ( + Tab::Settings, + 'l', + "Open update log", + "Opens the log of the last source update in $VISUAL or $EDITOR.", + &[ActionKind::OpenUpdateLog], + ), + ( + Tab::Settings, + 'v', + "Open server logs", + "Opens the Switchyard server logs folder in $VISUAL or $EDITOR.", + &[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); +/// 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), +]; +/// 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), +]; +/// 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), +]; +/// 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)] +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 { + let plain = Style::new(); + if !color { + return Self { + 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. + Self { + 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), + } + } +} +/// 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, + Calls, + Models, + Routes, + Harnesses, + Sessions, + Settings, +} +const TABS: [Tab; 7] = [ + Tab::Overview, + Tab::Calls, + Tab::Models, + Tab::Routes, + Tab::Harnesses, + Tab::Sessions, + Tab::Settings, +]; +impl Tab { + fn name(self) -> &'static str { + match self { + Tab::Overview => "Overview", + Tab::Calls => "Calls", + Tab::Models => "Models", + Tab::Routes => "Routes", + Tab::Harnesses => "Harnesses", + Tab::Sessions => "Sessions", + Tab::Settings => "Settings", + } + } + fn index(self) -> usize { + self as usize + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +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. + fn estimate(self, snapshot: &Snapshot) -> Option { + let key = match self { + Period::Today => "today", + Period::Week => "week", + Period::All => "all", + }; + snapshot.estimates.get(key).copied().flatten() + } +} + +enum FormKind { + Preferences, + SaveKey, + ImportKey, + 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 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 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, + id: String, + revision: String, + }, + Models, + ModelKey, + /// 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 { + Algorithms, + Endpoints, +} +/// 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 { + kind: FormKind, + 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, + /// This field shows the last validation or controller error below the fields. + error: String, +} +impl Form { + 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.into(), + 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 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::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), + _ => 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. 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(()) + } 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(|_| { + "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(preview_token) => { + confirm("UPDATE", "Type UPDATE to start the reviewed source update.")?; + Action::Update { + 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::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::Confirm(word, action) => { + confirm(word, &format!("Type {word} 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, + key: None, + }, + FormKind::ModelKey => Action::Models { + client: self.value(0), + 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))), + 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 { + 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::Algorithm { .. } => "Opening the route editor", + FormKind::RouteModels { .. } => "Reviewing the route", + FormKind::NewRoute { .. } + | FormKind::NewRouteModels { .. } + | FormKind::RouteIdentity { .. } => "Reviewing the route change", + // 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", + Action::ArchiveAccount { .. } => "Archiving the account", + Action::Launch { .. } => "Launching the session", + Action::RemoveSession { .. } => "Removing the session", + _ => "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", + FormKind::Project { .. } => "Reviewing the session", + } + } +} + +/// 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 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, + 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 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, + route, + algorithm, + choices, + revision, + } => Action::Apply { + generation, + route, + algorithm, + choices, + revision, + }, + Action::PreviewRouteChange { + generation, + operation, + route, + name, + id, + algorithm, + choices, + } => Action::RouteChange { + generation, + operation, + route, + name, + id, + algorithm, + 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, + }, + Action::PreviewAccountArchive { tool, name } => Action::ArchiveAccount { + tool, + name, + preview_token, + }, + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => Action::Launch { + preview_token, + tool, + account, + route, + id, + project, + }, + Action::PreviewSessionCleanup { id } => Action::RemoveSession { id, preview_token }, + _ => return None, + }) +} + +enum Popup { + Help { + scroll: u16, + }, + Detail { + scroll: u16, + }, + Restart, + Form(Form), + /// 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, + tone: Tone, + scroll: u16, + }, +} +impl Popup { + fn scroll_mut(&mut self) -> Option<&mut u16> { + match self { + Popup::Help { scroll } + | Popup::Detail { 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::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 { + 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>, + }, +} +/// 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, + /// 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, + } + } +} +struct Busy { + label: &'static str, + /// 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 { + text: String, + 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 + } + } +} + +/// 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, + /// 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, + 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, + 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 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 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, + popup: Option, + 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, + period: Period::Week, + filter: String::new(), + filtering: false, + session: None, + visible: Vec::new(), + selected: 0, + offset: 0, + action: 0, + route_filter: String::new(), + route_visible: Vec::new(), + route_selected: 0, + route_offset: 0, + harness: 0, + destination: Destination::Harness, + install_preview: None, + managed: Some(0), + server_keys: None, + popup: None, + 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) { + let data = match snapshot { + Ok(data) => data, + 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())); + 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); + 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]) + } + + /// 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 algorithms = &self.data.algorithms; + let filter = self.route_filter.as_str(); + self.route_visible = self + .data + .routes + .iter() + .enumerate() + .filter(|(_, route)| { + 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) + .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()), + } + } + + 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()) + } + + /// 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()), + Destination::Harness | Destination::Account(_) => None, + } + } + + /// 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 { + 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) + } + + 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() { + return; + } + if text.contains('\n') || text.chars().count() + 8 > usize::from(self.width) * 2 / 3 { + self.popup = Some(Popup::Message { + title: match tone { + Tone::Done => "Done", + Tone::Warning => "Attention", + Tone::Error => "Error", + }, + text, + tone, + scroll: 0, + }); + } else { + self.toast = Some(Toast { + text, + tone, + shown: Instant::now(), + }); + } + } + + /// 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 (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?; + 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, + // 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) { + (Some(ActionKind::ServerKeys), Err(error)) => { + self.server_keys = None; + 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, Tone::Error), + }, + (Some(ActionKind::PreviewUpdate), Ok(reply)) => { + 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", + )); + } + // 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"] + .as_array() + .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", + )); + } + } + (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)) => { + 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 | ActionKind::PreviewRouteChange), Ok(reply)) => { + if matches!(self.popup, Some(Popup::Form(_))) + && let Some(reviewed) = action.and_then(|action| reviewed(action, &reply.data)) + { + self.popup = Some(Popup::review( + change_lines(self.theme, &reply.data), + "Save", + FormKind::Confirm("SAVE".into(), Box::new(reviewed)), + "Review route changes", + "Type SAVE to apply", + )); + } + } + // 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::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() + .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); + } + ( + Some( + ActionKind::SaveServerKey + | ActionKind::ImportServerKey + | ActionKind::RemoveServerKey, + ), + Ok(reply), + ) => { + self.popup = None; + 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::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, tone); + } else { + self.popup = Some(Popup::Message { + title: if tone == Tone::Warning { + "Attention" + } else { + "Details" + }, + text: format!("{}\n\n{}", reply.message, details.join("\n")), + tone, + scroll: 0, + }); + } + } + } + None + } + + 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, Tone::Error); + return Ok(()); + } + let action = match &request.job { + Job::Refresh => { + self.last_refresh = Instant::now(); + None + } + Job::Run(action) => Some(action.clone()), + }; + jobs.send(request.job).map_err(|error| error.to_string())?; + self.busy = Some(Busy { + label: request.label, + action, + key: request.key, + started: Instant::now(), + }); + Ok(()) + } + + fn keys(&self) -> &'static [Key] { + 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, + (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::Harnesses) => HARNESSES_KEYS, + (None, false, Tab::Sessions) => SESSIONS_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 { + if self.tab == Tab::Routes { + &mut self.route_filter + } else { + &mut self.filter + } + } + + fn refilter(&mut self) { + 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) { + 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_mut() + .extend(text.chars().filter(|c| !c.is_control())); + self.refilter(); + } + } + + fn key(&mut self, key: KeyEvent) -> Option { + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + self.quit = true; + return None; + } + 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'..='7') => { + 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; + match self.tab { + Tab::Calls => { + self.filter.clear(); + self.session = None; + self.rebuild_calls(); + } + Tab::Routes => { + self.route_filter.clear(); + self.refilter(); + } + Tab::Overview + | Tab::Models + | Tab::Harnesses + | Tab::Sessions + | 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::Routes => self.routes_key(code), + Tab::Harnesses => self.harnesses_key(code), + Tab::Sessions => self.sessions_key(code), + Tab::Settings => return self.settings_key(code), + } + } + } + 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 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; + } + } + + /// 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. 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; + 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); + } + _ => {} + } + } + + 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 { + KeyCode::Enter => self.filtering = false, + KeyCode::Esc => { + self.filtering = false; + self.filter_mut().clear(); + } + KeyCode::Backspace => { + self.filter_mut().pop(); + } + KeyCode::Char('u') if control => self.filter_mut().clear(), + KeyCode::Char('w') if control => { + 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_mut().push(c); + } + _ => return, + } + self.refilter(); + } + + fn settings_key(&mut self, code: KeyCode) -> Option { + if let Some(action) = step(code, self.action, rows(Tab::Settings).count() - 1) { + self.action = action; + return None; + } + if code == KeyCode::Enter + && let Some(&(_, key, _, _, flow)) = rows(Tab::Settings).nth(self.action) + { + return self.run_row(flow[0], key); + } + 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 { + 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> { + 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))); + let generation = self.data.generation; + match kind { + // 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, + )) + } + // 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(); + 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) + } + // 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::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") + } + // 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())], + )) + } + // 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") + } + ActionKind::Restart => { + self.popup = Some(Popup::Restart); + Ok(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.open(Form::new( + FormKind::Preferences, + "Change preferences", + vec![ + ("Baseline model ID", baseline), + ("Refresh interval in seconds", refresh), + ], + )) + } + ActionKind::OpenPrices => request(Action::OpenPrices {}, "Opening the model prices"), + ActionKind::ServerKeys => request(Action::ServerKeys {}, "Checking the server keys"), + 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") + } + 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 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::Review { confirm: form, .. } => { + match key.code { + KeyCode::Esc => self.popup = None, + 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 + // window scrolls; text fields ignore these keys. + 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); + } + _ => { + 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 => { + if let Some(scroll) = popup.scroll_mut() + && 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, + }) +} + +/// 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(), + entry.route_id.as_str(), + 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| { + 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); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + text.push(','); + } + text.push(digit); + } + text +} + +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> { + 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 +} + +/// 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 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), + } +} + +/// 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 }), + Span::raw(if running { "running" } else { "not responding" }), + ] +} + +/// 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()) +} + +/// 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) + .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 { + 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 + 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 + 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 + // 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; + 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), + 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::Sessions => draw_sessions(frame, app, body), + Tab::Settings => draw_settings(frame, app, body), + } + draw_status(frame, app, status); + draw_popup(frame, app, body); +} + +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; + // 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 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 { + 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, 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 + { + 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_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, models] = 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), + ]; + if let Some(reason) = data.data_state.health["reason"].as_str() { + server.push(Line::styled(reason.to_string(), theme.muted)); + } + // 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 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) { + frame.render_widget( + 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, + ); +} + +/// 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_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 +} + +/// 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); + app.offset = keep_visible(app.selected, app.offset, app.visible.len(), 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, + ); + draw_scrollbar(frame, theme, area, app.visible.len(), app.selected); +} + +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 kind = if entry.tier == CLASSIFIER_TIER { + "Routing call (classifier or judge)" + } else { + "Answer call" + }; + let mut lines = vec![ + field("Time", local_time(&entry.ts)), + 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 { + 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); +} + +/// 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 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) { + // 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. Press a to create one." + } 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.editable { + 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(algorithm_title(&app.data, &route.kind).to_string()), + 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 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; + // 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, + compatibility: _, // harness_lines explains it for the selected harness + dependents: _, // remove_lines lists them in the delete review + } = route; + let field = |name: &str, value: String| labeled(theme, name, WIDTH, value); + let algorithm = data.algorithms.iter().find(|known| known.kind == *kind); + let mut lines = vec![ + field("Config", format!("[routes.{key}]")), + field( + "Algorithm", + algorithm.map_or_else( + || 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 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); +} + +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))); + let Some(preview) = app.install_preview() else { + frame.render_widget(detail, rest); + return; + }; + let diff = Paragraph::new(install_lines(theme, preview)) + .wrap(Wrap { trim: false }) + .block(block(theme, "Settings diff").padding(Padding::horizontal(1))); + // Wide terminals put the diff beside the details; others put it below them. + let [details_area, diff_area] = if rest.width >= 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) { + 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())); + 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!("{: { + 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 +} + +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 { + 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 = rows(Tab::Settings) + .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 (_, _, 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 + .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, name).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 { + 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()), + Span::styled("▏", theme.key), + Span::styled( + format!(" {} ", plural(matches 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), + 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 (mark, style) = match toast.tone { + Tone::Done => (" ✓ ", theme.ok), + Tone::Warning => (" ! ", theme.warn), + Tone::Error => (" ✗ ", theme.error), + }; + Line::from(vec![ + 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. 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 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); + frame.render_widget(Paragraph::new(left), left_area); + frame.render_widget(Paragraph::new(hints.right_aligned()), right_area); +} + +/// 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), + ] + }; + const HELP_WIDTH: usize = "? help ".len(); + let mut spans = Vec::new(); + let mut used = if global { HELP_WIDTH } else { 0 }; + let globals = if global { GLOBAL_KEYS } else { &[] }; + 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; + if used + width > room { + break; + } + used += width; + spans.extend(hint(key, label)); + } + if global && room >= HELP_WIDTH { + spans.extend(hint(Cow::Borrowed("?"), "help")); + } + 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 masked = matches!(form.kind, FormKind::SaveKey | FormKind::ModelKey); + let mut value = if masked && 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 { + 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::Detail { .. } => ( + "Call details", + app.selected_entry() + .map(|entry| detail_lines(theme, entry)) + .unwrap_or_default(), + Vec::new(), + theme.key, + 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) => ( + &*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, + lines.clone(), + pinned, + theme.warn, + 84, + footer(&[("⏎", *verb), ("esc", "Cancel")]), + ) + } + Popup::Message { + title, text, tone, .. + } => ( + *title, + text.lines() + .map(|line| Line::from(line.to_string())) + .collect(), + Vec::new(), + match tone { + Tone::Done => theme.key, + Tone::Warning => theme.warn, + Tone::Error => theme.error, + }, + 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().and_then(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); +} + +/// 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; + 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 + }; + 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)]; + lines.extend(key_lines(theme, app.server_keys.as_ref())); + lines.push(Line::default()); + lines + } + FormKind::Algorithm { .. } => algorithms(&form.fields[0].1), + 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), + 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 + .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::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::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::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(..) + | FormKind::Remove { .. } => 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 +} + +/// 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 !route.dependents.is_empty() { + lines.push(Line::default()); + lines.push(Line::styled( + format!( + "These harness settings use {} and must choose another route:", + route.id + ), + theme.warn, + )); + for dependent in &route.dependents { + // This destructuring has no `..`, so adding a field breaks the build until this + // review shows it. + let Dependent { + tool: _, // the label names the harness + label, + account, + files, + } = dependent; + let account = account.as_ref().map(|name| format!(" ({name})")); + let files: 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( + 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 +} + +/// This function returns a text field of a controller reply, or nothing when it is missing. +fn text(value: &Value, key: &str) -> 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 +} + +/// 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 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")), + ] +} + +/// 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 +} + +/// 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| { + 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. +/// 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, 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)), + ("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() { + lines.push(Line::default()); + } + lines.push(Line::styled(format!(" {section} "), theme.selected.bold())); + 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 { + wrapped.push(std::mem::take(&mut row)); + } + if !row.is_empty() { + row.push(' '); + } + row.push_str(word); + } + 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: 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, 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; + 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) = 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; + } + } + }); + let result = ratatui::run(|terminal| -> Result<(), String> { + loop { + 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()), + Tone::Done, + ), + Err(error) => app.notify(error, Tone::Error), + } + app.start(&jobs, Request::refresh())?; + } + } + } + if app.quit && app.busy.is_none() { + return Ok(()); + } + if app + .toast + .as_ref() + .filter(|toast| toast.tone == Tone::Done) + .is_some_and(|toast| 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::Paste(text) => app.paste(&text), + 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 +} + +#[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 { + 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(width, height)).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::Calls, 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")); + } + + #[test] + // 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::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 => &[], + } + }; + 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:?}" + ); + + let missing: Vec = ActionKind::iter() + .filter(|kind| !ACTIONS.iter().any(|&(.., flow)| flow.contains(kind))) + .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); + } +} diff --git a/crates/switchyard-desktop/src/windows.rs b/crates/switchyard-desktop/src/windows.rs new file mode 100644 index 0000000000..7f768cfd84 --- /dev/null +++ b/crates/switchyard-desktop/src/windows.rs @@ -0,0 +1,129 @@ +// 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); + } +} +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); + } + } +} +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}.")) + } + })()) +} 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"); + } +}