Vectrum
Sign In

Merge settings-parity: T3 Code settings parity, themes, remote connections and the agent browser

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>
JjohannSocommitted 2 parents 8d0805a 3eae5d8commit627e200

Showing 33 changed files with 14417 additions and 1195 deletions.

Merge commit — showing changes against the first parent.

+71−0Cargo.lockView File
@@ -1313,6 +1313,12 @@ dependencies = [
13131313 "linktime-proc-macro",
13141314 ]
13151315
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+
13161322 [[package]]
13171323 name = "data-url"
13181324 version = "0.3.2"
@@ -5745,6 +5751,7 @@ version = "0.23.45"
57455751 source = "registry+https://github.com/rust-lang/crates.io-index"
57465752 checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
57475753 dependencies = [
5754+ "log",
57485755 "once_cell",
57495756 "ring",
57505757 "rustls-pki-types",
@@ -6081,6 +6088,17 @@ dependencies = [
60816088 "serde",
60826089 ]
60836090
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+
60846102 [[package]]
60856103 name = "sha1_smol"
60866104 version = "1.0.1"
@@ -7024,6 +7042,22 @@ dependencies = [
70247042 "core_maths",
70257043 ]
70267044
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+
70277061 [[package]]
70287062 name = "typeid"
70297063 version = "1.0.3"
@@ -7131,6 +7165,21 @@ version = "0.9.0"
71317165 source = "registry+https://github.com/rust-lang/crates.io-index"
71327166 checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
71337167
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+
71347183 [[package]]
71357184 name = "url"
71367185 version = "2.5.8"
@@ -7281,11 +7330,15 @@ version = "0.1.0"
72817330 dependencies = [
72827331 "anyhow",
72837332 "base64 0.22.1",
7333+ "chrono",
72847334 "gpui-kit",
7335+ "image",
72857336 "libc",
72867337 "serde",
72877338 "serde_json",
72887339 "smol",
7340+ "tungstenite",
7341+ "ureq",
72897342 ]
72907343
72917344 [[package]]
@@ -7554,6 +7607,24 @@ dependencies = [
75547607 "wasm-bindgen",
75557608 ]
75567609
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+
75577628 [[package]]
75587629 name = "weezl"
75597630 version = "0.1.12"
+4−0Cargo.tomlView File
@@ -12,6 +12,10 @@ anyhow = "1"
1212 libc = "0.2"
1313 smol = "2"
1414 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"] }
1519
1620 [profile.dev]
1721 debug = "line-tables-only"
+59−13README.mdView File
@@ -13,8 +13,9 @@ talk to `pi --mode rpc` over its JSONL RPC protocol.
1313 The layout and workflow follow T3 Code. The look follows [MetalUI](https://metalui.dev/), the
1414 "soft hardware" design system [vectrum.cc](https://vectrum.cc/) is built on: the
1515 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.
1819
1920 ![Vectrum Code in Bone](docs/screenshot.png)
2021
@@ -61,17 +62,57 @@ follow the desktop.
6162 above the composer with their age and a stop button. When one finishes, the transcript
6263 says so and Claude picks the work back up and reports back on its own. A long-running
6364 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.
6470 - Interrupt with Esc or the stop button. Messages sent while the agent works are queued.
6571 - 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.
6695 - 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**.
75116
76117 ## Requirements
77118
@@ -79,6 +120,8 @@ follow the desktop.
79120 - The `claude` CLI, logged in (`claude` once interactively)
80121 - Optional: [Pi](https://github.com/earendil-works/pi) for Pi threads
81122 (`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`)
82125 - GPUI's Linux system libraries: Vulkan, Wayland/X11, xkbcommon, fontconfig, freetype
83126 (on Arch/CachyOS: `vulkan-icd-loader` plus your GPU's Vulkan driver, `wayland`,
84127 `libxkbcommon-x11`, `libxcb`, `fontconfig`, `freetype2`)
@@ -110,8 +153,9 @@ cargo run --release -- /path/to/project # defaults to the current directory
110153
111154 Favorite models and everything in the settings view are saved in
112155 `~/.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.
115159
116160 ## Layout
117161
@@ -134,12 +178,14 @@ Threads started without a project run in `~/.local/share/vectrum-code/scratch`.
134178 - `src/ui/metal.rs`: the MetalUI layer. Colorway tokens and shadow recipes copied from
135179 MetalUI's CSS, the theme mapping for the stock widgets, and the primitives (caps, wells,
136180 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.
137183 - `assets/fonts/`: Geist, Geist Mono and Geist Pixel, embedded in the binary
138184 (SIL Open Font License, see `assets/fonts/OFL.txt`).
139185
140186 ## Not yet supported
141187
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
143189 `AskUserQuestion` (the tool is disabled, so Claude asks in plain text) or for Pi extensions
144190 that ask for free text, code blocks have no syntax highlighting, and T3 Code's project
145191 scripts and terminal drawer aren't here yet.
+926−0src/browser.rsaddedView File
@@ -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(&current).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+ }
+132−53src/claude.rsView File
@@ -14,7 +14,9 @@ use std::{
1414 use anyhow::{Context as _, Result, anyhow};
1515 use serde_json::{Value, json};
1616
17+ use crate::browser;
1718 use crate::models::Provider;
19+ use crate::settings::{ProviderInstance, expand_home};
1820
1921 /// Environment variables that mark a process as running *inside* another
2022 /// Claude Code session. They leak in when this app is launched from a Claude
@@ -89,7 +91,7 @@ impl PermissionMode {
8991
9092 /// How hard the model thinks: Claude's `--effort` levels and Pi's thinking
9193 /// levels, which add `off` and `minimal`.
92- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
94+ #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9395 pub enum Effort {
9496 Off,
9597 Minimal,
@@ -135,7 +137,8 @@ impl Effort {
135137
136138 #[derive(Clone, Debug)]
137139 pub struct LaunchConfig {
138- pub provider: Provider,
140+ /// Which agent, with its binary, home folder, arguments and environment.
141+ pub instance: ProviderInstance,
139142 pub cwd: PathBuf,
140143 /// A Claude model alias, or `provider/model` for Pi. `None` is the default.
141144 pub model: Option<String>,
@@ -145,6 +148,8 @@ pub struct LaunchConfig {
145148 /// Session to resume (a Claude session id or a Pi session file), used to
146149 /// restart a thread whose process stopped.
147150 pub resume: Option<String>,
151+ /// The thread's agent-browser session, shared with the browser panel.
152+ pub browser_session: Option<String>,
148153 }
149154
150155 /// Everything the background reader threads report back to the UI.
@@ -162,10 +167,16 @@ pub struct AgentProcess {
162167
163168 impl AgentProcess {
164169 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 {
166171 Provider::Claude => claude_command(config)?,
167172 Provider::Pi => pi_command(config)?,
168173 };
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+ }
169180 Self::spawn_command(&binary, command, events)
170181 }
171182
@@ -175,9 +186,6 @@ impl AgentProcess {
175186 events: smol::channel::Sender<ProcessEvent>,
176187 ) -> Result<Self> {
177188 command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
178- for key in NESTED_SESSION_ENV {
179- command.env_remove(key);
180- }
181189 // Take the agent down with us if the app dies without cleaning up.
182190 unsafe {
183191 command.pre_exec(|| {
@@ -287,9 +295,7 @@ impl Drop for AgentProcess {
287295 }
288296
289297 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)?;
293299 command
294300 .args([
295301 "-p",
@@ -316,13 +322,12 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
316322 if let Some(session_id) = &config.resume {
317323 command.args(["--resume", session_id]);
318324 }
325+ command.args(split_args(&config.instance.args));
319326 Ok((binary, command))
320327 }
321328
322329 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)?;
326331 command.args(["--mode", "rpc"]).current_dir(&config.cwd);
327332 if let Some((provider, model)) = config.model.as_deref().and_then(|model| model.split_once('/')) {
328333 command.args(["--provider", provider, "--model", model]);
@@ -333,9 +338,52 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
333338 if let Some(session) = &config.resume {
334339 command.args(["--session", session]);
335340 }
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+ }
336382 // Pi is a Node script; a desktop launcher's PATH may not reach the Node it
337383 // 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+ {
339387 let path = std::env::var_os("PATH").unwrap_or_default();
340388 let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path));
341389 if let Ok(joined) = std::env::join_paths(paths) {
@@ -345,61 +393,80 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
345393 Ok((binary, command))
346394 }
347395
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+ {
350402 return Some(PathBuf::from(path));
351403 }
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()));
354406 }
407+ let name = instance.kind.key();
355408 let on_path = std::env::var_os("PATH")
356409 .into_iter()
357410 .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
358- .map(|dir| dir.join("claude"));
411+ .map(|dir| dir.join(name));
359412 // Desktop launchers often start apps without the user's shell PATH.
360413 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)]);
390424 // 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+ {
392428 fallbacks.extend(versions.flatten().map(|version| version.path().join("bin/pi")));
393429 }
394430 on_path.chain(fallbacks).find(|path| is_executable(path))
395431 }
396432
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);
402468 }
469+ args
403470 }
404471
405472 pub fn is_executable(path: &Path) -> bool {
@@ -408,3 +475,15 @@ pub fn is_executable(path: &Path) -> bool {
408475 .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
409476 .unwrap_or(false)
410477 }
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+ }
+60−0src/clock.rsaddedView File
@@ -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+ }
+78−15src/git.rsView File
@@ -6,6 +6,8 @@ use std::{
66 process::{Command, Stdio},
77 };
88
9+ use serde::{Deserialize, Serialize};
10+
911 /// Runs git in `path`, returning stdout, or stderr as the error.
1012 pub fn git(path: &Path, args: &[&str]) -> Result<String, String> {
1113 run(Command::new("git").arg("-C").arg(path).args(args))
@@ -93,7 +95,7 @@ pub fn snapshot(cwd: &Path, index: &Path) -> Option<String> {
9395 Some(tree.trim().to_string())
9496 }
9597
96- #[derive(Clone, Debug, PartialEq)]
98+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
9799 pub struct FileChange {
98100 pub path: String,
99101 /// `None` for binary files.
@@ -102,7 +104,7 @@ pub struct FileChange {
102104 }
103105
104106 /// The files one turn changed: the difference between two snapshots.
105- #[derive(Clone, Debug, PartialEq)]
107+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
106108 pub struct TurnChanges {
107109 pub base: String,
108110 pub head: String,
@@ -149,6 +151,10 @@ pub enum LineKind {
149151 pub struct DiffLine {
150152 pub kind: LineKind,
151153 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>,
152158 }
153159
154160 #[derive(Clone, Debug)]
@@ -159,16 +165,42 @@ pub struct FileDiff {
159165 }
160166
161167 /// 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]);
164182 if let Some(path) = path {
165183 args.extend(["--", path]);
166184 }
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(), '+')?))
168198 }
169199
170200 fn parse_diff(text: &str) -> Vec<FileDiff> {
171201 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);
172204 for line in text.lines() {
173205 if let Some(rest) = line.strip_prefix("diff --git ") {
174206 // `a/path b/path`: take the new side.
@@ -177,34 +209,65 @@ fn parse_diff(text: &str) -> Vec<FileDiff> {
177209 continue;
178210 }
179211 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)
182215 } else if line.starts_with("Binary files") {
183216 file.binary = true;
184217 continue;
185218 } else if file.lines.is_empty() && (line.starts_with("---") || line.starts_with("+++")) {
186219 continue;
187220 } 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))
190223 } 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)
193226 } 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))
196229 } else {
197230 // Headers: index, mode and new/deleted file lines.
198231 continue;
199232 };
200- file.lines.push(DiffLine { kind, text: line.to_string() });
233+ file.lines.push(DiffLine { kind, text: text.to_string(), old_line, new_line });
201234 }
202235 files
203236 }
204237
205238 #[cfg(test)]
206239 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+ }
208271
209272 #[::core::prelude::v1::test]
210273 fn parses_unified_diff() {
+25−0src/http.rsaddedView File
@@ -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+ }
+44−6src/main.rsView File
@@ -3,15 +3,28 @@
33 // GPUI's derive macros expand to `gpui::` paths; route them through the Kit.
44 extern crate gpui_kit as gpui;
55
6+ mod browser;
67 mod claude;
8+ mod clock;
79 mod git;
10+ mod http;
811 mod models;
12+ mod remote;
913 mod session;
1014 mod settings;
15+ mod store;
16+ mod textgen;
1117 mod ui;
18+ mod usage;
19+ mod worktree;
1220
13- use std::path::PathBuf;
21+ use std::{
22+ path::PathBuf,
23+ sync::Mutex,
24+ time::{Duration, Instant},
25+ };
1426
27+ use gpui_kit::component::{WindowExt as _, notification::Notification};
1528 use gpui_kit::*;
1629
1730 use crate::ui::{icons::AppAssets, metal, workspace::Workspace};
@@ -24,6 +37,7 @@ gpui_kit::actions!(
2437 Interrupt,
2538 ToggleSidebar,
2639 ToggleProjectPanel,
40+ ToggleBrowser,
2741 FocusSearch,
2842 OpenSettings,
2943 CloseSettings,
@@ -31,34 +45,37 @@ gpui_kit::actions!(
3145 ]
3246 );
3347
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+
3452 fn main() {
3553 let project = std::env::args_os()
3654 .nth(1)
3755 .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));
4157
4258 gpui_kit::application()
4359 .with_assets(AppAssets)
4460 .run(move |cx| {
4561 gpui_kit::init(cx);
4662 metal::load_fonts(cx);
47- metal::restore_colorway(cx);
4863 models::Catalog::init(cx);
64+ ui::browser::BrowserWatch::init(cx);
4965
5066 cx.bind_keys([
5167 KeyBinding::new("ctrl-n", NewThread, None),
5268 KeyBinding::new("ctrl-o", OpenProject, None),
5369 KeyBinding::new("ctrl-b", ToggleSidebar, None),
5470 KeyBinding::new("ctrl-shift-b", ToggleProjectPanel, None),
71+ KeyBinding::new("ctrl-shift-e", ToggleBrowser, None),
5572 KeyBinding::new("ctrl-k", FocusSearch, None),
5673 KeyBinding::new("ctrl-,", OpenSettings, None),
5774 KeyBinding::new("ctrl-q", Quit, None),
5875 KeyBinding::new("escape", Interrupt, Some("ChatView")),
5976 KeyBinding::new("escape", CloseSettings, Some("SettingsView")),
6077 ]);
61- cx.on_action(|_: &Quit, cx| cx.quit());
78+ cx.on_action(quit);
6279 cx.on_window_closed(|cx, _| cx.quit()).detach();
6380
6481 let bounds = Bounds::centered(None, size(px(1280.), px(840.)), cx);
@@ -80,3 +97,24 @@ fn main() {
8097 cx.activate(true);
8198 });
8299 }
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+ }
+477−100src/models.rsView File
@@ -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.
47
58 use std::{
69 io::{BufRead, BufReader, Write},
7- process::{Command, Stdio},
10+ path::Path,
11+ process::Stdio,
812 sync::mpsc,
9- time::Duration,
13+ time::{Duration, Instant, SystemTime},
1014 };
1115
1216 use gpui_kit::*;
17+ use serde::{Deserialize, Serialize};
1318 use serde_json::{Value, json};
1419
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};
1722
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")]
2026 pub enum Provider {
2127 #[default]
2228 Claude,
@@ -24,6 +30,8 @@ pub enum Provider {
2430 }
2531
2632 impl Provider {
33+ pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi];
34+
2735 pub fn label(self) -> &'static str {
2836 match self {
2937 Provider::Claude => "Claude",
@@ -39,13 +47,15 @@ impl Provider {
3947 }
4048
4149 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)
4351 }
4452 }
4553
4654 #[derive(Clone, Debug, PartialEq)]
4755 pub struct ModelEntry {
4856 pub provider: Provider,
57+ /// The provider instance offering it (`claude`, `claude-2`, `pi`).
58+ pub instance: String,
4959 /// What the agent is launched with: a Claude alias or model id, or Pi's
5060 /// `provider/model`.
5161 pub id: String,
@@ -63,64 +73,236 @@ pub struct ModelEntry {
6373 }
6474
6575 impl ModelEntry {
66- /// The key favorites are stored under.
76+ /// The key favorites and defaults are stored under.
6777 pub fn key(&self) -> String {
68- format!("{}:{}", self.provider.key(), self.id)
78+ format!("{}:{}", self.instance, self.id)
6979 }
7080 }
7181
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+
7292 #[derive(Clone, Debug, PartialEq)]
73- pub enum PiStatus {
74- Loading,
93+ pub enum ProbeStatus {
94+ /// Turned off in the settings.
95+ Disabled,
96+ Checking,
7597 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).
7799 Missing,
78100 Failed(String),
79101 }
80102
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>,
83111 /// 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>,
89167 favorites: Vec<String>,
90168 }
91169
92170 impl Global for Catalog {}
93171
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+
94184 impl Catalog {
95185 pub fn init(cx: &mut App) {
186+ let saved = settings::load();
96187 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,
103190 });
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();
105230 }
106231
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();
110260 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+ });
112270 cx.spawn(async move |cx| {
113- let result = probe.await;
271+ let (result, latest) = probe.await;
114272 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+ }
116282 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+ }
120298 }
121299 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+ }
124306 }
125307 }
126308 cx.refresh_windows();
@@ -129,46 +311,81 @@ impl Catalog {
129311 .detach();
130312 }
131313
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)
134316 }
135317
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 {
143338 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;
144345 }
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 {
149360 cx.refresh_windows();
150361 }
151362 }
152363
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)
158371 }
159372
160373 /// 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),
166379 }
167380 }
168381
169382 /// The entry stored under `key` (see [`ModelEntry::key`]).
170383 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()
172389 }
173390
174391 pub fn is_favorite(&self, model: &ModelEntry) -> bool {
@@ -176,10 +393,7 @@ impl Catalog {
176393 }
177394
178395 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()
183397 }
184398
185399 pub fn toggle_favorite(model: &ModelEntry, cx: &mut App) {
@@ -197,9 +411,15 @@ impl Catalog {
197411 }
198412 }
199413
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 {
203423 let mut default = None;
204424 let mut models = Vec::new();
205425 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>) {
209429 let display = text("displayName");
210430 let entry = ModelEntry {
211431 provider: Provider::Claude,
432+ instance: instance.to_string(),
212433 id: id.to_string(),
213434 resolved: Some(text("resolvedModel")).filter(|model| !model.is_empty()).unwrap_or_else(|| id.into()),
214435 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>) {
236457 {
237458 default.name = current.name.clone();
238459 }
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+ }
240490 }
241491
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)?;
246497 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())
248501 .stdin(Stdio::piped())
249502 .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+ _ => {}
256538 }
257539 }
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());
259563 let mut child = command.spawn().map_err(|err| failed(&err))?;
260564 let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?;
261565 let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?;
@@ -282,21 +586,27 @@ fn probe_pi_models() -> Result<Vec<ModelEntry>, PiStatus> {
282586 let error = reply.get("error").and_then(Value::as_str).unwrap_or("unknown error");
283587 return Err(failed(&error));
284588 }
285- Ok(reply
589+ let models = reply
286590 .pointer("/data/models")
287591 .and_then(Value::as_array)
288592 .into_iter()
289593 .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() })
292601 }
293602
294- pub fn pi_model_from(model: &Value) -> Option<ModelEntry> {
603+ pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> {
295604 let id = model.get("id")?.as_str()?;
296605 let provider = model.get("provider")?.as_str()?;
297606 let reasoning = model.get("reasoning").and_then(Value::as_bool).unwrap_or(false);
298607 Some(ModelEntry {
299608 provider: Provider::Pi,
609+ instance: instance.to_string(),
300610 id: format!("{provider}/{id}"),
301611 resolved: id.to_string(),
302612 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> {
313623 })
314624 }
315625
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+
316670 /// `openai-codex` → `OpenAI Codex`.
317671 fn provider_label(provider: &str) -> String {
318672 provider
@@ -333,7 +687,7 @@ fn provider_label(provider: &str) -> String {
333687
334688 #[cfg(test)]
335689 mod tests {
336- use super::{claude_models_from, provider_label};
690+ use super::{claude_probe_from, first_version, provider_label, version_newer};
337691 use serde_json::json;
338692
339693 #[::core::prelude::v1::test]
@@ -346,16 +700,39 @@ mod tests {
346700
347701 #[::core::prelude::v1::test]
348702 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"));
360737 }
361738 }
+170−0src/remote/client.rsaddedView File
@@ -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+ }
+319−0src/remote/mod.rsaddedView File

Large diffs are not rendered by default.

+188−0src/remote/server.rsaddedView File

Large diffs are not rendered by default.

+1306−63src/session.rsView File

Large diffs are not rendered by default.

+422−20src/settings.rsView File

Large diffs are not rendered by default.

+100−0src/store.rsaddedView File

Large diffs are not rendered by default.

+92−0src/textgen.rsaddedView File

Large diffs are not rendered by default.

+619−0src/ui/browser.rsaddedView File

Large diffs are not rendered by default.

+320−52src/ui/changes.rsView File

Large diffs are not rendered by default.

+534−91src/ui/chat.rsView File

Large diffs are not rendered by default.

+623−0src/ui/folder_browser.rsaddedView File

Large diffs are not rendered by default.

+17−0src/ui/icons.rsView File

Large diffs are not rendered by default.

+1056−0src/ui/markdown_input.rsaddedView File

Large diffs are not rendered by default.

+24−73src/ui/metal.rsView File

Large diffs are not rendered by default.

+5−0src/ui/mod.rsView File

Large diffs are not rendered by default.

+54−33src/ui/pickers.rsView File

Large diffs are not rendered by default.

+1659−421src/ui/settings.rsView File

Large diffs are not rendered by default.

+821−0src/ui/themes.rsaddedView File

Large diffs are not rendered by default.

+925−0src/ui/usage.rsaddedView File

Large diffs are not rendered by default.

+1384−255src/ui/workspace.rsView File

Large diffs are not rendered by default.

+629−0src/ui/workspace/remote.rsaddedView File

Large diffs are not rendered by default.

+1086−0src/usage.rsaddedView File

Large diffs are not rendered by default.

+188−0src/worktree.rsaddedView File

Large diffs are not rendered by default.