Conflicts with the question form and background tasks on master are resolved so both work with the new pieces: turns that background tasks wake up use the new turn start (SystemTime timestamps, snapshot_base), the question form docks on the rich composer and keeps it from collapsing, and the background task strip sits with the limit banner and queue above it. Question forms (with their answers) and task events are saved with the thread, and clients of a remote host can answer forms, background commands, stop tasks and see the host's background tasks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 33 changed files with 14417 additions and 1195 deletions.
| @@ -1313,6 +1313,12 @@ dependencies = [ | ||
| 1313 | 1313 | "linktime-proc-macro", |
| 1314 | 1314 | ] |
| 1315 | 1315 | |
| 1316 | + [[package]] | |
| 1317 | + name = "data-encoding" | |
| 1318 | + version = "2.11.1" | |
| 1319 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1320 | + checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" | |
| 1321 | + | |
| 1316 | 1322 | [[package]] |
| 1317 | 1323 | name = "data-url" |
| 1318 | 1324 | version = "0.3.2" |
| @@ -5745,6 +5751,7 @@ version = "0.23.45" | ||
| 5745 | 5751 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5746 | 5752 | checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" |
| 5747 | 5753 | dependencies = [ |
| 5754 | + "log", | |
| 5748 | 5755 | "once_cell", |
| 5749 | 5756 | "ring", |
| 5750 | 5757 | "rustls-pki-types", |
| @@ -6081,6 +6088,17 @@ dependencies = [ | ||
| 6081 | 6088 | "serde", |
| 6082 | 6089 | ] |
| 6083 | 6090 | |
| 6091 | + [[package]] | |
| 6092 | + name = "sha1" | |
| 6093 | + version = "0.11.0" | |
| 6094 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 6095 | + checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" | |
| 6096 | + dependencies = [ | |
| 6097 | + "cfg-if", | |
| 6098 | + "cpufeatures 0.3.1", | |
| 6099 | + "digest 0.11.3", | |
| 6100 | + ] | |
| 6101 | + | |
| 6084 | 6102 | [[package]] |
| 6085 | 6103 | name = "sha1_smol" |
| 6086 | 6104 | version = "1.0.1" |
| @@ -7024,6 +7042,22 @@ dependencies = [ | ||
| 7024 | 7042 | "core_maths", |
| 7025 | 7043 | ] |
| 7026 | 7044 | |
| 7045 | + [[package]] | |
| 7046 | + name = "tungstenite" | |
| 7047 | + version = "0.30.0" | |
| 7048 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7049 | + checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" | |
| 7050 | + dependencies = [ | |
| 7051 | + "bytes", | |
| 7052 | + "data-encoding", | |
| 7053 | + "http", | |
| 7054 | + "httparse", | |
| 7055 | + "log", | |
| 7056 | + "rand 0.10.3", | |
| 7057 | + "sha1", | |
| 7058 | + "thiserror 2.0.21", | |
| 7059 | + ] | |
| 7060 | + | |
| 7027 | 7061 | [[package]] |
| 7028 | 7062 | name = "typeid" |
| 7029 | 7063 | version = "1.0.3" |
| @@ -7131,6 +7165,21 @@ version = "0.9.0" | ||
| 7131 | 7165 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 7132 | 7166 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" |
| 7133 | 7167 | |
| 7168 | + [[package]] | |
| 7169 | + name = "ureq" | |
| 7170 | + version = "2.12.1" | |
| 7171 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7172 | + checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" | |
| 7173 | + dependencies = [ | |
| 7174 | + "base64 0.22.1", | |
| 7175 | + "log", | |
| 7176 | + "once_cell", | |
| 7177 | + "rustls", | |
| 7178 | + "rustls-pki-types", | |
| 7179 | + "url", | |
| 7180 | + "webpki-roots 0.26.11", | |
| 7181 | + ] | |
| 7182 | + | |
| 7134 | 7183 | [[package]] |
| 7135 | 7184 | name = "url" |
| 7136 | 7185 | version = "2.5.8" |
| @@ -7281,11 +7330,15 @@ version = "0.1.0" | ||
| 7281 | 7330 | dependencies = [ |
| 7282 | 7331 | "anyhow", |
| 7283 | 7332 | "base64 0.22.1", |
| 7333 | + "chrono", | |
| 7284 | 7334 | "gpui-kit", |
| 7335 | + "image", | |
| 7285 | 7336 | "libc", |
| 7286 | 7337 | "serde", |
| 7287 | 7338 | "serde_json", |
| 7288 | 7339 | "smol", |
| 7340 | + "tungstenite", | |
| 7341 | + "ureq", | |
| 7289 | 7342 | ] |
| 7290 | 7343 | |
| 7291 | 7344 | [[package]] |
| @@ -7554,6 +7607,24 @@ dependencies = [ | ||
| 7554 | 7607 | "wasm-bindgen", |
| 7555 | 7608 | ] |
| 7556 | 7609 | |
| 7610 | + [[package]] | |
| 7611 | + name = "webpki-roots" | |
| 7612 | + version = "0.26.11" | |
| 7613 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7614 | + checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" | |
| 7615 | + dependencies = [ | |
| 7616 | + "webpki-roots 1.0.9", | |
| 7617 | + ] | |
| 7618 | + | |
| 7619 | + [[package]] | |
| 7620 | + name = "webpki-roots" | |
| 7621 | + version = "1.0.9" | |
| 7622 | + source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 7623 | + checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" | |
| 7624 | + dependencies = [ | |
| 7625 | + "rustls-pki-types", | |
| 7626 | + ] | |
| 7627 | + | |
| 7557 | 7628 | [[package]] |
| 7558 | 7629 | name = "weezl" |
| 7559 | 7630 | version = "0.1.12" |
| @@ -12,6 +12,10 @@ anyhow = "1" | ||
| 12 | 12 | libc = "0.2" |
| 13 | 13 | smol = "2" |
| 14 | 14 | base64 = "0.22" |
| 15 | + ureq = { version = "2", default-features = false, features = ["tls"] } | |
| 16 | + tungstenite = { version = "0.30", default-features = false, features = ["handshake"] } | |
| 17 | + chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } | |
| 18 | + image = { version = "0.25", default-features = false, features = ["jpeg"] } | |
| 15 | 19 | |
| 16 | 20 | [profile.dev] |
| 17 | 21 | debug = "line-tables-only" |
| @@ -13,8 +13,9 @@ talk to `pi --mode rpc` over its JSONL RPC protocol. | ||
| 13 | 13 | The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the |
| 14 | 14 | "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the |
| 15 | 15 | conversation sits on a raised sheet, buttons are caps that sink when pressed, code and tool |
| 16 | - output sit in inset wells, and labels are engraved in mono caps. It comes in MetalUI's two colorways, Bone and Graphite, or Auto to | |
| 17 | - follow the desktop. | |
| 16 | + output sit in inset wells, and labels are engraved in mono caps. It comes in MetalUI's two colorways, Bone and Graphite, | |
| 17 | + plus popular color schemes rebuilt in the same style: Tokyo Night, Catppuccin, GitHub, One, Nord, | |
| 18 | + Gruvbox, Rosé Pine, Solarized and Dracula. | |
| 18 | 19 | |
| 19 | 20 |  |
| 20 | 21 | |
| @@ -61,17 +62,57 @@ follow the desktop. | ||
| 61 | 62 | above the composer with their age and a stop button. When one finishes, the transcript |
| 62 | 63 | says so and Claude picks the work back up and reports back on its own. A long-running |
| 63 | 64 | command can be sent to the background from its tool card, like Ctrl+B in Claude Code. |
| 65 | + - A browser the agent and you share (Ctrl+Shift+E, or the globe in the top bar), on | |
| 66 | + Vercel's [agent-browser](https://github.com/vercel-labs/agent-browser): each thread has | |
| 67 | + its own headless Chrome session, which its agent drives with the `agent-browser` CLI | |
| 68 | + while the panel shows it live. Click, scroll and type in the page yourself, or go to a | |
| 69 | + URL. When the agent opens a page while the panel is closed, a light on the globe says so. | |
| 64 | 70 | - Interrupt with Esc or the stop button. Messages sent while the agent works are queued. |
| 65 | 71 | - Crash recovery: if a process dies, the next message restarts it and resumes the session. |
| 72 | + - Threads are saved as you go and come back after a restart, settled ones included; the next | |
| 73 | + message resumes the conversation. Optionally, threads a crash or restart interrupted | |
| 74 | + continue on their own. | |
| 75 | + - New threads can start in a git worktree of their own: a fresh `vectrum/<title>` branch | |
| 76 | + checked out under `~/.local/share/vectrum-code/worktrees`, optionally from the branch as | |
| 77 | + origin has it, with submodules filled in. Pick Local or New worktree per thread on the | |
| 78 | + start screen. | |
| 79 | + - Thread titles written by a small model from the first message (Settings → Text generation). | |
| 80 | + - Pin threads to the top, snooze them until later, rename them (right-click a thread card). | |
| 81 | + The sidebar can group threads by project, fold working threads into a Working section, and | |
| 82 | + order projects by last message, name or date added. | |
| 83 | + - When a usage limit stops a thread, it can snooze until the limit resets and continue then. | |
| 84 | + Idle threads, and worktree threads whose pull request merged, can settle on their own. | |
| 85 | + - Send while the agent works to steer it, or queue the message for after the turn (and | |
| 86 | + Ctrl+Enter for the other one). Queued messages wait above the composer. | |
| 87 | + - A `/` menu with Claude's commands and skills (`$` for skills alone). | |
| 88 | + - Diffs with hidden whitespace changes, foldable files, line numbers, and a stacked or | |
| 89 | + side-by-side layout. | |
| 90 | + - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on | |
| 91 | + one device, and paste its pairing code (address and key) under Settings → Connections on | |
| 92 | + another. The host's projects and threads appear in the sidebar tagged with its name; open, | |
| 93 | + continue or start threads there and they run on the host. The connection is a WebSocket | |
| 94 | + guarded by the key but not encrypted, so use it on trusted networks or over a VPN. | |
| 66 | 95 | - A settings view (Ctrl+, or **Settings** in the sidebar), laid out like T3 Code's: |
| 67 | - - **General**: the model, reasoning and access new threads start with (or follow the | |
| 68 | - open thread), folding finished turns, sending with Ctrl+Enter, showing costs, the | |
| 69 | - editor "Open in" uses, and desktop notifications when a background thread finishes or | |
| 70 | - waits for you. | |
| 71 | - - **Appearance**: Bone, Graphite or Auto colorway, and comfortable or compact thread cards. | |
| 72 | - - **Providers**: Claude's account and models, Pi's status with a refresh, and a path | |
| 73 | - override for each binary. | |
| 74 | - - **Keybindings**, an **Archive** of settled threads to restore or delete, and **About**. | |
| 96 | + - **General**: what new threads start with (model, reasoning, permissions, workspace, | |
| 97 | + submodules), the text generation model, projects and threads, organization of the | |
| 98 | + sidebar, and confirmations (unpin, settle, delete, and the quit shortcut). | |
| 99 | + - **Behavior**: notifications (desktop, with sound, in-app), time format, response | |
| 100 | + streaming, turn folding and costs, proactive diffs, diff defaults, the composer (skills in | |
| 101 | + the `/` menu, collapsing on scroll, send key, follow-up behavior), update checks, | |
| 102 | + continuing after restarts, background activity, and the editor "Open in" uses. | |
| 103 | + - **Appearance**: comfortable or compact thread cards. | |
| 104 | + - **Themes**: Light, Dark or System mode (System follows the desktop), and a light and a | |
| 105 | + dark theme picked from live previews: Bone and Graphite; Tokyo Night (Night, Storm, Day); | |
| 106 | + Catppuccin (Mocha, Macchiato, Frappé, Latte); GitHub (Dark, Dark Dimmed, Light); One Dark | |
| 107 | + and One Light; Nord; Gruvbox Dark and Light; Rosé Pine (Main, Moon, Dawn); Solarized Dark | |
| 108 | + and Light; and Dracula. | |
| 109 | + - **Providers**: every agent instance (several Claude or Pi instances side by side, say two | |
| 110 | + accounts), each with a switch, its version, login and plan, display name, binary, config | |
| 111 | + folder, launch arguments and environment variables, plus updates and the health check | |
| 112 | + interval, and usage hubs (CLIProxyAPI servers, with their management key). | |
| 113 | + - **Usage**: cost, tokens and plan limits over the past day, week, month or quarter, read | |
| 114 | + from Claude Code's and Pi's local logs and any usage hubs, by provider, model and day. | |
| 115 | + - **Connections**, **Keybindings**, an **Archive** of settled threads, and **About**. | |
| 75 | 116 | |
| 76 | 117 | ## Requirements |
| 77 | 118 | |
| @@ -79,6 +120,8 @@ follow the desktop. | ||
| 79 | 120 | - The `claude` CLI, logged in (`claude` once interactively) |
| 80 | 121 | - Optional: [Pi](https://github.com/earendil-works/pi) for Pi threads |
| 81 | 122 | (`npm install -g @earendil-works/pi-coding-agent`, then log in to a provider with `pi`) |
| 123 | + - Optional: agent-browser for the browser panel | |
| 124 | + (`npm install -g agent-browser && agent-browser install`) | |
| 82 | 125 | - GPUI's Linux system libraries: Vulkan, Wayland/X11, xkbcommon, fontconfig, freetype |
| 83 | 126 | (on Arch/CachyOS: `vulkan-icd-loader` plus your GPU's Vulkan driver, `wayland`, |
| 84 | 127 | `libxkbcommon-x11`, `libxcb`, `fontconfig`, `freetype2`) |
| @@ -110,8 +153,9 @@ cargo run --release -- /path/to/project # defaults to the current directory | ||
| 110 | 153 | |
| 111 | 154 | Favorite models and everything in the settings view are saved in |
| 112 | 155 | `~/.config/vectrum-code/settings.json`. The environment variables above win over the binary |
| 113 | - paths set there. | |
| 114 | - Threads started without a project run in `~/.local/share/vectrum-code/scratch`. | |
| 156 | + paths set there for each agent's first instance. Threads and the project list live in | |
| 157 | + `~/.local/share/vectrum-code` (`threads/`, `state.json`); threads started without a project run | |
| 158 | + in its `scratch` folder. | |
| 115 | 159 | |
| 116 | 160 | ## Layout |
| 117 | 161 | |
| @@ -134,12 +178,14 @@ Threads started without a project run in `~/.local/share/vectrum-code/scratch`. | ||
| 134 | 178 | - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from |
| 135 | 179 | MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells, |
| 136 | 180 | keycaps, status LEDs, segmented switch). |
| 181 | + - `src/ui/themes.rs`: the color schemes, the palette builder that turns each into a full | |
| 182 | + MetalUI palette, the light/dark/system selection, and the Themes page. | |
| 137 | 183 | - `assets/fonts/`: Geist, Geist Mono and Geist Pixel, embedded in the binary |
| 138 | 184 | (SIL Open Font License, see `assets/fonts/OFL.txt`). |
| 139 | 185 | |
| 140 | 186 | ## Not yet supported |
| 141 | 187 | |
| 142 | - Threads, settled or not, aren't persisted across app restarts. There is no UI for Claude's | |
| 188 | + There is no UI for Claude's | |
| 143 | 189 | `AskUserQuestion` (the tool is disabled, so Claude asks in plain text) or for Pi extensions |
| 144 | 190 | that ask for free text, code blocks have no syntax highlighting, and T3 Code's project |
| 145 | 191 | scripts and terminal drawer aren't here yet. |
| @@ -0,0 +1,926 @@ | ||
| 1 | + //! The browser a thread shares with its agent: a headless Chrome run by | |
| 2 | + //! Vercel's `agent-browser` CLI, one session per thread. The agent drives it | |
| 3 | + //! with CLI commands. The app runs the same commands for the panel's buttons, | |
| 4 | + //! watches the daemon's viewport stream (JPEG frames over a WebSocket on | |
| 5 | + //! localhost) and sends the user's mouse and keyboard input back over it. | |
| 6 | + | |
| 7 | + use std::{ | |
| 8 | + collections::HashSet, | |
| 9 | + io::ErrorKind, | |
| 10 | + net::{SocketAddr, TcpStream}, | |
| 11 | + path::{Path, PathBuf}, | |
| 12 | + process::{Command, Stdio}, | |
| 13 | + sync::{ | |
| 14 | + Arc, Mutex, | |
| 15 | + atomic::{AtomicBool, Ordering}, | |
| 16 | + mpsc, | |
| 17 | + }, | |
| 18 | + thread, | |
| 19 | + time::{Duration, Instant}, | |
| 20 | + }; | |
| 21 | + | |
| 22 | + use anyhow::{Context as _, Result, anyhow}; | |
| 23 | + use base64::Engine as _; | |
| 24 | + use gpui_kit::RenderImage; | |
| 25 | + use serde::{Deserialize, Serialize}; | |
| 26 | + use serde_json::Value; | |
| 27 | + use tungstenite::{Message, WebSocket}; | |
| 28 | + | |
| 29 | + use crate::claude::is_executable; | |
| 30 | + | |
| 31 | + pub const INSTALL_COMMAND: &str = "npm install -g agent-browser && agent-browser install"; | |
| 32 | + const BINARY: &str = "agent-browser"; | |
| 33 | + /// The stream sends at most this many frames a second. | |
| 34 | + const MAX_FPS: u32 = 30; | |
| 35 | + /// How long a socket read waits before the stream thread sends queued input. | |
| 36 | + const POLL: Duration = Duration::from_millis(10); | |
| 37 | + /// How often the stream thread looks for a daemon while none runs. | |
| 38 | + const WAIT: Duration = Duration::from_millis(500); | |
| 39 | + /// A command the panel ran shows up in the stream within this long, if the | |
| 40 | + /// stream was connected when it started; a later one is the agent's. | |
| 41 | + const OWN_COMMAND: Duration = Duration::from_secs(3); | |
| 42 | + | |
| 43 | + /// The agent-browser session of a thread. | |
| 44 | + pub fn session_name(thread_id: &str) -> String { | |
| 45 | + format!("vectrum-{thread_id}") | |
| 46 | + } | |
| 47 | + | |
| 48 | + /// Where `agent-browser` is: PATH, then the usual npm, bun and Homebrew bin folders. | |
| 49 | + pub fn find_binary() -> Option<PathBuf> { | |
| 50 | + let on_path = std::env::var_os("PATH") | |
| 51 | + .into_iter() | |
| 52 | + .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>()) | |
| 53 | + .map(|dir| dir.join(BINARY)); | |
| 54 | + // Desktop launchers often start apps without the user's shell PATH. | |
| 55 | + let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); | |
| 56 | + let mut fallbacks: Vec<PathBuf> = [".npm-global/bin", ".local/bin", ".bun/bin", ".volta/bin", ".local/share/pnpm"] | |
| 57 | + .iter() | |
| 58 | + .map(|dir| home.join(dir).join(BINARY)) | |
| 59 | + .collect(); | |
| 60 | + fallbacks.extend( | |
| 61 | + ["/home/linuxbrew/.linuxbrew/bin", "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] | |
| 62 | + .iter() | |
| 63 | + .map(|dir| Path::new(dir).join(BINARY)), | |
| 64 | + ); | |
| 65 | + // nvm keeps one bin directory per Node version. | |
| 66 | + if let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node")) { | |
| 67 | + fallbacks.extend(versions.flatten().map(|version| version.path().join("bin").join(BINARY))); | |
| 68 | + } | |
| 69 | + on_path.chain(fallbacks).find(|path| is_executable(path)) | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// The folder agent-browser keeps its daemons' sockets, pid and port files in. | |
| 73 | + fn run_dir() -> PathBuf { | |
| 74 | + let var = |name: &str| std::env::var_os(name).filter(|value| !value.is_empty()); | |
| 75 | + let base = var("AGENT_BROWSER_SOCKET_DIR").map(PathBuf::from).unwrap_or_else(|| match var("XDG_RUNTIME_DIR") { | |
| 76 | + Some(dir) => PathBuf::from(dir).join("agent-browser"), | |
| 77 | + None => var("HOME").map(PathBuf::from).unwrap_or_else(std::env::temp_dir).join(".agent-browser"), | |
| 78 | + }); | |
| 79 | + match std::env::var("AGENT_BROWSER_NAMESPACE") { | |
| 80 | + Ok(namespace) if !namespace.is_empty() => base.join("namespaces").join(namespace).join("run"), | |
| 81 | + _ => base, | |
| 82 | + } | |
| 83 | + } | |
| 84 | + | |
| 85 | + fn pid_alive(pid: i32) -> bool { | |
| 86 | + pid > 0 && unsafe { libc::kill(pid, 0) } == 0 | |
| 87 | + } | |
| 88 | + | |
| 89 | + /// Whether a session's daemon runs, from its pid file. | |
| 90 | + pub fn is_running(session: &str) -> bool { | |
| 91 | + std::fs::read_to_string(run_dir().join(format!("{session}.pid"))) | |
| 92 | + .ok() | |
| 93 | + .and_then(|pid| pid.trim().parse().ok()) | |
| 94 | + .is_some_and(pid_alive) | |
| 95 | + } | |
| 96 | + | |
| 97 | + /// The sessions whose daemons run. | |
| 98 | + pub fn running_sessions() -> HashSet<String> { | |
| 99 | + let Ok(entries) = std::fs::read_dir(run_dir()) else { | |
| 100 | + return HashSet::new(); | |
| 101 | + }; | |
| 102 | + entries | |
| 103 | + .flatten() | |
| 104 | + .filter_map(|entry| { | |
| 105 | + let name = entry.file_name().into_string().ok()?; | |
| 106 | + let session = name.strip_suffix(".pid")?; | |
| 107 | + let pid = std::fs::read_to_string(entry.path()).ok()?.trim().parse().ok()?; | |
| 108 | + pid_alive(pid).then(|| session.to_string()) | |
| 109 | + }) | |
| 110 | + .collect() | |
| 111 | + } | |
| 112 | + | |
| 113 | + /// The localhost port a running daemon streams its viewport on. Every daemon | |
| 114 | + /// opens one; the daemon writes it next to its socket. | |
| 115 | + fn stream_port(session: &str) -> Option<u16> { | |
| 116 | + if !is_running(session) { | |
| 117 | + return None; | |
| 118 | + } | |
| 119 | + std::fs::read_to_string(run_dir().join(format!("{session}.stream"))).ok()?.trim().parse().ok() | |
| 120 | + } | |
| 121 | + | |
| 122 | + /// What the agent is told about the panel. | |
| 123 | + pub fn agent_prompt(session: &str) -> String { | |
| 124 | + format!( | |
| 125 | + "The user's app has a browser panel showing a shared headless browser that you drive with the \ | |
| 126 | + `agent-browser` CLI. Its session is `{session}`, already set in AGENT_BROWSER_SESSION, so plain \ | |
| 127 | + `agent-browser <command>` uses it; don't pass another --session. The user watches the browser \ | |
| 128 | + live and may click and type in it too. Core commands: `agent-browser open <url>`, \ | |
| 129 | + `agent-browser snapshot -i` (interactive elements with @refs), `agent-browser click @e2`, \ | |
| 130 | + `agent-browser fill @e3 \"text\"`, `agent-browser screenshot [path]`, `agent-browser get url`, \ | |
| 131 | + `agent-browser back|forward|reload`. `agent-browser skills get core` has the full guide. Use it \ | |
| 132 | + to check web pages you build or need to see, and leave the page open when you're done so the \ | |
| 133 | + user can look at it." | |
| 134 | + ) | |
| 135 | + } | |
| 136 | + | |
| 137 | + /// Sets a thread's browser session in an agent's environment, with | |
| 138 | + /// `agent-browser`'s folder on PATH, and returns the prompt that tells the | |
| 139 | + /// agent about it. `None` when agent-browser isn't installed. | |
| 140 | + pub fn prepare_agent(command: &mut Command, session: &str) -> Option<String> { | |
| 141 | + let binary = find_binary()?; | |
| 142 | + command.env("AGENT_BROWSER_SESSION", session); | |
| 143 | + if let Some(dir) = binary.parent() { | |
| 144 | + let current = command | |
| 145 | + .get_envs() | |
| 146 | + .find(|(key, _)| *key == "PATH") | |
| 147 | + .and_then(|(_, value)| value.map(ToOwned::to_owned)) | |
| 148 | + .or_else(|| std::env::var_os("PATH")) | |
| 149 | + .unwrap_or_default(); | |
| 150 | + let mut paths: Vec<PathBuf> = std::env::split_paths(¤t).collect(); | |
| 151 | + if !paths.iter().any(|path| path == dir) { | |
| 152 | + paths.push(dir.to_path_buf()); | |
| 153 | + if let Ok(joined) = std::env::join_paths(paths) { | |
| 154 | + command.env("PATH", joined); | |
| 155 | + } | |
| 156 | + } | |
| 157 | + } | |
| 158 | + Some(agent_prompt(session)) | |
| 159 | + } | |
| 160 | + | |
| 161 | + /// Closes a session's browser and daemon, if it runs. | |
| 162 | + pub fn stop(session: &str) { | |
| 163 | + if is_running(session) | |
| 164 | + && let Some(binary) = find_binary() | |
| 165 | + { | |
| 166 | + let _ = Command::new(binary) | |
| 167 | + .args(["--session", session, "close"]) | |
| 168 | + .stdin(Stdio::null()) | |
| 169 | + .stdout(Stdio::null()) | |
| 170 | + .stderr(Stdio::null()) | |
| 171 | + .status(); | |
| 172 | + } | |
| 173 | + } | |
| 174 | + | |
| 175 | + /// What the user typed into the address field, as a URL: `https://` is added | |
| 176 | + /// when there's no scheme, `http://` for local servers. | |
| 177 | + pub fn normalize_url(input: &str) -> Option<String> { | |
| 178 | + let input = input.trim(); | |
| 179 | + if input.is_empty() { | |
| 180 | + return None; | |
| 181 | + } | |
| 182 | + if input.contains("://") || ["about:", "data:", "file:", "chrome:"].iter().any(|scheme| input.starts_with(scheme)) { | |
| 183 | + return Some(input.to_string()); | |
| 184 | + } | |
| 185 | + let host = input.split(['/', '?', '#']).next().unwrap_or_default(); | |
| 186 | + let host = host | |
| 187 | + .rsplit_once(':') | |
| 188 | + .filter(|(_, port)| port.chars().all(|c| c.is_ascii_digit())) | |
| 189 | + .map_or(host, |(host, _)| host); | |
| 190 | + let local = | |
| 191 | + matches!(host, "localhost" | "0.0.0.0" | "[::1]") || host.starts_with("127.") || host.ends_with(".localhost"); | |
| 192 | + Some(format!("{}://{input}", if local { "http" } else { "https" })) | |
| 193 | + } | |
| 194 | + | |
| 195 | + // ---- the stream protocol -------------------------------------------------- | |
| 196 | + | |
| 197 | + /// A message the daemon's stream sends. | |
| 198 | + #[derive(Debug, Deserialize)] | |
| 199 | + #[serde(tag = "type", rename_all = "snake_case")] | |
| 200 | + pub enum StreamMessage { | |
| 201 | + /// A screencast frame: a base64 JPEG of the viewport. | |
| 202 | + Frame { data: String, metadata: FrameMetadata }, | |
| 203 | + /// The active tab navigated. | |
| 204 | + Url { url: String }, | |
| 205 | + /// Sent on connect and when the browser or screencast starts or stops. | |
| 206 | + Status { | |
| 207 | + /// Whether a browser runs; the daemon can run without one. | |
| 208 | + #[serde(default)] | |
| 209 | + connected: bool, | |
| 210 | + }, | |
| 211 | + /// A command the daemon started running, from any client. | |
| 212 | + Command { action: String }, | |
| 213 | + #[serde(other)] | |
| 214 | + Other, | |
| 215 | + } | |
| 216 | + | |
| 217 | + #[derive(Debug, Deserialize)] | |
| 218 | + #[serde(rename_all = "camelCase")] | |
| 219 | + pub struct FrameMetadata { | |
| 220 | + /// The viewport in CSS pixels, the space input coordinates are in. | |
| 221 | + pub device_width: f32, | |
| 222 | + pub device_height: f32, | |
| 223 | + } | |
| 224 | + | |
| 225 | + /// Input sent to the page over the stream; the daemon passes it on to | |
| 226 | + /// Chrome's `Input.dispatchMouseEvent` and `Input.dispatchKeyEvent`. | |
| 227 | + #[derive(Clone, Debug, Serialize)] | |
| 228 | + #[serde(tag = "type")] | |
| 229 | + pub enum Input { | |
| 230 | + #[serde(rename = "input_mouse")] | |
| 231 | + Mouse(MouseInput), | |
| 232 | + #[serde(rename = "input_keyboard")] | |
| 233 | + Key(KeyInput), | |
| 234 | + } | |
| 235 | + | |
| 236 | + /// CDP's modifier bits. | |
| 237 | + pub mod modifiers { | |
| 238 | + pub const ALT: u8 = 1; | |
| 239 | + pub const CTRL: u8 = 2; | |
| 240 | + pub const META: u8 = 4; | |
| 241 | + pub const SHIFT: u8 = 8; | |
| 242 | + } | |
| 243 | + | |
| 244 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] | |
| 245 | + pub enum MouseEventType { | |
| 246 | + #[serde(rename = "mouseMoved")] | |
| 247 | + Move, | |
| 248 | + #[serde(rename = "mousePressed")] | |
| 249 | + Down, | |
| 250 | + #[serde(rename = "mouseReleased")] | |
| 251 | + Up, | |
| 252 | + #[serde(rename = "mouseWheel")] | |
| 253 | + Wheel, | |
| 254 | + } | |
| 255 | + | |
| 256 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] | |
| 257 | + #[serde(rename_all = "lowercase")] | |
| 258 | + pub enum PointerButton { | |
| 259 | + None, | |
| 260 | + Left, | |
| 261 | + Middle, | |
| 262 | + Right, | |
| 263 | + } | |
| 264 | + | |
| 265 | + #[derive(Clone, Debug, Serialize)] | |
| 266 | + #[serde(rename_all = "camelCase")] | |
| 267 | + pub struct MouseInput { | |
| 268 | + pub event_type: MouseEventType, | |
| 269 | + pub x: f64, | |
| 270 | + pub y: f64, | |
| 271 | + pub button: PointerButton, | |
| 272 | + pub click_count: u32, | |
| 273 | + pub delta_x: f64, | |
| 274 | + pub delta_y: f64, | |
| 275 | + pub modifiers: u8, | |
| 276 | + } | |
| 277 | + | |
| 278 | + impl MouseInput { | |
| 279 | + pub fn new(event_type: MouseEventType, (x, y): (f64, f64), modifiers: u8) -> Self { | |
| 280 | + Self { event_type, x, y, button: PointerButton::None, click_count: 0, delta_x: 0., delta_y: 0., modifiers } | |
| 281 | + } | |
| 282 | + } | |
| 283 | + | |
| 284 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] | |
| 285 | + pub enum KeyEventType { | |
| 286 | + /// A key press that types `text`. | |
| 287 | + #[serde(rename = "keyDown")] | |
| 288 | + Down, | |
| 289 | + /// A key press that types nothing (arrows, shortcuts). | |
| 290 | + #[serde(rename = "rawKeyDown")] | |
| 291 | + RawDown, | |
| 292 | + #[serde(rename = "keyUp")] | |
| 293 | + Up, | |
| 294 | + /// Text typed without a key, like a paste. | |
| 295 | + #[serde(rename = "char")] | |
| 296 | + Char, | |
| 297 | + } | |
| 298 | + | |
| 299 | + #[derive(Clone, Debug, PartialEq, Serialize)] | |
| 300 | + #[serde(rename_all = "camelCase")] | |
| 301 | + pub struct KeyInput { | |
| 302 | + pub event_type: KeyEventType, | |
| 303 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 304 | + pub key: Option<String>, | |
| 305 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 306 | + pub code: Option<String>, | |
| 307 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 308 | + pub text: Option<String>, | |
| 309 | + pub windows_virtual_key_code: u32, | |
| 310 | + pub modifiers: u8, | |
| 311 | + } | |
| 312 | + | |
| 313 | + /// A GPUI key press or release as a DOM key event. `key` is GPUI's key name | |
| 314 | + /// (`a`, `enter`, `left`), `typed` the character it types, if any. | |
| 315 | + pub fn key_input(key: &str, typed: Option<&str>, modifiers: u8, down: bool) -> KeyInput { | |
| 316 | + let named = |dom: &str, code: &str, key_code: u32| (dom.to_string(), Some(code.to_string()), key_code); | |
| 317 | + let (dom_key, code, key_code) = match key { | |
| 318 | + "enter" => named("Enter", "Enter", 13), | |
| 319 | + "backspace" => named("Backspace", "Backspace", 8), | |
| 320 | + "tab" => named("Tab", "Tab", 9), | |
| 321 | + "escape" => named("Escape", "Escape", 27), | |
| 322 | + "space" => named(" ", "Space", 32), | |
| 323 | + "left" => named("ArrowLeft", "ArrowLeft", 37), | |
| 324 | + "up" => named("ArrowUp", "ArrowUp", 38), | |
| 325 | + "right" => named("ArrowRight", "ArrowRight", 39), | |
| 326 | + "down" => named("ArrowDown", "ArrowDown", 40), | |
| 327 | + "home" => named("Home", "Home", 36), | |
| 328 | + "end" => named("End", "End", 35), | |
| 329 | + "pageup" => named("PageUp", "PageUp", 33), | |
| 330 | + "pagedown" => named("PageDown", "PageDown", 34), | |
| 331 | + "delete" => named("Delete", "Delete", 46), | |
| 332 | + "insert" => named("Insert", "Insert", 45), | |
| 333 | + _ if key.len() > 1 && key.starts_with('f') && key[1..].parse::<u32>().is_ok_and(|n| (1..=12).contains(&n)) => { | |
| 334 | + let n: u32 = key[1..].parse().unwrap_or(1); | |
| 335 | + (format!("F{n}"), Some(format!("F{n}")), 111 + n) | |
| 336 | + } | |
| 337 | + _ => { | |
| 338 | + let mut chars = key.chars(); | |
| 339 | + match (chars.next(), chars.next()) { | |
| 340 | + (Some(c), None) if c.is_ascii_alphabetic() => { | |
| 341 | + let upper = c.to_ascii_uppercase(); | |
| 342 | + (typed.unwrap_or(key).to_string(), Some(format!("Key{upper}")), upper as u32) | |
| 343 | + } | |
| 344 | + (Some(c), None) if c.is_ascii_digit() => { | |
| 345 | + (typed.unwrap_or(key).to_string(), Some(format!("Digit{c}")), c as u32) | |
| 346 | + } | |
| 347 | + _ => (typed.unwrap_or(key).to_string(), None, 0), | |
| 348 | + } | |
| 349 | + } | |
| 350 | + }; | |
| 351 | + let shortcut = modifiers & (modifiers::CTRL | modifiers::META | modifiers::ALT) != 0; | |
| 352 | + // Enter types a carriage return, which is what submits forms. | |
| 353 | + let text = match key { | |
| 354 | + "enter" => Some("\r".to_string()), | |
| 355 | + "space" => Some(" ".to_string()), | |
| 356 | + _ => typed.filter(|text| !text.is_empty() && !text.chars().any(char::is_control)).map(str::to_string), | |
| 357 | + } | |
| 358 | + .filter(|_| !shortcut); | |
| 359 | + let event_type = match (down, &text) { | |
| 360 | + (false, _) => KeyEventType::Up, | |
| 361 | + (true, Some(_)) => KeyEventType::Down, | |
| 362 | + (true, None) => KeyEventType::RawDown, | |
| 363 | + }; | |
| 364 | + KeyInput { | |
| 365 | + event_type, | |
| 366 | + key: Some(dom_key), | |
| 367 | + code, | |
| 368 | + text: if down { text } else { None }, | |
| 369 | + windows_virtual_key_code: key_code, | |
| 370 | + modifiers, | |
| 371 | + } | |
| 372 | + } | |
| 373 | + | |
| 374 | + /// Text typed at the caret without keys, as a paste does: one `char` event | |
| 375 | + /// per character, which is all Chrome takes from one. | |
| 376 | + pub fn text_input(text: &str) -> Vec<KeyInput> { | |
| 377 | + text.chars() | |
| 378 | + .filter(|c| *c != '\r') | |
| 379 | + .map(|c| KeyInput { | |
| 380 | + event_type: KeyEventType::Char, | |
| 381 | + key: None, | |
| 382 | + code: None, | |
| 383 | + text: Some(if c == '\n' { "\r".to_string() } else { c.to_string() }), | |
| 384 | + windows_virtual_key_code: 0, | |
| 385 | + modifiers: 0, | |
| 386 | + }) | |
| 387 | + .collect() | |
| 388 | + } | |
| 389 | + | |
| 390 | + /// Where a frame of `image` pixels is drawn when fit inside `area` keeping its | |
| 391 | + /// aspect ratio and centered, as GPUI's `ObjectFit::Contain` draws it: | |
| 392 | + /// `(x, y, width, height)` relative to the area. | |
| 393 | + pub fn contain(area: (f32, f32), image: (f32, f32)) -> (f32, f32, f32, f32) { | |
| 394 | + let (area_w, area_h) = area; | |
| 395 | + let (image_w, image_h) = image; | |
| 396 | + if image_w <= 0. || image_h <= 0. || area_w <= 0. || area_h <= 0. { | |
| 397 | + return (0., 0., 0., 0.); | |
| 398 | + } | |
| 399 | + let (w, h) = if area_w / area_h > image_w / image_h { | |
| 400 | + (image_w * area_h / image_h, area_h) | |
| 401 | + } else { | |
| 402 | + (area_w, image_h * area_w / image_w) | |
| 403 | + }; | |
| 404 | + ((area_w - w) / 2., (area_h - h) / 2., w, h) | |
| 405 | + } | |
| 406 | + | |
| 407 | + /// Maps a point in the viewport area (relative to its top left) to page | |
| 408 | + /// coordinates in CSS pixels. `None` outside the drawn frame. | |
| 409 | + pub fn page_point(point: (f32, f32), area: (f32, f32), image: (f32, f32), viewport: (f32, f32)) -> Option<(f64, f64)> { | |
| 410 | + let (x, y, w, h) = contain(area, image); | |
| 411 | + if w <= 0. || h <= 0. { | |
| 412 | + return None; | |
| 413 | + } | |
| 414 | + let (rel_x, rel_y) = ((point.0 - x) / w, (point.1 - y) / h); | |
| 415 | + if !(0. ..=1.).contains(&rel_x) || !(0. ..=1.).contains(&rel_y) { | |
| 416 | + return None; | |
| 417 | + } | |
| 418 | + let page_x = (rel_x * viewport.0).min(viewport.0 - 1.).max(0.); | |
| 419 | + let page_y = (rel_y * viewport.1).min(viewport.1 - 1.).max(0.); | |
| 420 | + Some((page_x as f64, page_y as f64)) | |
| 421 | + } | |
| 422 | + | |
| 423 | + // ---- a session ------------------------------------------------------------ | |
| 424 | + | |
| 425 | + /// A decoded viewport frame. | |
| 426 | + pub struct Frame { | |
| 427 | + pub image: Arc<RenderImage>, | |
| 428 | + /// The frame's size in pixels. | |
| 429 | + pub size: (f32, f32), | |
| 430 | + /// The page's viewport in CSS pixels. | |
| 431 | + pub viewport: (f32, f32), | |
| 432 | + } | |
| 433 | + | |
| 434 | + /// What a session reports to the panel. | |
| 435 | + #[derive(Debug, PartialEq)] | |
| 436 | + pub enum BrowserEvent { | |
| 437 | + /// `agent-browser` isn't installed. | |
| 438 | + Missing, | |
| 439 | + /// No daemon runs for the session. | |
| 440 | + Waiting, | |
| 441 | + /// Watching the daemon's stream; `browser` tells whether Chrome runs. | |
| 442 | + Live { | |
| 443 | + browser: bool, | |
| 444 | + }, | |
| 445 | + /// A new frame waits in `BrowserSession::take_frame`. | |
| 446 | + Frame, | |
| 447 | + Url(String), | |
| 448 | + /// The agent (or anyone but the panel) ran a command. | |
| 449 | + Agent(String), | |
| 450 | + /// One of the panel's commands failed. | |
| 451 | + Failed(String), | |
| 452 | + } | |
| 453 | + | |
| 454 | + struct Shared { | |
| 455 | + stop: AtomicBool, | |
| 456 | + /// Whether the panel shows, so the stream is worth watching. | |
| 457 | + watching: AtomicBool, | |
| 458 | + frame: Mutex<Option<Frame>>, | |
| 459 | + /// Commands the panel ran that the stream hasn't echoed yet. | |
| 460 | + own: Mutex<Vec<(&'static str, Instant)>>, | |
| 461 | + } | |
| 462 | + | |
| 463 | + /// One thread's browser, as the panel drives and watches it. | |
| 464 | + pub struct BrowserSession { | |
| 465 | + name: String, | |
| 466 | + binary: Option<PathBuf>, | |
| 467 | + shared: Arc<Shared>, | |
| 468 | + events: smol::channel::Sender<BrowserEvent>, | |
| 469 | + input: mpsc::Sender<String>, | |
| 470 | + } | |
| 471 | + | |
| 472 | + impl BrowserSession { | |
| 473 | + /// Starts watching a session. Nothing runs until `set_watching(true)`. | |
| 474 | + pub fn new(name: String, events: smol::channel::Sender<BrowserEvent>) -> Self { | |
| 475 | + let binary = find_binary(); | |
| 476 | + let shared = Arc::new(Shared { | |
| 477 | + stop: AtomicBool::new(false), | |
| 478 | + watching: AtomicBool::new(false), | |
| 479 | + frame: Mutex::new(None), | |
| 480 | + own: Mutex::new(Vec::new()), | |
| 481 | + }); | |
| 482 | + let (input, input_rx) = mpsc::channel(); | |
| 483 | + if binary.is_some() { | |
| 484 | + let (name, shared, events) = (name.clone(), shared.clone(), events.clone()); | |
| 485 | + let _ = thread::Builder::new() | |
| 486 | + .name("browser-stream".into()) | |
| 487 | + .spawn(move || watch(&name, &shared, &events, &input_rx)); | |
| 488 | + } else { | |
| 489 | + let _ = events.try_send(BrowserEvent::Missing); | |
| 490 | + } | |
| 491 | + Self { name, binary, shared, events, input } | |
| 492 | + } | |
| 493 | + | |
| 494 | + pub fn name(&self) -> &str { | |
| 495 | + &self.name | |
| 496 | + } | |
| 497 | + | |
| 498 | + pub fn installed(&self) -> bool { | |
| 499 | + self.binary.is_some() | |
| 500 | + } | |
| 501 | + | |
| 502 | + /// Connects to the stream while the panel shows, and lets go of it otherwise. | |
| 503 | + pub fn set_watching(&self, watching: bool) { | |
| 504 | + self.shared.watching.store(watching, Ordering::Relaxed); | |
| 505 | + } | |
| 506 | + | |
| 507 | + pub fn open(&self, url: &str) { | |
| 508 | + self.run("navigate", &["open", url]); | |
| 509 | + } | |
| 510 | + | |
| 511 | + pub fn back(&self) { | |
| 512 | + self.run("back", &["back"]); | |
| 513 | + } | |
| 514 | + | |
| 515 | + pub fn forward(&self) { | |
| 516 | + self.run("forward", &["forward"]); | |
| 517 | + } | |
| 518 | + | |
| 519 | + pub fn reload(&self) { | |
| 520 | + self.run("reload", &["reload"]); | |
| 521 | + } | |
| 522 | + | |
| 523 | + /// Sizes the page's viewport, in CSS pixels at `scale` device pixels each. | |
| 524 | + pub fn set_viewport(&self, width: u32, height: u32, scale: f32) { | |
| 525 | + let (width, height, scale) = (width.to_string(), height.to_string(), format!("{scale:.2}")); | |
| 526 | + self.run("viewport", &["set", "viewport", &width, &height, &scale]); | |
| 527 | + } | |
| 528 | + | |
| 529 | + pub fn send(&self, input: Input) { | |
| 530 | + if let Ok(json) = serde_json::to_string(&input) { | |
| 531 | + let _ = self.input.send(json); | |
| 532 | + } | |
| 533 | + } | |
| 534 | + | |
| 535 | + /// The newest frame not taken yet. | |
| 536 | + pub fn take_frame(&self) -> Option<Frame> { | |
| 537 | + self.shared.frame.lock().ok()?.take() | |
| 538 | + } | |
| 539 | + | |
| 540 | + /// Runs `agent-browser --session <name> <args> --json` in the background. | |
| 541 | + fn run(&self, action: &'static str, args: &[&str]) { | |
| 542 | + let Some(binary) = self.binary.clone() else { | |
| 543 | + let _ = self.events.try_send(BrowserEvent::Missing); | |
| 544 | + return; | |
| 545 | + }; | |
| 546 | + if let Ok(mut own) = self.shared.own.lock() { | |
| 547 | + own.retain(|(_, at)| at.elapsed() < OWN_COMMAND); | |
| 548 | + own.push((action, Instant::now())); | |
| 549 | + } | |
| 550 | + let mut command = Command::new(binary); | |
| 551 | + command.args(["--session", &self.name]).args(args).arg("--json"); | |
| 552 | + let events = self.events.clone(); | |
| 553 | + let _ = thread::Builder::new().name("browser-command".into()).spawn(move || { | |
| 554 | + if let Err(err) = run_command(command) { | |
| 555 | + let _ = events.send_blocking(BrowserEvent::Failed(format!("{err:#}"))); | |
| 556 | + } | |
| 557 | + }); | |
| 558 | + } | |
| 559 | + } | |
| 560 | + | |
| 561 | + impl Drop for BrowserSession { | |
| 562 | + fn drop(&mut self) { | |
| 563 | + self.shared.stop.store(true, Ordering::Relaxed); | |
| 564 | + } | |
| 565 | + } | |
| 566 | + | |
| 567 | + fn run_command(mut command: Command) -> Result<()> { | |
| 568 | + let output = command | |
| 569 | + .stdin(Stdio::null()) | |
| 570 | + .stdout(Stdio::piped()) | |
| 571 | + .stderr(Stdio::piped()) | |
| 572 | + .output() | |
| 573 | + .context("failed to run agent-browser")?; | |
| 574 | + let reply: Option<Value> = serde_json::from_slice(&output.stdout).ok(); | |
| 575 | + let success = reply.as_ref().and_then(|reply| reply["success"].as_bool()).unwrap_or(output.status.success()); | |
| 576 | + if success { | |
| 577 | + return Ok(()); | |
| 578 | + } | |
| 579 | + let message = reply | |
| 580 | + .as_ref() | |
| 581 | + .and_then(|reply| reply["error"].as_str().map(str::to_string)) | |
| 582 | + .or_else(|| { | |
| 583 | + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); | |
| 584 | + (!stderr.is_empty()).then_some(stderr) | |
| 585 | + }) | |
| 586 | + .unwrap_or_else(|| format!("agent-browser exited with {}", output.status)); | |
| 587 | + Err(anyhow!(message)) | |
| 588 | + } | |
| 589 | + | |
| 590 | + /// The stream thread: waits for the session's daemon, watches its stream | |
| 591 | + /// while the panel shows, and reconnects when the daemon restarts. | |
| 592 | + fn watch(name: &str, shared: &Shared, events: &smol::channel::Sender<BrowserEvent>, input: &mpsc::Receiver<String>) { | |
| 593 | + let mut waiting = false; | |
| 594 | + while !shared.stop.load(Ordering::Relaxed) && !events.is_closed() { | |
| 595 | + if !shared.watching.load(Ordering::Relaxed) { | |
| 596 | + while input.try_recv().is_ok() {} | |
| 597 | + thread::sleep(WAIT / 2); | |
| 598 | + continue; | |
| 599 | + } | |
| 600 | + let socket = stream_port(name).map(connect); | |
| 601 | + match socket { | |
| 602 | + Some(Ok(mut socket)) => { | |
| 603 | + waiting = false; | |
| 604 | + stream(&mut socket, shared, events, input); | |
| 605 | + let _ = socket.close(None); | |
| 606 | + let _ = socket.flush(); | |
| 607 | + } | |
| 608 | + Some(Err(_)) => thread::sleep(WAIT), | |
| 609 | + None => { | |
| 610 | + if !waiting { | |
| 611 | + waiting = true; | |
| 612 | + let _ = events.send_blocking(BrowserEvent::Waiting); | |
| 613 | + } | |
| 614 | + while input.try_recv().is_ok() {} | |
| 615 | + thread::sleep(WAIT); | |
| 616 | + } | |
| 617 | + } | |
| 618 | + } | |
| 619 | + } | |
| 620 | + | |
| 621 | + fn connect(port: u16) -> Result<WebSocket<TcpStream>> { | |
| 622 | + let address = SocketAddr::from(([127, 0, 0, 1], port)); | |
| 623 | + let tcp = TcpStream::connect_timeout(&address, Duration::from_secs(2))?; | |
| 624 | + tcp.set_nodelay(true)?; | |
| 625 | + // A daemon that just started or is shutting down may never answer. | |
| 626 | + tcp.set_read_timeout(Some(Duration::from_secs(3)))?; | |
| 627 | + let url = format!("ws://127.0.0.1:{port}/?maxFps={MAX_FPS}"); | |
| 628 | + let (socket, _) = tungstenite::client(url, tcp).map_err(|err| anyhow!("stream handshake failed: {err}"))?; | |
| 629 | + socket.get_ref().set_read_timeout(Some(POLL))?; | |
| 630 | + Ok(socket) | |
| 631 | + } | |
| 632 | + | |
| 633 | + /// Relays one connection: frames and page changes to the panel, the user's | |
| 634 | + /// input to the page. Returns when the connection ends or isn't wanted. | |
| 635 | + fn stream( | |
| 636 | + socket: &mut WebSocket<TcpStream>, | |
| 637 | + shared: &Shared, | |
| 638 | + events: &smol::channel::Sender<BrowserEvent>, | |
| 639 | + input: &mpsc::Receiver<String>, | |
| 640 | + ) { | |
| 641 | + loop { | |
| 642 | + if shared.stop.load(Ordering::Relaxed) || !shared.watching.load(Ordering::Relaxed) { | |
| 643 | + return; | |
| 644 | + } | |
| 645 | + while let Ok(message) = input.try_recv() { | |
| 646 | + if socket.send(Message::text(message)).is_err() { | |
| 647 | + return; | |
| 648 | + } | |
| 649 | + } | |
| 650 | + let text = match socket.read() { | |
| 651 | + Ok(Message::Text(text)) => text, | |
| 652 | + Ok(Message::Close(_)) => return, | |
| 653 | + Ok(_) => continue, | |
| 654 | + Err(tungstenite::Error::Io(err)) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => { | |
| 655 | + continue; | |
| 656 | + } | |
| 657 | + Err(_) => return, | |
| 658 | + }; | |
| 659 | + let Ok(message) = serde_json::from_str::<StreamMessage>(text.as_str()) else { | |
| 660 | + continue; | |
| 661 | + }; | |
| 662 | + let event = match message { | |
| 663 | + StreamMessage::Frame { data, metadata, .. } => { | |
| 664 | + let Ok(frame) = decode_frame(&data, &metadata) else { | |
| 665 | + continue; | |
| 666 | + }; | |
| 667 | + let Ok(mut slot) = shared.frame.lock() else { | |
| 668 | + return; | |
| 669 | + }; | |
| 670 | + // The panel hasn't drawn the last one yet; this one replaces it. | |
| 671 | + if slot.replace(frame).is_some() { | |
| 672 | + continue; | |
| 673 | + } | |
| 674 | + BrowserEvent::Frame | |
| 675 | + } | |
| 676 | + StreamMessage::Url { url } => BrowserEvent::Url(url), | |
| 677 | + StreamMessage::Status { connected } => BrowserEvent::Live { browser: connected }, | |
| 678 | + StreamMessage::Command { action } => { | |
| 679 | + let own = shared.own.lock().ok().and_then(|mut own| { | |
| 680 | + let ix = own.iter().position(|(own, at)| *own == action && at.elapsed() < OWN_COMMAND)?; | |
| 681 | + Some(own.remove(ix)) | |
| 682 | + }); | |
| 683 | + if own.is_some() { | |
| 684 | + continue; | |
| 685 | + } | |
| 686 | + BrowserEvent::Agent(action) | |
| 687 | + } | |
| 688 | + StreamMessage::Other => continue, | |
| 689 | + }; | |
| 690 | + if events.send_blocking(event).is_err() { | |
| 691 | + return; | |
| 692 | + } | |
| 693 | + } | |
| 694 | + } | |
| 695 | + | |
| 696 | + /// Decodes a frame's JPEG into the BGRA image GPUI draws. | |
| 697 | + fn decode_frame(data: &str, metadata: &FrameMetadata) -> Result<Frame> { | |
| 698 | + let jpeg = base64::engine::general_purpose::STANDARD.decode(data)?; | |
| 699 | + let mut pixels = image::load_from_memory_with_format(&jpeg, image::ImageFormat::Jpeg)?.into_rgba8(); | |
| 700 | + let size = (pixels.width() as f32, pixels.height() as f32); | |
| 701 | + for pixel in pixels.chunks_exact_mut(4) { | |
| 702 | + pixel.swap(0, 2); | |
| 703 | + } | |
| 704 | + Ok(Frame { | |
| 705 | + image: Arc::new(RenderImage::new(vec![image::Frame::new(pixels)])), | |
| 706 | + size, | |
| 707 | + viewport: (metadata.device_width, metadata.device_height), | |
| 708 | + }) | |
| 709 | + } | |
| 710 | + | |
| 711 | + #[cfg(test)] | |
| 712 | + mod tests { | |
| 713 | + use super::*; | |
| 714 | + use serde_json::json; | |
| 715 | + | |
| 716 | + #[::core::prelude::v1::test] | |
| 717 | + fn parses_stream_messages() { | |
| 718 | + let frame = json!({ | |
| 719 | + "type": "frame", "seq": 41, "data": "abc", | |
| 720 | + "metadata": {"deviceWidth": 1280, "deviceHeight": 720, "pageScaleFactor": 1, "offsetTop": 0, | |
| 721 | + "scrollOffsetX": 0, "scrollOffsetY": 0, "timestamp": 1785038682238u64}, | |
| 722 | + }); | |
| 723 | + match serde_json::from_value::<StreamMessage>(frame).unwrap() { | |
| 724 | + StreamMessage::Frame { data, metadata } => { | |
| 725 | + assert_eq!(data, "abc"); | |
| 726 | + assert_eq!((metadata.device_width, metadata.device_height), (1280., 720.)); | |
| 727 | + } | |
| 728 | + other => panic!("unexpected {other:?}"), | |
| 729 | + } | |
| 730 | + let url = json!({"type": "url", "url": "https://example.com/", "timestamp": 1}); | |
| 731 | + assert!( | |
| 732 | + matches!(serde_json::from_value(url).unwrap(), StreamMessage::Url { url } if url == "https://example.com/") | |
| 733 | + ); | |
| 734 | + let status = json!({"type": "status", "connected": true, "screencasting": false, "viewportWidth": 1280, | |
| 735 | + "viewportHeight": 720, "engine": "chrome", "recording": false}); | |
| 736 | + assert!(matches!(serde_json::from_value(status).unwrap(), StreamMessage::Status { connected: true })); | |
| 737 | + let tabs = json!({"type": "tabs", "timestamp": 1, "tabs": [ | |
| 738 | + {"active": true, "label": null, "tabId": "t1", "title": "One", "type": "page", "url": "a"}, | |
| 739 | + ]}); | |
| 740 | + assert!(matches!(serde_json::from_value(tabs).unwrap(), StreamMessage::Other)); | |
| 741 | + let command = json!({"type": "command", "action": "navigate", "id": "r1", "params": {}, "timestamp": 1}); | |
| 742 | + assert!( | |
| 743 | + matches!(serde_json::from_value(command).unwrap(), StreamMessage::Command { action } if action == "navigate") | |
| 744 | + ); | |
| 745 | + let result = json!({"type": "result", "id": "r1", "action": "navigate", "success": true, "data": {}}); | |
| 746 | + assert!(matches!(serde_json::from_value(result).unwrap(), StreamMessage::Other)); | |
| 747 | + } | |
| 748 | + | |
| 749 | + #[::core::prelude::v1::test] | |
| 750 | + fn serializes_input() { | |
| 751 | + let mut click = MouseInput::new(MouseEventType::Down, (100., 200.), modifiers::SHIFT); | |
| 752 | + click.button = PointerButton::Left; | |
| 753 | + click.click_count = 1; | |
| 754 | + assert_eq!( | |
| 755 | + serde_json::to_value(Input::Mouse(click)).unwrap(), | |
| 756 | + json!({"type": "input_mouse", "eventType": "mousePressed", "x": 100.0, "y": 200.0, "button": "left", | |
| 757 | + "clickCount": 1, "deltaX": 0.0, "deltaY": 0.0, "modifiers": 8}), | |
| 758 | + ); | |
| 759 | + let mut wheel = MouseInput::new(MouseEventType::Wheel, (1., 2.), 0); | |
| 760 | + wheel.delta_y = 120.; | |
| 761 | + let wheel = serde_json::to_value(Input::Mouse(wheel)).unwrap(); | |
| 762 | + assert_eq!((wheel["eventType"].as_str(), wheel["deltaY"].as_f64()), (Some("mouseWheel"), Some(120.))); | |
| 763 | + assert_eq!( | |
| 764 | + serde_json::to_value(Input::Key(key_input("a", Some("a"), 0, true))).unwrap(), | |
| 765 | + json!({"type": "input_keyboard", "eventType": "keyDown", "key": "a", "code": "KeyA", "text": "a", | |
| 766 | + "windowsVirtualKeyCode": 65, "modifiers": 0}), | |
| 767 | + ); | |
| 768 | + assert_eq!( | |
| 769 | + serde_json::to_value(Input::Key(text_input("p").remove(0))).unwrap(), | |
| 770 | + json!({"type": "input_keyboard", "eventType": "char", "text": "p", "windowsVirtualKeyCode": 0, | |
| 771 | + "modifiers": 0}), | |
| 772 | + ); | |
| 773 | + } | |
| 774 | + | |
| 775 | + #[::core::prelude::v1::test] | |
| 776 | + fn maps_keys() { | |
| 777 | + let enter = key_input("enter", None, 0, true); | |
| 778 | + assert_eq!( | |
| 779 | + (enter.event_type, enter.key.as_deref(), enter.text.as_deref()), | |
| 780 | + (KeyEventType::Down, Some("Enter"), Some("\r")) | |
| 781 | + ); | |
| 782 | + assert_eq!(enter.windows_virtual_key_code, 13); | |
| 783 | + let left = key_input("left", None, 0, true); | |
| 784 | + assert_eq!( | |
| 785 | + (left.event_type, left.key.as_deref(), left.windows_virtual_key_code), | |
| 786 | + (KeyEventType::RawDown, Some("ArrowLeft"), 37) | |
| 787 | + ); | |
| 788 | + let shifted = key_input("a", Some("A"), modifiers::SHIFT, true); | |
| 789 | + assert_eq!( | |
| 790 | + (shifted.key.as_deref(), shifted.code.as_deref(), shifted.text.as_deref()), | |
| 791 | + (Some("A"), Some("KeyA"), Some("A")) | |
| 792 | + ); | |
| 793 | + let shortcut = key_input("a", None, modifiers::CTRL, true); | |
| 794 | + assert_eq!((shortcut.event_type, shortcut.text), (KeyEventType::RawDown, None)); | |
| 795 | + let up = key_input("a", Some("a"), 0, false); | |
| 796 | + assert_eq!((up.event_type, up.text), (KeyEventType::Up, None)); | |
| 797 | + assert_eq!(key_input("f5", None, 0, true).windows_virtual_key_code, 116); | |
| 798 | + assert_eq!(key_input("7", Some("7"), 0, true).code.as_deref(), Some("Digit7")); | |
| 799 | + assert_eq!(key_input(",", Some(","), 0, true).code, None); | |
| 800 | + } | |
| 801 | + | |
| 802 | + #[::core::prelude::v1::test] | |
| 803 | + fn maps_points_onto_the_page() { | |
| 804 | + // A 1280×720 frame in a 640×600 area: drawn 640×360, centered vertically. | |
| 805 | + assert_eq!(contain((640., 600.), (1280., 720.)), (0., 120., 640., 360.)); | |
| 806 | + assert_eq!(page_point((320., 300.), (640., 600.), (1280., 720.), (1280., 720.)), Some((640., 360.))); | |
| 807 | + assert_eq!(page_point((10., 50.), (640., 600.), (1280., 720.), (1280., 720.)), None); | |
| 808 | + // A wide area pillarboxes; the frame may be scaled from the CSS viewport. | |
| 809 | + assert_eq!(contain((1000., 360.), (640., 360.)), (180., 0., 640., 360.)); | |
| 810 | + assert_eq!(page_point((180., 0.), (1000., 360.), (640., 360.), (1280., 720.)), Some((0., 0.))); | |
| 811 | + assert_eq!(page_point((820., 360.), (1000., 360.), (640., 360.), (1280., 720.)), Some((1279., 719.))); | |
| 812 | + assert_eq!(page_point((5., 5.), (0., 0.), (1280., 720.), (1280., 720.)), None); | |
| 813 | + } | |
| 814 | + | |
| 815 | + #[::core::prelude::v1::test] | |
| 816 | + fn normalizes_urls() { | |
| 817 | + assert_eq!(normalize_url("example.com").as_deref(), Some("https://example.com")); | |
| 818 | + assert_eq!(normalize_url(" example.com/a?b ").as_deref(), Some("https://example.com/a?b")); | |
| 819 | + assert_eq!(normalize_url("http://example.com").as_deref(), Some("http://example.com")); | |
| 820 | + assert_eq!(normalize_url("localhost:3000/x").as_deref(), Some("http://localhost:3000/x")); | |
| 821 | + assert_eq!(normalize_url("127.0.0.1:8080").as_deref(), Some("http://127.0.0.1:8080")); | |
| 822 | + assert_eq!(normalize_url("app.localhost").as_deref(), Some("http://app.localhost")); | |
| 823 | + assert_eq!(normalize_url("about:blank").as_deref(), Some("about:blank")); | |
| 824 | + assert_eq!(normalize_url("file:///tmp/a.html").as_deref(), Some("file:///tmp/a.html")); | |
| 825 | + assert_eq!(normalize_url(" "), None); | |
| 826 | + } | |
| 827 | + | |
| 828 | + /// Drives a real session: `cargo test live_session -- --ignored`. Needs | |
| 829 | + /// agent-browser and its Chrome, and the network for example.com. | |
| 830 | + #[::core::prelude::v1::test] | |
| 831 | + #[ignore] | |
| 832 | + fn live_session() { | |
| 833 | + let binary = find_binary().expect("agent-browser is not installed"); | |
| 834 | + let name = session_name(&format!("test-{}", std::process::id())); | |
| 835 | + let cli = |args: &[&str]| -> Value { | |
| 836 | + let output = Command::new(&binary).args(["--session", &name]).args(args).arg("--json").output().unwrap(); | |
| 837 | + serde_json::from_slice(&output.stdout).unwrap() | |
| 838 | + }; | |
| 839 | + let (tx, rx) = smol::channel::unbounded(); | |
| 840 | + let session = BrowserSession::new(name.clone(), tx); | |
| 841 | + session.set_watching(true); | |
| 842 | + let deadline = Instant::now() + Duration::from_secs(60); | |
| 843 | + let agent_actions = std::cell::RefCell::new(Vec::new()); | |
| 844 | + let next = || { | |
| 845 | + let left = deadline.saturating_duration_since(Instant::now()); | |
| 846 | + let event = smol::block_on(smol::future::or(async { rx.recv().await.ok() }, async { | |
| 847 | + smol::Timer::after(left).await; | |
| 848 | + None | |
| 849 | + })) | |
| 850 | + .expect("timed out waiting for the browser"); | |
| 851 | + match &event { | |
| 852 | + BrowserEvent::Failed(err) => panic!("{err}"), | |
| 853 | + BrowserEvent::Agent(action) => agent_actions.borrow_mut().push(action.clone()), | |
| 854 | + _ => {} | |
| 855 | + } | |
| 856 | + event | |
| 857 | + }; | |
| 858 | + | |
| 859 | + // No daemon yet; the panel's `open` starts one, and frames follow. | |
| 860 | + assert_eq!(next(), BrowserEvent::Waiting); | |
| 861 | + session.open("https://example.com"); | |
| 862 | + let frame = loop { | |
| 863 | + if next() == BrowserEvent::Frame { | |
| 864 | + break session.take_frame().expect("a frame"); | |
| 865 | + } | |
| 866 | + }; | |
| 867 | + assert!(frame.size.0 > 0. && frame.size.1 > 0.); | |
| 868 | + assert_eq!(frame.viewport, (1280., 720.)); | |
| 869 | + | |
| 870 | + // A page with a link and a field at known places. | |
| 871 | + let page = std::env::temp_dir().join(format!("vectrum-browser-test-{}.html", std::process::id())); | |
| 872 | + std::fs::write( | |
| 873 | + &page, | |
| 874 | + r##"<!doctype html><title>test</title><body style="margin:0"> | |
| 875 | + <a href="#clicked" style="position:absolute;left:100px;top:100px">link</a> | |
| 876 | + <input id="field" style="position:absolute;left:100px;top:200px;width:300px"></body>"##, | |
| 877 | + ) | |
| 878 | + .unwrap(); | |
| 879 | + session.open(&format!("file://{}", page.display())); | |
| 880 | + while !matches!(next(), BrowserEvent::Url(url) if url.ends_with(".html")) {} | |
| 881 | + let click = |(x, y): (f64, f64)| { | |
| 882 | + session.send(Input::Mouse(MouseInput::new(MouseEventType::Move, (x, y), 0))); | |
| 883 | + for event_type in [MouseEventType::Down, MouseEventType::Up] { | |
| 884 | + let mut click = MouseInput::new(event_type, (x, y), 0); | |
| 885 | + click.button = PointerButton::Left; | |
| 886 | + click.click_count = 1; | |
| 887 | + session.send(Input::Mouse(click)); | |
| 888 | + } | |
| 889 | + }; | |
| 890 | + click((110., 108.)); | |
| 891 | + while !matches!(next(), BrowserEvent::Url(url) if url.ends_with("#clicked")) {} | |
| 892 | + | |
| 893 | + // Typing and pasting into the field. | |
| 894 | + click((150., 210.)); | |
| 895 | + for (key, typed) in [("h", "h"), ("i", "i")] { | |
| 896 | + session.send(Input::Key(key_input(key, Some(typed), 0, true))); | |
| 897 | + session.send(Input::Key(key_input(key, Some(typed), 0, false))); | |
| 898 | + } | |
| 899 | + for input in text_input(" there") { | |
| 900 | + session.send(Input::Key(input)); | |
| 901 | + } | |
| 902 | + thread::sleep(Duration::from_millis(500)); | |
| 903 | + assert_eq!(cli(&["get", "value", "#field"])["data"]["value"], "hi there"); | |
| 904 | + | |
| 905 | + // Commands from anyone but the panel show as the agent's. | |
| 906 | + while !agent_actions.borrow().iter().any(|action| action == "inputvalue") { | |
| 907 | + next(); | |
| 908 | + } | |
| 909 | + assert!(!agent_actions.borrow().iter().any(|action| action == "navigate"), "{agent_actions:?}"); | |
| 910 | + | |
| 911 | + drop(session); | |
| 912 | + let _ = std::fs::remove_file(page); | |
| 913 | + stop(&name); | |
| 914 | + let closing = Instant::now(); | |
| 915 | + while is_running(&name) && closing.elapsed() < Duration::from_secs(10) { | |
| 916 | + thread::sleep(Duration::from_millis(100)); | |
| 917 | + } | |
| 918 | + assert!(!is_running(&name)); | |
| 919 | + } | |
| 920 | + | |
| 921 | + #[::core::prelude::v1::test] | |
| 922 | + fn names_sessions() { | |
| 923 | + assert_eq!(session_name("18f-2a-0"), "vectrum-18f-2a-0"); | |
| 924 | + assert!(agent_prompt("vectrum-x").contains("`vectrum-x`")); | |
| 925 | + } | |
| 926 | + } | |
| @@ -14,7 +14,9 @@ use std::{ | ||
| 14 | 14 | use anyhow::{Context as _, Result, anyhow}; |
| 15 | 15 | use serde_json::{Value, json}; |
| 16 | 16 | |
| 17 | + use crate::browser; | |
| 17 | 18 | use crate::models::Provider; |
| 19 | + use crate::settings::{ProviderInstance, expand_home}; | |
| 18 | 20 | |
| 19 | 21 | /// Environment variables that mark a process as running *inside* another |
| 20 | 22 | /// Claude Code session. They leak in when this app is launched from a Claude |
| @@ -89,7 +91,7 @@ impl PermissionMode { | ||
| 89 | 91 | |
| 90 | 92 | /// How hard the model thinks: Claude's `--effort` levels and Pi's thinking |
| 91 | 93 | /// levels, which add `off` and `minimal`. |
| 92 | - #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 94 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | |
| 93 | 95 | pub enum Effort { |
| 94 | 96 | Off, |
| 95 | 97 | Minimal, |
| @@ -135,7 +137,8 @@ impl Effort { | ||
| 135 | 137 | |
| 136 | 138 | #[derive(Clone, Debug)] |
| 137 | 139 | pub struct LaunchConfig { |
| 138 | - pub provider: Provider, | |
| 140 | + /// Which agent, with its binary, home folder, arguments and environment. | |
| 141 | + pub instance: ProviderInstance, | |
| 139 | 142 | pub cwd: PathBuf, |
| 140 | 143 | /// A Claude model alias, or `provider/model` for Pi. `None` is the default. |
| 141 | 144 | pub model: Option<String>, |
| @@ -145,6 +148,8 @@ pub struct LaunchConfig { | ||
| 145 | 148 | /// Session to resume (a Claude session id or a Pi session file), used to |
| 146 | 149 | /// restart a thread whose process stopped. |
| 147 | 150 | pub resume: Option<String>, |
| 151 | + /// The thread's agent-browser session, shared with the browser panel. | |
| 152 | + pub browser_session: Option<String>, | |
| 148 | 153 | } |
| 149 | 154 | |
| 150 | 155 | /// Everything the background reader threads report back to the UI. |
| @@ -162,10 +167,16 @@ pub struct AgentProcess { | ||
| 162 | 167 | |
| 163 | 168 | impl AgentProcess { |
| 164 | 169 | pub fn spawn(config: &LaunchConfig, events: smol::channel::Sender<ProcessEvent>) -> Result<Self> { |
| 165 | - let (binary, command) = match config.provider { | |
| 170 | + let (binary, mut command) = match config.instance.kind { | |
| 166 | 171 | Provider::Claude => claude_command(config)?, |
| 167 | 172 | Provider::Pi => pi_command(config)?, |
| 168 | 173 | }; |
| 174 | + // Both agents take extra system prompt with this flag. | |
| 175 | + if let Some(session) = &config.browser_session | |
| 176 | + && let Some(prompt) = browser::prepare_agent(&mut command, session) | |
| 177 | + { | |
| 178 | + command.args(["--append-system-prompt", &prompt]); | |
| 179 | + } | |
| 169 | 180 | Self::spawn_command(&binary, command, events) |
| 170 | 181 | } |
| 171 | 182 | |
| @@ -175,9 +186,6 @@ impl AgentProcess { | ||
| 175 | 186 | events: smol::channel::Sender<ProcessEvent>, |
| 176 | 187 | ) -> Result<Self> { |
| 177 | 188 | command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); |
| 178 | - for key in NESTED_SESSION_ENV { | |
| 179 | - command.env_remove(key); | |
| 180 | - } | |
| 181 | 189 | // Take the agent down with us if the app dies without cleaning up. |
| 182 | 190 | unsafe { |
| 183 | 191 | command.pre_exec(|| { |
| @@ -287,9 +295,7 @@ impl Drop for AgentProcess { | ||
| 287 | 295 | } |
| 288 | 296 | |
| 289 | 297 | fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { |
| 290 | - let binary = find_claude_binary() | |
| 291 | - .ok_or_else(|| anyhow!("could not find the `claude` CLI on PATH (set VECTRUM_CODE_CLAUDE_BIN)"))?; | |
| 292 | - let mut command = Command::new(&binary); | |
| 298 | + let (binary, mut command) = base_command(&config.instance)?; | |
| 293 | 299 | command |
| 294 | 300 | .args([ |
| 295 | 301 | "-p", |
| @@ -316,13 +322,12 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 316 | 322 | if let Some(session_id) = &config.resume { |
| 317 | 323 | command.args(["--resume", session_id]); |
| 318 | 324 | } |
| 325 | + command.args(split_args(&config.instance.args)); | |
| 319 | 326 | Ok((binary, command)) |
| 320 | 327 | } |
| 321 | 328 | |
| 322 | 329 | fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { |
| 323 | - let binary = find_pi_binary() | |
| 324 | - .ok_or_else(|| anyhow!("could not find the `pi` CLI on PATH (set VECTRUM_CODE_PI_BIN)"))?; | |
| 325 | - let mut command = Command::new(&binary); | |
| 330 | + let (binary, mut command) = base_command(&config.instance)?; | |
| 326 | 331 | command.args(["--mode", "rpc"]).current_dir(&config.cwd); |
| 327 | 332 | if let Some((provider, model)) = config.model.as_deref().and_then(|model| model.split_once('/')) { |
| 328 | 333 | command.args(["--provider", provider, "--model", model]); |
| @@ -333,9 +338,52 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 333 | 338 | if let Some(session) = &config.resume { |
| 334 | 339 | command.args(["--session", session]); |
| 335 | 340 | } |
| 341 | + command.args(split_args(&config.instance.args)); | |
| 342 | + Ok((binary, command)) | |
| 343 | + } | |
| 344 | + | |
| 345 | + /// The environment variable that moves an agent's home folder. | |
| 346 | + pub fn home_var(kind: Provider) -> &'static str { | |
| 347 | + match kind { | |
| 348 | + Provider::Claude => "CLAUDE_CONFIG_DIR", | |
| 349 | + Provider::Pi => "PI_CODING_AGENT_DIR", | |
| 350 | + } | |
| 351 | + } | |
| 352 | + | |
| 353 | + /// The variable that overrides the binary of an agent's first instance. | |
| 354 | + pub fn binary_var(kind: Provider) -> &'static str { | |
| 355 | + match kind { | |
| 356 | + Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN", | |
| 357 | + Provider::Pi => "VECTRUM_CODE_PI_BIN", | |
| 358 | + } | |
| 359 | + } | |
| 360 | + | |
| 361 | + /// A command for an agent's binary with the instance's home folder and | |
| 362 | + /// environment, and without the variables of any Claude session around us. | |
| 363 | + pub fn base_command(instance: &ProviderInstance) -> Result<(PathBuf, Command)> { | |
| 364 | + let binary = find_binary(instance).ok_or_else(|| { | |
| 365 | + anyhow!( | |
| 366 | + "could not find the `{}` CLI on PATH (set its path in Settings → Providers)", | |
| 367 | + instance.kind.key() | |
| 368 | + ) | |
| 369 | + })?; | |
| 370 | + let mut command = Command::new(&binary); | |
| 371 | + for key in NESTED_SESSION_ENV { | |
| 372 | + command.env_remove(key); | |
| 373 | + } | |
| 374 | + if !instance.home.trim().is_empty() { | |
| 375 | + command.env(home_var(instance.kind), expand_home(instance.home.trim())); | |
| 376 | + } | |
| 377 | + for var in &instance.env { | |
| 378 | + if !var.key.trim().is_empty() { | |
| 379 | + command.env(var.key.trim(), &var.value); | |
| 380 | + } | |
| 381 | + } | |
| 336 | 382 | // Pi is a Node script; a desktop launcher's PATH may not reach the Node it |
| 337 | 383 | // was installed with, which usually sits next to it. |
| 338 | - if let Some(dir) = binary.parent() { | |
| 384 | + if instance.kind == Provider::Pi | |
| 385 | + && let Some(dir) = binary.parent() | |
| 386 | + { | |
| 339 | 387 | let path = std::env::var_os("PATH").unwrap_or_default(); |
| 340 | 388 | let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path)); |
| 341 | 389 | if let Ok(joined) = std::env::join_paths(paths) { |
| @@ -345,61 +393,80 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 345 | 393 | Ok((binary, command)) |
| 346 | 394 | } |
| 347 | 395 | |
| 348 | - pub fn find_claude_binary() -> Option<PathBuf> { | |
| 349 | - if let Some(path) = std::env::var_os("VECTRUM_CODE_CLAUDE_BIN") { | |
| 396 | + /// Where an agent's binary is: the environment override (first instance | |
| 397 | + /// only), the instance's own path, then PATH and the usual install folders. | |
| 398 | + pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> { | |
| 399 | + if instance.id == instance.kind.key() | |
| 400 | + && let Some(path) = std::env::var_os(binary_var(instance.kind)) | |
| 401 | + { | |
| 350 | 402 | return Some(PathBuf::from(path)); |
| 351 | 403 | } |
| 352 | - if let Some(path) = crate::settings::get(|settings| settings.claude_bin.clone()) { | |
| 353 | - return Some(expand_home(&path)); | |
| 404 | + if !instance.binary.trim().is_empty() { | |
| 405 | + return Some(expand_home(instance.binary.trim())); | |
| 354 | 406 | } |
| 407 | + let name = instance.kind.key(); | |
| 355 | 408 | let on_path = std::env::var_os("PATH") |
| 356 | 409 | .into_iter() |
| 357 | 410 | .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>()) |
| 358 | - .map(|dir| dir.join("claude")); | |
| 411 | + .map(|dir| dir.join(name)); | |
| 359 | 412 | // Desktop launchers often start apps without the user's shell PATH. |
| 360 | 413 | let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); |
| 361 | - let fallbacks = [ | |
| 362 | - home.join(".local/bin/claude"), | |
| 363 | - home.join(".claude/local/claude"), | |
| 364 | - PathBuf::from("/usr/local/bin/claude"), | |
| 365 | - PathBuf::from("/usr/bin/claude"), | |
| 366 | - ]; | |
| 367 | - on_path.chain(fallbacks).find(|path| is_executable(path)) | |
| 368 | - } | |
| 369 | - | |
| 370 | - pub fn find_pi_binary() -> Option<PathBuf> { | |
| 371 | - if let Some(path) = std::env::var_os("VECTRUM_CODE_PI_BIN") { | |
| 372 | - return Some(PathBuf::from(path)); | |
| 373 | - } | |
| 374 | - if let Some(path) = crate::settings::get(|settings| settings.pi_bin.clone()) { | |
| 375 | - return Some(expand_home(&path)); | |
| 376 | - } | |
| 377 | - let on_path = std::env::var_os("PATH") | |
| 378 | - .into_iter() | |
| 379 | - .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>()) | |
| 380 | - .map(|dir| dir.join("pi")); | |
| 381 | - let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default(); | |
| 382 | - let mut fallbacks = vec![ | |
| 383 | - home.join(".local/bin/pi"), | |
| 384 | - home.join(".npm-global/bin/pi"), | |
| 385 | - home.join(".bun/bin/pi"), | |
| 386 | - home.join(".volta/bin/pi"), | |
| 387 | - PathBuf::from("/usr/local/bin/pi"), | |
| 388 | - PathBuf::from("/usr/bin/pi"), | |
| 389 | - ]; | |
| 414 | + let mut fallbacks = match instance.kind { | |
| 415 | + Provider::Claude => vec![home.join(".local/bin/claude"), home.join(".claude/local/claude")], | |
| 416 | + Provider::Pi => vec![ | |
| 417 | + home.join(".local/bin/pi"), | |
| 418 | + home.join(".npm-global/bin/pi"), | |
| 419 | + home.join(".bun/bin/pi"), | |
| 420 | + home.join(".volta/bin/pi"), | |
| 421 | + ], | |
| 422 | + }; | |
| 423 | + fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]); | |
| 390 | 424 | // nvm keeps one bin directory per Node version. |
| 391 | - if let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node")) { | |
| 425 | + if instance.kind == Provider::Pi | |
| 426 | + && let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node")) | |
| 427 | + { | |
| 392 | 428 | fallbacks.extend(versions.flatten().map(|version| version.path().join("bin/pi"))); |
| 393 | 429 | } |
| 394 | 430 | on_path.chain(fallbacks).find(|path| is_executable(path)) |
| 395 | 431 | } |
| 396 | 432 | |
| 397 | - /// `~/bin/claude` → `/home/me/bin/claude`. | |
| 398 | - fn expand_home(path: &str) -> PathBuf { | |
| 399 | - match (path.strip_prefix("~/"), std::env::var_os("HOME")) { | |
| 400 | - (Some(rest), Some(home)) => PathBuf::from(home).join(rest), | |
| 401 | - _ => PathBuf::from(path), | |
| 433 | + /// Splits launch arguments like a shell: on whitespace, keeping quoted runs | |
| 434 | + /// together. | |
| 435 | + pub fn split_args(text: &str) -> Vec<String> { | |
| 436 | + let mut args = Vec::new(); | |
| 437 | + let mut current = String::new(); | |
| 438 | + let mut quote: Option<char> = None; | |
| 439 | + let mut started = false; | |
| 440 | + let mut chars = text.chars(); | |
| 441 | + while let Some(c) = chars.next() { | |
| 442 | + match (quote, c) { | |
| 443 | + (Some(q), c) if c == q => quote = None, | |
| 444 | + (Some('"'), '\\') => current.extend(chars.next()), | |
| 445 | + (Some(_), c) => current.push(c), | |
| 446 | + (None, '"' | '\'') => { | |
| 447 | + quote = Some(c); | |
| 448 | + started = true; | |
| 449 | + } | |
| 450 | + (None, '\\') => { | |
| 451 | + current.extend(chars.next()); | |
| 452 | + started = true; | |
| 453 | + } | |
| 454 | + (None, c) if c.is_whitespace() => { | |
| 455 | + if started || !current.is_empty() { | |
| 456 | + args.push(std::mem::take(&mut current)); | |
| 457 | + } | |
| 458 | + started = false; | |
| 459 | + } | |
| 460 | + (None, c) => { | |
| 461 | + current.push(c); | |
| 462 | + started = true; | |
| 463 | + } | |
| 464 | + } | |
| 465 | + } | |
| 466 | + if started || !current.is_empty() { | |
| 467 | + args.push(current); | |
| 402 | 468 | } |
| 469 | + args | |
| 403 | 470 | } |
| 404 | 471 | |
| 405 | 472 | pub fn is_executable(path: &Path) -> bool { |
| @@ -408,3 +475,15 @@ pub fn is_executable(path: &Path) -> bool { | ||
| 408 | 475 | .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) |
| 409 | 476 | .unwrap_or(false) |
| 410 | 477 | } |
| 478 | + | |
| 479 | + #[cfg(test)] | |
| 480 | + mod tests { | |
| 481 | + use super::split_args; | |
| 482 | + | |
| 483 | + #[::core::prelude::v1::test] | |
| 484 | + fn splits_like_a_shell() { | |
| 485 | + assert_eq!(split_args(r#"--a 1 --b "two words" 'x y' z\ w"#), ["--a", "1", "--b", "two words", "x y", "z w"]); | |
| 486 | + assert_eq!(split_args(" "), Vec::<String>::new()); | |
| 487 | + assert_eq!(split_args(r#"--empty """#), ["--empty", ""]); | |
| 488 | + } | |
| 489 | + } | |
| @@ -0,0 +1,60 @@ | ||
| 1 | + //! Clock times as the user wants to read them: 12- or 24-hour, or whatever | |
| 2 | + //! the locale prefers. | |
| 3 | + | |
| 4 | + use std::time::SystemTime; | |
| 5 | + | |
| 6 | + use chrono::{DateTime, Datelike as _, Local}; | |
| 7 | + | |
| 8 | + use crate::settings::{self, TimeFormat}; | |
| 9 | + | |
| 10 | + /// Whether times show as `2:32 PM` rather than `14:32`. | |
| 11 | + pub fn uses_12_hours() -> bool { | |
| 12 | + match settings::get(|settings| settings.time_format) { | |
| 13 | + TimeFormat::Hour12 => true, | |
| 14 | + TimeFormat::Hour24 => false, | |
| 15 | + TimeFormat::System => locale_uses_12_hours(), | |
| 16 | + } | |
| 17 | + } | |
| 18 | + | |
| 19 | + /// The locales that write the time with AM and PM. | |
| 20 | + fn locale_uses_12_hours() -> bool { | |
| 21 | + let locale = ["LC_ALL", "LC_TIME", "LANG"] | |
| 22 | + .iter() | |
| 23 | + .find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty())) | |
| 24 | + .unwrap_or_default(); | |
| 25 | + ["en_US", "en_CA", "en_AU", "en_NZ", "en_PH", "en_IN", "es_MX", "es_US", "hi_IN", "ar_", "ko_KR", "fil_PH"] | |
| 26 | + .iter() | |
| 27 | + .any(|prefix| locale.starts_with(prefix)) | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// `14:32` or `2:32 PM`. | |
| 31 | + pub fn clock(time: SystemTime) -> String { | |
| 32 | + let time: DateTime<Local> = time.into(); | |
| 33 | + if uses_12_hours() { time.format("%-I:%M %p").to_string() } else { time.format("%H:%M").to_string() } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// A moment near now, as briefly as it reads clearly: `14:32`, `tomorrow | |
| 37 | + /// 09:00`, `Fri 09:00`, `Oct 21, 09:00`. | |
| 38 | + pub fn when(time: SystemTime) -> String { | |
| 39 | + let local: DateTime<Local> = time.into(); | |
| 40 | + let today = Local::now().date_naive(); | |
| 41 | + let days = (local.date_naive() - today).num_days(); | |
| 42 | + let clock = clock(time); | |
| 43 | + match days { | |
| 44 | + 0 => clock, | |
| 45 | + 1 => format!("tomorrow {clock}"), | |
| 46 | + -1 => format!("yesterday {clock}"), | |
| 47 | + 2..=6 => format!("{} {clock}", local.format("%a")), | |
| 48 | + _ if local.year() == today.year() => format!("{}, {clock}", local.format("%b %-d")), | |
| 49 | + _ => format!("{}, {clock}", local.format("%b %-d %Y")), | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// The next time the clock shows `hour`:00, `days` days from today. | |
| 54 | + pub fn at_hour(days: i64, hour: u32) -> SystemTime { | |
| 55 | + let date = Local::now().date_naive() + chrono::Duration::days(days); | |
| 56 | + date.and_hms_opt(hour, 0, 0) | |
| 57 | + .and_then(|naive| naive.and_local_timezone(Local).earliest()) | |
| 58 | + .map(SystemTime::from) | |
| 59 | + .unwrap_or_else(SystemTime::now) | |
| 60 | + } | |
| @@ -6,6 +6,8 @@ use std::{ | ||
| 6 | 6 | process::{Command, Stdio}, |
| 7 | 7 | }; |
| 8 | 8 | |
| 9 | + use serde::{Deserialize, Serialize}; | |
| 10 | + | |
| 9 | 11 | /// Runs git in `path`, returning stdout, or stderr as the error. |
| 10 | 12 | pub fn git(path: &Path, args: &[&str]) -> Result<String, String> { |
| 11 | 13 | run(Command::new("git").arg("-C").arg(path).args(args)) |
| @@ -93,7 +95,7 @@ pub fn snapshot(cwd: &Path, index: &Path) -> Option<String> { | ||
| 93 | 95 | Some(tree.trim().to_string()) |
| 94 | 96 | } |
| 95 | 97 | |
| 96 | - #[derive(Clone, Debug, PartialEq)] | |
| 98 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 97 | 99 | pub struct FileChange { |
| 98 | 100 | pub path: String, |
| 99 | 101 | /// `None` for binary files. |
| @@ -102,7 +104,7 @@ pub struct FileChange { | ||
| 102 | 104 | } |
| 103 | 105 | |
| 104 | 106 | /// The files one turn changed: the difference between two snapshots. |
| 105 | - #[derive(Clone, Debug, PartialEq)] | |
| 107 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 106 | 108 | pub struct TurnChanges { |
| 107 | 109 | pub base: String, |
| 108 | 110 | pub head: String, |
| @@ -149,6 +151,10 @@ pub enum LineKind { | ||
| 149 | 151 | pub struct DiffLine { |
| 150 | 152 | pub kind: LineKind, |
| 151 | 153 | pub text: String, |
| 154 | + /// Where the line sits in the old and the new file. Added lines have no | |
| 155 | + /// old number, removed ones no new number, hunk rows neither. | |
| 156 | + pub old_line: Option<u32>, | |
| 157 | + pub new_line: Option<u32>, | |
| 152 | 158 | } |
| 153 | 159 | |
| 154 | 160 | #[derive(Clone, Debug)] |
| @@ -159,16 +165,42 @@ pub struct FileDiff { | ||
| 159 | 165 | } |
| 160 | 166 | |
| 161 | 167 | /// The unified diff between two snapshots, optionally for one path. |
| 162 | - pub fn diff_between(cwd: &Path, base: &str, head: &str, path: Option<&str>) -> Result<Vec<FileDiff>, String> { | |
| 163 | - let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3", base, head]; | |
| 168 | + /// `ignore_whitespace` leaves out whitespace-only edits, and with them the | |
| 169 | + /// files that changed nothing else. | |
| 170 | + pub fn diff_between( | |
| 171 | + cwd: &Path, | |
| 172 | + base: &str, | |
| 173 | + head: &str, | |
| 174 | + path: Option<&str>, | |
| 175 | + ignore_whitespace: bool, | |
| 176 | + ) -> Result<Vec<FileDiff>, String> { | |
| 177 | + let mut args = vec!["diff", "--no-color", "--no-ext-diff", "--no-renames", "-U3"]; | |
| 178 | + if ignore_whitespace { | |
| 179 | + args.push("--ignore-all-space"); | |
| 180 | + } | |
| 181 | + args.extend([base, head]); | |
| 164 | 182 | if let Some(path) = path { |
| 165 | 183 | args.extend(["--", path]); |
| 166 | 184 | } |
| 167 | - Ok(parse_diff(&git(cwd, &args)?)) | |
| 185 | + let mut files = parse_diff(&git(cwd, &args)?); | |
| 186 | + if ignore_whitespace { | |
| 187 | + // Newer gits drop such a file; older ones keep its header, hunkless. | |
| 188 | + files.retain(|file| file.binary || !file.lines.is_empty()); | |
| 189 | + } | |
| 190 | + Ok(files) | |
| 191 | + } | |
| 192 | + | |
| 193 | + /// The first old and new line of a hunk, from `@@ -a,b +c,d @@ context`. | |
| 194 | + fn parse_hunk(line: &str) -> Option<(u32, u32)> { | |
| 195 | + let mut ranges = line.strip_prefix("@@ ")?.split(' '); | |
| 196 | + let start = |range: Option<&str>, sign: char| range?.strip_prefix(sign)?.split(',').next()?.parse().ok(); | |
| 197 | + Some((start(ranges.next(), '-')?, start(ranges.next(), '+')?)) | |
| 168 | 198 | } |
| 169 | 199 | |
| 170 | 200 | fn parse_diff(text: &str) -> Vec<FileDiff> { |
| 171 | 201 | let mut files: Vec<FileDiff> = Vec::new(); |
| 202 | + // The numbers the next old and new line will have. | |
| 203 | + let (mut old, mut new) = (0, 0); | |
| 172 | 204 | for line in text.lines() { |
| 173 | 205 | if let Some(rest) = line.strip_prefix("diff --git ") { |
| 174 | 206 | // `a/path b/path`: take the new side. |
| @@ -177,34 +209,65 @@ fn parse_diff(text: &str) -> Vec<FileDiff> { | ||
| 177 | 209 | continue; |
| 178 | 210 | } |
| 179 | 211 | let Some(file) = files.last_mut() else { continue }; |
| 180 | - let kind = if line.starts_with("@@") { | |
| 181 | - LineKind::Hunk | |
| 212 | + let (kind, text, old_line, new_line) = if line.starts_with("@@") { | |
| 213 | + (old, new) = parse_hunk(line).unwrap_or_default(); | |
| 214 | + (LineKind::Hunk, line, None, None) | |
| 182 | 215 | } else if line.starts_with("Binary files") { |
| 183 | 216 | file.binary = true; |
| 184 | 217 | continue; |
| 185 | 218 | } else if file.lines.is_empty() && (line.starts_with("---") || line.starts_with("+++")) { |
| 186 | 219 | continue; |
| 187 | 220 | } else if let Some(text) = line.strip_prefix('+') { |
| 188 | - file.lines.push(DiffLine { kind: LineKind::Added, text: text.to_string() }); | |
| 189 | - continue; | |
| 221 | + new += 1; | |
| 222 | + (LineKind::Added, text, None, Some(new - 1)) | |
| 190 | 223 | } else if let Some(text) = line.strip_prefix('-') { |
| 191 | - file.lines.push(DiffLine { kind: LineKind::Removed, text: text.to_string() }); | |
| 192 | - continue; | |
| 224 | + old += 1; | |
| 225 | + (LineKind::Removed, text, Some(old - 1), None) | |
| 193 | 226 | } else if let Some(text) = line.strip_prefix(' ') { |
| 194 | - file.lines.push(DiffLine { kind: LineKind::Context, text: text.to_string() }); | |
| 195 | - continue; | |
| 227 | + (old, new) = (old + 1, new + 1); | |
| 228 | + (LineKind::Context, text, Some(old - 1), Some(new - 1)) | |
| 196 | 229 | } else { |
| 197 | 230 | // Headers: index, mode and new/deleted file lines. |
| 198 | 231 | continue; |
| 199 | 232 | }; |
| 200 | - file.lines.push(DiffLine { kind, text: line.to_string() }); | |
| 233 | + file.lines.push(DiffLine { kind, text: text.to_string(), old_line, new_line }); | |
| 201 | 234 | } |
| 202 | 235 | files |
| 203 | 236 | } |
| 204 | 237 | |
| 205 | 238 | #[cfg(test)] |
| 206 | 239 | mod tests { |
| 207 | - use super::{LineKind, parse_diff}; | |
| 240 | + use super::{LineKind, parse_diff, parse_hunk}; | |
| 241 | + | |
| 242 | + #[::core::prelude::v1::test] | |
| 243 | + fn parses_hunk_headers() { | |
| 244 | + assert_eq!(parse_hunk("@@ -1,2 +3,4 @@ fn a() {"), Some((1, 3))); | |
| 245 | + assert_eq!(parse_hunk("@@ -0,0 +1 @@"), Some((0, 1))); | |
| 246 | + assert_eq!(parse_hunk("@@ -7 +7 @@"), Some((7, 7))); | |
| 247 | + assert_eq!(parse_hunk("@@@ -1,2 -1,2 +1,3 @@@"), None); | |
| 248 | + } | |
| 249 | + | |
| 250 | + #[::core::prelude::v1::test] | |
| 251 | + fn numbers_lines() { | |
| 252 | + let diff = "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -10,4 +12,5 @@ fn a() {\n keep\n-gone\n+one\n+two\n keep\n@@ -40 +43 @@\n-x\n+y\n"; | |
| 253 | + let files = parse_diff(diff); | |
| 254 | + let numbers: Vec<_> = files[0].lines.iter().map(|line| (line.old_line, line.new_line)).collect(); | |
| 255 | + assert_eq!( | |
| 256 | + numbers, | |
| 257 | + [ | |
| 258 | + (None, None), | |
| 259 | + (Some(10), Some(12)), | |
| 260 | + (Some(11), None), | |
| 261 | + (None, Some(13)), | |
| 262 | + (None, Some(14)), | |
| 263 | + (Some(12), Some(15)), | |
| 264 | + (None, None), | |
| 265 | + (Some(40), None), | |
| 266 | + (None, Some(43)), | |
| 267 | + ] | |
| 268 | + ); | |
| 269 | + assert_eq!(files[0].lines[0].text, "@@ -10,4 +12,5 @@ fn a() {"); | |
| 270 | + } | |
| 208 | 271 | |
| 209 | 272 | #[::core::prelude::v1::test] |
| 210 | 273 | fn parses_unified_diff() { |
| @@ -0,0 +1,25 @@ | ||
| 1 | + //! A small blocking HTTP client for the few requests the app makes itself: | |
| 2 | + //! version checks and usage hubs. Call it from a background task. | |
| 3 | + | |
| 4 | + use std::time::Duration; | |
| 5 | + | |
| 6 | + /// GETs `url` and returns the body, or a short description of what failed. | |
| 7 | + pub fn get(url: &str, headers: &[(&str, &str)], timeout: Duration) -> Result<String, String> { | |
| 8 | + let agent = ureq::AgentBuilder::new().timeout(timeout).build(); | |
| 9 | + let mut request = agent.get(url).set("User-Agent", concat!("vectrum-code/", env!("CARGO_PKG_VERSION"))); | |
| 10 | + for (name, value) in headers { | |
| 11 | + request = request.set(name, value); | |
| 12 | + } | |
| 13 | + match request.call() { | |
| 14 | + Ok(response) => response.into_string().map_err(|err| err.to_string()), | |
| 15 | + Err(ureq::Error::Status(code, response)) => { | |
| 16 | + let body = response.into_string().unwrap_or_default(); | |
| 17 | + let detail = serde_json::from_str::<serde_json::Value>(&body) | |
| 18 | + .ok() | |
| 19 | + .and_then(|value| value.get("error").and_then(|error| error.as_str()).map(str::to_string)) | |
| 20 | + .unwrap_or_else(|| body.trim().chars().take(120).collect()); | |
| 21 | + Err(if detail.is_empty() { format!("HTTP {code}") } else { format!("HTTP {code}: {detail}") }) | |
| 22 | + } | |
| 23 | + Err(err) => Err(err.to_string()), | |
| 24 | + } | |
| 25 | + } | |
| @@ -3,15 +3,28 @@ | ||
| 3 | 3 | // GPUI's derive macros expand to `gpui::` paths; route them through the Kit. |
| 4 | 4 | extern crate gpui_kit as gpui; |
| 5 | 5 | |
| 6 | + mod browser; | |
| 6 | 7 | mod claude; |
| 8 | + mod clock; | |
| 7 | 9 | mod git; |
| 10 | + mod http; | |
| 8 | 11 | mod models; |
| 12 | + mod remote; | |
| 9 | 13 | mod session; |
| 10 | 14 | mod settings; |
| 15 | + mod store; | |
| 16 | + mod textgen; | |
| 11 | 17 | mod ui; |
| 18 | + mod usage; | |
| 19 | + mod worktree; | |
| 12 | 20 | |
| 13 | - use std::path::PathBuf; | |
| 21 | + use std::{ | |
| 22 | + path::PathBuf, | |
| 23 | + sync::Mutex, | |
| 24 | + time::{Duration, Instant}, | |
| 25 | + }; | |
| 14 | 26 | |
| 27 | + use gpui_kit::component::{WindowExt as _, notification::Notification}; | |
| 15 | 28 | use gpui_kit::*; |
| 16 | 29 | |
| 17 | 30 | use crate::ui::{icons::AppAssets, metal, workspace::Workspace}; |
| @@ -24,6 +37,7 @@ gpui_kit::actions!( | ||
| 24 | 37 | Interrupt, |
| 25 | 38 | ToggleSidebar, |
| 26 | 39 | ToggleProjectPanel, |
| 40 | + ToggleBrowser, | |
| 27 | 41 | FocusSearch, |
| 28 | 42 | OpenSettings, |
| 29 | 43 | CloseSettings, |
| @@ -31,34 +45,37 @@ gpui_kit::actions!( | ||
| 31 | 45 | ] |
| 32 | 46 | ); |
| 33 | 47 | |
| 48 | + /// With the Hold quit shortcut, a second Ctrl+Q (or the key repeat of a | |
| 49 | + /// held one) within this long quits. | |
| 50 | + const QUIT_WINDOW: Duration = Duration::from_millis(1200); | |
| 51 | + | |
| 34 | 52 | fn main() { |
| 35 | 53 | let project = std::env::args_os() |
| 36 | 54 | .nth(1) |
| 37 | 55 | .map(PathBuf::from) |
| 38 | - .or_else(|| std::env::current_dir().ok()) | |
| 39 | - .unwrap_or_else(|| PathBuf::from(".")); | |
| 40 | - let project = project.canonicalize().unwrap_or(project); | |
| 56 | + .map(|project| project.canonicalize().unwrap_or(project)); | |
| 41 | 57 | |
| 42 | 58 | gpui_kit::application() |
| 43 | 59 | .with_assets(AppAssets) |
| 44 | 60 | .run(move |cx| { |
| 45 | 61 | gpui_kit::init(cx); |
| 46 | 62 | metal::load_fonts(cx); |
| 47 | - metal::restore_colorway(cx); | |
| 48 | 63 | models::Catalog::init(cx); |
| 64 | + ui::browser::BrowserWatch::init(cx); | |
| 49 | 65 | |
| 50 | 66 | cx.bind_keys([ |
| 51 | 67 | KeyBinding::new("ctrl-n", NewThread, None), |
| 52 | 68 | KeyBinding::new("ctrl-o", OpenProject, None), |
| 53 | 69 | KeyBinding::new("ctrl-b", ToggleSidebar, None), |
| 54 | 70 | KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None), |
| 71 | + KeyBinding::new("ctrl-shift-e", ToggleBrowser, None), | |
| 55 | 72 | KeyBinding::new("ctrl-k", FocusSearch, None), |
| 56 | 73 | KeyBinding::new("ctrl-,", OpenSettings, None), |
| 57 | 74 | KeyBinding::new("ctrl-q", Quit, None), |
| 58 | 75 | KeyBinding::new("escape", Interrupt, Some("ChatView")), |
| 59 | 76 | KeyBinding::new("escape", CloseSettings, Some("SettingsView")), |
| 60 | 77 | ]); |
| 61 | - cx.on_action(|_: &Quit, cx| cx.quit()); | |
| 78 | + cx.on_action(quit); | |
| 62 | 79 | cx.on_window_closed(|cx, _| cx.quit()).detach(); |
| 63 | 80 | |
| 64 | 81 | let bounds = Bounds::centered(None, size(px(1280.), px(840.)), cx); |
| @@ -80,3 +97,24 @@ fn main() { | ||
| 80 | 97 | cx.activate(true); |
| 81 | 98 | }); |
| 82 | 99 | } |
| 100 | + | |
| 101 | + /// Quits, or with the Hold shortcut, asks for the keys to be held or pressed | |
| 102 | + /// again. | |
| 103 | + fn quit(_: &Quit, cx: &mut App) { | |
| 104 | + static FIRST_PRESS: Mutex<Option<Instant>> = Mutex::new(None); | |
| 105 | + if settings::get(|settings| settings.quit_shortcut) == settings::QuitShortcut::Press { | |
| 106 | + cx.quit(); | |
| 107 | + return; | |
| 108 | + } | |
| 109 | + let mut first = FIRST_PRESS.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); | |
| 110 | + if first.is_some_and(|at| at.elapsed() < QUIT_WINDOW) { | |
| 111 | + cx.quit(); | |
| 112 | + return; | |
| 113 | + } | |
| 114 | + *first = Some(Instant::now()); | |
| 115 | + if let Some(window) = cx.active_window() { | |
| 116 | + let _ = window.update(cx, |_, window, cx| { | |
| 117 | + window.push_notification(Notification::info("Hold Ctrl+Q, or press it again, to quit."), cx); | |
| 118 | + }); | |
| 119 | + } | |
| 120 | + } | |
| @@ -1,22 +1,28 @@ | ||
| 1 | - //! Every model the app can run, across providers, in one catalog the pickers | |
| 2 | - //! read: Claude's from the CLI's `initialize` reply, Pi's from asking `pi` for | |
| 3 | - //! the models its configured providers offer. Also the user's favorites. | |
| 1 | + //! Every agent the app can run and what it offers, in one catalog the | |
| 2 | + //! pickers and settings read. Each configured provider instance gets a | |
| 3 | + //! background health check: Claude's models, account, slash commands and | |
| 4 | + //! usage limits come from a short-lived `claude` asked over its control | |
| 5 | + //! channel, Pi's models from asking `pi` for what its providers offer. Also | |
| 6 | + //! the user's favorite models. | |
| 4 | 7 | |
| 5 | 8 | use std::{ |
| 6 | 9 | io::{BufRead, BufReader, Write}, |
| 7 | - process::{Command, Stdio}, | |
| 10 | + path::Path, | |
| 11 | + process::Stdio, | |
| 8 | 12 | sync::mpsc, |
| 9 | - time::Duration, | |
| 13 | + time::{Duration, Instant, SystemTime}, | |
| 10 | 14 | }; |
| 11 | 15 | |
| 12 | 16 | use gpui_kit::*; |
| 17 | + use serde::{Deserialize, Serialize}; | |
| 13 | 18 | use serde_json::{Value, json}; |
| 14 | 19 | |
| 15 | - use crate::claude::{Effort, find_pi_binary}; | |
| 16 | - use crate::settings; | |
| 20 | + use crate::claude::{Effort, base_command}; | |
| 21 | + use crate::settings::{self, ProviderInstance}; | |
| 17 | 22 | |
| 18 | - /// The agent behind a thread. | |
| 19 | - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] | |
| 23 | + /// The kind of agent behind a thread. | |
| 24 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 25 | + #[serde(rename_all = "lowercase")] | |
| 20 | 26 | pub enum Provider { |
| 21 | 27 | #[default] |
| 22 | 28 | Claude, |
| @@ -24,6 +30,8 @@ pub enum Provider { | ||
| 24 | 30 | } |
| 25 | 31 | |
| 26 | 32 | impl Provider { |
| 33 | + pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi]; | |
| 34 | + | |
| 27 | 35 | pub fn label(self) -> &'static str { |
| 28 | 36 | match self { |
| 29 | 37 | Provider::Claude => "Claude", |
| @@ -39,13 +47,15 @@ impl Provider { | ||
| 39 | 47 | } |
| 40 | 48 | |
| 41 | 49 | pub fn from_key(key: &str) -> Option<Self> { |
| 42 | - [Provider::Claude, Provider::Pi].into_iter().find(|provider| provider.key() == key) | |
| 50 | + Self::ALL.into_iter().find(|provider| provider.key() == key) | |
| 43 | 51 | } |
| 44 | 52 | } |
| 45 | 53 | |
| 46 | 54 | #[derive(Clone, Debug, PartialEq)] |
| 47 | 55 | pub struct ModelEntry { |
| 48 | 56 | pub provider: Provider, |
| 57 | + /// The provider instance offering it (`claude`, `claude-2`, `pi`). | |
| 58 | + pub instance: String, | |
| 49 | 59 | /// What the agent is launched with: a Claude alias or model id, or Pi's |
| 50 | 60 | /// `provider/model`. |
| 51 | 61 | pub id: String, |
| @@ -63,64 +73,236 @@ pub struct ModelEntry { | ||
| 63 | 73 | } |
| 64 | 74 | |
| 65 | 75 | impl ModelEntry { |
| 66 | - /// The key favorites are stored under. | |
| 76 | + /// The key favorites and defaults are stored under. | |
| 67 | 77 | pub fn key(&self) -> String { |
| 68 | - format!("{}:{}", self.provider.key(), self.id) | |
| 78 | + format!("{}:{}", self.instance, self.id) | |
| 69 | 79 | } |
| 70 | 80 | } |
| 71 | 81 | |
| 82 | + /// A `/` command or skill an agent offers. | |
| 83 | + #[derive(Clone, Debug, PartialEq)] | |
| 84 | + pub struct SlashCommand { | |
| 85 | + pub name: String, | |
| 86 | + pub description: String, | |
| 87 | + pub argument_hint: String, | |
| 88 | + /// Installed skills, as opposed to the agent's own commands. | |
| 89 | + pub skill: bool, | |
| 90 | + } | |
| 91 | + | |
| 72 | 92 | #[derive(Clone, Debug, PartialEq)] |
| 73 | - pub enum PiStatus { | |
| 74 | - Loading, | |
| 93 | + pub enum ProbeStatus { | |
| 94 | + /// Turned off in the settings. | |
| 95 | + Disabled, | |
| 96 | + Checking, | |
| 75 | 97 | Ready, |
| 76 | - /// `pi` isn't installed (or isn't on a path we look at). | |
| 98 | + /// The binary isn't installed (or isn't on a path we look at). | |
| 77 | 99 | Missing, |
| 78 | 100 | Failed(String), |
| 79 | 101 | } |
| 80 | 102 | |
| 81 | - pub struct Catalog { | |
| 82 | - pub claude: Vec<ModelEntry>, | |
| 103 | + /// What the last health check found out about one provider instance. | |
| 104 | + #[derive(Clone, Debug)] | |
| 105 | + pub struct InstanceState { | |
| 106 | + pub config: ProviderInstance, | |
| 107 | + pub status: ProbeStatus, | |
| 108 | + /// A check is running; the fields keep the previous one's findings. | |
| 109 | + pub checking: bool, | |
| 110 | + pub models: Vec<ModelEntry>, | |
| 83 | 111 | /// Claude's `default` entry, which the picker shows as the model it resolves to. |
| 84 | - pub claude_default: Option<ModelEntry>, | |
| 85 | - pub pi: Vec<ModelEntry>, | |
| 86 | - pub pi_status: PiStatus, | |
| 87 | - /// The signed-in Claude account, once a Claude session reported it. | |
| 88 | - pub claude_account: Option<String>, | |
| 112 | + pub default: Option<ModelEntry>, | |
| 113 | + /// The signed-in account (Claude's email). | |
| 114 | + pub account: Option<String>, | |
| 115 | + /// The subscription or login (`Claude Team`). | |
| 116 | + pub plan: Option<String>, | |
| 117 | + pub version: Option<String>, | |
| 118 | + /// The newest published version, once an update check ran. | |
| 119 | + pub latest: Option<String>, | |
| 120 | + /// Claude's `get_usage` reply: plan limits and how much of them is used. | |
| 121 | + pub usage: Option<Value>, | |
| 122 | + pub commands: Vec<SlashCommand>, | |
| 123 | + pub checked: Option<SystemTime>, | |
| 124 | + } | |
| 125 | + | |
| 126 | + impl InstanceState { | |
| 127 | + fn new(config: ProviderInstance) -> Self { | |
| 128 | + Self { | |
| 129 | + status: if config.enabled { ProbeStatus::Checking } else { ProbeStatus::Disabled }, | |
| 130 | + config, | |
| 131 | + checking: false, | |
| 132 | + models: Vec::new(), | |
| 133 | + default: None, | |
| 134 | + account: None, | |
| 135 | + plan: None, | |
| 136 | + version: None, | |
| 137 | + latest: None, | |
| 138 | + usage: None, | |
| 139 | + commands: Vec::new(), | |
| 140 | + checked: None, | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + pub fn id(&self) -> &str { | |
| 145 | + &self.config.id | |
| 146 | + } | |
| 147 | + | |
| 148 | + pub fn label(&self) -> String { | |
| 149 | + self.config.label() | |
| 150 | + } | |
| 151 | + | |
| 152 | + pub fn kind(&self) -> Provider { | |
| 153 | + self.config.kind | |
| 154 | + } | |
| 155 | + | |
| 156 | + /// Whether a newer version than the installed one is out. | |
| 157 | + pub fn update_available(&self) -> bool { | |
| 158 | + match (&self.version, &self.latest) { | |
| 159 | + (Some(version), Some(latest)) => version_newer(latest, version), | |
| 160 | + _ => false, | |
| 161 | + } | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + pub struct Catalog { | |
| 166 | + pub instances: Vec<InstanceState>, | |
| 89 | 167 | favorites: Vec<String>, |
| 90 | 168 | } |
| 91 | 169 | |
| 92 | 170 | impl Global for Catalog {} |
| 93 | 171 | |
| 172 | + /// What one health check of an instance found. | |
| 173 | + #[derive(Default)] | |
| 174 | + struct Probe { | |
| 175 | + models: Vec<ModelEntry>, | |
| 176 | + default: Option<ModelEntry>, | |
| 177 | + account: Option<String>, | |
| 178 | + plan: Option<String>, | |
| 179 | + version: Option<String>, | |
| 180 | + usage: Option<Value>, | |
| 181 | + commands: Vec<SlashCommand>, | |
| 182 | + } | |
| 183 | + | |
| 94 | 184 | impl Catalog { |
| 95 | 185 | pub fn init(cx: &mut App) { |
| 186 | + let saved = settings::load(); | |
| 96 | 187 | cx.set_global(Catalog { |
| 97 | - claude: Vec::new(), | |
| 98 | - claude_default: None, | |
| 99 | - pi: Vec::new(), | |
| 100 | - pi_status: PiStatus::Loading, | |
| 101 | - claude_account: None, | |
| 102 | - favorites: settings::load().favorites, | |
| 188 | + instances: saved.providers.into_iter().map(InstanceState::new).collect(), | |
| 189 | + favorites: saved.favorites, | |
| 103 | 190 | }); |
| 104 | - Self::refresh_pi(cx); | |
| 191 | + Self::refresh_all(cx); | |
| 192 | + } | |
| 193 | + | |
| 194 | + pub fn global(cx: &App) -> &Catalog { | |
| 195 | + cx.global::<Catalog>() | |
| 196 | + } | |
| 197 | + | |
| 198 | + /// Picks up added, removed and edited instances from the settings, and | |
| 199 | + /// checks the ones that changed. | |
| 200 | + pub fn sync_instances(cx: &mut App) { | |
| 201 | + let configs = settings::instances(); | |
| 202 | + let catalog = cx.global_mut::<Catalog>(); | |
| 203 | + let mut changed = Vec::new(); | |
| 204 | + let mut instances = Vec::new(); | |
| 205 | + for config in configs { | |
| 206 | + match catalog.instances.iter().position(|state| state.config.id == config.id) { | |
| 207 | + Some(ix) => { | |
| 208 | + let mut state = catalog.instances[ix].clone(); | |
| 209 | + if state.config != config { | |
| 210 | + let enabled = config.enabled; | |
| 211 | + state.config = config; | |
| 212 | + if !enabled { | |
| 213 | + state.status = ProbeStatus::Disabled; | |
| 214 | + } | |
| 215 | + changed.push(state.config.id.clone()); | |
| 216 | + } | |
| 217 | + instances.push(state); | |
| 218 | + } | |
| 219 | + None => { | |
| 220 | + changed.push(config.id.clone()); | |
| 221 | + instances.push(InstanceState::new(config)); | |
| 222 | + } | |
| 223 | + } | |
| 224 | + } | |
| 225 | + catalog.instances = instances; | |
| 226 | + for id in changed { | |
| 227 | + Self::refresh(&id, cx); | |
| 228 | + } | |
| 229 | + cx.refresh_windows(); | |
| 105 | 230 | } |
| 106 | 231 | |
| 107 | - /// Asks Pi (again) for the models its providers offer. | |
| 108 | - pub fn refresh_pi(cx: &mut App) { | |
| 109 | - cx.global_mut::<Catalog>().pi_status = PiStatus::Loading; | |
| 232 | + /// Checks every enabled instance again. | |
| 233 | + pub fn refresh_all(cx: &mut App) { | |
| 234 | + let ids: Vec<String> = Self::global(cx).instances.iter().map(|state| state.config.id.clone()).collect(); | |
| 235 | + for id in ids { | |
| 236 | + Self::refresh(&id, cx); | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + /// Checks one instance in the background: its version, account, models, | |
| 241 | + /// commands and limits. | |
| 242 | + pub fn refresh(id: &str, cx: &mut App) { | |
| 243 | + let update_checks = settings::get(|settings| settings.update_checks); | |
| 244 | + let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id) else { | |
| 245 | + return; | |
| 246 | + }; | |
| 247 | + if !state.config.enabled { | |
| 248 | + state.status = ProbeStatus::Disabled; | |
| 249 | + cx.refresh_windows(); | |
| 250 | + return; | |
| 251 | + } | |
| 252 | + if state.checking { | |
| 253 | + return; | |
| 254 | + } | |
| 255 | + state.checking = true; | |
| 256 | + if state.checked.is_none() { | |
| 257 | + state.status = ProbeStatus::Checking; | |
| 258 | + } | |
| 259 | + let config = state.config.clone(); | |
| 110 | 260 | cx.refresh_windows(); |
| 111 | - let probe = cx.background_executor().spawn(async { probe_pi_models() }); | |
| 261 | + let id = id.to_string(); | |
| 262 | + let probe = cx.background_executor().spawn(async move { | |
| 263 | + let probe = match config.kind { | |
| 264 | + Provider::Claude => probe_claude(&config), | |
| 265 | + Provider::Pi => probe_pi(&config), | |
| 266 | + }; | |
| 267 | + let latest = if update_checks { latest_version(config.kind) } else { None }; | |
| 268 | + (probe, latest) | |
| 269 | + }); | |
| 112 | 270 | cx.spawn(async move |cx| { |
| 113 | - let result = probe.await; | |
| 271 | + let (result, latest) = probe.await; | |
| 114 | 272 | cx.update(|cx| { |
| 115 | - let catalog = cx.global_mut::<Catalog>(); | |
| 273 | + let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id) | |
| 274 | + else { | |
| 275 | + return; | |
| 276 | + }; | |
| 277 | + state.checking = false; | |
| 278 | + state.checked = Some(SystemTime::now()); | |
| 279 | + if latest.is_some() { | |
| 280 | + state.latest = latest; | |
| 281 | + } | |
| 116 | 282 | match result { |
| 117 | - Ok(models) => { | |
| 118 | - catalog.pi = models; | |
| 119 | - catalog.pi_status = PiStatus::Ready; | |
| 283 | + Ok(probe) => { | |
| 284 | + state.status = ProbeStatus::Ready; | |
| 285 | + if !probe.models.is_empty() || state.config.kind == Provider::Pi { | |
| 286 | + state.models = probe.models; | |
| 287 | + state.default = probe.default; | |
| 288 | + } | |
| 289 | + state.account = probe.account.or(state.account.take()); | |
| 290 | + state.plan = probe.plan.or(state.plan.take()); | |
| 291 | + state.version = probe.version.or(state.version.take()); | |
| 292 | + if probe.usage.is_some() { | |
| 293 | + state.usage = probe.usage; | |
| 294 | + } | |
| 295 | + if !probe.commands.is_empty() { | |
| 296 | + state.commands = probe.commands; | |
| 297 | + } | |
| 120 | 298 | } |
| 121 | 299 | Err(status) => { |
| 122 | - catalog.pi.clear(); | |
| 123 | - catalog.pi_status = status; | |
| 300 | + if state.config.enabled { | |
| 301 | + state.status = status; | |
| 302 | + } | |
| 303 | + if state.config.kind == Provider::Pi { | |
| 304 | + state.models.clear(); | |
| 305 | + } | |
| 124 | 306 | } |
| 125 | 307 | } |
| 126 | 308 | cx.refresh_windows(); |
| @@ -129,46 +311,81 @@ impl Catalog { | ||
| 129 | 311 | .detach(); |
| 130 | 312 | } |
| 131 | 313 | |
| 132 | - pub fn global(cx: &App) -> &Catalog { | |
| 133 | - cx.global::<Catalog>() | |
| 314 | + pub fn instance(&self, id: &str) -> Option<&InstanceState> { | |
| 315 | + self.instances.iter().find(|state| state.config.id == id) | |
| 134 | 316 | } |
| 135 | 317 | |
| 136 | - /// Takes Claude's model list and account from a session's `initialize` reply. | |
| 137 | - pub fn set_claude_models(info: &Value, cx: &mut App) { | |
| 138 | - if let Some(account) = info.pointer("/account/email").and_then(Value::as_str) { | |
| 139 | - cx.global_mut::<Catalog>().claude_account = Some(account.to_string()); | |
| 140 | - } | |
| 141 | - let (default, models) = claude_models_from(info); | |
| 142 | - if models.is_empty() { | |
| 318 | + /// The enabled instances, in the order the settings list them. | |
| 319 | + pub fn enabled(&self) -> impl Iterator<Item = &InstanceState> { | |
| 320 | + self.instances.iter().filter(|state| state.config.enabled) | |
| 321 | + } | |
| 322 | + | |
| 323 | + pub fn label(&self, id: &str) -> String { | |
| 324 | + self.instance(id).map(InstanceState::label).unwrap_or_else(|| id.to_string()) | |
| 325 | + } | |
| 326 | + | |
| 327 | + /// The first enabled instance of a kind. | |
| 328 | + pub fn first_of(&self, kind: Provider) -> Option<&InstanceState> { | |
| 329 | + self.enabled().find(|state| state.config.kind == kind) | |
| 330 | + } | |
| 331 | + | |
| 332 | + /// Takes Claude's model list, account and commands from a session's | |
| 333 | + /// `initialize` reply. | |
| 334 | + pub fn set_claude_info(instance: &str, info: &Value, cx: &mut App) { | |
| 335 | + let probe = claude_probe_from(instance, info); | |
| 336 | + let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == instance) | |
| 337 | + else { | |
| 143 | 338 | return; |
| 339 | + }; | |
| 340 | + let mut changed = false; | |
| 341 | + if probe.account.is_some() && state.account != probe.account { | |
| 342 | + state.account = probe.account; | |
| 343 | + state.plan = probe.plan; | |
| 344 | + changed = true; | |
| 144 | 345 | } |
| 145 | - let catalog = cx.global_mut::<Catalog>(); | |
| 146 | - if catalog.claude != models { | |
| 147 | - catalog.claude = models; | |
| 148 | - catalog.claude_default = default; | |
| 346 | + if !probe.models.is_empty() && state.models != probe.models { | |
| 347 | + state.models = probe.models; | |
| 348 | + state.default = probe.default; | |
| 349 | + changed = true; | |
| 350 | + } | |
| 351 | + if !probe.commands.is_empty() && state.commands != probe.commands { | |
| 352 | + state.commands = probe.commands; | |
| 353 | + changed = true; | |
| 354 | + } | |
| 355 | + if state.status != ProbeStatus::Ready { | |
| 356 | + state.status = ProbeStatus::Ready; | |
| 357 | + changed = true; | |
| 358 | + } | |
| 359 | + if changed { | |
| 149 | 360 | cx.refresh_windows(); |
| 150 | 361 | } |
| 151 | 362 | } |
| 152 | 363 | |
| 153 | - pub fn models(&self, provider: Provider) -> &[ModelEntry] { | |
| 154 | - match provider { | |
| 155 | - Provider::Claude => &self.claude, | |
| 156 | - Provider::Pi => &self.pi, | |
| 157 | - } | |
| 364 | + pub fn models(&self, instance: &str) -> &[ModelEntry] { | |
| 365 | + self.instance(instance).map(|state| state.models.as_slice()).unwrap_or_default() | |
| 366 | + } | |
| 367 | + | |
| 368 | + /// Every enabled instance's models. | |
| 369 | + pub fn all_models(&self) -> impl Iterator<Item = &ModelEntry> { | |
| 370 | + self.enabled().flat_map(|state| &state.models) | |
| 158 | 371 | } |
| 159 | 372 | |
| 160 | 373 | /// The entry for a thread's model; `None` means the provider's default. |
| 161 | - pub fn find(&self, provider: Provider, id: Option<&str>) -> Option<&ModelEntry> { | |
| 162 | - match (provider, id) { | |
| 163 | - (Provider::Claude, None) => self.claude_default.as_ref(), | |
| 164 | - (provider, Some(id)) => self.models(provider).iter().find(|model| model.id == id), | |
| 165 | - (Provider::Pi, None) => None, | |
| 374 | + pub fn find(&self, instance: &str, id: Option<&str>) -> Option<&ModelEntry> { | |
| 375 | + let state = self.instance(instance)?; | |
| 376 | + match id { | |
| 377 | + None => state.default.as_ref(), | |
| 378 | + Some(id) => state.models.iter().find(|model| model.id == id), | |
| 166 | 379 | } |
| 167 | 380 | } |
| 168 | 381 | |
| 169 | 382 | /// The entry stored under `key` (see [`ModelEntry::key`]). |
| 170 | 383 | pub fn find_key(&self, key: &str) -> Option<&ModelEntry> { |
| 171 | - self.claude.iter().chain(&self.pi).find(|model| model.key() == key) | |
| 384 | + self.instances.iter().flat_map(|state| &state.models).find(|model| model.key() == key) | |
| 385 | + } | |
| 386 | + | |
| 387 | + pub fn commands(&self, instance: &str) -> &[SlashCommand] { | |
| 388 | + self.instance(instance).map(|state| state.commands.as_slice()).unwrap_or_default() | |
| 172 | 389 | } |
| 173 | 390 | |
| 174 | 391 | pub fn is_favorite(&self, model: &ModelEntry) -> bool { |
| @@ -176,10 +393,7 @@ impl Catalog { | ||
| 176 | 393 | } |
| 177 | 394 | |
| 178 | 395 | pub fn favorites(&self) -> Vec<&ModelEntry> { |
| 179 | - self.favorites | |
| 180 | - .iter() | |
| 181 | - .filter_map(|key| self.claude.iter().chain(&self.pi).find(|model| &model.key() == key)) | |
| 182 | - .collect() | |
| 396 | + self.favorites.iter().filter_map(|key| self.all_models().find(|model| &model.key() == key)).collect() | |
| 183 | 397 | } |
| 184 | 398 | |
| 185 | 399 | pub fn toggle_favorite(model: &ModelEntry, cx: &mut App) { |
| @@ -197,9 +411,15 @@ impl Catalog { | ||
| 197 | 411 | } |
| 198 | 412 | } |
| 199 | 413 | |
| 200 | - /// Parses the `models` of Claude's `initialize` reply into the default entry | |
| 201 | - /// and the pickable ones. | |
| 202 | - fn claude_models_from(info: &Value) -> (Option<ModelEntry>, Vec<ModelEntry>) { | |
| 414 | + /// Splits a model key into its instance and model id. Keys from before | |
| 415 | + /// instances existed name the kind, which is also its first instance's id. | |
| 416 | + pub fn split_key(key: &str) -> Option<(&str, &str)> { | |
| 417 | + key.split_once(':') | |
| 418 | + } | |
| 419 | + | |
| 420 | + /// Parses Claude's `initialize` reply: the models (the default entry apart), | |
| 421 | + /// the account and the commands. | |
| 422 | + fn claude_probe_from(instance: &str, info: &Value) -> Probe { | |
| 203 | 423 | let mut default = None; |
| 204 | 424 | let mut models = Vec::new(); |
| 205 | 425 | for model in info.get("models").and_then(Value::as_array).into_iter().flatten() { |
| @@ -209,6 +429,7 @@ fn claude_models_from(info: &Value) -> (Option<ModelEntry>, Vec<ModelEntry>) { | ||
| 209 | 429 | let display = text("displayName"); |
| 210 | 430 | let entry = ModelEntry { |
| 211 | 431 | provider: Provider::Claude, |
| 432 | + instance: instance.to_string(), | |
| 212 | 433 | id: id.to_string(), |
| 213 | 434 | resolved: Some(text("resolvedModel")).filter(|model| !model.is_empty()).unwrap_or_else(|| id.into()), |
| 214 | 435 | name: if display.starts_with("Claude") { display.clone() } else { format!("Claude {display}") }, |
| @@ -236,26 +457,109 @@ fn claude_models_from(info: &Value) -> (Option<ModelEntry>, Vec<ModelEntry>) { | ||
| 236 | 457 | { |
| 237 | 458 | default.name = current.name.clone(); |
| 238 | 459 | } |
| 239 | - (default, models) | |
| 460 | + let commands = info | |
| 461 | + .get("commands") | |
| 462 | + .and_then(Value::as_array) | |
| 463 | + .into_iter() | |
| 464 | + .flatten() | |
| 465 | + .filter_map(|command| { | |
| 466 | + let name = command.get("name")?.as_str()?; | |
| 467 | + // Internal commands start with underscores. | |
| 468 | + (!name.starts_with('_')).then(|| SlashCommand { | |
| 469 | + name: name.to_string(), | |
| 470 | + description: command.get("description").and_then(Value::as_str).unwrap_or_default().to_string(), | |
| 471 | + argument_hint: command.get("argumentHint").and_then(Value::as_str).unwrap_or_default().to_string(), | |
| 472 | + // Claude's own commands are marked builtin; skills from | |
| 473 | + // plugins and skill folders aren't. | |
| 474 | + skill: command.get("builtin").and_then(Value::as_bool) != Some(true), | |
| 475 | + }) | |
| 476 | + }) | |
| 477 | + .collect(); | |
| 478 | + Probe { | |
| 479 | + models, | |
| 480 | + default, | |
| 481 | + account: info.pointer("/account/email").and_then(Value::as_str).map(str::to_string), | |
| 482 | + plan: info | |
| 483 | + .pointer("/account/subscriptionType") | |
| 484 | + .and_then(Value::as_str) | |
| 485 | + .map(|plan| if plan.to_lowercase().contains("subscription") { plan.to_string() } else { format!("{plan} Subscription") }), | |
| 486 | + version: info.get("claude_code_version").and_then(Value::as_str).map(str::to_string), | |
| 487 | + usage: None, | |
| 488 | + commands, | |
| 489 | + } | |
| 240 | 490 | } |
| 241 | 491 | |
| 242 | - /// Starts `pi` just long enough to list the models its providers offer. | |
| 243 | - fn probe_pi_models() -> Result<Vec<ModelEntry>, PiStatus> { | |
| 244 | - let binary = find_pi_binary().ok_or(PiStatus::Missing)?; | |
| 245 | - let mut command = Command::new(&binary); | |
| 492 | + /// Starts `claude` just long enough to ask what it offers and how much of | |
| 493 | + /// the plan's limits is used. Nothing is sent to a model. | |
| 494 | + fn probe_claude(config: &ProviderInstance) -> Result<Probe, ProbeStatus> { | |
| 495 | + let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); | |
| 496 | + let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; | |
| 246 | 497 | command |
| 247 | - .args(["--mode", "rpc", "--no-session"]) | |
| 498 | + .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]) | |
| 499 | + .arg("--no-session-persistence") | |
| 500 | + .current_dir(std::env::temp_dir()) | |
| 248 | 501 | .stdin(Stdio::piped()) |
| 249 | 502 | .stdout(Stdio::piped()) |
| 250 | - .stderr(Stdio::null()); | |
| 251 | - if let Some(dir) = binary.parent() { | |
| 252 | - let path = std::env::var_os("PATH").unwrap_or_default(); | |
| 253 | - let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path)); | |
| 254 | - if let Ok(joined) = std::env::join_paths(paths) { | |
| 255 | - command.env("PATH", joined); | |
| 503 | + .stderr(Stdio::piped()); | |
| 504 | + let mut child = command.spawn().map_err(|err| failed(&err))?; | |
| 505 | + let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?; | |
| 506 | + let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; | |
| 507 | + let stderr = child.stderr.take().ok_or_else(|| failed(&"no stderr"))?; | |
| 508 | + let request = |id: &str, subtype: &str| { | |
| 509 | + json!({"type": "control_request", "request_id": id, "request": {"subtype": subtype}}).to_string() | |
| 510 | + }; | |
| 511 | + writeln!(stdin, "{}", request("probe-init", "initialize")).map_err(|err| failed(&err))?; | |
| 512 | + writeln!(stdin, "{}", request("probe-usage", "get_usage")).map_err(|err| failed(&err))?; | |
| 513 | + | |
| 514 | + let (tx, rx) = mpsc::channel(); | |
| 515 | + std::thread::spawn(move || { | |
| 516 | + for line in BufReader::new(stdout).lines().map_while(Result::ok) { | |
| 517 | + if let Ok(value) = serde_json::from_str::<Value>(&line) | |
| 518 | + && value.get("type").and_then(Value::as_str) == Some("control_response") | |
| 519 | + && tx.send(value["response"].clone()).is_err() | |
| 520 | + { | |
| 521 | + return; | |
| 522 | + } | |
| 523 | + } | |
| 524 | + }); | |
| 525 | + let stderr_tail = std::thread::spawn(move || { | |
| 526 | + BufReader::new(stderr).lines().map_while(Result::ok).filter(|line| !line.trim().is_empty()).last() | |
| 527 | + }); | |
| 528 | + let deadline = Instant::now() + Duration::from_secs(25); | |
| 529 | + let (mut init, mut usage) = (None, None); | |
| 530 | + while init.is_none() || usage.is_none() { | |
| 531 | + let Some(left) = deadline.checked_duration_since(Instant::now()) else { break }; | |
| 532 | + let Ok(response) = rx.recv_timeout(left) else { break }; | |
| 533 | + let ok = response.get("subtype").and_then(Value::as_str) == Some("success"); | |
| 534 | + match response.get("request_id").and_then(Value::as_str) { | |
| 535 | + Some("probe-init") => init = Some(ok.then(|| response["response"].clone())), | |
| 536 | + Some("probe-usage") => usage = Some(ok.then(|| response["response"].clone())), | |
| 537 | + _ => {} | |
| 256 | 538 | } |
| 257 | 539 | } |
| 258 | - let failed = |err: &dyn std::fmt::Display| PiStatus::Failed(err.to_string()); | |
| 540 | + drop(stdin); | |
| 541 | + let _ = child.kill(); | |
| 542 | + let _ = child.wait(); | |
| 543 | + let Some(Some(info)) = init else { | |
| 544 | + let detail = stderr_tail.join().ok().flatten().unwrap_or_else(|| "Claude Code didn't answer".into()); | |
| 545 | + return Err(failed(&detail)); | |
| 546 | + }; | |
| 547 | + let mut probe = claude_probe_from(&config.id, &info); | |
| 548 | + probe.usage = usage.flatten().filter(|usage| usage.get("rate_limits_available").and_then(Value::as_bool) != Some(false)); | |
| 549 | + Ok(probe) | |
| 550 | + } | |
| 551 | + | |
| 552 | + /// Starts `pi` just long enough to list the models its providers offer, and | |
| 553 | + /// asks its version. | |
| 554 | + fn probe_pi(config: &ProviderInstance) -> Result<Probe, ProbeStatus> { | |
| 555 | + let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); | |
| 556 | + let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; | |
| 557 | + command | |
| 558 | + .args(["--mode", "rpc", "--no-session"]) | |
| 559 | + .current_dir(std::env::temp_dir()) | |
| 560 | + .stdin(Stdio::piped()) | |
| 561 | + .stdout(Stdio::piped()) | |
| 562 | + .stderr(Stdio::null()); | |
| 259 | 563 | let mut child = command.spawn().map_err(|err| failed(&err))?; |
| 260 | 564 | let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?; |
| 261 | 565 | let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; |
| @@ -282,21 +586,27 @@ fn probe_pi_models() -> Result<Vec<ModelEntry>, PiStatus> { | ||
| 282 | 586 | let error = reply.get("error").and_then(Value::as_str).unwrap_or("unknown error"); |
| 283 | 587 | return Err(failed(&error)); |
| 284 | 588 | } |
| 285 | - Ok(reply | |
| 589 | + let models = reply | |
| 286 | 590 | .pointer("/data/models") |
| 287 | 591 | .and_then(Value::as_array) |
| 288 | 592 | .into_iter() |
| 289 | 593 | .flatten() |
| 290 | - .filter_map(pi_model_from) | |
| 291 | - .collect()) | |
| 594 | + .filter_map(|model| pi_model_from(&config.id, model)) | |
| 595 | + .collect(); | |
| 596 | + let version = base_command(config).ok().and_then(|(_, mut command)| { | |
| 597 | + let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; | |
| 598 | + first_version(&String::from_utf8_lossy(&output.stdout)) | |
| 599 | + }); | |
| 600 | + Ok(Probe { models, account: None, plan: None, version, ..Probe::default() }) | |
| 292 | 601 | } |
| 293 | 602 | |
| 294 | - pub fn pi_model_from(model: &Value) -> Option<ModelEntry> { | |
| 603 | + pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> { | |
| 295 | 604 | let id = model.get("id")?.as_str()?; |
| 296 | 605 | let provider = model.get("provider")?.as_str()?; |
| 297 | 606 | let reasoning = model.get("reasoning").and_then(Value::as_bool).unwrap_or(false); |
| 298 | 607 | Some(ModelEntry { |
| 299 | 608 | provider: Provider::Pi, |
| 609 | + instance: instance.to_string(), | |
| 300 | 610 | id: format!("{provider}/{id}"), |
| 301 | 611 | resolved: id.to_string(), |
| 302 | 612 | name: model.get("name").and_then(Value::as_str).unwrap_or(id).to_string(), |
| @@ -313,6 +623,50 @@ pub fn pi_model_from(model: &Value) -> Option<ModelEntry> { | ||
| 313 | 623 | }) |
| 314 | 624 | } |
| 315 | 625 | |
| 626 | + /// The newest published version of an agent, from the npm registry. | |
| 627 | + fn latest_version(kind: Provider) -> Option<String> { | |
| 628 | + let package = match kind { | |
| 629 | + Provider::Claude => "@anthropic-ai/claude-code", | |
| 630 | + Provider::Pi => "@earendil-works/pi-coding-agent", | |
| 631 | + }; | |
| 632 | + let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; | |
| 633 | + serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string) | |
| 634 | + } | |
| 635 | + | |
| 636 | + /// The command that updates an agent in place, and how to describe it. | |
| 637 | + pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Command, &'static str)> { | |
| 638 | + let (binary, mut command) = base_command(config).ok()?; | |
| 639 | + match config.kind { | |
| 640 | + Provider::Claude => { | |
| 641 | + command.arg("update"); | |
| 642 | + Some((command, "claude update")) | |
| 643 | + } | |
| 644 | + Provider::Pi => { | |
| 645 | + // Pi is an npm package; update it with the npm next to it. | |
| 646 | + let npm = binary.parent().map(|dir| dir.join("npm")).filter(|npm| npm.exists()); | |
| 647 | + let mut command = std::process::Command::new(npm.as_deref().unwrap_or(Path::new("npm"))); | |
| 648 | + command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]); | |
| 649 | + Some((command, "npm install -g @earendil-works/pi-coding-agent")) | |
| 650 | + } | |
| 651 | + } | |
| 652 | + } | |
| 653 | + | |
| 654 | + /// The first `1.2.3`-looking word in `text`. | |
| 655 | + fn first_version(text: &str) -> Option<String> { | |
| 656 | + text.split_whitespace() | |
| 657 | + .map(|word| word.trim_start_matches('v')) | |
| 658 | + .find(|word| word.split('.').count() >= 2 && word.split('.').all(|part| part.parse::<u64>().is_ok())) | |
| 659 | + .map(str::to_string) | |
| 660 | + } | |
| 661 | + | |
| 662 | + /// Whether version `a` is newer than `b`, comparing numerically. | |
| 663 | + fn version_newer(a: &str, b: &str) -> bool { | |
| 664 | + let parse = |version: &str| -> Vec<u64> { | |
| 665 | + version.trim_start_matches('v').split(['.', '-', '+']).map_while(|part| part.parse().ok()).collect() | |
| 666 | + }; | |
| 667 | + parse(a) > parse(b) | |
| 668 | + } | |
| 669 | + | |
| 316 | 670 | /// `openai-codex` → `OpenAI Codex`. |
| 317 | 671 | fn provider_label(provider: &str) -> String { |
| 318 | 672 | provider |
| @@ -333,7 +687,7 @@ fn provider_label(provider: &str) -> String { | ||
| 333 | 687 | |
| 334 | 688 | #[cfg(test)] |
| 335 | 689 | mod tests { |
| 336 | - use super::{claude_models_from, provider_label}; | |
| 690 | + use super::{claude_probe_from, first_version, provider_label, version_newer}; | |
| 337 | 691 | use serde_json::json; |
| 338 | 692 | |
| 339 | 693 | #[::core::prelude::v1::test] |
| @@ -346,16 +700,39 @@ mod tests { | ||
| 346 | 700 | |
| 347 | 701 | #[::core::prelude::v1::test] |
| 348 | 702 | fn claude_default_takes_the_current_name() { |
| 349 | - let info = json!({"models": [ | |
| 350 | - {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"}, | |
| 351 | - {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5", | |
| 352 | - "supportedEffortLevels": ["low", "high"], "supportsFastMode": true}, | |
| 353 | - {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"}, | |
| 354 | - ]}); | |
| 355 | - let (default, models) = claude_models_from(&info); | |
| 356 | - assert_eq!(default.unwrap().name, "Claude Opus 5.5"); | |
| 357 | - assert_eq!(models.len(), 2); | |
| 358 | - assert!(models[1].legacy); | |
| 359 | - assert_eq!(models[0].efforts.len(), 2); | |
| 703 | + let info = json!({ | |
| 704 | + "models": [ | |
| 705 | + {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"}, | |
| 706 | + {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5", | |
| 707 | + "supportedEffortLevels": ["low", "high"], "supportsFastMode": true}, | |
| 708 | + {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"}, | |
| 709 | + ], | |
| 710 | + "account": {"email": "me@example.com", "subscriptionType": "Claude Team"}, | |
| 711 | + "commands": [ | |
| 712 | + {"name": "compact", "description": "Compact", "argumentHint": "", "builtin": true}, | |
| 713 | + {"name": "pdf", "description": "PDF skill", "argumentHint": ""}, | |
| 714 | + {"name": "__internal", "description": "", "argumentHint": "", "builtin": true}, | |
| 715 | + ], | |
| 716 | + "claude_code_version": "2.1.296", | |
| 717 | + }); | |
| 718 | + let probe = claude_probe_from("claude", &info); | |
| 719 | + assert_eq!(probe.default.unwrap().name, "Claude Opus 5.5"); | |
| 720 | + assert_eq!(probe.models.len(), 2); | |
| 721 | + assert!(probe.models[1].legacy); | |
| 722 | + assert_eq!(probe.models[0].efforts.len(), 2); | |
| 723 | + assert_eq!(probe.models[0].key(), "claude:opus"); | |
| 724 | + assert_eq!(probe.plan.as_deref(), Some("Claude Team Subscription")); | |
| 725 | + assert_eq!(probe.commands.len(), 2); | |
| 726 | + assert!(!probe.commands[0].skill && probe.commands[1].skill); | |
| 727 | + assert_eq!(probe.version.as_deref(), Some("2.1.296")); | |
| 728 | + } | |
| 729 | + | |
| 730 | + #[::core::prelude::v1::test] | |
| 731 | + fn compares_versions() { | |
| 732 | + assert!(version_newer("2.1.300", "2.1.296")); | |
| 733 | + assert!(!version_newer("2.1.296", "2.1.296")); | |
| 734 | + assert!(version_newer("1.10.0", "1.9.9")); | |
| 735 | + assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0")); | |
| 736 | + assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296")); | |
| 360 | 737 | } |
| 361 | 738 | } |
| @@ -0,0 +1,170 @@ | ||
| 1 | + //! The client side: a connection to another device's app on its own thread, | |
| 2 | + //! reconnecting when it drops. Messages from the host reach the workspace as | |
| 3 | + //! [`ClientEvent`]s. | |
| 4 | + | |
| 5 | + use std::{ | |
| 6 | + io::ErrorKind, | |
| 7 | + net::{TcpStream, ToSocketAddrs}, | |
| 8 | + sync::{ | |
| 9 | + Arc, | |
| 10 | + atomic::{AtomicBool, Ordering}, | |
| 11 | + mpsc, | |
| 12 | + }, | |
| 13 | + thread, | |
| 14 | + time::Duration, | |
| 15 | + }; | |
| 16 | + | |
| 17 | + use tungstenite::{Message, WebSocket}; | |
| 18 | + | |
| 19 | + use super::{ClientMessage, PROTOCOL, ServerMessage, with_port}; | |
| 20 | + | |
| 21 | + const CONNECT_TIMEOUT: Duration = Duration::from_secs(6); | |
| 22 | + const POLL: Duration = Duration::from_millis(40); | |
| 23 | + | |
| 24 | + #[derive(Clone, Debug, PartialEq)] | |
| 25 | + pub enum ConnectionStatus { | |
| 26 | + Connecting, | |
| 27 | + /// Connected to the host of that name. | |
| 28 | + Connected(String), | |
| 29 | + Failed(String), | |
| 30 | + /// The host refused the key; no more retries until the settings change. | |
| 31 | + Denied(String), | |
| 32 | + } | |
| 33 | + | |
| 34 | + pub enum ClientEvent { | |
| 35 | + Status(ConnectionStatus), | |
| 36 | + Message(ServerMessage), | |
| 37 | + } | |
| 38 | + | |
| 39 | + pub struct RemoteClient { | |
| 40 | + outbox: mpsc::Sender<String>, | |
| 41 | + stop: Arc<AtomicBool>, | |
| 42 | + pub events: smol::channel::Receiver<ClientEvent>, | |
| 43 | + } | |
| 44 | + | |
| 45 | + impl RemoteClient { | |
| 46 | + /// Connects to `address` (`host` or `host:port`) and keeps reconnecting | |
| 47 | + /// until dropped. | |
| 48 | + pub fn connect(address: &str, key: String, name: String) -> Self { | |
| 49 | + let address = with_port(address); | |
| 50 | + let stop = Arc::new(AtomicBool::new(false)); | |
| 51 | + let (outbox, inbox) = mpsc::channel::<String>(); | |
| 52 | + let (events_tx, events) = smol::channel::unbounded(); | |
| 53 | + { | |
| 54 | + let stop = stop.clone(); | |
| 55 | + let _ = thread::Builder::new().name("remote-client".into()).spawn(move || { | |
| 56 | + let mut backoff = Duration::from_secs(2); | |
| 57 | + while !stop.load(Ordering::Relaxed) { | |
| 58 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connecting)); | |
| 59 | + match open(&address, &key, &name) { | |
| 60 | + Ok((mut socket, host)) => { | |
| 61 | + backoff = Duration::from_secs(2); | |
| 62 | + // Whatever was asked while offline is stale now. | |
| 63 | + while inbox.try_recv().is_ok() {} | |
| 64 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Connected(host))); | |
| 65 | + let reason = run(&mut socket, &inbox, &events_tx, &stop); | |
| 66 | + let _ = socket.close(None); | |
| 67 | + if stop.load(Ordering::Relaxed) { | |
| 68 | + break; | |
| 69 | + } | |
| 70 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Failed(reason))); | |
| 71 | + } | |
| 72 | + Err(Failure::Denied(reason)) => { | |
| 73 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Denied(reason))); | |
| 74 | + break; | |
| 75 | + } | |
| 76 | + Err(Failure::Other(reason)) => { | |
| 77 | + let _ = events_tx.send_blocking(ClientEvent::Status(ConnectionStatus::Failed(reason))); | |
| 78 | + } | |
| 79 | + } | |
| 80 | + // Wait before retrying, a little longer each time. | |
| 81 | + let mut waited = Duration::ZERO; | |
| 82 | + while waited < backoff && !stop.load(Ordering::Relaxed) { | |
| 83 | + thread::sleep(Duration::from_millis(200)); | |
| 84 | + waited += Duration::from_millis(200); | |
| 85 | + } | |
| 86 | + backoff = (backoff * 2).min(Duration::from_secs(30)); | |
| 87 | + } | |
| 88 | + }); | |
| 89 | + } | |
| 90 | + Self { outbox, stop, events } | |
| 91 | + } | |
| 92 | + | |
| 93 | + pub fn send(&self, message: &ClientMessage) { | |
| 94 | + if let Ok(text) = serde_json::to_string(message) { | |
| 95 | + let _ = self.outbox.send(text); | |
| 96 | + } | |
| 97 | + } | |
| 98 | + } | |
| 99 | + | |
| 100 | + impl Drop for RemoteClient { | |
| 101 | + fn drop(&mut self) { | |
| 102 | + self.stop.store(true, Ordering::Relaxed); | |
| 103 | + } | |
| 104 | + } | |
| 105 | + | |
| 106 | + enum Failure { | |
| 107 | + Denied(String), | |
| 108 | + Other(String), | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// Connects, says hello, and waits for the welcome. | |
| 112 | + fn open(address: &str, key: &str, name: &str) -> Result<(WebSocket<TcpStream>, String), Failure> { | |
| 113 | + let other = |err: &dyn std::fmt::Display| Failure::Other(err.to_string()); | |
| 114 | + let target = address.to_socket_addrs().map_err(|err| other(&err))?.next().ok_or_else(|| other(&"no such host"))?; | |
| 115 | + let stream = TcpStream::connect_timeout(&target, CONNECT_TIMEOUT).map_err(|err| other(&err))?; | |
| 116 | + let _ = stream.set_nodelay(true); | |
| 117 | + stream.set_read_timeout(Some(Duration::from_secs(15))).map_err(|err| other(&err))?; | |
| 118 | + let (mut socket, _) = tungstenite::client(format!("ws://{address}/"), stream).map_err(|err| other(&err))?; | |
| 119 | + let hello = ClientMessage::Hello { key: key.to_string(), name: name.to_string(), protocol: PROTOCOL }; | |
| 120 | + socket | |
| 121 | + .send(Message::text(serde_json::to_string(&hello).unwrap_or_default())) | |
| 122 | + .map_err(|err| other(&err))?; | |
| 123 | + loop { | |
| 124 | + match socket.read().map_err(|err| other(&err))? { | |
| 125 | + Message::Text(text) => { | |
| 126 | + return match serde_json::from_str::<ServerMessage>(text.as_str()) { | |
| 127 | + Ok(ServerMessage::Welcome { host, .. }) => { | |
| 128 | + let _ = socket.get_mut().set_read_timeout(Some(POLL)); | |
| 129 | + Ok((socket, host)) | |
| 130 | + } | |
| 131 | + Ok(ServerMessage::Denied { reason }) => Err(Failure::Denied(reason)), | |
| 132 | + _ => Err(other(&"the host answered something unexpected")), | |
| 133 | + }; | |
| 134 | + } | |
| 135 | + Message::Close(_) => return Err(Failure::Denied("The host closed the connection.".into())), | |
| 136 | + _ => {} | |
| 137 | + } | |
| 138 | + } | |
| 139 | + } | |
| 140 | + | |
| 141 | + /// Trades messages until the connection drops; returns why it did. | |
| 142 | + fn run( | |
| 143 | + socket: &mut WebSocket<TcpStream>, | |
| 144 | + inbox: &mpsc::Receiver<String>, | |
| 145 | + events: &smol::channel::Sender<ClientEvent>, | |
| 146 | + stop: &AtomicBool, | |
| 147 | + ) -> String { | |
| 148 | + while !stop.load(Ordering::Relaxed) { | |
| 149 | + while let Ok(text) = inbox.try_recv() { | |
| 150 | + if let Err(err) = socket.send(Message::text(text)) { | |
| 151 | + return err.to_string(); | |
| 152 | + } | |
| 153 | + } | |
| 154 | + match socket.read() { | |
| 155 | + Ok(Message::Text(text)) => match serde_json::from_str::<ServerMessage>(text.as_str()) { | |
| 156 | + Ok(message) => { | |
| 157 | + if events.send_blocking(ClientEvent::Message(message)).is_err() { | |
| 158 | + return "closed".into(); | |
| 159 | + } | |
| 160 | + } | |
| 161 | + Err(err) => eprintln!("vectrum-code: unreadable message from host: {err}"), | |
| 162 | + }, | |
| 163 | + Ok(Message::Close(_)) => return "The host closed the connection.".into(), | |
| 164 | + Ok(_) => {} | |
| 165 | + Err(tungstenite::Error::Io(err)) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) => {} | |
| 166 | + Err(err) => return err.to_string(), | |
| 167 | + } | |
| 168 | + } | |
| 169 | + "closed".into() | |
| 170 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.