From eea2ad4bcb2a3910de0fb0c7a96cddaf0f6379fd Mon Sep 17 00:00:00 2001 From: Elyas Mehtabuddin Date: Wed, 21 Oct 2026 10:28:50 -0700 Subject: [PATCH] feat(desktop): launch worktree sessions Signed-off-by: Elyas Mehtabuddin --- crates/switchyard-desktop/src/controller.rs | 355 ++++++++- crates/switchyard-desktop/src/harness.rs | 1 - crates/switchyard-desktop/src/sessions.rs | 825 ++++++++++++++++++++ crates/switchyard-desktop/src/tui.rs | 632 ++++++++++++++- 4 files changed, 1777 insertions(+), 36 deletions(-) diff --git a/crates/switchyard-desktop/src/controller.rs b/crates/switchyard-desktop/src/controller.rs index 715b150f3f..1138066bbd 100644 --- a/crates/switchyard-desktop/src/controller.rs +++ b/crates/switchyard-desktop/src/controller.rs @@ -12,6 +12,7 @@ use crate::{ harness::{self, Harness}, history, models, pricing, rollup, server, server_config::{ALGORITHMS, Choice, Route, ServerConfig}, + sessions, }; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; @@ -100,6 +101,21 @@ pub enum Action { tool: Harness, name: String, }, + Launch { + preview_token: Option, + tool: Harness, + account: Option, + route: String, + id: String, + project: String, + }, + PreviewLaunch { + tool: Harness, + account: Option, + route: String, + id: String, + project: String, + }, LoginAccount { tool: Harness, name: String, @@ -117,6 +133,16 @@ pub enum Action { tool: Harness, name: String, }, + OpenSession { + id: String, + }, + PreviewSessionCleanup { + id: String, + }, + RemoveSession { + id: String, + preview_token: Option, + }, Restart {}, PreviewUpdate {}, Update { @@ -150,6 +176,12 @@ pub enum Action { #[derive(Clone, Default, Serialize)] pub struct Snapshot { pub update_status: Value, + /// Each value is the `session.json` file that `sessions::launch` wrote for a managed + /// worktree session, plus `process_status`. The snapshot passes these files through as JSON. + pub session_inventory: Vec, + /// This field holds the error that stopped the inventory read. `session_inventory` is empty + /// when this field is set. + pub session_inventory_error: Option, /// This field is `None` only in the terminal app's placeholder snapshot, which it shows /// when the app settings cannot be loaded. pub settings: Option, @@ -402,6 +434,17 @@ impl Review { } } +// Each Controller retains one launch review until the next preview or a selected config change. +// Review keeps raw settings private; Launch uses these values and the reviewed Git commit. +struct ReviewedLaunch { + review: Review, + project: Value, + model: String, + server_url: String, + login: bool, + account: Option, +} + pub struct Controller { settings: PathBuf, config: Config, @@ -410,9 +453,11 @@ pub struct Controller { generation: u64, route_reviews: std::collections::HashMap, tool_review: Option, + session_cleanup: Option<(String, String, Value)>, account_archive: Option<(Harness, String, String)>, route_change: Option, update_review: Option<(String, Value)>, + launch_review: Option, } impl Controller { pub fn new(config: Config, settings: PathBuf) -> Self { @@ -423,9 +468,11 @@ impl Controller { 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> { @@ -435,6 +482,7 @@ impl Controller { self.route_reviews.clear(); self.tool_review = None; self.route_change = None; + self.launch_review = None; } self.config = config; @@ -636,6 +684,10 @@ impl Controller { let update_status = install_metadata(&self.settings) .and_then(|path| switchyard_desktop::install::update_status(&path)) .unwrap_or_else(|error| json!({"state":"unavailable","message":error})); + let (session_inventory, session_inventory_error) = match sessions::inventory() { + Ok(inventory) => (inventory, None), + Err(error) => (Vec::new(), Some(error)), + }; let sessions = history.sessions(); let Config { server_url, @@ -648,6 +700,8 @@ impl Controller { } = self.config.clone(); Ok(Snapshot { update_status, + session_inventory, + session_inventory_error, data_state: DataState { usage: Source { state: usage_state, @@ -726,9 +780,16 @@ impl Controller { } Action::OpenUpdateLog {} => self.update_log().map(Some), Action::OpenLogs {} => logs_dir().map(Some), - // An account folder opens in the editor too, because a terminal may run over SSH, - // where `open` would start a file manager on another screen. + // Account and session folders open in the editor too, because a terminal may run + // over SSH, where `open` would start a file manager on another screen. Action::OpenAccount { tool, name } => account_path(*tool, Some(name)), + 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), } } @@ -1055,6 +1116,102 @@ impl Controller { Action::AddAccount { tool, name } => { accounts::add(tool, name.trim()).map(Reply::message) } + Action::Launch { + tool, + account, + route, + id, + project, + preview_token, + } => { + let inputs = + json!({"tool":tool,"account":account,"route":route,"id":id,"project":project}); + let plan = self + .launch_review + .as_ref() + .ok_or("Review the session before opening Terminal.")?; + plan.review.check(preview_token.as_deref(), &inputs)?; + let current = sessions::preview(std::path::Path::new(&project))?; + if current != plan.project { + return Err( + "The project changed since review. Review the session again.".into(), + ); + } + sessions::launch( + tool, + std::path::Path::new( + plan.project["project"] + .as_str() + .ok_or("Missing reviewed project.")?, + ), + &plan.model, + &plan.server_url, + plan.login, + plan.account.as_deref(), + plan.project["revision"] + .as_str() + .ok_or("Missing reviewed commit.")?, + ) + .map(Reply::message) + } + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => { + if tool == Harness::CodexApp { + return Err("Use Codex app’s workspace controls or choose Codex CLI.".into()); + } + if harness::binary(tool).is_none() { + return Err("Install the selected harness first.".into()); + } + let inputs = + json!({"tool":tool,"account":account,"route":route,"id":id,"project":project}); + let review = Review::capture( + inputs.clone(), + vec![self.config.config_file.clone(), self.settings.clone()], + )?; + let app_config = Config::load(&self.settings)?; + review.check(Some(&review.token), &inputs)?; + if app_config.config_file != self.config.config_file { + return Err("Review these changes again before applying them.".into()); + } + let config = ServerConfig::parse(review.original()?).map_err( + |_| "The server config contains invalid TOML. Open it in Settings to fix it.", + )?; + let route = current_route(&config, &route, Some(&id))?; + let login = login_mode(tool, &config, &route)?; + let account = account_path(tool, account.as_deref())?; + let project_review = sessions::preview(std::path::Path::new(&project))?; + let mut data = project_review.clone(); + data["tool"] = json!(tool); + data["route"] = json!(route.id); + data["account_directory"] = json!(account); + data["server_url"] = json!(app_config.server_url); + data["login"] = json!(login); + if tool == Harness::Pi { + data["settings_notice"] = json!( + "Pi uses isolated session settings. \ + Personal settings and extensions are not copied." + ); + } + review.check(Some(&review.token), &inputs)?; + data["preview_token"] = json!(review.token); + self.launch_review = Some(ReviewedLaunch { + review, + project: project_review, + model: route.id, + server_url: app_config.server_url, + login, + account, + }); + Ok(Reply { + message: String::new(), + data, + }) + } Action::LoginAccount { tool, name } => { let path = account_path(tool, Some(&name))?.ok_or("Choose an account.")?; accounts::login(tool, &path, &name).map(Reply::message) @@ -1098,6 +1255,37 @@ impl Controller { server::command("open", &[&path.display().to_string()])?; Ok(Reply::message("Opened the account folder.".into())) } + Action::OpenSession { id } => { + let receipt = sessions::managed_session(&id)?; + server::command( + "open", + &[receipt["worktree"] + .as_str() + .ok_or("Missing session directory.")?], + )?; + Ok(Reply::message("Opened the session folder.".into())) + } + Action::PreviewSessionCleanup { id } => { + let data = sessions::cleanup_preview(&id)?; + let token = uuid::Uuid::now_v7().to_string(); + self.session_cleanup = Some((id, token.clone(), data.clone())); + let mut data = data; + data["preview_token"] = json!(token); + Ok(Reply { + message: String::new(), + data, + }) + } + Action::RemoveSession { id, preview_token } => { + let (expected_id, token, data) = self + .session_cleanup + .as_ref() + .ok_or("Review session cleanup first.")?; + if &id != expected_id || preview_token.as_deref() != Some(token.as_str()) { + return Err("Review session cleanup again.".into()); + } + sessions::cleanup(&id, data).map(Reply::message) + } Action::Restart {} => server::restart_checked(&self.config).map(Reply::message), Action::CheckConfig {} => server::check_config(&self.config) .map(|()| Reply::message("Server config check passed.".into())), @@ -1363,6 +1551,169 @@ fn update(settings: &std::path::Path) -> Result { #[cfg(test)] mod tests { use super::*; + // The subprocess isolates harness discovery and every launch destination. + #[cfg(unix)] + #[test] + fn session_launch_requires_an_unchanged_review_before_creating_files() { + use std::os::unix::fs::PermissionsExt; + use std::{fs, process::Command}; + if let Some(home) = std::env::var_os("SWITCHYARD_LAUNCH_REVIEW_FIXTURE") { + let home = PathBuf::from(home); + let project = home.join("project"); + let git = |args: &[&str]| { + let result = Command::new("/usr/bin/git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + }; + fs::create_dir(&project).expect("project"); + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "initial", + ]); + let settings = home.join("desktop.toml"); + let server = home.join("server.toml"); + let text = "[llm_clients.c]\n\ + base_url='http://localhost:1234'\n\ + [targets.m]\n\ + id='actual'\n\ + llm_client='c'\n\ + [routes.r]\n\ + id='public'\n\ + type='passthrough'\n\ + target='m'\n"; + fs::write(&server, text).expect("server"); + fs::write( + &settings, + format!( + "config_file={server:?}\nrouting_log={:?}\n", + home.join("routing.jsonl") + ), + ) + .expect("settings"); + let mut controller = + Controller::new(Config::load(&settings).expect("settings"), settings.clone()); + let preview = |controller: &mut Controller| { + controller + .dispatch(Action::PreviewLaunch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: project.display().to_string(), + }) + .expect("preview") + }; + let launch = |token: Option, path: String| Action::Launch { + tool: Harness::CodexCli, + account: None, + route: "r".into(), + id: "public".into(), + project: path, + preview_token: token, + }; + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + for rejected in [ + launch(None, project.display().to_string()), + launch(Some("wrong".into()), project.display().to_string()), + launch(Some(token.clone()), home.display().to_string()), + ] { + assert!(controller.dispatch(rejected).is_err()); + } + fs::write(&server, format!("{text}# external edit\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + assert_eq!( + fs::read_to_string(&server).expect("preserved"), + format!("{text}# external edit\n") + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + let original = fs::read_to_string(&settings).expect("settings"); + fs::write(&settings, format!("{original}# external settings\n")).expect("edit"); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .is_err() + ); + let reviewed = preview(&mut controller); + let token = reviewed.data["preview_token"] + .as_str() + .expect("token") + .to_string(); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "changed", + ]); + assert!( + controller + .dispatch(launch(Some(token), project.display().to_string())) + .err() + .expect("changed HEAD") + .contains("project changed") + ); + assert!(!home.join(".switchyard/worktrees").exists()); + assert!(!home.join("terminal-called").exists()); + assert_eq!(fs::read_dir(&home).expect("files").count(), 4); + return; + } + let home = tempfile::tempdir().expect("fixture"); + let bin = home.path().join("bin"); + fs::create_dir(&bin).expect("bin"); + for name in ["codex", "open", "osascript"] { + let path = bin.join(name); + fs::write( + &path, + "#!/bin/sh\ntouch \"$HOME/terminal-called\"\nexit 0\n", + ) + .expect("stub"); + fs::set_permissions(path, fs::Permissions::from_mode(0o755)).expect("executable"); + } + let output=Command::new(std::env::current_exe().expect("test binary")) + .args(["--exact", "controller::tests::session_launch_requires_an_unchanged_review_before_creating_files"]) + .env("SWITCHYARD_LAUNCH_REVIEW_FIXTURE",home.path()).env("HOME",home.path()) + .env("PATH",format!("{}:/usr/bin:/bin",bin.display())).output().expect("child"); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } #[test] // An algorithm switch must not give stale choices a revision for externally edited bytes. fn algorithm_switches_keep_the_first_revision_and_external_edits_reject_it() { diff --git a/crates/switchyard-desktop/src/harness.rs b/crates/switchyard-desktop/src/harness.rs index 40fd320a3f..97e38b7167 100644 --- a/crates/switchyard-desktop/src/harness.rs +++ b/crates/switchyard-desktop/src/harness.rs @@ -528,7 +528,6 @@ fn configured( Ok(incoming) } -#[cfg(test)] pub fn install( tool: Harness, files: &[PathBuf], diff --git a/crates/switchyard-desktop/src/sessions.rs b/crates/switchyard-desktop/src/sessions.rs index ebdd585853..076d0c32b5 100644 --- a/crates/switchyard-desktop/src/sessions.rs +++ b/crates/switchyard-desktop/src/sessions.rs @@ -1,6 +1,831 @@ // 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()) +} + +pub fn launch( + tool: Harness, + project: &Path, + model: &str, + root: &str, + login: bool, + account: Option<&Path>, + revision: &str, +) -> Result { + let root = harness::validate(tool, root, model, login)?; + if let Some(account) = account { + crate::accounts::validate(tool, account)?; + } + if tool == Harness::CodexApp { + return Err("Choose Codex CLI to launch a worktree session. \ + Codex app uses its own workspace controls." + .into()); + } + let binary = harness::binary(tool).ok_or("Install the selected harness first.")?; + if project.as_os_str().is_empty() { + return Err("Choose a Git checkout directory.".into()); + } + let project = crate::config::expand_home(project)?; + let project = std::fs::canonicalize(&project).map_err(|e| format!("Open project: {e}"))?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["rev-parse", "--show-toplevel"]) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err("Choose a Git checkout directory.".into()); + } + let repository = Path::new( + std::str::from_utf8(&output.stdout) + .map_err(|error| error.to_string())? + .trim(), + ) + .to_path_buf(); + let repository = std::fs::canonicalize(repository).map_err(|error| error.to_string())?; + let relative = project + .strip_prefix(&repository) + .map_err(|error| error.to_string())? + .to_path_buf(); + let id = format!("sy-{}", uuid::Uuid::now_v7()); + // Path resolution must succeed before launch creates directories or a worktree. + let default_files = harness::paths(tool)?; + let worktree = session_root()?.join(&id); + std::fs::create_dir_all( + worktree + .parent() + .ok_or("Worktree needs a parent directory")?, + ) + .map_err(|e| e.to_string())?; + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(["worktree", "add", "-b"]) + .arg(&id) + .arg(&worktree) + .arg(revision) + .output() + .map_err(|e| e.to_string())?; + if !output.status.success() { + return Err(format!( + "Could not create worktree: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + let private = worktree + .parent() + .ok_or("Worktree needs a parent directory")? + .join(format!("{id}-settings")); + let mut pending = PendingSession { + project, + worktree: worktree.clone(), + private: private.clone(), + branch: id.clone(), + revision: revision.into(), + opened: false, + }; + let files = match tool { + Harness::Pi => vec![private.join("models.json"), private.join("settings.json")], + _ => vec![private.join("settings.json")], + }; + std::fs::create_dir_all(&private).map_err(|error| error.to_string())?; + if matches!(tool, Harness::Claude | Harness::Pi) { + harness::install(tool, &files, &root, model, None, login)?; + } + let receipt = serde_json::json!({"id":id,"tool":tool,"route":model,"repository":repository,"revision":preview(&worktree)?["revision"],"worktree":worktree,"branch":id,"created_at":chrono::Utc::now().to_rfc3339(),"settings_directory":private,"account_directory":account}); + std::fs::write( + private.join("session.json"), + serde_json::to_vec_pretty(&receipt).map_err(|error| error.to_string())?, + ) + .map_err(|error| error.to_string())?; + let account = account.or_else(|| match tool { + Harness::Claude if std::env::var_os("CLAUDE_CONFIG_DIR").is_none() => None, + _ => default_files[0].parent(), + }); + let codex_args = [ + "-c".to_string(), + format!("model={}", toml_edit::Value::from(model)), + "-c".into(), + "model_provider=\"sy\"".into(), + "-c".into(), + format!( + "model_providers.sy={{name=\"Switchyard\",base_url={},wire_api=\"responses\",\ + requires_openai_auth={login},http_headers={{\"x-switchyard-session-id\"={},\ + \"x-switchyard-origin\"=\"codex\"}}}}", + toml_edit::Value::from(format!("{}/v1", root.trim_end_matches('/'))), + toml_edit::Value::from(id.as_str()) + ), + ]; + #[cfg(windows)] + { + let mut environment = Vec::new(); + let mut remove_environment = Vec::new(); + let args = match tool { + Harness::CodexCli => codex_args.into(), + Harness::Claude => { + environment.push(( + "ANTHROPIC_CUSTOM_HEADERS".into(), + format!("x-switchyard-session-id: {id}\nx-switchyard-origin: claude"), + )); + if login { + remove_environment.extend( + [ + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", + ] + .map(String::from), + ); + } + vec![ + "--settings".into(), + files[0].display().to_string(), + "--model".into(), + model.into(), + ] + } + Harness::Pi => { + environment.push(("PI_CODING_AGENT_DIR".into(), private.display().to_string())); + vec![ + "--provider".into(), + "switchyard".into(), + "--model".into(), + model.into(), + ] + } + Harness::CodexApp => return Err("Choose a CLI harness.".into()), + }; + if let Some(path) = account { + match tool { + Harness::CodexCli => { + environment.push(("CODEX_HOME".into(), path.display().to_string())) + } + Harness::Claude => { + environment.push(("CLAUDE_CONFIG_DIR".into(), path.display().to_string())) + } + _ => {} + } + } + crate::windows::open_terminal(crate::windows::TerminalJob { + binary, + args, + directory: Some(worktree.join(&relative)), + environment, + remove_environment, + })?; + } + #[cfg(not(windows))] + { + let program = quote(&binary.display().to_string()); + let command = match tool { + Harness::CodexCli => format!( + "{program} {}", + codex_args + .iter() + .map(|arg| quote(arg)) + .collect::>() + .join(" ") + ), + Harness::Claude => format!( + "env ANTHROPIC_CUSTOM_HEADERS={} {program} --settings {} --model {}", + quote(&format!( + "x-switchyard-session-id: {id}\nx-switchyard-origin: claude" + )), + quote(&files[0].display().to_string()), + quote(model) + ), + Harness::Pi => format!( + "env PI_CODING_AGENT_DIR={} {program} --provider switchyard --model {}", + quote(&private.display().to_string()), + quote(model) + ), + Harness::CodexApp => return Err("Choose a CLI harness.".into()), + }; + let account_env = match (tool, account) { + (Harness::CodexCli, Some(path)) => { + format!("export CODEX_HOME={}\n", quote(&path.display().to_string())) + } + (Harness::Claude, Some(path)) => format!( + "export CLAUDE_CONFIG_DIR={}\n", + quote(&path.display().to_string()) + ), + _ => String::new(), + }; + let subscription_env = if tool == Harness::Claude && login { + "unset ANTHROPIC_AUTH_TOKEN ANTHROPIC_API_KEY CLAUDE_CODE_OAUTH_TOKEN\n" + } else { + "" + }; + let script = format!( + "#!/bin/bash\n\ + export PATH=\"$HOME/.cargo/bin:/opt/homebrew/bin:/usr/local/bin:$PATH\"\n\ + rm -- \"$0\"\n\ + cd {} || exit 1\n\ + {account_env}{subscription_env}{command}", + quote(&worktree.join(relative).display().to_string()) + ); + crate::server::open_terminal_script("switchyard-session-", &script)?; + } + pending.opened = true; + Ok(format!( + "Opened Terminal to start the session.\n\ + Worktree: {}\n\ + Branch: {id}\n\ + Model route: {model}\n\ + Remove the worktree with git worktree remove after saving or discarding its changes.", + worktree.display() + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn failed_session_setup_removes_its_worktree_branch_and_private_settings() { + let dir = tempfile::tempdir().expect("directory"); + let project = dir.path().join("project"); + std::fs::create_dir(&project).expect("project"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + output + }; + git(&["init"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ]); + let worktree = dir.path().join("worktree"); + let private = dir.path().join("settings"); + git(&[ + "worktree", + "add", + "-b", + "session-fixture", + worktree.to_str().expect("path"), + ]); + std::fs::create_dir(&private).expect("private"); + std::fs::write(private.join("settings.json"), "{}").expect("settings"); + let revision = String::from_utf8_lossy(&git(&["rev-parse", "HEAD"]).stdout) + .trim() + .to_string(); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the session branch. + git(&["checkout", "--orphan", "unrelated"]); + git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + drop(PendingSession { + project: project.clone(), + worktree: worktree.clone(), + private: private.clone(), + branch: "session-fixture".into(), + revision, + opened: false, + }); + assert!(!worktree.exists()); + assert!(!private.exists()); + assert!( + !String::from_utf8_lossy(&git(&["branch", "--list", "session-fixture"]).stdout) + .contains("session-fixture") + ); + } + // The subprocess isolates HOME and PATH. + // The Terminal stub injects a conflicting directory when the script must select one. + // Claude's default case requires an unset CLAUDE_CONFIG_DIR, not an explicit ~/.claude. + #[cfg(unix)] + #[test] + fn generated_sessions_use_explicit_accounts_without_changing_user_defaults() { + use std::os::unix::fs::PermissionsExt; + if let Ok(fixture) = std::env::var("SWITCHYARD_SESSION_FIXTURE") { + let fixture = std::path::PathBuf::from(fixture); + let tool = if std::env::var("SWITCHYARD_SESSION_TOOL").expect("tool") == "codex" { + Harness::CodexCli + } else { + Harness::Claude + }; + let account = + std::env::var_os("SWITCHYARD_SESSION_ACCOUNT").map(std::path::PathBuf::from); + let revision = preview(&fixture.join("project")).expect("preview")["revision"] + .as_str() + .expect("revision") + .to_string(); + // Advancing the original checkout makes a launch from HEAD differ from the review. + if tool == Harness::CodexCli && account.is_none() { + let committed = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args([ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "advanced", + ]) + .output() + .expect("advance HEAD"); + assert!(committed.status.success()); + } + launch( + tool, + &fixture.join("project"), + "route'$(literal)", + "http://localhost:4123", + tool == Harness::Claude, + account.as_deref(), + &revision, + ) + .expect("launch"); + if tool == Harness::CodexCli && account.is_none() { + let records = inventory().expect("inventory"); + assert_eq!(records.len(), 1); + assert_eq!(records[0]["revision"], revision); + assert_ne!( + preview(&fixture.join("project")).expect("advanced project")["revision"], + revision + ); + let id = records[0]["id"].as_str().expect("session id"); + let receipt = session_root() + .expect("session root") + .join(format!("{id}-settings/session.json")); + let original = std::fs::read(&receipt).expect("receipt"); + // Valid JSON roots must still be objects before inventory adds process_status. + for malformed in ["null", "[]", "false", "42"] { + std::fs::write(&receipt, malformed).expect("malformed receipt"); + assert!(inventory().is_err()); + assert!(cleanup_preview(id).is_err()); + } + std::fs::write(&receipt, original).expect("restore receipt"); + let clean = cleanup_preview(id).expect("preview cleanup"); + let worktree = Path::new(records[0]["worktree"].as_str().expect("worktree")); + let unsaved = worktree.join("unsaved-user-work.txt"); + std::fs::write(&unsaved, "keep this work").expect("user work"); + assert!(cleanup(id, &clean).is_err()); + assert!(unsaved.exists()); + let dirty = cleanup_preview(id).expect("dirty preview"); + assert!(cleanup(id, &dirty).is_err()); + assert!(worktree.exists()); + std::fs::remove_file(&unsaved).expect("save fixture work"); + let clean = cleanup_preview(id).expect("clean preview"); + // Cleanup deletes ignored files, so a new ignored file requires a new review. + let info = fixture.join("project/.git/info"); + std::fs::create_dir_all(&info).expect("git info"); + std::fs::write(info.join("exclude"), "ignored-notes.txt\n").expect("exclude"); + std::fs::write(worktree.join("ignored-notes.txt"), "notes").expect("notes"); + assert!(cleanup(id, &clean).is_err()); + assert!(worktree.join("ignored-notes.txt").exists()); + let reviewed = cleanup_preview(id).expect("ignored preview"); + assert_eq!( + reviewed["ignored"], + serde_json::json!(["ignored-notes.txt"]) + ); + // An unrelated HEAD in the original checkout makes `branch -d` refuse the branch. + let project_git = |args: &[&str]| { + let output = std::process::Command::new("git") + .arg("-C") + .arg(fixture.join("project")) + .args(args) + .output() + .expect("git"); + assert!(output.status.success()); + output + }; + project_git(&["checkout", "--orphan", "unrelated"]); + project_git(&[ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "unrelated", + ]); + cleanup(id, &reviewed).expect("safe cleanup"); + assert!(!worktree.exists()); + assert!(inventory().expect("inventory after cleanup").is_empty()); + assert!(project_git(&["branch", "--list", id]).stdout.is_empty()); + } + return; + } + for (name, named, inherited_dir) in [ + ("codex", false, true), + ("codex", true, true), + ("claude", false, true), + ("claude", true, true), + ("claude", false, false), + ("claude", true, false), + ] { + let fixture = tempfile::tempdir().expect("fixture"); + let home = fixture.path().join("home"); + let bin = fixture.path().join("bin"); + let project = fixture.path().join("project"); + for path in [&home, &bin, &project] { + std::fs::create_dir(path).expect("directory"); + } + for args in [ + vec!["init"], + vec![ + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "--allow-empty", + "-m", + "fixture", + ], + ] { + assert!( + std::process::Command::new("git") + .arg("-C") + .arg(&project) + .args(args) + .output() + .expect("git") + .status + .success() + ); + } + let default = home.join("default-login"); + std::fs::create_dir(&default).expect("default"); + std::fs::write(default.join("settings.json"), "{\"user\":true}").expect("settings"); + let account = home.join(".switchyard/accounts").join(name).join("named"); + if named { + std::fs::create_dir_all(&account).expect("account"); + } + let output = fixture.path().join("arguments"); + let captured = fixture.path().join("session.command"); + // macOS runs the script through `open`; Linux runs it through the program in + // TERMINAL, which receives `-e` before the script path. + let open = bin.join(if cfg!(target_os = "linux") { + "terminal" + } else { + "open" + }); + let terminal_claude = if inherited_dir || named { + "export CLAUDE_CONFIG_DIR=wrong-terminal-login" + } else { + "unset CLAUDE_CONFIG_DIR" + }; + std::fs::write( + &open, + format!( + "#!/bin/bash\n\ + [ \"$1\" = -e ] && shift\n\ + cp -- \"$1\" {}\n\ + export CODEX_HOME=wrong-terminal-login ANTHROPIC_AUTH_TOKEN=wrong-token\n\ + {terminal_claude}\n\ + bash \"$1\"\n", + quote(&captured.display().to_string()) + ), + ) + .expect("open"); + let tool = bin.join(name); + std::fs::write( + &tool, + format!( + "#!/bin/bash\n\ + printf '%s\\n' \"${{CODEX_HOME:-}}\" \"${{CLAUDE_CONFIG_DIR:-}}\" \ + \"${{ANTHROPIC_AUTH_TOKEN:-}}\" \"$@\" > {0}.partial && mv {0}.partial {0}\n", + quote(&output.display().to_string()) + ), + ) + .expect("tool"); + for path in [&open, &tool] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .expect("executable"); + } + let mut child = std::process::Command::new(std::env::current_exe().expect("runner")); + child + .args([ + "--exact", + "sessions::tests::\ + generated_sessions_use_explicit_accounts_without_changing_user_defaults", + ]) + .env("SWITCHYARD_SESSION_FIXTURE", fixture.path()) + .env("SWITCHYARD_SESSION_TOOL", name) + .env("HOME", &home) + .env("CODEX_HOME", &default) + .env("CLAUDE_CONFIG_DIR", &default) + .env("PATH", format!("{}:/usr/bin:/bin", bin.display())) + .env("TERMINAL", &open); + if named { + child.env("SWITCHYARD_SESSION_ACCOUNT", &account); + } + if !inherited_dir { + child.env_remove("CLAUDE_CONFIG_DIR"); + } + let result = child.output().expect("child"); + assert!( + result.status.success(), + "{}\n{}", + String::from_utf8_lossy(&result.stdout), + String::from_utf8_lossy(&result.stderr) + ); + // A Linux terminal starts apart from the launch, so the arguments can arrive + // after the child exits. The tool stub renames the file into place when done. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + while !output.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + let arguments = std::fs::read_to_string(output).expect("arguments"); + let lines: Vec<_> = arguments.lines().collect(); + let expected = if named { + account.to_str().expect("path") + } else if inherited_dir { + default.to_str().expect("path") + } else { + "" + }; + assert_eq!(lines[usize::from(name == "claude")], expected); + if name == "claude" { + assert_eq!(lines[2], ""); + } + assert!(arguments.contains("route'$(literal)")); + let script = std::fs::read_to_string(captured).expect("script"); + assert!(script.contains("x-switchyard-session-id")); + if name == "claude" && !named && !inherited_dir { + assert!(!script.contains("export CLAUDE_CONFIG_DIR=")); + } + assert_eq!( + std::fs::read_to_string(default.join("settings.json")).expect("settings"), + "{\"user\":true}" + ); + } + } +} diff --git a/crates/switchyard-desktop/src/tui.rs b/crates/switchyard-desktop/src/tui.rs index 6482553379..5c25e57c09 100644 --- a/crates/switchyard-desktop/src/tui.rs +++ b/crates/switchyard-desktop/src/tui.rs @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The terminal app shows usage, recent calls, routes, and harness settings from controller -//! snapshots, and it runs server, route, harness, and account actions. +//! The terminal app shows usage, recent calls, routes, harness settings, and worktree sessions +//! from controller snapshots, and it runs server, route, harness, account, and session actions. //! //! The UI thread owns every view setting. One worker thread owns the controller and runs //! refreshes and actions, so moving around and filtering never wait for the log or a service. @@ -54,7 +54,7 @@ const SPINNER: [char; 8] = ['⣷', '⣯', '⣟', '⡿', '⢿', '⣻', '⣽', ' type Key = (&'static str, &'static str, &'static str); const GLOBAL_KEYS: &[Key] = &[ ( - "1-6", + "1-7", "page", "Switch pages. Tab, Shift-Tab, ← and → also switch pages.", ), @@ -114,8 +114,8 @@ const HARNESSES_KEYS: &[Key] = &[ ( "↑↓", "route", - "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes page \ - selects the same route, and its filter limits the choices.", + "Choose the route to install. j, k, PgUp, PgDn, g, and G also move it. The Routes and \ + Sessions pages select the same route, and the Routes filter limits the choices.", ), ("t", "harness", "Select the next harness."), ( @@ -131,6 +131,29 @@ const HARNESSES_KEYS: &[Key] = &[ detected settings again.", ), ]; +const SESSIONS_KEYS: &[Key] = &[ + ( + "↑↓", + "route", + "Choose the route for a new session. j, k, PgUp, PgDn, g, and G also move it. The \ + Routes and Harnesses pages select the same route.", + ), + ( + "t", + "harness", + "Select the next harness for a new session. The Harnesses page selects the same one.", + ), + ( + "a", + "account", + "Select the next saved account for a new session, then the harness's own login again.", + ), + ( + "s", + "session", + "Select the next recorded session. The open and remove keys use the selection.", + ), +]; const SETTINGS_KEYS: &[Key] = &[ ("↑↓", "select", "Select an action. j, g, and G also move."), ( @@ -285,6 +308,37 @@ const ACTIONS: &[ActionRow] = &[ ActionKind::ArchiveAccount, ], ), + ( + Tab::Sessions, + 'l', + "launch", + "Asks for a Git project folder and shows the session review. After you type LAUNCH, \ + it creates a worktree from committed HEAD and opens the harness there in a new \ + terminal window.", + &[ActionKind::PreviewLaunch, ActionKind::Launch], + ), + ( + Tab::Sessions, + 'n', + "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', @@ -413,6 +467,15 @@ const HARNESS_COLUMNS: [(Column, bool); 4] = [ (("CURRENT SETTINGS", Constraint::Fill(3), false), true), (("ACCOUNTS", Constraint::Length(8), true), false), ]; +/// The sessions table drops the columns marked `true` below 100 columns; the details still +/// show them. +const SESSION_COLUMNS: [(Column, bool); 5] = [ + (("SESSION", Constraint::Length(39), false), false), + (("REPOSITORY", Constraint::Fill(2), false), true), + (("ROUTE", Constraint::Fill(1), false), false), + (("CREATED", Constraint::Length(8), true), true), + (("LAST CALL", Constraint::Length(9), true), false), +]; /// Theme maps each kind of text to one style. NO_COLOR keeps only bold, dim, and reverse. #[derive(Clone, Copy)] @@ -467,14 +530,16 @@ enum Tab { Models, Routes, Harnesses, + Sessions, Settings, } -const TABS: [Tab; 6] = [ +const TABS: [Tab; 7] = [ Tab::Overview, Tab::Calls, Tab::Models, Tab::Routes, Tab::Harnesses, + Tab::Sessions, Tab::Settings, ]; impl Tab { @@ -485,6 +550,7 @@ impl Tab { Tab::Models => "Models", Tab::Routes => "Routes", Tab::Harnesses => "Harnesses", + Tab::Sessions => "Sessions", Tab::Settings => "Settings", } } @@ -585,6 +651,13 @@ enum FormKind { /// The harness is the one selected when the form opened. Account(Harness), SettingsFile, + /// The harness, account, and route are the ones selected when the form opened. + Project { + tool: Harness, + account: Option, + route: String, + id: String, + }, } /// Choices names the list that ↑ and ↓ cycle through in a form field. enum Choices { @@ -873,6 +946,18 @@ impl Form { name: self.value(0), }, FormKind::SettingsFile => return Ok(Submit::SettingsFile(self.value(0))), + FormKind::Project { + tool, + account, + route, + id, + } => Action::PreviewLaunch { + tool: *tool, + account: account.clone(), + route: route.clone(), + id: id.clone(), + project: self.value(0), + }, })) } fn label(&self) -> &'static str { @@ -892,6 +977,8 @@ impl Form { 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", @@ -899,6 +986,7 @@ impl Form { FormKind::ModelKey => "Saving the key and loading models", FormKind::Account(_) => "Adding the account", FormKind::SettingsFile => "Choosing the settings file", + FormKind::Project { .. } => "Reviewing the session", } } } @@ -1033,6 +1121,21 @@ fn reviewed(previewed: Action, preview: &Value) -> Option { name, preview_token, }, + Action::PreviewLaunch { + tool, + account, + route, + id, + project, + } => Action::Launch { + preview_token, + tool, + account, + route, + id, + project, + }, + Action::PreviewSessionCleanup { id } => Action::RemoveSession { id, preview_token }, _ => return None, }) } @@ -1163,7 +1266,8 @@ impl Tone { } } -/// Destination is where the Harnesses page installs and restores settings. +/// Destination is where the Harnesses page installs and restores settings. The Sessions page +/// launches with its account, and with the harness's own login otherwise. enum Destination { /// The harness's own settings files. Harness, @@ -1199,6 +1303,10 @@ struct App { /// This field holds the last install preview and the action that produced it. The /// Harnesses page shows it only while that action matches the selection. install_preview: Option<(Action, Value)>, + /// This field indexes `data.session_inventory`, newest first. It starts at the first + /// session, and it is `None` after a refresh stops listing the selected session, so no + /// action reaches another session. + managed: Option, /// This field holds the last `ServerKeys` reply. It is `None` until the user checks the keys, /// and a failed check clears it so the page never shows outdated key states. server_keys: Option, @@ -1233,6 +1341,7 @@ impl App { harness: 0, destination: Destination::Harness, install_preview: None, + managed: Some(0), server_keys: None, popup: None, toast: None, @@ -1257,7 +1366,15 @@ impl App { .filter(|_| self.selected > 0) .map(|entry| (entry.ts.clone(), entry.model.clone(), entry.turn_id.clone())); let route = self.selected_route_key(); + let managed = self.session_id().ok(); self.data = data; + if let Some(id) = managed { + self.managed = self + .data + .session_inventory + .iter() + .position(|receipt| receipt["id"] == id); + } self.refreshed = Some(Instant::now()); self.rebuild_calls(); self.rebuild_routes(route); @@ -1415,6 +1532,18 @@ impl App { current.then_some(preview) } + fn selected_session(&self) -> Option<&Value> { + self.data.session_inventory.get(self.managed?) + } + + /// This function returns the ID of the session that the open and remove keys use. + fn session_id(&self) -> Result { + self.selected_session() + .and_then(|receipt| receipt["id"].as_str()) + .map(str::to_owned) + .ok_or_else(|| "Press s to select a recorded session first.".into()) + } + /// Short messages appear in the status bar; long ones open a window so nothing is cut off. fn notify(&mut self, text: String, tone: Tone) { if text.is_empty() { @@ -1567,6 +1696,32 @@ impl App { )); } } + // Like a route review, the launch review replaces the project form that asked for + // it, so a form that the user closed meanwhile stays closed. + (Some(ActionKind::PreviewLaunch), Ok(reply)) => { + if matches!(self.popup, Some(Popup::Form(_))) + && let Some(launch) = action.and_then(|action| reviewed(action, &reply.data)) + { + self.popup = Some(Popup::review( + launch_lines(self.theme, &reply.data), + "Launch", + FormKind::Confirm("LAUNCH".into(), Box::new(launch)), + "Start this session?", + "Type LAUNCH to start it", + )); + } + } + (Some(ActionKind::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() @@ -1677,6 +1832,7 @@ impl App { (None, false, Tab::Models) => MODELS_KEYS, (None, false, Tab::Routes) => ROUTES_KEYS, (None, false, Tab::Harnesses) => HARNESSES_KEYS, + (None, false, Tab::Sessions) => SESSIONS_KEYS, (None, false, Tab::Settings) => SETTINGS_KEYS, } } @@ -1726,7 +1882,7 @@ impl App { match key.code { KeyCode::Char('q') => self.quit = true, KeyCode::Char('?') => self.popup = Some(Popup::Help { scroll: 0 }), - KeyCode::Char(digit @ '1'..='6') => { + KeyCode::Char(digit @ '1'..='7') => { self.tab = TABS[digit as usize - '1' as usize]; } KeyCode::Tab | KeyCode::Right => self.tab = TABS[(self.tab.index() + 1) % TABS.len()], @@ -1746,7 +1902,11 @@ impl App { self.route_filter.clear(); self.refilter(); } - Tab::Overview | Tab::Models | Tab::Harnesses | Tab::Settings => {} + Tab::Overview + | Tab::Models + | Tab::Harnesses + | Tab::Sessions + | Tab::Settings => {} } } code => { @@ -1771,6 +1931,7 @@ impl App { }, Tab::Routes => self.routes_key(code), Tab::Harnesses => self.harnesses_key(code), + Tab::Sessions => self.sessions_key(code), Tab::Settings => return self.settings_key(code), } } @@ -1816,8 +1977,8 @@ impl App { /// The arrows choose the route, which is the longer list; `t` cycles through the few /// harnesses. A new harness starts at its own settings, because accounts belong to one - /// harness. - fn harnesses_key(&mut self, code: KeyCode) { + /// harness. The Harnesses and Sessions pages share these keys and the selection. + fn selection_key(&mut self, code: KeyCode) { let last = self.route_visible.len().saturating_sub(1); if let Some(selected) = step(code, self.route_selected, last) { self.route_selected = selected; @@ -1838,20 +1999,34 @@ impl App { let next = names.get(current.map_or(0, |index| index + 1)).cloned(); self.destination = next.map_or(Destination::Harness, Destination::Account); } - KeyCode::Char('x') => { - self.popup = Some(Popup::Form(Form::new( - FormKind::SettingsFile, - "Custom settings file · leave empty to use the detected settings", - vec![( - "Absolute file path (Pi: models.json)", - self.custom_file().unwrap_or_default(), - )], - ))); - } _ => {} } } + fn harnesses_key(&mut self, code: KeyCode) { + if code != KeyCode::Char('x') { + return self.selection_key(code); + } + self.popup = Some(Popup::Form(Form::new( + FormKind::SettingsFile, + "Custom settings file · leave empty to use the detected settings", + vec![( + "Absolute file path (Pi: models.json)", + self.custom_file().unwrap_or_default(), + )], + ))); + } + + /// The `s` key selects the next recorded session. Without a selection, it selects the + /// newest one. + fn sessions_key(&mut self, code: KeyCode) { + if code != KeyCode::Char('s') { + return self.selection_key(code); + } + let count = self.data.session_inventory.len().max(1); + self.managed = Some(self.managed.map_or(0, |index| (index + 1) % count)); + } + fn filter_key(&mut self, key: KeyEvent) { let control = key.modifiers.contains(KeyModifiers::CONTROL); match key.code { @@ -2064,6 +2239,39 @@ impl App { let archive = Action::PreviewAccountArchive { tool, name }; request(archive, "Reviewing the account archive") } + // The project form submits PreviewLaunch, and the review window submits Launch. + ActionKind::PreviewLaunch | ActionKind::Launch => { + let harness = self.harness()?; + let route = self.selected_route()?; + let title = format!( + "Launch a worktree session · {} · {}", + harness.label, route.label + ); + let kind = FormKind::Project { + tool: harness.tool, + account: self.account(), + route: route.key.clone(), + id: route.id.clone(), + }; + self.open(Form::new( + kind, + title, + vec![("Git project folder", String::new())], + )) + } + // 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") @@ -2331,6 +2539,22 @@ fn age(ts: &str, now: DateTime) -> String { } } +/// This function shows a recorded time in local time with its age, or the recorded text when it +/// is not an RFC 3339 time. +fn local_time(ts: &str) -> String { + DateTime::parse_from_rfc3339(ts).map_or_else( + |_| ts.to_string(), + |time| { + format!( + "{} ({})", + time.with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M:%S"), + age(ts, Utc::now()) + ) + }, + ) +} + fn server_state(theme: Theme, running: bool) -> [Span<'static>; 2] { [ Span::styled("● ", if running { theme.ok } else { theme.error }), @@ -2410,6 +2634,10 @@ fn draw(frame: &mut Frame, app: &mut App) { skipped: _, // calls_block errors: _, // draw_errors and draw_settings refresh_seconds: _, // App::refresh_due + // draw_session_table lists the inventory or shows the error that stopped reading it, + // and session_lines shows the selected session. + session_inventory: _, + session_inventory_error: _, } = &app.data; let area = frame.area(); app.width = area.width; @@ -2430,6 +2658,7 @@ fn draw(frame: &mut Frame, app: &mut App) { Tab::Models => draw_models(frame, app, body), Tab::Routes => draw_routes(frame, app, body), Tab::Harnesses => draw_harnesses(frame, app, body), + Tab::Sessions => draw_sessions(frame, app, body), Tab::Settings => draw_settings(frame, app, body), } draw_status(frame, app, status); @@ -2994,24 +3223,13 @@ fn draw_call_table(frame: &mut Frame, app: &mut App, area: Rect) { fn detail_lines(theme: Theme, entry: &Entry) -> Vec> { let usage = ModelTokens::from(entry); let field = |name: &str, value: String| labeled(theme, name, 9, value); - let time = DateTime::parse_from_rfc3339(&entry.ts).map_or_else( - |_| entry.ts.clone(), - |time| { - format!( - "{} ({})", - time.with_timezone(&chrono::Local) - .format("%Y-%m-%d %H:%M:%S"), - age(&entry.ts, Utc::now()) - ) - }, - ); let kind = if entry.tier == CLASSIFIER_TIER { "Routing call (classifier or judge)" } else { "Answer call" }; let mut lines = vec![ - field("Time", time), + field("Time", local_time(&entry.ts)), field("Model", entry.model.clone()), field("Kind", kind.into()), field("Route", entry.route_id.clone()), @@ -3658,6 +3876,211 @@ fn harness_lines(app: &App, harness: &ToolInfo) -> Vec> { lines } +fn draw_sessions(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let launch = Paragraph::new(new_session_lines(app)).wrap(Wrap { trim: false }); + // The borders take two rows. + let rows = launch.line_count(area.width.saturating_sub(4)) as u16 + 2; + let [launch_area, inventory] = Layout::vertical([ + Constraint::Length(rows.min(area.height / 2)), + Constraint::Fill(1), + ]) + .areas(area); + frame.render_widget( + launch.block(block(theme, "New session").padding(Padding::horizontal(1))), + launch_area, + ); + let Some(receipt) = app.selected_session() else { + draw_session_table(frame, app, inventory); + return; + }; + let detail = Paragraph::new(session_lines(app, receipt)).wrap(Wrap { trim: false }); + // The details go below the list at full width, because worktree paths are long. The list + // keeps its borders, header, and the selected row; the details get the rest. + let rows = detail.line_count(inventory.width.saturating_sub(4)) as u16 + 2; + let [list, detail_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(rows.min(inventory.height.saturating_sub(4))), + ]) + .areas(inventory); + draw_session_table(frame, app, list); + let title = text(receipt, "id"); + frame.render_widget( + detail.block(block(theme, &title).padding(Padding::horizontal(1))), + detail_area, + ); +} + +fn draw_session_table(frame: &mut Frame, app: &App, area: Rect) { + let theme = app.theme; + let sessions = &app.data.session_inventory; + let outer = block(theme, &format!("Recorded sessions [{}]", sessions.len())); + // A failed read lists no sessions, so its error replaces the empty-list text. + let empty = match &app.data.session_inventory_error { + Some(error) => Some(Line::styled(error.clone(), theme.error)), + None if sessions.is_empty() => Some(Line::styled( + "Switchyard has not recorded any managed sessions.", + theme.muted, + )), + None => None, + }; + if let Some(line) = empty { + let paragraph = Paragraph::new(line).centered().wrap(Wrap { trim: false }); + frame.render_widget(paragraph.block(outer), area); + return; + } + let wide = area.width >= 100; + let shown = |column: &(Column, bool)| wide || !column.1; + let now = Utc::now(); + let rows: Vec = sessions + .iter() + .map(|receipt| { + let id = text(receipt, "id"); + let last = last_call(&app.data, &id).map_or_else(|| "—".into(), |e| age(&e.ts, now)); + let created = age(&text(receipt, "created_at"), now); + let cells = [ + Cell::from(id), + Cell::from(Span::styled(text(receipt, "repository"), theme.muted)), + Cell::from(text(receipt, "route")), + right(Span::styled(created, theme.muted)), + right(last), + ]; + Row::new( + cells + .into_iter() + .zip(&SESSION_COLUMNS) + .filter(|(_, column)| shown(column)) + .map(|(cell, _)| cell), + ) + }) + .collect(); + let columns: Vec = SESSION_COLUMNS + .iter() + .filter(|column| shown(column)) + .map(|column| column.0) + .collect(); + let selected = app.managed.filter(|&index| index < sessions.len()); + let mut state = TableState::default().with_selected(selected); + frame.render_stateful_widget( + table(theme, &columns, rows) + .row_highlight_style(theme.selected) + .highlight_symbol("› ") + .block(outer), + area, + &mut state, + ); +} + +/// This function shows what the l key launches: the harness, the account, the route, and +/// whether the harness can use the route. +fn new_session_lines(app: &App) -> Vec> { + const WIDTH: usize = 9; + let theme = app.theme; + let field = |name: &str, value: Span<'static>| { + Line::from(vec![ + Span::styled(format!("{name: { + let (state, style) = if harness.available { + ("Detected", theme.ok) + } else { + ("Not detected", theme.warn) + }; + lines.push(Line::from(vec![ + Span::styled(format!("{: { + match harness.account_details.iter().find(|a| a.name == *name) { + Some(account) => Span::raw(format!( + "{name} · login status {} · {}", + account.login_status, + account.path.display() + )), + None => Span::styled( + format!("{name} is no longer listed. Press a to choose another."), + theme.warn, + ), + } + } + Destination::Harness | Destination::File(_) => Span::raw("Current login"), + }; + lines.push(field("Account", account)); + match app.selected_route() { + Ok(route) => { + lines.push(field("Route", Span::raw(route.label.clone()))); + match route.compatibility.get(&harness.tool) { + Some(Some(reason)) => lines.push(Line::styled(reason.clone(), theme.warn)), + Some(None) => { + lines.push(Line::styled("This harness can use the route.", theme.ok)); + } + None => {} + } + } + Err(error) => lines.push(field("Route", Span::styled(error, theme.warn))), + } + } + Err(error) => lines.push(Line::styled(error, theme.warn)), + } + lines.extend([ + Line::default(), + Line::styled( + "Each session is a new Git worktree from the project's committed HEAD, on its own \ + branch. Uncommitted changes stay in the original checkout.", + theme.muted, + ), + ]); + lines +} + +/// This function shows where the selected session lives and its newest recorded call. +fn session_lines(app: &App, receipt: &Value) -> Vec> { + let theme = app.theme; + let field = |name: &str, value: String| labeled(theme, name, 11, value); + let last = last_call(&app.data, receipt["id"].as_str().unwrap_or_default()); + vec![ + field("Repository", text(receipt, "repository")), + field("Branch", text(receipt, "branch")), + field("Route", text(receipt, "route")), + field("Worktree", text(receipt, "worktree")), + field("Created", local_time(&text(receipt, "created_at"))), + field( + "Last call", + last.map_or_else( + || "No recent recorded call".into(), + |entry| local_time(&entry.ts), + ), + ), + Line::default(), + Line::styled( + "Recorded calls do not establish that a process is running.", + theme.muted, + ), + ] +} + +/// This function returns the session's newest call among the calls that the app loaded. +fn last_call<'a>(data: &'a Snapshot, id: &str) -> Option<&'a Entry> { + let session = Some(id); + data.entries + .iter() + .rev() + .find(|entry| entry.session_id.as_deref() == session) +} + /// This function lists the server key variables from the last `ServerKeys` reply. fn key_lines(theme: Theme, keys: Option<&Value>) -> Vec> { let Some(data) = keys else { @@ -4142,6 +4565,14 @@ fn form_notes(app: &App, form: &Form) -> Vec> { ), Line::default(), ], + FormKind::Project { .. } => vec![ + Line::from( + "Switchyard creates a Git worktree from the folder's committed HEAD and opens \ + the harness there in a new terminal window. Uncommitted changes stay in the \ + original checkout. The next step shows the review.", + ), + Line::default(), + ], FormKind::Preferences | FormKind::Update(_) | FormKind::Confirm(..) @@ -4301,6 +4732,99 @@ fn archive_lines(theme: Theme, preview: &Value) -> Vec> { ] } +/// This function returns a text field of a controller reply, or `fallback` when it is empty +/// or missing. +fn text_or(value: &Value, key: &str, fallback: &str) -> String { + match value[key].as_str() { + Some(text) if !text.is_empty() => text.into(), + _ => fallback.into(), + } +} + +/// This function shows a launch preview: where the session starts, what it uses, and what +/// stays in the original checkout. +fn launch_lines(theme: Theme, preview: &Value) -> Vec> { + let field = |name: &str, value: String| labeled(theme, name, 17, value); + let folder = text(preview, "relative_directory"); + let folder = if folder.is_empty() { + "New worktree root".into() + } else { + format!("{folder} within the new worktree") + }; + let mut lines = vec![ + field("Repository", text(preview, "repository")), + field("Starting folder", folder), + field("Branch", text_or(preview, "branch", "Detached HEAD")), + field("Revision", text(preview, "revision")), + field("Route", text(preview, "route")), + field( + "Account", + text_or(preview, "account_directory", "Current login"), + ), + Line::default(), + ]; + if preview["dirty"] == true { + lines.push(Line::styled( + "Your checkout has uncommitted changes.", + theme.warn, + )); + } + lines.push(Line::from(text(preview, "notice"))); + let settings = text(preview, "settings_notice"); + if !settings.is_empty() { + lines.extend([Line::default(), Line::from(settings)]); + } + lines +} + +/// 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| { @@ -4353,6 +4877,7 @@ fn help_lines(theme: Theme, width: usize) -> Vec> { ("Models", MODELS_KEYS, Some(Tab::Models)), ("Routes", ROUTES_KEYS, Some(Tab::Routes)), ("Harnesses", HARNESSES_KEYS, Some(Tab::Harnesses)), + ("Sessions", SESSIONS_KEYS, Some(Tab::Sessions)), ("Settings", SETTINGS_KEYS, Some(Tab::Settings)), ] { if !lines.is_empty() { @@ -4720,6 +5245,8 @@ mod tests { Tab::Models => &['p', '[', ']'], Tab::Routes => &['/'], Tab::Harnesses => &['t', 'a', 'x'], + // The arrows choose the route here, so k must keep moving it. + Tab::Sessions => &['t', 'a', 's', 'k'], Tab::Overview | Tab::Settings => &[], } }; @@ -5029,4 +5556,43 @@ mod tests { let installed = std::fs::read_to_string(&custom).expect("custom"); assert!(installed.contains("public"), "{installed}"); } + + #[test] + // The open and remove keys must reach only the session that the user selected. A refresh + // keeps that session selected while it is listed and selects none after it leaves. + fn session_actions_reach_only_the_selected_session() { + let listed = |ids: &[&str]| Snapshot { + session_inventory: ids.iter().map(|id| json!({"id": id})).collect(), + ..Snapshot::default() + }; + let open = |app: &mut App| match press(app, KeyCode::Char('o')) { + Some(Request { + job: Job::Run(Action::OpenSession { id }), + .. + }) => Some(id), + _ => None, + }; + let mut app = App::new(Theme::new(false), listed(&["b", "a"])); + app.tab = Tab::Sessions; + press(&mut app, KeyCode::Char('s')); + assert_eq!(open(&mut app).as_deref(), Some("a")); + app.apply_snapshot(Ok(listed(&["c", "b", "a"]))); + assert_eq!(open(&mut app).as_deref(), Some("a")); + app.apply_snapshot(Ok(listed(&["c", "b"]))); + assert_eq!(open(&mut app), None); + assert!(press(&mut app, KeyCode::Char('d')).is_none()); + press(&mut app, KeyCode::Char('s')); + assert_eq!(open(&mut app).as_deref(), Some("c")); + } + + #[test] + // Cleanup deletes every ignored file, so a review that names only 20 must count the rest. + fn cleanup_review_counts_the_ignored_files_it_does_not_name() { + let ignored: Vec = (1..=25).map(|n| format!("build/{n:02}.log")).collect(); + let lines = cleanup_lines(Theme::new(false), &json!({"ignored": ignored})); + let text: Vec = lines.iter().map(ToString::to_string).collect(); + assert!(text.iter().any(|line| line == "build/20.log")); + assert!(!text.iter().any(|line| line == "build/21.log")); + assert!(text.iter().any(|line| line == "…and 5 more"), "{text:?}"); + } }