From f6f935cb82dec3fb7f708093367fdcffae6190ed Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:25:17 +0800 Subject: [PATCH 1/4] docs: add CLAUDE.md project guide Generated via /init: common build/test commands, the layered crate architecture, the provider contract, and the sentinel vs HYARD delegation protocols. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..4e7a646 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,129 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What Switchyard is + +A local desktop workbench that drives the AI coding CLIs already installed and +logged in on the user's machine (`codex`, `claude`, `gemini`, `agy`). Switchyard +provides **no model accounts** and does not host code or count tokens — model +capability, auth, billing, and context-window/token budgeting all belong to each +provider CLI. Switchyard owns local concerns: routing turns, the canonical +session store, local diffs, artifacts, permissions, and peer delegation. + +## Toolchain (Windows MSVC baseline) + +- Dev baseline is `stable-x86_64-pc-windows-msvc` (VS 2019 Build Tools). GNU is + **not** a supported target — do not assume `x86_64-pc-windows-gnu` works. +- In the **Bash tool**, cargo may not be on `PATH`; prefix sessions with + `export PATH="$HOME/.cargo/bin:$PATH"`. PowerShell generally has it already. +- The repo-root `cargo` (bash) / `cargo.cmd` wrappers rewrite a + `--target-dir target-*` arg or `CARGO_TARGET_DIR=target-*` to live under + `target/` so parallel builds don't clobber the default target dir. Plain + `cargo` (the real one on PATH) is fine for normal work. + +## Common commands + +Rust workspace (run from repo root): + +```bash +cargo build --workspace +cargo test --workspace --all-targets # full suite +cargo test -p switchyard-tests --test integration_e2e # one integration file +cargo test -p switchyard-core router:: # filter by name +cargo fmt --all -- --check +cargo clippy --workspace --all-targets -- -D warnings # CI treats warnings as errors +``` + +CI (`.github/workflows/rust-windows.yml`) gates merges on, in order: `ruff format +--check .`, `ruff check .`, `python tools/validate_ci_config.py`, `cargo fmt`, +`cargo clippy ... -D warnings`, `cargo test --workspace --all-targets`. Match +these locally before pushing. (Ruff only lints `tools/` — the lone Python there.) + +Desktop GUI (Tauri 2 + React/Vite frontend): + +```powershell +.\start-gui.ps1 # installs frontend deps, starts Vite (:5173), launches Tauri dev +``` + +Frontend-only (in `crates/switchyard-gui/frontend/`): + +```bash +npm run dev # Vite dev server +npm run build # tsc -b && vite build +npm run lint # eslint +npm run test:turn-merge # the one frontend unit test (turnMerge) +``` + +CLI / TUI (binary is `switchyard`): + +```bash +cargo run -p switchyard-cli -- run --provider claude -m "review auth" +cargo run -p switchyard-cli -- tui --resume-latest +cargo run -p switchyard-cli -- check --json # probe provider availability + host surfaces +cargo run -p switchyard-cli -- host list # HYARD bridge (see below) +``` + +## Live provider tests + +Default `cargo test` uses in-repo **fake providers** and never touches real CLIs. +Tests that hit installed providers are gated behind env vars and self-skip when +unset/unauthenticated: `SWITCHYARD_RUN_LIVE_PROVIDER_TESTS=1` plus +`SWITCHYARD_TEST_{CODEX,CLAUDE,GEMINI}=1`. End-to-end smoke entry points are +`scripts/smoke-hyard-host.ps1` (single) and `scripts/smoke-hyard-matrix.ps1`. + +## Architecture + +A layered Rust workspace; one layer ≈ one or more `switchyard-*` crates. Data +flows: user turn → Router picks the active `core` provider → Context Composer +assembles the send payload → Provider adapter runs a headless turn → native +output is mapped to internal events → UI renders + Store/Artifacts persist. + +| Layer | Crate(s) | Responsibility | +|-------|----------|----------------| +| Interface | `switchyard-cli`, `switchyard-tui` | CLI commands, ratatui TUI, diff/event views | +| Application | `switchyard-core` | Router, turn runner, event dispatch, write to canonical session. Router is **not** its own crate — it's a subsystem here | +| Orchestrator | `switchyard-orchestrator` | Validates `delegate` requests, supervises peer jobs (retry/backoff), normalizes results. Hosts `WorkerSupervisor` (moved out of core to break a dependency cycle) | +| Context | `switchyard-context` | Context Composer: summary + recent-window assembly, peer-state injection. **No tokenizer / token counting** by design | +| Provider | `switchyard-provider-api`, `switchyard-provider-{codex,claude,gemini,antigravity,subprocess}` | Adapter trait (probe / start_turn / finalize), maps native output to events; `subprocess` = shared process plumbing | +| Artifact | `switchyard-artifacts` | File-change records, **locally computed** diffs, command/review summaries | +| Store | `switchyard-session`, `switchyard-store` | `session` = domain model only; `store` = persistence/query interfaces + SQLite | +| Runtime | `switchyard-runtime`, `switchyard-host-jobs` | Durable runtime authority: commits lifecycle change + ordered event to SQLite *before* IPC broadcast (event log is exactly-once; IPC is at-least-once). Backs GUI live updates and async HYARD jobs | +| Config | `switchyard-config` | Parses `switchyard.toml`; leaf dep, never depends back on business crates | +| Registry | `switchyard-app-providers` | Central `build_provider_registry(&config)` wiring all adapters | +| Desktop | `switchyard-gui` | Tauri shell; `src/main.rs` holds Tauri commands + state, plus `git.rs` / `file_watcher.rs` / `pty.rs`. React frontend under `frontend/` | + +### Two delegation protocols (don't conflate them) + +- **Sentinel** — in-process, used by Switchyard's own CLI/TUI. A model emits a + `<<>>…<<>>` block; the Router parses + it. See `switchyard-provider-api/src/sentinel.rs`. +- **HYARD** — provider-native host surface (slash commands / skills) for peers. + Host packs in `host-packs/{claude,codex,gemini}/` translate `/hyard:*` calls to + `switchyard host ` subprocesses, the authoritative async-job + transport. Each invocation prints exactly one JSON doc to stdout; exit code only + decides success-doc vs error-doc. Spec: `docs/protocol/HYARD_COMMAND_PROTOCOL_V1.md` + (filename says V1, content is V2). Delegation is **leaf-only**: peers cannot + re-delegate, one delegate at a time per session. + +## Invariants (enforced; see `docs/development/ENGINEERING_RULES.md`) + +- **Canonical session is the single source of truth.** Provider-native session + state is never authoritative; only Switchyard writes the canonical session. +- **Diffs shown in UI are always locally computed** by the Artifact layer. + Provider-reported file changes are hints only. +- **Provider differences stay inside adapters** — no provider special-casing in + higher layers. Keep `store`/`session`/`context` strictly separated (store holds + no runtime state machine; context reads no config paths / does no workspace IO). +- **Delegation must be replayable** — every delegate is visible in the log/session. +- **Headless by default** — providers run non-interactively; PTY is a gated + fallback only (`portable-pty`), not the default path. +- Docs/ADRs lead implementation: update `docs/` before landing new modules, + protocols, or behavior changes. + +## Local data + +Sessions, artifacts, and the SQLite store live under the workspace's +`.switchyard/` (paths configurable in `switchyard.toml`). It may contain chat +history and attachments — keep it out of public commits (it's gitignored). +Templates seeded into user workspaces live in `templates/` and `host-packs/`. From d00307913c9a33818e31e0618d2b3c86831127f8 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:25:41 +0800 Subject: [PATCH 2/4] feat(provider): add KohakuTerrarium (kt) headless provider New crate `switchyard-provider-kohaku`: drives `kt run --headless --json -p ` as a one-shot subprocess and maps the JSONL stream onto ProviderEvents (sandbox/leaf/model flags; clear error when no creature is configured). Mirrors the Claude adapter and reuses the subprocess streaming helpers. - Register backend "kohaku" in switchyard-app-providers (alias, inference, default command `kt`, default `@kt-biome/creatures/general` creature) + tests. - switchyard.toml: `[providers.kohaku]` block. - CLI: `switchyard app` launches the desktop GUI (searches next-to-exe, target/{release,debug}, then PATH); add `kt` notes. - switchyard-config: `global_config_path()` (~/.switchyard/switchyard.toml) so global settings can persist without a workspace. - tests: deterministic integration test via a fake `kt` bin (kohaku_integration.rs) + env-gated live smoke (kohaku_live_smoke.rs, SWITCHYARD_TEST_KOHAKU=1). Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 17 ++ Cargo.toml | 2 + crates/switchyard-app-providers/Cargo.toml | 1 + crates/switchyard-app-providers/src/lib.rs | 34 ++- crates/switchyard-cli/src/main.rs | 69 +++++ crates/switchyard-config/src/lib.rs | 9 + crates/switchyard-provider-kohaku/Cargo.toml | 17 ++ .../switchyard-provider-kohaku/src/jsonl.rs | 110 ++++++++ crates/switchyard-provider-kohaku/src/lib.rs | 140 ++++++++++ .../switchyard-provider-kohaku/src/probe.rs | 79 ++++++ crates/switchyard-provider-kohaku/src/turn.rs | 257 ++++++++++++++++++ switchyard.toml | 14 + tests/Cargo.toml | 7 + tests/bin/fake_kt.rs | 74 +++++ tests/tests/kohaku_integration.rs | 165 +++++++++++ tests/tests/kohaku_live_smoke.rs | 113 ++++++++ 16 files changed, 1106 insertions(+), 2 deletions(-) create mode 100644 crates/switchyard-provider-kohaku/Cargo.toml create mode 100644 crates/switchyard-provider-kohaku/src/jsonl.rs create mode 100644 crates/switchyard-provider-kohaku/src/lib.rs create mode 100644 crates/switchyard-provider-kohaku/src/probe.rs create mode 100644 crates/switchyard-provider-kohaku/src/turn.rs create mode 100644 tests/bin/fake_kt.rs create mode 100644 tests/tests/kohaku_integration.rs create mode 100644 tests/tests/kohaku_live_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index e38d0f7..86488c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3595,6 +3595,7 @@ dependencies = [ "switchyard-provider-claude", "switchyard-provider-codex", "switchyard-provider-gemini", + "switchyard-provider-kohaku", ] [[package]] @@ -3690,6 +3691,7 @@ dependencies = [ "filetime", "notify", "portable-pty", + "regex", "serde", "serde_json", "switchyard-app-providers", @@ -3817,6 +3819,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "switchyard-provider-kohaku" +version = "0.1.3" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "switchyard-config", + "switchyard-provider-api", + "switchyard-provider-subprocess", + "tokio", + "uuid", +] + [[package]] name = "switchyard-provider-subprocess" version = "0.1.3" @@ -3896,6 +3912,7 @@ dependencies = [ "switchyard-provider-claude", "switchyard-provider-codex", "switchyard-provider-gemini", + "switchyard-provider-kohaku", "switchyard-provider-subprocess", "switchyard-runtime", "switchyard-session", diff --git a/Cargo.toml b/Cargo.toml index 7376238..14825cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "crates/switchyard-provider-claude", "crates/switchyard-provider-gemini", "crates/switchyard-provider-antigravity", + "crates/switchyard-provider-kohaku", "crates/switchyard-gui", "tests", ] @@ -51,6 +52,7 @@ switchyard-provider-codex = { path = "crates/switchyard-provider-codex" } switchyard-provider-claude = { path = "crates/switchyard-provider-claude" } switchyard-provider-gemini = { path = "crates/switchyard-provider-gemini" } switchyard-provider-antigravity = { path = "crates/switchyard-provider-antigravity" } +switchyard-provider-kohaku = { path = "crates/switchyard-provider-kohaku" } # External dependencies tokio = { version = "1", features = ["full"] } diff --git a/crates/switchyard-app-providers/Cargo.toml b/crates/switchyard-app-providers/Cargo.toml index 5b2159d..cea1afb 100644 --- a/crates/switchyard-app-providers/Cargo.toml +++ b/crates/switchyard-app-providers/Cargo.toml @@ -13,3 +13,4 @@ switchyard-provider-codex = { workspace = true } switchyard-provider-claude = { workspace = true } switchyard-provider-gemini = { workspace = true } switchyard-provider-antigravity = { workspace = true } +switchyard-provider-kohaku = { workspace = true } diff --git a/crates/switchyard-app-providers/src/lib.rs b/crates/switchyard-app-providers/src/lib.rs index 24939d3..eafda67 100644 --- a/crates/switchyard-app-providers/src/lib.rs +++ b/crates/switchyard-app-providers/src/lib.rs @@ -11,9 +11,11 @@ use switchyard_provider_api::Provider; use switchyard_provider_claude::ClaudeProvider; use switchyard_provider_codex::CodexProvider; use switchyard_provider_gemini::GeminiProvider; +use switchyard_provider_kohaku::KohakuProvider; /// Built-in provider aliases exposed even when the local config omits them. -pub const BUILT_IN_PROVIDER_ALIASES: &[&str] = &["codex", "claude", "gemini", "antigravity"]; +pub const BUILT_IN_PROVIDER_ALIASES: &[&str] = + &["codex", "claude", "gemini", "antigravity", "kohaku"]; /// Default timeout for built-in provider fallbacks. /// @@ -58,6 +60,8 @@ pub fn inferred_provider_backend(name: &str) -> Option<&'static str> { Some("antigravity") } else if name.contains("gemini") { Some("gemini") + } else if name.contains("kohaku") { + Some("kohaku") } else { None } @@ -70,6 +74,7 @@ pub fn default_provider_command(backend: &str) -> String { "claude" => "claude".to_string(), "gemini" => "gemini".to_string(), "antigravity" => "agy".to_string(), + "kohaku" => "kt".to_string(), other => other.to_string(), } } @@ -81,9 +86,17 @@ pub fn default_provider_config(provider_id: &str) -> ProviderConfig { } fn default_provider_config_for_backend(backend: &str) -> ProviderConfig { + // KohakuTerrarium needs a creature as args[0]; default to the official + // `general` creature from the kt-biome pack (`kt install @kt-biome`) so + // kohaku works out of the box instead of erroring on an empty creature. + let args = if backend == "kohaku" { + vec!["@kt-biome/creatures/general".to_string()] + } else { + Vec::new() + }; ProviderConfig { command: default_provider_command(backend), - args: Vec::new(), + args, env: std::collections::HashMap::new(), model: None, thinking_level: None, @@ -150,6 +163,19 @@ fn register_provider_backend( p }), ), + "kohaku" => registry.register( + name, + Box::new(|cfg| { + let p: Box = match cfg { + Some(c) => Box::new(KohakuProvider::from_config(c)), + None => { + let c = default_provider_config_for_backend("kohaku"); + Box::new(KohakuProvider::from_config(&c)) + } + }; + p + }), + ), _ => {} } } @@ -161,6 +187,7 @@ mod tests { use switchyard_provider_claude::ClaudeProvider; use switchyard_provider_codex::CodexProvider; use switchyard_provider_gemini::GeminiProvider; + use switchyard_provider_kohaku::KohakuProvider; #[test] fn built_in_fallback_configs_have_no_hard_timeout() { @@ -176,11 +203,13 @@ mod tests { let claude = ClaudeProvider::from_config(&default_provider_config("claude")); let gemini = GeminiProvider::from_config(&default_provider_config("gemini")); let antigravity = AntigravityProvider::from_config(&default_provider_config("antigravity")); + let kohaku = KohakuProvider::from_config(&default_provider_config("kohaku")); assert_eq!(codex.timeout_secs, 0); assert_eq!(claude.timeout_secs, 0); assert_eq!(gemini.timeout_secs, 0); assert_eq!(antigravity.timeout_secs, 0); + assert_eq!(kohaku.timeout_secs, 0); } #[test] @@ -198,6 +227,7 @@ mod tests { assert_eq!(inferred_provider_backend("CLAUDE"), Some("claude")); assert_eq!(inferred_provider_backend("agy"), Some("antigravity")); assert_eq!(inferred_provider_backend("google-gemini"), Some("gemini")); + assert_eq!(inferred_provider_backend("kohaku"), Some("kohaku")); assert_eq!(inferred_provider_backend("unknown"), None); } } diff --git a/crates/switchyard-cli/src/main.rs b/crates/switchyard-cli/src/main.rs index 2d41d52..887beff 100644 --- a/crates/switchyard-cli/src/main.rs +++ b/crates/switchyard-cli/src/main.rs @@ -92,6 +92,13 @@ enum Commands { #[arg(long, default_value = ".")] cwd: PathBuf, }, + /// Launch the Switchyard desktop GUI app (spawns the `switchyard-gui` + /// binary located next to this executable). + App { + /// Extra arguments forwarded to the GUI executable. + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, /// Validate configuration and check provider availability. Check { /// Output machine-readable JSON report after probes complete. @@ -267,6 +274,9 @@ enum HookCli { } /// Known CLI command names for auto-discovery. +// `kt` is intentionally absent: the KohakuTerrarium provider is registered as +// `kohaku` (command `kt`), so it already appears via the registry. Listing the +// bare `kt` here would add a confusing "adapter not implemented" duplicate. const KNOWN_CLIS: &[&str] = &["codex", "claude", "gemini", "agy"]; #[derive(Clone, Serialize)] @@ -429,6 +439,15 @@ async fn main() { process::exit(1); } } + Commands::App { args } => match launch_gui(&args) { + Ok(path) => { + println!("launched Switchyard GUI: {}", path.display()); + } + Err(err) => { + eprintln!("{err}"); + process::exit(1); + } + }, Commands::Check { json } => { let config_issues = config.validate(); @@ -756,3 +775,53 @@ async fn main() { } } } + +/// Launch the Switchyard desktop GUI by spawning the `switchyard-gui` +/// executable. Searched in order: next to the running `switchyard` binary +/// (packaged install / same target dir), then `/target/{release,debug}` +/// (source checkout — `switchyard` may be installed in ~/.cargo/bin while the +/// GUI lives in the repo target dir), then PATH. Spawns detached so the +/// terminal is freed; returns the launched path. +fn launch_gui(extra: &[String]) -> Result { + const NAMES: [&str; 2] = ["switchyard-gui.exe", "switchyard-gui"]; + + let exe = std::env::current_exe().map_err(|e| format!("cannot resolve current exe: {e}"))?; + let exe_dir = exe + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + + let mut candidates: Vec = Vec::new(); + for n in NAMES { + candidates.push(exe_dir.join(n)); + } + for profile in ["release", "debug"] { + for n in NAMES { + candidates.push(cwd.join("target").join(profile).join(n)); + } + } + + let gui = candidates + .into_iter() + .find(|p| p.exists()) + .or_else(|| find_on_path("switchyard-gui").map(PathBuf::from)); + + let Some(gui) = gui else { + return Err(format!( + "switchyard-gui not found (looked next to {}, under {}\\target\\{{release,debug}}, \ + and on PATH).\n\ + Build it with `cargo build -p switchyard-gui` (and \ + `npm --prefix crates/switchyard-gui/frontend run build` so the bundled \ + frontend exists), or use `.\\start-gui.ps1` for a dev run.", + exe_dir.display(), + cwd.display() + )); + }; + + process::Command::new(&gui) + .args(extra) + .spawn() + .map_err(|e| format!("failed to spawn {}: {e}", gui.display()))?; + Ok(gui) +} diff --git a/crates/switchyard-config/src/lib.rs b/crates/switchyard-config/src/lib.rs index d2af5ac..4686f3d 100644 --- a/crates/switchyard-config/src/lib.rs +++ b/crates/switchyard-config/src/lib.rs @@ -453,6 +453,15 @@ fn home_dir() -> Option { .map(PathBuf::from) } +/// The global (user-level) config path: `~/.switchyard/switchyard.toml`. +/// +/// This is the app's own config home, used when no workspace is open so +/// global settings can be saved/loaded without a project. [`SwitchyardConfig::resolve`] +/// already falls back to reading this file. +pub fn global_config_path() -> Option { + home_dir().map(|home| home.join(DOT_DIR).join(CONFIG_FILENAME)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/switchyard-provider-kohaku/Cargo.toml b/crates/switchyard-provider-kohaku/Cargo.toml new file mode 100644 index 0000000..bb1ae1a --- /dev/null +++ b/crates/switchyard-provider-kohaku/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "switchyard-provider-kohaku" +version.workspace = true +edition.workspace = true + +[dependencies] +switchyard-config = { workspace = true } +switchyard-provider-api = { workspace = true } +switchyard-provider-subprocess = { workspace = true } +async-trait = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/switchyard-provider-kohaku/src/jsonl.rs b/crates/switchyard-provider-kohaku/src/jsonl.rs new file mode 100644 index 0000000..fb8130f --- /dev/null +++ b/crates/switchyard-provider-kohaku/src/jsonl.rs @@ -0,0 +1,110 @@ +//! Parse the KohakuTerrarium headless JSONL stream. +//! +//! The `switchyard-headless` fork emits one JSON object per line on stdout: +//! `turn_start`, `text`, `activity` (with `activity_type` + nested +//! `metadata`), `turn_end` (`status`/`text`/`error`/`usage`), and `error`. +//! Every line is also passed through verbatim as an `ItemUpdated` event for +//! the diagnostics drawer; this module only classifies the lines that affect +//! the assistant text and the turn outcome. + +use serde_json::Value; + +#[derive(Debug, PartialEq, Eq)] +pub enum KohakuEvent { + /// An assistant text delta (`{"type":"text","content":...}`). + Text(String), + /// The turn boundary (`{"type":"turn_end",...}`). + TurnEnd { + status: String, + text: String, + error: Option, + }, + /// activity / turn_start / error / anything else — passed through as-is. + Other, +} + +pub fn classify(json: &Value) -> KohakuEvent { + match json.get("type").and_then(Value::as_str) { + Some("text") => KohakuEvent::Text( + json.get("content") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + ), + Some("turn_end") => KohakuEvent::TurnEnd { + status: json + .get("status") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + text: json + .get("text") + .and_then(Value::as_str) + .unwrap_or("") + .to_string(), + error: json.get("error").and_then(Value::as_str).map(str::to_string), + }, + _ => KohakuEvent::Other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn classifies_text_delta() { + assert_eq!( + classify(&json!({"type": "text", "content": "Hello"})), + KohakuEvent::Text("Hello".to_string()) + ); + } + + #[test] + fn classifies_turn_end_ok() { + assert_eq!( + classify(&json!({ + "type": "turn_end", + "status": "ok", + "text": "done", + "error": null, + "usage": {"total_tokens": 5} + })), + KohakuEvent::TurnEnd { + status: "ok".to_string(), + text: "done".to_string(), + error: None, + } + ); + } + + #[test] + fn classifies_turn_end_error() { + assert_eq!( + classify(&json!({ + "type": "turn_end", + "status": "error", + "text": "", + "error": "boom" + })), + KohakuEvent::TurnEnd { + status: "error".to_string(), + text: String::new(), + error: Some("boom".to_string()), + } + ); + } + + #[test] + fn activity_and_turn_start_are_other() { + assert_eq!( + classify(&json!({"type": "activity", "activity_type": "tool_start"})), + KohakuEvent::Other + ); + assert_eq!( + classify(&json!({"type": "turn_start", "agent": "x"})), + KohakuEvent::Other + ); + } +} diff --git a/crates/switchyard-provider-kohaku/src/lib.rs b/crates/switchyard-provider-kohaku/src/lib.rs new file mode 100644 index 0000000..38c1725 --- /dev/null +++ b/crates/switchyard-provider-kohaku/src/lib.rs @@ -0,0 +1,140 @@ +//! KohakuTerrarium (`kt`) CLI adapter. +//! +//! Drives `kt run --headless --json -p ` as a one-shot +//! subprocess and maps its JSONL event stream onto [`ProviderEvent`]s. This +//! requires a `kt` build that supports the headless mode (the +//! `switchyard-headless` fork); [`probe`] detects it via the capability line +//! `kt --version` prints. +//! +//! v1 is one-shot per turn (no [`PersistentProvider`]); multi-turn continuity +//! is achieved by resuming the same `--session .kohakutr` each turn. + +mod jsonl; +mod probe; +mod turn; + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use tokio::sync::{Mutex, mpsc}; +use uuid::Uuid; + +use switchyard_provider_api::*; +use switchyard_provider_subprocess::{effective_timeout_secs, resolve_command}; + +pub struct KohakuProvider { + pub original_command: String, + pub command: String, + /// Configured args — the FIRST entry must be the creature/recipe ref + /// (e.g. `@kt_biome/coder` or a config-folder path); it becomes the + /// `agent_path` positional after `kt run`. + pub args: Vec, + pub env: HashMap, + /// Maps onto the `kt --llm ` profile (e.g. `enzi/gpt-5.5-custom`). + pub model: Option, + /// Reserved: KT has no standalone effort flag; reasoning is encoded in the + /// `--llm` selector's `@reasoning=` variation, so this is not mapped in v1. + pub thinking_level: Option, + pub timeout_secs: u64, + results: Arc>>, +} + +impl KohakuProvider { + pub fn new( + command: impl Into, + args: Vec, + env: HashMap, + timeout_secs: u64, + ) -> Self { + Self::new_with_options(command, args, env, timeout_secs, None, None) + } + + pub fn new_with_options( + command: impl Into, + args: Vec, + env: HashMap, + timeout_secs: u64, + model: Option, + thinking_level: Option, + ) -> Self { + let original_command = command.into(); + let command = resolve_command(&original_command); + Self { + original_command, + command, + args, + env, + model, + thinking_level, + timeout_secs, + results: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn from_config(cfg: &switchyard_config::ProviderConfig) -> Self { + Self::new_with_options( + cfg.command.clone(), + cfg.args.clone(), + cfg.env.clone(), + cfg.timeout_secs, + cfg.model.clone(), + cfg.thinking_level.clone(), + ) + } +} + +#[async_trait] +impl Provider for KohakuProvider { + async fn probe(&self) -> Result { + probe::run_probe(&self.command).await + } + + async fn start_turn( + &self, + turn_id: Uuid, + input: TurnInput, + policy: ExecutionPolicy, + _context: ContextBundle, + event_tx: mpsc::Sender, + cancel: CancellationToken, + ) -> Result<(), ProviderError> { + let timeout_secs = effective_timeout_secs(self.timeout_secs, policy.timeout_secs); + let result = turn::run_kohaku_turn( + turn_id, + &self.original_command, + &self.command, + &self.args, + self.model.as_deref(), + self.thinking_level.as_deref(), + &input, + timeout_secs, + Some(&self.env), + &policy, + Some(&policy.cwd), + &event_tx, + cancel, + ) + .await?; + + self.results.lock().await.insert(turn_id, result); + Ok(()) + } + + async fn finalize_turn( + &self, + turn_id: Uuid, + ) -> Result<(TurnResult, ArtifactBundle), ProviderError> { + self.results + .lock() + .await + .remove(&turn_id) + .ok_or_else(|| ProviderError::ExecutionFailed(format!("no result for turn {turn_id}"))) + } + + fn as_persistent(&self) -> Option<&dyn PersistentProvider> { + // v1: one-shot per turn. Multi-turn continuity is via `--session` + // resume rather than a long-lived process. + None + } +} diff --git a/crates/switchyard-provider-kohaku/src/probe.rs b/crates/switchyard-provider-kohaku/src/probe.rs new file mode 100644 index 0000000..9a2185a --- /dev/null +++ b/crates/switchyard-provider-kohaku/src/probe.rs @@ -0,0 +1,79 @@ +//! KohakuTerrarium probe: detect installation, version, and whether this `kt` +//! supports the headless JSONL mode (the `switchyard-headless` fork). + +use switchyard_provider_api::{HostSurfaceProbe, ProbeResult, ProviderError}; +use switchyard_provider_subprocess::{ + SubprocessConfig, check_auth_error, default_cli_capabilities, run_subprocess, +}; + +/// Marker the fork prints in `kt --version` (see `cli/version.py`). +const HEADLESS_FORK_HINT: &str = + "install the switchyard-headless fork: pip install 'git+https://github.com/Yidhar/KohakuTerrarium@headless'"; + +pub async fn run_probe(command: &str) -> Result { + let args = vec!["--version".to_string()]; + let config = SubprocessConfig { + command, + args: &args, + stdin_data: None, + timeout_secs: 10, + cwd: None, + pty_registry_key: None, + prefer_pty: false, + env: None, + }; + + let output = match run_subprocess(&config).await { + Ok(o) => o, + Err(switchyard_provider_subprocess::SubprocessError::NotFound(_)) => { + return Err(ProviderError::NotInstalled(format!( + "command '{command}' not found (install KohakuTerrarium; {HEADLESS_FORK_HINT})" + ))); + } + Err(e) => { + return Err(ProviderError::ExecutionFailed(format!( + "'{command} --version': {e}" + ))); + } + }; + + if output.exit_code != Some(0) { + if let Some(err) = check_auth_error( + &output.stdout, + output.stderr.as_deref().unwrap_or(""), + "kohaku", + ) { + return Err(err); + } + return Err(ProviderError::ExecutionFailed(format!( + "'{command} --version' exited {:?}", + output.exit_code + ))); + } + + let version = output.stdout.lines().next().map(|l| l.trim().to_string()); + + // The fork advertises headless support in `kt --version`. On stock `kt` + // the line is absent — keep the provider visible but flag the gap so the + // user knows why a turn would fail. + let lower = output.stdout.to_ascii_lowercase(); + let headless_capable = lower.contains("headless:") || output.stdout.contains("--headless"); + let mut issues = Vec::new(); + if !headless_capable { + issues.push(format!( + "this `kt` lacks headless mode (`kt run --headless --json`); {HEADLESS_FORK_HINT}" + )); + } + + Ok(ProbeResult { + version, + available: true, + capabilities: default_cli_capabilities(), + issues, + // KohakuTerrarium is integrated as a leaf provider; it does not expose + // a HYARD host surface to delegate to other providers. + host_surface: HostSurfaceProbe::unavailable(vec![ + "KohakuTerrarium runs as a leaf provider; no HYARD host surface".to_string(), + ]), + }) +} diff --git a/crates/switchyard-provider-kohaku/src/turn.rs b/crates/switchyard-provider-kohaku/src/turn.rs new file mode 100644 index 0000000..fad1d86 --- /dev/null +++ b/crates/switchyard-provider-kohaku/src/turn.rs @@ -0,0 +1,257 @@ +//! KohakuTerrarium headless turn execution via +//! `kt run --headless --json -p `. +//! +//! `--json` makes `kt` emit one JSON object per line on stdout (logs go to +//! stderr), which we relay as [`ProviderEvent`]s so the chat ticker renders +//! token-by-token. The process exits 0 when the turn ends `ok`, non-zero +//! otherwise — which [`build_turn_result`] folds into the turn outcome. + +use tokio::sync::mpsc; +use uuid::Uuid; + +use switchyard_provider_api::*; +use switchyard_provider_subprocess::{ + StreamingOutputLine, SubprocessConfig, build_subprocess_invocation_plan, build_turn_result, + compose_prompt, emit_completion_event, handle_subprocess_error, run_subprocess_streaming, +}; + +use crate::jsonl::{KohakuEvent, classify}; + +#[allow(clippy::too_many_arguments)] +pub async fn run_kohaku_turn( + turn_id: Uuid, + original_command: &str, + command: &str, + configured_args: &[String], + model: Option<&str>, + thinking_level: Option<&str>, + input: &TurnInput, + timeout_secs: u64, + env: Option<&std::collections::HashMap>, + policy: &ExecutionPolicy, + cwd: Option<&std::path::Path>, + event_tx: &mpsc::Sender, + cancel: CancellationToken, +) -> Result<(TurnResult, ArtifactBundle), ProviderError> { + event_tx + .send(ProviderEvent::turn_started(turn_id, "kohaku")) + .await + .ok(); + + // A real KohakuTerrarium turn needs a creature: `kt run ...`. + // The first configured arg is the required `agent_path` positional. Without + // it the spawn would either fail on argparse or (with no creature) never do + // a real model call — fail loudly with actionable guidance instead. + let creature = configured_args.first().map(|s| s.trim()).unwrap_or(""); + if creature.is_empty() || creature.starts_with('-') { + let err = ProviderError::ExecutionFailed( + "kohaku: no creature configured. Set the provider's first CLI argument (args[0]) to a \ + creature ref — a config-folder path or @pkg/creatures/." + .to_string(), + ); + event_tx + .send(ProviderEvent::turn_failed(turn_id, "kohaku", err.to_string())) + .await + .ok(); + return Err(err); + } + + // argv: kt run --headless --json + // [--llm ] --sandbox --cwd --no-subagents + // -p + // The creature ref must lead so it binds to the required `agent_path` + // positional. + let mut args: Vec = vec!["run".to_string()]; + args.extend_from_slice(configured_args); + args.push("--headless".to_string()); + args.push("--json".to_string()); + args.extend(kohaku_runtime_args(model, thinking_level)); + args.extend(kohaku_policy_args(policy)); + // Leaf execution: a routed/peer run must not spawn sub-agents. + args.push("--no-subagents".to_string()); + args.push("-p".to_string()); + args.push(compose_prompt(input)); + + let plan = build_subprocess_invocation_plan(original_command, command, &args); + + let config = SubprocessConfig { + command: &plan.command, + args: &plan.args, + stdin_data: None, + timeout_secs, + cwd, + pty_registry_key: Some(turn_id), + prefer_pty: false, + env, + }; + + let (line_tx, mut line_rx) = mpsc::channel::(256); + + event_tx + .send(ProviderEvent::execution_telemetry( + turn_id, + "kohaku", + &plan.execution, + )) + .await + .ok(); + + let event_tx_clone = event_tx.clone(); + let consumer = tokio::spawn(async move { + // `assistant_message` accumulates streamed `text` deltas (the primary + // response body). `final_text` is the consolidated `turn_end.text`, + // used only when no deltas arrived (e.g. a tool-only turn). + let mut assistant_message = String::new(); + let mut final_text: Option = None; + while let Some(output_line) = line_rx.recv().await { + let line = output_line.text; + let protocol_line = line.trim_end_matches(['\r', '\n']); + event_tx_clone + .send(ProviderEvent::terminal_output( + turn_id, + "kohaku", + &line, + Some("merged"), + Some(output_line.transport.as_str()), + )) + .await + .ok(); + if protocol_line.is_empty() { + continue; + } + if let Ok(json) = serde_json::from_str::(protocol_line) { + match classify(&json) { + KohakuEvent::Text(text) if !text.is_empty() => { + assistant_message.push_str(&text); + event_tx_clone + .send(ProviderEvent::text_message(turn_id, "kohaku", &text)) + .await + .ok(); + } + KohakuEvent::TurnEnd { text, .. } => { + if final_text.is_none() && !text.is_empty() { + final_text = Some(text); + } + } + _ => {} + } + + // Pass the raw JSON through for the diagnostics drawer and any + // downstream observability (tool/subagent activity, usage). + event_tx_clone + .send(ProviderEvent::new( + turn_id, + EventType::ItemUpdated, + "kohaku", + json, + )) + .await + .ok(); + } else if !protocol_line.trim().is_empty() { + // Non-JSON line — surface as text so unexpected output is + // visible when debugging. + event_tx_clone + .send(ProviderEvent::text_message(turn_id, "kohaku", protocol_line)) + .await + .ok(); + } + } + if assistant_message.is_empty() { + final_text.unwrap_or_default() + } else { + assistant_message + } + }); + + let result = run_subprocess_streaming(&config, &line_tx, cancel).await; + drop(line_tx); + let streamed_text = consumer.await.unwrap_or_default(); + + let output = match result { + Ok(o) => o, + Err(e) => return Err(handle_subprocess_error(e, turn_id, "kohaku", event_tx).await), + }; + + emit_completion_event(&output, turn_id, "kohaku", event_tx).await; + + let response_text = if streamed_text.is_empty() { + output.stdout.trim().to_string() + } else { + streamed_text + }; + + Ok(build_turn_result(response_text, &output, "kohaku")) +} + +/// Map Switchyard's sandbox mode + workspace onto `kt` headless flags. +fn kohaku_policy_args(policy: &ExecutionPolicy) -> Vec { + let preset = match policy.effective_sandbox_mode() { + // KT's sandbox plugin is a process-internal intent gate (not an OS + // boundary); Switchyard still enforces its own OS-level sandbox. + EffectiveSandboxMode::ReadOnly => "READ_ONLY", + EffectiveSandboxMode::WorkspaceWrite => "WORKSPACE", + // "danger" = unrestricted; do not load KT's sandbox plugin. + EffectiveSandboxMode::DangerFullAccess => "off", + }; + let mut args = vec!["--sandbox".to_string(), preset.to_string()]; + // KT's workspace root + tool cwd derive from the Agent pwd. + args.push("--cwd".to_string()); + args.push(policy.cwd.display().to_string()); + // NOTE: KT has no multi-root writable allowlist, so + // `policy.additional_allowed_paths()` is not mapped in v1. + args +} + +/// Map the configured model onto the `kt --llm ` profile. +/// +/// KT has no standalone effort flag — reasoning is encoded in the selector's +/// `@reasoning=` variation — so `thinking_level` is not appended in v1 (a +/// user wanting it can include it directly in `model`, e.g. +/// `enzi/gpt-5.5-custom@reasoning=low`). +pub(crate) fn kohaku_runtime_args( + model: Option<&str>, + _thinking_level: Option<&str>, +) -> Vec { + let mut args = Vec::new(); + if let Some(model) = model.map(str::trim).filter(|m| !m.is_empty()) { + args.push("--llm".to_string()); + args.push(model.to_string()); + } + args +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn runtime_args_map_model_only() { + assert_eq!( + kohaku_runtime_args(Some("enzi/gpt-5.5-custom"), Some("high")), + vec!["--llm", "enzi/gpt-5.5-custom"] + ); + assert_eq!( + kohaku_runtime_args(Some(" "), None), + Vec::::new() + ); + assert_eq!(kohaku_runtime_args(None, Some("low")), Vec::::new()); + } + + #[test] + fn policy_args_map_sandbox_modes() { + let ro = kohaku_policy_args(&ExecutionPolicy::read_only("/repo")); + assert_eq!(ro[0..2], ["--sandbox", "READ_ONLY"]); + assert_eq!(ro[2], "--cwd"); + assert_eq!(ro[3], PathBuf::from("/repo").display().to_string()); + + assert_eq!( + kohaku_policy_args(&ExecutionPolicy::workspace_write("/repo"))[0..2], + ["--sandbox", "WORKSPACE"] + ); + assert_eq!( + kohaku_policy_args(&ExecutionPolicy::danger_full_access("/repo"))[0..2], + ["--sandbox", "off"] + ); + } +} diff --git a/switchyard.toml b/switchyard.toml index 719a4f2..325f929 100644 --- a/switchyard.toml +++ b/switchyard.toml @@ -38,6 +38,20 @@ backend = "claude" [providers.claude.env] +[providers.kohaku] +# KohakuTerrarium. Requires a `kt` with headless support (switchyard-headless +# fork) and an installed creature pack (`kt install @kt-biome`). +# args[0] MUST be a creature ref (a config-folder path or `@pkg/creatures/`); +# the adapter appends `--headless --json -p `. +command = "kt" +args = ["@kt-biome/creatures/general"] +# Maps to `kt --llm `. Leave empty to use the creature's own model. +model = "enzi/gpt-5.5-custom" +timeout_secs = 0 +backend = "kohaku" + +[providers.kohaku.env] + [sandbox] mode = "danger-full-access" allowed_paths = [] diff --git a/tests/Cargo.toml b/tests/Cargo.toml index b1c22eb..4c29207 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -12,6 +12,12 @@ path = "../crates/switchyard-cli/src/main.rs" name = "switchyard-tui" path = "../crates/switchyard-tui/src/main.rs" +# Fake `kt` for the deterministic KohakuTerrarium provider integration test +# (kohaku_integration.rs locates it via CARGO_BIN_EXE_fake_kt). +[[bin]] +name = "fake_kt" +path = "bin/fake_kt.rs" + [dependencies] switchyard-app-providers = { workspace = true } switchyard-core = { workspace = true } @@ -25,6 +31,7 @@ switchyard-provider-claude = { workspace = true } switchyard-provider-codex = { workspace = true } switchyard-provider-gemini = { workspace = true } switchyard-provider-antigravity = { workspace = true } +switchyard-provider-kohaku = { workspace = true } switchyard-cli = { workspace = true } switchyard-tui = { workspace = true } switchyard-host-jobs = { workspace = true } diff --git a/tests/bin/fake_kt.rs b/tests/bin/fake_kt.rs new file mode 100644 index 0000000..cbcf3fd --- /dev/null +++ b/tests/bin/fake_kt.rs @@ -0,0 +1,74 @@ +//! Fake `kt` for the KohakuTerrarium provider integration test. +//! +//! Ignores most args, validates that the adapter requested headless JSONL, +//! echoes the `-p` prompt, and emits a canned KohakuTerrarium headless JSONL +//! stream on stdout. Logs go to stderr (mirroring real `kt`) to prove the +//! adapter keeps stdout pure JSONL. +//! +//! Behaviour switches: +//! - `FAKE_KT_FAIL=1` → emit a failed `turn_end` and exit 1. + +use std::io::Write; + +fn emit(out: &mut impl Write, v: serde_json::Value) { + writeln!(out, "{v}").expect("write jsonl line"); +} + +fn main() { + let args: Vec = std::env::args().collect(); + eprintln!("[fake_kt] argv = {args:?}"); + + // The adapter must always request the headless JSONL surface. + if !args.iter().any(|a| a == "--headless") || !args.iter().any(|a| a == "--json") { + eprintln!("[fake_kt] missing --headless/--json"); + std::process::exit(3); + } + + let prompt = args + .iter() + .position(|a| a == "-p") + .and_then(|i| args.get(i + 1)) + .cloned() + .unwrap_or_default(); + + let stdout = std::io::stdout(); + let mut out = stdout.lock(); + + emit( + &mut out, + serde_json::json!({"type":"turn_start","agent":"fake","model":"fake-model"}), + ); + emit( + &mut out, + serde_json::json!({"type":"activity","activity_type":"processing_start","detail":"","metadata":{}}), + ); + + if std::env::var("FAKE_KT_FAIL").is_ok() { + emit( + &mut out, + serde_json::json!({"type":"turn_end","status":"error","text":"","error":"boom","usage":null,"duration_s":0.0}), + ); + out.flush().ok(); + std::process::exit(1); + } + + let echo = format!("echo: {prompt}"); + emit(&mut out, serde_json::json!({"type":"text","content": echo})); + emit( + &mut out, + serde_json::json!({"type":"activity","activity_type":"tool_start","detail":"read","metadata":{"name":"read","args":{"path":"x"}}}), + ); + emit( + &mut out, + serde_json::json!({"type":"activity","activity_type":"tool_done","detail":"read","metadata":{}}), + ); + emit( + &mut out, + serde_json::json!({"type":"activity","activity_type":"token_usage","detail":"","metadata":{"total_tokens":5}}), + ); + emit( + &mut out, + serde_json::json!({"type":"turn_end","status":"ok","text": echo, "error": null, "usage": {"total_tokens":5}, "duration_s": 0.01}), + ); + out.flush().ok(); +} diff --git a/tests/tests/kohaku_integration.rs b/tests/tests/kohaku_integration.rs new file mode 100644 index 0000000..58a19cd --- /dev/null +++ b/tests/tests/kohaku_integration.rs @@ -0,0 +1,165 @@ +//! Deterministic integration test for the KohakuTerrarium provider. +//! +//! Drives the real `KohakuProvider` subprocess path against a fake `kt` +//! binary (no network / no auth), asserting the full chain: argv building → +//! subprocess spawn → headless JSONL parsing → `ProviderEvent` mapping → +//! `TurnResult`. Runs in CI. + +use std::collections::HashMap; + +use switchyard_provider_api::{ + CancellationToken, ContextBundle, EventType, ExecutionPolicy, Provider, TurnInput, +}; +use switchyard_provider_kohaku::KohakuProvider; + +fn fake_kt() -> String { + // Cargo sets this for integration tests of the package that defines the bin. + env!("CARGO_BIN_EXE_fake_kt").to_string() +} + +fn policy() -> ExecutionPolicy { + ExecutionPolicy { + timeout_secs: 30, + write_access: false, + cwd: std::env::current_dir().unwrap(), + allowed_paths: vec![], + } +} + +fn context() -> ContextBundle { + ContextBundle { + summary: None, + recent_turns: vec![], + peer_state: vec![], + artifacts: vec![], + } +} + +fn input(msg: &str) -> TurnInput { + TurnInput { + user_message: msg.to_string(), + system_prompt: None, + attachments: vec![], + } +} + +#[tokio::test] +async fn kohaku_headless_turn_maps_jsonl_to_events() { + let provider = KohakuProvider::new( + fake_kt(), + vec!["@fake/creature".to_string()], + HashMap::new(), + 30, + ); + let turn_id = uuid::Uuid::now_v7(); + let (tx, mut rx) = tokio::sync::mpsc::channel(256); + + provider + .start_turn( + turn_id, + input("ping"), + policy(), + context(), + tx, + CancellationToken::new(), + ) + .await + .expect("start_turn ok"); + + let mut started = false; + let mut completed = false; + let mut streamed = String::new(); + while let Some(e) = rx.recv().await { + match e.event_type { + EventType::TurnStarted => started = true, + EventType::TurnCompleted => completed = true, + _ => {} + } + if e.payload.get("item_type").and_then(|v| v.as_str()) == Some("agent_message") + && let Some(t) = e.payload.get("text").and_then(|v| v.as_str()) + { + streamed.push_str(t); + } + } + + assert!(started, "should emit TurnStarted"); + assert!(completed, "should emit TurnCompleted on exit 0"); + assert!( + streamed.contains("echo: ping"), + "streamed assistant text should echo the prompt, got: {streamed:?}" + ); + + let (result, bundle) = provider.finalize_turn(turn_id).await.expect("finalize"); + assert_eq!(result.exit_code, Some(0)); + assert!( + result.response_text.contains("echo: ping"), + "response_text: {:?}", + result.response_text + ); + assert!(!bundle.artifacts.is_empty(), "should archive raw output"); +} + +#[tokio::test] +async fn kohaku_headless_turn_failure_propagates() { + let mut env = HashMap::new(); + env.insert("FAKE_KT_FAIL".to_string(), "1".to_string()); + let provider = KohakuProvider::new(fake_kt(), vec!["@fake/creature".to_string()], env, 30); + let turn_id = uuid::Uuid::now_v7(); + let (tx, mut rx) = tokio::sync::mpsc::channel(256); + + provider + .start_turn( + turn_id, + input("ping"), + policy(), + context(), + tx, + CancellationToken::new(), + ) + .await + .expect("start_turn returns Ok even when the turn itself fails"); + + let mut failed = false; + while let Some(e) = rx.recv().await { + if e.event_type == EventType::TurnFailed { + failed = true; + } + } + assert!(failed, "should emit TurnFailed on non-zero exit"); + + let (result, _) = provider.finalize_turn(turn_id).await.expect("finalize"); + assert_eq!(result.exit_code, Some(1), "exit code should propagate"); +} + +#[tokio::test] +async fn kohaku_missing_creature_fails_clearly() { + // No creature configured (args empty) — must fail with actionable guidance + // rather than spawning `kt run --headless` with no agent_path. + let provider = KohakuProvider::new(fake_kt(), vec![], HashMap::new(), 30); + let turn_id = uuid::Uuid::now_v7(); + let (tx, mut rx) = tokio::sync::mpsc::channel(256); + + let err = provider + .start_turn( + turn_id, + input("hi"), + policy(), + context(), + tx, + CancellationToken::new(), + ) + .await + .expect_err("should fail when no creature is configured"); + assert!( + err.to_string().contains("no creature configured"), + "error should guide the user, got: {err}" + ); + + let mut failed = false; + while let Some(e) = rx.recv().await { + if e.event_type == EventType::TurnFailed { + failed = true; + } + } + assert!(failed, "should emit TurnFailed"); +} diff --git a/tests/tests/kohaku_live_smoke.rs b/tests/tests/kohaku_live_smoke.rs new file mode 100644 index 0000000..1a03a37 --- /dev/null +++ b/tests/tests/kohaku_live_smoke.rs @@ -0,0 +1,113 @@ +//! Live smoke test for the KohakuTerrarium provider. +//! +//! Skipped by default. Enable with `SWITCHYARD_TEST_KOHAKU=1`. +//! +//! Optional env (the `kt` console script usually is NOT on the global PATH — +//! it lives in KohakuTerrarium's venv, so point at it explicitly): +//! SWITCHYARD_KOHAKU_CMD path to `kt` (default: "kt") +//! SWITCHYARD_KOHAKU_CREATURE creature ref/path (required for the turn test) +//! SWITCHYARD_KOHAKU_LLM --llm selector (e.g. "enzi/gpt-5.5-custom") + +use std::collections::HashMap; + +use switchyard_provider_api::{ + CancellationToken, ContextBundle, EventType, ExecutionPolicy, Provider, TurnInput, +}; +use switchyard_provider_kohaku::KohakuProvider; + +fn should_run() -> bool { + std::env::var("SWITCHYARD_TEST_KOHAKU").is_ok_and(|v| v == "1") +} + +fn kt_cmd() -> String { + std::env::var("SWITCHYARD_KOHAKU_CMD").unwrap_or_else(|_| "kt".to_string()) +} + +#[tokio::test] +async fn kohaku_probe_detects_installation() { + if !should_run() { + eprintln!("skipping: set SWITCHYARD_TEST_KOHAKU=1 to enable"); + return; + } + + let provider = KohakuProvider::new(kt_cmd(), vec![], HashMap::new(), 30); + match provider.probe().await { + Ok(result) => { + assert!(result.available); + println!("kt version: {:?}", result.version); + // The switchyard-headless fork advertises headless support; on a + // stock `kt` this surfaces as an issue rather than a hard failure. + if !result.issues.is_empty() { + println!("probe issues: {:?}", result.issues); + } + } + Err(e) => { + println!("probe failed (expected if kt not installed/on PATH): {e}"); + } + } +} + +#[tokio::test] +async fn kohaku_minimal_turn() { + if !should_run() { + eprintln!("skipping: set SWITCHYARD_TEST_KOHAKU=1 to enable"); + return; + } + let Ok(creature) = std::env::var("SWITCHYARD_KOHAKU_CREATURE") else { + eprintln!("skipping turn: set SWITCHYARD_KOHAKU_CREATURE to a leaf creature ref"); + return; + }; + let model = std::env::var("SWITCHYARD_KOHAKU_LLM").ok(); + + let provider = + KohakuProvider::new_with_options(kt_cmd(), vec![creature], HashMap::new(), 120, model, None); + let turn_id = uuid::Uuid::now_v7(); + let (tx, mut rx) = tokio::sync::mpsc::channel(256); + + let input = TurnInput { + user_message: "Say exactly: hello switchyard".to_string(), + system_prompt: None, + attachments: vec![], + }; + let policy = ExecutionPolicy { + timeout_secs: 120, + write_access: false, + cwd: std::env::current_dir().unwrap(), + allowed_paths: vec![], + }; + let context = ContextBundle { + summary: None, + recent_turns: vec![], + peer_state: vec![], + artifacts: vec![], + }; + + match provider + .start_turn(turn_id, input, policy, context, tx, CancellationToken::new()) + .await + { + Ok(()) => { + let mut completed = false; + let mut streamed = String::new(); + while let Some(e) = rx.recv().await { + if e.event_type == EventType::TurnCompleted { + completed = true; + } + if e.payload.get("item_type").and_then(|v| v.as_str()) == Some("agent_message") + && let Some(t) = e.payload.get("text").and_then(|v| v.as_str()) + { + streamed.push_str(t); + } + } + let (result, _) = provider.finalize_turn(turn_id).await.unwrap(); + println!( + "response: {} (completed={completed}, streamed={streamed:?})", + result.response_text + ); + assert!(!result.response_text.trim().is_empty()); + } + Err(e) => { + println!("turn failed (may be expected without a valid backend): {e}"); + } + } +} From 3dc74e4851304be6cfcf12795735005b739c6486 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:26:05 +0800 Subject: [PATCH 3/4] feat(gui): KohakuTerrarium UX + Explorer/search/settings improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings: kohaku in the provider list + roles; built-in providers always shown/editable; dynamic model dropdown from `kt config llm list --all`; save global settings without an open workspace. - Explorer: VS Code-style collapsible side bar (toggle the active rail icon); file context menu with Cut / Copy / Copy as Path|Relative / Paste (fs_paste_entry) and "Add to Chat" — staged as removable context chips in the composer (not a raw text paste). - Search: VS Code-style panel backed by ripgrep with case / whole-word / regex toggles, file-name mode, hit highlighting, collapsible per-file groups, batch Replace All, and jump-to-line on click (Canvas scrolls + selects the match). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-gui/Cargo.toml | 1 + crates/switchyard-gui/frontend/src/App.tsx | 76 ++- .../frontend/src/components/Canvas.tsx | 22 + .../frontend/src/components/ChatArea.tsx | 20 + .../frontend/src/components/FilesTree.tsx | 92 ++- .../frontend/src/components/IconRail.tsx | 12 +- .../frontend/src/components/SearchPanel.tsx | 358 +++++++++++ .../frontend/src/components/SettingsModal.tsx | 106 +++- .../frontend/src/components/Sidebar.tsx | 2 +- .../frontend/src/providerCliCapabilities.ts | 77 ++- .../frontend/src/services/api.ts | 5 + crates/switchyard-gui/src/main.rs | 562 +++++++++++++++++- 12 files changed, 1278 insertions(+), 55 deletions(-) create mode 100644 crates/switchyard-gui/frontend/src/components/SearchPanel.tsx diff --git a/crates/switchyard-gui/Cargo.toml b/crates/switchyard-gui/Cargo.toml index 66afe24..63730a6 100644 --- a/crates/switchyard-gui/Cargo.toml +++ b/crates/switchyard-gui/Cargo.toml @@ -26,6 +26,7 @@ uuid = { workspace = true } notify = "6" portable-pty = { workspace = true } base64 = "0.22" +regex = "1" [build-dependencies] tauri-build = { version = "2.0.3", features = [] } diff --git a/crates/switchyard-gui/frontend/src/App.tsx b/crates/switchyard-gui/frontend/src/App.tsx index 5d6412a..dfcdc32 100644 --- a/crates/switchyard-gui/frontend/src/App.tsx +++ b/crates/switchyard-gui/frontend/src/App.tsx @@ -21,11 +21,12 @@ import { Sidebar } from './components/Sidebar'; import { IconRail, type RailMode } from './components/IconRail'; import { AppTopBar } from './components/AppTopBar'; import { WelcomeWorkspace } from './components/WelcomeWorkspace'; -import { ChatArea } from './components/ChatArea'; +import { ChatArea, type ComposerApi } from './components/ChatArea'; import type { CanvasMode, CanvasTab } from './components/Canvas'; import { fetchSnapshot, saveFile } from './components/canvasApi'; import { StatusBar } from './components/StatusBar'; import { parseSlash, type SlashContext } from './components/slashCommands'; +import { defaultProviderConfigFor } from './providerCliCapabilities'; // ArtifactDrawer is no longer rendered — its bottom-bar UX didn't fit // the new layout. The import + state are dropped along with the bar. import { renderMessageBody, isSystemStatusText, renderTurnEvents, renderTurnActivitySummary } from './components/ui/RenderHelpers'; @@ -50,6 +51,7 @@ import { const Canvas = lazy(() => import('./components/Canvas')); const ControlCenter = lazy(() => import('./components/ControlCenter')); const FilesTree = lazy(() => import('./components/FilesTree')); +const SearchPanel = lazy(() => import('./components/SearchPanel')); const SettingsModal = lazy(() => import('./components/SettingsModal')); const SourceControl = lazy(() => import('./components/SourceControl')); const TerminalPanel = lazy(() => import('./components/TerminalPanel')); @@ -1954,6 +1956,23 @@ function App() { // Left-rail mode. `chat` shows the session list (current behavior); // `files` / `terminal` are placeholders wired to land in later phases. const [railMode, setRailMode] = useState('chat'); + // VS Code-style collapsible side bar: clicking the active rail icon toggles + // the second column; switching to a different mode always re-expands it. + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const handleRailModeClick = (mode: RailMode) => { + if (mode === railMode) { + setSidebarCollapsed((c) => !c); + } else { + setRailMode(mode); + setSidebarCollapsed(false); + } + }; + // Composer imperative API — the Explorer's "Add to Chat" stages files as + // removable context chips here (AI-IDE style), not as pasted text. + const composerApiRef = useRef(null); + const handleAddPathsToChat = useCallback((paths: string[]) => { + composerApiRef.current?.addAttachmentsFromPaths(paths); + }, []); const [leftColumnWidth, setLeftColumnWidth] = useState(() => readStoredLayoutNumber( 'switchyard.leftColumnWidth', @@ -2032,12 +2051,15 @@ function App() { const [canvasTabs, setCanvasTabs] = useState([]); const [activeCanvasTabId, setActiveCanvasTabId] = useState(null); - const openFileInCanvas = useCallback(async (path: string) => { + const openFileInCanvas = useCallback(async (path: string, line?: number) => { const id = path; - // If the tab already exists, focus it without re-reading. User can - // hit the refresh button if they want a fresh read. + const goto = line ?? null; + // If the tab already exists, focus it (and update the goto-line so a + // search "go to match" still jumps) without re-reading. setCanvasTabs((prev) => { - if (prev.some((t) => t.id === id)) return prev; + if (prev.some((t) => t.id === id)) { + return prev.map((t) => (t.id === id ? { ...t, gotoLine: goto } : t)); + } return [ ...prev, { @@ -2051,6 +2073,7 @@ function App() { dirty: false, saving: false, ai_before_content: null, + gotoLine: goto, }, ]; }); @@ -5370,8 +5393,11 @@ function App() { setConfig((prev) => { if (!prev) return null; const providersCopy = { ...prev.providers }; + // Seed a complete default for a built-in that isn't in the config yet, + // so editing a single field never produces a partial/broken entry. + const base = providersCopy[provider] ?? defaultProviderConfigFor(provider); providersCopy[provider] = { - ...providersCopy[provider], + ...base, [field]: value }; return { @@ -5391,10 +5417,11 @@ function App() { setConfig((prev) => { if (!prev) return null; const providersCopy = { ...prev.providers }; - const envCopy = { ...providersCopy[provider].env }; + const base = providersCopy[provider] ?? defaultProviderConfigFor(provider); + const envCopy = { ...base.env }; envCopy[key] = value; providersCopy[provider] = { - ...providersCopy[provider], + ...base, env: envCopy }; return { ...prev, providers: providersCopy }; @@ -5429,13 +5456,13 @@ function App() { // Choose standard backend template const backend = prompt( - 'Choose provider backend type (codex, claude, gemini, antigravity):', + 'Choose provider backend type (codex, claude, gemini, antigravity, kohaku):', 'codex', ); if (backend === null) return; const trimmedBackend = backend.trim().toLowerCase(); - if (!['codex', 'claude', 'gemini', 'antigravity'].includes(trimmedBackend)) { - alert('Backend must be one of: codex, claude, gemini, antigravity'); + if (!['codex', 'claude', 'gemini', 'antigravity', 'kohaku'].includes(trimmedBackend)) { + alert('Backend must be one of: codex, claude, gemini, antigravity, kohaku'); return; } @@ -5446,7 +5473,9 @@ function App() { ? 'claude' : trimmedBackend === 'gemini' ? 'gemini' - : 'agy'; // antigravity ships as `agy`, not `antigravity-cli` + : trimmedBackend === 'kohaku' + ? 'kt' // KohakuTerrarium ships as `kt` + : 'agy'; // antigravity ships as `agy`, not `antigravity-cli` const defaultArgs: string[] = []; setConfig((prev) => { @@ -5648,6 +5677,10 @@ function App() { style={{ '--switchyard-left-column-width': `${Math.round(leftColumnWidthRef.current)}px`, '--switchyard-canvas-column-width': `${Math.round(canvasColumnWidthRef.current)}px`, + // Collapse the side bar + its sash to zero-width columns (VS Code-style). + ...(sidebarCollapsed + ? { gridTemplateColumns: 'auto 0px 0px minmax(0, 1fr)' } + : {}), } as CSSProperties} > { + setRailMode(m); + setSidebarCollapsed(false); + }} onSwitchWorkspace={handleSwitchWorkspace} onRenameWorkspace={handleRenameWorkspace} onOpenFolder={handleOpenFolderAsWorkspace} @@ -5670,7 +5706,7 @@ function App() { {/* 0. Icon Rail — mode switcher + bottom settings control. */} setDrawerOpen((v) => !v)} onOpenSettings={() => setShowSettings(true)} /> @@ -5683,7 +5719,7 @@ function App() { className="left-column" style={{ width: '100%', - display: 'flex', + display: sidebarCollapsed ? 'none' : 'flex', flexDirection: 'column', flexShrink: 0, borderRight: '1px solid var(--border-muted)', @@ -5717,9 +5753,15 @@ function App() { onOpenFile={openFileInCanvas} onAddFolder={handleAddFolderToCurrentWorkspace} onRemoveExtraRoot={handleRemoveExtraRoot} + onAddToChat={(p) => handleAddPathsToChat([p])} /> )} + {railMode === 'search' && ( + }> + + + )} {railMode === 'source_control' && ( }> { appContainerRef.current?.style.setProperty( @@ -5783,6 +5826,9 @@ function App() {
{currentWorkspace ? ( { + composerApiRef.current = api; + }} selectedSession={selectedSession} isGenerating={isGenerating} turns={turns} diff --git a/crates/switchyard-gui/frontend/src/components/Canvas.tsx b/crates/switchyard-gui/frontend/src/components/Canvas.tsx index 254bd01..661375c 100644 --- a/crates/switchyard-gui/frontend/src/components/Canvas.tsx +++ b/crates/switchyard-gui/frontend/src/components/Canvas.tsx @@ -60,6 +60,9 @@ export interface CanvasTab { /// Revert/Dismiss controls. Set by the TurnCompleted-driven artifact /// intake in App.tsx. ai_before_content: string | null; + /// 1-based line to scroll to + select on open (e.g. from a search hit). + /// Transient — bumped each time a "go to match" requests this file. + gotoLine?: number | null; } interface CanvasProps { @@ -523,6 +526,7 @@ const CanvasBody: React.FC<{ key={tab.id} tabId={tab.id} value={value} + gotoLine={tab.gotoLine} languageExtensions={languageExtensions} onDraftChange={onDraftChange} /> @@ -574,6 +578,7 @@ const vscodeEditorTheme = EditorView.theme({ interface CanvasEditorProps { tabId: string; value: string; + gotoLine?: number | null; languageExtensions: Extension[]; onDraftChange: (tabId: string, draft: string) => void; } @@ -581,6 +586,7 @@ interface CanvasEditorProps { const CanvasEditor: React.FC = ({ tabId, value, + gotoLine, languageExtensions, onDraftChange, }) => { @@ -653,6 +659,22 @@ const CanvasEditor: React.FC = ({ scheduleEditorStateRead(); }, [scheduleEditorStateRead, value]); + // Jump to a requested line (search "go to match"): scroll it to center + + // place the cursor there. Re-runs on `value` so it still fires once the + // file content finishes loading after the tab opens. + useEffect(() => { + if (gotoLine == null || gotoLine < 1) return; + const view = viewRef.current; + if (!view) return; + const ln = Math.min(gotoLine, view.state.doc.lines); + const info = view.state.doc.line(ln); + view.dispatch({ + selection: { anchor: info.from }, + effects: EditorView.scrollIntoView(info.from, { y: 'center' }), + }); + view.focus(); + }, [gotoLine, value]); + useEffect(() => { return () => { if (scrollElementRef.current && scrollHandlerRef.current) { diff --git a/crates/switchyard-gui/frontend/src/components/ChatArea.tsx b/crates/switchyard-gui/frontend/src/components/ChatArea.tsx index 9137867..61ad71f 100644 --- a/crates/switchyard-gui/frontend/src/components/ChatArea.tsx +++ b/crates/switchyard-gui/frontend/src/components/ChatArea.tsx @@ -77,6 +77,9 @@ interface ChatAreaProps { historyHasMoreBefore?: boolean; onLoadOlderHistory?: () => void | Promise; isLoadingOlderHistory?: boolean; + /// Registers the composer's imperative API (used by the Explorer's + /// "Add to Chat" to stage context files as chips). + onComposerReady?: (api: ComposerApi) => void; } const SANDBOX_OPTIONS: Array<{ @@ -1599,6 +1602,7 @@ export const ChatArea: React.FC = ({ historyHasMoreBefore = false, onLoadOlderHistory, isLoadingOlderHistory = false, + onComposerReady, }) => { const messagesContainerRef = useRef(null); const messagesContentRef = useRef(null); @@ -2761,11 +2765,19 @@ export const ChatArea: React.FC = ({ handleCancel={composerCancel} sandboxMode={sandboxMode} onSandboxModeChange={onSandboxModeChange} + onReady={onComposerReady} />
); }; +/// Imperative handle the composer registers so other panes (e.g. the file +/// Explorer's "Add to Chat") can attach context files as removable chips +/// instead of pasting raw text — the standard AI-IDE pattern. +export interface ComposerApi { + addAttachmentsFromPaths: (paths: string[]) => void; +} + interface ChatComposerProps { selectedSession: Session | null; isGenerating: boolean; @@ -2773,6 +2785,7 @@ interface ChatComposerProps { handleCancel: () => void; sandboxMode: SandboxMode; onSandboxModeChange: (mode: SandboxMode) => void | Promise; + onReady?: (api: ComposerApi) => void; } /// Keep the hot typing state local to the composer. Previously `inputText` @@ -2788,6 +2801,7 @@ const ChatComposer: React.FC = React.memo(({ handleCancel, sandboxMode, onSandboxModeChange, + onReady, }) => { const [inputText, setInputText] = useState(''); const [attachments, setAttachments] = useState([]); @@ -2829,6 +2843,12 @@ const ChatComposer: React.FC = React.memo(({ }); }, []); + // Expose the composer API so other panes (file Explorer "Add to Chat") + // can stage context files as removable chips here. + useEffect(() => { + onReady?.({ addAttachmentsFromPaths }); + }, [onReady, addAttachmentsFromPaths]); + const addOrPersistNativeAttachmentPaths = useCallback(async (paths: string[]) => { const savedPaths: string[] = []; for (const rawPath of paths) { diff --git a/crates/switchyard-gui/frontend/src/components/FilesTree.tsx b/crates/switchyard-gui/frontend/src/components/FilesTree.tsx index b476113..ed8705c 100644 --- a/crates/switchyard-gui/frontend/src/components/FilesTree.tsx +++ b/crates/switchyard-gui/frontend/src/components/FilesTree.tsx @@ -82,17 +82,25 @@ interface FilesTreeProps { onOpenFile: (path: string) => void; onAddFolder: () => void; onRemoveExtraRoot: (root: string) => void; + /// Stage a file as a context chip in the chat composer (AI-IDE "Add to Chat"). + onAddToChat?: (path: string) => void; } /// Top-level Explorer. Unlike the first version, it renders synthetic /// root nodes instead of only showing the primary root's children, which /// makes workspace scope explicit and allows additional project folders. +function parentDirOf(p: string): string { + const i = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')); + return i >= 0 ? p.slice(0, i) : ''; +} + export const FilesTree: React.FC = ({ workspace, gitRefreshNonce, onOpenFile, onAddFolder, onRemoveExtraRoot, + onAddToChat, }) => { const [gitStatus, setGitStatus] = useState(null); const [treeRefreshNonce, setTreeRefreshNonce] = useState(0); @@ -177,6 +185,20 @@ export const FilesTree: React.FC = ({ setTreeRefreshNonce((value) => value + 1); }; + // Explorer cut/copy → paste clipboard. Paths are workspace-relative + // (same scope as list_dir); the backend `fs_paste_entry` resolves + copies/moves. + const [clipboard, setClipboard] = useState<{ path: string; mode: 'copy' | 'cut' } | null>(null); + const pasteInto = async (destDir: string) => { + if (!clipboard) return; + try { + await invoke('fs_paste_entry', { src: clipboard.path, destDir, mode: clipboard.mode }); + } catch (error) { + console.error('paste failed', error); + } + if (clipboard.mode === 'cut') setClipboard(null); + refreshTree(); + }; + const showContextMenu = ( event: React.MouseEvent, items: ContextMenuItem[], @@ -194,6 +216,15 @@ export const FilesTree: React.FC = ({ onSelect: onAddFolder, }, { id: 'refresh', label: 'Refresh Explorer', onSelect: refreshTree }, + ...(clipboard + ? [ + { + id: 'paste', + label: 'Paste', + onSelect: () => pasteInto('.'), + } satisfies ContextMenuItem, + ] + : []), ]); }; @@ -264,6 +295,11 @@ export const FilesTree: React.FC = ({ onOpenFile={onOpenFile} onAddFolder={onAddFolder} onRemoveExtraRoot={onRemoveExtraRoot} + onAddToChat={onAddToChat} + clipboardActive={clipboard != null} + onCut={(p) => setClipboard({ path: p, mode: 'cut' })} + onCopy={(p) => setClipboard({ path: p, mode: 'copy' })} + onPaste={pasteInto} decorations={decorations} copyText={copyText} showContextMenu={showContextMenu} @@ -293,6 +329,11 @@ const FilesTreeNode: React.FC<{ onOpenFile: (path: string) => void; onAddFolder: () => void; onRemoveExtraRoot: (root: string) => void; + onAddToChat?: (path: string) => void; + clipboardActive?: boolean; + onCut?: (path: string) => void; + onCopy?: (path: string) => void; + onPaste?: (destDir: string) => void; decorations: Map; copyText: (text: string) => Promise; showContextMenu: (event: React.MouseEvent, items: ContextMenuItem[]) => void; @@ -303,6 +344,11 @@ const FilesTreeNode: React.FC<{ onOpenFile, onAddFolder, onRemoveExtraRoot, + onAddToChat, + clipboardActive, + onCut, + onCopy, + onPaste, decorations, copyText, showContextMenu, @@ -413,15 +459,31 @@ const FilesTreeNode: React.FC<{ onSelect: expanded ? collapseNode : expandNode, }, { id: 'refresh', label: 'Refresh', onSelect: refreshNode }, + { id: 'sep-cut', separator: true }, + ...(!node.isRoot + ? [ + { id: 'cut', label: 'Cut', onSelect: () => onCut?.(node.path) } satisfies ContextMenuItem, + { id: 'copy', label: 'Copy', onSelect: () => onCopy?.(node.path) } satisfies ContextMenuItem, + ] + : []), + ...(clipboardActive + ? [ + { + id: 'paste', + label: 'Paste', + onSelect: () => onPaste?.(node.path), + } satisfies ContextMenuItem, + ] + : []), { id: 'sep-open', separator: true }, { id: 'copy-path', - label: 'Copy Path', + label: 'Copy as Path', onSelect: () => copyText(absolutePath), }, { id: 'copy-relative-path', - label: 'Copy Relative Path', + label: 'Copy as Relative Path', onSelect: () => copyText(relativePath), }, { id: 'sep-workspace', separator: true }, @@ -442,16 +504,33 @@ const FilesTreeNode: React.FC<{ : []), ] : [ + { + id: 'add-to-chat', + label: 'Add to Chat', + onSelect: () => onAddToChat?.(absolutePath), + }, + { id: 'sep-chat', separator: true }, + { id: 'cut', label: 'Cut', onSelect: () => onCut?.(node.path) }, + { id: 'copy', label: 'Copy', onSelect: () => onCopy?.(node.path) }, + ...(clipboardActive + ? [ + { + id: 'paste', + label: 'Paste', + onSelect: () => onPaste?.(parentDirOf(node.path)), + } satisfies ContextMenuItem, + ] + : []), { id: 'open', label: 'Open', onSelect: () => onOpenFile(node.path) }, { id: 'sep-open', separator: true }, { id: 'copy-path', - label: 'Copy Path', + label: 'Copy as Path', onSelect: () => copyText(absolutePath), }, { id: 'copy-relative-path', - label: 'Copy Relative Path', + label: 'Copy as Relative Path', onSelect: () => copyText(relativePath), }, ]; @@ -569,6 +648,11 @@ const FilesTreeNode: React.FC<{ onOpenFile={onOpenFile} onAddFolder={onAddFolder} onRemoveExtraRoot={onRemoveExtraRoot} + onAddToChat={onAddToChat} + clipboardActive={clipboardActive} + onCut={onCut} + onCopy={onCopy} + onPaste={onPaste} decorations={decorations} copyText={copyText} showContextMenu={showContextMenu} diff --git a/crates/switchyard-gui/frontend/src/components/IconRail.tsx b/crates/switchyard-gui/frontend/src/components/IconRail.tsx index 840fd4c..ae14a0a 100644 --- a/crates/switchyard-gui/frontend/src/components/IconRail.tsx +++ b/crates/switchyard-gui/frontend/src/components/IconRail.tsx @@ -2,15 +2,16 @@ import React from 'react'; import { MessageSquare, FolderOpen, + Search, GitBranch, Activity, Settings as SettingsIcon, } from 'lucide-react'; -/// Modes the second column (Workspace / Files / Source Control) can show. +/// Modes the second column (Workspace / Files / Search / Source Control) can show. /// Terminal is deliberately not a rail mode — its toggle lives in the /// bottom StatusBar (matching VS Code's status-bar-driven terminal panel). -export type RailMode = 'chat' | 'files' | 'source_control'; +export type RailMode = 'chat' | 'files' | 'search' | 'source_control'; interface IconRailProps { mode: RailMode; @@ -127,6 +128,13 @@ export const IconRail: React.FC = ({ > + onModeChange('search')} + title="Search — find in workspace" + > + + onModeChange('source_control')} diff --git a/crates/switchyard-gui/frontend/src/components/SearchPanel.tsx b/crates/switchyard-gui/frontend/src/components/SearchPanel.tsx new file mode 100644 index 0000000..71d2740 --- /dev/null +++ b/crates/switchyard-gui/frontend/src/components/SearchPanel.tsx @@ -0,0 +1,358 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { invoke } from '@tauri-apps/api/core'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +/// One match from the backend `search_workspace`. `path` is workspace-relative; +/// `spans` are char-offset [start,end) ranges within `text` to highlight. +interface SearchMatch { + path: string; + line: number; + text: string; + spans: [number, number][]; +} + +interface SearchOptions { + caseSensitive: boolean; + wholeWord: boolean; + regex: boolean; + filename: boolean; + maxResults?: number; +} + +interface SearchPanelProps { + /// Open a (workspace-relative) file path in the Canvas, optionally jumping to + /// a 1-based line. + onOpenFile: (path: string, line?: number) => void; +} + +function renderHighlighted(text: string, spans: [number, number][]): React.ReactNode { + if (!spans || spans.length === 0) return text; + const sorted = [...spans].sort((a, b) => a[0] - b[0]); + const out: React.ReactNode[] = []; + let cursor = 0; + sorted.forEach(([s, e], i) => { + const start = Math.max(s, cursor); + if (start > cursor) out.push(text.slice(cursor, start)); + if (e > start) { + out.push( + + {text.slice(start, e)} + , + ); + } + cursor = Math.max(cursor, e); + }); + if (cursor < text.length) out.push(text.slice(cursor)); + return out; +} + +const ToggleButton: React.FC<{ + active: boolean; + title: string; + onClick: () => void; + children: React.ReactNode; +}> = ({ active, title, onClick, children }) => ( + +); + +/// VS Code-style search + replace panel: content / filename modes, case / +/// whole-word / regex toggles, hit highlighting, collapsible per-file groups, +/// click-to-open at the matched line, and batch replace. +export const SearchPanel: React.FC = ({ onOpenFile }) => { + const [query, setQuery] = useState(''); + const [replaceText, setReplaceText] = useState(''); + const [showReplace, setShowReplace] = useState(false); + const [caseSensitive, setCaseSensitive] = useState(false); + const [wholeWord, setWholeWord] = useState(false); + const [regex, setRegex] = useState(false); + const [filenameMode, setFilenameMode] = useState(false); + + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [collapsed, setCollapsed] = useState>(new Set()); + const [status, setStatus] = useState(null); + const reqRef = useRef(0); + + const options: SearchOptions = useMemo( + () => ({ caseSensitive, wholeWord, regex, filename: filenameMode, maxResults: 1000 }), + [caseSensitive, wholeWord, regex, filenameMode], + ); + + useEffect(() => { + const q = query.trim(); + if (!q) { + setResults([]); + setError(null); + setLoading(false); + return; + } + const id = ++reqRef.current; + setLoading(true); + const timer = setTimeout(async () => { + try { + const r = await invoke('search_workspace', { query: q, options }); + if (reqRef.current === id) { + setResults(r); + setError(null); + } + } catch (e) { + if (reqRef.current === id) { + setError(String(e)); + setResults([]); + } + } finally { + if (reqRef.current === id) setLoading(false); + } + }, 250); + return () => clearTimeout(timer); + }, [query, options]); + + const groups = useMemo(() => { + const map = new Map(); + for (const r of results) { + const arr = map.get(r.path); + if (arr) arr.push(r); + else map.set(r.path, [r]); + } + return Array.from(map.entries()); + }, [results]); + + const toggleCollapse = (path: string) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }; + + const handleReplaceAll = async () => { + const q = query.trim(); + if (!q) return; + const total = results.length; + if ( + !window.confirm( + `Replace all ${total} match${total === 1 ? '' : 'es'} with "${replaceText}"? This writes to disk and cannot be undone here.`, + ) + ) { + return; + } + setStatus('Replacing…'); + try { + const summary = await invoke<{ filesChanged: number; replacements: number }>( + 'replace_in_workspace', + { query: q, replacement: replaceText, options }, + ); + setStatus( + `Replaced ${summary.replacements} occurrence${summary.replacements === 1 ? '' : 's'} in ${summary.filesChanged} file${summary.filesChanged === 1 ? '' : 's'}.`, + ); + // Re-run the search to reflect the new state. + const id = ++reqRef.current; + const r = await invoke('search_workspace', { query: q, options }); + if (reqRef.current === id) setResults(r); + } catch (e) { + setStatus(`Replace failed: ${String(e)}`); + } + }; + + return ( +
+
+ SEARCH +
+ +
+
+ {!filenameMode && ( + + )} + setQuery(e.target.value)} + style={{ flex: 1, minWidth: 0 }} + /> + setCaseSensitive((v) => !v)}> + Aa + + setWholeWord((v) => !v)}> + ab + + setRegex((v) => !v)}> + .* + +
+ + {showReplace && !filenameMode && ( +
+ setReplaceText(e.target.value)} + style={{ flex: 1, minWidth: 0 }} + /> + +
+ )} + +
+ +
+
+ +
+ {loading &&
Searching…
} + {error && ( +
{error}
+ )} + {status && !error && ( +
{status}
+ )} + {!loading && !error && query.trim() !== '' && results.length === 0 && ( +
No results
+ )} + {!loading && results.length > 0 && ( +
+ {results.length} result{results.length === 1 ? '' : 's'} in {groups.length} file + {groups.length === 1 ? '' : 's'} +
+ )} + {groups.map(([path, hits]) => { + const isCollapsed = collapsed.has(path); + return ( +
+ + {!filenameMode && + !isCollapsed && + hits.map((h, idx) => ( + + ))} +
+ ); + })} +
+
+ ); +}; + +export default SearchPanel; diff --git a/crates/switchyard-gui/frontend/src/components/SettingsModal.tsx b/crates/switchyard-gui/frontend/src/components/SettingsModal.tsx index b6b4436..bcf8aee 100644 --- a/crates/switchyard-gui/frontend/src/components/SettingsModal.tsx +++ b/crates/switchyard-gui/frontend/src/components/SettingsModal.tsx @@ -1,7 +1,14 @@ import React from 'react'; import { X, Plus, Trash } from 'lucide-react'; import type { SwitchyardConfig, ProviderConfig } from '../types'; -import { THINKING_LEVEL_OPTIONS, providerCliMapping } from '../providerCliCapabilities'; +import { + THINKING_LEVEL_OPTIONS, + providerCliMapping, + BUILTIN_PROVIDER_NAMES, + defaultProviderConfigFor, + inferProviderBackend, +} from '../providerCliCapabilities'; +import { listKohakuModels } from '../services/api'; interface SettingsModalProps { config: SwitchyardConfig; @@ -30,6 +37,28 @@ export const SettingsModal: React.FC = ({ onRemoveEnvVar, onDeleteProvider, }) => { + // Built-in providers are always offered (even when absent from the active + // workspace's switchyard.toml), so kohaku/codex/claude/... are configurable + // out of the box. Editing one seeds a complete default entry (see + // handleProviderFieldChange / addEnvVar in App.tsx). + const providerNames = Array.from( + new Set([...Object.keys(config.providers || {}), ...BUILTIN_PROVIDER_NAMES]), + ); + + // KohakuTerrarium model selectors are listed dynamically from `kt model + // list` (the user's configured llm profiles), like KT's own app. + const [kohakuModels, setKohakuModels] = React.useState([]); + React.useEffect(() => { + const prov = config.providers[settingsTab] || defaultProviderConfigFor(settingsTab); + if (inferProviderBackend(settingsTab, prov.backend) === 'kohaku') { + listKohakuModels(prov.command || 'kt') + .then(setKohakuModels) + .catch(() => setKohakuModels([])); + } else { + setKohakuModels([]); + } + }, [settingsTab, config]); + return (
@@ -51,7 +80,7 @@ export const SettingsModal: React.FC = ({ {/* Dynamic tabs for configured providers */} - {Object.keys(config.providers || {}).map((pName) => ( + {providerNames.map((pName) => (
-
- - onProviderFieldChange(pName, 'model', e.target.value || null)} - /> -
+ {(() => { + // kohaku pulls kt's live model table; other backends have + // no machine-readable model list, so free text (no stale + // hardcoded suggestions). + const modelOptions = + cliMapping.backend === 'kohaku' ? kohakuModels : []; + const listId = `model-options-${pName}`; + return ( +
+ + 0 ? listId : undefined} + className="settings-input settings-input-mono" + value={prov.model ?? ''} + placeholder={ + cliMapping.backend === 'kohaku' + ? 'Pick a kt profile (provider/preset) or type one, e.g. enzi/gpt-5.5-custom' + : cliMapping.modelMapped + ? 'Pick or type, e.g. gpt-5-codex, claude-sonnet-4-5, gemini-2.5-pro' + : 'Stored only; this backend does not map it to a stable CLI flag' + } + onChange={(e) => + onProviderFieldChange(pName, 'model', e.target.value || null) + } + /> + {modelOptions.length > 0 && ( + + {modelOptions.map((m) => ( + + )} + {cliMapping.backend === 'kohaku' && kohakuModels.length === 0 && ( +
+ No kt profiles found. Add one with `kt config key set <provider>` / + `kt login <provider>`, then reopen settings. +
+ )} +
+ ); + })()}
diff --git a/crates/switchyard-gui/frontend/src/components/Sidebar.tsx b/crates/switchyard-gui/frontend/src/components/Sidebar.tsx index 3f9a8ca..c84523d 100644 --- a/crates/switchyard-gui/frontend/src/components/Sidebar.tsx +++ b/crates/switchyard-gui/frontend/src/components/Sidebar.tsx @@ -51,7 +51,7 @@ export const Sidebar: React.FC = ({ onRenameSession, }) => { const providers = config ? Object.keys(config.providers || {}) : []; - const defaults = ['codex', 'claude', 'gemini', 'antigravity']; + const defaults = ['codex', 'claude', 'gemini', 'antigravity', 'kohaku']; const allProviders = Array.from(new Set([...providers, ...defaults])); const [editingSessionId, setEditingSessionId] = React.useState(null); diff --git a/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts b/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts index 6bbbff7..22ca822 100644 --- a/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts +++ b/crates/switchyard-gui/frontend/src/providerCliCapabilities.ts @@ -1,4 +1,62 @@ -export type ProviderBackend = 'codex' | 'claude' | 'gemini' | 'antigravity' | string; +import type { ProviderConfig } from './types'; + +export type ProviderBackend = + | 'codex' + | 'claude' + | 'gemini' + | 'antigravity' + | 'kohaku' + | string; + +/// Built-in provider backends, always offered in the UI even when the active +/// workspace's switchyard.toml does not list them (mirrors the Rust +/// `BUILT_IN_PROVIDER_ALIASES`). +export const BUILTIN_PROVIDER_NAMES = [ + 'codex', + 'claude', + 'gemini', + 'antigravity', + 'kohaku', +]; + +/// Default subprocess command for a known backend (mirrors the Rust +/// `default_provider_command`). +export function defaultProviderCommand(backend: ProviderBackend): string { + switch (backend) { + case 'codex': + return 'codex'; + case 'claude': + return 'claude'; + case 'gemini': + return 'gemini'; + case 'antigravity': + return 'agy'; + case 'kohaku': + return 'kt'; + default: + return ''; + } +} + +/// A complete default ProviderConfig for a provider name — used to seed an +/// editable Settings entry for a built-in that isn't in the config yet, so +/// partial/broken entries are never created on first edit. +export function defaultProviderConfigFor(name: string): ProviderConfig { + const backend = inferProviderBackend(name); + // KohakuTerrarium needs a creature as the first arg; default to the + // official kt-biome `general` creature so it works out of the box. + const args = backend === 'kohaku' ? ['@kt-biome/creatures/general'] : []; + return { + command: defaultProviderCommand(backend), + args, + env: {}, + model: null, + thinking_level: null, + timeout_secs: 0, + backend: backend || null, + }; +} + export interface ThinkingLevelOption { value: string; @@ -36,6 +94,7 @@ export function inferProviderBackend( if (name.includes('claude')) return 'claude'; if (name.includes('antigravity') || name.includes('agy')) return 'antigravity'; if (name.includes('gemini')) return 'gemini'; + if (name.includes('kohaku')) return 'kohaku'; return ''; } @@ -85,6 +144,22 @@ export function providerCliMapping( modelHint: 'Not mapped; agy keeps its configured/default model.', thinkingHint: 'Not mapped; agy keeps its configured/default thinking behavior.', }; + case 'kohaku': + return { + backend: inferred, + modelMapped: true, + thinkingMapped: false, + summary: + 'KohakuTerrarium (kt): Switchyard runs `kt run --headless --json -p `. ' + + 'Set "Subprocess CLI Command" to `kt` if it is on PATH, otherwise the full path to kt.exe ' + + '(e.g. \\Scripts\\kt.exe). The FIRST CLI Execution Argument must be the creature ref ' + + '(a config-folder path or @pkg/creatures/). Default Model maps to `--llm `; ' + + 'the sandbox mode maps to `--sandbox READ_ONLY|WORKSPACE|off`. Requires a kt with headless ' + + 'support (the switchyard-headless fork).', + modelHint: '--llm (e.g. enzi/gpt-5.5-custom)', + thinkingHint: + 'Not a separate flag; encode reasoning in the --llm selector, e.g. @reasoning=low.', + }; default: return { backend: inferred, diff --git a/crates/switchyard-gui/frontend/src/services/api.ts b/crates/switchyard-gui/frontend/src/services/api.ts index a3b59e7..e5e2fce 100644 --- a/crates/switchyard-gui/frontend/src/services/api.ts +++ b/crates/switchyard-gui/frontend/src/services/api.ts @@ -33,6 +33,11 @@ export const saveConfig = (config: SwitchyardConfig): Promise => { return invoke('save_config', { config }); }; +// List KohakuTerrarium model selectors (provider/preset) for the model dropdown. +export const listKohakuModels = (command?: string): Promise => { + return invoke('list_kohaku_models', { command: command ?? null }); +}; + export const runTurn = ( sessionId: string, message: string, diff --git a/crates/switchyard-gui/src/main.rs b/crates/switchyard-gui/src/main.rs index fde5bef..82b1b8c 100644 --- a/crates/switchyard-gui/src/main.rs +++ b/crates/switchyard-gui/src/main.rs @@ -2244,6 +2244,480 @@ async fn list_dir( Ok(entries) } +/// Copy or move a file/folder within the current workspace (Explorer +/// cut/copy → paste). `src` and `dest_dir` are workspace-relative (resolved +/// the same scoped way as `list_dir`); an empty/"." `dest_dir` means the +/// primary root. `mode` is "cut" (move) or "copy". Target name collisions are +/// disambiguated with a " copy" suffix rather than clobbering. +#[tauri::command] +async fn fs_paste_entry( + workspace_state: tauri::State<'_, WorkspaceState>, + src: String, + dest_dir: String, + mode: String, +) -> Result<(), String> { + let ws = workspace_state.current()?; + let repo_root = workspace_state.git_repo_root(); + let src_path = resolve_workspace_path(&ws, &src, repo_root.as_deref())?; + let dest_parent = if dest_dir.trim().is_empty() || dest_dir == "." { + ws.primary_root.clone() + } else { + resolve_workspace_path(&ws, &dest_dir, repo_root.as_deref())? + }; + if !dest_parent.is_dir() { + return Err(format!("destination is not a folder: {}", dest_parent.display())); + } + let name = src_path + .file_name() + .ok_or_else(|| "invalid source path".to_string())? + .to_owned(); + let mut target = dest_parent.join(&name); + if target == src_path || target.exists() { + target = unique_paste_target(&dest_parent, &src_path); + } + if src_path.is_dir() && target.starts_with(&src_path) { + return Err("cannot paste a folder into itself".to_string()); + } + + let cut = mode == "cut"; + let outcome = (|| -> std::io::Result<()> { + if cut { + if std::fs::rename(&src_path, &target).is_ok() { + return Ok(()); + } + // Cross-device / non-empty-dir rename fallback: copy then delete. + copy_recursive(&src_path, &target)?; + if src_path.is_dir() { + std::fs::remove_dir_all(&src_path)?; + } else { + std::fs::remove_file(&src_path)?; + } + Ok(()) + } else { + copy_recursive(&src_path, &target) + } + })(); + outcome.map_err(|e| format!("paste failed: {e}")) +} + +/// Pick a non-colliding target name in `dir` based on `src`'s name, e.g. +/// `report.md` → `report copy.md` → `report copy 2.md`. +fn unique_paste_target(dir: &std::path::Path, src: &std::path::Path) -> std::path::PathBuf { + let stem = src + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default(); + let ext = src + .extension() + .map(|e| format!(".{}", e.to_string_lossy())) + .unwrap_or_default(); + let mut n = 1u32; + loop { + let suffix = if n == 1 { + " copy".to_string() + } else { + format!(" copy {n}") + }; + let candidate = dir.join(format!("{stem}{suffix}{ext}")); + if !candidate.exists() { + return candidate; + } + n += 1; + } +} + +/// Recursively copy a file or directory tree. +fn copy_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { + if src.is_dir() { + std::fs::create_dir_all(dst)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + copy_recursive(&entry.path(), &dst.join(entry.file_name()))?; + } + Ok(()) + } else { + if let Some(parent) = dst.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dst).map(|_| ()) + } +} + +#[derive(serde::Serialize)] +struct SearchMatch { + path: String, + line: u32, + text: String, + /// Char-offset [start, end) spans of matches within `text`, for highlighting. + spans: Vec<[u32; 2]>, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct ReplaceSummary { + files_changed: u32, + replacements: u32, +} + +#[derive(serde::Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] +struct SearchOptions { + #[serde(default)] + case_sensitive: bool, + #[serde(default)] + whole_word: bool, + #[serde(default)] + regex: bool, + /// Match file paths instead of file contents. + #[serde(default)] + filename: bool, + #[serde(default)] + max_results: Option, +} + +/// Build a Rust regex from the query + toggles (used for filename search and +/// replace; content search uses ripgrep with matching flags). +fn build_search_regex(query: &str, opts: &SearchOptions) -> Result { + let mut pattern = if opts.regex { + query.to_string() + } else { + regex::escape(query) + }; + if opts.whole_word { + pattern = format!(r"\b(?:{pattern})\b"); + } + regex::RegexBuilder::new(&pattern) + .case_insensitive(!opts.case_sensitive) + .build() + .map_err(|e| format!("invalid pattern: {e}")) +} + +/// Number of chars before byte offset `byte_idx` in `s` (byte → char index). +fn byte_to_char(s: &str, byte_idx: usize) -> u32 { + s.char_indices().take_while(|(b, _)| *b < byte_idx).count() as u32 +} + +/// VS Code-style workspace search across the primary root. Content mode uses +/// ripgrep (fast, .gitignore-aware) when available, else a bounded built-in +/// walk; filename mode matches file paths. Honors case / whole-word / regex. +/// `path` is workspace-relative; `spans` mark hits for highlighting. +#[tauri::command] +async fn search_workspace( + workspace_state: tauri::State<'_, WorkspaceState>, + query: String, + options: Option, +) -> Result, String> { + let ws = workspace_state.current()?; + if query.trim().is_empty() { + return Ok(Vec::new()); + } + let opts = options.unwrap_or_default(); + let root = ws.primary_root.clone(); + let cap = opts.max_results.unwrap_or(500).min(2000); + if opts.filename { + return filename_search(&root, &query, &opts, cap).await; + } + if let Some(hits) = ripgrep_search(&root, &query, &opts, cap).await { + return Ok(hits); + } + fallback_search(&root, &query, &opts, cap) +} + +fn rg_match_flags(cmd: &mut tokio::process::Command, opts: &SearchOptions) { + if !opts.regex { + cmd.arg("--fixed-strings"); + } + if opts.whole_word { + cmd.arg("--word-regexp"); + } + if opts.case_sensitive { + cmd.arg("--case-sensitive"); + } else { + cmd.arg("--ignore-case"); + } +} + +#[cfg(windows)] +fn rg_no_window(cmd: &mut tokio::process::Command) { + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); +} +#[cfg(not(windows))] +fn rg_no_window(_cmd: &mut tokio::process::Command) {} + +async fn ripgrep_search( + root: &std::path::Path, + query: &str, + opts: &SearchOptions, + cap: usize, +) -> Option> { + let mut cmd = tokio::process::Command::new("rg"); + cmd.arg("--json") + .arg("--line-number") + .arg("--no-heading") + .arg("--max-count") + .arg("200"); + rg_match_flags(&mut cmd, opts); + cmd.arg("-e").arg(query).arg(root); + rg_no_window(&mut cmd); + // Spawn failure (rg not installed) → None so we fall back. A non-zero exit + // (no matches) still yields Ok with empty stdout, which is fine. + let output = cmd.output().await.ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut hits = Vec::new(); + for line in stdout.lines() { + if hits.len() >= cap { + break; + } + let Ok(v) = serde_json::from_str::(line) else { + continue; + }; + if v.get("type").and_then(|t| t.as_str()) != Some("match") { + continue; + } + let Some(data) = v.get("data") else { continue }; + let abs = data + .get("path") + .and_then(|p| p.get("text")) + .and_then(|t| t.as_str()); + let line_no = data.get("line_number").and_then(|n| n.as_u64()); + let raw = data + .get("lines") + .and_then(|l| l.get("text")) + .and_then(|t| t.as_str()); + let (Some(abs), Some(line_no), Some(raw)) = (abs, line_no, raw) else { + continue; + }; + let trimmed = raw.trim_end_matches(['\r', '\n']); + let display: String = trimmed.chars().take(400).collect(); + let max_char = display.chars().count() as u32; + let mut spans = Vec::new(); + if let Some(subs) = data.get("submatches").and_then(|s| s.as_array()) { + for sm in subs { + let bs = sm.get("start").and_then(|x| x.as_u64()); + let be = sm.get("end").and_then(|x| x.as_u64()); + if let (Some(bs), Some(be)) = (bs, be) { + let cs = byte_to_char(trimmed, bs as usize); + let ce = byte_to_char(trimmed, be as usize).min(max_char); + if cs < max_char && ce > cs { + spans.push([cs, ce]); + } + } + } + } + let rel = std::path::Path::new(abs) + .strip_prefix(root) + .ok() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| abs.to_string()); + hits.push(SearchMatch { + path: rel, + line: line_no as u32, + text: display, + spans, + }); + } + Some(hits) +} + +fn fallback_search( + root: &std::path::Path, + query: &str, + opts: &SearchOptions, + cap: usize, +) -> Result, String> { + let re = build_search_regex(query, opts)?; + let mut out = Vec::new(); + for file in walk_collect_files(root, 50_000) { + if out.len() >= cap { + break; + } + let Ok(content) = std::fs::read_to_string(&file) else { + continue; + }; + let rel = file + .strip_prefix(root) + .unwrap_or(&file) + .to_string_lossy() + .to_string(); + for (i, line) in content.lines().enumerate() { + if out.len() >= cap { + break; + } + let display: String = line.chars().take(400).collect(); + let max_char = display.chars().count() as u32; + let spans: Vec<[u32; 2]> = re + .find_iter(line) + .map(|m| { + [ + byte_to_char(line, m.start()), + byte_to_char(line, m.end()).min(max_char), + ] + }) + .filter(|s| s[0] < max_char && s[1] > s[0]) + .collect(); + if !spans.is_empty() { + out.push(SearchMatch { + path: rel.clone(), + line: (i + 1) as u32, + text: display, + spans, + }); + } + } + } + Ok(out) +} + +async fn filename_search( + root: &std::path::Path, + query: &str, + opts: &SearchOptions, + cap: usize, +) -> Result, String> { + let re = build_search_regex(query, opts)?; + let files = match ripgrep_list_files(root).await { + Some(f) => f, + None => walk_collect_files(root, 50_000), + }; + let mut out = Vec::new(); + for file in files { + if out.len() >= cap { + break; + } + let rel = file + .strip_prefix(root) + .unwrap_or(&file) + .to_string_lossy() + .to_string(); + if let Some(m) = re.find(&rel) { + let cs = byte_to_char(&rel, m.start()); + let ce = byte_to_char(&rel, m.end()); + out.push(SearchMatch { + path: rel.clone(), + line: 0, + text: rel, + spans: vec![[cs, ce]], + }); + } + } + Ok(out) +} + +/// List all files via `rg --files` (gitignore-aware). None if rg is absent. +async fn ripgrep_list_files(root: &std::path::Path) -> Option> { + let mut cmd = tokio::process::Command::new("rg"); + cmd.arg("--files").arg(root); + rg_no_window(&mut cmd); + let output = cmd.output().await.ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + Some(stdout.lines().map(std::path::PathBuf::from).collect()) +} + +/// Bounded recursive file walk (fallback when ripgrep is unavailable). +fn walk_collect_files(root: &std::path::Path, limit: usize) -> Vec { + let skip = [ + "node_modules", + ".git", + "target", + "dist", + ".venv", + "__pycache__", + ]; + let mut files = Vec::new(); + let mut stack = vec![root.to_path_buf()]; + while let Some(dir) = stack.pop() { + if files.len() >= limit { + break; + } + let Ok(rd) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in rd.flatten() { + let p = entry.path(); + let name = entry.file_name().to_string_lossy().to_string(); + let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false); + if is_dir { + if !name.starts_with('.') && !skip.contains(&name.as_str()) { + stack.push(p); + } + } else if files.len() < limit { + files.push(p); + } + } + } + files +} + +/// Files containing at least one match, via `rg --files-with-matches`. None if +/// rg is absent (callers fall back to a full walk + per-file regex check). +async fn ripgrep_files_with_matches( + root: &std::path::Path, + query: &str, + opts: &SearchOptions, +) -> Option> { + let mut cmd = tokio::process::Command::new("rg"); + cmd.arg("--files-with-matches"); + rg_match_flags(&mut cmd, opts); + cmd.arg("-e").arg(query).arg(root); + rg_no_window(&mut cmd); + let output = cmd.output().await.ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + Some(stdout.lines().map(std::path::PathBuf::from).collect()) +} + +/// Batch find-and-replace across the workspace. Returns how many files changed +/// and total replacements. Literal mode does not expand `$` in `replacement`. +#[tauri::command] +async fn replace_in_workspace( + workspace_state: tauri::State<'_, WorkspaceState>, + query: String, + replacement: String, + options: Option, +) -> Result { + let ws = workspace_state.current()?; + if query.trim().is_empty() { + return Err("empty search query".to_string()); + } + let opts = options.unwrap_or_default(); + if opts.filename { + return Err("replace is not supported in filename mode".to_string()); + } + let root = ws.primary_root.clone(); + let re = build_search_regex(&query, &opts)?; + let files = match ripgrep_files_with_matches(&root, &query, &opts).await { + Some(f) => f, + None => walk_collect_files(&root, 50_000), + }; + let mut files_changed = 0u32; + let mut replacements = 0u32; + for file in files { + let Ok(content) = std::fs::read_to_string(&file) else { + continue; + }; + let count = re.find_iter(&content).count() as u32; + if count == 0 { + continue; + } + let new_content = if opts.regex { + re.replace_all(&content, replacement.as_str()).into_owned() + } else { + re.replace_all(&content, regex::NoExpand(replacement.as_str())) + .into_owned() + }; + if new_content != content { + std::fs::write(&file, &new_content) + .map_err(|e| format!("write {}: {e}", file.display()))?; + files_changed += 1; + replacements += count; + } + } + Ok(ReplaceSummary { + files_changed, + replacements, + }) +} + // =========================================================================== // Source Control (git) Tauri commands // =========================================================================== @@ -2483,6 +2957,13 @@ fn default_role_names(name: &str) -> Vec { // safe default is "worker" only — let the user opt it in to core // explicitly via config if they really want plain-text core. "antigravity" => vec!["worker".to_string()], + // KohakuTerrarium streams structured JSONL (headless fork), so it can + // serve as a core as well as a leaf worker/analyst peer. + "kohaku" => vec![ + "worker".to_string(), + "analyst".to_string(), + "core".to_string(), + ], _ => vec!["worker".to_string()], } } @@ -2522,6 +3003,7 @@ async fn collect_provider_statuses( "claude".to_string(), "gemini".to_string(), "antigravity".to_string(), + "kohaku".to_string(), ]); provider_names.extend(config.providers.keys().cloned()); provider_names.extend(registry.names().into_iter().map(ToOwned::to_owned)); @@ -2629,9 +3111,15 @@ async fn load_config( workspace_state: tauri::State<'_, WorkspaceState>, ) -> Result { let Ok(ws) = workspace_state.current() else { - // No workspace selected is now a first-class state. Return an - // in-memory default config and, importantly, do not create + // No workspace selected is now a first-class state. Load the global + // app config (~/.switchyard/switchyard.toml) if present so global + // settings persist; otherwise in-memory defaults. Never create // switchyard.toml in the GUI process cwd. + if let Some(path) = switchyard_config::global_config_path() + && path.is_file() + { + return Ok(SwitchyardConfig::from_file(&path).unwrap_or_default()); + } return Ok(SwitchyardConfig::default()); }; let cwd = ws.primary_root; @@ -2712,14 +3200,76 @@ async fn save_config( workspace_state: tauri::State<'_, WorkspaceState>, config: SwitchyardConfig, ) -> Result<(), String> { - let cwd = workspace_state.current()?.primary_root; - let config_path = cwd.join("switchyard.toml"); + // With a workspace open, save to its switchyard.toml. With no workspace, + // persist to the global app config (~/.switchyard/switchyard.toml) so + // "global settings" don't require a project — the app has its own home. + let config_path = match workspace_state.current() { + Ok(ws) => ws.primary_root.join("switchyard.toml"), + Err(_) => { + let path = switchyard_config::global_config_path() + .ok_or_else(|| "cannot resolve home directory for global config".to_string())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("failed to create global config dir: {e}"))?; + } + path + } + }; config .write_to(&config_path) .map_err(|e| format!("failed to save config: {}", e))?; Ok(()) } +/// List KohakuTerrarium model selectors (`provider/preset`) by parsing +/// ` model list`, to populate the Settings model dropdown. Best-effort: +/// returns an empty list on any failure so the UI simply shows no suggestions. +#[tauri::command] +async fn list_kohaku_models(command: Option) -> Result, String> { + let cmd = command + .map(|c| c.trim().to_string()) + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "kt".to_string()); + let mut process = tokio::process::Command::new(&cmd); + // `config llm list --all` = kt's full model table: user presets + + // built-in presets (anthropic/codex/gemini/glm/...). Columns are + // `[marker] Name Provider Model ...`; the `--llm` selector is `provider/name`. + process.args(["config", "llm", "list", "--all"]); + #[cfg(windows)] + { + // tokio's Command has an inherent `creation_flags` on Windows. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + process.creation_flags(CREATE_NO_WINDOW); + } + let Ok(output) = process.output().await else { + return Ok(Vec::new()); + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let mut models = Vec::new(); + let mut past_separator = false; + for line in stdout.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("---") { + past_separator = true; + continue; + } + if !past_separator || trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if trimmed.starts_with("Tip:") || trimmed.starts_with("Profiles file") { + continue; + } + // Drop the leading status marker (✓ default / · built-in) then read + // Name + Provider → `provider/name`. + let cleaned = trimmed.trim_start_matches(['✓', '·', ' ']); + let mut toks = cleaned.split_whitespace(); + if let (Some(name), Some(provider)) = (toks.next(), toks.next()) { + models.push(format!("{provider}/{name}")); + } + } + Ok(models) +} + #[tauri::command] async fn list_sessions( workspace_state: tauri::State<'_, WorkspaceState>, @@ -4495,6 +5045,7 @@ fn main() { .invoke_handler(tauri::generate_handler![ load_config, save_config, + list_kohaku_models, list_provider_status_quick, list_provider_status, list_sessions, @@ -4547,6 +5098,9 @@ fn main() { read_file, write_file, list_dir, + fs_paste_entry, + search_workspace, + replace_in_workspace, // Hook installer surface hook_install, hook_uninstall, From 15cb765dd26072eb55460cccf1042b31195d0ac6 Mon Sep 17 00:00:00 2001 From: Yidhar <80593214+Yidhar@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:41:02 +0800 Subject: [PATCH 4/4] style: apply rustfmt to kohaku provider + gui changes Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/switchyard-gui/src/main.rs | 5 ++++- .../switchyard-provider-kohaku/src/jsonl.rs | 5 ++++- .../switchyard-provider-kohaku/src/probe.rs | 3 +-- crates/switchyard-provider-kohaku/src/turn.rs | 17 +++++++++++------ tests/tests/kohaku_live_smoke.rs | 19 ++++++++++++++++--- 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/crates/switchyard-gui/src/main.rs b/crates/switchyard-gui/src/main.rs index 82b1b8c..c1b9fa8 100644 --- a/crates/switchyard-gui/src/main.rs +++ b/crates/switchyard-gui/src/main.rs @@ -2265,7 +2265,10 @@ async fn fs_paste_entry( resolve_workspace_path(&ws, &dest_dir, repo_root.as_deref())? }; if !dest_parent.is_dir() { - return Err(format!("destination is not a folder: {}", dest_parent.display())); + return Err(format!( + "destination is not a folder: {}", + dest_parent.display() + )); } let name = src_path .file_name() diff --git a/crates/switchyard-provider-kohaku/src/jsonl.rs b/crates/switchyard-provider-kohaku/src/jsonl.rs index fb8130f..fca25a0 100644 --- a/crates/switchyard-provider-kohaku/src/jsonl.rs +++ b/crates/switchyard-provider-kohaku/src/jsonl.rs @@ -42,7 +42,10 @@ pub fn classify(json: &Value) -> KohakuEvent { .and_then(Value::as_str) .unwrap_or("") .to_string(), - error: json.get("error").and_then(Value::as_str).map(str::to_string), + error: json + .get("error") + .and_then(Value::as_str) + .map(str::to_string), }, _ => KohakuEvent::Other, } diff --git a/crates/switchyard-provider-kohaku/src/probe.rs b/crates/switchyard-provider-kohaku/src/probe.rs index 9a2185a..dfc59ee 100644 --- a/crates/switchyard-provider-kohaku/src/probe.rs +++ b/crates/switchyard-provider-kohaku/src/probe.rs @@ -7,8 +7,7 @@ use switchyard_provider_subprocess::{ }; /// Marker the fork prints in `kt --version` (see `cli/version.py`). -const HEADLESS_FORK_HINT: &str = - "install the switchyard-headless fork: pip install 'git+https://github.com/Yidhar/KohakuTerrarium@headless'"; +const HEADLESS_FORK_HINT: &str = "install the switchyard-headless fork: pip install 'git+https://github.com/Yidhar/KohakuTerrarium@headless'"; pub async fn run_probe(command: &str) -> Result { let args = vec!["--version".to_string()]; diff --git a/crates/switchyard-provider-kohaku/src/turn.rs b/crates/switchyard-provider-kohaku/src/turn.rs index fad1d86..77bb8d6 100644 --- a/crates/switchyard-provider-kohaku/src/turn.rs +++ b/crates/switchyard-provider-kohaku/src/turn.rs @@ -50,7 +50,11 @@ pub async fn run_kohaku_turn( .to_string(), ); event_tx - .send(ProviderEvent::turn_failed(turn_id, "kohaku", err.to_string())) + .send(ProviderEvent::turn_failed( + turn_id, + "kohaku", + err.to_string(), + )) .await .ok(); return Err(err); @@ -151,7 +155,11 @@ pub async fn run_kohaku_turn( // Non-JSON line — surface as text so unexpected output is // visible when debugging. event_tx_clone - .send(ProviderEvent::text_message(turn_id, "kohaku", protocol_line)) + .send(ProviderEvent::text_message( + turn_id, + "kohaku", + protocol_line, + )) .await .ok(); } @@ -231,10 +239,7 @@ mod tests { kohaku_runtime_args(Some("enzi/gpt-5.5-custom"), Some("high")), vec!["--llm", "enzi/gpt-5.5-custom"] ); - assert_eq!( - kohaku_runtime_args(Some(" "), None), - Vec::::new() - ); + assert_eq!(kohaku_runtime_args(Some(" "), None), Vec::::new()); assert_eq!(kohaku_runtime_args(None, Some("low")), Vec::::new()); } diff --git a/tests/tests/kohaku_live_smoke.rs b/tests/tests/kohaku_live_smoke.rs index 1a03a37..3f498db 100644 --- a/tests/tests/kohaku_live_smoke.rs +++ b/tests/tests/kohaku_live_smoke.rs @@ -59,8 +59,14 @@ async fn kohaku_minimal_turn() { }; let model = std::env::var("SWITCHYARD_KOHAKU_LLM").ok(); - let provider = - KohakuProvider::new_with_options(kt_cmd(), vec![creature], HashMap::new(), 120, model, None); + let provider = KohakuProvider::new_with_options( + kt_cmd(), + vec![creature], + HashMap::new(), + 120, + model, + None, + ); let turn_id = uuid::Uuid::now_v7(); let (tx, mut rx) = tokio::sync::mpsc::channel(256); @@ -83,7 +89,14 @@ async fn kohaku_minimal_turn() { }; match provider - .start_turn(turn_id, input, policy, context, tx, CancellationToken::new()) + .start_turn( + turn_id, + input, + policy, + context, + tx, + CancellationToken::new(), + ) .await { Ok(()) => {