diff --git a/Cargo.lock b/Cargo.lock index c614838f..65ac805c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3960,6 +3960,24 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ide-agent" +version = "0.1.15" +dependencies = [ + "ide-core", + "ide-jsonrpc", + "ide-vcs", + "parking_lot", + "serde", + "serde_json", + "specta", + "tempfile", + "tokio", + "toml 0.9.12+spec-1.1.0", + "tracing", + "uuid", +] + [[package]] name = "ide-analysis" version = "0.1.15" @@ -5862,6 +5880,7 @@ version = "0.1.15" dependencies = [ "dirs 6.0.0", "dunce", + "ide-agent", "ide-analysis", "ide-build", "ide-clipboard", diff --git a/Cargo.toml b/Cargo.toml index 30f1ad27..7230496c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ ide-http = { path = "crates/ide-http" } ide-db = { path = "crates/ide-db" } ide-web = { path = "crates/ide-web" } ide-spell = { path = "crates/ide-spell" } +ide-agent = { path = "crates/ide-agent" } ide-clipboard = { path = "crates/ide-clipboard" } # serialization & IPC typing diff --git a/crates/ide-agent/Cargo.toml b/crates/ide-agent/Cargo.toml new file mode 100644 index 00000000..248f6007 --- /dev/null +++ b/crates/ide-agent/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ide-agent" +description = "Coding agents over the Agent Client Protocol: tasks, sessions, worktrees and their timelines" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ide-core.workspace = true +ide-jsonrpc.workspace = true +ide-vcs.workspace = true +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true +parking_lot.workspace = true +uuid.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/ide-agent/src/config.rs b/crates/ide-agent/src/config.rs new file mode 100644 index 00000000..59fbfe18 --- /dev/null +++ b/crates/ide-agent/src/config.rs @@ -0,0 +1,224 @@ +//! Session settings (model, mode, thinking effort…), slash commands and context use, as +//! the agent reports them over ACP. + +use crate::model::{AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentUsage}; +use serde_json::{Value, json}; + +/// The settings in a `session/new` or `session/load` result: its select `configOptions`, +/// plus the older `modes` / `models` fields for what those don't cover. +pub(crate) fn options_of(result: &Value) -> Vec { + let mut out = result.get("configOptions").map(parse_options).unwrap_or_default(); + let has = |out: &[AgentConfigOption], category: &str| out.iter().any(|o| o.category.as_deref() == Some(category)); + if !has(&out, "mode") + && let Some(modes) = result.get("modes") + { + out.push(legacy(modes, "mode", "Mode", "availableModes", "id", "currentModeId", AgentConfigVia::Mode)); + } + if !has(&out, "model") + && let Some(models) = result.get("models") + { + out.push(legacy( + models, + "model", + "Model", + "availableModels", + "modelId", + "currentModelId", + AgentConfigVia::Model, + )); + } + out.retain(|o| !o.values.is_empty()); + out +} + +/// A `configOptions` array; only select options are kept (the UI shows them as dropdowns). +pub(crate) fn parse_options(options: &Value) -> Vec { + let Some(list) = options.as_array() else { return Vec::new() }; + list.iter() + .filter(|o| o.get("type").and_then(Value::as_str).is_none_or(|t| t == "select")) + .filter_map(|o| { + let id = o.get("id").or_else(|| o.get("configId")).and_then(Value::as_str)?.to_owned(); + let mut values = Vec::new(); + for v in o.get("options").and_then(Value::as_array).into_iter().flatten() { + // Grouped options: `{ group, name, options: [...] }`. + match v.get("options").and_then(Value::as_array) { + Some(group) => values.extend(group.iter().filter_map(value_of)), + None => values.extend(value_of(v)), + } + } + Some(AgentConfigOption { + name: str_of(o, "name").unwrap_or_else(|| id.clone()), + description: str_of(o, "description"), + category: str_of(o, "category").or_else(|| matches!(id.as_str(), "model" | "mode").then(|| id.clone())), + current: str_of(o, "currentValue"), + values, + via: AgentConfigVia::Option, + id, + }) + }) + .collect() +} + +/// A `config_option_update`: its options replace the config-option ones; `modes` / `models` +/// stand-ins stay. +pub(crate) fn merge_update(config: &mut Vec, options: &Value) { + let fresh = parse_options(options); + let covered = |o: &AgentConfigOption| fresh.iter().any(|f| f.category.is_some() && f.category == o.category); + config.retain(|o| o.via != AgentConfigVia::Option && !covered(o)); + let legacy = std::mem::take(config); + config.extend(fresh); + config.extend(legacy); + config.retain(|o| !o.values.is_empty()); +} + +/// `current_mode_update`. +pub(crate) fn set_mode(config: &mut [AgentConfigOption], mode: &str) { + if let Some(o) = config.iter_mut().find(|o| o.category.as_deref() == Some("mode")) { + o.current = Some(mode.to_owned()); + } +} + +/// The request (method, params without the session id) that sets `option` to `value`. +pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static str, Value) { + match option.via { + AgentConfigVia::Option => ("session/set_config_option", json!({ "configId": option.id, "value": value })), + AgentConfigVia::Mode => ("session/set_mode", json!({ "modeId": value })), + AgentConfigVia::Model => ("session/set_model", json!({ "modelId": value })), + } +} + +/// `available_commands_update`. +pub(crate) fn commands_of(update: &Value) -> Vec { + update + .get("availableCommands") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|c| { + Some(AgentCommand { + name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(), + description: str_of(c, "description").unwrap_or_default(), + hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned), + }) + }) + .collect() +} + +/// `usage_update`: `{ used, size, cost?: { amount, currency } }`. +pub(crate) fn usage_of(update: &Value) -> Option { + let tokens = |key: &str| update.get(key)?.as_f64().map(|n| n.clamp(0.0, f64::from(u32::MAX)) as u32); + let used = tokens("used")?; + let size = tokens("size").filter(|s| *s > 0)?; + Some(AgentUsage { + used, + size, + cost: update.pointer("/cost/amount").and_then(Value::as_f64), + currency: update.pointer("/cost/currency").and_then(Value::as_str).map(str::to_owned), + }) +} + +fn legacy( + v: &Value, + id: &str, + name: &str, + list: &str, + key: &str, + current: &str, + via: AgentConfigVia, +) -> AgentConfigOption { + let values = v + .get(list) + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|m| { + let value = m.get(key)?.as_str()?.to_owned(); + Some(AgentConfigValue { + name: str_of(m, "name").unwrap_or_else(|| value.clone()), + description: str_of(m, "description"), + value, + }) + }) + .collect(); + AgentConfigOption { + id: id.to_owned(), + name: name.to_owned(), + description: None, + category: Some(id.to_owned()), + current: str_of(v, current), + values, + via, + } +} + +fn value_of(v: &Value) -> Option { + let value = v.get("value")?.as_str()?.to_owned(); + Some(AgentConfigValue { + name: str_of(v, "name").unwrap_or_else(|| value.clone()), + description: str_of(v, "description"), + value, + }) +} + +fn str_of(v: &Value, key: &str) -> Option { + v.get(key).and_then(Value::as_str).map(str::to_owned) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(c: &[AgentConfigOption]) -> Vec<(&str, Option<&str>)> { + c.iter().map(|o| (o.id.as_str(), o.current.as_deref())).collect() + } + + #[test] + fn config_options_win_over_the_older_fields() { + let r = json!({ + "modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Manual"}]}, + "configOptions": [ + {"id": "mode", "category": "mode", "type": "select", "currentValue": "plan", + "options": [{"value": "default", "name": "Manual"}, {"value": "plan", "name": "Plan"}]}, + {"id": "effort", "category": "thought_level", "type": "select", "currentValue": "medium", + "options": [{"group": "g", "name": "G", "options": [{"value": "low"}, {"value": "medium", "name": "Medium"}]}]}, + {"id": "fast", "type": "boolean", "currentValue": false} + ], + "models": {"currentModelId": "m1", "availableModels": [{"modelId": "m1", "name": "One"}]} + }); + let c = options_of(&r); + assert_eq!(ids(&c), [("mode", Some("plan")), ("effort", Some("medium")), ("model", Some("m1"))]); + assert_eq!(c[1].values.iter().map(|v| v.name.as_str()).collect::>(), ["low", "Medium"]); + assert_eq!(set_request(&c[0], "default").0, "session/set_config_option"); + assert_eq!(set_request(&c[2], "m1"), ("session/set_model", json!({"modelId": "m1"}))); + } + + #[test] + fn updates_replace_options_and_track_the_mode() { + let mut c = options_of(&json!({ + "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask"}, {"id": "code"}]}, + "configOptions": [{"id": "model", "type": "select", "currentValue": "a", "options": [{"value": "a"}]}] + })); + assert_eq!(c[0].category.as_deref(), Some("model"), "the category defaults from the id"); + set_mode(&mut c, "code"); + assert_eq!(c[1].current.as_deref(), Some("code")); + assert_eq!(set_request(&c[1], "ask").0, "session/set_mode"); + merge_update( + &mut c, + &json!([{"id": "model", "type": "select", "currentValue": "b", "options": [{"value": "b"}]}]), + ); + assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]); + } + + #[test] + fn commands_and_usage() { + let cmds = commands_of(&json!({"availableCommands": [ + {"name": "review", "description": "Review changes", "input": {"hint": "what to review"}}, + {"name": "/init", "description": "Write CLAUDE.md", "input": null} + ]})); + assert_eq!(cmds[0].hint.as_deref(), Some("what to review")); + assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None)); + let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap(); + assert_eq!((u.used, u.cost, u.currency.as_deref()), (1200, Some(0.42), Some("USD"))); + assert!(usage_of(&json!({"used": 1, "size": 0})).is_none()); + } +} diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs new file mode 100644 index 00000000..c08548da --- /dev/null +++ b/crates/ide-agent/src/lib.rs @@ -0,0 +1,1078 @@ +//! Coding agents (Claude Code, Codex, Gemini CLI…) driven over the Agent Client Protocol. +//! +//! A task is one agent conversation, working in its own git worktree or in the project +//! checkout. Each task runs its agent's ACP server as a child process (newline-delimited +//! JSON-RPC over stdio), folds `session/update` notifications into a timeline, and streams +//! timeline and status changes to the subscribed window. Tasks are saved under the +//! project's store directory and survive restarts; the agent session is reloaded on the +//! next prompt when the agent supports `session/load`. + +mod config; +mod model; +mod provider; +mod timeline; +mod worktree; + +pub use model::*; + +use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes}; +use parking_lot::{Mutex, RwLock}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::collections::{BTreeSet, HashMap, VecDeque}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Weak}; +use std::time::Duration; +use timeline::Timeline; + +/// Receives the events of one project's tasks. +pub type AgentSink = Arc; + +/// Entry changes are batched for this long before they are sent. +const FLUSH_DELAY: Duration = Duration::from_millis(40); +/// Updates that arrive this late after `session/load` returns are still replayed history. +const REPLAY_GRACE: Duration = Duration::from_millis(300); +/// Notifications are delivered on their own thread: the last `session/update`s of a turn +/// can trail its `session/prompt` response by this much. +const TURN_GRACE: Duration = Duration::from_millis(100); +const STDERR_LINES: usize = 20; +/// Referenced files up to this size are embedded in the prompt (when the agent takes it). +const MAX_EMBED: u64 = 256 * 1024; + +/// The agent tasks of one project. +pub struct AgentManager { + shared: Arc, + tasks: RwLock>>, +} + +pub struct AgentPaths { + pub project_root: PathBuf, + /// One `.json` per task. + pub store_dir: PathBuf, + /// Parent of the tasks' worktrees. + pub worktrees_dir: PathBuf, + /// Settings and commands each agent offered last time (shared by all projects). + pub models_file: PathBuf, + /// User-defined agents, one `.toml` each (`name`, `command`, `args`, `env`). + pub custom_agents_dir: Option, +} + +/// Starts the ACP server of a provider in a directory, speaking to `client`. +type Connector = Arc< + dyn Fn(&provider::ProviderSpec, &Path, Arc, Box) -> std::io::Result + + Send + + Sync, +>; + +struct Shared { + paths: AgentPaths, + runtime: tokio::runtime::Handle, + connector: Connector, + sink: RwLock>, + known: Mutex>, + providers: RwLock>, +} + +/// What an agent offered in its last session, for the New Task page. +#[derive(Clone, Default, Serialize, Deserialize)] +struct Known { + #[serde(default)] + config: Vec, + #[serde(default)] + commands: Vec, +} + +impl Shared { + fn spec(&self, id: &str) -> Option { + self.providers.read().iter().find(|p| p.id == id).cloned() + } + + fn emit(&self, event: AgentEvent) { + let sink = self.sink.read().clone(); + if let Some(sink) = sink { + sink(event); + } + } + + fn known(&self, provider: &str) -> Known { + self.known.lock().get(provider).cloned().unwrap_or_default() + } + + fn remember(&self, provider: &str, config: Option<&[AgentConfigOption]>, commands: Option<&[AgentCommand]>) { + let snapshot = { + let mut cache = self.known.lock(); + let known = cache.entry(provider.to_owned()).or_default(); + let mut changed = false; + if let Some(c) = config.filter(|c| !c.is_empty() && known.config != *c) { + known.config = c.to_vec(); + changed = true; + } + if let Some(c) = commands.filter(|c| !c.is_empty() && known.commands != *c) { + known.commands = c.to_vec(); + changed = true; + } + if !changed { + return; + } + serde_json::to_vec_pretty(&*cache) + }; + if let Ok(bytes) = snapshot + && let Err(e) = write_file(&self.paths.models_file, &bytes) + { + tracing::warn!("saving agent settings: {e}"); + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct TaskFile { + task: AgentTask, + session_id: Option, + entries: Vec, + /// Settings the user chose, re-applied to every new or reloaded session. + #[serde(default)] + wanted: Vec, +} + +struct TaskState { + task: AgentTask, + session_id: Option, + timeline: Timeline, + wanted: Vec, + /// A turn is running or about to (prompts arriving now are queued). + busy: bool, +} + +/// A running agent and its session. +#[derive(Clone)] +struct Live { + peer: Peer, + session: String, + /// `promptCapabilities.image`. + images: bool, + /// `promptCapabilities.embeddedContext`: referenced files are sent with their text. + embedded: bool, +} + +struct Task { + id: String, + shared: Arc, + state: Mutex, + live: Mutex>, + /// Serializes starting the agent. + connecting: tokio::sync::Mutex<()>, + /// Unanswered permission requests: where to send the chosen option id. + permissions: Mutex>>>, + next_permission: AtomicU32, + /// Set while `session/load` replays history we already have. + replaying: AtomicBool, + /// The current turn is being cancelled to send the first queued message right away. + send_now: AtomicBool, + dirty: Mutex>, + flush_scheduled: AtomicBool, + stderr: Mutex>, +} + +impl AgentManager { + /// Loads the project's saved tasks. Spawned work runs on `runtime`. + pub fn new(paths: AgentPaths, runtime: tokio::runtime::Handle) -> Self { + let connector: Connector = Arc::new(|spec, cwd, client, on_stderr| { + Peer::spawn_framed(&spec.command, &spec.args, cwd, &spec.env, client, on_stderr, Framing::Lines) + }); + Self::with_connector(paths, runtime, connector) + } + + fn with_connector(paths: AgentPaths, runtime: tokio::runtime::Handle, connector: Connector) -> Self { + let known = + std::fs::read(&paths.models_file).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default(); + let providers = RwLock::new(provider::load(paths.custom_agents_dir.as_deref())); + let shared = Arc::new(Shared { + paths, + runtime, + connector, + sink: RwLock::new(None), + known: Mutex::new(known), + providers, + }); + let mut tasks = HashMap::new(); + for file in std::fs::read_dir(&shared.paths.store_dir).into_iter().flatten().flatten() { + let path = file.path(); + if path.extension().is_none_or(|e| e != "json") { + continue; + } + match std::fs::read(&path) + .map_err(|e| e.to_string()) + .and_then(|b| serde_json::from_slice::(&b).map_err(|e| e.to_string())) + .and_then(|v| serde_json::from_value::(upgrade(v)).map_err(|e| e.to_string())) + { + Ok(mut f) => { + // The agent process did not survive the restart: whatever was in flight is over. + if matches!(f.task.status, AgentStatus::Running | AgentStatus::NeedsInput | AgentStatus::Starting) { + f.task.status = AgentStatus::Ready; + let mut timeline = Timeline::from_entries(std::mem::take(&mut f.entries)); + timeline.end_turn(); + f.entries = timeline.entries; + } + let task = Task::restore(f, shared.clone()); + tasks.insert(task.id.clone(), task); + } + Err(e) => tracing::warn!("skipping agent task {}: {e}", path.display()), + } + } + Self { shared, tasks: RwLock::new(tasks) } + } + + /// Routes events to `sink` (replacing the previous subscriber); returns every task. + pub fn subscribe(&self, sink: AgentSink) -> Vec { + *self.shared.sink.write() = Some(sink); + let mut tasks: Vec = self.tasks.read().values().map(|t| t.state.lock().task.clone()).collect(); + tasks.sort_by(|a, b| b.updated_ms.total_cmp(&a.updated_ms)); + tasks + } + + /// The agents (custom ones re-read), whether each is installed, and what they offered last time. + pub fn providers(&self) -> Vec { + let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref()); + let list = specs + .iter() + .map(|p| { + let known = self.shared.known(&p.id); + AgentProvider { + id: p.id.clone(), + name: p.name.clone(), + command: std::iter::once(p.command.as_str()) + .chain(p.args.iter().map(String::as_str)) + .collect::>() + .join(" "), + installed: provider::installed(&p.command), + config: known.config, + commands: known.commands, + } + }) + .collect(); + *self.shared.providers.write() = specs; + list + } + + fn task(&self, id: &str) -> IdeResult> { + self.tasks.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no agent task {id}"))) + } + + pub fn entries(&self, id: &str) -> IdeResult> { + Ok(self.task(id)?.state.lock().timeline.entries.clone()) + } + + /// Creates the task (and its worktree) and sends `req.prompt` as its first message. + pub async fn create(&self, req: NewAgentTask) -> IdeResult { + let spec = self + .shared + .spec(&req.provider) + .ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?; + let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?; + let title = title_of(&prompt); + let root = self.shared.paths.project_root.clone(); + let (cwd, branch, base, base_commit) = match req.isolation { + Isolation::Worktree => { + let dir = self.shared.paths.worktrees_dir.clone(); + let (t, b) = (title.clone(), req.base.clone()); + let c = blocking(move || worktree::create(&root, &dir, &t, b.as_deref())).await?; + (c.path, Some(c.branch), Some(c.base), Some(c.base_commit)) + } + Isolation::Local => { + let r = root.clone(); + let head = blocking(move || worktree::head_of(&r)).await.ok(); + let (base, commit) = head.unzip(); + (root, None, base, commit) + } + }; + // Until the session reports its own, show what the agent offered last time with the choices applied. + let known = self.shared.known(&spec.id); + let mut config = known.config; + for choice in &req.config { + if let Some(o) = config.iter_mut().find(|o| o.id == choice.id) { + o.current = Some(choice.value.clone()); + } + } + let now = now_ms(); + let task = AgentTask { + id: uuid::Uuid::new_v4().simple().to_string(), + title, + provider: spec.id.clone(), + model: model_of(&config), + status: AgentStatus::Starting, + isolation: req.isolation, + cwd: IpcPath(cwd), + branch, + base, + base_commit, + error: None, + created_ms: now, + updated_ms: now, + config, + commands: known.commands, + usage: None, + queue: Vec::new(), + }; + let file = TaskFile { task: task.clone(), session_id: None, entries: Vec::new(), wanted: req.config }; + let handle = Task::restore(file, self.shared.clone()); + handle.state.lock().busy = true; + self.tasks.write().insert(task.id.clone(), handle.clone()); + handle.emit_task(); + handle.persist(); + self.shared.runtime.spawn(async move { handle.run(prompt).await }); + Ok(task) + } + + /// Sends a message. While the agent works it is queued, or with `now` the current turn + /// is cancelled and the message goes next. + pub async fn prompt(&self, id: &str, prompt: AgentPrompt, now: bool) -> IdeResult<()> { + let task = self.task(id)?; + let prompt = clean(prompt).ok_or_else(|| IdeError::invalid("empty message"))?; + let start = { + let mut s = task.state.lock(); + if s.task.status == AgentStatus::Archived { + return Err(IdeError::invalid("the task is archived")); + } + if s.busy { + let item = AgentQueued { id: uuid::Uuid::new_v4().simple().to_string(), prompt }; + if now { + s.task.queue.insert(0, item); + } else { + s.task.queue.push(item); + } + None + } else { + s.busy = true; + Some(prompt) + } + }; + match start { + Some(prompt) => { + self.shared.runtime.spawn(async move { task.run(prompt).await }); + } + None => { + task.persist(); + task.emit_task(); + if now { + task.send_now.store(true, Ordering::Release); + task.cancel(); + } + } + } + Ok(()) + } + + /// Drops a queued message. + pub fn unqueue(&self, id: &str, queued: &str) -> IdeResult { + let task = self.task(id)?; + let item = { + let mut s = task.state.lock(); + let i = + s.task.queue.iter().position(|q| q.id == queued).ok_or_else(|| IdeError::not_found("already sent"))?; + s.task.queue.remove(i) + }; + task.persist(); + task.emit_task(); + Ok(item) + } + + /// Sends queued message `queued` next, cancelling the current turn if there is one. + pub fn send_queued_now(&self, id: &str, queued: &str) -> IdeResult<()> { + let task = self.task(id)?; + let idle = { + let mut s = task.state.lock(); + let i = + s.task.queue.iter().position(|q| q.id == queued).ok_or_else(|| IdeError::not_found("already sent"))?; + let item = s.task.queue.remove(i); + if s.busy { + s.task.queue.insert(0, item); + None + } else { + s.busy = true; + Some(item) + } + }; + task.persist(); + task.emit_task(); + match idle { + Some(item) => { + self.shared.runtime.spawn(async move { task.run(item.prompt).await }); + } + None => { + task.send_now.store(true, Ordering::Release); + task.cancel(); + } + } + Ok(()) + } + + /// Changes a session setting (model, mode…): right away when the agent runs, else when it next starts. + pub async fn set_config(&self, id: &str, option: &str, value: String) -> IdeResult<()> { + let task = self.task(id)?; + let target = { + let s = task.state.lock(); + let o = + s.task.config.iter().find(|o| o.id == option).ok_or_else(|| IdeError::not_found("no such setting"))?; + if !o.values.iter().any(|v| v.value == value) { + return Err(IdeError::invalid(format!("{} has no value {value}", o.name))); + } + o.clone() + }; + let live = task.live.lock().clone().filter(|l| !l.peer.is_closed()); + if let Some(live) = live { + let (method, mut params) = config::set_request(&target, &value); + params["sessionId"] = json!(live.session); + let reply: Value = + live.peer.request(method, params, None).await.map_err(|e| IdeError::internal(task.describe(e)))?; + if let Some(options) = reply.get("configOptions") { + config::merge_update(&mut task.state.lock().task.config, options); + } + } + { + let mut s = task.state.lock(); + s.wanted.retain(|c| c.id != option); + s.wanted.push(AgentConfigChoice { id: option.to_owned(), value: value.clone() }); + if let Some(o) = s.task.config.iter_mut().find(|o| o.id == option) { + o.current = Some(value); + } + s.task.model = model_of(&s.task.config).or(s.task.model.take()); + } + task.persist(); + task.emit_task(); + Ok(()) + } + + /// Stops the current turn (`session/cancel`); pending permission requests are cancelled. + /// Queued messages stay queued. + pub fn cancel(&self, id: &str) -> IdeResult<()> { + self.task(id)?.cancel(); + Ok(()) + } + + /// Answers permission request `permission` with option `option` (`None` cancels it). + pub fn respond(&self, id: &str, permission: u32, option: Option) -> IdeResult<()> { + let task = self.task(id)?; + let pending = task.permissions.lock().remove(&permission); + match pending { + Some(reply) => { + let _ = reply.send(option); + Ok(()) + } + None => Err(IdeError::not_found("the request was already answered")), + } + } + + /// Stops the agent and moves the task to Archived; its worktree stays. + pub fn archive(&self, id: &str) -> IdeResult<()> { + let task = self.task(id)?; + task.kill(); + task.set_status(AgentStatus::Archived, None); + Ok(()) + } + + /// Forgets the task; with `remove_worktree` also deletes its worktree (the branch stays). + pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> { + let task = self.task(id)?; + task.kill(); + let (isolation, cwd) = { + let s = task.state.lock(); + (s.task.isolation, s.task.cwd.0.clone()) + }; + if remove_worktree && isolation == Isolation::Worktree && cwd.exists() { + let root = self.shared.paths.project_root.clone(); + blocking(move || worktree::remove(&root, &cwd)).await?; + } + self.tasks.write().remove(id); + let _ = std::fs::remove_file(self.shared.paths.store_dir.join(format!("{id}.json"))); + self.shared.emit(AgentEvent::Removed { id: id.to_owned() }); + Ok(()) + } + + /// Files the task changed since it started. + pub async fn changes(&self, id: &str) -> IdeResult> { + let (cwd, base) = self.task(id)?.cwd_and_base(); + blocking(move || worktree::changes(&cwd, &base)).await + } + + /// Content of `path` (in the task's directory) when the task started. + pub async fn base_text(&self, id: &str, path: PathBuf) -> IdeResult> { + let (cwd, base) = self.task(id)?.cwd_and_base(); + blocking(move || worktree::file_at(&cwd, &path, &base)).await + } + + /// Stops every agent (project closed). + pub fn shutdown(&self) { + for task in self.tasks.read().values() { + task.kill(); + } + *self.shared.sink.write() = None; + } +} + +impl Task { + fn restore(file: TaskFile, shared: Arc) -> Arc { + let task = file.task; + let timeline = Timeline::from_entries(file.entries); + Arc::new(Self { + id: task.id.clone(), + shared, + state: Mutex::new(TaskState { + task, + session_id: file.session_id, + timeline, + wanted: file.wanted, + busy: false, + }), + live: Mutex::new(None), + connecting: tokio::sync::Mutex::new(()), + permissions: Mutex::new(HashMap::new()), + next_permission: AtomicU32::new(1), + replaying: AtomicBool::new(false), + send_now: AtomicBool::new(false), + dirty: Mutex::new(BTreeSet::new()), + flush_scheduled: AtomicBool::new(false), + stderr: Mutex::new(VecDeque::new()), + }) + } + + fn cwd_and_base(&self) -> (PathBuf, String) { + let s = self.state.lock(); + (s.task.cwd.0.clone(), s.task.base_commit.clone().unwrap_or_else(|| "HEAD".into())) + } + + fn emit_task(&self) { + let task = self.state.lock().task.clone(); + self.shared.emit(AgentEvent::Task { task }); + } + + fn set_status(&self, status: AgentStatus, error: Option) { + { + let mut s = self.state.lock(); + s.task.status = status; + s.task.error = error; + s.task.updated_ms = now_ms(); + } + // Saved first: whoever reacts to the status change finds it on disk. + self.persist(); + self.emit_task(); + } + + fn persist(&self) { + let bytes = { + let s = self.state.lock(); + serde_json::to_vec(&TaskFile { + task: s.task.clone(), + session_id: s.session_id.clone(), + entries: s.timeline.entries.clone(), + wanted: s.wanted.clone(), + }) + }; + let path = self.shared.paths.store_dir.join(format!("{}.json", self.id)); + if let Err(e) = bytes.map_err(|e| e.to_string()).and_then(|b| write_file(&path, &b)) { + tracing::warn!("saving agent task {}: {e}", self.id); + } + } + + fn mark_dirty(self: &Arc, indices: impl IntoIterator) { + self.dirty.lock().extend(indices); + if !self.flush_scheduled.swap(true, Ordering::AcqRel) { + let task = self.clone(); + self.shared.runtime.spawn(async move { + tokio::time::sleep(FLUSH_DELAY).await; + task.flush_scheduled.store(false, Ordering::Release); + task.flush(); + }); + } + } + + fn flush(&self) { + let indices = std::mem::take(&mut *self.dirty.lock()); + let events: Vec = { + let s = self.state.lock(); + indices + .into_iter() + .filter_map(|i| { + let entry = s.timeline.entries.get(i)?.clone(); + Some(AgentEvent::Entry { task: self.id.clone(), index: i as u32, entry }) + }) + .collect() + }; + for e in events { + self.shared.emit(e); + } + } + + fn push_entry(self: &Arc, entry: AgentEntry) -> usize { + let i = self.state.lock().timeline.push(entry); + self.mark_dirty([i]); + i + } + + /// Runs `prompt`, then the queued messages, until the queue is empty, a turn fails, or + /// the user stops one. The caller set `busy`. + async fn run(self: Arc, mut prompt: AgentPrompt) { + loop { + let result = self.turn(&prompt).await; + tokio::time::sleep(TURN_GRACE).await; + let changed = self.state.lock().timeline.end_turn(); + self.mark_dirty(changed); + let send_now = self.send_now.swap(false, Ordering::AcqRel); + let next = { + let mut s = self.state.lock(); + let go_on = s.task.status != AgentStatus::Archived + && match &result { + Ok(stop) => stop != "cancelled" || send_now, + Err(_) => false, + }; + let next = (go_on && !s.task.queue.is_empty()).then(|| s.task.queue.remove(0)); + if next.is_none() { + s.busy = false; + } + next + }; + if self.state.lock().task.status == AgentStatus::Archived { + self.persist(); + return; + } + if let Ok(stop) = &result + && stop == "refusal" + { + self.push_entry(AgentEntry::Error { message: "The agent declined to continue.".into() }); + } + match (next, result) { + (Some(queued), _) => prompt = queued.prompt, + (None, Ok(_)) => return self.set_status(AgentStatus::Ready, None), + (None, Err(message)) => { + self.push_entry(AgentEntry::Error { message: message.clone() }); + return self.set_status(AgentStatus::Failed, Some(message)); + } + } + } + } + + /// Sends one prompt, starting the agent first if needed; returns the stop reason. + async fn turn(self: &Arc, prompt: &AgentPrompt) -> Result { + let attachments = prompt + .files + .iter() + .map(|f| file_name(&f.0)) + .chain(prompt.images.iter().map(|i| i.name.clone().unwrap_or_else(|| "image".into()))) + .collect(); + self.push_entry(AgentEntry::User { text: prompt.text.clone(), attachments }); + self.set_status(AgentStatus::Running, None); + let live = self.ensure_session().await?; + if !prompt.images.is_empty() && !live.images { + return Err("This agent can't read images.".into()); + } + let contents = if live.embedded && !prompt.files.is_empty() { + let files: Vec = prompt.files.iter().map(|f| f.0.clone()).collect(); + tokio::task::spawn_blocking(move || files.iter().map(|f| embeddable(f)).collect()).await.unwrap_or_default() + } else { + Vec::new() + }; + let result: Result = live + .peer + .request("session/prompt", json!({ "sessionId": live.session, "prompt": blocks(prompt, &contents) }), None) + .await; + match result { + Ok(v) => Ok(v.get("stopReason").and_then(Value::as_str).unwrap_or("end_turn").to_owned()), + Err(e) => Err(self.describe(e)), + } + } + + async fn ensure_session(self: &Arc) -> Result { + let _guard = self.connecting.lock().await; + if let Some(live) = self.live.lock().clone() + && !live.peer.is_closed() + { + return Ok(live); + } + let (provider_id, cwd, saved) = { + let s = self.state.lock(); + (s.task.provider.clone(), s.task.cwd.0.clone(), s.session_id.clone()) + }; + let spec = self.shared.spec(&provider_id).ok_or_else(|| format!("unknown agent {provider_id}"))?; + self.stderr.lock().clear(); + let weak = Arc::downgrade(self); + let on_stderr: Box = Box::new(move |line| { + if let Some(t) = weak.upgrade() { + let mut buf = t.stderr.lock(); + if buf.len() == STDERR_LINES { + buf.pop_front(); + } + buf.push_back(line); + } + }); + let client = Arc::new(Client { task: Arc::downgrade(self) }); + let peer = (self.shared.connector)(&spec, &cwd, client, on_stderr) + .map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?; + + let init: Value = peer + .request( + "initialize", + json!({ + "protocolVersion": 1, + "clientCapabilities": { "fs": { "readTextFile": false, "writeTextFile": false }, "terminal": false }, + "clientInfo": { "name": "OpenIDEA", "version": env!("CARGO_PKG_VERSION") }, + }), + None, + ) + .await + .map_err(|e| self.describe(e))?; + let can_load = init.pointer("/agentCapabilities/loadSession").and_then(Value::as_bool) == Some(true); + let capable = |c: &str| { + init.pointer(&format!("/agentCapabilities/promptCapabilities/{c}")).and_then(Value::as_bool) == Some(true) + }; + let (images, embedded) = (capable("image"), capable("embeddedContext")); + let cwd_s = cwd.to_string_lossy().into_owned(); + + let mut session = None; + if let (Some(id), true) = (saved, can_load) { + self.replaying.store(true, Ordering::Release); + let loaded: Result = + peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await; + tokio::time::sleep(REPLAY_GRACE).await; + self.replaying.store(false, Ordering::Release); + match loaded { + Ok(v) => session = Some((id, v)), + Err(e) => tracing::info!("agent task {}: session/load failed, starting a new session: {e}", self.id), + } + } + let (session_id, result) = match session { + Some(s) => s, + None => { + let v: Value = peer + .request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None) + .await + .map_err(|e| match e { + RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => { + format!("{} needs you to sign in. {}", spec.name, spec.login_hint) + } + e => self.describe(e), + })?; + let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned(); + (id, v) + } + }; + + // The session's settings, then the user's choices on top. + // A session that reports none keeps the last known ones, but what is set is unknown. + let reported = config::options_of(&result); + let known = !reported.is_empty(); + let (mut options, wanted) = { + let s = self.state.lock(); + (if known { reported } else { s.task.config.clone() }, s.wanted.clone()) + }; + self.shared.remember(&provider_id, Some(&options), None); + for choice in &wanted { + let Some(o) = options.iter().find(|o| o.id == choice.id).cloned() else { continue }; + let already = known && o.current.as_deref() == Some(choice.value.as_str()); + if already || !o.values.iter().any(|v| v.value == choice.value) { + continue; + } + let (method, mut params) = config::set_request(&o, &choice.value); + params["sessionId"] = json!(session_id); + match peer.request::<_, Value>(method, params, None).await { + Ok(reply) => { + if let Some(fresh) = reply.get("configOptions") { + config::merge_update(&mut options, fresh); + } + if let Some(o) = options.iter_mut().find(|o| o.id == choice.id) { + o.current = Some(choice.value.clone()); + } + } + Err(e) => tracing::warn!("agent task {}: setting {} to {} failed: {e}", self.id, o.id, choice.value), + } + } + { + let mut s = self.state.lock(); + s.task.model = model_of(&options).or(s.task.model.take()); + s.task.config = options; + s.session_id = Some(session_id.clone()); + } + self.persist(); + self.emit_task(); + let live = Live { peer, session: session_id, images, embedded }; + *self.live.lock() = Some(live.clone()); + Ok(live) + } + + /// A user-facing message for a failed request, with the agent's last stderr lines when it died. + fn describe(&self, e: RpcError) -> String { + match e { + RpcError::Closed => { + let tail: Vec = self.stderr.lock().iter().cloned().collect(); + if tail.is_empty() { + "The agent process exited.".into() + } else { + format!("The agent process exited:\n{}", tail.join("\n")) + } + } + RpcError::Server { message, .. } => message, + e => e.to_string(), + } + } + + fn on_update(self: &Arc, update: &Value) { + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default(); + // Session state (also while `session/load` replays history). + let provider = self.state.lock().task.provider.clone(); + let task_changed = { + let mut s = self.state.lock(); + match kind { + "config_option_update" => { + if let Some(options) = update.get("configOptions") { + config::merge_update(&mut s.task.config, options); + s.task.model = model_of(&s.task.config).or(s.task.model.take()); + } + true + } + "current_mode_update" => { + if let Some(mode) = update.get("currentModeId").and_then(Value::as_str) { + config::set_mode(&mut s.task.config, mode); + } + true + } + "available_commands_update" => { + s.task.commands = config::commands_of(update); + true + } + "usage_update" => { + s.task.usage = config::usage_of(update).or(s.task.usage.take()); + true + } + _ => false, + } + }; + if task_changed { + let (config, commands) = { + let s = self.state.lock(); + (s.task.config.clone(), s.task.commands.clone()) + }; + self.shared.remember(&provider, Some(&config), Some(&commands)); + if kind != "usage_update" { + self.persist(); + } + self.emit_task(); + return; + } + if self.replaying.load(Ordering::Acquire) { + return; + } + let applied = self.state.lock().timeline.apply(update); + self.mark_dirty(applied.changed); + } + + /// Blocks the calling (blocking-pool) thread until the user answers. + fn ask_permission(self: &Arc, params: &Value) -> Value { + let id = self.next_permission.fetch_add(1, Ordering::Relaxed); + let title = params + .pointer("/toolCall/title") + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + .unwrap_or("The agent asks for permission") + .to_owned(); + let options: Vec = params + .get("options") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|o| { + Some(AgentPermissionOption { + id: o.get("optionId")?.as_str()?.to_owned(), + name: o.get("name").and_then(Value::as_str).unwrap_or_default().to_owned(), + kind: o.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned(), + }) + }) + .collect() + }) + .unwrap_or_default(); + // The tool call it is about arrives as a notification on another thread: let it land first. + if let Some(tool) = params.pointer("/toolCall/toolCallId").and_then(Value::as_str) { + for _ in 0..20 { + if self.state.lock().timeline.has_tool(tool) { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + let (tx, rx) = std::sync::mpsc::channel(); + let entry = self.push_entry(AgentEntry::Permission { id, title, options: options.clone(), chosen: None }); + self.permissions.lock().insert(id, tx); + self.set_status(AgentStatus::NeedsInput, None); + + let answer = rx.recv().ok().flatten(); + let name = answer + .as_ref() + .and_then(|a| options.iter().find(|o| &o.id == a)) + .map_or_else(|| "Cancelled".to_owned(), |o| o.name.clone()); + { + let mut s = self.state.lock(); + if let Some(AgentEntry::Permission { chosen, .. }) = s.timeline.entries.get_mut(entry) { + *chosen = Some(name); + } + } + self.mark_dirty([entry]); + let still_asking = !self.permissions.lock().is_empty(); + if !still_asking && self.state.lock().task.status == AgentStatus::NeedsInput { + self.set_status(AgentStatus::Running, None); + } + match answer { + Some(option) => json!({ "outcome": { "outcome": "selected", "optionId": option } }), + None => json!({ "outcome": { "outcome": "cancelled" } }), + } + } + + fn cancel_permissions(&self) { + for (_, reply) in self.permissions.lock().drain() { + let _ = reply.send(None); + } + } + + fn cancel(&self) { + self.cancel_permissions(); + if let Some(live) = self.live.lock().clone() { + let _ = live.peer.notify("session/cancel", json!({ "sessionId": live.session })); + } + } + + fn kill(&self) { + self.cancel_permissions(); + if let Some(live) = self.live.lock().take() { + live.peer.kill(); + } + } +} + +/// Answers the agent's requests on behalf of one task. +struct Client { + task: Weak, +} + +impl Handler for Client { + fn on_notification(&self, method: &str, params: Value) { + if method == "session/update" + && let (Some(task), Some(update)) = (self.task.upgrade(), params.get("update")) + { + task.on_update(update); + } + } + + fn on_request(&self, method: &str, params: Value) -> Result { + let task = self.task.upgrade().ok_or((codes::INTERNAL_ERROR, "task closed".to_owned()))?; + match method { + "session/request_permission" => Ok(task.ask_permission(¶ms)), + _ => Err((codes::METHOD_NOT_FOUND, format!("unsupported method {method}"))), + } + } +} + +/// The prompt trimmed, or `None` when there is nothing to send. +fn clean(mut prompt: AgentPrompt) -> Option { + prompt.text = prompt.text.trim().to_owned(); + (!prompt.text.is_empty() || !prompt.images.is_empty() || !prompt.files.is_empty()).then_some(prompt) +} + +/// ACP content blocks: the text, each file (embedded when its text is in `contents`, else +/// a `resource_link`), then the images. +fn blocks(prompt: &AgentPrompt, contents: &[Option]) -> Vec { + let mut out = Vec::new(); + if !prompt.text.is_empty() { + out.push(json!({ "type": "text", "text": prompt.text })); + } + for (i, f) in prompt.files.iter().enumerate() { + let uri = file_uri(&f.0); + out.push(match contents.get(i).cloned().flatten() { + Some(text) => json!({ "type": "resource", "resource": { "uri": uri, "text": text } }), + None => json!({ "type": "resource_link", "uri": uri, "name": file_name(&f.0) }), + }); + } + for i in &prompt.images { + out.push(json!({ "type": "image", "mimeType": i.mime_type, "data": i.data })); + } + out +} + +/// The text of a referenced file, when it is a small UTF-8 file. +fn embeddable(path: &Path) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_EMBED { + return None; + } + String::from_utf8(std::fs::read(path).ok()?).ok() +} + +/// A task file from before a field existed, with that field's empty value. +fn upgrade(mut file: Value) -> Value { + if let Some(task) = file.get_mut("task").and_then(Value::as_object_mut) { + for (key, empty) in + [("config", json!([])), ("commands", json!([])), ("usage", Value::Null), ("queue", json!([]))] + { + task.entry(key).or_insert(empty); + } + } + for entry in file.get_mut("entries").and_then(Value::as_array_mut).into_iter().flatten() { + if entry.get("type").and_then(Value::as_str) == Some("user") + && let Some(e) = entry.as_object_mut() + { + e.entry("attachments").or_insert(json!([])); + } + } + file +} + +fn model_of(config: &[AgentConfigOption]) -> Option { + config.iter().find(|o| o.category.as_deref() == Some("model")).and_then(|o| o.current.clone()) +} + +fn file_name(path: &Path) -> String { + path.file_name().map_or_else(|| path.to_string_lossy().into_owned(), |n| n.to_string_lossy().into_owned()) +} + +/// `file://` URI of an absolute path, percent-encoded. +fn file_uri(path: &Path) -> String { + let s = path.to_string_lossy().replace('\\', "/"); + let mut out = String::from(if s.starts_with('/') { "file://" } else { "file:///" }); + for b in s.bytes() { + if b.is_ascii_alphanumeric() || b"/-._~:".contains(&b) { + out.push(b as char); + } else { + out.push_str(&format!("%{b:02X}")); + } + } + out +} + +fn title_of(prompt: &AgentPrompt) -> String { + let line = prompt.text.lines().map(str::trim).find(|l| !l.is_empty()); + let line = match line { + Some(l) => l.to_owned(), + None => prompt.files.first().map(|f| file_name(&f.0)).unwrap_or_else(|| "Image".into()), + }; + match line.char_indices().nth(60) { + Some((cut, _)) => format!("{}…", &line[..cut]), + None => line, + } +} + +fn now_ms() -> f64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0.0, |d| d.as_millis() as f64) +} + +fn write_file(path: &Path, bytes: &[u8]) -> Result<(), String> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| e.to_string()) +} + +async fn blocking(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? +} + +#[cfg(test)] +mod tests; diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs new file mode 100644 index 00000000..17ed1b89 --- /dev/null +++ b/crates/ide-agent/src/model.rs @@ -0,0 +1,285 @@ +//! IPC types: tasks, their timeline entries and the events streamed to the UI. + +use ide_core::IpcPath; +use serde::{Deserialize, Serialize}; +use specta::Type; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentStatus { + /// Creating the worktree / starting the agent. + Starting, + Running, + /// Waiting for a permission answer. + NeedsInput, + /// The turn ended; the result can be reviewed or the conversation continued. + Ready, + Failed, + Archived, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum Isolation { + /// A new git worktree on its own branch. + Worktree, + /// The project's own checkout. + Local, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentTask { + pub id: String, + pub title: String, + /// [`AgentProvider::id`]. + pub provider: String, + /// Model the task runs on (its `model` setting), when known. + pub model: Option, + pub status: AgentStatus, + pub isolation: Isolation, + /// Directory the agent works in (the worktree, or the project root). + pub cwd: IpcPath, + /// Branch of the task's worktree. + pub branch: Option, + /// Branch (or revision) the worktree was created from. + pub base: Option, + /// Commit the task's changes are compared against. + pub base_commit: Option, + /// Why the task failed, when it did. + pub error: Option, + pub created_ms: f64, + pub updated_ms: f64, + /// The session's settings (model, mode, thinking effort…) as last reported, with the + /// user's choices applied. + pub config: Vec, + /// Slash commands the agent offers. + pub commands: Vec, + /// Context window use, when the agent reports it. + pub usage: Option, + /// Messages waiting for the current turn to end, oldest first. + pub queue: Vec, +} + +/// One value of a select-type session setting. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfigValue { + pub value: String, + pub name: String, + pub description: Option, +} + +/// How a setting is changed. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentConfigVia { + /// `session/set_config_option`. + #[default] + Option, + /// `session/set_mode` (agents that report `modes` instead of a mode config option). + Mode, + /// `session/set_model` (agents that report `models` instead of a model config option). + Model, +} + +/// A session setting: an ACP select config option, or the older `modes` / `models`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfigOption { + pub id: String, + pub name: String, + pub description: Option, + /// ACP category: `model`, `mode`, `thought_level`, or agent-specific. + pub category: Option, + pub current: Option, + pub values: Vec, + pub via: AgentConfigVia, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentCommand { + /// Without the leading `/`. + pub name: String, + pub description: String, + /// What to type after the command, when it takes input. + pub hint: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentUsage { + /// Tokens in the context window. + pub used: u32, + /// Size of the context window. + pub size: u32, + /// Session cost so far. + pub cost: Option, + pub currency: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentImage { + pub mime_type: String, + /// Base64, without a `data:` prefix. + pub data: String, + pub name: Option, +} + +/// A message to the agent: text, plus files to look at and images. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentPrompt { + pub text: String, + pub files: Vec, + pub images: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentQueued { + pub id: String, + pub prompt: AgentPrompt, +} + +/// A setting chosen for a new task, by [`AgentConfigOption::id`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfigChoice { + pub id: String, + pub value: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum AgentToolStatus { + Pending, + Running, + Completed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentToolDiff { + pub path: IpcPath, + pub added: u32, + pub removed: u32, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentPlanItem { + pub content: String, + /// `pending`, `in_progress` or `completed`. + pub status: String, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentPermissionOption { + pub id: String, + pub name: String, + /// `allow_once`, `allow_always`, `reject_once` or `reject_always`. + pub kind: String, +} + +/// One item of a task's conversation. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum AgentEntry { + User { + text: String, + /// Names of the files and images sent with it. + attachments: Vec, + }, + Agent { + text: String, + }, + Thought { + text: String, + }, + Tool { + id: String, + title: String, + /// ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… + kind: String, + status: AgentToolStatus, + locations: Vec, + /// Text the tool reported (command output…), truncated. + output: Option, + diffs: Vec, + }, + Plan { + items: Vec, + }, + Permission { + id: u32, + title: String, + options: Vec, + /// Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". + chosen: Option, + }, + Error { + message: String, + }, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentProvider { + pub id: String, + pub name: String, + /// The command line that starts its ACP server. + pub command: String, + /// Whether the command was found (on `PATH`). + pub installed: bool, + /// Settings (models, modes…) the agent offered last time it ran; empty before its first session. + pub config: Vec, + /// Slash commands it offered last time. + pub commands: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct NewAgentTask { + pub prompt: AgentPrompt, + pub provider: String, + /// Settings for the new session (model, mode…). + pub config: Vec, + pub isolation: Isolation, + /// Branch to start the worktree from; the current branch when `None`. + pub base: Option, +} + +/// A file the task changed, relative to [`AgentTask::base_commit`]. +#[derive(Clone, Debug, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AgentChange { + pub path: IpcPath, + pub rel_path: String, + pub kind: ide_vcs::ChangeKind, + pub added: u32, + pub removed: u32, +} + +/// Streamed to the window that subscribed (`AgentManager::subscribe`). +#[derive(Clone, Debug, Serialize, Type)] +#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] +pub enum AgentEvent { + /// A task was created or its metadata changed. + Task { + task: AgentTask, + }, + Removed { + id: String, + }, + /// Entry `index` of `task` was added or replaced. + Entry { + task: String, + index: u32, + entry: AgentEntry, + }, +} diff --git a/crates/ide-agent/src/provider.rs b/crates/ide-agent/src/provider.rs new file mode 100644 index 00000000..27435eb9 --- /dev/null +++ b/crates/ide-agent/src/provider.rs @@ -0,0 +1,158 @@ +//! Agents and how to launch their ACP servers: the built-in ones, plus user-defined +//! agents from `/agents/.toml`. + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ProviderSpec { + pub id: String, + pub name: String, + pub command: String, + pub args: Vec, + pub env: Vec<(String, String)>, + /// How to sign in when the agent reports it needs authentication. + pub login_hint: String, +} + +/// `(id, name, command line, sign-in hint)`. npm adapters are pinned so a session's +/// protocol does not change under the user. +const BUILTIN: &[(&str, &str, &[&str], &str)] = &[ + ( + "claude", + "Claude Code", + &["npx", "-y", "@agentclientprotocol/claude-agent-acp@0.97.0"], + "Run `claude` in a terminal and sign in.", + ), + ("codex", "Codex", &["npx", "-y", "@zed-industries/codex-acp@0.16.0"], "Run `codex login` in a terminal."), + ("gemini", "Gemini CLI", &["gemini", "--experimental-acp"], "Run `gemini` in a terminal and sign in."), + ("copilot", "GitHub Copilot", &["copilot", "--acp"], "Run `copilot` in a terminal and sign in."), + ("cursor", "Cursor", &["cursor-agent", "acp"], "Run `cursor-agent login` in a terminal."), + ("opencode", "OpenCode", &["opencode", "acp"], "Run `opencode auth login` in a terminal."), + ("qwen", "Qwen Code", &["qwen", "--experimental-acp"], "Run `qwen` in a terminal and sign in."), + ("kimi", "Kimi CLI", &["kimi", "acp"], "Run `kimi` in a terminal and sign in."), + ("goose", "Goose", &["goose", "acp"], "Run `goose configure` in a terminal."), + ("auggie", "Auggie", &["auggie", "--acp"], "Run `auggie login` in a terminal."), + ("kiro", "Kiro", &["kiro-cli", "acp"], "Run `kiro-cli login` in a terminal."), + ("vibe", "Mistral Vibe", &["vibe-acp"], "Run `vibe` in a terminal and sign in."), +]; + +fn builtins() -> Vec { + BUILTIN + .iter() + .map(|(id, name, cmd, hint)| ProviderSpec { + id: (*id).into(), + name: (*name).into(), + command: cmd[0].into(), + args: cmd[1..].iter().map(|a| (*a).to_owned()).collect(), + env: Vec::new(), + login_hint: (*hint).into(), + }) + .collect() +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CustomAgent { + name: Option, + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +/// Built-in agents, then the `*.toml` agents of `custom_dir`; a custom agent with a +/// built-in id replaces it. +pub(crate) fn load(custom_dir: Option<&Path>) -> Vec { + let mut specs = builtins(); + let mut files: Vec = custom_dir + .and_then(|d| std::fs::read_dir(d).ok()) + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "toml")) + .collect(); + files.sort(); + for path in files { + let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue }; + let parsed = std::fs::read_to_string(&path) + .map_err(|e| e.to_string()) + .and_then(|t| toml::from_str::(&t).map_err(|e| e.to_string())); + let agent = match parsed { + Ok(a) => a, + Err(e) => { + tracing::warn!("skipping agent {}: {e}", path.display()); + continue; + } + }; + let mut env: Vec<(String, String)> = agent.env.into_iter().collect(); + env.sort(); + let spec = ProviderSpec { + name: agent.name.unwrap_or_else(|| id.clone()), + command: agent.command, + args: agent.args, + env, + login_hint: "Sign in with the agent's own command line first.".into(), + id, + }; + match specs.iter_mut().find(|s| s.id == spec.id) { + Some(existing) => *existing = spec, + None => specs.push(spec), + } + } + specs +} + +/// Whether `command` resolves to an executable (a path, or a name on `PATH`). +pub(crate) fn installed(command: &str) -> bool { + let p = Path::new(command); + if p.components().count() > 1 { + return is_executable(p); + } + let exts: &[&str] = if cfg!(windows) { &["exe", "cmd", "bat"] } else { &[] }; + std::env::var_os("PATH").is_some_and(|path| { + std::env::split_paths(&path).any(|dir| { + let c = dir.join(command); + is_executable(&c) || exts.iter().any(|e| is_executable(&c.with_extension(e))) + }) + }) +} + +fn is_executable(p: &Path) -> bool { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + p.metadata().is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } + #[cfg(not(unix))] + { + p.is_file() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn custom_agents_extend_and_override_the_builtins() { + let dir = tempfile::tempdir().unwrap(); + let write = |name: &str, text: &str| std::fs::write(dir.path().join(name), text).unwrap(); + write("mine.toml", "name = \"Mine\"\ncommand = \"/opt/mine\"\nargs = [\"acp\"]\n[env]\nK = \"v\"\n"); + write("gemini.toml", "command = \"gemini\"\nargs = [\"--acp\"]\n"); + write("broken.toml", "nope = 1\n"); + let specs = load(Some(dir.path())); + let mine = specs.iter().find(|s| s.id == "mine").unwrap(); + assert_eq!((mine.name.as_str(), mine.command.as_str()), ("Mine", "/opt/mine")); + assert_eq!(mine.env, vec![("K".to_owned(), "v".to_owned())]); + let gemini = specs.iter().find(|s| s.id == "gemini").unwrap(); + assert_eq!((gemini.name.as_str(), gemini.args.as_slice()), ("gemini", &["--acp".to_owned()][..])); + assert!(!specs.iter().any(|s| s.id == "broken")); + assert_eq!(specs.iter().filter(|s| s.id == "claude").count(), 1); + assert!(!installed("/definitely/not/here")); + assert!(installed("sh")); + } +} diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs new file mode 100644 index 00000000..a41b17fc --- /dev/null +++ b/crates/ide-agent/src/tests.rs @@ -0,0 +1,423 @@ +//! End-to-end task flow against an in-memory ACP agent. + +use super::*; +use std::sync::OnceLock; +use tokio::io::duplex; + +/// Says "Hi", edits a file, asks to run a command and reports what it was allowed. +#[derive(Default)] +struct FakeAgent { + peer: OnceLock, + calls: Mutex>, +} + +impl FakeAgent { + fn update(&self, session: &str, update: Value) { + let peer = self.peer.get().unwrap(); + peer.notify("session/update", json!({ "sessionId": session, "update": update })).unwrap(); + } + + fn say(&self, session: &str, text: &str) { + self.update( + session, + json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": text}}), + ); + } +} + +impl Handler for FakeAgent { + fn on_notification(&self, method: &str, _: Value) { + self.calls.lock().push(method.to_owned()); + } + + fn on_request(&self, method: &str, params: Value) -> Result { + self.calls.lock().push(method.to_owned()); + let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned(); + match method { + "initialize" => Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": true}})), + "session/new" => Ok(json!({"sessionId": "s1", + "modes": {"currentModeId": "ask", "availableModes": [{"id": "ask", "name": "Ask"}, {"id": "code", "name": "Code"}]}, + "configOptions": [{"id": "model", "category": "model", "type": "select", "currentValue": "big", + "options": [{"value": "big", "name": "Big"}, {"value": "small", "name": "Small"}]}]})), + "session/load" => { + self.say(&session, "replayed history"); + Ok(json!({})) + } + "session/set_config_option" | "session/set_mode" => { + self.calls.lock().push(format!("{method} {}", params.get("value").or(params.get("modeId")).unwrap())); + Ok(json!({})) + } + "session/prompt" => { + let text = params.pointer("/prompt/0/text").and_then(Value::as_str).unwrap_or_default().to_owned(); + if text == "again" { + self.say(&session, "second turn"); + return Ok(json!({"stopReason": "end_turn"})); + } + if text == "wait" { + // Works until cancelled. + while !self.calls.lock().iter().any(|c| c == "session/cancel") { + std::thread::sleep(Duration::from_millis(5)); + } + return Ok(json!({"stopReason": "cancelled"})); + } + self.update( + &session, + json!({"sessionUpdate": "available_commands_update", + "availableCommands": [{"name": "review", "description": "Review", "input": {"hint": "focus"}}]}), + ); + self.update(&session, json!({"sessionUpdate": "usage_update", "used": 50, "size": 1000})); + self.say(&session, "Hi"); + self.update( + &session, + json!({"sessionUpdate": "tool_call", "toolCallId": "t1", "title": "Edit a.rs", + "kind": "edit", "status": "completed", + "content": [{"type": "diff", "path": "/p/a.rs", "oldText": "a\n", "newText": "b\n"}]}), + ); + let peer = self.peer.get().unwrap().clone(); + let answer: Value = tokio::runtime::Handle::current() + .block_on(peer.request( + "session/request_permission", + json!({"sessionId": session, "toolCall": {"toolCallId": "t1", "title": "Run cargo test"}, + "options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"}, + {"optionId": "no", "name": "Reject", "kind": "reject_once"}]}), + None, + )) + .unwrap(); + let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none"); + self.say(&session, &format!("chose {chosen}")); + Ok(json!({"stopReason": "end_turn"})) + } + _ => Err((codes::METHOD_NOT_FOUND, method.to_owned())), + } + } +} + +fn manager(dir: &Path, agent: Arc) -> AgentManager { + let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| { + let (a, b) = duplex(1 << 20); + let (ar, aw) = tokio::io::split(a); + let (br, bw) = tokio::io::split(b); + let server = Peer::connect_framed(br, bw, agent.clone(), Framing::Lines); + // Keep the latest connection: a restarted manager connects again. + let _ = agent.peer.set(server); + Ok(Peer::connect_framed(ar, aw, client, Framing::Lines)) + }); + AgentManager::with_connector( + AgentPaths { + project_root: dir.to_path_buf(), + store_dir: dir.join("store"), + worktrees_dir: dir.join("worktrees"), + models_file: dir.join("models.json"), + custom_agents_dir: None, + }, + tokio::runtime::Handle::current(), + connector, + ) +} + +async fn wait_for(mgr: &AgentManager, id: &str, status: AgentStatus) { + for _ in 0..500 { + if mgr.task(id).unwrap().state.lock().task.status == status { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("task never reached {status:?}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_task_runs_asks_for_permission_and_survives_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let events = Arc::new(Mutex::new(Vec::::new())); + let sink = events.clone(); + mgr.subscribe(Arc::new(move |e| sink.lock().push(e))); + + let task = mgr + .create(NewAgentTask { + prompt: text("Fix the lexer\nmore detail"), + provider: "claude".into(), + config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + assert_eq!(task.title, "Fix the lexer"); + assert_ne!(mgr.task(&task.id).unwrap().state.lock().task.status, AgentStatus::Ready, "a new task is not settled"); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + + let permission = mgr + .entries(&task.id) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + mgr.respond(&task.id, permission, Some("yes".into())).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + + let entries = mgr.entries(&task.id).unwrap(); + let kinds: Vec = entries + .iter() + .map(|e| match e { + AgentEntry::User { text, .. } | AgentEntry::Agent { text } => text.clone(), + AgentEntry::Tool { title, .. } => title.clone(), + AgentEntry::Permission { chosen, .. } => format!("permission: {}", chosen.clone().unwrap_or_default()), + other => format!("{other:?}"), + }) + .collect(); + assert_eq!(kinds, ["Fix the lexer\nmore detail", "Hi", "Edit a.rs", "permission: Allow", "chose yes"]); + assert!( + agent.calls.lock().contains(&"session/set_config_option \"small\"".to_owned()), + "switched to the chosen model" + ); + { + let t = mgr.task(&task.id).unwrap().state.lock().task.clone(); + assert_eq!(t.model.as_deref(), Some("small")); + let modes = t.config.iter().find(|o| o.id == "mode").unwrap(); + assert_eq!((modes.via, modes.current.as_deref()), (AgentConfigVia::Mode, Some("ask"))); + assert_eq!(t.commands.iter().map(|c| c.name.as_str()).collect::>(), ["review"]); + assert_eq!(t.usage.as_ref().map(|u| (u.used, u.size)), Some((50, 1000))); + } + mgr.set_config(&task.id, "mode", "code".into()).await.unwrap(); + assert!(agent.calls.lock().contains(&"session/set_mode \"code\"".to_owned())); + assert!(mgr.set_config(&task.id, "mode", "nope".into()).await.is_err()); + + tokio::time::sleep(FLUSH_DELAY * 3).await; + { + let events = events.lock(); + assert!( + events.iter().any(|e| matches!(e, AgentEvent::Task { task } if task.status == AgentStatus::NeedsInput)) + ); + let last_entry = events.iter().rev().find_map(|e| match e { + AgentEvent::Entry { index, entry, .. } => Some((*index, entry.clone())), + _ => None, + }); + assert_eq!(last_entry, Some((4, AgentEntry::Agent { text: "chose yes".into() }))); + } + let providers = mgr.providers(); + let claude = providers.iter().find(|p| p.id == "claude").unwrap(); + assert_eq!(claude.config.iter().map(|o| o.id.as_str()).collect::>(), ["model", "mode"]); + assert_eq!(claude.commands.len(), 1); + + // A new manager (IDE restart) finds the task and resumes its session without duplicating history. + mgr.shutdown(); + let agent2 = Arc::new(FakeAgent::default()); + let mgr2 = manager(tmp.path(), agent2.clone()); + assert_eq!(mgr2.entries(&task.id).unwrap().len(), 5); + assert_eq!(mgr2.providers().iter().find(|p| p.id == "claude").unwrap().config.len(), 2); + mgr2.prompt(&task.id, text("again"), false).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + wait_for(&mgr2, &task.id, AgentStatus::Ready).await; + let entries = mgr2.entries(&task.id).unwrap(); + assert_eq!(entries.len(), 7); + assert_eq!(entries[6], AgentEntry::Agent { text: "second turn".into() }); + assert!(agent2.calls.lock().contains(&"session/load".to_owned())); + // The chosen settings are re-applied to the reloaded session. + let calls = agent2.calls.lock().clone(); + assert!(calls.contains(&"session/set_mode \"code\"".to_owned()), "{calls:?}"); +} + +fn text(t: &str) -> AgentPrompt { + AgentPrompt { text: t.into(), ..AgentPrompt::default() } +} + +#[tokio::test(flavor = "multi_thread")] +async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() { + let tmp = tempfile::tempdir().unwrap(); + let agent = Arc::new(FakeAgent::default()); + let mgr = manager(tmp.path(), agent.clone()); + let task = mgr + .create(NewAgentTask { + prompt: text("wait"), + provider: "claude".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Running).await; + mgr.prompt(&task.id, text("again"), false).await.unwrap(); + let queue = mgr.task(&task.id).unwrap().state.lock().task.queue.clone(); + assert_eq!(queue.iter().map(|q| q.prompt.text.as_str()).collect::>(), ["again"]); + assert!(mgr.prompt(&task.id, text(" "), false).await.is_err()); + + mgr.send_queued_now(&task.id, &queue[0].id).unwrap(); + for _ in 0..500 { + if mgr.entries(&task.id).unwrap().iter().any(|e| *e == AgentEntry::Agent { text: "second turn".into() }) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let users: Vec = mgr + .entries(&task.id) + .unwrap() + .into_iter() + .filter_map(|e| match e { + AgentEntry::User { text, .. } => Some(text), + _ => None, + }) + .collect(); + assert_eq!(users, ["wait", "again"]); + assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty()); + + // Stopping a turn keeps the queue for later. + mgr.prompt(&task.id, text("wait"), false).await.unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Running).await; + agent.calls.lock().retain(|c| c != "session/cancel"); + mgr.prompt(&task.id, text("later"), false).await.unwrap(); + mgr.cancel(&task.id).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let queued = mgr.task(&task.id).unwrap().state.lock().task.queue.clone(); + assert_eq!(queued.len(), 1); + assert_eq!(mgr.unqueue(&task.id, &queued[0].id).unwrap().prompt.text, "later"); + assert!(mgr.task(&task.id).unwrap().state.lock().task.queue.is_empty()); +} + +#[test] +fn prompts_become_acp_content_blocks() { + let p = AgentPrompt { + text: "look".into(), + files: vec![IpcPath(PathBuf::from("/p/my file.rs"))], + images: vec![AgentImage { mime_type: "image/png".into(), data: "AAA".into(), name: None }], + }; + assert_eq!( + blocks(&p, &[]), + [ + json!({"type": "text", "text": "look"}), + json!({"type": "resource_link", "uri": "file:///p/my%20file.rs", "name": "my file.rs"}), + json!({"type": "image", "mimeType": "image/png", "data": "AAA"}), + ] + ); + assert_eq!( + blocks(&p, &[Some("fn a() {}".into())])[1], + json!({"type": "resource", "resource": {"uri": "file:///p/my%20file.rs", "text": "fn a() {}"}}) + ); + let old = upgrade( + json!({"task": {"id": "t"}, "entries": [{"type": "user", "text": "hi"}, {"type": "agent", "text": "a"}]}), + ); + assert_eq!(old["task"]["queue"], json!([])); + assert_eq!(old["entries"][0]["attachments"], json!([])); + assert!(old["entries"][1].get("attachments").is_none()); + assert_eq!(title_of(&AgentPrompt { files: p.files.clone(), ..AgentPrompt::default() }), "my file.rs"); + assert!(clean(text(" ")).is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { + let tmp = tempfile::tempdir().unwrap(); + let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); + let task = mgr + .create(NewAgentTask { + prompt: text("do it"), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + mgr.cancel(&task.id).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + let entries = mgr.entries(&task.id).unwrap(); + assert!(entries.iter().any(|e| matches!(e, AgentEntry::Permission { chosen: Some(c), .. } if c == "Cancelled"))); + assert_eq!(entries.last(), Some(&AgentEntry::Agent { text: "chose none".into() })); + assert_eq!(mgr.task(&task.id).unwrap().state.lock().task.model.as_deref(), Some("big"), "the agent's default"); + + mgr.delete(&task.id, false).await.unwrap(); + assert!(mgr.entries(&task.id).is_err()); + assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists()); + assert!( + mgr.create(NewAgentTask { + prompt: text(" "), + provider: "codex".into(), + config: vec![], + isolation: Isolation::Local, + base: None + }) + .await + .is_err() + ); +} + +/// Drives the real Claude Code adapter (needs `npx`, network and a signed-in `claude`): +/// `OPENIDEA_AGENT_E2E=1 cargo test -p ide-agent real_claude -- --ignored --nocapture`. +#[tokio::test(flavor = "multi_thread")] +#[ignore] +async fn real_claude_task_in_a_worktree() { + if std::env::var_os("OPENIDEA_AGENT_E2E").is_none() { + return; + } + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + for args in [&["init", "-q", "-b", "main"][..], &["commit", "-q", "--allow-empty", "-m", "init"]] { + let ok = std::process::Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]) + .args(args) + .current_dir(&repo) + .status() + .unwrap() + .success(); + assert!(ok); + } + let mgr = AgentManager::new( + AgentPaths { + project_root: repo.clone(), + store_dir: tmp.path().join("store"), + worktrees_dir: tmp.path().join("wt"), + models_file: tmp.path().join("models.json"), + custom_agents_dir: None, + }, + tokio::runtime::Handle::current(), + ); + let task = mgr + .create(NewAgentTask { + prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."), + provider: "claude".into(), + config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }], + isolation: Isolation::Worktree, + base: None, + }) + .await + .unwrap(); + let deadline = std::time::Instant::now() + Duration::from_secs(240); + loop { + assert!(std::time::Instant::now() < deadline, "timed out"); + let status = mgr.task(&task.id).unwrap().state.lock().task.status; + match status { + AgentStatus::NeedsInput => { + let pending = mgr.entries(&task.id).unwrap().into_iter().rev().find_map(|e| match e { + AgentEntry::Permission { id, options, chosen: None, .. } => Some((id, options)), + _ => None, + }); + if let Some((id, options)) = pending { + let allow = options.iter().find(|o| o.kind == "allow_once").or(options.first()).unwrap(); + eprintln!("allowing: {}", allow.name); + let _ = mgr.respond(&task.id, id, Some(allow.id.clone())); + } + } + AgentStatus::Ready | AgentStatus::Failed => break, + _ => {} + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + for e in mgr.entries(&task.id).unwrap() { + eprintln!("{e:?}"); + } + let t = mgr.task(&task.id).unwrap().state.lock().task.clone(); + assert_eq!(t.status, AgentStatus::Ready, "{:?}", t.error); + assert_eq!(t.model.as_deref(), Some("haiku")); + let changes = mgr.changes(&task.id).await.unwrap(); + assert!(changes.iter().any(|c| c.rel_path == "hello.txt"), "{changes:?}"); + assert!(!repo.join("hello.txt").exists(), "the project checkout is untouched"); + assert!(!mgr.providers().iter().find(|p| p.id == "claude").unwrap().config.is_empty()); + mgr.shutdown(); +} diff --git a/crates/ide-agent/src/timeline.rs b/crates/ide-agent/src/timeline.rs new file mode 100644 index 00000000..c9a00a67 --- /dev/null +++ b/crates/ide-agent/src/timeline.rs @@ -0,0 +1,304 @@ +//! Folds ACP `session/update` notifications into a task's entries. + +use crate::model::{AgentEntry, AgentPlanItem, AgentToolDiff, AgentToolStatus}; +use ide_core::IpcPath; +use serde_json::Value; +use std::collections::HashMap; + +/// Tool output kept per call; longer output keeps its tail. +const MAX_OUTPUT: usize = 64 * 1024; + +#[derive(Default)] +pub(crate) struct Timeline { + pub entries: Vec, + tools: HashMap, + plan: Option, +} + +/// What a `session/update` changed in the timeline. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct Applied { + /// Indices of the entries added or changed. + pub changed: Vec, +} + +impl Timeline { + pub fn from_entries(entries: Vec) -> Self { + let mut t = Self { entries, ..Self::default() }; + for (i, e) in t.entries.iter().enumerate() { + match e { + AgentEntry::Tool { id, .. } => { + t.tools.insert(id.clone(), i); + } + AgentEntry::Plan { .. } => t.plan = Some(i), + _ => {} + } + } + t + } + + pub fn has_tool(&self, id: &str) -> bool { + self.tools.contains_key(id) + } + + pub fn push(&mut self, entry: AgentEntry) -> usize { + self.entries.push(entry); + self.entries.len() - 1 + } + + /// Applies one `session/update` `update` object. + pub fn apply(&mut self, update: &Value) -> Applied { + let mut out = Applied::default(); + let kind = update.get("sessionUpdate").and_then(Value::as_str).unwrap_or_default(); + match kind { + "agent_message_chunk" | "agent_thought_chunk" => { + let Some(text) = content_text(update.get("content")) else { return out }; + out.changed.push(self.append_text(kind == "agent_thought_chunk", &text)); + } + "tool_call" => { + let id = str_field(update, "toolCallId"); + let entry = AgentEntry::Tool { + id: id.clone(), + title: str_field(update, "title"), + kind: update.get("kind").and_then(Value::as_str).unwrap_or("other").to_owned(), + status: tool_status(update.get("status")).unwrap_or(AgentToolStatus::Pending), + locations: locations(update.get("locations")), + output: None, + diffs: Vec::new(), + }; + let index = match self.tools.get(&id) { + Some(&i) => { + self.entries[i] = entry; + i + } + None => { + let i = self.push(entry); + self.tools.insert(id, i); + i + } + }; + self.merge_tool(index, update); + out.changed.push(index); + } + "tool_call_update" => { + let Some(&index) = self.tools.get(&str_field(update, "toolCallId")) else { return out }; + self.merge_tool(index, update); + out.changed.push(index); + } + "plan" => { + let items = update + .get("entries") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .map(|e| AgentPlanItem { content: str_field(e, "content"), status: str_field(e, "status") }) + .collect() + }) + .unwrap_or_default(); + let entry = AgentEntry::Plan { items }; + let index = match self.plan { + Some(i) => { + self.entries[i] = entry; + i + } + None => self.push(entry), + }; + self.plan = Some(index); + out.changed.push(index); + } + _ => {} + } + out + } + + fn append_text(&mut self, thought: bool, text: &str) -> usize { + match self.entries.last_mut() { + Some(AgentEntry::Agent { text: t }) if !thought => t.push_str(text), + Some(AgentEntry::Thought { text: t }) if thought => t.push_str(text), + _ => { + let text = text.to_owned(); + return self.push(if thought { AgentEntry::Thought { text } } else { AgentEntry::Agent { text } }); + } + } + self.entries.len() - 1 + } + + fn merge_tool(&mut self, index: usize, update: &Value) { + let AgentEntry::Tool { title, kind, status, locations: locs, output, diffs, .. } = &mut self.entries[index] + else { + return; + }; + if let Some(t) = update.get("title").and_then(Value::as_str) { + *title = t.to_owned(); + } + if let Some(k) = update.get("kind").and_then(Value::as_str) { + *kind = k.to_owned(); + } + if let Some(s) = tool_status(update.get("status")) { + *status = s; + } + if update.get("locations").is_some_and(Value::is_array) { + *locs = locations(update.get("locations")); + } + let Some(content) = update.get("content").and_then(Value::as_array) else { return }; + let mut text = String::new(); + let mut new_diffs = Vec::new(); + for item in content { + match item.get("type").and_then(Value::as_str) { + Some("content") => { + if let Some(t) = content_text(item.get("content")) { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&t); + } + } + Some("diff") => { + let old = item.get("oldText").and_then(Value::as_str).unwrap_or_default(); + let new = item.get("newText").and_then(Value::as_str).unwrap_or_default(); + let (added, removed) = line_stats(old, new); + new_diffs.push(AgentToolDiff { path: IpcPath(str_field(item, "path").into()), added, removed }); + } + _ => {} + } + } + if !text.is_empty() { + *output = Some(tail(text)); + } + if !new_diffs.is_empty() { + *diffs = new_diffs; + } + } + + /// Settles what an ended turn left open: running tools and pending permissions. + pub fn end_turn(&mut self) -> Vec { + let mut changed = Vec::new(); + for (i, e) in self.entries.iter_mut().enumerate() { + match e { + AgentEntry::Tool { status, .. } + if matches!(status, AgentToolStatus::Pending | AgentToolStatus::Running) => + { + *status = AgentToolStatus::Failed; + changed.push(i); + } + AgentEntry::Permission { chosen, .. } if chosen.is_none() => { + *chosen = Some("Cancelled".into()); + changed.push(i); + } + _ => {} + } + } + changed + } +} + +fn str_field(v: &Value, key: &str) -> String { + v.get(key).and_then(Value::as_str).unwrap_or_default().to_owned() +} + +fn content_text(content: Option<&Value>) -> Option { + let c = content?; + match c.get("type").and_then(Value::as_str) { + Some("text") => c.get("text").and_then(Value::as_str).map(str::to_owned), + Some("resource_link") => c.get("uri").and_then(Value::as_str).map(str::to_owned), + Some("resource") => c.pointer("/resource/text").and_then(Value::as_str).map(str::to_owned), + _ => None, + } +} + +fn tool_status(v: Option<&Value>) -> Option { + Some(match v?.as_str()? { + "pending" => AgentToolStatus::Pending, + "in_progress" => AgentToolStatus::Running, + "completed" => AgentToolStatus::Completed, + "failed" => AgentToolStatus::Failed, + _ => return None, + }) +} + +fn locations(v: Option<&Value>) -> Vec { + v.and_then(Value::as_array) + .map(|a| a.iter().filter_map(|l| l.get("path")?.as_str()).map(|p| IpcPath(p.into())).collect()) + .unwrap_or_default() +} + +fn line_stats(old: &str, new: &str) -> (u32, u32) { + ide_vcs::diff_hunks(old, new).iter().fold((0, 0), |(a, r), h| (a + h.lines, r + h.old_lines)) +} + +fn tail(mut text: String) -> String { + if text.len() > MAX_OUTPUT { + let mut cut = text.len() - MAX_OUTPUT; + while !text.is_char_boundary(cut) { + cut += 1; + } + text.replace_range(..cut, "…"); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn chunks_merge_into_one_message_until_another_entry_comes() { + let mut t = Timeline::default(); + let chunk = |s: &str| json!({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": s}}); + assert_eq!(t.apply(&chunk("Hel")).changed, vec![0]); + assert_eq!(t.apply(&chunk("lo")).changed, vec![0]); + t.apply(&json!({"sessionUpdate": "agent_thought_chunk", "content": {"type": "text", "text": "hmm"}})); + t.apply(&chunk("again")); + assert_eq!( + t.entries, + vec![ + AgentEntry::Agent { text: "Hello".into() }, + AgentEntry::Thought { text: "hmm".into() }, + AgentEntry::Agent { text: "again".into() }, + ] + ); + } + + #[test] + fn tool_calls_update_in_place() { + let mut t = Timeline::default(); + t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c1", "title": "Edit a.rs", "kind": "edit", + "status": "pending", "locations": [{"path": "/p/a.rs", "line": 3}]})); + let applied = t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "c1", "status": "completed", + "content": [ + {"type": "diff", "path": "/p/a.rs", "oldText": "a\nb\n", "newText": "a\nB\nc\n"}, + {"type": "content", "content": {"type": "text", "text": "done"}} + ]})); + assert_eq!(applied.changed, vec![0]); + let AgentEntry::Tool { status, diffs, output, locations, .. } = &t.entries[0] else { panic!() }; + assert_eq!(*status, AgentToolStatus::Completed); + assert_eq!(diffs, &vec![AgentToolDiff { path: IpcPath("/p/a.rs".into()), added: 2, removed: 1 }]); + assert_eq!(output.as_deref(), Some("done")); + assert_eq!(locations, &vec![IpcPath("/p/a.rs".into())]); + // Unknown calls are ignored. + assert!(t.apply(&json!({"sessionUpdate": "tool_call_update", "toolCallId": "zz"})).changed.is_empty()); + } + + #[test] + fn plan_is_one_entry_and_end_turn_settles_open_items() { + let mut t = Timeline::default(); + let plan = + |s: &str| json!({"sessionUpdate": "plan", "entries": [{"content": "x", "priority": "high", "status": s}]}); + t.apply(&plan("pending")); + t.push(AgentEntry::Agent { text: "between".into() }); + assert_eq!(t.apply(&plan("completed")).changed, vec![0]); + t.apply(&json!({"sessionUpdate": "tool_call", "toolCallId": "c", "title": "Run", "status": "in_progress"})); + t.push(AgentEntry::Permission { id: 1, title: "Run?".into(), options: vec![], chosen: None }); + assert_eq!(t.end_turn(), vec![2, 3]); + let again = Timeline::from_entries(t.entries.clone()); + assert_eq!(again.plan, Some(0)); + assert_eq!(again.tools.get("c"), Some(&2)); + } + + #[test] + fn long_output_keeps_its_tail() { + let s = tail("é".repeat(MAX_OUTPUT)); + assert!(s.starts_with('…') && s.len() <= MAX_OUTPUT + 3); + } +} diff --git a/crates/ide-agent/src/worktree.rs b/crates/ide-agent/src/worktree.rs new file mode 100644 index 00000000..5563cf04 --- /dev/null +++ b/crates/ide-agent/src/worktree.rs @@ -0,0 +1,156 @@ +//! Task worktrees and the changes made in them. + +use crate::model::AgentChange; +use ide_core::{IdeError, IdeResult, IpcPath}; +use ide_vcs::{ChangeKind, Repo}; +use std::path::{Path, PathBuf}; + +pub(crate) struct Created { + pub path: PathBuf, + pub branch: String, + pub base: String, + pub base_commit: String, +} + +fn repo_at(path: &Path) -> IdeResult { + Repo::discover(path)?.ok_or_else(|| IdeError::unavailable(format!("{} is not in a git repository", path.display()))) +} + +/// The current branch (or commit when detached) and its commit. +pub(crate) fn head_of(path: &Path) -> IdeResult<(String, String)> { + let info = repo_at(path)?.branch_info()?; + let commit = info.head_commit.ok_or_else(|| IdeError::unavailable("the repository has no commits yet"))?; + Ok((info.head.unwrap_or_else(|| commit.clone()), commit)) +} + +/// `git worktree add -b agent/ / `. +pub(crate) fn create(project_root: &Path, worktrees_dir: &Path, title: &str, base: Option<&str>) -> IdeResult { + let repo = repo_at(project_root)?; + let (base, base_commit) = match base.map(str::trim).filter(|b| !b.is_empty()) { + Some(b) => { + let commit = + repo.merge_base(b, b)?.ok_or_else(|| IdeError::not_found(format!("no branch or revision {b}")))?; + (b.to_owned(), commit) + } + None => head_of(project_root)?, + }; + std::fs::create_dir_all(worktrees_dir).map_err(|e| IdeError::internal(e.to_string()))?; + let branch_exists = |s: &str| { + let r = format!("refs/heads/agent/{s}"); + repo.merge_base(&r, &r).ok().flatten().is_some() + }; + let slug = unique_slug(&slugify(title), |s| worktrees_dir.join(s).exists() || branch_exists(s)); + let path = worktrees_dir.join(&slug); + let branch = format!("agent/{slug}"); + repo.add_worktree(&path, &base_commit, Some(&branch))?; + Ok(Created { path, branch, base, base_commit }) +} + +pub(crate) fn remove(project_root: &Path, path: &Path) -> IdeResult<()> { + repo_at(project_root)?.remove_worktree(path, true) +} + +/// Files under `cwd` that differ from `base` (untracked files included), with line counts. +pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult> { + let repo = repo_at(cwd)?; + let root = repo.root().to_path_buf(); + let stats = repo.numstat_with_worktree(base)?; + let stat = |rel: &str| stats.iter().find(|(p, ..)| p == rel).map_or((0, 0), |&(_, a, r)| (a, r)); + let mut out: Vec = repo + .diff_with_worktree(base)? + .into_iter() + .map(|f| { + let (added, removed) = stat(&f.path); + AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed } + }) + .collect(); + for s in repo.status()? { + if s.unstaged == Some(ChangeKind::Untracked) && !out.iter().any(|c| c.rel_path == s.rel_path) { + let added = std::fs::read_to_string(&*s.path).map_or(0, |t| t.lines().count() as u32); + out.push(AgentChange { path: s.path, rel_path: s.rel_path, kind: ChangeKind::Added, added, removed: 0 }); + } + } + out.sort_by(|a, b| a.rel_path.cmp(&b.rel_path)); + Ok(out) +} + +pub(crate) fn file_at(cwd: &Path, path: &Path, rev: &str) -> IdeResult> { + repo_at(cwd)?.file_at(path, Some(rev)) +} + +/// A short ASCII branch-safe name from the task title. +pub(crate) fn slugify(title: &str) -> String { + let mut out = String::new(); + for c in title.chars() { + if c.is_ascii_alphanumeric() { + out.push(c.to_ascii_lowercase()); + } else if !out.is_empty() && !out.ends_with('-') { + out.push('-'); + } + if out.len() >= 32 { + break; + } + } + let out = out.trim_matches('-'); + if out.is_empty() { "task".to_owned() } else { out.to_owned() } +} + +fn unique_slug(base: &str, taken: impl Fn(&str) -> bool) -> String { + if !taken(base) { + return base.to_owned(); + } + (2..).map(|n| format!("{base}-{n}")).find(|s| !taken(s)).expect("unbounded") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn slugs_are_branch_safe() { + assert_eq!(slugify("Fix lexer UTF-16 columns!"), "fix-lexer-utf-16-columns"); + assert_eq!(slugify("修复 lexer 越界"), "lexer"); + assert_eq!(slugify("修复越界"), "task"); + assert_eq!(slugify(&"a".repeat(50)).len(), 32); + assert_eq!(unique_slug("x", |s| s == "x" || s == "x-2"), "x-3"); + } + + fn git(dir: &Path, args: &[&str]) { + let ok = std::process::Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]) + .args(args) + .current_dir(dir) + .status() + .unwrap() + .success(); + assert!(ok, "git {args:?}"); + } + + #[test] + fn worktree_changes_are_listed_against_the_base() { + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path().join("repo"); + std::fs::create_dir(&repo).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + std::fs::write(repo.join("a.txt"), "one\ntwo\n").unwrap(); + git(&repo, &["add", "."]); + git(&repo, &["commit", "-q", "-m", "init"]); + + let created = create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap(); + assert_eq!(created.branch, "agent/edit-a"); + assert_eq!(created.base, "main"); + std::fs::write(created.path.join("a.txt"), "one\nTWO\nthree\n").unwrap(); + std::fs::write(created.path.join("new.txt"), "x\ny\n").unwrap(); + + let got = changes(&created.path, &created.base_commit).unwrap(); + let summary: Vec<_> = got.iter().map(|c| (c.rel_path.as_str(), c.kind, c.added, c.removed)).collect(); + assert_eq!(summary, [("a.txt", ChangeKind::Modified, 2, 1), ("new.txt", ChangeKind::Added, 2, 0)]); + let base_text = file_at(&created.path, &created.path.join("a.txt"), &created.base_commit).unwrap(); + assert_eq!(base_text.as_deref(), Some("one\ntwo\n")); + + // A second task with the same title gets its own worktree. + assert_eq!(create(&repo, &tmp.path().join("wt"), "Edit A", None).unwrap().branch, "agent/edit-a-2"); + remove(&repo, &created.path).unwrap(); + assert!(!created.path.exists()); + } +} diff --git a/crates/ide-jsonrpc/src/lib.rs b/crates/ide-jsonrpc/src/lib.rs index 3ff6e985..13cdee9f 100644 --- a/crates/ide-jsonrpc/src/lib.rs +++ b/crates/ide-jsonrpc/src/lib.rs @@ -1,7 +1,8 @@ //! Transport shared by the LSP and DAP clients. //! //! * [`framing`]: `Content-Length` header framing over any async byte stream -//! (used by both protocols). +//! (used by both protocols), and newline-delimited JSON ([`Framing::Lines`], +//! the Agent Client Protocol). //! * [`Peer`]: a JSON-RPC 2.0 endpoint with request/response correlation, //! cancellation (`$/cancelRequest`) and handlers for server→client traffic. @@ -88,6 +89,34 @@ pub mod framing { writer.write_all(&buf).await?; writer.flush().await } + + /// Reads one newline-terminated message, skipping blank lines. + pub async fn read_line_message(reader: &mut R) -> std::io::Result>> { + let mut line = Vec::new(); + loop { + line.clear(); + let n = (&mut *reader).take(MAX_MESSAGE_LEN as u64 + 1).read_until(b'\n', &mut line).await?; + if n == 0 { + return Ok(None); + } + if line.len() > MAX_MESSAGE_LEN { + return Err(Error::new(ErrorKind::InvalidData, "message too large")); + } + let text = trim_line(&line); + if !text.iter().all(u8::is_ascii_whitespace) { + return Ok(Some(text.to_vec())); + } + } + } + + /// Writes one message followed by `\n` and flushes once. + pub async fn write_line_message(writer: &mut W, body: &[u8]) -> std::io::Result<()> { + let mut buf = Vec::with_capacity(body.len() + 1); + buf.extend_from_slice(body); + buf.push(b'\n'); + writer.write_all(&buf).await?; + writer.flush().await + } } use parking_lot::Mutex; @@ -210,6 +239,32 @@ impl Inner { } } +/// How messages are delimited on the wire. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Framing { + /// `Content-Length` headers (LSP, DAP). + #[default] + ContentLength, + /// One JSON message per line (Agent Client Protocol). + Lines, +} + +impl Framing { + async fn read(self, reader: &mut R) -> std::io::Result>> { + match self { + Framing::ContentLength => framing::read_message(reader).await, + Framing::Lines => framing::read_line_message(reader).await, + } + } + + async fn write(self, writer: &mut W, body: &[u8]) -> std::io::Result<()> { + match self { + Framing::ContentLength => framing::write_message(writer, body).await, + Framing::Lines => framing::write_line_message(writer, body).await, + } + } +} + /// A connected JSON-RPC peer. Cloning shares the connection. #[derive(Clone)] pub struct Peer { @@ -238,6 +293,19 @@ impl Peer { env: &[(String, String)], handler: Arc, on_stderr: Box, + ) -> std::io::Result { + Self::spawn_framed(command, args, cwd, env, handler, on_stderr, Framing::ContentLength) + } + + /// [`spawn`](Self::spawn) with the given wire framing. + pub fn spawn_framed( + command: &str, + args: &[String], + cwd: &std::path::Path, + env: &[(String, String)], + handler: Arc, + on_stderr: Box, + framing: Framing, ) -> std::io::Result { use std::process::Stdio; let mut cmd = tokio::process::Command::new(command); @@ -255,7 +323,7 @@ impl Peer { let stderr = child.stderr.take(); let pid = child.id(); - let peer = Self::start(stdout, stdin, handler, false, pid); + let peer = Self::start(stdout, stdin, handler, false, pid, framing); if let Some(stderr) = stderr { let name = command.to_owned(); @@ -299,10 +367,26 @@ impl Peer { R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, { - Self::start(reader, writer, handler, true, None) + Self::start(reader, writer, handler, true, None, Framing::ContentLength) } - fn start(reader: R, mut writer: W, handler: Arc, close_on_eof: bool, pid: Option) -> Self + /// [`connect`](Self::connect) with the given wire framing. + pub fn connect_framed(reader: R, writer: W, handler: Arc, framing: Framing) -> Self + where + R: AsyncRead + Unpin + Send + 'static, + W: AsyncWrite + Unpin + Send + 'static, + { + Self::start(reader, writer, handler, true, None, framing) + } + + fn start( + reader: R, + mut writer: W, + handler: Arc, + close_on_eof: bool, + pid: Option, + framing: Framing, + ) -> Self where R: AsyncRead + Unpin + Send + 'static, W: AsyncWrite + Unpin + Send + 'static, @@ -330,7 +414,7 @@ impl Peer { _ = kill.cancelled() => None, }; let Some(body) = body else { break }; - if let Err(e) = framing::write_message(&mut writer, &body).await { + if let Err(e) = framing.write(&mut writer, &body).await { tracing::debug!("jsonrpc write failed: {e}"); if let Some(inner) = weak.upgrade() { inner.close_transport(); @@ -369,7 +453,7 @@ impl Peer { let mut reader = BufReader::new(reader); loop { let msg = tokio::select! { - msg = framing::read_message(&mut reader) => msg, + msg = framing.read(&mut reader) => msg, _ = inner.kill.cancelled() => break, }; match msg { diff --git a/crates/ide-jsonrpc/src/tests.rs b/crates/ide-jsonrpc/src/tests.rs index b3a644c2..11050997 100644 --- a/crates/ide-jsonrpc/src/tests.rs +++ b/crates/ide-jsonrpc/src/tests.rs @@ -120,6 +120,28 @@ async fn request_response_and_errors() { assert!(matches!(err, RpcError::Protocol(_))); } +#[tokio::test] +async fn line_framing_round_trips_and_skips_blank_lines() { + let mut buf = Vec::new(); + framing::write_line_message(&mut buf, br#"{"a":1}"#).await.unwrap(); + buf.extend_from_slice(b"\r\n\n{\"b\":2}\r\n"); + let mut reader = tokio::io::BufReader::new(&buf[..]); + assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"a":1}"#); + assert_eq!(framing::read_line_message(&mut reader).await.unwrap().unwrap(), br#"{"b":2}"#); + assert!(framing::read_line_message(&mut reader).await.unwrap().is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn line_framed_peers_talk() { + let (a_io, b_io) = duplex(64 * 1024); + let (ar, aw) = tokio::io::split(a_io); + let (br, bw) = tokio::io::split(b_io); + let client = Peer::connect_framed(ar, aw, Arc::new(NoopHandler), Framing::Lines); + let _server = Peer::connect_framed(br, bw, Arc::new(Recorder::default()), Framing::Lines); + let v: Value = client.request("echo", json!({"x": "line\nbreak"}), None).await.unwrap(); + assert_eq!(v, json!({"x": "line\nbreak"})); +} + #[tokio::test(flavor = "multi_thread")] async fn notifications_arrive_in_order() { let rec = Arc::new(Recorder::default()); diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index cb377443..950debbf 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -821,6 +821,11 @@ impl Repo { pub fn diff_with_worktree(&self, rev: &str) -> IdeResult> { ops::diff_with_worktree(self, rev) } + /// `(repo-relative path, added lines, deleted lines)` of the tracked files whose + /// working-tree content differs from `rev`. + pub fn numstat_with_worktree(&self, rev: &str) -> IdeResult> { + ops::numstat_with_worktree(self, rev) + } // ---- patches and shelf ---- diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index 24f25b0b..3795d0ef 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -349,6 +349,26 @@ pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult IdeResult> { + check_commits(&[rev.to_string()])?; + let out = run(r.root(), &["diff", "--numstat", "-z", "--no-renames", "--no-ext-diff", rev, "--"], None)?; + Ok(parse_numstat(&out.stdout_str())) +} + +pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> { + text.split('\0') + .filter_map(|rec| { + let mut parts = rec.splitn(3, '\t'); + let added = parts.next()?.parse().unwrap_or(0); + let deleted = parts.next()?.parse().unwrap_or(0); + let path = parts.next().filter(|p| !p.is_empty())?; + Some((path.to_owned(), added, deleted)) + }) + .collect() +} + /// Files that differ between two revisions (Update Project's "N files updated"). pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult> { check_commits(&[from.to_string(), to.to_string()])?; @@ -402,4 +422,13 @@ mod tests { ] ); } + + #[test] + fn numstat_parsing() { + let got = parse_numstat("3\t1\tsrc/a.rs\0-\t-\timg.png\0\x30\t7\tdir/b c.txt\0"); + assert_eq!( + got, + vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)] + ); + } } diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e9f3d7f9..410a81bd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -53,6 +53,7 @@ ide-analysis.workspace = true ide-coverage.workspace = true ide-profiler.workspace = true ide-http.workspace = true +ide-agent.workspace = true ide-db.workspace = true ide-web.workspace = true ide-spell.workspace = true diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs new file mode 100644 index 00000000..ea34a02e --- /dev/null +++ b/src-tauri/src/commands/agent.rs @@ -0,0 +1,150 @@ +use crate::state::AppState; +use ide_agent::{ + AgentChange, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask, +}; +use ide_core::{IdeResult, IpcPath}; +use std::sync::Arc; +use tauri::ipc::Channel; +use tauri::{State, Window}; + +/// Streams the project's agent events to this window; returns every task. +#[tauri::command] +#[specta::specta] +pub fn agent_subscribe( + window: Window, + state: State<'_, AppState>, + on_event: Channel, +) -> IdeResult> { + let project = state.project(window.label())?; + Ok(project.agents.subscribe(Arc::new(move |e| { + let _ = on_event.send(e); + }))) +} + +#[tauri::command] +#[specta::specta] +pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult> { + Ok(state.project(window.label())?.agents.providers()) +} + +#[tauri::command] +#[specta::specta] +pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { + state.project(window.label())?.agents.entries(&task) +} + +#[tauri::command] +#[specta::specta] +pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult { + let project = state.project(window.label())?; + project.agents.create(request).await +} + +/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. +#[tauri::command] +#[specta::specta] +pub async fn agent_prompt( + window: Window, + state: State<'_, AppState>, + task: String, + prompt: AgentPrompt, + now: bool, +) -> IdeResult<()> { + let project = state.project(window.label())?; + project.agents.prompt(&task, prompt, now).await +} + +/// Drops a queued message and returns it (to edit it). +#[tauri::command] +#[specta::specta] +pub fn agent_unqueue( + window: Window, + state: State<'_, AppState>, + task: String, + queued: String, +) -> IdeResult { + state.project(window.label())?.agents.unqueue(&task, &queued) +} + +/// Sends a queued message now, interrupting the current turn. +#[tauri::command] +#[specta::specta] +pub fn agent_send_queued_now( + window: Window, + state: State<'_, AppState>, + task: String, + queued: String, +) -> IdeResult<()> { + state.project(window.label())?.agents.send_queued_now(&task, &queued) +} + +/// Changes a session setting (model, mode, thinking effort…). +#[tauri::command] +#[specta::specta] +pub async fn agent_set_config( + window: Window, + state: State<'_, AppState>, + task: String, + option: String, + value: String, +) -> IdeResult<()> { + let project = state.project(window.label())?; + project.agents.set_config(&task, &option, value).await +} + +#[tauri::command] +#[specta::specta] +pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { + state.project(window.label())?.agents.cancel(&task) +} + +/// Answers a permission request; `option: null` cancels it. +#[tauri::command] +#[specta::specta] +pub fn agent_respond( + window: Window, + state: State<'_, AppState>, + task: String, + permission: u32, + option: Option, +) -> IdeResult<()> { + state.project(window.label())?.agents.respond(&task, permission, option) +} + +#[tauri::command] +#[specta::specta] +pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> { + state.project(window.label())?.agents.archive(&task) +} + +#[tauri::command] +#[specta::specta] +pub async fn agent_delete( + window: Window, + state: State<'_, AppState>, + task: String, + remove_worktree: bool, +) -> IdeResult<()> { + let project = state.project(window.label())?; + project.agents.delete(&task, remove_worktree).await +} + +#[tauri::command] +#[specta::specta] +pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult> { + let project = state.project(window.label())?; + project.agents.changes(&task).await +} + +/// Content of `path` when the task started (left side of its diff). +#[tauri::command] +#[specta::specta] +pub async fn agent_base_text( + window: Window, + state: State<'_, AppState>, + task: String, + path: IpcPath, +) -> IdeResult> { + let project = state.project(window.label())?; + project.agents.base_text(&task, path.into_path_buf()).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8a9f04df..45c4f044 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -6,6 +6,7 @@ //! * streaming results use a `Channel` argument; cancellable requests take a //! UI-allocated `RequestId` and are cancelled via `cancel_request`. +pub mod agent; pub mod analysis; pub mod app; pub mod build; @@ -59,6 +60,20 @@ pub fn builder() -> tauri_specta::Builder { app::project_roots, app::cancel_request, app::log_frontend, + agent::agent_subscribe, + agent::agent_providers, + agent::agent_entries, + agent::agent_create, + agent::agent_prompt, + agent::agent_unqueue, + agent::agent_send_queued_now, + agent::agent_set_config, + agent::agent_cancel, + agent::agent_respond, + agent::agent_archive, + agent::agent_delete, + agent::agent_changes, + agent::agent_base_text, fs::fs_read_dir, bytes::fs_probe, bytes::fs_read_range, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index f8dd64db..eded2c92 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -74,6 +74,8 @@ pub struct Project { pub db: Arc, /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. pub http_history: Arc, + /// Coding agent tasks (Agents tool window). + pub agents: Arc, watcher: Watcher, /// Watchers of the Project Structure content roots outside the project folder. content_watchers: Mutex>, @@ -403,6 +405,21 @@ impl AppState { self.data_dir.join("db-history.json"), )); let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key))); + let agents = Arc::new(ide_agent::AgentManager::new( + ide_agent::AgentPaths { + project_root: root.clone(), + store_dir: self.data_dir.join("agents").join(&project_key), + // Outside the app data dir: its path has spaces on macOS, which trips up build scripts. + worktrees_dir: dirs::home_dir() + .unwrap_or_else(|| self.data_dir.clone()) + .join(".openidea") + .join("worktrees") + .join(&project_key), + models_file: self.data_dir.join("agents").join("models.json"), + custom_agents_dir: Some(self.config_dir.join("agents")), + }, + tauri::async_runtime::handle().inner().clone(), + )); let project = Arc::new(Project { root, sink, @@ -420,6 +437,7 @@ impl AppState { cache_dir: self.data_dir.join("cache").join(&project_key), db, http_history, + agents, watcher, content_watchers: Mutex::new(content_watchers), _scratch_watcher: scratch_watcher, @@ -430,6 +448,7 @@ impl AppState { pub fn close_project(&self, window: &str) { if let Some(project) = self.projects.write().remove(window) { + project.agents.shutdown(); let lsp = project.lsp.clone(); tauri::async_runtime::spawn(async move { lsp.shutdown_all().await }); } diff --git a/src/bindings.ts b/src/bindings.ts index 765fcc44..7a173d2b 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -53,6 +53,27 @@ export const commands = { cancelRequest: (id: RequestId) => __TAURI_INVOKE("cancel_request", { id }), /** Routes frontend logs into the backend `tracing` pipeline. */ logFrontend: (level: LogLevel, message: string) => __TAURI_INVOKE("log_frontend", { level, message }), + /** Streams the project's agent events to this window; returns every task. */ + agentSubscribe: (onEvent: Channel) => typedError(__TAURI_INVOKE("agent_subscribe", { onEvent })), + agentProviders: () => typedError(__TAURI_INVOKE("agent_providers")), + agentEntries: (task: string) => typedError(__TAURI_INVOKE("agent_entries", { task })), + agentCreate: (request: NewAgentTask) => typedError(__TAURI_INVOKE("agent_create", { request })), + /** Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. */ + agentPrompt: (task: string, prompt: AgentPrompt, now: boolean) => typedError(__TAURI_INVOKE("agent_prompt", { task, prompt, now })), + /** Drops a queued message and returns it (to edit it). */ + agentUnqueue: (task: string, queued: string) => typedError(__TAURI_INVOKE("agent_unqueue", { task, queued })), + /** Sends a queued message now, interrupting the current turn. */ + agentSendQueuedNow: (task: string, queued: string) => typedError(__TAURI_INVOKE("agent_send_queued_now", { task, queued })), + /** Changes a session setting (model, mode, thinking effort…). */ + agentSetConfig: (task: string, option: string, value: string) => typedError(__TAURI_INVOKE("agent_set_config", { task, option, value })), + agentCancel: (task: string) => typedError(__TAURI_INVOKE("agent_cancel", { task })), + /** Answers a permission request; `option: null` cancels it. */ + agentRespond: (task: string, permission: number, option: string | null) => typedError(__TAURI_INVOKE("agent_respond", { task, permission, option })), + agentArchive: (task: string) => typedError(__TAURI_INVOKE("agent_archive", { task })), + agentDelete: (task: string, removeWorktree: boolean) => typedError(__TAURI_INVOKE("agent_delete", { task, removeWorktree })), + agentChanges: (task: string) => typedError(__TAURI_INVOKE("agent_changes", { task })), + /** Content of `path` when the task started (left side of its diff). */ + agentBaseText: (task: string, path: IpcPath) => typedError(__TAURI_INVOKE("agent_base_text", { task, path })), fsReadDir: (path: string) => typedError(__TAURI_INVOKE("fs_read_dir", { path })), /** Size and text/binary classification, from the file's head only. */ fsProbe: (path: string) => typedError(__TAURI_INVOKE("fs_probe", { path })), @@ -816,6 +837,180 @@ export type ActiveTheme = { theme: ThemeDef, }; +/** A file the task changed, relative to [`AgentTask::base_commit`]. */ +export type AgentChange = { + path: IpcPath, + relPath: string, + kind: ChangeKind, + added: number, + removed: number, +}; + +export type AgentCommand = { + /** Without the leading `/`. */ + name: string, + description: string, + /** What to type after the command, when it takes input. */ + hint: string | null, +}; + +/** A setting chosen for a new task, by [`AgentConfigOption::id`]. */ +export type AgentConfigChoice = { + id: string, + value: string, +}; + +/** A session setting: an ACP select config option, or the older `modes` / `models`. */ +export type AgentConfigOption = { + id: string, + name: string, + description: string | null, + /** ACP category: `model`, `mode`, `thought_level`, or agent-specific. */ + category: string | null, + current: string | null, + values: AgentConfigValue[], + via: AgentConfigVia, +}; + +/** One value of a select-type session setting. */ +export type AgentConfigValue = { + value: string, + name: string, + description: string | null, +}; + +/** How a setting is changed. */ +export type AgentConfigVia = +/** `session/set_config_option`. */ +"option" | +/** `session/set_mode` (agents that report `modes` instead of a mode config option). */ +"mode" | +/** `session/set_model` (agents that report `models` instead of a model config option). */ +"model"; + +/** One item of a task's conversation. */ +export type AgentEntry = { type: "user"; text: string; +/** Names of the files and images sent with it. */ +attachments: string[] } | { type: "agent"; text: string } | { type: "thought"; text: string } | { type: "tool"; id: string; title: string; +/** ACP tool kind: `read`, `edit`, `execute`, `search`, `fetch`, `think`… */ +kind: string; status: AgentToolStatus; locations: IpcPath[]; +/** Text the tool reported (command output…), truncated. */ +output: string | null; diffs: AgentToolDiff[] } | { type: "plan"; items: AgentPlanItem[] } | { type: "permission"; id: number; title: string; options: AgentPermissionOption[]; +/** Name of the chosen option, or `None` while pending. Cancelled requests read "Cancelled". */ +chosen: string | null } | { type: "error"; message: string }; + +/** Streamed to the window that subscribed (`AgentManager::subscribe`). */ +export type AgentEvent = +/** A task was created or its metadata changed. */ +{ type: "task"; task: AgentTask } | { type: "removed"; id: string } | +/** Entry `index` of `task` was added or replaced. */ +{ type: "entry"; task: string; index: number; entry: AgentEntry }; + +export type AgentImage = { + mimeType: string, + /** Base64, without a `data:` prefix. */ + data: string, + name: string | null, +}; + +export type AgentPermissionOption = { + id: string, + name: string, + /** `allow_once`, `allow_always`, `reject_once` or `reject_always`. */ + kind: string, +}; + +export type AgentPlanItem = { + content: string, + /** `pending`, `in_progress` or `completed`. */ + status: string, +}; + +/** A message to the agent: text, plus files to look at and images. */ +export type AgentPrompt = { + text: string, + files: IpcPath[], + images: AgentImage[], +}; + +export type AgentProvider = { + id: string, + name: string, + /** The command line that starts its ACP server. */ + command: string, + /** Whether the command was found (on `PATH`). */ + installed: boolean, + /** Settings (models, modes…) the agent offered last time it ran; empty before its first session. */ + config: AgentConfigOption[], + /** Slash commands it offered last time. */ + commands: AgentCommand[], +}; + +export type AgentQueued = { + id: string, + prompt: AgentPrompt, +}; + +export type AgentStatus = +/** Creating the worktree / starting the agent. */ +"starting" | "running" | +/** Waiting for a permission answer. */ +"needsInput" | +/** The turn ended; the result can be reviewed or the conversation continued. */ +"ready" | "failed" | "archived"; + +export type AgentTask = { + id: string, + title: string, + /** [`AgentProvider::id`]. */ + provider: string, + /** Model the task runs on (its `model` setting), when known. */ + model: string | null, + status: AgentStatus, + isolation: Isolation, + /** Directory the agent works in (the worktree, or the project root). */ + cwd: IpcPath, + /** Branch of the task's worktree. */ + branch: string | null, + /** Branch (or revision) the worktree was created from. */ + base: string | null, + /** Commit the task's changes are compared against. */ + baseCommit: string | null, + /** Why the task failed, when it did. */ + error: string | null, + createdMs: number | null, + updatedMs: number | null, + /** + * The session's settings (model, mode, thinking effort…) as last reported, with the + * user's choices applied. + */ + config: AgentConfigOption[], + /** Slash commands the agent offers. */ + commands: AgentCommand[], + /** Context window use, when the agent reports it. */ + usage: AgentUsage | null, + /** Messages waiting for the current turn to end, oldest first. */ + queue: AgentQueued[], +}; + +export type AgentToolDiff = { + path: IpcPath, + added: number, + removed: number, +}; + +export type AgentToolStatus = "pending" | "running" | "completed" | "failed"; + +export type AgentUsage = { + /** Tokens in the context window. */ + used: number, + /** Size of the context window. */ + size: number, + /** Session cost so far. */ + cost: number | null, + currency: string | null, +}; + export type AppInfo = { version: string, platform: string, @@ -2910,6 +3105,12 @@ export type InvalidateCachesOptions = { /** A path field of an IPC type. */ export type IpcPath = string; +export type Isolation = +/** A new git worktree on its own branch. */ +"worktree" | +/** The project's own checkout. */ +"local"; + export type IssueLink = { /** Regular expression matched in commit messages. */ pattern?: string, @@ -3454,6 +3655,16 @@ export type MethodStat = { total: number | null, }; +export type NewAgentTask = { + prompt: AgentPrompt, + provider: string, + /** Settings for the new session (model, mode…). */ + config: AgentConfigChoice[], + isolation: Isolation, + /** Branch to start the worktree from; the current branch when `None`. */ + base: string | null, +}; + /** Result of `node_at`: the smallest named node covering a range, with its ancestors. */ export type NodeAt = { node: SyntaxNode, diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index de03f1b5..50d35a6d 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -21,11 +21,13 @@ import { menuGroupItems, notifications, resolveFileEditor, + useContributions, type EditorInput, type OpenOptions, + type OpenEditorTab, } from "@/workbench/contributions"; import { useDistractionFree } from "@/workbench/distractionFree"; -import { showContextMenu, type MenuItem } from "@/ui"; +import { fileIconName, showContextMenu, type MenuItem } from "@/ui"; import { usePlatform } from "@/platform/platformInfo"; import { activateTab, @@ -140,6 +142,32 @@ function isTextTab(tab: Tab | null | undefined): tab is Tab & { input: { path: s return !!tab && isTextInput(tab.input); } +type EditorKinds = ReturnType["editorKinds"]; + +/** Tab title of an editor input: the file name, or what its editor kind calls it. */ +export function tabTitle(input: EditorInput, kinds: EditorKinds): string { + if (input.kind === "file") return basename(input.path ?? input.key); + if (input.kind === "virtual") + return (input.data as VirtualInputData | undefined)?.title ?? basename(input.path ?? input.key); + const kind = kinds.get(input.kind); + try { + return kind?.title(input) ?? input.key; + } catch { + return input.key; + } +} + +export function tabIcon(input: EditorInput, kinds: EditorKinds): string { + if (input.kind === "file") return fileIconName(basename(input.path ?? "")); + if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds)); + const kind = kinds.get(input.kind); + try { + return kind?.icon?.(input) ?? "file"; + } catch { + return "file"; + } +} + export class EditorController implements SlotHost { manager: DocumentManager; readonly slots = new Map(); @@ -157,6 +185,9 @@ export class EditorController implements SlotHost { private restoredViews = new Map(); /** Tabs created by session restore; dropped silently if their file is gone. */ private restoredTabs = new Set(); + /** When each input key was last the active tab (a counter, across panes). */ + private lastActive = new Map(); + private activeClock = 0; constructor(backend: DocBackend = docBackend(), scheduler?: Scheduler) { this.manager = new DocumentManager( @@ -178,6 +209,7 @@ export class EditorController implements SlotHost { useTabs.subscribe((s) => { const prev = prevTabs; prevTabs = s; + this.stampActive(s); this.onTabsChanged(prev, s); }), ); @@ -301,7 +333,8 @@ export class EditorController implements SlotHost { if (files[path] && slot.model.lastFocused !== slot && !this.restoredViews.has(path)) continue; files[path] = slot.viewState(); } - storeSession(root, serializeSession(useTabs.getState(), files)); + const kinds = useContributions.getState().editorKinds; + storeSession(root, serializeSession(useTabs.getState(), files, (input) => !!kinds.get(input.kind)?.restorable)); } /** Reopens the tabs, splits and carets saved for `root` (after the project opened). */ @@ -993,6 +1026,29 @@ export class EditorController implements SlotHost { pane?.querySelector("[data-editor-focus], .cm-content")?.focus({ preventScroll: true }); } + private stampActive(s: TabsState) { + const pane = s.panes[s.activePane]; + const key = pane?.tabs.find((t) => t.id === pane.active)?.input.key; + if (key && this.lastActive.get(key) !== this.activeClock) this.lastActive.set(key, ++this.activeClock); + } + + /** Every open tab once (all panes), most recently active first; tabs never active this session follow in tab order. */ + openTabs(): OpenEditorTab[] { + const s = useTabs.getState(); + const kinds = useContributions.getState().editorKinds; + const out = new Map(); + for (const id of paneIds(s)) + for (const t of s.panes[id]?.tabs ?? []) + if (!out.has(t.input.key)) + out.set(t.input.key, { + input: t.input, + title: tabTitle(t.input, kinds), + icon: tabIcon(t.input, kinds), + lastActive: this.lastActive.get(t.input.key) ?? 0, + }); + return [...out.values()].sort((a, b) => b.lastActive - a.lastActive); + } + openPaths(): string[] { const s = useTabs.getState(); const out = new Set(); diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index ac7433e2..c4e95281 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -42,6 +42,7 @@ export function activate(): void | (() => void) { saveAll: () => ctrl.saveAll(), focus: () => ctrl.focus(), openPaths: () => ctrl.openPaths(), + openTabs: () => ctrl.openTabs(), closePath: (path) => ctrl.closePath(path), closeInput: (key) => ctrl.closeInput(key), reformat: async (path) => { diff --git a/src/editor/core/model/session.test.ts b/src/editor/core/model/session.test.ts index 3cd45ef5..27aab7a4 100644 --- a/src/editor/core/model/session.test.ts +++ b/src/editor/core/model/session.test.ts @@ -39,6 +39,31 @@ describe("editor session", () => { expect(r.layout).toMatchObject({ kind: "split", dir: "column" }); }); + it("saves tabs of restorable editor kinds with their input", () => { + let s = initialTabsState(); + s = open(s, "/p/a.ts"); + s = openTab(s, s.activePane, { kind: "agent-session", key: "agent:1", data: { task: "1" } }).state; + s = openTab(s, s.activePane, { kind: "diff", key: "diff:x", data: { big: true } }).state; + s = openTab(s, s.activePane, { kind: "agent-session", key: "agent:1", data: { task: "1" } }).state; + const restorable = (i: { kind: string }) => i.kind === "agent-session"; + const session = serializeSession(s, {}, restorable)!; + expect(session.layout).toMatchObject({ kind: "pane", active: 1 }); + const r = restoreTabsState(parseSession(JSON.parse(JSON.stringify(session)))!, fileKey); + const pane = r.panes[paneIds(r)[0]!]!; + expect(pane.tabs.map((t) => t.input)).toEqual([file("/p/a.ts"), { kind: "agent-session", key: "agent:1", data: { task: "1" } }]); + expect(pane.active).toBe(pane.tabs[1]!.id); + // Only restorable tabs: still a session. Junk inputs (file kind, no key) are dropped. + const empty = initialTabsState(); + const only = openTab(empty, empty.activePane, { kind: "agent-session", key: "agent:2", data: {} }).state; + expect(serializeSession(only, {}, restorable)).not.toBeNull(); + expect(serializeSession(only, {})).toBeNull(); + const junk = parseSession({ + version: 1, + layout: { kind: "pane", tabs: [{ input: { kind: "file", key: "k" } }, { input: { kind: "x" } }, { input: { kind: "x", key: "y" } }], active: 0 }, + })!; + expect(junk.layout).toEqual({ kind: "pane", tabs: [{ input: { kind: "x", key: "y" } }], active: 0 }); + }); + it("returns null for sessions without file tabs and rejects junk", () => { expect(serializeSession(initialTabsState(), {})).toBeNull(); expect(parseSession({ version: 2 })).toBeNull(); diff --git a/src/editor/core/model/session.ts b/src/editor/core/model/session.ts index 0e579ced..19987a25 100644 --- a/src/editor/core/model/session.ts +++ b/src/editor/core/model/session.ts @@ -1,14 +1,19 @@ /** * Editor session per project (IDEA's workspace.xml editor state): open file * tabs with pinned / preview flags and the active tab of every pane, the split - * layout, the active pane and caret / scroll position per file. Serialised to + * layout, the active pane and caret / scroll position per file. Tabs of editor + * kinds that ask for it (`EditorKindContribution.restorable`, like IDEA's + * FileEditorProvider state) are saved too, as their whole input. Serialised to * plain JSON (localStorage, keyed by project root) and rebuilt into a fresh * TabsState with new ids. */ +import type { EditorInput } from "@/workbench/contributions"; import { initialTabsState, paneIds, type LayoutNode, type Pane, type Tab, type TabsState } from "./tabs"; +/** A file tab (`path`), or a tab of a restorable editor kind (`input`, with JSON `data`). */ export interface SessionTab { - path: string; + path?: string; + input?: EditorInput; pinned?: boolean; preview?: boolean; } @@ -34,18 +39,26 @@ export interface EditorSession { const STORAGE_PREFIX = "openidea.editorSession.v1:"; -export function serializeSession(s: TabsState, files: Record): EditorSession | null { +const isFileTab = (t: Tab) => t.input.kind === "file" && !!t.input.path; + +/** `restorable`: whether a non-file tab is saved (its kind asked for it). */ +export function serializeSession( + s: TabsState, + files: Record, + restorable: (input: EditorInput) => boolean = () => false, +): EditorSession | null { + const saved = (t: Tab) => isFileTab(t) || (t.input.kind !== "file" && restorable(t.input)); const walk = (n: LayoutNode): SessionLayout | null => { if (n.kind === "pane") { const pane = s.panes[n.paneId]; if (!pane) return null; - const fileTabs = pane.tabs.filter((t) => t.input.kind === "file" && !!t.input.path); - if (!fileTabs.length) return null; - const active = Math.max(0, fileTabs.findIndex((t) => t.id === pane.active)); + const kept = pane.tabs.filter(saved); + if (!kept.length) return null; + const active = Math.max(0, kept.findIndex((t) => t.id === pane.active)); return { kind: "pane", - tabs: fileTabs.map((t) => ({ - path: t.input.path!, + tabs: kept.map((t) => ({ + ...(isFileTab(t) ? { path: t.input.path! } : { input: { ...t.input } }), ...(t.pinned ? { pinned: true } : {}), ...(t.preview ? { preview: true } : {}), })), @@ -59,10 +72,11 @@ export function serializeSession(s: TabsState, files: Record s.panes[id]?.tabs.some((t) => t.input.kind === "file" && t.input.path)); + const kept = paneIds(s).filter((id) => s.panes[id]?.tabs.some(saved)); const activePane = Math.max(0, kept.indexOf(s.activePane)); const paths = new Set(); - const collect = (l: SessionLayout) => (l.kind === "pane" ? l.tabs.forEach((t) => paths.add(t.path)) : l.children.forEach(collect)); + const collect = (l: SessionLayout) => + l.kind === "pane" ? l.tabs.forEach((t) => t.path && paths.add(t.path)) : l.children.forEach(collect); collect(layout); const keptFiles: Record = {}; for (const p of paths) if (files[p]) keptFiles[p] = files[p]!; @@ -78,9 +92,15 @@ export function slashPath(p: string): string { } function isSessionTab(v: unknown): v is SessionTab { - return !!v && typeof v === "object" && typeof (v as SessionTab).path === "string" && !!(v as SessionTab).path; + if (!v || typeof v !== "object") return false; + const t = v as SessionTab; + if (typeof t.path === "string") return !!t.path; + const i = t.input as Partial | undefined; + return !!i && typeof i.kind === "string" && i.kind !== "file" && typeof i.key === "string" && !!i.key; } +const tabKey = (t: SessionTab) => (t.path !== undefined ? `file:${t.path}` : `input:${t.input!.key}`); + /** Validates untrusted JSON; returns null when unusable. */ export function parseSession(raw: unknown): EditorSession | null { if (!raw || typeof raw !== "object") return null; @@ -90,7 +110,9 @@ export function parseSession(raw: unknown): EditorSession | null { if (!l || typeof l !== "object" || depth > 16) return null; const n = l as SessionLayout; if (n.kind === "pane") { - const tabs = Array.isArray(n.tabs) ? n.tabs.filter(isSessionTab).map((t) => ({ ...t, path: slashPath(t.path) })) : []; + const tabs = Array.isArray(n.tabs) + ? n.tabs.filter(isSessionTab).map((t) => (t.path !== undefined ? { ...t, path: slashPath(t.path) } : t)) + : []; if (!tabs.length) return null; const active = typeof n.active === "number" && n.active >= 0 && n.active < tabs.length ? Math.floor(n.active) : 0; return { kind: "pane", tabs, active }; @@ -128,17 +150,23 @@ export function restoreTabsState(session: EditorSession, fileKey: (path: string) const seen = new Set(); const tabs: Tab[] = []; let preview = false; + const byKey = new Map(); for (const t of l.tabs) { - if (seen.has(t.path)) continue; - seen.add(t.path); + const key = tabKey(t); + if (seen.has(key)) continue; + seen.add(key); // At most one preview tab per pane; pinned tabs first. const isPreview: boolean = !!t.preview && !t.pinned && !preview; preview ||= isPreview; - tabs.push({ id: id("tab"), input: { kind: "file", key: fileKey(t.path), path: t.path }, pinned: !!t.pinned, preview: isPreview }); + const input: EditorInput = + t.path !== undefined ? { kind: "file", key: fileKey(t.path), path: t.path } : { ...t.input! }; + const tab: Tab = { id: id("tab"), input, pinned: !!t.pinned, preview: isPreview }; + byKey.set(key, tab); + tabs.push(tab); } tabs.sort((a, b) => Number(b.pinned) - Number(a.pinned)); - const activePath = l.tabs[l.active]?.path; - const active = tabs.find((t) => t.input.path === activePath) ?? tabs[0]!; + const activeTab = l.tabs[l.active]; + const active = (activeTab && byKey.get(tabKey(activeTab))) ?? tabs[0]!; panes[paneId] = { id: paneId, tabs, active: active.id, mru: [active.id, ...tabs.filter((t) => t !== active).map((t) => t.id)] }; order.push(paneId); return { kind: "pane", paneId }; diff --git a/src/editor/core/view/EditorArea.tsx b/src/editor/core/view/EditorArea.tsx index f36f4645..77c8f0ce 100644 --- a/src/editor/core/view/EditorArea.tsx +++ b/src/editor/core/view/EditorArea.tsx @@ -31,12 +31,11 @@ import { SplitHandle, SplitPanel, Tabs, - fileIconName, showContextMenu, type TabItem, type TabsDnd, } from "@/ui"; -import { editorController, isTextInput, useTabs, type VirtualInputData } from "../controller"; +import { editorController, isTextInput, tabIcon, tabTitle, useTabs, type VirtualInputData } from "../controller"; import { useDocMeta } from "../model/document"; import type { LayoutNode, SplitEdge, Tab } from "../model/tabs"; import { disambiguateTitles } from "../model/tabTitles"; @@ -115,28 +114,6 @@ function useTitleHints(): Map { const virtualData = (input: EditorInput) => input.data as VirtualInputData | undefined; -function tabTitle(input: EditorInput, kinds: ReturnType["editorKinds"]): string { - if (input.kind === "file") return basename(input.path ?? input.key); - if (input.kind === "virtual") return virtualData(input)?.title ?? basename(input.path ?? input.key); - const kind = kinds.get(input.kind); - try { - return kind?.title(input) ?? input.key; - } catch { - return input.key; - } -} - -function tabIcon(input: EditorInput, kinds: ReturnType["editorKinds"]): string { - if (input.kind === "file") return fileIconName(basename(input.path ?? "")); - if (input.kind === "virtual") return fileIconName(tabTitle(input, kinds)); - const kind = kinds.get(input.kind); - try { - return kind?.icon?.(input) ?? "file"; - } catch { - return "file"; - } -} - function PaneView({ paneId }: { paneId: string }) { const ctrl = editorController(); const pane = useTabs((s) => s.panes[paneId]); diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css new file mode 100644 index 00000000..e2eb069e --- /dev/null +++ b/src/features/agents/Agents.module.css @@ -0,0 +1,99 @@ +.view { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} +.view > * { + flex: 1; + min-height: 0; +} + +.row { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + flex: 1; +} +.groupLabel { + color: var(--fg-strong); + font-weight: 600; +} +.count { + color: var(--fg-subtle); + font-size: var(--fs-tiny); +} +.title { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.detail { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-subtle); +} +.age { + flex: none; + padding-right: 8px; + color: var(--fg-subtle); + font-size: var(--fs-small); + font-variant-numeric: tabular-nums; +} +.ready { + color: var(--success); +} +.failed { + color: var(--danger); +} +.archived { + color: var(--fg-subtle); +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 24px 12px; + color: var(--fg-muted); +} +.link { + border: 0; + background: none; + padding: 0; + color: var(--link); + font: inherit; + cursor: pointer; +} +.link:hover { + color: var(--link-hover); +} + +.statusItem { + display: inline-flex; + align-items: center; + gap: 10px; + height: 100%; + padding: 0 6px; + border: 0; + border-radius: var(--radius-small); + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} +.statusItem:hover { + background: var(--bg-hover); +} +.statusPart { + display: inline-flex; + align-items: center; + gap: 5px; +} diff --git a/src/features/agents/AgentsStatus.tsx b/src/features/agents/AgentsStatus.tsx new file mode 100644 index 00000000..2b955f12 --- /dev/null +++ b/src/features/agents/AgentsStatus.tsx @@ -0,0 +1,29 @@ +/** Status bar widget: tasks waiting for the user / running; opens the most urgent one. */ +import { Icon, Spinner, T } from "@/ui"; +import { openTask, useAgents } from "./store"; +import styles from "./Agents.module.css"; + +export function AgentsStatus() { + const tasks = useAgents((s) => s.tasks); + const list = [...tasks.values()]; + const input = list.filter((t) => t.status === "needsInput"); + const running = list.filter((t) => t.status === "running" || t.status === "starting").length; + if (!input.length && !running) return null; + const target = input[0] ?? list.find((t) => t.status === "running"); + return ( + + ); +} diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx new file mode 100644 index 00000000..fe0b8a01 --- /dev/null +++ b/src/features/agents/AgentsView.tsx @@ -0,0 +1,128 @@ +/** Agents tool window: tasks grouped by what they need from the user. */ +import { useMemo, useState } from "react"; +import type { AgentStatus as Status, AgentTask } from "@/bindings"; +import { executeAction } from "@/platform/actions"; +import { confirmChecked } from "@/workbench/messages"; +import { Icon, IconButton, Spinner, T, Tree, showContextMenu, type MenuItem } from "@/ui"; +import { age, branchLabel, groupTasks, isBusy, type TaskGroup } from "./model"; +import { archiveTask, deleteTask, openHome, openTask, useAgents } from "./store"; +import styles from "./Agents.module.css"; + +export function AgentsHeaderActions() { + return ; +} + +type Node = { kind: "group"; group: TaskGroup } | { kind: "task"; task: AgentTask }; + +const nodeKey = (n: Node) => (n.kind === "group" ? `g:${n.group.status}` : n.task.id); + +export function TaskStatusIcon({ status }: { status: Status }) { + switch (status) { + case "needsInput": + return ; + case "running": + case "starting": + return ; + case "ready": + return ; + case "failed": + return ; + case "archived": + return ; + } +} + +export function AgentsView() { + const loaded = useAgents((s) => s.loaded); + const tasks = useAgents((s) => s.tasks); + const providers = useAgents((s) => s.providers); + const [collapsed, setCollapsed] = useState>(() => new Set(["g:archived"])); + const [selected, setSelected] = useState>(() => new Set()); + const roots = useMemo(() => groupTasks(tasks.values()).map((group) => ({ kind: "group", group })), [tasks]); + const expanded = useMemo(() => new Set(roots.map(nodeKey).filter((k) => !collapsed.has(k))), [roots, collapsed]); + if (!loaded) return ; + const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id; + + return ( +
+ + aria-label="Agent tasks" + roots={roots} + getKey={nodeKey} + getLabel={(n) => (n.kind === "group" ? n.group.label : n.task.title)} + getChildren={(n) => (n.kind === "group" ? n.group.tasks.map((task) => ({ kind: "task", task }) as Node) : undefined)} + isExpandable={(n) => n.kind === "group"} + expanded={expanded} + onExpandedChange={(_next, node, open) => + setCollapsed((c) => { + const s = new Set(c); + if (open) s.delete(nodeKey(node)); + else s.add(nodeKey(node)); + return s; + }) + } + selected={selected} + onSelectionChange={(sel, lead) => { + setSelected(sel); + if (lead?.kind === "task") openTask(lead.task.id); + }} + onActivate={(n) => n.kind === "task" && openTask(n.task.id)} + onContextMenu={(n, _sel, e) => n?.kind === "task" && showContextMenu(e, taskMenu(n.task))} + renderNode={(n) => + n.kind === "group" ? ( + + + {n.group.label} + + {n.group.tasks.length} + + ) : ( + + + {n.task.title} + + {providerName(n.task.provider)} + {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} + + {age(n.task.updatedMs ?? 0)} + + ) + } + empty={ +
+ No agent tasks yet + +
+ } + /> +
+ ); +} + +function taskMenu(task: AgentTask): MenuItem[] { + const busy = isBusy(task.status); + return [ + { label: "Open", run: () => openTask(task.id) }, + { label: "Open in Terminal", icon: "terminal", run: () => void executeAction("terminal.openAt", task.cwd) }, + { kind: "separator" }, + { label: "Archive", disabled: busy || task.status === "archived", run: () => void archiveTask(task.id) }, + { label: "Delete…", run: () => void confirmDelete(task) }, + ]; +} + +async function confirmDelete(task: AgentTask) { + const worktree = task.isolation === "worktree"; + const r = await confirmChecked({ + title: "Delete Agent Task", + message: worktree + ? `Delete “${task.title}”? Its branch ${task.branch ?? ""} is kept.` + : `Delete “${task.title}”? Files it changed are kept.`, + ok: "Delete", + icon: "warning", + checkbox: worktree ? "Also remove its worktree folder" : undefined, + checked: worktree, + }); + if (r) await deleteTask(task.id, worktree && r.checked); +} diff --git a/src/features/agents/Composer.module.css b/src/features/agents/Composer.module.css new file mode 100644 index 00000000..5e0c3e3d --- /dev/null +++ b/src/features/agents/Composer.module.css @@ -0,0 +1,145 @@ +/* A framed text area (IDEA text field frame), with the pickers in its bottom row. */ +.composer { + position: relative; + display: flex; + flex-direction: column; + border-radius: var(--radius-popup); + background: var(--bg-field, var(--bg-button)); + box-shadow: inset 0 0 0 1px var(--border-button); + cursor: text; + transition: box-shadow var(--dur-fast) var(--ease); +} +.composer:focus-within { + box-shadow: + inset 0 0 0 1px var(--focus-ring), + 0 0 0 1px var(--focus-ring); +} +.textarea { + display: block; + width: 100%; + min-height: 44px; + resize: none; + border: 0; + outline: none; + background: transparent; + color: var(--fg-strong); + font: inherit; + font-size: var(--fs-ui); + line-height: 1.5; + padding: 10px 12px 2px; + -webkit-user-select: text; + user-select: text; +} +.textarea:focus-visible { + outline: none; +} +.textarea::placeholder { + color: var(--fg-faint); +} +.bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 2px; + padding: 4px 6px 6px; + cursor: default; +} +.spacer { + flex: 1; +} +.pickerValue { + display: inline-flex; + align-items: baseline; + gap: 5px; + white-space: nowrap; +} +.pickerName { + color: var(--fg-subtle); +} + +.images { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 8px 10px 0; + cursor: default; +} +.image { + position: relative; + width: 44px; + height: 44px; + border-radius: var(--radius-small); + box-shadow: inset 0 0 0 1px var(--border); + overflow: hidden; +} +.image img { + width: 100%; + height: 100%; + object-fit: cover; +} +.removeImage { + position: absolute; + top: 2px; + right: 2px; + display: grid; + place-items: center; + width: 16px; + height: 16px; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--bg-popup); + color: var(--fg); + cursor: pointer; + opacity: 0; +} +.image:hover .removeImage, +.removeImage:focus-visible { + opacity: 1; +} + +/* `@` / `/` completion, above the box like a code-completion popup. */ +.popup { + position: absolute; + left: 0; + bottom: calc(100% + 4px); + z-index: 10; + width: min(560px, 100%); + max-height: 300px; + overflow: auto; + padding: 4px 0; + border: 1px solid var(--border-popup); + border-radius: var(--radius-popup); + background: var(--bg-popup); + box-shadow: var(--shadow-popup); + cursor: default; +} +.item { + display: flex; + align-items: center; + gap: 6px; + height: var(--h-row); + margin: 0 4px; + padding: 0 8px; + border-radius: var(--radius-row); + white-space: nowrap; +} +.active { + background: var(--bg-list-selected); +} +.itemName { + flex: none; + color: var(--fg-strong); +} +.itemHint { + flex: none; + color: var(--fg-muted); + font-style: italic; +} +.itemDetail { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-subtle); +} diff --git a/src/features/agents/Composer.tsx b/src/features/agents/Composer.tsx new file mode 100644 index 00000000..aff05d6d --- /dev/null +++ b/src/features/agents/Composer.tsx @@ -0,0 +1,366 @@ +/** + * The message box of the New Task page and of sessions: `@` file references (project file + * search), `/` agent commands, ↑ / ↓ history, pasted or dropped images, Shift+Tab to cycle + * the agent's mode. Its draft lives in the store under `id`. + */ +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react"; +import clsx from "clsx"; +import type { AgentCommand, AgentConfigOption, AgentImage } from "@/bindings"; +import { call, commands, isTauri } from "@/platform/ipc"; +import { useTx } from "@/platform/i18n"; +import { basename } from "@/platform/project"; +import { notifications } from "@/workbench/contributions"; +import { Icon, IconButton, fileIconName } from "@/ui"; +import { + IMAGE_EXTENSIONS, + MAX_IMAGE_BYTES, + applyCompletion, + atEdgeLine, + completionAt, + imageFromDataUrl, + matchCommands, + mentionOf, + modeOption, + nextValue, + recall, + type Completion, + type FileRef, +} from "./composerModel"; +import { composerDraft, updateComposer, useAgents } from "./store"; +import styles from "./Composer.module.css"; + +const MAX_PROMPT_HEIGHT = 280; +const MAX_ITEMS = 12; + +type Item = + | { kind: "file"; path: string; rel: string } + | { kind: "command"; command: AgentCommand }; + +export interface ComposerProps { + /** Store key of the draft. */ + id: string; + placeholder: string; + /** Directory `@` references resolve in (the task's worktree, or the project root). */ + root: string; + /** The project root the file index covers. */ + projectRoot: string; + commands: readonly AgentCommand[]; + /** Sent messages, newest first. */ + history: readonly string[]; + /** Settings; Shift+Tab cycles the mode one. */ + config: readonly AgentConfigOption[]; + onConfig?: (option: string, value: string) => void; + /** `now`: ⌘↵ (send even while the agent works, interrupting it). */ + onSubmit: (now: boolean) => void; + disabled?: boolean; + autoFocus?: boolean; + /** Left of the bottom row: agent / model / mode pickers. */ + toolbar?: ReactNode; + /** Right of the bottom row: send / stop buttons. */ + actions?: ReactNode; + className?: string; +} + +export function Composer(props: ComposerProps) { + const { id, root, projectRoot } = props; + const tx = useTx(); + const draft = useAgents((s) => s.composers.get(id)); + const text = draft?.text ?? ""; + const images = draft?.images ?? []; + const ref = useRef(null); + const boxRef = useRef(null); + const imageInput = useRef(null); + const [completion, setCompletion] = useState(null); + const [files, setFiles] = useState([]); + const [active, setActive] = useState(0); + const recalled = useRef<{ index: number; stash: string }>({ index: -1, stash: "" }); + + useEffect(() => { + if (props.autoFocus) ref.current?.focus(); + }, [props.autoFocus]); + + // Grow with the text, up to a limit. + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, MAX_PROMPT_HEIGHT)}px`; + }, [text]); + + // `@` completion: project file search, mapped into the task's directory. + useEffect(() => { + if (completion?.kind !== "file") return; + let alive = true; + const timer = setTimeout(() => { + call(commands.findFiles(completion.query, MAX_ITEMS)) + .then((found) => { + if (!alive) return; + setFiles( + found.map((f) => ({ + kind: "file", + rel: f.relPath, + path: root === projectRoot ? f.path : `${root}/${f.relPath}`, + })), + ); + }) + .catch(() => alive && setFiles([])); + }, 60); + return () => { + alive = false; + clearTimeout(timer); + }; + }, [completion?.kind, completion?.query, root, projectRoot]); + + const items: Item[] = useMemo(() => { + if (!completion) return []; + if (completion.kind === "file") return files; + return matchCommands(props.commands, completion.query) + .slice(0, MAX_ITEMS) + .map((command) => ({ kind: "command", command })); + }, [completion, files, props.commands]); + const open = !!completion && items.length > 0; + + const setText = (value: string, caret?: number) => { + updateComposer(id, { text: value }); + const c = caret ?? value.length; + setCompletion(completionAt(value, c)); + setActive(0); + if (caret !== undefined) { + requestAnimationFrame(() => { + ref.current?.focus(); + ref.current?.setSelectionRange(caret, caret); + }); + } + }; + + const accept = (item: Item | undefined) => { + if (!item || !completion) return; + if (item.kind === "file") { + const mention = mentionOf(item.rel); + const next = applyCompletion(text, completion, mention); + const current = composerDraft(id); + updateComposer(id, { files: [...current.files, { path: item.path, rel: item.rel } satisfies FileRef] }); + setText(next.text, next.caret); + } else { + const next = applyCompletion(text, completion, `/${item.command.name}`); + setText(next.text, next.caret); + } + setCompletion(null); + }; + + const addImages = (list: AgentImage[]) => { + if (list.length) updateComposer(id, { images: [...composerDraft(id).images, ...list] }); + }; + + const readImageFiles = (picked: Iterable) => { + for (const file of picked) { + if (!file.type.startsWith("image/")) continue; + if (file.size > MAX_IMAGE_BYTES) { + notifications().notify({ level: "warning", title: "Image too large", body: `${file.name} is over 5 MB.` }); + continue; + } + const reader = new FileReader(); + reader.onload = () => { + const image = typeof reader.result === "string" ? imageFromDataUrl(reader.result, file.name || "image") : null; + if (image) addImages([image]); + }; + reader.readAsDataURL(file); + } + }; + + // Files dropped from the OS onto the box: images are attached, other files referenced. + useEffect(() => { + if (!isTauri()) return; + let unlisten: (() => void) | undefined; + let disposed = false; + void import("@tauri-apps/api/webview").then(({ getCurrentWebview }) => + getCurrentWebview() + .onDragDropEvent((event) => { + const p = event.payload; + if (p.type !== "drop" || !p.paths.length) return; + const box = boxRef.current?.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + const [x, y] = [p.position.x / ratio, p.position.y / ratio]; + if (!box || x < box.left || x > box.right || y < box.top || y > box.bottom) return; + const refs: FileRef[] = []; + for (const path of p.paths) { + if (IMAGE_EXTENSIONS.test(path)) { + void call(commands.fsReadImage(path)).then((url) => { + const image = imageFromDataUrl(url, basename(path)); + if (image) addImages([image]); + }); + } else { + refs.push({ path, rel: path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path }); + } + } + if (refs.length) { + const current = composerDraft(id); + const mentions = refs.map((r) => mentionOf(r.rel)).join(" "); + const glue = current.text && !/\s$/.test(current.text) ? " " : ""; + updateComposer(id, { text: `${current.text}${glue}${mentions} `, files: [...current.files, ...refs] }); + } + }) + .then((u) => { + if (disposed) u(); + else unlisten = u; + }), + ); + return () => { + disposed = true; + unlisten?.(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-subscribe only when the target changes + }, [id, root]); + + const onKeyDown = (e: KeyboardEvent) => { + if (e.nativeEvent.isComposing) return; + if (open) { + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + const d = e.key === "ArrowDown" ? 1 : -1; + setActive((a) => (a + d + items.length) % items.length); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + accept(items[active]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setCompletion(null); + return; + } + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (!props.disabled) props.onSubmit(e.metaKey || e.ctrlKey); + recalled.current = { index: -1, stash: "" }; + return; + } + if (e.key === "Tab" && e.shiftKey) { + const mode = modeOption(props.config); + const value = mode && nextValue(mode); + if (mode && value && props.onConfig) { + e.preventDefault(); + props.onConfig(mode.id, value); + } + return; + } + if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.altKey && !e.metaKey) { + const dir = e.key === "ArrowUp" ? -1 : 1; + const el = e.currentTarget; + const r = recalled.current; + // Only from an empty box or one showing a recalled message, with the caret on the edge line. + const fresh = r.index === -1 ? text === "" : text === props.history[r.index]; + if (!fresh || !atEdgeLine(text, el.selectionStart, dir)) return; + const next = recall(props.history, r.index, dir); + if (next === null) return; + e.preventDefault(); + if (r.index === -1) r.stash = text; + r.index = next; + const value = next === -1 ? r.stash : props.history[next]!; + setText(value, value.length); + setCompletion(null); + } + }; + + return ( +
ref.current?.focus()}> + {open && ( +
+ {items.map((item, i) => ( +
setActive(i)} + onMouseDown={(e) => { + e.preventDefault(); + accept(item); + }} + > + {item.kind === "file" ? ( + <> + + {basename(item.rel)} + {item.rel} + + ) : ( + <> + /{item.command.name} + {item.command.hint && {item.command.hint}} + {item.command.description} + + )} +
+ ))} +
+ )} + {images.length > 0 && ( +
e.stopPropagation()}> + {images.map((img, i) => ( + + {img.name + + + ))} +
+ )} +