Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 18 changed files with 5037 additions and 858 deletions.
| @@ -5745,6 +5745,7 @@ version = "0.23.45" | ||
| 5745 | 5745 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5746 | 5746 | checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" |
| 5747 | 5747 | dependencies = [ |
| 5748 | + "log", | |
| 5748 | 5749 | "once_cell", |
| 5749 | 5750 | "ring", |
| 5750 | 5751 | "rustls-pki-types", |
| @@ -7131,6 +7132,21 @@ version = "0.9.0" | ||
| 7131 | 7132 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 7132 | 7133 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" |
| 7133 | 7134 | |
| 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 | + | |
| 7134 | 7150 | [[package]] |
| 7135 | 7151 | name = "url" |
| 7136 | 7152 | version = "2.5.8" |
| @@ -7281,11 +7297,13 @@ version = "0.1.0" | ||
| 7281 | 7297 | dependencies = [ |
| 7282 | 7298 | "anyhow", |
| 7283 | 7299 | "base64 0.22.1", |
| 7300 | + "chrono", | |
| 7284 | 7301 | "gpui-kit", |
| 7285 | 7302 | "libc", |
| 7286 | 7303 | "serde", |
| 7287 | 7304 | "serde_json", |
| 7288 | 7305 | "smol", |
| 7306 | + "ureq", | |
| 7289 | 7307 | ] |
| 7290 | 7308 | |
| 7291 | 7309 | [[package]] |
| @@ -7554,6 +7572,24 @@ dependencies = [ | ||
| 7554 | 7572 | "wasm-bindgen", |
| 7555 | 7573 | ] |
| 7556 | 7574 | |
| 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 | + | |
| 7557 | 7593 | [[package]] |
| 7558 | 7594 | name = "weezl" |
| 7559 | 7595 | version = "0.1.12" |
| @@ -12,6 +12,8 @@ anyhow = "1" | ||
| 12 | 12 | libc = "0.2" |
| 13 | 13 | smol = "2" |
| 14 | 14 | base64 = "0.22" |
| 15 | + ureq = { version = "2", default-features = false, features = ["tls"] } | |
| 16 | + chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } | |
| 15 | 17 | |
| 16 | 18 | [profile.dev] |
| 17 | 19 | debug = "line-tables-only" |
| @@ -15,6 +15,7 @@ use anyhow::{Context as _, Result, anyhow}; | ||
| 15 | 15 | use serde_json::{Value, json}; |
| 16 | 16 | |
| 17 | 17 | use crate::models::Provider; |
| 18 | + use crate::settings::{ProviderInstance, expand_home}; | |
| 18 | 19 | |
| 19 | 20 | /// Environment variables that mark a process as running *inside* another |
| 20 | 21 | /// Claude Code session. They leak in when this app is launched from a Claude |
| @@ -89,7 +90,7 @@ impl PermissionMode { | ||
| 89 | 90 | |
| 90 | 91 | /// How hard the model thinks: Claude's `--effort` levels and Pi's thinking |
| 91 | 92 | /// levels, which add `off` and `minimal`. |
| 92 | - #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 93 | + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] | |
| 93 | 94 | pub enum Effort { |
| 94 | 95 | Off, |
| 95 | 96 | Minimal, |
| @@ -135,7 +136,8 @@ impl Effort { | ||
| 135 | 136 | |
| 136 | 137 | #[derive(Clone, Debug)] |
| 137 | 138 | pub struct LaunchConfig { |
| 138 | - pub provider: Provider, | |
| 139 | + /// Which agent, with its binary, home folder, arguments and environment. | |
| 140 | + pub instance: ProviderInstance, | |
| 139 | 141 | pub cwd: PathBuf, |
| 140 | 142 | /// A Claude model alias, or `provider/model` for Pi. `None` is the default. |
| 141 | 143 | pub model: Option<String>, |
| @@ -162,7 +164,7 @@ pub struct AgentProcess { | ||
| 162 | 164 | |
| 163 | 165 | impl AgentProcess { |
| 164 | 166 | 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 { | |
| 166 | 168 | Provider::Claude => claude_command(config)?, |
| 167 | 169 | Provider::Pi => pi_command(config)?, |
| 168 | 170 | }; |
| @@ -175,9 +177,6 @@ impl AgentProcess { | ||
| 175 | 177 | events: smol::channel::Sender<ProcessEvent>, |
| 176 | 178 | ) -> Result<Self> { |
| 177 | 179 | command.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()); |
| 178 | - for key in NESTED_SESSION_ENV { | |
| 179 | - command.env_remove(key); | |
| 180 | - } | |
| 181 | 180 | // Take the agent down with us if the app dies without cleaning up. |
| 182 | 181 | unsafe { |
| 183 | 182 | command.pre_exec(|| { |
| @@ -287,9 +286,7 @@ impl Drop for AgentProcess { | ||
| 287 | 286 | } |
| 288 | 287 | |
| 289 | 288 | 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)?; | |
| 293 | 290 | command |
| 294 | 291 | .args([ |
| 295 | 292 | "-p", |
| @@ -319,13 +316,12 @@ fn claude_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 319 | 316 | if let Some(session_id) = &config.resume { |
| 320 | 317 | command.args(["--resume", session_id]); |
| 321 | 318 | } |
| 319 | + command.args(split_args(&config.instance.args)); | |
| 322 | 320 | Ok((binary, command)) |
| 323 | 321 | } |
| 324 | 322 | |
| 325 | 323 | 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)?; | |
| 329 | 325 | command.args(["--mode", "rpc"]).current_dir(&config.cwd); |
| 330 | 326 | if let Some((provider, model)) = config.model.as_deref().and_then(|model| model.split_once('/')) { |
| 331 | 327 | command.args(["--provider", provider, "--model", model]); |
| @@ -336,9 +332,52 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 336 | 332 | if let Some(session) = &config.resume { |
| 337 | 333 | command.args(["--session", session]); |
| 338 | 334 | } |
| 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 | + } | |
| 339 | 376 | // Pi is a Node script; a desktop launcher's PATH may not reach the Node it |
| 340 | 377 | // 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 | + { | |
| 342 | 381 | let path = std::env::var_os("PATH").unwrap_or_default(); |
| 343 | 382 | let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path)); |
| 344 | 383 | if let Ok(joined) = std::env::join_paths(paths) { |
| @@ -348,61 +387,80 @@ fn pi_command(config: &LaunchConfig) -> Result<(PathBuf, Command)> { | ||
| 348 | 387 | Ok((binary, command)) |
| 349 | 388 | } |
| 350 | 389 | |
| 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 | + { | |
| 353 | 396 | return Some(PathBuf::from(path)); |
| 354 | 397 | } |
| 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())); | |
| 357 | 400 | } |
| 401 | + let name = instance.kind.key(); | |
| 358 | 402 | let on_path = std::env::var_os("PATH") |
| 359 | 403 | .into_iter() |
| 360 | 404 | .flat_map(|paths| std::env::split_paths(&paths).collect::<Vec<_>>()) |
| 361 | - .map(|dir| dir.join("claude")); | |
| 405 | + .map(|dir| dir.join(name)); | |
| 362 | 406 | // Desktop launchers often start apps without the user's shell PATH. |
| 363 | 407 | 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)]); | |
| 393 | 418 | // 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 | + { | |
| 395 | 422 | fallbacks.extend(versions.flatten().map(|version| version.path().join("bin/pi"))); |
| 396 | 423 | } |
| 397 | 424 | on_path.chain(fallbacks).find(|path| is_executable(path)) |
| 398 | 425 | } |
| 399 | 426 | |
| 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); | |
| 405 | 462 | } |
| 463 | + args | |
| 406 | 464 | } |
| 407 | 465 | |
| 408 | 466 | pub fn is_executable(path: &Path) -> bool { |
| @@ -411,3 +469,15 @@ pub fn is_executable(path: &Path) -> bool { | ||
| 411 | 469 | .map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0) |
| 412 | 470 | .unwrap_or(false) |
| 413 | 471 | } |
| 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 | + } | |
| @@ -0,0 +1,60 @@ | ||
| 1 | + //! Clock times as the user wants to read them: 12- or 24-hour, or whatever | |
| 2 | + //! the locale prefers. | |
| 3 | + | |
| 4 | + use std::time::SystemTime; | |
| 5 | + | |
| 6 | + use chrono::{DateTime, Datelike as _, Local}; | |
| 7 | + | |
| 8 | + use crate::settings::{self, TimeFormat}; | |
| 9 | + | |
| 10 | + /// Whether times show as `2:32 PM` rather than `14:32`. | |
| 11 | + pub fn uses_12_hours() -> bool { | |
| 12 | + match settings::get(|settings| settings.time_format) { | |
| 13 | + TimeFormat::Hour12 => true, | |
| 14 | + TimeFormat::Hour24 => false, | |
| 15 | + TimeFormat::System => locale_uses_12_hours(), | |
| 16 | + } | |
| 17 | + } | |
| 18 | + | |
| 19 | + /// The locales that write the time with AM and PM. | |
| 20 | + fn locale_uses_12_hours() -> bool { | |
| 21 | + let locale = ["LC_ALL", "LC_TIME", "LANG"] | |
| 22 | + .iter() | |
| 23 | + .find_map(|key| std::env::var(key).ok().filter(|value| !value.is_empty())) | |
| 24 | + .unwrap_or_default(); | |
| 25 | + ["en_US", "en_CA", "en_AU", "en_NZ", "en_PH", "en_IN", "es_MX", "es_US", "hi_IN", "ar_", "ko_KR", "fil_PH"] | |
| 26 | + .iter() | |
| 27 | + .any(|prefix| locale.starts_with(prefix)) | |
| 28 | + } | |
| 29 | + | |
| 30 | + /// `14:32` or `2:32 PM`. | |
| 31 | + pub fn clock(time: SystemTime) -> String { | |
| 32 | + let time: DateTime<Local> = time.into(); | |
| 33 | + if uses_12_hours() { time.format("%-I:%M %p").to_string() } else { time.format("%H:%M").to_string() } | |
| 34 | + } | |
| 35 | + | |
| 36 | + /// A moment near now, as briefly as it reads clearly: `14:32`, `tomorrow | |
| 37 | + /// 09:00`, `Fri 09:00`, `Oct 21, 09:00`. | |
| 38 | + pub fn when(time: SystemTime) -> String { | |
| 39 | + let local: DateTime<Local> = time.into(); | |
| 40 | + let today = Local::now().date_naive(); | |
| 41 | + let days = (local.date_naive() - today).num_days(); | |
| 42 | + let clock = clock(time); | |
| 43 | + match days { | |
| 44 | + 0 => clock, | |
| 45 | + 1 => format!("tomorrow {clock}"), | |
| 46 | + -1 => format!("yesterday {clock}"), | |
| 47 | + 2..=6 => format!("{} {clock}", local.format("%a")), | |
| 48 | + _ if local.year() == today.year() => format!("{}, {clock}", local.format("%b %-d")), | |
| 49 | + _ => format!("{}, {clock}", local.format("%b %-d %Y")), | |
| 50 | + } | |
| 51 | + } | |
| 52 | + | |
| 53 | + /// The next time the clock shows `hour`:00, `days` days from today. | |
| 54 | + pub fn at_hour(days: i64, hour: u32) -> SystemTime { | |
| 55 | + let date = Local::now().date_naive() + chrono::Duration::days(days); | |
| 56 | + date.and_hms_opt(hour, 0, 0) | |
| 57 | + .and_then(|naive| naive.and_local_timezone(Local).earliest()) | |
| 58 | + .map(SystemTime::from) | |
| 59 | + .unwrap_or_else(SystemTime::now) | |
| 60 | + } | |
| @@ -6,6 +6,8 @@ use std::{ | ||
| 6 | 6 | process::{Command, Stdio}, |
| 7 | 7 | }; |
| 8 | 8 | |
| 9 | + use serde::{Deserialize, Serialize}; | |
| 10 | + | |
| 9 | 11 | /// Runs git in `path`, returning stdout, or stderr as the error. |
| 10 | 12 | pub fn git(path: &Path, args: &[&str]) -> Result<String, String> { |
| 11 | 13 | run(Command::new("git").arg("-C").arg(path).args(args)) |
| @@ -93,7 +95,7 @@ pub fn snapshot(cwd: &Path, index: &Path) -> Option<String> { | ||
| 93 | 95 | Some(tree.trim().to_string()) |
| 94 | 96 | } |
| 95 | 97 | |
| 96 | - #[derive(Clone, Debug, PartialEq)] | |
| 98 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 97 | 99 | pub struct FileChange { |
| 98 | 100 | pub path: String, |
| 99 | 101 | /// `None` for binary files. |
| @@ -102,7 +104,7 @@ pub struct FileChange { | ||
| 102 | 104 | } |
| 103 | 105 | |
| 104 | 106 | /// The files one turn changed: the difference between two snapshots. |
| 105 | - #[derive(Clone, Debug, PartialEq)] | |
| 107 | + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 106 | 108 | pub struct TurnChanges { |
| 107 | 109 | pub base: String, |
| 108 | 110 | pub head: String, |
| @@ -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 | + } | |
| @@ -4,14 +4,24 @@ | ||
| 4 | 4 | extern crate gpui_kit as gpui; |
| 5 | 5 | |
| 6 | 6 | mod claude; |
| 7 | + mod clock; | |
| 7 | 8 | mod git; |
| 9 | + mod http; | |
| 8 | 10 | mod models; |
| 9 | 11 | mod session; |
| 10 | 12 | mod settings; |
| 13 | + mod store; | |
| 14 | + mod textgen; | |
| 11 | 15 | mod ui; |
| 16 | + mod worktree; | |
| 12 | 17 | |
| 13 | - use std::path::PathBuf; | |
| 18 | + use std::{ | |
| 19 | + path::PathBuf, | |
| 20 | + sync::Mutex, | |
| 21 | + time::{Duration, Instant}, | |
| 22 | + }; | |
| 14 | 23 | |
| 24 | + use gpui_kit::component::{WindowExt as _, notification::Notification}; | |
| 15 | 25 | use gpui_kit::*; |
| 16 | 26 | |
| 17 | 27 | use crate::ui::{icons::AppAssets, metal, workspace::Workspace}; |
| @@ -31,13 +41,15 @@ gpui_kit::actions!( | ||
| 31 | 41 | ] |
| 32 | 42 | ); |
| 33 | 43 | |
| 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 | + | |
| 34 | 48 | fn main() { |
| 35 | 49 | let project = std::env::args_os() |
| 36 | 50 | .nth(1) |
| 37 | 51 | .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)); | |
| 41 | 53 | |
| 42 | 54 | gpui_kit::application() |
| 43 | 55 | .with_assets(AppAssets) |
| @@ -58,7 +70,7 @@ fn main() { | ||
| 58 | 70 | KeyBinding::new("escape", Interrupt, Some("ChatView")), |
| 59 | 71 | KeyBinding::new("escape", CloseSettings, Some("SettingsView")), |
| 60 | 72 | ]); |
| 61 | - cx.on_action(|_: &Quit, cx| cx.quit()); | |
| 73 | + cx.on_action(quit); | |
| 62 | 74 | cx.on_window_closed(|cx, _| cx.quit()).detach(); |
| 63 | 75 | |
| 64 | 76 | let bounds = Bounds::centered(None, size(px(1280.), px(840.)), cx); |
| @@ -80,3 +92,24 @@ fn main() { | ||
| 80 | 92 | cx.activate(true); |
| 81 | 93 | }); |
| 82 | 94 | } |
| 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 | + } | |
| @@ -1,22 +1,28 @@ | ||
| 1 | - //! Every model the app can run, across providers, in one catalog the pickers | |
| 2 | - //! read: Claude's from the CLI's `initialize` reply, Pi's from asking `pi` for | |
| 3 | - //! the models its configured providers offer. Also the user's favorites. | |
| 1 | + //! Every agent the app can run and what it offers, in one catalog the | |
| 2 | + //! pickers and settings read. Each configured provider instance gets a | |
| 3 | + //! background health check: Claude's models, account, slash commands and | |
| 4 | + //! usage limits come from a short-lived `claude` asked over its control | |
| 5 | + //! channel, Pi's models from asking `pi` for what its providers offer. Also | |
| 6 | + //! the user's favorite models. | |
| 4 | 7 | |
| 5 | 8 | use std::{ |
| 6 | 9 | io::{BufRead, BufReader, Write}, |
| 7 | - process::{Command, Stdio}, | |
| 10 | + path::Path, | |
| 11 | + process::Stdio, | |
| 8 | 12 | sync::mpsc, |
| 9 | - time::Duration, | |
| 13 | + time::{Duration, Instant, SystemTime}, | |
| 10 | 14 | }; |
| 11 | 15 | |
| 12 | 16 | use gpui_kit::*; |
| 17 | + use serde::{Deserialize, Serialize}; | |
| 13 | 18 | use serde_json::{Value, json}; |
| 14 | 19 | |
| 15 | - use crate::claude::{Effort, find_pi_binary}; | |
| 16 | - use crate::settings; | |
| 20 | + use crate::claude::{Effort, base_command}; | |
| 21 | + use crate::settings::{self, ProviderInstance}; | |
| 17 | 22 | |
| 18 | - /// The agent behind a thread. | |
| 19 | - #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] | |
| 23 | + /// The kind of agent behind a thread. | |
| 24 | + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] | |
| 25 | + #[serde(rename_all = "lowercase")] | |
| 20 | 26 | pub enum Provider { |
| 21 | 27 | #[default] |
| 22 | 28 | Claude, |
| @@ -24,6 +30,8 @@ pub enum Provider { | ||
| 24 | 30 | } |
| 25 | 31 | |
| 26 | 32 | impl Provider { |
| 33 | + pub const ALL: [Provider; 2] = [Provider::Claude, Provider::Pi]; | |
| 34 | + | |
| 27 | 35 | pub fn label(self) -> &'static str { |
| 28 | 36 | match self { |
| 29 | 37 | Provider::Claude => "Claude", |
| @@ -39,13 +47,15 @@ impl Provider { | ||
| 39 | 47 | } |
| 40 | 48 | |
| 41 | 49 | pub fn from_key(key: &str) -> Option<Self> { |
| 42 | - [Provider::Claude, Provider::Pi].into_iter().find(|provider| provider.key() == key) | |
| 50 | + Self::ALL.into_iter().find(|provider| provider.key() == key) | |
| 43 | 51 | } |
| 44 | 52 | } |
| 45 | 53 | |
| 46 | 54 | #[derive(Clone, Debug, PartialEq)] |
| 47 | 55 | pub struct ModelEntry { |
| 48 | 56 | pub provider: Provider, |
| 57 | + /// The provider instance offering it (`claude`, `claude-2`, `pi`). | |
| 58 | + pub instance: String, | |
| 49 | 59 | /// What the agent is launched with: a Claude alias or model id, or Pi's |
| 50 | 60 | /// `provider/model`. |
| 51 | 61 | pub id: String, |
| @@ -63,64 +73,236 @@ pub struct ModelEntry { | ||
| 63 | 73 | } |
| 64 | 74 | |
| 65 | 75 | impl ModelEntry { |
| 66 | - /// The key favorites are stored under. | |
| 76 | + /// The key favorites and defaults are stored under. | |
| 67 | 77 | pub fn key(&self) -> String { |
| 68 | - format!("{}:{}", self.provider.key(), self.id) | |
| 78 | + format!("{}:{}", self.instance, self.id) | |
| 69 | 79 | } |
| 70 | 80 | } |
| 71 | 81 | |
| 82 | + /// A `/` command or skill an agent offers. | |
| 83 | + #[derive(Clone, Debug, PartialEq)] | |
| 84 | + pub struct SlashCommand { | |
| 85 | + pub name: String, | |
| 86 | + pub description: String, | |
| 87 | + pub argument_hint: String, | |
| 88 | + /// Installed skills, as opposed to the agent's own commands. | |
| 89 | + pub skill: bool, | |
| 90 | + } | |
| 91 | + | |
| 72 | 92 | #[derive(Clone, Debug, PartialEq)] |
| 73 | - pub enum PiStatus { | |
| 74 | - Loading, | |
| 93 | + pub enum ProbeStatus { | |
| 94 | + /// Turned off in the settings. | |
| 95 | + Disabled, | |
| 96 | + Checking, | |
| 75 | 97 | Ready, |
| 76 | - /// `pi` isn't installed (or isn't on a path we look at). | |
| 98 | + /// The binary isn't installed (or isn't on a path we look at). | |
| 77 | 99 | Missing, |
| 78 | 100 | Failed(String), |
| 79 | 101 | } |
| 80 | 102 | |
| 81 | - pub struct Catalog { | |
| 82 | - pub claude: Vec<ModelEntry>, | |
| 103 | + /// What the last health check found out about one provider instance. | |
| 104 | + #[derive(Clone, Debug)] | |
| 105 | + pub struct InstanceState { | |
| 106 | + pub config: ProviderInstance, | |
| 107 | + pub status: ProbeStatus, | |
| 108 | + /// A check is running; the fields keep the previous one's findings. | |
| 109 | + pub checking: bool, | |
| 110 | + pub models: Vec<ModelEntry>, | |
| 83 | 111 | /// Claude's `default` entry, which the picker shows as the model it resolves to. |
| 84 | - pub claude_default: Option<ModelEntry>, | |
| 85 | - pub pi: Vec<ModelEntry>, | |
| 86 | - pub pi_status: PiStatus, | |
| 87 | - /// The signed-in Claude account, once a Claude session reported it. | |
| 88 | - pub claude_account: Option<String>, | |
| 112 | + pub default: Option<ModelEntry>, | |
| 113 | + /// The signed-in account (Claude's email). | |
| 114 | + pub account: Option<String>, | |
| 115 | + /// The subscription or login (`Claude Team`). | |
| 116 | + pub plan: Option<String>, | |
| 117 | + pub version: Option<String>, | |
| 118 | + /// The newest published version, once an update check ran. | |
| 119 | + pub latest: Option<String>, | |
| 120 | + /// Claude's `get_usage` reply: plan limits and how much of them is used. | |
| 121 | + pub usage: Option<Value>, | |
| 122 | + pub commands: Vec<SlashCommand>, | |
| 123 | + pub checked: Option<SystemTime>, | |
| 124 | + } | |
| 125 | + | |
| 126 | + impl InstanceState { | |
| 127 | + fn new(config: ProviderInstance) -> Self { | |
| 128 | + Self { | |
| 129 | + status: if config.enabled { ProbeStatus::Checking } else { ProbeStatus::Disabled }, | |
| 130 | + config, | |
| 131 | + checking: false, | |
| 132 | + models: Vec::new(), | |
| 133 | + default: None, | |
| 134 | + account: None, | |
| 135 | + plan: None, | |
| 136 | + version: None, | |
| 137 | + latest: None, | |
| 138 | + usage: None, | |
| 139 | + commands: Vec::new(), | |
| 140 | + checked: None, | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + pub fn id(&self) -> &str { | |
| 145 | + &self.config.id | |
| 146 | + } | |
| 147 | + | |
| 148 | + pub fn label(&self) -> String { | |
| 149 | + self.config.label() | |
| 150 | + } | |
| 151 | + | |
| 152 | + pub fn kind(&self) -> Provider { | |
| 153 | + self.config.kind | |
| 154 | + } | |
| 155 | + | |
| 156 | + /// Whether a newer version than the installed one is out. | |
| 157 | + pub fn update_available(&self) -> bool { | |
| 158 | + match (&self.version, &self.latest) { | |
| 159 | + (Some(version), Some(latest)) => version_newer(latest, version), | |
| 160 | + _ => false, | |
| 161 | + } | |
| 162 | + } | |
| 163 | + } | |
| 164 | + | |
| 165 | + pub struct Catalog { | |
| 166 | + pub instances: Vec<InstanceState>, | |
| 89 | 167 | favorites: Vec<String>, |
| 90 | 168 | } |
| 91 | 169 | |
| 92 | 170 | impl Global for Catalog {} |
| 93 | 171 | |
| 172 | + /// What one health check of an instance found. | |
| 173 | + #[derive(Default)] | |
| 174 | + struct Probe { | |
| 175 | + models: Vec<ModelEntry>, | |
| 176 | + default: Option<ModelEntry>, | |
| 177 | + account: Option<String>, | |
| 178 | + plan: Option<String>, | |
| 179 | + version: Option<String>, | |
| 180 | + usage: Option<Value>, | |
| 181 | + commands: Vec<SlashCommand>, | |
| 182 | + } | |
| 183 | + | |
| 94 | 184 | impl Catalog { |
| 95 | 185 | pub fn init(cx: &mut App) { |
| 186 | + let saved = settings::load(); | |
| 96 | 187 | cx.set_global(Catalog { |
| 97 | - claude: Vec::new(), | |
| 98 | - claude_default: None, | |
| 99 | - pi: Vec::new(), | |
| 100 | - pi_status: PiStatus::Loading, | |
| 101 | - claude_account: None, | |
| 102 | - favorites: settings::load().favorites, | |
| 188 | + instances: saved.providers.into_iter().map(InstanceState::new).collect(), | |
| 189 | + favorites: saved.favorites, | |
| 103 | 190 | }); |
| 104 | - Self::refresh_pi(cx); | |
| 191 | + Self::refresh_all(cx); | |
| 192 | + } | |
| 193 | + | |
| 194 | + pub fn global(cx: &App) -> &Catalog { | |
| 195 | + cx.global::<Catalog>() | |
| 196 | + } | |
| 197 | + | |
| 198 | + /// Picks up added, removed and edited instances from the settings, and | |
| 199 | + /// checks the ones that changed. | |
| 200 | + pub fn sync_instances(cx: &mut App) { | |
| 201 | + let configs = settings::instances(); | |
| 202 | + let catalog = cx.global_mut::<Catalog>(); | |
| 203 | + let mut changed = Vec::new(); | |
| 204 | + let mut instances = Vec::new(); | |
| 205 | + for config in configs { | |
| 206 | + match catalog.instances.iter().position(|state| state.config.id == config.id) { | |
| 207 | + Some(ix) => { | |
| 208 | + let mut state = catalog.instances[ix].clone(); | |
| 209 | + if state.config != config { | |
| 210 | + let enabled = config.enabled; | |
| 211 | + state.config = config; | |
| 212 | + if !enabled { | |
| 213 | + state.status = ProbeStatus::Disabled; | |
| 214 | + } | |
| 215 | + changed.push(state.config.id.clone()); | |
| 216 | + } | |
| 217 | + instances.push(state); | |
| 218 | + } | |
| 219 | + None => { | |
| 220 | + changed.push(config.id.clone()); | |
| 221 | + instances.push(InstanceState::new(config)); | |
| 222 | + } | |
| 223 | + } | |
| 224 | + } | |
| 225 | + catalog.instances = instances; | |
| 226 | + for id in changed { | |
| 227 | + Self::refresh(&id, cx); | |
| 228 | + } | |
| 229 | + cx.refresh_windows(); | |
| 105 | 230 | } |
| 106 | 231 | |
| 107 | - /// Asks Pi (again) for the models its providers offer. | |
| 108 | - pub fn refresh_pi(cx: &mut App) { | |
| 109 | - cx.global_mut::<Catalog>().pi_status = PiStatus::Loading; | |
| 232 | + /// Checks every enabled instance again. | |
| 233 | + pub fn refresh_all(cx: &mut App) { | |
| 234 | + let ids: Vec<String> = Self::global(cx).instances.iter().map(|state| state.config.id.clone()).collect(); | |
| 235 | + for id in ids { | |
| 236 | + Self::refresh(&id, cx); | |
| 237 | + } | |
| 238 | + } | |
| 239 | + | |
| 240 | + /// Checks one instance in the background: its version, account, models, | |
| 241 | + /// commands and limits. | |
| 242 | + pub fn refresh(id: &str, cx: &mut App) { | |
| 243 | + let update_checks = settings::get(|settings| settings.update_checks); | |
| 244 | + let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id) else { | |
| 245 | + return; | |
| 246 | + }; | |
| 247 | + if !state.config.enabled { | |
| 248 | + state.status = ProbeStatus::Disabled; | |
| 249 | + cx.refresh_windows(); | |
| 250 | + return; | |
| 251 | + } | |
| 252 | + if state.checking { | |
| 253 | + return; | |
| 254 | + } | |
| 255 | + state.checking = true; | |
| 256 | + if state.checked.is_none() { | |
| 257 | + state.status = ProbeStatus::Checking; | |
| 258 | + } | |
| 259 | + let config = state.config.clone(); | |
| 110 | 260 | cx.refresh_windows(); |
| 111 | - let probe = cx.background_executor().spawn(async { probe_pi_models() }); | |
| 261 | + let id = id.to_string(); | |
| 262 | + let probe = cx.background_executor().spawn(async move { | |
| 263 | + let probe = match config.kind { | |
| 264 | + Provider::Claude => probe_claude(&config), | |
| 265 | + Provider::Pi => probe_pi(&config), | |
| 266 | + }; | |
| 267 | + let latest = if update_checks { latest_version(config.kind) } else { None }; | |
| 268 | + (probe, latest) | |
| 269 | + }); | |
| 112 | 270 | cx.spawn(async move |cx| { |
| 113 | - let result = probe.await; | |
| 271 | + let (result, latest) = probe.await; | |
| 114 | 272 | cx.update(|cx| { |
| 115 | - let catalog = cx.global_mut::<Catalog>(); | |
| 273 | + let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == id) | |
| 274 | + else { | |
| 275 | + return; | |
| 276 | + }; | |
| 277 | + state.checking = false; | |
| 278 | + state.checked = Some(SystemTime::now()); | |
| 279 | + if latest.is_some() { | |
| 280 | + state.latest = latest; | |
| 281 | + } | |
| 116 | 282 | match result { |
| 117 | - Ok(models) => { | |
| 118 | - catalog.pi = models; | |
| 119 | - catalog.pi_status = PiStatus::Ready; | |
| 283 | + Ok(probe) => { | |
| 284 | + state.status = ProbeStatus::Ready; | |
| 285 | + if !probe.models.is_empty() || state.config.kind == Provider::Pi { | |
| 286 | + state.models = probe.models; | |
| 287 | + state.default = probe.default; | |
| 288 | + } | |
| 289 | + state.account = probe.account.or(state.account.take()); | |
| 290 | + state.plan = probe.plan.or(state.plan.take()); | |
| 291 | + state.version = probe.version.or(state.version.take()); | |
| 292 | + if probe.usage.is_some() { | |
| 293 | + state.usage = probe.usage; | |
| 294 | + } | |
| 295 | + if !probe.commands.is_empty() { | |
| 296 | + state.commands = probe.commands; | |
| 297 | + } | |
| 120 | 298 | } |
| 121 | 299 | Err(status) => { |
| 122 | - catalog.pi.clear(); | |
| 123 | - catalog.pi_status = status; | |
| 300 | + if state.config.enabled { | |
| 301 | + state.status = status; | |
| 302 | + } | |
| 303 | + if state.config.kind == Provider::Pi { | |
| 304 | + state.models.clear(); | |
| 305 | + } | |
| 124 | 306 | } |
| 125 | 307 | } |
| 126 | 308 | cx.refresh_windows(); |
| @@ -129,46 +311,81 @@ impl Catalog { | ||
| 129 | 311 | .detach(); |
| 130 | 312 | } |
| 131 | 313 | |
| 132 | - pub fn global(cx: &App) -> &Catalog { | |
| 133 | - cx.global::<Catalog>() | |
| 314 | + pub fn instance(&self, id: &str) -> Option<&InstanceState> { | |
| 315 | + self.instances.iter().find(|state| state.config.id == id) | |
| 134 | 316 | } |
| 135 | 317 | |
| 136 | - /// Takes Claude's model list and account from a session's `initialize` reply. | |
| 137 | - pub fn set_claude_models(info: &Value, cx: &mut App) { | |
| 138 | - if let Some(account) = info.pointer("/account/email").and_then(Value::as_str) { | |
| 139 | - cx.global_mut::<Catalog>().claude_account = Some(account.to_string()); | |
| 140 | - } | |
| 141 | - let (default, models) = claude_models_from(info); | |
| 142 | - if models.is_empty() { | |
| 318 | + /// The enabled instances, in the order the settings list them. | |
| 319 | + pub fn enabled(&self) -> impl Iterator<Item = &InstanceState> { | |
| 320 | + self.instances.iter().filter(|state| state.config.enabled) | |
| 321 | + } | |
| 322 | + | |
| 323 | + pub fn label(&self, id: &str) -> String { | |
| 324 | + self.instance(id).map(InstanceState::label).unwrap_or_else(|| id.to_string()) | |
| 325 | + } | |
| 326 | + | |
| 327 | + /// The first enabled instance of a kind. | |
| 328 | + pub fn first_of(&self, kind: Provider) -> Option<&InstanceState> { | |
| 329 | + self.enabled().find(|state| state.config.kind == kind) | |
| 330 | + } | |
| 331 | + | |
| 332 | + /// Takes Claude's model list, account and commands from a session's | |
| 333 | + /// `initialize` reply. | |
| 334 | + pub fn set_claude_info(instance: &str, info: &Value, cx: &mut App) { | |
| 335 | + let probe = claude_probe_from(instance, info); | |
| 336 | + let Some(state) = cx.global_mut::<Catalog>().instances.iter_mut().find(|state| state.config.id == instance) | |
| 337 | + else { | |
| 143 | 338 | return; |
| 339 | + }; | |
| 340 | + let mut changed = false; | |
| 341 | + if probe.account.is_some() && state.account != probe.account { | |
| 342 | + state.account = probe.account; | |
| 343 | + state.plan = probe.plan; | |
| 344 | + changed = true; | |
| 144 | 345 | } |
| 145 | - let catalog = cx.global_mut::<Catalog>(); | |
| 146 | - if catalog.claude != models { | |
| 147 | - catalog.claude = models; | |
| 148 | - catalog.claude_default = default; | |
| 346 | + if !probe.models.is_empty() && state.models != probe.models { | |
| 347 | + state.models = probe.models; | |
| 348 | + state.default = probe.default; | |
| 349 | + changed = true; | |
| 350 | + } | |
| 351 | + if !probe.commands.is_empty() && state.commands != probe.commands { | |
| 352 | + state.commands = probe.commands; | |
| 353 | + changed = true; | |
| 354 | + } | |
| 355 | + if state.status != ProbeStatus::Ready { | |
| 356 | + state.status = ProbeStatus::Ready; | |
| 357 | + changed = true; | |
| 358 | + } | |
| 359 | + if changed { | |
| 149 | 360 | cx.refresh_windows(); |
| 150 | 361 | } |
| 151 | 362 | } |
| 152 | 363 | |
| 153 | - pub fn models(&self, provider: Provider) -> &[ModelEntry] { | |
| 154 | - match provider { | |
| 155 | - Provider::Claude => &self.claude, | |
| 156 | - Provider::Pi => &self.pi, | |
| 157 | - } | |
| 364 | + pub fn models(&self, instance: &str) -> &[ModelEntry] { | |
| 365 | + self.instance(instance).map(|state| state.models.as_slice()).unwrap_or_default() | |
| 366 | + } | |
| 367 | + | |
| 368 | + /// Every enabled instance's models. | |
| 369 | + pub fn all_models(&self) -> impl Iterator<Item = &ModelEntry> { | |
| 370 | + self.enabled().flat_map(|state| &state.models) | |
| 158 | 371 | } |
| 159 | 372 | |
| 160 | 373 | /// The entry for a thread's model; `None` means the provider's default. |
| 161 | - pub fn find(&self, provider: Provider, id: Option<&str>) -> Option<&ModelEntry> { | |
| 162 | - match (provider, id) { | |
| 163 | - (Provider::Claude, None) => self.claude_default.as_ref(), | |
| 164 | - (provider, Some(id)) => self.models(provider).iter().find(|model| model.id == id), | |
| 165 | - (Provider::Pi, None) => None, | |
| 374 | + pub fn find(&self, instance: &str, id: Option<&str>) -> Option<&ModelEntry> { | |
| 375 | + let state = self.instance(instance)?; | |
| 376 | + match id { | |
| 377 | + None => state.default.as_ref(), | |
| 378 | + Some(id) => state.models.iter().find(|model| model.id == id), | |
| 166 | 379 | } |
| 167 | 380 | } |
| 168 | 381 | |
| 169 | 382 | /// The entry stored under `key` (see [`ModelEntry::key`]). |
| 170 | 383 | pub fn find_key(&self, key: &str) -> Option<&ModelEntry> { |
| 171 | - self.claude.iter().chain(&self.pi).find(|model| model.key() == key) | |
| 384 | + self.instances.iter().flat_map(|state| &state.models).find(|model| model.key() == key) | |
| 385 | + } | |
| 386 | + | |
| 387 | + pub fn commands(&self, instance: &str) -> &[SlashCommand] { | |
| 388 | + self.instance(instance).map(|state| state.commands.as_slice()).unwrap_or_default() | |
| 172 | 389 | } |
| 173 | 390 | |
| 174 | 391 | pub fn is_favorite(&self, model: &ModelEntry) -> bool { |
| @@ -176,10 +393,7 @@ impl Catalog { | ||
| 176 | 393 | } |
| 177 | 394 | |
| 178 | 395 | pub fn favorites(&self) -> Vec<&ModelEntry> { |
| 179 | - self.favorites | |
| 180 | - .iter() | |
| 181 | - .filter_map(|key| self.claude.iter().chain(&self.pi).find(|model| &model.key() == key)) | |
| 182 | - .collect() | |
| 396 | + self.favorites.iter().filter_map(|key| self.all_models().find(|model| &model.key() == key)).collect() | |
| 183 | 397 | } |
| 184 | 398 | |
| 185 | 399 | pub fn toggle_favorite(model: &ModelEntry, cx: &mut App) { |
| @@ -197,9 +411,15 @@ impl Catalog { | ||
| 197 | 411 | } |
| 198 | 412 | } |
| 199 | 413 | |
| 200 | - /// Parses the `models` of Claude's `initialize` reply into the default entry | |
| 201 | - /// and the pickable ones. | |
| 202 | - fn claude_models_from(info: &Value) -> (Option<ModelEntry>, Vec<ModelEntry>) { | |
| 414 | + /// Splits a model key into its instance and model id. Keys from before | |
| 415 | + /// instances existed name the kind, which is also its first instance's id. | |
| 416 | + pub fn split_key(key: &str) -> Option<(&str, &str)> { | |
| 417 | + key.split_once(':') | |
| 418 | + } | |
| 419 | + | |
| 420 | + /// Parses Claude's `initialize` reply: the models (the default entry apart), | |
| 421 | + /// the account and the commands. | |
| 422 | + fn claude_probe_from(instance: &str, info: &Value) -> Probe { | |
| 203 | 423 | let mut default = None; |
| 204 | 424 | let mut models = Vec::new(); |
| 205 | 425 | for model in info.get("models").and_then(Value::as_array).into_iter().flatten() { |
| @@ -209,6 +429,7 @@ fn claude_models_from(info: &Value) -> (Option<ModelEntry>, Vec<ModelEntry>) { | ||
| 209 | 429 | let display = text("displayName"); |
| 210 | 430 | let entry = ModelEntry { |
| 211 | 431 | provider: Provider::Claude, |
| 432 | + instance: instance.to_string(), | |
| 212 | 433 | id: id.to_string(), |
| 213 | 434 | resolved: Some(text("resolvedModel")).filter(|model| !model.is_empty()).unwrap_or_else(|| id.into()), |
| 214 | 435 | name: if display.starts_with("Claude") { display.clone() } else { format!("Claude {display}") }, |
| @@ -236,26 +457,109 @@ fn claude_models_from(info: &Value) -> (Option<ModelEntry>, Vec<ModelEntry>) { | ||
| 236 | 457 | { |
| 237 | 458 | default.name = current.name.clone(); |
| 238 | 459 | } |
| 239 | - (default, models) | |
| 460 | + let commands = info | |
| 461 | + .get("commands") | |
| 462 | + .and_then(Value::as_array) | |
| 463 | + .into_iter() | |
| 464 | + .flatten() | |
| 465 | + .filter_map(|command| { | |
| 466 | + let name = command.get("name")?.as_str()?; | |
| 467 | + // Internal commands start with underscores. | |
| 468 | + (!name.starts_with('_')).then(|| SlashCommand { | |
| 469 | + name: name.to_string(), | |
| 470 | + description: command.get("description").and_then(Value::as_str).unwrap_or_default().to_string(), | |
| 471 | + argument_hint: command.get("argumentHint").and_then(Value::as_str).unwrap_or_default().to_string(), | |
| 472 | + // Claude's own commands are marked builtin; skills from | |
| 473 | + // plugins and skill folders aren't. | |
| 474 | + skill: command.get("builtin").and_then(Value::as_bool) != Some(true), | |
| 475 | + }) | |
| 476 | + }) | |
| 477 | + .collect(); | |
| 478 | + Probe { | |
| 479 | + models, | |
| 480 | + default, | |
| 481 | + account: info.pointer("/account/email").and_then(Value::as_str).map(str::to_string), | |
| 482 | + plan: info | |
| 483 | + .pointer("/account/subscriptionType") | |
| 484 | + .and_then(Value::as_str) | |
| 485 | + .map(|plan| if plan.to_lowercase().contains("subscription") { plan.to_string() } else { format!("{plan} Subscription") }), | |
| 486 | + version: info.get("claude_code_version").and_then(Value::as_str).map(str::to_string), | |
| 487 | + usage: None, | |
| 488 | + commands, | |
| 489 | + } | |
| 240 | 490 | } |
| 241 | 491 | |
| 242 | - /// Starts `pi` just long enough to list the models its providers offer. | |
| 243 | - fn probe_pi_models() -> Result<Vec<ModelEntry>, PiStatus> { | |
| 244 | - let binary = find_pi_binary().ok_or(PiStatus::Missing)?; | |
| 245 | - let mut command = Command::new(&binary); | |
| 492 | + /// Starts `claude` just long enough to ask what it offers and how much of | |
| 493 | + /// the plan's limits is used. Nothing is sent to a model. | |
| 494 | + fn probe_claude(config: &ProviderInstance) -> Result<Probe, ProbeStatus> { | |
| 495 | + let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); | |
| 496 | + let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; | |
| 246 | 497 | command |
| 247 | - .args(["--mode", "rpc", "--no-session"]) | |
| 498 | + .args(["-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose"]) | |
| 499 | + .arg("--no-session-persistence") | |
| 500 | + .current_dir(std::env::temp_dir()) | |
| 248 | 501 | .stdin(Stdio::piped()) |
| 249 | 502 | .stdout(Stdio::piped()) |
| 250 | - .stderr(Stdio::null()); | |
| 251 | - if let Some(dir) = binary.parent() { | |
| 252 | - let path = std::env::var_os("PATH").unwrap_or_default(); | |
| 253 | - let paths = std::iter::once(dir.to_path_buf()).chain(std::env::split_paths(&path)); | |
| 254 | - if let Ok(joined) = std::env::join_paths(paths) { | |
| 255 | - command.env("PATH", joined); | |
| 503 | + .stderr(Stdio::piped()); | |
| 504 | + let mut child = command.spawn().map_err(|err| failed(&err))?; | |
| 505 | + let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?; | |
| 506 | + let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; | |
| 507 | + let stderr = child.stderr.take().ok_or_else(|| failed(&"no stderr"))?; | |
| 508 | + let request = |id: &str, subtype: &str| { | |
| 509 | + json!({"type": "control_request", "request_id": id, "request": {"subtype": subtype}}).to_string() | |
| 510 | + }; | |
| 511 | + writeln!(stdin, "{}", request("probe-init", "initialize")).map_err(|err| failed(&err))?; | |
| 512 | + writeln!(stdin, "{}", request("probe-usage", "get_usage")).map_err(|err| failed(&err))?; | |
| 513 | + | |
| 514 | + let (tx, rx) = mpsc::channel(); | |
| 515 | + std::thread::spawn(move || { | |
| 516 | + for line in BufReader::new(stdout).lines().map_while(Result::ok) { | |
| 517 | + if let Ok(value) = serde_json::from_str::<Value>(&line) | |
| 518 | + && value.get("type").and_then(Value::as_str) == Some("control_response") | |
| 519 | + && tx.send(value["response"].clone()).is_err() | |
| 520 | + { | |
| 521 | + return; | |
| 522 | + } | |
| 523 | + } | |
| 524 | + }); | |
| 525 | + let stderr_tail = std::thread::spawn(move || { | |
| 526 | + BufReader::new(stderr).lines().map_while(Result::ok).filter(|line| !line.trim().is_empty()).last() | |
| 527 | + }); | |
| 528 | + let deadline = Instant::now() + Duration::from_secs(25); | |
| 529 | + let (mut init, mut usage) = (None, None); | |
| 530 | + while init.is_none() || usage.is_none() { | |
| 531 | + let Some(left) = deadline.checked_duration_since(Instant::now()) else { break }; | |
| 532 | + let Ok(response) = rx.recv_timeout(left) else { break }; | |
| 533 | + let ok = response.get("subtype").and_then(Value::as_str) == Some("success"); | |
| 534 | + match response.get("request_id").and_then(Value::as_str) { | |
| 535 | + Some("probe-init") => init = Some(ok.then(|| response["response"].clone())), | |
| 536 | + Some("probe-usage") => usage = Some(ok.then(|| response["response"].clone())), | |
| 537 | + _ => {} | |
| 256 | 538 | } |
| 257 | 539 | } |
| 258 | - let failed = |err: &dyn std::fmt::Display| PiStatus::Failed(err.to_string()); | |
| 540 | + drop(stdin); | |
| 541 | + let _ = child.kill(); | |
| 542 | + let _ = child.wait(); | |
| 543 | + let Some(Some(info)) = init else { | |
| 544 | + let detail = stderr_tail.join().ok().flatten().unwrap_or_else(|| "Claude Code didn't answer".into()); | |
| 545 | + return Err(failed(&detail)); | |
| 546 | + }; | |
| 547 | + let mut probe = claude_probe_from(&config.id, &info); | |
| 548 | + probe.usage = usage.flatten().filter(|usage| usage.get("rate_limits_available").and_then(Value::as_bool) != Some(false)); | |
| 549 | + Ok(probe) | |
| 550 | + } | |
| 551 | + | |
| 552 | + /// Starts `pi` just long enough to list the models its providers offer, and | |
| 553 | + /// asks its version. | |
| 554 | + fn probe_pi(config: &ProviderInstance) -> Result<Probe, ProbeStatus> { | |
| 555 | + let failed = |err: &dyn std::fmt::Display| ProbeStatus::Failed(err.to_string()); | |
| 556 | + let (_, mut command) = base_command(config).map_err(|_| ProbeStatus::Missing)?; | |
| 557 | + command | |
| 558 | + .args(["--mode", "rpc", "--no-session"]) | |
| 559 | + .current_dir(std::env::temp_dir()) | |
| 560 | + .stdin(Stdio::piped()) | |
| 561 | + .stdout(Stdio::piped()) | |
| 562 | + .stderr(Stdio::null()); | |
| 259 | 563 | let mut child = command.spawn().map_err(|err| failed(&err))?; |
| 260 | 564 | let mut stdin = child.stdin.take().ok_or_else(|| failed(&"no stdin"))?; |
| 261 | 565 | let stdout = child.stdout.take().ok_or_else(|| failed(&"no stdout"))?; |
| @@ -282,21 +586,27 @@ fn probe_pi_models() -> Result<Vec<ModelEntry>, PiStatus> { | ||
| 282 | 586 | let error = reply.get("error").and_then(Value::as_str).unwrap_or("unknown error"); |
| 283 | 587 | return Err(failed(&error)); |
| 284 | 588 | } |
| 285 | - Ok(reply | |
| 589 | + let models = reply | |
| 286 | 590 | .pointer("/data/models") |
| 287 | 591 | .and_then(Value::as_array) |
| 288 | 592 | .into_iter() |
| 289 | 593 | .flatten() |
| 290 | - .filter_map(pi_model_from) | |
| 291 | - .collect()) | |
| 594 | + .filter_map(|model| pi_model_from(&config.id, model)) | |
| 595 | + .collect(); | |
| 596 | + let version = base_command(config).ok().and_then(|(_, mut command)| { | |
| 597 | + let output = command.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()).output().ok()?; | |
| 598 | + first_version(&String::from_utf8_lossy(&output.stdout)) | |
| 599 | + }); | |
| 600 | + Ok(Probe { models, account: None, plan: None, version, ..Probe::default() }) | |
| 292 | 601 | } |
| 293 | 602 | |
| 294 | - pub fn pi_model_from(model: &Value) -> Option<ModelEntry> { | |
| 603 | + pub fn pi_model_from(instance: &str, model: &Value) -> Option<ModelEntry> { | |
| 295 | 604 | let id = model.get("id")?.as_str()?; |
| 296 | 605 | let provider = model.get("provider")?.as_str()?; |
| 297 | 606 | let reasoning = model.get("reasoning").and_then(Value::as_bool).unwrap_or(false); |
| 298 | 607 | Some(ModelEntry { |
| 299 | 608 | provider: Provider::Pi, |
| 609 | + instance: instance.to_string(), | |
| 300 | 610 | id: format!("{provider}/{id}"), |
| 301 | 611 | resolved: id.to_string(), |
| 302 | 612 | name: model.get("name").and_then(Value::as_str).unwrap_or(id).to_string(), |
| @@ -313,6 +623,50 @@ pub fn pi_model_from(model: &Value) -> Option<ModelEntry> { | ||
| 313 | 623 | }) |
| 314 | 624 | } |
| 315 | 625 | |
| 626 | + /// The newest published version of an agent, from the npm registry. | |
| 627 | + fn latest_version(kind: Provider) -> Option<String> { | |
| 628 | + let package = match kind { | |
| 629 | + Provider::Claude => "@anthropic-ai/claude-code", | |
| 630 | + Provider::Pi => "@earendil-works/pi-coding-agent", | |
| 631 | + }; | |
| 632 | + let body = crate::http::get(&format!("https://registry.npmjs.org/{package}/latest"), &[], Duration::from_secs(10)).ok()?; | |
| 633 | + serde_json::from_str::<Value>(&body).ok()?.get("version")?.as_str().map(str::to_string) | |
| 634 | + } | |
| 635 | + | |
| 636 | + /// The command that updates an agent in place, and how to describe it. | |
| 637 | + pub fn update_command(config: &ProviderInstance) -> Option<(std::process::Command, &'static str)> { | |
| 638 | + let (binary, mut command) = base_command(config).ok()?; | |
| 639 | + match config.kind { | |
| 640 | + Provider::Claude => { | |
| 641 | + command.arg("update"); | |
| 642 | + Some((command, "claude update")) | |
| 643 | + } | |
| 644 | + Provider::Pi => { | |
| 645 | + // Pi is an npm package; update it with the npm next to it. | |
| 646 | + let npm = binary.parent().map(|dir| dir.join("npm")).filter(|npm| npm.exists()); | |
| 647 | + let mut command = std::process::Command::new(npm.as_deref().unwrap_or(Path::new("npm"))); | |
| 648 | + command.args(["install", "-g", "@earendil-works/pi-coding-agent@latest"]); | |
| 649 | + Some((command, "npm install -g @earendil-works/pi-coding-agent")) | |
| 650 | + } | |
| 651 | + } | |
| 652 | + } | |
| 653 | + | |
| 654 | + /// The first `1.2.3`-looking word in `text`. | |
| 655 | + fn first_version(text: &str) -> Option<String> { | |
| 656 | + text.split_whitespace() | |
| 657 | + .map(|word| word.trim_start_matches('v')) | |
| 658 | + .find(|word| word.split('.').count() >= 2 && word.split('.').all(|part| part.parse::<u64>().is_ok())) | |
| 659 | + .map(str::to_string) | |
| 660 | + } | |
| 661 | + | |
| 662 | + /// Whether version `a` is newer than `b`, comparing numerically. | |
| 663 | + fn version_newer(a: &str, b: &str) -> bool { | |
| 664 | + let parse = |version: &str| -> Vec<u64> { | |
| 665 | + version.trim_start_matches('v').split(['.', '-', '+']).map_while(|part| part.parse().ok()).collect() | |
| 666 | + }; | |
| 667 | + parse(a) > parse(b) | |
| 668 | + } | |
| 669 | + | |
| 316 | 670 | /// `openai-codex` → `OpenAI Codex`. |
| 317 | 671 | fn provider_label(provider: &str) -> String { |
| 318 | 672 | provider |
| @@ -333,7 +687,7 @@ fn provider_label(provider: &str) -> String { | ||
| 333 | 687 | |
| 334 | 688 | #[cfg(test)] |
| 335 | 689 | mod tests { |
| 336 | - use super::{claude_models_from, provider_label}; | |
| 690 | + use super::{claude_probe_from, first_version, provider_label, version_newer}; | |
| 337 | 691 | use serde_json::json; |
| 338 | 692 | |
| 339 | 693 | #[::core::prelude::v1::test] |
| @@ -346,16 +700,39 @@ mod tests { | ||
| 346 | 700 | |
| 347 | 701 | #[::core::prelude::v1::test] |
| 348 | 702 | fn claude_default_takes_the_current_name() { |
| 349 | - let info = json!({"models": [ | |
| 350 | - {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"}, | |
| 351 | - {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5", | |
| 352 | - "supportedEffortLevels": ["low", "high"], "supportsFastMode": true}, | |
| 353 | - {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"}, | |
| 354 | - ]}); | |
| 355 | - let (default, models) = claude_models_from(&info); | |
| 356 | - assert_eq!(default.unwrap().name, "Claude Opus 5.5"); | |
| 357 | - assert_eq!(models.len(), 2); | |
| 358 | - assert!(models[1].legacy); | |
| 359 | - assert_eq!(models[0].efforts.len(), 2); | |
| 703 | + let info = json!({ | |
| 704 | + "models": [ | |
| 705 | + {"value": "default", "resolvedModel": "claude-opus-5-5", "displayName": "Default (recommended)"}, | |
| 706 | + {"value": "opus", "resolvedModel": "claude-opus-5-5", "displayName": "Opus 5.5", | |
| 707 | + "supportedEffortLevels": ["low", "high"], "supportsFastMode": true}, | |
| 708 | + {"value": "claude-haiku-4-5", "displayName": "Haiku 4.5"}, | |
| 709 | + ], | |
| 710 | + "account": {"email": "me@example.com", "subscriptionType": "Claude Team"}, | |
| 711 | + "commands": [ | |
| 712 | + {"name": "compact", "description": "Compact", "argumentHint": "", "builtin": true}, | |
| 713 | + {"name": "pdf", "description": "PDF skill", "argumentHint": ""}, | |
| 714 | + {"name": "__internal", "description": "", "argumentHint": "", "builtin": true}, | |
| 715 | + ], | |
| 716 | + "claude_code_version": "2.1.296", | |
| 717 | + }); | |
| 718 | + let probe = claude_probe_from("claude", &info); | |
| 719 | + assert_eq!(probe.default.unwrap().name, "Claude Opus 5.5"); | |
| 720 | + assert_eq!(probe.models.len(), 2); | |
| 721 | + assert!(probe.models[1].legacy); | |
| 722 | + assert_eq!(probe.models[0].efforts.len(), 2); | |
| 723 | + assert_eq!(probe.models[0].key(), "claude:opus"); | |
| 724 | + assert_eq!(probe.plan.as_deref(), Some("Claude Team Subscription")); | |
| 725 | + assert_eq!(probe.commands.len(), 2); | |
| 726 | + assert!(!probe.commands[0].skill && probe.commands[1].skill); | |
| 727 | + assert_eq!(probe.version.as_deref(), Some("2.1.296")); | |
| 728 | + } | |
| 729 | + | |
| 730 | + #[::core::prelude::v1::test] | |
| 731 | + fn compares_versions() { | |
| 732 | + assert!(version_newer("2.1.300", "2.1.296")); | |
| 733 | + assert!(!version_newer("2.1.296", "2.1.296")); | |
| 734 | + assert!(version_newer("1.10.0", "1.9.9")); | |
| 735 | + assert_eq!(first_version("pi v1.1.0\n").as_deref(), Some("1.1.0")); | |
| 736 | + assert_eq!(first_version("2.1.296 (Claude Code)").as_deref(), Some("2.1.296")); | |
| 360 | 737 | } |
| 361 | 738 | } |
| @@ -5,17 +5,20 @@ use std::{ | ||
| 5 | 5 | collections::{HashMap, HashSet, VecDeque}, |
| 6 | 6 | path::{Path, PathBuf}, |
| 7 | 7 | sync::atomic::{AtomicU64, Ordering}, |
| 8 | - time::Instant, | |
| 8 | + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, | |
| 9 | 9 | }; |
| 10 | 10 | |
| 11 | 11 | use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; |
| 12 | 12 | use gpui_kit::component::text::TextViewState; |
| 13 | 13 | use gpui_kit::*; |
| 14 | + use serde::{Deserialize, Serialize}; | |
| 14 | 15 | use serde_json::{Value, json}; |
| 15 | 16 | |
| 16 | 17 | use crate::claude::{AgentProcess, Effort, LaunchConfig, PermissionMode, ProcessEvent}; |
| 17 | 18 | use crate::git::{self, TurnChanges}; |
| 18 | 19 | use crate::models::{Catalog, Provider}; |
| 20 | + use crate::settings::{self, Streaming}; | |
| 21 | + use crate::worktree::{self, PullRequest, Worktree, WorktreePlan}; | |
| 19 | 22 | |
| 20 | 23 | const DEFAULT_TITLE: &str = "New thread"; |
| 21 | 24 | const STDERR_TAIL: usize = 20; |
| @@ -109,17 +112,41 @@ pub struct Question { | ||
| 109 | 112 | pub answer: Option<SharedString>, |
| 110 | 113 | } |
| 111 | 114 | |
| 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 | + | |
| 112 | 125 | pub enum ChatItem { |
| 113 | 126 | User { |
| 114 | 127 | text: SharedString, |
| 115 | 128 | /// File names of what was attached. |
| 116 | 129 | attachments: Vec<SharedString>, |
| 130 | + at: SystemTime, | |
| 117 | 131 | }, |
| 118 | - Assistant(Entity<TextViewState>), | |
| 132 | + Assistant(AssistantText), | |
| 119 | 133 | Tool(ToolCall), |
| 120 | 134 | Notice(NoticeKind, SharedString), |
| 121 | 135 | 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>, | |
| 123 | 150 | } |
| 124 | 151 | |
| 125 | 152 | /// How hard the model reasons, as picked in the composer. |
| @@ -194,6 +221,8 @@ pub struct FinishedTurn { | ||
| 194 | 221 | pub end: usize, |
| 195 | 222 | pub duration_ms: u64, |
| 196 | 223 | pub cost: Option<f64>, |
| 224 | + /// When it ended. | |
| 225 | + pub at: SystemTime, | |
| 197 | 226 | /// Whether anything besides the answer happened (tools, interim text). |
| 198 | 227 | pub has_work: bool, |
| 199 | 228 | } |
| @@ -205,9 +234,22 @@ struct StreamingMessage { | ||
| 205 | 234 | } |
| 206 | 235 | |
| 207 | 236 | pub struct Session { |
| 237 | + /// Names the thread's file on disk. | |
| 238 | + pub id: String, | |
| 208 | 239 | pub provider: Provider, |
| 240 | + /// The provider instance running it (`claude`, `claude-2`, `pi`). | |
| 241 | + pub instance: String, | |
| 209 | 242 | 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. | |
| 210 | 248 | 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>, | |
| 211 | 253 | pub items: Vec<ChatItem>, |
| 212 | 254 | pub status: SessionStatus, |
| 213 | 255 | pub thinking: bool, |
| @@ -228,10 +270,27 @@ pub struct Session { | ||
| 228 | 270 | /// Pi reports cost per message; this sums the running turn. |
| 229 | 271 | turn_cost: f64, |
| 230 | 272 | pub turn_started: Option<Instant>, |
| 273 | + pub created: SystemTime, | |
| 231 | 274 | /// 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, | |
| 233 | 278 | /// Filed away by the user: its process is stopped until the next message. |
| 234 | 279 | 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, | |
| 235 | 294 | /// Tokens in the context window as of the last response. |
| 236 | 295 | pub context_tokens: Option<u64>, |
| 237 | 296 | pub context_window: Option<u64>, |
| @@ -256,25 +315,58 @@ pub struct Session { | ||
| 256 | 315 | _events_task: Task<()>, |
| 257 | 316 | } |
| 258 | 317 | |
| 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 | + | |
| 259 | 333 | 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 { | |
| 269 | 353 | let snapshot_index = std::env::temp_dir().join(format!( |
| 270 | 354 | "vectrum-code-{}-{}.index", |
| 271 | 355 | std::process::id(), |
| 272 | 356 | NEXT_SESSION.fetch_add(1, Ordering::Relaxed) |
| 273 | 357 | )); |
| 274 | - let mut session = Self { | |
| 358 | + let NewSession { instance, provider, project, model, permission_mode, reasoning, fast_mode, worktree } = new; | |
| 359 | + Self { | |
| 360 | + id, | |
| 275 | 361 | provider, |
| 362 | + instance, | |
| 276 | 363 | 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, | |
| 278 | 370 | items: Vec::new(), |
| 279 | 371 | status: SessionStatus::Starting, |
| 280 | 372 | thinking: false, |
| @@ -289,8 +381,17 @@ impl Session { | ||
| 289 | 381 | process_cost: 0., |
| 290 | 382 | turn_cost: 0., |
| 291 | 383 | turn_started: None, |
| 292 | - updated: Instant::now(), | |
| 384 | + created: now, | |
| 385 | + updated: now, | |
| 386 | + last_message: now, | |
| 293 | 387 | 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, | |
| 294 | 395 | context_tokens: None, |
| 295 | 396 | context_window: None, |
| 296 | 397 | expanded_turns: HashSet::new(), |
| @@ -306,9 +407,7 @@ impl Session { | ||
| 306 | 407 | pending_controls: HashMap::new(), |
| 307 | 408 | next_request_id: 0, |
| 308 | 409 | _events_task: Task::ready(()), |
| 309 | - }; | |
| 310 | - session.start(None, cx); | |
| 311 | - session | |
| 410 | + } | |
| 312 | 411 | } |
| 313 | 412 | |
| 314 | 413 | pub fn is_working(&self) -> bool { |
| @@ -329,7 +428,7 @@ impl Session { | ||
| 329 | 428 | fn start(&mut self, resume: Option<String>, cx: &mut Context<Self>) { |
| 330 | 429 | let (tx, rx) = smol::channel::unbounded(); |
| 331 | 430 | let config = LaunchConfig { |
| 332 | - provider: self.provider, | |
| 431 | + instance: settings::instance(&self.instance, self.provider), | |
| 333 | 432 | cwd: self.cwd.clone(), |
| 334 | 433 | model: self.model.clone(), |
| 335 | 434 | permission_mode: self.permission_mode, |
| @@ -419,30 +518,135 @@ impl Session { | ||
| 419 | 518 | |
| 420 | 519 | // ---- user actions ------------------------------------------------- |
| 421 | 520 | |
| 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. | |
| 424 | 525 | 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>) { | |
| 425 | 635 | let mut images = Vec::new(); |
| 426 | - let mut names = Vec::new(); | |
| 427 | 636 | let mut file_paths = Vec::new(); |
| 428 | 637 | 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()); | |
| 433 | 638 | match image_media_type(path) { |
| 434 | 639 | Some(media_type) => match read_image(path) { |
| 435 | 640 | Ok(data) => images.push((media_type, data)), |
| 436 | 641 | 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))); | |
| 438 | 643 | continue; |
| 439 | 644 | } |
| 440 | 645 | }, |
| 441 | 646 | None => file_paths.push(path.display().to_string()), |
| 442 | 647 | } |
| 443 | - names.push(SharedString::from(name)); | |
| 444 | 648 | } |
| 445 | - let mut prompt = text.clone(); | |
| 649 | + let mut prompt = text; | |
| 446 | 650 | if !file_paths.is_empty() { |
| 447 | 651 | if !prompt.is_empty() { |
| 448 | 652 | prompt.push_str("\n\n"); |
| @@ -453,7 +657,6 @@ impl Session { | ||
| 453 | 657 | } |
| 454 | 658 | } |
| 455 | 659 | if prompt.is_empty() && images.is_empty() { |
| 456 | - cx.notify(); | |
| 457 | 660 | return; |
| 458 | 661 | } |
| 459 | 662 | if self.provider == Provider::Claude && self.reasoning == Reasoning::Ultrathink { |
| @@ -466,7 +669,8 @@ impl Session { | ||
| 466 | 669 | self.start(resume, cx); |
| 467 | 670 | } |
| 468 | 671 | let Some(process) = &self.process else { |
| 469 | - cx.notify(); | |
| 672 | + self.status = SessionStatus::Exited; | |
| 673 | + self.turn_started = None; | |
| 470 | 674 | return; |
| 471 | 675 | }; |
| 472 | 676 | match self.provider { |
| @@ -494,34 +698,107 @@ impl Session { | ||
| 494 | 698 | }); |
| 495 | 699 | // Pi rejects a prompt mid-run unless told when to deliver it. |
| 496 | 700 | if self.is_working() { |
| 497 | - command["streamingBehavior"] = "followUp".into(); | |
| 701 | + command["streamingBehavior"] = "steer".into(); | |
| 498 | 702 | } |
| 499 | 703 | self.pi(command); |
| 500 | 704 | } |
| 501 | 705 | } |
| 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 | + } | |
| 502 | 726 | |
| 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; | |
| 506 | 731 | } |
| 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 | + _ => {} | |
| 515 | 755 | } |
| 516 | - self.status = SessionStatus::Working; | |
| 517 | - self.settled = false; | |
| 518 | - self.updated = Instant::now(); | |
| 519 | 756 | cx.notify(); |
| 520 | 757 | } |
| 521 | 758 | |
| 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 | + | |
| 522 | 798 | /// Files the thread under Settled and stops its process. The transcript |
| 523 | 799 | /// stays, and the next message resumes the conversation. |
| 524 | 800 | pub fn settle(&mut self, cx: &mut Context<Self>) { |
| 801 | + self.flush_text(cx); | |
| 525 | 802 | self.process.take(); |
| 526 | 803 | // Drop the event stream too, so the process exit isn't reported as a crash. |
| 527 | 804 | self._events_task = Task::ready(()); |
| @@ -536,6 +813,7 @@ impl Session { | ||
| 536 | 813 | self.status = SessionStatus::Idle; |
| 537 | 814 | } |
| 538 | 815 | self.settled = true; |
| 816 | + self.preparing = false; | |
| 539 | 817 | cx.notify(); |
| 540 | 818 | } |
| 541 | 819 | |
| @@ -543,7 +821,7 @@ impl Session { | ||
| 543 | 821 | /// with the next message. |
| 544 | 822 | pub fn unsettle(&mut self, cx: &mut Context<Self>) { |
| 545 | 823 | self.settled = false; |
| 546 | - self.updated = Instant::now(); | |
| 824 | + self.updated = SystemTime::now(); | |
| 547 | 825 | cx.notify(); |
| 548 | 826 | } |
| 549 | 827 | |
| @@ -670,7 +948,7 @@ impl Session { | ||
| 670 | 948 | let end = ix + self.items[ix..] |
| 671 | 949 | .iter() |
| 672 | 950 | .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 { | |
| 674 | 952 | return None; |
| 675 | 953 | }; |
| 676 | 954 | let answer = (first..end) |
| @@ -679,7 +957,7 @@ impl Session { | ||
| 679 | 957 | let has_work = (first..end).any(|ix| { |
| 680 | 958 | Some(ix) != answer && !matches!(self.items[ix], ChatItem::Notice(NoticeKind::Error, _)) |
| 681 | 959 | }); |
| 682 | - Some(FinishedTurn { first, answer, end, duration_ms, cost, has_work }) | |
| 960 | + Some(FinishedTurn { first, answer, end, duration_ms, cost, at, has_work }) | |
| 683 | 961 | } |
| 684 | 962 | |
| 685 | 963 | pub fn toggle_tool(&mut self, ix: usize, cx: &mut Context<Self>) { |
| @@ -775,11 +1053,12 @@ impl Session { | ||
| 775 | 1053 | } |
| 776 | 1054 | self.stderr_tail.push_back(line); |
| 777 | 1055 | } |
| 778 | - ProcessEvent::Exited(code) => self.handle_exit(code), | |
| 1056 | + ProcessEvent::Exited(code) => self.handle_exit(code, cx), | |
| 779 | 1057 | } |
| 780 | 1058 | } |
| 781 | 1059 | |
| 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); | |
| 783 | 1062 | self.process = None; |
| 784 | 1063 | self.thinking = false; |
| 785 | 1064 | self.streaming = None; |
| @@ -807,9 +1086,11 @@ impl Session { | ||
| 807 | 1086 | /// Closes the running turn: records its end and, in the background, what |
| 808 | 1087 | /// it changed on disk. |
| 809 | 1088 | fn finish_turn(&mut self, duration_ms: u64, cost: Option<f64>, cx: &mut Context<Self>) { |
| 1089 | + self.flush_text(cx); | |
| 810 | 1090 | 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 }); | |
| 813 | 1094 | let end = self.items.len() - 1; |
| 814 | 1095 | let Some(base) = self.base_snapshot.take() else { return }; |
| 815 | 1096 | let cwd = self.cwd.clone(); |
| @@ -826,6 +1107,7 @@ impl Session { | ||
| 826 | 1107 | if let Some(changes) = changes.filter(|changes| !changes.files.is_empty()) { |
| 827 | 1108 | let _ = this.update(cx, |this, cx| { |
| 828 | 1109 | this.turn_changes.insert(end, changes); |
| 1110 | + cx.emit(SessionEvent::TurnChanges(end)); | |
| 829 | 1111 | cx.notify(); |
| 830 | 1112 | }); |
| 831 | 1113 | } |
| @@ -857,6 +1139,7 @@ impl Session { | ||
| 857 | 1139 | }, |
| 858 | 1140 | Some("user") if parent_tool_use_id.is_none() => self.handle_user(&message["message"]), |
| 859 | 1141 | Some("result") => self.handle_result(&message, cx), |
| 1142 | + Some("rate_limit_event") => self.handle_rate_limit(&message["rate_limit_info"]), | |
| 860 | 1143 | Some("control_request") => self.handle_control_request(&message), |
| 861 | 1144 | Some("control_response") => self.handle_control_response(&message["response"], cx), |
| 862 | 1145 | Some("control_cancel_request") => { |
| @@ -936,9 +1219,9 @@ impl Session { | ||
| 936 | 1219 | let Some(item_ix) = self.streamed_item(index) else { return }; |
| 937 | 1220 | let delta = &event["delta"]; |
| 938 | 1221 | 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(_))) => { | |
| 940 | 1223 | 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); | |
| 942 | 1225 | } |
| 943 | 1226 | (Some("input_json_delta"), Some(ChatItem::Tool(tool))) => { |
| 944 | 1227 | let json = delta.get("partial_json").and_then(Value::as_str); |
| @@ -949,6 +1232,9 @@ impl Session { | ||
| 949 | 1232 | } |
| 950 | 1233 | Some("content_block_stop") => { |
| 951 | 1234 | self.thinking = false; |
| 1235 | + if let Some(ix) = self.streamed_item(index) { | |
| 1236 | + self.flush_item(ix, cx); | |
| 1237 | + } | |
| 952 | 1238 | if let Some(ChatItem::Tool(tool)) = self.streamed_item(index).and_then(|ix| self.items.get_mut(ix)) |
| 953 | 1239 | && let Ok(input) = serde_json::from_str::<Value>(&tool.partial_input) |
| 954 | 1240 | { |
| @@ -1051,6 +1337,35 @@ impl Session { | ||
| 1051 | 1337 | } |
| 1052 | 1338 | } |
| 1053 | 1339 | |
| 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 | + | |
| 1054 | 1369 | fn handle_result(&mut self, message: &Value, cx: &mut Context<Self>) { |
| 1055 | 1370 | self.thinking = false; |
| 1056 | 1371 | self.streaming = None; |
| @@ -1096,6 +1411,8 @@ impl Session { | ||
| 1096 | 1411 | } |
| 1097 | 1412 | } |
| 1098 | 1413 | self.finish_turn(duration_ms, cost, cx); |
| 1414 | + self.schedule_after_limit(); | |
| 1415 | + self.send_next_queued(cx); | |
| 1099 | 1416 | } |
| 1100 | 1417 | |
| 1101 | 1418 | fn handle_control_request(&mut self, message: &Value) { |
| @@ -1127,7 +1444,7 @@ impl Session { | ||
| 1127 | 1444 | .unwrap_or_default(), |
| 1128 | 1445 | state: PermissionState::Pending, |
| 1129 | 1446 | }; |
| 1130 | - self.updated = Instant::now(); | |
| 1447 | + self.updated = SystemTime::now(); | |
| 1131 | 1448 | let tool_use_id = request.get("tool_use_id").and_then(Value::as_str).unwrap_or_default(); |
| 1132 | 1449 | if let Some(tool) = self.find_tool_mut(tool_use_id) { |
| 1133 | 1450 | tool.input = prompt.input.clone(); |
| @@ -1160,7 +1477,7 @@ impl Session { | ||
| 1160 | 1477 | |
| 1161 | 1478 | if let PendingControl::Initialize = pending { |
| 1162 | 1479 | let info = &response["response"]; |
| 1163 | - Catalog::set_claude_models(info, cx); | |
| 1480 | + Catalog::set_claude_info(&self.instance, info, cx); | |
| 1164 | 1481 | if let Some(mode) = info |
| 1165 | 1482 | .get("current_permission_mode") |
| 1166 | 1483 | .and_then(Value::as_str) |
| @@ -1241,6 +1558,7 @@ impl Session { | ||
| 1241 | 1558 | let duration_ms = self.turn_started.map_or(0, |start| start.elapsed().as_millis() as u64); |
| 1242 | 1559 | let cost = Some(std::mem::take(&mut self.turn_cost)).filter(|cost| *cost > 0.); |
| 1243 | 1560 | self.finish_turn(duration_ms, cost, cx); |
| 1561 | + self.send_next_queued(cx); | |
| 1244 | 1562 | } |
| 1245 | 1563 | Some("auto_retry_start") => { |
| 1246 | 1564 | let attempt = message.get("attempt").and_then(Value::as_u64).unwrap_or(1); |
| @@ -1347,8 +1665,11 @@ impl Session { | ||
| 1347 | 1665 | ix |
| 1348 | 1666 | } |
| 1349 | 1667 | }; |
| 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); | |
| 1352 | 1673 | } |
| 1353 | 1674 | } |
| 1354 | 1675 | Some("thinking_start" | "thinking_delta") => self.thinking = true, |
| @@ -1436,17 +1757,41 @@ impl Session { | ||
| 1436 | 1757 | } |
| 1437 | 1758 | _ => {} |
| 1438 | 1759 | } |
| 1439 | - self.updated = Instant::now(); | |
| 1760 | + self.updated = SystemTime::now(); | |
| 1440 | 1761 | } |
| 1441 | 1762 | |
| 1442 | 1763 | // ---- helpers ----------------------------------------------------- |
| 1443 | 1764 | |
| 1444 | 1765 | 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() })); | |
| 1447 | 1768 | self.items.len() - 1 |
| 1448 | 1769 | } |
| 1449 | 1770 | |
| 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 | + | |
| 1450 | 1795 | fn push_tool(&mut self, id: &str, name: &str, input: Value) -> usize { |
| 1451 | 1796 | self.items.push(ChatItem::Tool(ToolCall { |
| 1452 | 1797 | id: id.to_string(), |
| @@ -1520,6 +1865,250 @@ impl Session { | ||
| 1520 | 1865 | } |
| 1521 | 1866 | } |
| 1522 | 1867 | |
| 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 | + | |
| 1523 | 2112 | impl Drop for Session { |
| 1524 | 2113 | fn drop(&mut self) { |
| 1525 | 2114 | let _ = std::fs::remove_file(&self.snapshot_index); |
| @@ -1546,6 +2135,47 @@ fn read_image(path: &Path) -> Result<String, String> { | ||
| 1546 | 2135 | Ok(BASE64.encode(bytes)) |
| 1547 | 2136 | } |
| 1548 | 2137 | |
| 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 | + | |
| 1549 | 2179 | fn title_from(text: &str) -> String { |
| 1550 | 2180 | let line = text.lines().find(|line| !line.trim().is_empty()).unwrap_or(text).trim(); |
| 1551 | 2181 | 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 | ||
| 1617 | 2247 | } |
| 1618 | 2248 | line |
| 1619 | 2249 | } |
| 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 | + } | |
| @@ -6,6 +6,160 @@ use std::{path::PathBuf, sync::Mutex}; | ||
| 6 | 6 | |
| 7 | 7 | use serde::{Deserialize, Serialize}; |
| 8 | 8 | |
| 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 | + | |
| 9 | 163 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 10 | 164 | #[serde(default)] |
| 11 | 165 | pub struct Settings { |
| @@ -14,6 +168,8 @@ pub struct Settings { | ||
| 14 | 168 | /// Model keys (`claude:opus`, `pi:anthropic/claude-sonnet-5-5`), in the |
| 15 | 169 | /// order they were starred. |
| 16 | 170 | pub favorites: Vec<String>, |
| 171 | + | |
| 172 | + // ---- new threads ---- | |
| 17 | 173 | /// The model key new threads start on. `None` follows the open thread. |
| 18 | 174 | pub default_model: Option<String>, |
| 19 | 175 | /// `default`, an effort level (`high`), `ultracode` or `ultrathink`. |
| @@ -21,23 +177,96 @@ pub struct Settings { | ||
| 21 | 177 | pub default_reasoning: Option<String>, |
| 22 | 178 | /// A permission mode as the CLI spells it. `None` follows the open thread. |
| 23 | 179 | 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 ---- | |
| 26 | 195 | /// Fold a finished turn's tool calls into one "Worked for" row. |
| 27 | 196 | pub fold_turns: bool, |
| 28 | 197 | /// Show what threads and turns cost. |
| 29 | 198 | pub show_costs: bool, |
| 30 | - /// One-line thread cards in the sidebar. | |
| 31 | - pub compact_threads: bool, | |
| 32 | 199 | /// Send with Ctrl+Enter, so Enter starts a new line. |
| 33 | 200 | 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 ---- | |
| 34 | 243 | /// A desktop notification when a thread finishes or needs you while the |
| 35 | 244 | /// window is in the background. |
| 36 | 245 | 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>, | |
| 41 | 270 | } |
| 42 | 271 | |
| 43 | 272 | impl Default for Settings { |
| @@ -48,18 +277,72 @@ impl Default for Settings { | ||
| 48 | 277 | default_model: None, |
| 49 | 278 | default_reasoning: None, |
| 50 | 279 | 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()), | |
| 52 | 286 | fold_turns: true, |
| 53 | 287 | show_costs: true, |
| 54 | - compact_threads: false, | |
| 55 | 288 | 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, | |
| 56 | 312 | 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(), | |
| 57 | 322 | claude_bin: None, |
| 58 | 323 | pi_bin: None, |
| 59 | 324 | } |
| 60 | 325 | } |
| 61 | 326 | } |
| 62 | 327 | |
| 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 | + | |
| 63 | 346 | static CACHE: Mutex<Option<Settings>> = Mutex::new(None); |
| 64 | 347 | |
| 65 | 348 | pub fn path() -> Option<PathBuf> { |
| @@ -70,10 +353,12 @@ pub fn path() -> Option<PathBuf> { | ||
| 70 | 353 | } |
| 71 | 354 | |
| 72 | 355 | fn read_file() -> Settings { |
| 73 | - path() | |
| 356 | + let mut settings: Settings = path() | |
| 74 | 357 | .and_then(|path| std::fs::read_to_string(path).ok()) |
| 75 | 358 | .and_then(|text| serde_json::from_str(&text).ok()) |
| 76 | - .unwrap_or_default() | |
| 359 | + .unwrap_or_default(); | |
| 360 | + settings.normalize(); | |
| 361 | + settings | |
| 77 | 362 | } |
| 78 | 363 | |
| 79 | 364 | /// Reads one value out of the settings. |
| @@ -86,12 +371,23 @@ pub fn load() -> Settings { | ||
| 86 | 371 | get(Settings::clone) |
| 87 | 372 | } |
| 88 | 373 | |
| 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 | + | |
| 89 | 384 | /// Applies `edit` to the settings and writes them back. |
| 90 | 385 | pub fn update(edit: impl FnOnce(&mut Settings)) { |
| 91 | 386 | let settings = { |
| 92 | 387 | let mut cache = CACHE.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 93 | 388 | let settings = cache.get_or_insert_with(read_file); |
| 94 | 389 | edit(settings); |
| 390 | + settings.normalize(); | |
| 95 | 391 | settings.clone() |
| 96 | 392 | }; |
| 97 | 393 | let Some(path) = path() else { return }; |
| @@ -105,10 +401,29 @@ pub fn update(edit: impl FnOnce(&mut Settings)) { | ||
| 105 | 401 | } |
| 106 | 402 | } |
| 107 | 403 | |
| 108 | - /// Puts everything but favorites back to its default. | |
| 404 | + /// Puts everything but favorites, agents and usage hubs back to its default. | |
| 109 | 405 | pub fn restore_defaults() { |
| 110 | 406 | update(|settings| { |
| 111 | 407 | 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() }; | |
| 113 | 411 | }); |
| 114 | 412 | } |
| 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 | + } | |
| @@ -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 | + } | |
| @@ -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 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.