Vectrum
Sign In

Settings parity with T3 Code: provider instances, persistence, worktrees, sidebar organization

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JjohannSocommitted 1 parent 8cf2c30commit0781cf9

Showing 18 changed files with 5037 additions and 858 deletions.

+36−0Cargo.lockView File
@@ -5745,6 +5745,7 @@ version = "0.23.45"
57455745 source = "registry+https://github.com/rust-lang/crates.io-index"
57465746 checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
57475747 dependencies = [
5748+ "log",
57485749 "once_cell",
57495750 "ring",
57505751 "rustls-pki-types",
@@ -7131,6 +7132,21 @@ version = "0.9.0"
71317132 source = "registry+https://github.com/rust-lang/crates.io-index"
71327133 checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
71337134
7135+ [[package]]
7136+ name = "ureq"
7137+ version = "2.12.1"
7138+ source = "registry+https://github.com/rust-lang/crates.io-index"
7139+ checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
7140+ dependencies = [
7141+ "base64 0.22.1",
7142+ "log",
7143+ "once_cell",
7144+ "rustls",
7145+ "rustls-pki-types",
7146+ "url",
7147+ "webpki-roots 0.26.11",
7148+ ]
7149+
71347150 [[package]]
71357151 name = "url"
71367152 version = "2.5.8"
@@ -7281,11 +7297,13 @@ version = "0.1.0"
72817297 dependencies = [
72827298 "anyhow",
72837299 "base64 0.22.1",
7300+ "chrono",
72847301 "gpui-kit",
72857302 "libc",
72867303 "serde",
72877304 "serde_json",
72887305 "smol",
7306+ "ureq",
72897307 ]
72907308
72917309 [[package]]
@@ -7554,6 +7572,24 @@ dependencies = [
75547572 "wasm-bindgen",
75557573 ]
75567574
7575+ [[package]]
7576+ name = "webpki-roots"
7577+ version = "0.26.11"
7578+ source = "registry+https://github.com/rust-lang/crates.io-index"
7579+ checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
7580+ dependencies = [
7581+ "webpki-roots 1.0.9",
7582+ ]
7583+
7584+ [[package]]
7585+ name = "webpki-roots"
7586+ version = "1.0.9"
7587+ source = "registry+https://github.com/rust-lang/crates.io-index"
7588+ checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
7589+ dependencies = [
7590+ "rustls-pki-types",
7591+ ]
7592+
75577593 [[package]]
75587594 name = "weezl"
75597595 version = "0.1.12"
+2−0Cargo.tomlView File
@@ -12,6 +12,8 @@ anyhow = "1"
1212 libc = "0.2"
1313 smol = "2"
1414 base64 = "0.22"
15+ ureq = { version = "2", default-features = false, features = ["tls"] }
16+ chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
1517
1618 [profile.dev]
1719 debug = "line-tables-only"
+123−53src/claude.rsView File
@@ -15,6 +15,7 @@ use anyhow::{Context as _, Result, anyhow};
1515 use serde_json::{Value, json};
1616
1717 use crate::models::Provider;
18+ use crate::settings::{ProviderInstance, expand_home};
1819
1920 /// Environment variables that mark a process as running *inside* another
2021 /// Claude Code session. They leak in when this app is launched from a Claude
@@ -89,7 +90,7 @@ impl PermissionMode {
8990
9091 /// How hard the model thinks: Claude's `--effort` levels and Pi's thinking
9192 /// levels, which add `off` and `minimal`.
92- #[derive(Clone, Copy, Debug, PartialEq, Eq)]
93+ #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
9394 pub enum Effort {
9495 Off,
9596 Minimal,
@@ -135,7 +136,8 @@ impl Effort {
135136
136137 #[derive(Clone, Debug)]
137138 pub struct LaunchConfig {
138- pub provider: Provider,
139+ /// Which agent, with its binary, home folder, arguments and environment.
140+ pub instance: ProviderInstance,
139141 pub cwd: PathBuf,
140142 /// A Claude model alias, or `provider/model` for Pi. `None` is the default.
141143 pub model: Option<String>,
@@ -162,7 +164,7 @@ pub struct AgentProcess {
162164
163165 impl AgentProcess {
164166 pub fn spawn(config: &LaunchConfig, events: smol::channel::Sender<ProcessEvent>) -> Result<Self> {
165- let (binary, command) = match config.provider {
167+ let (binary, command) = match config.instance.kind {
166168 Provider::Claude => claude_command(config)?,
167169 Provider::Pi => pi_command(config)?,
168170 };
@@ -175,9 +177,6 @@ impl AgentProcess {
175177 events: smol::channel::Sender<ProcessEvent>,
176178 ) -> Result<Self> {
177179 command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
178- for key in NESTED_SESSION_ENV {
179- command.env_remove(key);
180- }
181180 // Take the agent down with us if the app dies without cleaning up.
182181 unsafe {
183182 command.pre_exec(|| {
@@ -287,9 +286,7 @@ impl Drop for AgentProcess {
287286 }
288287
289288 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);
289+ let (binary, mut command) = base_command(&config.instance)?;
293290 command
294291 .args([
295292 "-p",
@@ -319,13 +316,12 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
319316 if let Some(session_id) = &config.resume {
320317 command.args(["--resume", session_id]);
321318 }
319+ command.args(split_args(&config.instance.args));
322320 Ok((binary, command))
323321 }
324322
325323 fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
326- let binary = find_pi_binary()
327- .ok_or_else(|| anyhow!("could not find the `pi` CLI on PATH (set VECTRUM_CODE_PI_BIN)"))?;
328- let mut command = Command::new(&binary);
324+ let (binary, mut command) = base_command(&config.instance)?;
329325 command.args(["--mode", "rpc"]).current_dir(&config.cwd);
330326 if let Some((provider, model)) = config.model.as_deref().and_then(|model| model.split_once('/')) {
331327 command.args(["--provider", provider, "--model", model]);
@@ -336,9 +332,52 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
336332 if let Some(session) = &config.resume {
337333 command.args(["--session", session]);
338334 }
335+ command.args(split_args(&config.instance.args));
336+ Ok((binary, command))
337+ }
338+
339+ /// The environment variable that moves an agent's home folder.
340+ pub fn home_var(kind: Provider) -> &'static str {
341+ match kind {
342+ Provider::Claude => "CLAUDE_CONFIG_DIR",
343+ Provider::Pi => "PI_CODING_AGENT_DIR",
344+ }
345+ }
346+
347+ /// The variable that overrides the binary of an agent's first instance.
348+ pub fn binary_var(kind: Provider) -> &'static str {
349+ match kind {
350+ Provider::Claude => "VECTRUM_CODE_CLAUDE_BIN",
351+ Provider::Pi => "VECTRUM_CODE_PI_BIN",
352+ }
353+ }
354+
355+ /// A command for an agent's binary with the instance's home folder and
356+ /// environment, and without the variables of any Claude session around us.
357+ pub fn base_command(instance: &ProviderInstance) -> Result<(PathBuf, Command)> {
358+ let binary = find_binary(instance).ok_or_else(|| {
359+ anyhow!(
360+ "could not find the `{}` CLI on PATH (set its path in Settings → Providers)",
361+ instance.kind.key()
362+ )
363+ })?;
364+ let mut command = Command::new(&binary);
365+ for key in NESTED_SESSION_ENV {
366+ command.env_remove(key);
367+ }
368+ if !instance.home.trim().is_empty() {
369+ command.env(home_var(instance.kind), expand_home(instance.home.trim()));
370+ }
371+ for var in &instance.env {
372+ if !var.key.trim().is_empty() {
373+ command.env(var.key.trim(), &var.value);
374+ }
375+ }
339376 // Pi is a Node script; a desktop launcher's PATH may not reach the Node it
340377 // was installed with, which usually sits next to it.
341- if let Some(dir) = binary.parent() {
378+ if instance.kind == Provider::Pi
379+ && let Some(dir) = binary.parent()
380+ {
342381 let path = std::env::var_os("PATH").unwrap_or_default();
343382 let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path));
344383 if let Ok(joined) = std::env::join_paths(paths) {
@@ -348,61 +387,80 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> {
348387 Ok((binary, command))
349388 }
350389
351- pub fn find_claude_binary() -> Option<PathBuf> {
352- if let Some(path) = std::env::var_os("VECTRUM_CODE_CLAUDE_BIN") {
390+ /// Where an agent's binary is: the environment override (first instance
391+ /// only), the instance's own path, then PATH and the usual install folders.
392+ pub fn find_binary(instance: &ProviderInstance) -> Option<PathBuf> {
393+ if instance.id == instance.kind.key()
394+ && let Some(path) = std::env::var_os(binary_var(instance.kind))
395+ {
353396 return Some(PathBuf::from(path));
354397 }
355- if let Some(path) = crate::settings::get(|settings| settings.claude_bin.clone()) {
356- return Some(expand_home(&path));
398+ if !instance.binary.trim().is_empty() {
399+ return Some(expand_home(instance.binary.trim()));
357400 }
401+ let name = instance.kind.key();
358402 let on_path = std::env::var_os("PATH")
359403 .into_iter()
360404 .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
361- .map(|dir| dir.join("claude"));
405+ .map(|dir| dir.join(name));
362406 // Desktop launchers often start apps without the user's shell PATH.
363407 let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
364- let fallbacks = [
365- home.join(".local/bin/claude"),
366- home.join(".claude/local/claude"),
367- PathBuf::from("/usr/local/bin/claude"),
368- PathBuf::from("/usr/bin/claude"),
369- ];
370- on_path.chain(fallbacks).find(|path| is_executable(path))
371- }
372-
373- pub fn find_pi_binary() -> Option<PathBuf> {
374- if let Some(path) = std::env::var_os("VECTRUM_CODE_PI_BIN") {
375- return Some(PathBuf::from(path));
376- }
377- if let Some(path) = crate::settings::get(|settings| settings.pi_bin.clone()) {
378- return Some(expand_home(&path));
379- }
380- let on_path = std::env::var_os("PATH")
381- .into_iter()
382- .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>())
383- .map(|dir| dir.join("pi"));
384- let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
385- let mut fallbacks = vec![
386- home.join(".local/bin/pi"),
387- home.join(".npm-global/bin/pi"),
388- home.join(".bun/bin/pi"),
389- home.join(".volta/bin/pi"),
390- PathBuf::from("/usr/local/bin/pi"),
391- PathBuf::from("/usr/bin/pi"),
392- ];
408+ let mut fallbacks = match instance.kind {
409+ Provider::Claude => vec![home.join(".local/bin/claude"), home.join(".claude/local/claude")],
410+ Provider::Pi => vec![
411+ home.join(".local/bin/pi"),
412+ home.join(".npm-global/bin/pi"),
413+ home.join(".bun/bin/pi"),
414+ home.join(".volta/bin/pi"),
415+ ],
416+ };
417+ fallbacks.extend([PathBuf::from("/usr/local/bin").join(name), PathBuf::from("/usr/bin").join(name)]);
393418 // nvm keeps one bin directory per Node version.
394- if let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node")) {
419+ if instance.kind == Provider::Pi
420+ && let Ok(versions) = std::fs::read_dir(home.join(".nvm/versions/node"))
421+ {
395422 fallbacks.extend(versions.flatten().map(|version| version.path().join("bin/pi")));
396423 }
397424 on_path.chain(fallbacks).find(|path| is_executable(path))
398425 }
399426
400- /// `~/bin/claude` → `/home/me/bin/claude`.
401- fn expand_home(path: &str) -> PathBuf {
402- match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
403- (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
404- _ => PathBuf::from(path),
427+ /// Splits launch arguments like a shell: on whitespace, keeping quoted runs
428+ /// together.
429+ pub fn split_args(text: &str) -> Vec<String> {
430+ let mut args = Vec::new();
431+ let mut current = String::new();
432+ let mut quote: Option<char> = None;
433+ let mut started = false;
434+ let mut chars = text.chars();
435+ while let Some(c) = chars.next() {
436+ match (quote, c) {
437+ (Some(q), c) if c == q => quote = None,
438+ (Some('"'), '\\') => current.extend(chars.next()),
439+ (Some(_), c) => current.push(c),
440+ (None, '"' | '\'') => {
441+ quote = Some(c);
442+ started = true;
443+ }
444+ (None, '\\') => {
445+ current.extend(chars.next());
446+ started = true;
447+ }
448+ (None, c) if c.is_whitespace() => {
449+ if started || !current.is_empty() {
450+ args.push(std::mem::take(&mut current));
451+ }
452+ started = false;
453+ }
454+ (None, c) => {
455+ current.push(c);
456+ started = true;
457+ }
458+ }
459+ }
460+ if started || !current.is_empty() {
461+ args.push(current);
405462 }
463+ args
406464 }
407465
408466 pub fn is_executable(path: &Path) -> bool {
@@ -411,3 +469,15 @@ pub fn is_executable(path: &Path) -> bool {
411469 .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
412470 .unwrap_or(false)
413471 }
472+
473+ #[cfg(test)]
474+ mod tests {
475+ use super::split_args;
476+
477+ #[::core::prelude::v1::test]
478+ fn splits_like_a_shell() {
479+ assert_eq!(split_args(r#"--a 1 --b "two words" 'x y' z\ w"#), ["--a", "1", "--b", "two words", "x y", "z w"]);
480+ assert_eq!(split_args(" "), Vec::<String>::new());
481+ assert_eq!(split_args(r#"--empty """#), ["--empty", ""]);
482+ }
483+ }
+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+ }
+4−2src/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,
+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+ }
+38−5src/main.rsView File
@@ -4,14 +4,24 @@
44 extern crate gpui_kit as gpui;
55
66 mod claude;
7+ mod clock;
78 mod git;
9+ mod http;
810 mod models;
911 mod session;
1012 mod settings;
13+ mod store;
14+ mod textgen;
1115 mod ui;
16+ mod worktree;
1217
13- use std::path::PathBuf;
18+ use std::{
19+ path::PathBuf,
20+ sync::Mutex,
21+ time::{Duration, Instant},
22+ };
1423
24+ use gpui_kit::component::{WindowExt as _, notification::Notification};
1525 use gpui_kit::*;
1626
1727 use crate::ui::{icons::AppAssets, metal, workspace::Workspace};
@@ -31,13 +41,15 @@ gpui_kit::actions!(
3141 ]
3242 );
3343
44+ /// With the Hold quit shortcut, a second Ctrl+Q (or the key repeat of a
45+ /// held one) within this long quits.
46+ const QUIT_WINDOW: Duration = Duration::from_millis(1200);
47+
3448 fn main() {
3549 let project = std::env::args_os()
3650 .nth(1)
3751 .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);
52+ .map(|project| project.canonicalize().unwrap_or(project));
4153
4254 gpui_kit::application()
4355 .with_assets(AppAssets)
@@ -58,7 +70,7 @@ fn main() {
5870 KeyBinding::new("escape", Interrupt, Some("ChatView")),
5971 KeyBinding::new("escape", CloseSettings, Some("SettingsView")),
6072 ]);
61- cx.on_action(|_: &Quit, cx| cx.quit());
73+ cx.on_action(quit);
6274 cx.on_window_closed(|cx, _| cx.quit()).detach();
6375
6476 let bounds = Bounds::centered(None, size(px(1280.), px(840.)), cx);
@@ -80,3 +92,24 @@ fn main() {
8092 cx.activate(true);
8193 });
8294 }
95+
96+ /// Quits, or with the Hold shortcut, asks for the keys to be held or pressed
97+ /// again.
98+ fn quit(_: &Quit, cx: &mut App) {
99+ static FIRST_PRESS: Mutex<Option<Instant>> = Mutex::new(None);
100+ if settings::get(|settings| settings.quit_shortcut) == settings::QuitShortcut::Press {
101+ cx.quit();
102+ return;
103+ }
104+ let mut first = FIRST_PRESS.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
105+ if first.is_some_and(|at| at.elapsed() < QUIT_WINDOW) {
106+ cx.quit();
107+ return;
108+ }
109+ *first = Some(Instant::now());
110+ if let Some(window) = cx.active_window() {
111+ let _ = window.update(cx, |_, window, cx| {
112+ window.push_notification(Notification::info("Hold Ctrl+Q, or press it again, to quit."), cx);
113+ });
114+ }
115+ }
+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 }
+709−63src/session.rsView File
@@ -5,17 +5,20 @@ use std::{
55 collections::{HashMap, HashSet, VecDeque},
66 path::{Path, PathBuf},
77 sync::atomic::{AtomicU64, Ordering},
8- time::Instant,
8+ time::{Duration, Instant, SystemTime, UNIX_EPOCH},
99 };
1010
1111 use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64};
1212 use gpui_kit::component::text::TextViewState;
1313 use gpui_kit::*;
14+ use serde::{Deserialize, Serialize};
1415 use serde_json::{Value, json};
1516
1617 use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessEvent};
1718 use crate::git::{self, TurnChanges};
1819 use crate::models::{Catalog, Provider};
20+ use crate::settings::{self, Streaming};
21+ use crate::worktree::{self, PullRequest, Worktree, WorktreePlan};
1922
2023 const DEFAULT_TITLE: &str = "New thread";
2124 const STDERR_TAIL: usize = 20;
@@ -109,17 +112,41 @@ pub struct Question {
109112 pub answer: Option<SharedString>,
110113 }
111114
115+ /// An answer as the agent wrote it, and the Markdown view showing it. While
116+ /// streaming, the view may lag behind the text to show only finished
117+ /// paragraphs.
118+ pub struct AssistantText {
119+ pub text: String,
120+ pub view: Entity<TextViewState>,
121+ /// How much of `text` the view shows.
122+ shown: usize,
123+ }
124+
112125 pub enum ChatItem {
113126 User {
114127 text: SharedString,
115128 /// File names of what was attached.
116129 attachments: Vec<SharedString>,
130+ at: SystemTime,
117131 },
118- Assistant(Entity<TextViewState>),
132+ Assistant(AssistantText),
119133 Tool(ToolCall),
120134 Notice(NoticeKind, SharedString),
121135 Question(Question),
122- TurnEnd { duration_ms: u64, cost: Option<f64> },
136+ TurnEnd { duration_ms: u64, cost: Option<f64>, at: SystemTime },
137+ }
138+
139+ /// What a session tells the views around it.
140+ pub enum SessionEvent {
141+ /// A finished turn's changes on disk are known, by its `TurnEnd` index.
142+ TurnChanges(usize),
143+ }
144+
145+ /// A message waiting for the running turn to end.
146+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
147+ pub struct QueuedMessage {
148+ pub text: String,
149+ pub attachments: Vec<PathBuf>,
123150 }
124151
125152 /// How hard the model reasons, as picked in the composer.
@@ -194,6 +221,8 @@ pub struct FinishedTurn {
194221 pub end: usize,
195222 pub duration_ms: u64,
196223 pub cost: Option<f64>,
224+ /// When it ended.
225+ pub at: SystemTime,
197226 /// Whether anything besides the answer happened (tools, interim text).
198227 pub has_work: bool,
199228 }
@@ -205,9 +234,22 @@ struct StreamingMessage {
205234 }
206235
207236 pub struct Session {
237+ /// Names the thread's file on disk.
238+ pub id: String,
208239 pub provider: Provider,
240+ /// The provider instance running it (`claude`, `claude-2`, `pi`).
241+ pub instance: String,
209242 pub title: SharedString,
243+ /// Set by the user; generated titles don't replace it.
244+ pub title_custom: bool,
245+ /// The project folder the thread belongs to.
246+ pub project: PathBuf,
247+ /// Where the agent runs: the project folder, or a place in the thread's worktree.
210248 pub cwd: PathBuf,
249+ pub worktree: Option<Worktree>,
250+ /// The worktree to create when the first message is sent.
251+ pub pending_worktree: Option<WorktreePlan>,
252+ pub pull_request: Option<PullRequest>,
211253 pub items: Vec<ChatItem>,
212254 pub status: SessionStatus,
213255 pub thinking: bool,
@@ -228,10 +270,27 @@ pub struct Session {
228270 /// Pi reports cost per message; this sums the running turn.
229271 turn_cost: f64,
230272 pub turn_started: Option<Instant>,
273+ pub created: SystemTime,
231274 /// When the thread last saw activity, for ordering the thread list.
232- pub updated: Instant,
275+ pub updated: SystemTime,
276+ /// When the user last sent a message.
277+ pub last_message: SystemTime,
233278 /// Filed away by the user: its process is stopped until the next message.
234279 pub settled: bool,
280+ /// Kept at the top of the thread list.
281+ pub pinned: bool,
282+ /// Hidden from the thread list until then.
283+ pub snoozed_until: Option<SystemTime>,
284+ /// When a usage limit that stopped the thread resets.
285+ pub limit_resets: Option<SystemTime>,
286+ /// When to continue the thread on its own, after a usage limit.
287+ pub resume_at: Option<SystemTime>,
288+ /// Messages held until the running turn ends.
289+ pub queue: Vec<QueuedMessage>,
290+ /// Restored from disk after the app stopped it mid-turn.
291+ pub interrupted: bool,
292+ /// Creating the worktree before the first message goes out.
293+ pub preparing: bool,
235294 /// Tokens in the context window as of the last response.
236295 pub context_tokens: Option<u64>,
237296 pub context_window: Option<u64>,
@@ -256,25 +315,58 @@ pub struct Session {
256315 _events_task: Task<()>,
257316 }
258317
318+ /// What a new thread starts with.
319+ pub struct NewSession {
320+ pub instance: String,
321+ pub provider: Provider,
322+ pub project: PathBuf,
323+ pub model: Option<String>,
324+ pub permission_mode: PermissionMode,
325+ pub reasoning: Reasoning,
326+ pub fast_mode: bool,
327+ /// Start in a worktree of its own, made when the first message is sent.
328+ pub worktree: Option<WorktreePlan>,
329+ }
330+
331+ impl EventEmitter<SessionEvent> for Session {}
332+
259333 impl Session {
260- pub fn new(
261- provider: Provider,
262- cwd: PathBuf,
263- model: Option<String>,
264- permission_mode: PermissionMode,
265- reasoning: Reasoning,
266- fast_mode: bool,
267- cx: &mut Context<Self>,
268- ) -> Self {
334+ pub fn new(new: NewSession, cx: &mut Context<Self>) -> Self {
335+ let now = SystemTime::now();
336+ let id = format!(
337+ "{:x}-{:x}-{}",
338+ now.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis(),
339+ std::process::id(),
340+ NEXT_SESSION.load(Ordering::Relaxed)
341+ );
342+ let start = new.worktree.is_none();
343+ let mut session = Self::empty(id, new, now);
344+ if start {
345+ session.start(None, cx);
346+ } else {
347+ session.status = SessionStatus::Idle;
348+ }
349+ session
350+ }
351+
352+ fn empty(id: String, new: NewSession, now: SystemTime) -> Self {
269353 let snapshot_index = std::env::temp_dir().join(format!(
270354 "vectrum-code-{}-{}.index",
271355 std::process::id(),
272356 NEXT_SESSION.fetch_add(1, Ordering::Relaxed)
273357 ));
274- let mut session = Self {
358+ let NewSession { instance, provider, project, model, permission_mode, reasoning, fast_mode, worktree } = new;
359+ Self {
360+ id,
275361 provider,
362+ instance,
276363 title: DEFAULT_TITLE.into(),
277- cwd,
364+ title_custom: false,
365+ cwd: project.clone(),
366+ project,
367+ worktree: None,
368+ pending_worktree: worktree,
369+ pull_request: None,
278370 items: Vec::new(),
279371 status: SessionStatus::Starting,
280372 thinking: false,
@@ -289,8 +381,17 @@ impl Session {
289381 process_cost: 0.,
290382 turn_cost: 0.,
291383 turn_started: None,
292- updated: Instant::now(),
384+ created: now,
385+ updated: now,
386+ last_message: now,
293387 settled: false,
388+ pinned: false,
389+ snoozed_until: None,
390+ limit_resets: None,
391+ resume_at: None,
392+ queue: Vec::new(),
393+ interrupted: false,
394+ preparing: false,
294395 context_tokens: None,
295396 context_window: None,
296397 expanded_turns: HashSet::new(),
@@ -306,9 +407,7 @@ impl Session {
306407 pending_controls: HashMap::new(),
307408 next_request_id: 0,
308409 _events_task: Task::ready(()),
309- };
310- session.start(None, cx);
311- session
410+ }
312411 }
313412
314413 pub fn is_working(&self) -> bool {
@@ -329,7 +428,7 @@ impl Session {
329428 fn start(&mut self, resume: Option<String>, cx: &mut Context<Self>) {
330429 let (tx, rx) = smol::channel::unbounded();
331430 let config = LaunchConfig {
332- provider: self.provider,
431+ instance: settings::instance(&self.instance, self.provider),
333432 cwd: self.cwd.clone(),
334433 model: self.model.clone(),
335434 permission_mode: self.permission_mode,
@@ -419,30 +518,135 @@ impl Session {
419518
420519 // ---- user actions -------------------------------------------------
421520
422- /// Sends a message. Images are attached inline; other files are listed by
423- /// path for the agent to read.
521+ /// Sends a message now, even mid-turn, where the agent takes it in as
522+ /// soon as it can. Images are attached inline; other files are listed by
523+ /// path for the agent to read. The thread's first message also creates
524+ /// its worktree, if it starts in one.
424525 pub fn send_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
526+ if text.trim().is_empty() && attachments.is_empty() {
527+ return;
528+ }
529+ let names: Vec<SharedString> = attachments.iter().map(|path| file_name(path).into()).collect();
530+ let first = !self.items.iter().any(|item| matches!(item, ChatItem::User { .. }));
531+ let now = SystemTime::now();
532+ self.items.push(ChatItem::User { text: text.clone().into(), attachments: names.clone(), at: now });
533+ self.last_message = now;
534+ self.updated = now;
535+ self.settled = false;
536+ self.snoozed_until = None;
537+ self.resume_at = None;
538+ self.interrupted = false;
539+ if first {
540+ let source = if text.trim().is_empty() { names.join(", ") } else { text.clone() };
541+ if !self.title_custom {
542+ self.title = title_from(&source).into();
543+ self.generate_title(source, cx);
544+ }
545+ }
546+
547+ if let Some(plan) = self.pending_worktree.take() {
548+ self.status = SessionStatus::Working;
549+ self.preparing = true;
550+ self.turn_started = Some(Instant::now());
551+ self.turn_cost = 0.;
552+ let title = self.title.to_string();
553+ let task = cx.background_executor().spawn(async move { worktree::create(&plan, &title) });
554+ cx.spawn(async move |this, cx| {
555+ let created = task.await;
556+ let _ = this.update(cx, |this, cx| {
557+ this.preparing = false;
558+ match created {
559+ Ok((worktree, cwd)) => {
560+ this.cwd = cwd;
561+ this.worktree = Some(worktree);
562+ }
563+ Err(err) => this.push_notice(
564+ NoticeKind::Error,
565+ format!("Couldn't create a worktree: {err}\n\nThe thread runs in the project folder instead."),
566+ ),
567+ }
568+ this.start(None, cx);
569+ this.snapshot_base(cx);
570+ this.deliver(text, attachments, cx);
571+ cx.notify();
572+ });
573+ })
574+ .detach();
575+ cx.notify();
576+ return;
577+ }
578+
579+ if !self.is_working() {
580+ self.turn_started = Some(Instant::now());
581+ self.turn_cost = 0.;
582+ self.snapshot_base(cx);
583+ }
584+ self.deliver(text, attachments, cx);
585+ self.status = SessionStatus::Working;
586+ cx.notify();
587+ }
588+
589+ /// Holds a message until the running turn ends; sends it now if none runs.
590+ pub fn queue_message(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
591+ if !self.is_working() {
592+ self.send_message(text, attachments, cx);
593+ return;
594+ }
595+ if text.trim().is_empty() && attachments.is_empty() {
596+ return;
597+ }
598+ self.queue.push(QueuedMessage { text, attachments });
599+ cx.notify();
600+ }
601+
602+ /// Sends a queued message now instead of after the turn.
603+ pub fn steer_queued(&mut self, ix: usize, cx: &mut Context<Self>) {
604+ if ix < self.queue.len() {
605+ let message = self.queue.remove(ix);
606+ self.send_message(message.text, message.attachments, cx);
607+ }
608+ }
609+
610+ pub fn remove_queued(&mut self, ix: usize, cx: &mut Context<Self>) {
611+ if ix < self.queue.len() {
612+ self.queue.remove(ix);
613+ cx.notify();
614+ }
615+ }
616+
617+ /// Sends the next queued message once a turn has ended.
618+ fn send_next_queued(&mut self, cx: &mut Context<Self>) {
619+ if !self.is_working() && !self.queue.is_empty() {
620+ let message = self.queue.remove(0);
621+ self.send_message(message.text, message.attachments, cx);
622+ }
623+ }
624+
625+ /// Records the working tree as the starting turn finds it.
626+ fn snapshot_base(&mut self, cx: &mut Context<Self>) {
627+ let cwd = self.cwd.clone();
628+ let index = self.snapshot_index.clone();
629+ self.base_snapshot = Some(cx.background_executor().spawn(async move { git::snapshot(&cwd, &index) }));
630+ }
631+
632+ /// Writes a message to the agent, starting (or resuming) its process if
633+ /// it isn't running.
634+ fn deliver(&mut self, text: String, attachments: Vec<PathBuf>, cx: &mut Context<Self>) {
425635 let mut images = Vec::new();
426- let mut names = Vec::new();
427636 let mut file_paths = Vec::new();
428637 for path in &attachments {
429- let name = path
430- .file_name()
431- .map(|name| name.to_string_lossy().to_string())
432- .unwrap_or_else(|| path.display().to_string());
433638 match image_media_type(path) {
434639 Some(media_type) => match read_image(path) {
435640 Ok(data) => images.push((media_type, data)),
436641 Err(err) => {
437- self.push_notice(NoticeKind::Error, format!("Couldn't attach {name}: {err}"));
642+ self.push_notice(NoticeKind::Error, format!("Couldn't attach {}: {err}", file_name(path)));
438643 continue;
439644 }
440645 },
441646 None => file_paths.push(path.display().to_string()),
442647 }
443- names.push(SharedString::from(name));
444648 }
445- let mut prompt = text.clone();
649+ let mut prompt = text;
446650 if !file_paths.is_empty() {
447651 if !prompt.is_empty() {
448652 prompt.push_str("\n\n");
@@ -453,7 +657,6 @@ impl Session {
453657 }
454658 }
455659 if prompt.is_empty() && images.is_empty() {
456- cx.notify();
457660 return;
458661 }
459662 if self.provider == Provider::Claude && self.reasoning == Reasoning::Ultrathink {
@@ -466,7 +669,8 @@ impl Session {
466669 self.start(resume, cx);
467670 }
468671 let Some(process) = &self.process else {
469- cx.notify();
672+ self.status = SessionStatus::Exited;
673+ self.turn_started = None;
470674 return;
471675 };
472676 match self.provider {
@@ -494,34 +698,107 @@ impl Session {
494698 });
495699 // Pi rejects a prompt mid-run unless told when to deliver it.
496700 if self.is_working() {
497- command["streamingBehavior"] = "followUp".into();
701+ command["streamingBehavior"] = "steer".into();
498702 }
499703 self.pi(command);
500704 }
501705 }
706+ self.status = SessionStatus::Working;
707+ }
708+
709+ /// Asks the text generation model for a title, in the background.
710+ fn generate_title(&mut self, message: String, cx: &mut Context<Self>) {
711+ if settings::get(|settings| settings.text_model.is_empty()) {
712+ return;
713+ }
714+ let task = cx.background_executor().spawn(async move { crate::textgen::title(&message) });
715+ cx.spawn(async move |this, cx| {
716+ let Some(title) = task.await else { return };
717+ let _ = this.update(cx, |this, cx| {
718+ if !this.title_custom {
719+ this.title = title.into();
720+ cx.notify();
721+ }
722+ });
723+ })
724+ .detach();
725+ }
502726
503- if self.title.as_ref() == DEFAULT_TITLE {
504- let source = if text.trim().is_empty() { names.join(", ") } else { text.clone() };
505- self.title = title_from(&source).into();
727+ pub fn rename(&mut self, title: String, cx: &mut Context<Self>) {
728+ let title = title.trim();
729+ if title.is_empty() {
730+ return;
506731 }
507- self.items.push(ChatItem::User { text: text.into(), attachments: names });
508- if !self.is_working() {
509- self.turn_started = Some(Instant::now());
510- self.turn_cost = 0.;
511- let cwd = self.cwd.clone();
512- let index = self.snapshot_index.clone();
513- self.base_snapshot =
514- Some(cx.background_executor().spawn(async move { git::snapshot(&cwd, &index) }));
732+ self.title = title.to_string().into();
733+ self.title_custom = true;
734+ cx.notify();
735+ }
736+
737+ /// Picks where the (still empty) thread will run: in the project folder,
738+ /// or a worktree made with its first message.
739+ pub fn set_worktree_plan(&mut self, plan: Option<WorktreePlan>, cx: &mut Context<Self>) {
740+ if !self.items.is_empty() || self.worktree.is_some() {
741+ return;
742+ }
743+ let had_plan = self.pending_worktree.is_some();
744+ self.pending_worktree = plan;
745+ match (had_plan, self.pending_worktree.is_some()) {
746+ (false, true) => {
747+ // The agent starts in the worktree once there is one.
748+ self.process.take();
749+ self._events_task = Task::ready(());
750+ self.pending_controls.clear();
751+ self.status = SessionStatus::Idle;
752+ }
753+ (true, false) if self.process.is_none() => self.start(None, cx),
754+ _ => {}
515755 }
516- self.status = SessionStatus::Working;
517- self.settled = false;
518- self.updated = Instant::now();
519756 cx.notify();
520757 }
521758
759+ pub fn set_pinned(&mut self, pinned: bool, cx: &mut Context<Self>) {
760+ self.pinned = pinned;
761+ cx.notify();
762+ }
763+
764+ pub fn snooze(&mut self, until: Option<SystemTime>, cx: &mut Context<Self>) {
765+ self.snoozed_until = until;
766+ if until.is_none() {
767+ self.updated = SystemTime::now();
768+ }
769+ cx.notify();
770+ }
771+
772+ pub fn is_snoozed(&self) -> bool {
773+ self.snoozed_until.is_some_and(|until| until > SystemTime::now())
774+ }
775+
776+ /// When the usage limit that stopped the thread resets, while it hasn't.
777+ pub fn limited_until(&self) -> Option<SystemTime> {
778+ self.limit_resets.filter(|resets| *resets > SystemTime::now())
779+ }
780+
781+ /// Drops the continuation scheduled for when a usage limit resets.
782+ pub fn cancel_resume(&mut self, cx: &mut Context<Self>) {
783+ self.resume_at = None;
784+ cx.notify();
785+ }
786+
787+ /// Whether the last turn ended in an error.
788+ pub fn ended_with_error(&self) -> bool {
789+ self.status == SessionStatus::Exited
790+ || self
791+ .items
792+ .iter()
793+ .rev()
794+ .take_while(|item| !matches!(item, ChatItem::User { .. }))
795+ .any(|item| matches!(item, ChatItem::Notice(NoticeKind::Error, _)))
796+ }
797+
522798 /// Files the thread under Settled and stops its process. The transcript
523799 /// stays, and the next message resumes the conversation.
524800 pub fn settle(&mut self, cx: &mut Context<Self>) {
801+ self.flush_text(cx);
525802 self.process.take();
526803 // Drop the event stream too, so the process exit isn't reported as a crash.
527804 self._events_task = Task::ready(());
@@ -536,6 +813,7 @@ impl Session {
536813 self.status = SessionStatus::Idle;
537814 }
538815 self.settled = true;
816+ self.preparing = false;
539817 cx.notify();
540818 }
541819
@@ -543,7 +821,7 @@ impl Session {
543821 /// with the next message.
544822 pub fn unsettle(&mut self, cx: &mut Context<Self>) {
545823 self.settled = false;
546- self.updated = Instant::now();
824+ self.updated = SystemTime::now();
547825 cx.notify();
548826 }
549827
@@ -670,7 +948,7 @@ impl Session {
670948 let end = ix + self.items[ix..]
671949 .iter()
672950 .position(|item| matches!(item, ChatItem::User { .. } | ChatItem::TurnEnd { .. }))?;
673- let ChatItem::TurnEnd { duration_ms, cost } = self.items[end] else {
951+ let ChatItem::TurnEnd { duration_ms, cost, at } = self.items[end] else {
674952 return None;
675953 };
676954 let answer = (first..end)
@@ -679,7 +957,7 @@ impl Session {
679957 let has_work = (first..end).any(|ix| {
680958 Some(ix) != answer && !matches!(self.items[ix], ChatItem::Notice(NoticeKind::Error, _))
681959 });
682- Some(FinishedTurn { first, answer, end, duration_ms, cost, has_work })
960+ Some(FinishedTurn { first, answer, end, duration_ms, cost, at, has_work })
683961 }
684962
685963 pub fn toggle_tool(&mut self, ix: usize, cx: &mut Context<Self>) {
@@ -775,11 +1053,12 @@ impl Session {
7751053 }
7761054 self.stderr_tail.push_back(line);
7771055 }
778- ProcessEvent::Exited(code) => self.handle_exit(code),
1056+ ProcessEvent::Exited(code) => self.handle_exit(code, cx),
7791057 }
7801058 }
7811059
782- fn handle_exit(&mut self, code: Option<i32>) {
1060+ fn handle_exit(&mut self, code: Option<i32>, cx: &mut Context<Self>) {
1061+ self.flush_text(cx);
7831062 self.process = None;
7841063 self.thinking = false;
7851064 self.streaming = None;
@@ -807,9 +1086,11 @@ impl Session {
8071086 /// Closes the running turn: records its end and, in the background, what
8081087 /// it changed on disk.
8091088 fn finish_turn(&mut self, duration_ms: u64, cost: Option<f64>, cx: &mut Context<Self>) {
1089+ self.flush_text(cx);
8101090 self.turn_started = None;
811- self.updated = Instant::now();
812- self.items.push(ChatItem::TurnEnd { duration_ms, cost });
1091+ let now = SystemTime::now();
1092+ self.updated = now;
1093+ self.items.push(ChatItem::TurnEnd { duration_ms, cost, at: now });
8131094 let end = self.items.len() - 1;
8141095 let Some(base) = self.base_snapshot.take() else { return };
8151096 let cwd = self.cwd.clone();
@@ -826,6 +1107,7 @@ impl Session {
8261107 if let Some(changes) = changes.filter(|changes| !changes.files.is_empty()) {
8271108 let _ = this.update(cx, |this, cx| {
8281109 this.turn_changes.insert(end, changes);
1110+ cx.emit(SessionEvent::TurnChanges(end));
8291111 cx.notify();
8301112 });
8311113 }
@@ -857,6 +1139,7 @@ impl Session {
8571139 },
8581140 Some("user") if parent_tool_use_id.is_none() => self.handle_user(&message["message"]),
8591141 Some("result") => self.handle_result(&message, cx),
1142+ Some("rate_limit_event") => self.handle_rate_limit(&message["rate_limit_info"]),
8601143 Some("control_request") => self.handle_control_request(&message),
8611144 Some("control_response") => self.handle_control_response(&message["response"], cx),
8621145 Some("control_cancel_request") => {
@@ -936,9 +1219,9 @@ impl Session {
9361219 let Some(item_ix) = self.streamed_item(index) else { return };
9371220 let delta = &event["delta"];
9381221 match (delta.get("type").and_then(Value::as_str), self.items.get_mut(item_ix)) {
939- (Some("text_delta"), Some(ChatItem::Assistant(markdown))) => {
1222+ (Some("text_delta"), Some(ChatItem::Assistant(_))) => {
9401223 let text = delta.get("text").and_then(Value::as_str).unwrap_or_default();
941- markdown.update(cx, |state, cx| state.push_str(text, cx));
1224+ self.append_text(item_ix, text, cx);
9421225 }
9431226 (Some("input_json_delta"), Some(ChatItem::Tool(tool))) => {
9441227 let json = delta.get("partial_json").and_then(Value::as_str);
@@ -949,6 +1232,9 @@ impl Session {
9491232 }
9501233 Some("content_block_stop") => {
9511234 self.thinking = false;
1235+ if let Some(ix) = self.streamed_item(index) {
1236+ self.flush_item(ix, cx);
1237+ }
9521238 if let Some(ChatItem::Tool(tool)) = self.streamed_item(index).and_then(|ix| self.items.get_mut(ix))
9531239 && let Ok(input) = serde_json::from_str::<Value>(&tool.partial_input)
9541240 {
@@ -1051,6 +1337,35 @@ impl Session {
10511337 }
10521338 }
10531339
1340+ /// Notes when a usage limit that refused this thread resets.
1341+ fn handle_rate_limit(&mut self, info: &Value) {
1342+ match info.get("status").and_then(Value::as_str) {
1343+ Some("rejected") => {
1344+ self.limit_resets = info.get("resetsAt").and_then(Value::as_f64).map(|resets| {
1345+ // Seconds since the epoch; milliseconds just in case.
1346+ let secs = if resets > 1e12 { resets / 1000. } else { resets };
1347+ UNIX_EPOCH + Duration::from_secs_f64(secs.max(0.))
1348+ });
1349+ }
1350+ Some("allowed") => self.limit_resets = None,
1351+ _ => {}
1352+ }
1353+ }
1354+
1355+ /// After a turn a usage limit stopped: continue or snooze the thread
1356+ /// once the limit resets, as the settings say.
1357+ fn schedule_after_limit(&mut self) {
1358+ let Some(resets) = self.limited_until() else { return };
1359+ let (resume, snooze) = settings::get(|settings| (settings.auto_resume_limited, settings.snooze_limited));
1360+ if resume {
1361+ // A minute's grace, so the limit has surely lifted.
1362+ self.resume_at = Some(resets + Duration::from_secs(60));
1363+ }
1364+ if snooze {
1365+ self.snoozed_until = Some(resets);
1366+ }
1367+ }
1368+
10541369 fn handle_result(&mut self, message: &Value, cx: &mut Context<Self>) {
10551370 self.thinking = false;
10561371 self.streaming = None;
@@ -1096,6 +1411,8 @@ impl Session {
10961411 }
10971412 }
10981413 self.finish_turn(duration_ms, cost, cx);
1414+ self.schedule_after_limit();
1415+ self.send_next_queued(cx);
10991416 }
11001417
11011418 fn handle_control_request(&mut self, message: &Value) {
@@ -1127,7 +1444,7 @@ impl Session {
11271444 .unwrap_or_default(),
11281445 state: PermissionState::Pending,
11291446 };
1130- self.updated = Instant::now();
1447+ self.updated = SystemTime::now();
11311448 let tool_use_id = request.get("tool_use_id").and_then(Value::as_str).unwrap_or_default();
11321449 if let Some(tool) = self.find_tool_mut(tool_use_id) {
11331450 tool.input = prompt.input.clone();
@@ -1160,7 +1477,7 @@ impl Session {
11601477
11611478 if let PendingControl::Initialize = pending {
11621479 let info = &response["response"];
1163- Catalog::set_claude_models(info, cx);
1480+ Catalog::set_claude_info(&self.instance, info, cx);
11641481 if let Some(mode) = info
11651482 .get("current_permission_mode")
11661483 .and_then(Value::as_str)
@@ -1241,6 +1558,7 @@ impl Session {
12411558 let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64);
12421559 let cost = Some(std::mem::take(&mut self.turn_cost)).filter(|cost| *cost > 0.);
12431560 self.finish_turn(duration_ms, cost, cx);
1561+ self.send_next_queued(cx);
12441562 }
12451563 Some("auto_retry_start") => {
12461564 let attempt = message.get("attempt").and_then(Value::as_u64).unwrap_or(1);
@@ -1347,8 +1665,11 @@ impl Session {
13471665 ix
13481666 }
13491667 };
1350- if let Some(ChatItem::Assistant(markdown)) = self.items.get(ix) {
1351- markdown.update(cx, |state, cx| state.push_str(delta, cx));
1668+ self.append_text(ix, delta, cx);
1669+ }
1670+ Some("text_end") => {
1671+ if let Some(ix) = self.streamed_item(index) {
1672+ self.flush_item(ix, cx);
13521673 }
13531674 }
13541675 Some("thinking_start" | "thinking_delta") => self.thinking = true,
@@ -1436,17 +1757,41 @@ impl Session {
14361757 }
14371758 _ => {}
14381759 }
1439- self.updated = Instant::now();
1760+ self.updated = SystemTime::now();
14401761 }
14411762
14421763 // ---- helpers -----------------------------------------------------
14431764
14441765 fn push_assistant_text(&mut self, text: &str, cx: &mut Context<Self>) -> usize {
1445- let markdown = cx.new(|cx| TextViewState::markdown(text, cx).selectable(true));
1446- self.items.push(ChatItem::Assistant(markdown));
1766+ let view = cx.new(|cx| TextViewState::markdown(text, cx).selectable(true));
1767+ self.items.push(ChatItem::Assistant(AssistantText { text: text.to_string(), view, shown: text.len() }));
14471768 self.items.len() - 1
14481769 }
14491770
1771+ /// Adds streamed text to an answer. With "Show finished paragraphs" on,
1772+ /// the view only takes whole paragraphs and code blocks.
1773+ fn append_text(&mut self, ix: usize, delta: &str, cx: &mut Context<Self>) {
1774+ let paragraphs = settings::get(|settings| settings.streaming == Streaming::Paragraphs);
1775+ let Some(ChatItem::Assistant(answer)) = self.items.get_mut(ix) else { return };
1776+ answer.text.push_str(delta);
1777+ let target = if paragraphs { finished_prefix(&answer.text) } else { answer.text.len() };
1778+ answer.show(target, cx);
1779+ }
1780+
1781+ /// Shows the rest of an answer's text.
1782+ fn flush_item(&mut self, ix: usize, cx: &mut Context<Self>) {
1783+ if let Some(ChatItem::Assistant(answer)) = self.items.get_mut(ix) {
1784+ let end = answer.text.len();
1785+ answer.show(end, cx);
1786+ }
1787+ }
1788+
1789+ fn flush_text(&mut self, cx: &mut Context<Self>) {
1790+ for ix in 0..self.items.len() {
1791+ self.flush_item(ix, cx);
1792+ }
1793+ }
1794+
14501795 fn push_tool(&mut self, id: &str, name: &str, input: Value) -> usize {
14511796 self.items.push(ChatItem::Tool(ToolCall {
14521797 id: id.to_string(),
@@ -1520,6 +1865,250 @@ impl Session {
15201865 }
15211866 }
15221867
1868+ // ---- saving and restoring ----------------------------------------------
1869+
1870+ /// A thread as it's kept on disk between runs of the app.
1871+ #[derive(Serialize, Deserialize)]
1872+ pub struct SavedThread {
1873+ pub id: String,
1874+ pub instance: String,
1875+ pub provider: Provider,
1876+ pub title: String,
1877+ #[serde(default)]
1878+ pub title_custom: bool,
1879+ pub project: PathBuf,
1880+ pub cwd: PathBuf,
1881+ #[serde(default)]
1882+ pub worktree: Option<Worktree>,
1883+ #[serde(default)]
1884+ pub pending_worktree: Option<WorktreePlan>,
1885+ #[serde(default)]
1886+ pub pull_request: Option<PullRequest>,
1887+ pub resume_id: Option<String>,
1888+ pub model: Option<String>,
1889+ pub active_model: Option<String>,
1890+ pub permission_mode: String,
1891+ pub reasoning: String,
1892+ pub fast_mode: bool,
1893+ pub total_cost: f64,
1894+ pub context_tokens: Option<u64>,
1895+ pub context_window: Option<u64>,
1896+ /// Milliseconds since the epoch, like every time below.
1897+ pub created: u64,
1898+ pub updated: u64,
1899+ pub last_message: u64,
1900+ pub settled: bool,
1901+ pub pinned: bool,
1902+ pub snoozed_until: Option<u64>,
1903+ pub limit_resets: Option<u64>,
1904+ pub resume_at: Option<u64>,
1905+ pub queue: Vec<QueuedMessage>,
1906+ /// The agent was mid-turn when the thread was saved.
1907+ pub working: bool,
1908+ pub items: Vec<SavedItem>,
1909+ /// What each finished turn changed, by its `TurnEnd` index.
1910+ pub turn_changes: Vec<(usize, TurnChanges)>,
1911+ }
1912+
1913+ #[derive(Serialize, Deserialize)]
1914+ #[serde(tag = "type", rename_all = "snake_case")]
1915+ pub enum SavedItem {
1916+ User { text: String, attachments: Vec<String>, at: u64 },
1917+ Assistant { text: String },
1918+ Tool {
1919+ id: String,
1920+ name: String,
1921+ input: Value,
1922+ result: Option<String>,
1923+ /// `done` or `error`; a tool still running when saved counts as failed.
1924+ state: String,
1925+ /// `allowed`, `denied` or `cancelled`, if it asked for permission.
1926+ permission: Option<String>,
1927+ subagent_tool_count: usize,
1928+ },
1929+ Notice { error: bool, text: String },
1930+ Question { title: String, message: Option<String>, options: Vec<String>, answer: Option<String> },
1931+ TurnEnd { duration_ms: u64, cost: Option<f64>, at: u64 },
1932+ }
1933+
1934+ pub fn to_millis(time: SystemTime) -> u64 {
1935+ time.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64
1936+ }
1937+
1938+ pub fn from_millis(millis: u64) -> SystemTime {
1939+ UNIX_EPOCH + Duration::from_millis(millis)
1940+ }
1941+
1942+ impl Session {
1943+ pub fn save(&self) -> SavedThread {
1944+ let items = self
1945+ .items
1946+ .iter()
1947+ .map(|item| match item {
1948+ ChatItem::User { text, attachments, at } => SavedItem::User {
1949+ text: text.to_string(),
1950+ attachments: attachments.iter().map(ToString::to_string).collect(),
1951+ at: to_millis(*at),
1952+ },
1953+ ChatItem::Assistant(answer) => SavedItem::Assistant { text: answer.text.clone() },
1954+ ChatItem::Tool(tool) => SavedItem::Tool {
1955+ id: tool.id.clone(),
1956+ name: tool.name.clone(),
1957+ input: tool.input.clone(),
1958+ result: tool.result.clone(),
1959+ state: if tool.state == ToolState::Done { "done" } else { "error" }.into(),
1960+ permission: tool.permission.as_ref().map(|prompt| {
1961+ match prompt.state {
1962+ PermissionState::Allowed => "allowed",
1963+ PermissionState::Denied => "denied",
1964+ PermissionState::Pending | PermissionState::Cancelled => "cancelled",
1965+ }
1966+ .into()
1967+ }),
1968+ subagent_tool_count: tool.subagent_tool_count,
1969+ },
1970+ ChatItem::Notice(kind, text) => {
1971+ SavedItem::Notice { error: *kind == NoticeKind::Error, text: text.to_string() }
1972+ }
1973+ ChatItem::Question(question) => SavedItem::Question {
1974+ title: question.title.to_string(),
1975+ message: question.message.as_ref().map(ToString::to_string),
1976+ options: question.options.iter().map(ToString::to_string).collect(),
1977+ answer: question.answer.as_ref().map(ToString::to_string),
1978+ },
1979+ ChatItem::TurnEnd { duration_ms, cost, at } => {
1980+ SavedItem::TurnEnd { duration_ms: *duration_ms, cost: *cost, at: to_millis(*at) }
1981+ }
1982+ })
1983+ .collect();
1984+ SavedThread {
1985+ id: self.id.clone(),
1986+ instance: self.instance.clone(),
1987+ provider: self.provider,
1988+ title: self.title.to_string(),
1989+ title_custom: self.title_custom,
1990+ project: self.project.clone(),
1991+ cwd: self.cwd.clone(),
1992+ worktree: self.worktree.clone(),
1993+ pending_worktree: self.pending_worktree.clone(),
1994+ pull_request: self.pull_request.clone(),
1995+ resume_id: self.resume_id.clone(),
1996+ model: self.model.clone(),
1997+ active_model: self.active_model.clone(),
1998+ permission_mode: self.permission_mode.as_cli().into(),
1999+ reasoning: self.reasoning.key().into(),
2000+ fast_mode: self.fast_mode,
2001+ total_cost: self.total_cost,
2002+ context_tokens: self.context_tokens,
2003+ context_window: self.context_window,
2004+ created: to_millis(self.created),
2005+ updated: to_millis(self.updated),
2006+ last_message: to_millis(self.last_message),
2007+ settled: self.settled,
2008+ pinned: self.pinned,
2009+ snoozed_until: self.snoozed_until.map(to_millis),
2010+ limit_resets: self.limit_resets.map(to_millis),
2011+ resume_at: self.resume_at.map(to_millis),
2012+ queue: self.queue.clone(),
2013+ working: self.is_working(),
2014+ items,
2015+ turn_changes: self.turn_changes.iter().map(|(end, changes)| (*end, changes.clone())).collect(),
2016+ }
2017+ }
2018+
2019+ /// Brings a saved thread back. Its agent starts again, resuming the
2020+ /// conversation, with the next message.
2021+ pub fn restore(saved: SavedThread, cx: &mut Context<Self>) -> Self {
2022+ let new = NewSession {
2023+ instance: saved.instance,
2024+ provider: saved.provider,
2025+ project: saved.project,
2026+ model: saved.model,
2027+ permission_mode: PermissionMode::from_cli(&saved.permission_mode).unwrap_or_default(),
2028+ reasoning: Reasoning::from_key(&saved.reasoning).unwrap_or_default(),
2029+ fast_mode: saved.fast_mode,
2030+ worktree: saved.pending_worktree,
2031+ };
2032+ let mut session = Self::empty(saved.id, new, from_millis(saved.created));
2033+ session.status = SessionStatus::Idle;
2034+ session.title = saved.title.into();
2035+ session.title_custom = saved.title_custom;
2036+ session.cwd = saved.cwd;
2037+ session.worktree = saved.worktree;
2038+ session.pull_request = saved.pull_request;
2039+ session.resume_id = saved.resume_id;
2040+ session.active_model = saved.active_model;
2041+ session.total_cost = saved.total_cost;
2042+ session.context_tokens = saved.context_tokens;
2043+ session.context_window = saved.context_window;
2044+ session.updated = from_millis(saved.updated);
2045+ session.last_message = from_millis(saved.last_message);
2046+ session.settled = saved.settled;
2047+ session.pinned = saved.pinned;
2048+ session.snoozed_until = saved.snoozed_until.map(from_millis);
2049+ session.limit_resets = saved.limit_resets.map(from_millis);
2050+ session.resume_at = saved.resume_at.map(from_millis);
2051+ session.queue = saved.queue;
2052+ session.interrupted = saved.working;
2053+ session.turn_changes = saved.turn_changes.into_iter().collect();
2054+ for item in saved.items {
2055+ let item = match item {
2056+ SavedItem::User { text, attachments, at } => ChatItem::User {
2057+ text: text.into(),
2058+ attachments: attachments.into_iter().map(Into::into).collect(),
2059+ at: from_millis(at),
2060+ },
2061+ SavedItem::Assistant { text } => {
2062+ session.push_assistant_text(&text, cx);
2063+ continue;
2064+ }
2065+ SavedItem::Tool { id, name, input, result, state, permission, subagent_tool_count } => {
2066+ ChatItem::Tool(ToolCall {
2067+ id,
2068+ name,
2069+ input,
2070+ partial_input: String::new(),
2071+ result,
2072+ state: if state == "done" { ToolState::Done } else { ToolState::Error },
2073+ permission: permission.map(|state| PermissionPrompt {
2074+ request_id: String::new(),
2075+ input: Value::Null,
2076+ suggestions: Vec::new(),
2077+ state: match state.as_str() {
2078+ "allowed" => PermissionState::Allowed,
2079+ "denied" => PermissionState::Denied,
2080+ _ => PermissionState::Cancelled,
2081+ },
2082+ }),
2083+ expanded: false,
2084+ subagent_tool_count,
2085+ detached: false,
2086+ })
2087+ }
2088+ SavedItem::Notice { error, text } => {
2089+ ChatItem::Notice(if error { NoticeKind::Error } else { NoticeKind::Info }, text.into())
2090+ }
2091+ SavedItem::Question { title, message, options, answer } => ChatItem::Question(Question {
2092+ request_id: String::new(),
2093+ title: title.into(),
2094+ message: message.map(Into::into),
2095+ options: options.into_iter().map(Into::into).collect(),
2096+ confirm: false,
2097+ answer: Some(answer.unwrap_or_else(|| "No answer".into()).into()),
2098+ }),
2099+ SavedItem::TurnEnd { duration_ms, cost, at } => {
2100+ ChatItem::TurnEnd { duration_ms, cost, at: from_millis(at) }
2101+ }
2102+ };
2103+ session.items.push(item);
2104+ }
2105+ if session.interrupted {
2106+ session.push_notice(NoticeKind::Info, "The app stopped while this thread was working.");
2107+ }
2108+ session
2109+ }
2110+ }
2111+
15232112 impl Drop for Session {
15242113 fn drop(&mut self) {
15252114 let _ = std::fs::remove_file(&self.snapshot_index);
@@ -1546,6 +2135,47 @@ fn read_image(path: &Path) -> Result<String, String> {
15462135 Ok(BASE64.encode(bytes))
15472136 }
15482137
2138+ impl AssistantText {
2139+ /// Moves the view up to `end` bytes of the text.
2140+ fn show(&mut self, end: usize, cx: &mut App) {
2141+ if end > self.shown && self.text.is_char_boundary(end) {
2142+ let new = self.text[self.shown..end].to_string();
2143+ self.view.update(cx, |state, cx| state.push_str(&new, cx));
2144+ self.shown = end;
2145+ }
2146+ }
2147+ }
2148+
2149+ /// How much of streamed Markdown is finished: up to the last blank line
2150+ /// outside a code block, or the end of the last closed code block.
2151+ fn finished_prefix(text: &str) -> usize {
2152+ let mut fence: Option<&str> = None;
2153+ let mut finished = 0;
2154+ let mut offset = 0;
2155+ for line in text.split_inclusive('\n') {
2156+ offset += line.len();
2157+ if !line.ends_with('\n') {
2158+ break;
2159+ }
2160+ let trimmed = line.trim();
2161+ let marker = ["```", "~~~"].into_iter().find(|marker| trimmed.starts_with(marker));
2162+ match (fence, marker) {
2163+ (None, Some(marker)) => fence = Some(marker),
2164+ (Some(open), Some(marker)) if open == marker && trimmed.trim_start_matches(marker).is_empty() => {
2165+ fence = None;
2166+ finished = offset;
2167+ }
2168+ (None, None) if trimmed.is_empty() => finished = offset,
2169+ _ => {}
2170+ }
2171+ }
2172+ finished
2173+ }
2174+
2175+ fn file_name(path: &Path) -> String {
2176+ path.file_name().map(|name| name.to_string_lossy().to_string()).unwrap_or_else(|| path.display().to_string())
2177+ }
2178+
15492179 fn title_from(text: &str) -> String {
15502180 let line = text.lines().find(|line| !line.trim().is_empty()).unwrap_or(text).trim();
15512181 let mut title: String = line.chars().take(48).collect();
@@ -1617,3 +2247,19 @@ pub fn tool_summary(name: &str, input: &Value, cwd: &std::path::Path) -> String
16172247 }
16182248 line
16192249 }
2250+
2251+ #[cfg(test)]
2252+ mod tests {
2253+ use super::finished_prefix;
2254+
2255+ #[::core::prelude::v1::test]
2256+ fn finished_paragraphs() {
2257+ assert_eq!(finished_prefix("Half a sent"), 0);
2258+ assert_eq!(finished_prefix("One.\n\nTwo is still"), "One.\n\n".len());
2259+ let open_fence = "Intro.\n\n```rust\nfn a() {}\n\nfn b";
2260+ assert_eq!(finished_prefix(open_fence), "Intro.\n\n".len());
2261+ let closed = "```rust\nfn a() {}\n```\nAfter";
2262+ assert_eq!(finished_prefix(closed), "```rust\nfn a() {}\n```\n".len());
2263+ assert_eq!(finished_prefix("Ünïcödé.\n\nMore"), "Ünïcödé.\n\n".len());
2264+ }
2265+ }
+329−14src/settings.rsView File
@@ -6,6 +6,160 @@ use std::{path::PathBuf, sync::Mutex};
66
77 use serde::{Deserialize, Serialize};
88
9+ use crate::models::Provider;
10+
11+ /// Where a new thread starts: in the project folder, or in a git worktree of
12+ /// its own on a fresh branch.
13+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
14+ #[serde(rename_all = "snake_case")]
15+ pub enum WorkspaceMode {
16+ #[default]
17+ Local,
18+ Worktree,
19+ }
20+
21+ /// How a new worktree fills in git submodules.
22+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
23+ #[serde(rename_all = "snake_case")]
24+ pub enum Submodules {
25+ Skip,
26+ TopLevel,
27+ #[default]
28+ Recursive,
29+ }
30+
31+ /// What Ctrl+Q takes.
32+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33+ #[serde(rename_all = "snake_case")]
34+ pub enum QuitShortcut {
35+ /// One press quits.
36+ Press,
37+ /// Holding the keys, or pressing them twice quickly, quits.
38+ #[default]
39+ Hold,
40+ }
41+
42+ /// How projects are ordered in the sidebar and the project menus.
43+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44+ #[serde(rename_all = "snake_case")]
45+ pub enum ProjectOrder {
46+ /// The project with the most recent message first.
47+ #[default]
48+ LastMessage,
49+ Name,
50+ Added,
51+ }
52+
53+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
54+ #[serde(rename_all = "snake_case")]
55+ pub enum TimeFormat {
56+ /// Whatever the locale prefers.
57+ #[default]
58+ System,
59+ Hour12,
60+ Hour24,
61+ }
62+
63+ /// How an answer appears while the agent writes it.
64+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
65+ #[serde(rename_all = "snake_case")]
66+ pub enum Streaming {
67+ #[default]
68+ Tokens,
69+ /// Each paragraph or code block once it's complete.
70+ Paragraphs,
71+ }
72+
73+ /// What a message sent while the agent works does.
74+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
75+ #[serde(rename_all = "snake_case")]
76+ pub enum FollowUp {
77+ /// Hand it to the agent at once, so it can change course mid-turn.
78+ #[default]
79+ Steer,
80+ /// Hold it until the turn ends.
81+ Queue,
82+ }
83+
84+ /// How eagerly the app checks on things in the background.
85+ #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
86+ #[serde(rename_all = "snake_case")]
87+ pub enum BackgroundActivity {
88+ Always,
89+ /// Pauses while the window has been in the background for a while, or
90+ /// the machine saves power.
91+ #[default]
92+ Balanced,
93+ /// Pauses whenever the window is in the background.
94+ Minimal,
95+ }
96+
97+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
98+ #[serde(default)]
99+ pub struct EnvVar {
100+ pub key: String,
101+ pub value: String,
102+ }
103+
104+ /// One configured agent: Claude Code or Pi, with its own binary, home
105+ /// folder, arguments and environment. There can be several of a kind, say
106+ /// two Claude accounts.
107+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
108+ #[serde(default)]
109+ pub struct ProviderInstance {
110+ /// Stable id, used in model keys (`claude:opus`). The first instance of a
111+ /// kind is named after it.
112+ pub id: String,
113+ pub kind: Provider,
114+ /// Empty shows the kind's name.
115+ pub name: String,
116+ pub enabled: bool,
117+ /// Empty looks the binary up on PATH.
118+ pub binary: String,
119+ /// `CLAUDE_CONFIG_DIR` or `PI_CODING_AGENT_DIR`; empty is the default.
120+ pub home: String,
121+ /// Extra arguments, split like a shell would.
122+ pub args: String,
123+ pub env: Vec<EnvVar>,
124+ }
125+
126+ impl Default for ProviderInstance {
127+ fn default() -> Self {
128+ Self::new(Provider::Claude, "claude")
129+ }
130+ }
131+
132+ impl ProviderInstance {
133+ pub fn new(kind: Provider, id: &str) -> Self {
134+ Self {
135+ id: id.to_string(),
136+ kind,
137+ name: String::new(),
138+ enabled: true,
139+ binary: String::new(),
140+ home: String::new(),
141+ args: String::new(),
142+ env: Vec::new(),
143+ }
144+ }
145+
146+ pub fn label(&self) -> String {
147+ if self.name.trim().is_empty() { self.kind.label().to_string() } else { self.name.trim().to_string() }
148+ }
149+ }
150+
151+ /// A usage hub the Usage page also reads, such as a CLIProxyAPI server.
152+ #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
153+ #[serde(default)]
154+ pub struct UsageHub {
155+ pub name: String,
156+ /// `cliproxy`.
157+ pub kind: String,
158+ pub url: String,
159+ /// The hub's management key.
160+ pub key: String,
161+ }
162+
9163 #[derive(Clone, Debug, Serialize, Deserialize)]
10164 #[serde(default)]
11165 pub struct Settings {
@@ -14,6 +168,8 @@ pub struct Settings {
14168 /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the
15169 /// order they were starred.
16170 pub favorites: Vec<String>,
171+
172+ // ---- new threads ----
17173 /// The model key new threads start on. `None` follows the open thread.
18174 pub default_model: Option<String>,
19175 /// `default`, an effort level (`high`), `ultracode` or `ultrathink`.
@@ -21,23 +177,96 @@ pub struct Settings {
21177 pub default_reasoning: Option<String>,
22178 /// A permission mode as the CLI spells it. `None` follows the open thread.
23179 pub default_access: Option<String>,
24- /// The editor "Open in" uses, by name (`Zed`).
25- pub editor: Option<String>,
180+ pub workspace: WorkspaceMode,
181+ pub submodules: Submodules,
182+ /// Base new worktrees on the branch as origin has it.
183+ pub start_from_origin: bool,
184+ /// Where the Add project browser opens; empty is the home folder.
185+ pub add_project_dir: String,
186+
187+ // ---- text generation ----
188+ /// The model key thread titles are written with; empty keeps the first
189+ /// line of the first message.
190+ pub text_model: String,
191+ /// The reasoning level for generated text, as [`Settings::default_reasoning`].
192+ pub text_reasoning: Option<String>,
193+
194+ // ---- conversation ----
26195 /// Fold a finished turn's tool calls into one "Worked for" row.
27196 pub fold_turns: bool,
28197 /// Show what threads and turns cost.
29198 pub show_costs: bool,
30- /// One-line thread cards in the sidebar.
31- pub compact_threads: bool,
32199 /// Send with Ctrl+Enter, so Enter starts a new line.
33200 pub send_with_ctrl_enter: bool,
201+ pub follow_up: FollowUp,
202+ pub streaming: Streaming,
203+ /// List skills in the `/` menu too; `$` always lists them.
204+ pub slash_skills: bool,
205+ /// Highlight Markdown in the composer as you type.
206+ pub rich_composer: bool,
207+ /// Rest the composer to one line while scrolling a conversation.
208+ pub collapse_composer: bool,
209+ pub time_format: TimeFormat,
210+ /// Open the diff after a turn that changed enough.
211+ pub proactive_panels: bool,
212+ /// Ignore whitespace-only edits in diffs.
213+ pub hide_whitespace: bool,
214+ /// Open diffs with every file folded.
215+ pub diff_collapsed: bool,
216+ pub diff_side_by_side: bool,
217+
218+ // ---- sidebar ----
219+ /// One-line thread cards in the sidebar.
220+ pub compact_threads: bool,
221+ /// Group sidebar threads under their project.
222+ pub group_by_project: bool,
223+ pub project_order: ProjectOrder,
224+ /// Fold working threads into their own section until they need you.
225+ pub working_section: bool,
226+ /// Continue a thread stopped by a usage limit once the limit resets.
227+ pub auto_resume_limited: bool,
228+ /// Snooze a thread stopped by a usage limit until the limit resets.
229+ pub snooze_limited: bool,
230+ /// Settle a worktree thread once its pull request merges.
231+ pub auto_settle_merged: bool,
232+ pub auto_settle_inactive: bool,
233+ pub inactive_days: u32,
234+
235+ // ---- confirmations ----
236+ pub confirm_unpin: bool,
237+ /// Settling from a thread card takes a second click.
238+ pub confirm_settle: bool,
239+ pub confirm_delete: bool,
240+ pub quit_shortcut: QuitShortcut,
241+
242+ // ---- notifications and background work ----
34243 /// A desktop notification when a thread finishes or needs you while the
35244 /// window is in the background.
36245 pub notify: bool,
37- /// Overrides the `claude` binary lookup (`VECTRUM_CODE_CLAUDE_BIN` still wins).
38- pub claude_bin: Option<String>,
39- /// Overrides the `pi` binary lookup (`VECTRUM_CODE_PI_BIN` still wins).
40- pub pi_bin: Option<String>,
246+ /// Play a sound with each desktop notification.
247+ pub notify_sound: bool,
248+ /// A toast when another thread finishes or needs you while the window
249+ /// has focus.
250+ pub in_app_notifications: bool,
251+ /// Resume threads a restart or crash interrupted.
252+ pub continue_after_restart: bool,
253+ /// Check installed agents for newer versions.
254+ pub update_checks: bool,
255+ pub background_activity: BackgroundActivity,
256+ /// Seconds between provider health checks; 0 turns them off.
257+ pub health_check_secs: u64,
258+
259+ // ---- integrations ----
260+ /// The editor "Open in" uses, by name (`Zed`).
261+ pub editor: Option<String>,
262+ pub providers: Vec<ProviderInstance>,
263+ pub usage_hubs: Vec<UsageHub>,
264+
265+ /// Superseded by `providers`; read once to carry an old path override over.
266+ #[serde(skip_serializing)]
267+ claude_bin: Option<String>,
268+ #[serde(skip_serializing)]
269+ pi_bin: Option<String>,
41270 }
42271
43272 impl Default for Settings {
@@ -48,18 +277,72 @@ impl Default for Settings {
48277 default_model: None,
49278 default_reasoning: None,
50279 default_access: None,
51- editor: None,
280+ workspace: WorkspaceMode::Local,
281+ submodules: Submodules::Recursive,
282+ start_from_origin: false,
283+ add_project_dir: String::new(),
284+ text_model: "claude:haiku".into(),
285+ text_reasoning: Some("low".into()),
52286 fold_turns: true,
53287 show_costs: true,
54- compact_threads: false,
55288 send_with_ctrl_enter: false,
289+ follow_up: FollowUp::Steer,
290+ streaming: Streaming::Tokens,
291+ slash_skills: true,
292+ rich_composer: true,
293+ collapse_composer: false,
294+ time_format: TimeFormat::System,
295+ proactive_panels: false,
296+ hide_whitespace: true,
297+ diff_collapsed: false,
298+ diff_side_by_side: false,
299+ compact_threads: false,
300+ group_by_project: false,
301+ project_order: ProjectOrder::LastMessage,
302+ working_section: false,
303+ auto_resume_limited: false,
304+ snooze_limited: false,
305+ auto_settle_merged: true,
306+ auto_settle_inactive: false,
307+ inactive_days: 3,
308+ confirm_unpin: false,
309+ confirm_settle: false,
310+ confirm_delete: true,
311+ quit_shortcut: QuitShortcut::Hold,
56312 notify: false,
313+ notify_sound: false,
314+ in_app_notifications: false,
315+ continue_after_restart: false,
316+ update_checks: false,
317+ background_activity: BackgroundActivity::Balanced,
318+ health_check_secs: 300,
319+ editor: None,
320+ providers: Vec::new(),
321+ usage_hubs: Vec::new(),
57322 claude_bin: None,
58323 pi_bin: None,
59324 }
60325 }
61326 }
62327
328+ impl Settings {
329+ /// Makes sure there's at least one instance of each kind, carrying old
330+ /// binary overrides over.
331+ fn normalize(&mut self) {
332+ for (kind, old_bin) in [(Provider::Claude, self.claude_bin.take()), (Provider::Pi, self.pi_bin.take())] {
333+ if !self.providers.iter().any(|instance| instance.kind == kind) {
334+ let mut instance = ProviderInstance::new(kind, kind.key());
335+ instance.binary = old_bin.unwrap_or_default();
336+ self.providers.push(instance);
337+ }
338+ }
339+ }
340+
341+ pub fn instance(&self, id: &str) -> Option<&ProviderInstance> {
342+ self.providers.iter().find(|instance| instance.id == id)
343+ }
344+ }
345+
63346 static CACHE: Mutex<Option<Settings>> = Mutex::new(None);
64347
65348 pub fn path() -> Option<PathBuf> {
@@ -70,10 +353,12 @@ pub fn path() -> Option<PathBuf> {
70353 }
71354
72355 fn read_file() -> Settings {
73- path()
356+ let mut settings: Settings = path()
74357 .and_then(|path| std::fs::read_to_string(path).ok())
75358 .and_then(|text| serde_json::from_str(&text).ok())
76- .unwrap_or_default()
359+ .unwrap_or_default();
360+ settings.normalize();
361+ settings
77362 }
78363
79364 /// Reads one value out of the settings.
@@ -86,12 +371,23 @@ pub fn load() -> Settings {
86371 get(Settings::clone)
87372 }
88373
374+ /// The configured agents, enabled or not.
375+ pub fn instances() -> Vec<ProviderInstance> {
376+ get(|settings| settings.providers.clone())
377+ }
378+
379+ /// One agent's configuration, falling back to a default for its kind.
380+ pub fn instance(id: &str, kind: Provider) -> ProviderInstance {
381+ get(|settings| settings.instance(id).cloned()).unwrap_or_else(|| ProviderInstance::new(kind, id))
382+ }
383+
89384 /// Applies `edit` to the settings and writes them back.
90385 pub fn update(edit: impl FnOnce(&mut Settings)) {
91386 let settings = {
92387 let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
93388 let settings = cache.get_or_insert_with(read_file);
94389 edit(settings);
390+ settings.normalize();
95391 settings.clone()
96392 };
97393 let Some(path) = path() else { return };
@@ -105,10 +401,29 @@ pub fn update(edit: impl FnOnce(&mut Settings)) {
105401 }
106402 }
107403
108- /// Puts everything but favorites back to its default.
404+ /// Puts everything but favorites, agents and usage hubs back to its default.
109405 pub fn restore_defaults() {
110406 update(|settings| {
111407 let favorites = std::mem::take(&mut settings.favorites);
112- *settings = Settings { favorites, ..Settings::default() };
408+ let providers = std::mem::take(&mut settings.providers);
409+ let usage_hubs = std::mem::take(&mut settings.usage_hubs);
410+ *settings = Settings { favorites, providers, usage_hubs, ..Settings::default() };
113411 });
114412 }
413+
414+ /// `~/bin/claude` → `/home/me/bin/claude`.
415+ pub fn expand_home(path: &str) -> PathBuf {
416+ match (path.strip_prefix("~/"), std::env::var_os("HOME")) {
417+ (Some(rest), Some(home)) => PathBuf::from(home).join(rest),
418+ _ if path == "~" => std::env::var_os("HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(path)),
419+ _ => PathBuf::from(path),
420+ }
421+ }
422+
423+ /// Where the app keeps its own data: threads, worktrees, scratch space.
424+ pub fn data_dir() -> Option<PathBuf> {
425+ let base = std::env::var_os("XDG_DATA_HOME")
426+ .map(PathBuf::from)
427+ .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".local/share")))?;
428+ Some(base.join("vectrum-code"))
429+ }
+100−0src/store.rsaddedView File
@@ -0,0 +1,100 @@
1+ //! Threads and projects kept between runs, under
2+ //! `$XDG_DATA_HOME/vectrum-code`: one JSON file per thread in `threads/`,
3+ //! and `state.json` for the project list and the open thread.
4+
5+ use std::path::{Path, PathBuf};
6+
7+ use serde::{Deserialize, Serialize};
8+
9+ use crate::session::SavedThread;
10+ use crate::settings;
11+
12+ #[derive(Clone, Debug, Default, Serialize, Deserialize)]
13+ #[serde(default)]
14+ pub struct SavedProject {
15+ pub path: PathBuf,
16+ pub name: String,
17+ /// Milliseconds since the epoch.
18+ pub added: u64,
19+ }
20+
21+ #[derive(Clone, Debug, Default, Serialize, Deserialize)]
22+ #[serde(default)]
23+ pub struct AppState {
24+ pub projects: Vec<SavedProject>,
25+ /// The thread that was open.
26+ pub active: Option<String>,
27+ }
28+
29+ fn threads_dir() -> Option<PathBuf> {
30+ Some(settings::data_dir()?.join("threads"))
31+ }
32+
33+ fn state_path() -> Option<PathBuf> {
34+ Some(settings::data_dir()?.join("state.json"))
35+ }
36+
37+ /// Writes through a temporary file, so a crash mid-write can't leave a
38+ /// half-written file behind.
39+ fn write_atomically(path: &Path, contents: &[u8]) -> std::io::Result<()> {
40+ if let Some(parent) = path.parent() {
41+ std::fs::create_dir_all(parent)?;
42+ }
43+ let temp = path.with_extension("json.tmp");
44+ std::fs::write(&temp, contents)?;
45+ std::fs::rename(temp, path)
46+ }
47+
48+ pub fn load_state() -> AppState {
49+ state_path()
50+ .and_then(|path| std::fs::read_to_string(path).ok())
51+ .and_then(|text| serde_json::from_str(&text).ok())
52+ .unwrap_or_default()
53+ }
54+
55+ pub fn save_state(state: &AppState) {
56+ let Some(path) = state_path() else { return };
57+ if let Err(err) = write_atomically(&path, serde_json::to_string_pretty(state).unwrap_or_default().as_bytes()) {
58+ eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
59+ }
60+ }
61+
62+ /// Every saved thread that still reads.
63+ pub fn load_threads() -> Vec<SavedThread> {
64+ let Some(dir) = threads_dir() else { return Vec::new() };
65+ let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
66+ entries
67+ .flatten()
68+ .map(|entry| entry.path())
69+ .filter(|path| path.extension().is_some_and(|extension| extension == "json"))
70+ .filter_map(|path| {
71+ let text = std::fs::read_to_string(&path).ok()?;
72+ match serde_json::from_str(&text) {
73+ Ok(thread) => Some(thread),
74+ Err(err) => {
75+ eprintln!("vectrum-code: skipping {}: {err}", path.display());
76+ None
77+ }
78+ }
79+ })
80+ .collect()
81+ }
82+
83+ pub fn save_thread(thread: &SavedThread) {
84+ let Some(dir) = threads_dir() else { return };
85+ let path = dir.join(format!("{}.json", thread.id));
86+ match serde_json::to_vec(thread) {
87+ Ok(bytes) => {
88+ if let Err(err) = write_atomically(&path, &bytes) {
89+ eprintln!("vectrum-code: couldn't save {}: {err}", path.display());
90+ }
91+ }
92+ Err(err) => eprintln!("vectrum-code: couldn't save thread {}: {err}", thread.id),
93+ }
94+ }
95+
96+ pub fn delete_thread(id: &str) {
97+ if let Some(dir) = threads_dir() {
98+ let _ = std::fs::remove_file(dir.join(format!("{id}.json")));
99+ }
100+ }
+92−0src/textgen.rsaddedView File
@@ -0,0 +1,92 @@
1+ //! Short generated text, such as thread titles, written by the model picked
2+ //! under Settings → General → Text generation. Each request is a one-off,
3+ //! tool-less agent run. Everything here blocks; call it from a background task.
4+
5+ use std::process::Stdio;
6+
7+ use crate::claude::{Effort, base_command};
8+ use crate::models::{Provider, split_key};
9+ use crate::session::Reasoning;
10+ use crate::settings;
11+
12+ const TITLE_PROMPT: &str = "You name coding-assistant conversations. Reply with only a short title (3 to 6 words, \
13+ sentence case, no quotes, no trailing period) for a conversation that starts with the user's message below.";
14+
15+ /// A title for a thread that starts with `message`, or `None` when text
16+ /// generation is off or fails.
17+ pub fn title(message: &str) -> Option<String> {
18+ let (key, reasoning) = settings::get(|settings| (settings.text_model.clone(), settings.text_reasoning.clone()));
19+ let (instance_id, model) = split_key(&key)?;
20+ let kind = settings::get(|settings| settings.instance(instance_id).map(|instance| instance.kind))
21+ .or_else(|| Provider::from_key(instance_id))?;
22+ let instance = settings::instance(instance_id, kind);
23+ if !instance.enabled {
24+ return None;
25+ }
26+ let effort = reasoning.as_deref().and_then(Reasoning::from_key).and_then(|reasoning| match reasoning {
27+ Reasoning::Level(effort) => Some(effort),
28+ _ => None,
29+ });
30+ let message: String = message.chars().take(2_000).collect();
31+ let prompt = format!("{TITLE_PROMPT}\n\n<message>\n{message}\n</message>");
32+
33+ let (_, mut command) = base_command(&instance).ok()?;
34+ match kind {
35+ Provider::Claude => {
36+ command.args(["-p", "--output-format", "text", "--no-session-persistence", "--tools", ""]);
37+ command.args(["--strict-mcp-config", "--model", model]);
38+ if let Some(effort) = effort.filter(|effort| *effort >= Effort::Low) {
39+ command.args(["--effort", effort.as_cli()]);
40+ }
41+ }
42+ Provider::Pi => {
43+ command.args(["-p", "--no-session"]);
44+ if let Some((provider, model)) = model.split_once('/') {
45+ command.args(["--provider", provider, "--model", model]);
46+ }
47+ if let Some(effort) = effort {
48+ command.args(["--thinking", effort.as_cli()]);
49+ }
50+ }
51+ }
52+ let output = command
53+ .arg(prompt)
54+ .current_dir(std::env::temp_dir())
55+ .stdin(Stdio::null())
56+ .stderr(Stdio::null())
57+ .output()
58+ .ok()?;
59+ if !output.status.success() {
60+ return None;
61+ }
62+ clean_title(&String::from_utf8_lossy(&output.stdout))
63+ }
64+
65+ /// The title out of a model's reply: its last non-empty line, without
66+ /// quotes, markup or a trailing period, and not too long.
67+ fn clean_title(reply: &str) -> Option<String> {
68+ let line = reply.lines().rev().map(str::trim).find(|line| !line.is_empty())?;
69+ let line = line.trim_start_matches(['#', '*', '-', ' ']).trim_start_matches("Title:").trim();
70+ let line = line.trim_matches(['"', '\'', '`', '*', '“', '”']).trim_end_matches('.').trim();
71+ if line.is_empty() {
72+ return None;
73+ }
74+ let mut title: String = line.chars().take(60).collect();
75+ if line.chars().count() > 60 {
76+ title.push('…');
77+ }
78+ Some(title)
79+ }
80+
81+ #[cfg(test)]
82+ mod tests {
83+ use super::clean_title;
84+
85+ #[::core::prelude::v1::test]
86+ fn cleans_titles() {
87+ assert_eq!(clean_title("\"Fix login redirect.\"\n").as_deref(), Some("Fix login redirect"));
88+ assert_eq!(clean_title("Title: Add dark mode").as_deref(), Some("Add dark mode"));
89+ assert_eq!(clean_title("**Refactor settings**").as_deref(), Some("Refactor settings"));
90+ assert_eq!(clean_title(" \n "), None);
91+ }
92+ }
+434−51src/ui/chat.rsView File

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

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

Large diffs are not rendered by default.

+1258−300src/ui/settings.rsView File

Large diffs are not rendered by default.

+1132−237src/ui/workspace.rsView File

Large diffs are not rendered by default.

+153−0src/worktree.rsaddedView File

Large diffs are not rendered by default.