//! Agent chat: a side panel that talks to a coding agent the user already has (Claude Code, //! Codex, Antigravity, OpenCode, …) over the Agent Client Protocol, and gives that agent //! SolidCraft's MCP server, bridged to this window, as its way to read and change the open design //! (docs/agent-chat.md, docs/design/in-app-agent-chat.md). //! //! Desktop only: the agent is a child process. Off unless turned on in Preferences. SolidCraft //! never handles the agent's credentials: an agent that needs a login says so, and the panel //! shows the agent's own login command for the user to run. //! //! - `backend.rs`: the [`backend::ChatBackend`] seam; `acp_client.rs` is the ACP backend. //! - `gate.rs`: what the agent's tool requests may do (run, ask the user first, or never). //! - `panel.rs`: the panel itself (docking, minimising, the picker, the conversation). //! - Here: the conversation, turns (each one undoes as one step), and the requests waiting for //! Allow or Deny. // The web build shows only a note: the process side is desktop only. #![cfg_attr(target_arch = "wasm32", allow(dead_code, unused_imports))] pub mod acp; #[cfg(not(target_arch = "wasm32"))] mod acp_client; pub mod agents; #[cfg(not(target_arch = "wasm32"))] mod api_client; pub mod backend; #[cfg(not(target_arch = "wasm32"))] pub mod discover; pub mod gate; mod markdown; mod markdown_view; mod panel; #[cfg(not(target_arch = "wasm32"))] mod process; pub mod settings; use std::sync::Arc; use std::sync::mpsc::{Receiver, Sender}; use serde_json::{Value, json}; use crate::i18n::{format as trf, tr}; use crate::{ControlRequest, SolveApp}; use backend::{ChatBackend, Event, PermissionOption, PromptPart, ToolStatus}; pub use panel::{show, tip as panel_tip, toggle}; /// Most entries kept in the conversation; older ones are dropped. const MAX_ITEMS: usize = 2000; /// Longest message sent. const MAX_PROMPT: usize = 100_000; /// How long a request waits for Allow or Deny before it is declined (the agent's MCP server gives /// up on a request after five minutes). const APPROVAL_TIMEOUT_MS: f64 = 240_000.0; /// Size of the picture of the view sent with a message. const VIEW_IMAGE: (u32, u32) = (960, 600); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum State { Off, Starting, Ready, Busy, NeedsAuth, Exited, } impl State { fn id(self) -> &'static str { match self { State::Off => "off", State::Starting => "starting", State::Ready => "ready", State::Busy => "busy", State::NeedsAuth => "auth", State::Exited => "exited", } } fn label(self) -> &'static str { tr(match self { State::Off => "ui.agent_chat_state_off", State::Starting => "ui.agent_chat_state_starting", State::Ready => "ui.agent_chat_state_ready", State::Busy => "ui.agent_chat_state_busy", State::NeedsAuth => "ui.agent_chat_state_auth", State::Exited => "ui.agent_chat_state_exited", }) } fn running(self) -> bool { matches!(self, State::Starting | State::Ready | State::Busy | State::NeedsAuth) } } #[derive(Clone, Debug)] pub enum Item { User { text: String, image: bool, }, Agent { thought: bool, text: String, }, Tool { id: String, title: String, kind: String, status: Option, output: Option, }, Plan(Vec<(String, String)>), /// The agent asks before running something of its own (its options, its words). Permission { key: u64, title: String, options: Vec, answer: Option, }, /// A request of the agent's that SolidCraft holds for the user (files, replacing the design, /// or any change while the mode is "ask"); `tool`: what "Always allow" remembers. Approval { id: u64, title: String, tool: String, answer: Option, }, /// A message's changes, merged into one undo step; `doc`: the design right after it. Changes { steps: usize, doc: Arc, undone: bool, }, Notice { text: String, error: bool, }, } /// The message being answered: what undo and the limits need. struct Turn { /// The design and the undo depth before it. start_doc: Arc, start_undo: usize, /// Requests that ran commands, and requests of any kind. changes: u32, requests: u32, limit_told: bool, label: String, } /// A message typed while the agent works: shown in the transcript as "Queued" until it is sent. #[derive(Clone, Debug, PartialEq)] pub struct Pending { pub text: String, pub image: bool, } /// A request held for Allow or Deny. struct Held { id: u64, req: ControlRequest, since: f64, } /// Starts the chat's own control endpoint for the agent's MCP server (the desktop host provides /// it): requests arriving there go to the sender; the port it listens on. pub type ControlStarter = Box) -> Option>; #[derive(Default)] pub struct AgentChat { /// The panel is shown (View menu, the application bar's chat button, Ctrl+Shift+A). pub open: bool, pub items: Vec, pub input: String, state: Option, agent: String, images: bool, /// Send a picture of the view with the next message. pub attach_view: bool, auth: Vec, auth_status: Option, /// A message typed before the agent was ready. queued: Option<(String, bool)>, /// Messages typed while the agent works, in order. They go as one prompt when the turn ends /// (or at once with Send now); after Stop they wait for Send. pub pending: Vec, /// The queued message being edited, and its text so far. pub pending_edit: Option<(usize, String)>, /// Send now was pressed: the queue goes as soon as the cancelled turn has ended. steering: bool, /// The turn was stopped: its queue is not sent by itself. stopped: bool, /// Why the last turn ended. ended: Option, /// New chat while a message ran: its late end is not reported. discard_end: bool, /// The mode to put back when the fresh session is ready. restore_mode: Option, /// New chat is waiting for the user's yes (a message is running or queued). pub confirm_new: bool, /// Where the panel was drawn last (for its shortcuts). pub panel_rect: Option, /// The last message sent, kept to send again after a sign-in. last: Option<(String, bool)>, /// The command the running agent was started with. argv: Vec, /// The agent picked for this conversation (the header's switcher); `None`: the default. pub agent_id: Option, /// The agent the running backend is (for permissions and "always allow"). running_id: String, /// The agent's own modes, and the current one. modes: Vec<(String, String)>, mode: String, /// The models the agent offers (none: no picker), and the current one. pub models: Vec, pub model: String, /// An API endpoint's models, kept per endpoint (its base URL) for the next connection. models_cache: std::collections::HashMap>, /// The running API agent's endpoint (empty for other agents). running_endpoint: String, backend: Option>, turn: Option, held: Vec, next_approval: u64, /// The chat's control endpoint: the port, and its requests. pub tools_port: Option, tools_rx: Option>, tools_tx: Option>, /// Settings → AI: the Add agent dialog and connection tests. pub settings: settings::State, /// Desktop host hook: start the endpoint on demand. pub control_starter: Option, /// The picker is unfolded (it folds to one line once an agent is connected). picking: Option, #[cfg(not(target_arch = "wasm32"))] detection: Option, #[cfg(not(target_arch = "wasm32"))] detecting: Option>, } impl Drop for AgentChat { fn drop(&mut self) { self.stop_backend(true); } } impl AgentChat { pub fn state(&self) -> State { self.state.unwrap_or(State::Off) } fn push(&mut self, item: Item) { if self.items.len() >= MAX_ITEMS { self.items.drain(..MAX_ITEMS / 10); } self.items.push(item); } fn notice(&mut self, text: impl Into, error: bool) { self.push(Item::Notice { text: text.into(), error }); } /// Requests waiting for Allow or Deny. pub fn waiting(&self) -> usize { self.held.len() + self.items.iter().filter(|i| matches!(i, Item::Permission { answer: None, .. })).count() } fn stop_backend(&mut self, wait: bool) { self.steering = false; if let Some(mut b) = self.backend.take() { b.cancel(); b.shutdown(wait); } for h in self.held.drain(..) { let _ = h.req.reply.send(json!({"ok": false, "error": "the agent chat stopped"})); } for i in &mut self.items { if let Item::Approval { answer: a @ None, .. } = i { *a = Some(false); } if let Item::Permission { answer: a @ None, .. } = i { *a = Some(tr("ui.agent_chat_cancel").to_string()); } } } /// Answer the agent's own permission question at `index` (`None`: cancelled). pub fn answer(&mut self, index: usize, option: Option<&str>) { let Some(Item::Permission { key, options, answer, .. }) = self.items.get_mut(index) else { return }; if answer.is_some() { return; } let label = option.and_then(|o| options.iter().find(|x| x.id == o)).map_or_else(|| tr("ui.agent_chat_cancel").to_string(), |o| o.name.clone()); *answer = Some(label); let key = *key; if let Some(b) = self.backend.as_mut() { b.answer_permission(key, option); } } /// Ask the agent to sign in by itself (an "agent" login method: it opens the browser). pub fn authenticate(&mut self, method: &str) { if let Some(b) = self.backend.as_mut() { b.authenticate(method); self.state = Some(State::Starting); } } /// Take in what the backend reported. fn on_event(&mut self, e: Event) -> Option { match e { Event::Ready { agent, images } => { self.agent = agent; self.images = images; self.state = Some(State::Ready); self.picking = Some(false); // Keep the mode across a new chat. if let Some(m) = self.restore_mode.take() && self.modes.iter().any(|(id, _)| *id == m) && let Some(b) = self.backend.as_mut() { b.set_mode(&m); self.mode = m; } } Event::Text { thought, text } => { if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut() && *t == thought { last.push_str(&text); } else { self.push(Item::Agent { thought, text }); } } Event::Tool { id, title, kind, status, output } => { let existing = self.items.iter_mut().rev().find(|i| matches!(i, Item::Tool { id: x, .. } if *x == id)); if let Some(Item::Tool { title: t, kind: k, status: s, output: o, .. }) = existing { if let Some(v) = title { *t = v; } if let Some(v) = kind { *k = v; } if status.is_some() { *s = status; } if output.is_some() { *o = output; } } else { self.push(Item::Tool { id, title: title.unwrap_or_default(), kind: kind.unwrap_or_default(), status, output }); } } Event::Plan(entries) => { // A plan is replaced whole: update this turn's plan where it is. let turn = self.items.iter().rposition(|i| matches!(i, Item::User { .. })).unwrap_or(0); if let Some(Item::Plan(p)) = self.items.iter_mut().skip(turn).rev().find(|i| matches!(i, Item::Plan(_))) { *p = entries; } else { self.push(Item::Plan(entries)); } } Event::Permission { key, title, options } => return Some(Outcome::Permission { key, title, options }), Event::Modes { current, available } => { if !available.is_empty() { self.modes = available; } self.mode = current; } Event::Models { current, available } => { if !available.is_empty() { if !self.running_endpoint.is_empty() { self.models_cache.insert(self.running_endpoint.clone(), available.clone()); } self.models = available; } self.model = current; } Event::AuthRequired { methods } => { self.auth = methods; self.state = Some(State::NeedsAuth); self.picking = Some(false); // What was asked goes again once the login is done. if self.queued.is_none() { self.queued = self.last.take(); } } Event::AuthStatus(s) => self.auth_status = Some(s), Event::TurnEnded { reason } => { if self.state() == State::Busy { self.state = Some(State::Ready); } self.ended = Some(reason.clone()); if std::mem::take(&mut self.discard_end) { return Some(Outcome::TurnEnd); } if !matches!(reason.as_str(), "" | "auth" | "error") { self.notice(format!("({reason})"), false); } return Some(Outcome::TurnEnd); } Event::Error(e) => { self.notice(e, true); if !self.state().running() || self.state() == State::Starting { self.state = Some(State::Exited); } } Event::Exited { tail } => { self.state = Some(State::Exited); self.steering = false; let last: Vec = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) }; self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true); self.stop_backend(false); return Some(Outcome::TurnEnd); } } None } } /// What an event asks of the app. enum Outcome { /// A message was answered (or the agent stopped): the turn's bookkeeping ends. TurnEnd, /// The agent asks before running something of its own: answered by the mode, or asked. Permission { key: u64, title: String, options: Vec }, } /// The AI features are on (the master switch in Settings → AI) and this is the desktop app. pub fn shown(app: &SolveApp) -> bool { app.preferences.ai_features && cfg!(not(target_arch = "wasm32")) } /// The AI button at the toolbar row's right end is shown. pub fn toolbar_button_shown(app: &SolveApp) -> bool { shown(app) && app.preferences.ai_toolbar_button } /// Whether a permission question is about one of SolidCraft's own tools (which SolidCraft's own /// gate checks): the agents name MCP tools after their server. fn solidcraft_tool(title: &str) -> bool { title.to_ascii_lowercase().contains("solidcraft") } /// `solidcraft-cli` next to this program, else on the search path. #[cfg(not(target_arch = "wasm32"))] fn find_cli() -> Option { let name = if cfg!(windows) { "solidcraft-cli.exe" } else { "solidcraft-cli" }; if let Some(p) = solidcraft_engine::geom::env::var("CLI").map(std::path::PathBuf::from).filter(|p| p.is_file()) { return Some(p); } std::env::current_exe() .ok() .and_then(|e| e.parent().map(|d| d.join(name))) .filter(|p| p.is_file()) .or_else(|| discover::find_in("solidcraft-cli", &discover::search_dirs())) } /// The agent's working folder: an empty folder of SolidCraft's own, so an agent's own file tools /// (where it has them) see nothing of the user's. fn working_dir() -> std::path::PathBuf { let d = solidcraft_engine::recovery::data_dir() .map(|d| d.join("agent-chat").join("work")) .unwrap_or_else(|| std::env::temp_dir().join("solidcraft-agent-chat")); #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::create_dir_all(&d); d } /// Start the agent: the chat's control endpoint for its MCP bridge if needed, the process. #[cfg(not(target_arch = "wasm32"))] pub fn connect(app: &mut SolveApp, ctx: &egui::Context) { disconnect(app); let Some(entry) = agents::current(app) else { app.agent_chat.notice(tr("ui.agent_chat_none_found"), true); return; }; // An API agent's key comes from the keychain for this connection only. let key = if entry.kind == agents::Kind::Api && entry.key { match app.services.secrets.as_ref().map(|s| s.get(&entry.id)) { Some(Ok(k)) => k, Some(Err(e)) => { app.agent_chat.notice(trf("ui.ai_keychain_error", &[("error", &e)]), true); return; } None => None, } } else { None }; let http = app.services.agent_http.clone(); let chat = &mut app.agent_chat; if chat.tools_port.is_none() && let Some(start) = chat.control_starter.as_mut() { let (tx, rx) = std::sync::mpsc::channel(); if let Some(port) = start(tx.clone()) { chat.tools_port = Some(port); chat.tools_rx = Some(rx); chat.tools_tx = Some(tx); } } let cli = find_cli(); if chat.tools_port.is_none() { chat.notice(tr("ui.agent_chat_no_control"), true); } else if cli.is_none() { chat.notice(tr("ui.agent_chat_no_cli"), true); } let bridge = cli.zip(chat.tools_port).map(|(cli, port)| acp::McpBridge { cli, port }); let wake = ctx.clone(); let start = backend::StartContext { cwd: working_dir(), bridge, tools: chat.tools_tx.clone(), wake: Box::new(move || wake.request_repaint()) }; let started: Result, String> = match entry.kind { agents::Kind::Acp if entry.argv.is_empty() => Err(tr("ui.agent_chat_none_found").to_string()), agents::Kind::Acp => acp_client::AcpBackend::start(&entry.argv, start).map(|b| Box::new(b) as Box), agents::Kind::Api => match http { Some(http) => api_client::ApiBackend::start( api_client::ApiConfig { name: entry.name.clone(), base_url: entry.base_url.clone(), model: entry.model.clone(), key, http }, start, ) .map(|b| Box::new(b) as Box), None => Err(tr("ui.ai_no_http").to_string()), }, }; match started { Ok(b) => { chat.backend = Some(b); chat.argv = entry.argv.clone(); chat.running_id = entry.id.clone(); chat.modes.clear(); chat.mode.clear(); chat.models.clear(); chat.model.clear(); chat.running_endpoint = if entry.kind == agents::Kind::Api { entry.base_url.clone() } else { String::new() }; // An endpoint's models from the last time, until the new list arrives. if let Some(list) = chat.models_cache.get(&chat.running_endpoint) { chat.models = list.clone(); chat.model = entry.model.clone(); } chat.state = Some(State::Starting); chat.auth.clear(); chat.auth_status = None; chat.agent.clear(); } Err(e) => { chat.notice(trf("ui.agent_chat_failed_start", &[("error", &e)]), true); chat.state = Some(State::Exited); } } } /// Stop the agent (and everything it started); the conversation stays. pub fn disconnect(app: &mut SolveApp) { end_turn(app); let chat = &mut app.agent_chat; chat.stop_backend(false); if chat.state.is_some() { chat.state = Some(State::Off); } } /// The app is quitting: end the agent and its helpers before the window goes. pub fn shutdown(app: &mut SolveApp) { end_turn(app); app.agent_chat.stop_backend(true); } /// Most messages waiting in the queue. const MAX_PENDING: usize = 20; /// Said to the agent before a message that interrupted it, so it knows its last step may be /// incomplete. const INTERRUPT_NOTE: &str = "[The user interrupted your previous step.]"; /// Send what the user typed: now if the agent is ready, queued behind the running message if it /// is working, else once it is ready (starting it if needed). pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) { let text: String = text.chars().take(MAX_PROMPT).collect(); if text.trim().is_empty() { return; } let image = app.agent_chat.attach_view && app.agent_chat.images; if app.agent_chat.state() == State::Busy { let chat = &mut app.agent_chat; if chat.pending.len() >= MAX_PENDING { chat.notice(tr("ui.agent_chat_queue_full"), false); } else { chat.pending.push(Pending { text, image }); } return; } // Messages left waiting by Stop go first, in one prompt with this one. if !app.agent_chat.pending.is_empty() { app.agent_chat.pending.push(Pending { text, image }); send_queue(app, ctx, false); return; } submit(app, ctx, text, image, false); } /// Put `text` in the conversation and send it (`interrupted`: the agent is told it cut in on its /// previous step). fn submit(app: &mut SolveApp, ctx: &egui::Context, text: String, image: bool, interrupted: bool) { app.agent_chat.push(Item::User { text: text.clone(), image }); match app.agent_chat.state() { State::Ready => start_turn_with(app, text, image, interrupted.then_some(INTERRUPT_NOTE)), s => { app.agent_chat.queued = Some((text, image)); #[cfg(not(target_arch = "wasm32"))] if !s.running() { connect(app, ctx); } #[cfg(target_arch = "wasm32")] let _ = (s, ctx); } } } /// Send the queued messages as one prompt, in order. fn send_queue(app: &mut SolveApp, ctx: &egui::Context, interrupted: bool) { let chat = &mut app.agent_chat; chat.pending_edit = None; if chat.pending.is_empty() { return; } let all = std::mem::take(&mut chat.pending); let image = all.iter().any(|p| p.image); let text = all.into_iter().map(|p| p.text).collect::>().join("\n\n"); let text: String = text.chars().take(MAX_PROMPT).collect(); submit(app, ctx, text, image, interrupted); } /// Send now: stop the running step and send the queue as the next prompt once the agent has said /// it stopped (its note tells the agent it was interrupted). With nothing running, sends the queue. pub fn send_now(app: &mut SolveApp, ctx: &egui::Context) { if app.agent_chat.pending.is_empty() { return; } if app.agent_chat.state() == State::Busy { app.agent_chat.steering = true; app.agent_chat.stopped = false; interrupt(app); } else { send_queue(app, ctx, false); } } /// New chat: stop what runs (as Stop does), clear the conversation and the queue, and start a /// fresh session on the same agent (no restart); the agent and its mode stay, the design's undo /// history is untouched. pub fn new_chat(app: &mut SolveApp) { let running = app.agent_chat.state() == State::Busy; if running { cancel(app); } decline_all(app, "the chat was cleared"); end_turn(app); let chat = &mut app.agent_chat; chat.confirm_new = false; chat.items.clear(); chat.pending.clear(); chat.pending_edit = None; chat.queued = None; chat.last = None; chat.steering = false; chat.stopped = false; chat.ended = None; chat.discard_end = running; if matches!(chat.state(), State::Ready | State::Busy) { chat.restore_mode = Some(chat.mode.clone()).filter(|m| !m.is_empty()); chat.state = Some(State::Starting); if let Some(b) = chat.backend.as_mut() { b.new_session(); } } } /// Whether New chat would throw something away that the user may want (so it asks first). pub fn new_chat_needs_confirm(app: &SolveApp) -> bool { app.agent_chat.state() == State::Busy || !app.agent_chat.pending.is_empty() } /// Take a queued message out of the queue. pub fn remove_pending(app: &mut SolveApp, index: usize) { let chat = &mut app.agent_chat; if index < chat.pending.len() { chat.pending.remove(index); } chat.pending_edit = None; } /// The turn ended: the queue goes on its own after a normal end, and after a Send now; not after /// Stop, a sign-in request or an error. fn after_turn(app: &mut SolveApp, ctx: &egui::Context) { let reason = app.agent_chat.ended.take().unwrap_or_default(); let steering = std::mem::take(&mut app.agent_chat.steering); if app.agent_chat.state() != State::Ready || app.agent_chat.pending.is_empty() { return; } if steering && !matches!(reason.as_str(), "auth" | "error") { send_queue(app, ctx, reason == "cancelled"); } else if !app.agent_chat.stopped && reason.is_empty() { send_queue(app, ctx, false); } } fn start_turn(app: &mut SolveApp, text: String, image: bool) { start_turn_with(app, text, image, None); } /// Start a message; `note` goes before the text for the agent, not into the conversation. fn start_turn_with(app: &mut SolveApp, text: String, image: bool, note: Option<&str>) { let sent = match note { Some(n) => format!("{n}\n\n{text}"), None => text.clone(), }; let mut parts = vec![PromptPart::Text(sent)]; if image { match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) { Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }), Err(e) => log::warn!("the view's picture is left out of the prompt: {e}"), } } let label: String = text.lines().next().unwrap_or("").chars().take(48).collect(); app.agent_chat.turn = Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label }); app.agent_chat.last = Some((text, image)); app.agent_chat.state = Some(State::Busy); app.agent_chat.stopped = false; app.agent_chat.steering = false; app.agent_chat.ended = None; if let Some(b) = app.agent_chat.backend.as_mut() { b.prompt(&parts); } } /// Stop: ask the agent to stop the message it is on; requests waiting for approval are declined. /// Messages queued meanwhile stay queued (they wait for Send). pub fn cancel(app: &mut SolveApp) { app.agent_chat.steering = false; app.agent_chat.stopped = true; interrupt(app); } /// Ask the agent to stop the message it is on and decline what waits for approval. fn interrupt(app: &mut SolveApp) { decline_all(app, "the user stopped the message"); if let Some(b) = app.agent_chat.backend.as_mut() { b.cancel(); } for i in &mut app.agent_chat.items { if let Item::Permission { answer: a @ None, .. } = i { *a = Some(tr("ui.agent_chat_cancel").to_string()); } } } fn decline_all(app: &mut SolveApp, why: &str) { let ids: Vec = app.agent_chat.held.iter().map(|h| h.id).collect(); for id in ids { approve_with(app, id, false, why); } } /// Allow or deny a held request. pub fn approve(app: &mut SolveApp, id: u64, allow: bool) { approve_with(app, id, allow, "the user declined it"); } /// Allow a held request and remember to allow its tool for this agent from now on. pub fn approve_always(app: &mut SolveApp, id: u64) { let tool = app.agent_chat.items.iter().find_map(|i| match i { Item::Approval { id: x, tool, .. } if *x == id => Some(tool.clone()), _ => None, }); if let Some(tool) = tool { let agent = app.agent_chat.running_id.clone(); agents::remember(&mut app.preferences, &agent, &tool); } approve(app, id, true); } /// Answer the agent's own permission question at `index` with one of its options (`None`: /// cancelled); an "always allow" answer is remembered for this agent and tool. pub fn answer_permission(app: &mut SolveApp, index: usize, option: Option<&str>) { if let (Some(o), Some(Item::Permission { title, options, answer: None, .. })) = (option, app.agent_chat.items.get(index)) && options.iter().any(|x| x.id == o && x.kind == "allow_always") { let (agent, title) = (app.agent_chat.running_id.clone(), title.clone()); agents::remember(&mut app.preferences, &agent, &title); } app.agent_chat.answer(index, option); } /// The agent asks before running something of its own. SolidCraft's own tools are checked by /// SolidCraft's gate, so they're allowed here (no double question); otherwise the mode or an /// earlier "always allow" may answer, and the chat says so; otherwise the user is asked. fn on_permission(app: &mut SolveApp, key: u64, title: String, options: Vec) { let agent = app.agent_chat.running_id.clone(); let mode = agents::mode_for(&app.preferences, &agent); let remembered = agents::always_allowed(&app.preferences, &agent, &title); let why = if solidcraft_tool(&title) { Some("ui.ai_auto_ours") } else if remembered { Some("ui.ai_auto_remembered") } else if mode == agents::Mode::All { Some("ui.ai_auto_mode") } else { None }; let once = options.iter().find(|o| o.kind == "allow_once"); let always = options.iter().find(|o| o.kind == "allow_always"); let pick = if remembered { always.or(once) } else { once.or(always) }.map(|o| o.id.clone()); match (why, pick) { (Some(why), Some(option)) => { if let Some(b) = app.agent_chat.backend.as_mut() { b.answer_permission(key, Some(&option)); } app.agent_chat.push(Item::Notice { text: trf("ui.ai_auto_allowed", &[("title", &title), ("why", tr(why))]), error: false }); } _ => app.agent_chat.push(Item::Permission { key, title, options, answer: None }), } } fn approve_with(app: &mut SolveApp, id: u64, allow: bool, why: &str) { let chat = &mut app.agent_chat; let Some(i) = chat.held.iter().position(|h| h.id == id) else { return }; let h = chat.held.remove(i); for item in &mut chat.items { if let Item::Approval { id: x, answer, .. } = item && *x == id { *answer = Some(allow); } } if allow { app.control_held.push_back(h.req); } else { let _ = h.req.reply.send(json!({"ok": false, "error": format!("not run: {why}")})); } } /// The message is answered: its changes become one undo step, and what still waits is declined. fn end_turn(app: &mut SolveApp) { decline_all(app, "the message ended"); let Some(turn) = app.agent_chat.turn.take() else { return }; let steps = merge_undo(&mut app.session.undo, &turn.start_doc, turn.start_undo, &trf("ui.agent_chat_undo_label", &[("message", &turn.label)])); if steps > 0 { let doc = app.session.doc.clone(); app.agent_chat.push(Item::Changes { steps, doc, undone: false }); } } /// Merge the undo steps made since `start_doc` (at depth `start_undo`) into one, labelled `label`; /// how many steps it took in (0: none were made, or the history no longer reaches that far). fn merge_undo( undo: &mut Vec, start_doc: &Arc, start_undo: usize, label: &str, ) -> usize { // The first change of the turn saved the design as it was when the turn began. When the // history is full its oldest steps go, so look for it at or below where it was put. let Some(first) = (0..undo.len().min(start_undo + 1)).rev().find(|&i| undo.get(i).is_some_and(|s| Arc::ptr_eq(&s.doc, start_doc))) else { return 0; }; let steps = undo.len() - first; undo.truncate(first + 1); if let Some(s) = undo.get_mut(first) { s.label = label.to_string(); } steps } /// Undo a message's changes from the conversation (only while they are the last thing done). pub fn undo_changes(app: &mut SolveApp, index: usize) { let Some(Item::Changes { doc, undone, .. }) = app.agent_chat.items.get(index) else { return }; if *undone || !Arc::ptr_eq(doc, &app.session.doc) { return; } if app.run("edit.undo", json!({})).is_ok() && let Some(Item::Changes { undone, .. }) = app.agent_chat.items.get_mut(index) { *undone = true; } } /// The requests the agent's tools sent through the chat's endpoint: each one passes the gate. fn pump_tools(app: &mut SolveApp) { let Some(rx) = app.agent_chat.tools_rx.take() else { return }; let limit = app.preferences.agent_chat_turn_limit.clamp(1, 1000); for mut req in rx.try_iter().take(64) { let refuse = |req: ControlRequest, e: String| { let _ = req.reply.send(json!({"ok": false, "error": e})); }; let undo_len = app.session.undo.len(); let Some(turn) = app.agent_chat.turn.as_mut() else { refuse(req, "the agent chat has no message in progress".into()); continue; }; turn.requests += 1; if turn.requests > gate::MAX_REQUESTS_PER_TURN { refuse(req, format!("too many requests in one message (at most {})", gate::MAX_REQUESTS_PER_TURN)); continue; } // Requests without folders of their own stay inside the chat's working folder. if let Some(o) = req.params.as_object_mut() && !o.contains_key("allow_paths") { o.insert("allow_paths".into(), json!([working_dir().to_string_lossy()])); } let undo_left = undo_len.saturating_sub(turn.start_undo); // The permission mode (the agent's own, else the global one) and "always allow" answers. let mode = agents::mode_for(&app.preferences, &app.agent_chat.running_id); let tool = gate::tool_key(&req.method, &req.params); let always = agents::always_allowed(&app.preferences, &app.agent_chat.running_id, &tool); let verdict = match gate::request(&req.method, &req.params, undo_left) { gate::Verdict::Ask(_) if mode == agents::Mode::All || always => gate::Verdict::Run { change: true }, gate::Verdict::Run { change: true } if mode == agents::Mode::Ask && !always => { gate::Verdict::Ask(gate::describe(&req.method, &req.params)) } v => v, }; match verdict { gate::Verdict::Run { change } => { if change { if turn.changes >= limit { let first = !turn.limit_told; turn.limit_told = true; refuse(req, format!("the limit of {limit} changes for one message is reached: tell the user what is left to do")); if first { app.agent_chat.notice(trf("ui.agent_chat_limit", &[("limit", &limit.to_string())]), false); } continue; } turn.changes += 1; } app.control_held.push_back(req); } gate::Verdict::Ask(title) => { let chat = &mut app.agent_chat; chat.next_approval += 1; let id = chat.next_approval; chat.push(Item::Approval { id, title, tool, answer: None }); chat.held.push(Held { id, req, since: crate::now_ms() }); } gate::Verdict::Refuse(e) => { app.agent_chat.notice(trf("ui.agent_chat_refused", &[("reason", &e)]), false); refuse(req, e); } } } app.agent_chat.tools_rx = Some(rx); // Questions nobody answered: the agent stopped waiting long ago. let now = crate::now_ms(); let stale: Vec = app.agent_chat.held.iter().filter(|h| now - h.since > APPROVAL_TIMEOUT_MS).map(|h| h.id).collect(); for id in stale { approve_with(app, id, false, "nobody answered in time"); } } /// Read what the agent sent since the last frame, its tool requests, and the agent search. pub fn poll(app: &mut SolveApp, ctx: &egui::Context) { #[cfg(not(target_arch = "wasm32"))] if let Some(rx) = &app.agent_chat.detecting && let Ok(d) = rx.try_recv() { app.agent_chat.detection = Some(d); app.agent_chat.detecting = None; } // AI features turned off: nothing keeps running, nothing is looked for. if !shown(app) { if app.agent_chat.backend.is_some() || app.agent_chat.open { disconnect(app); app.agent_chat.open = false; } return; } let events = app.agent_chat.backend.as_mut().map(|b| b.poll()).unwrap_or_default(); for e in events { let ready = matches!(e, Event::Ready { .. }); match app.agent_chat.on_event(e) { Some(Outcome::TurnEnd) => { end_turn(app); after_turn(app, ctx); } Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options), None => {} } if ready { apply_saved_model(app); } if ready && let Some((text, image)) = app.agent_chat.queued.take() { start_turn(app, text, image); } } pump_tools(app); if !app.agent_chat.held.is_empty() || app.agent_chat.state() == State::Busy { ctx.request_repaint_after(std::time::Duration::from_millis(250)); } } /// Look for installed agents in the background (cached; Refresh looks again). #[cfg(not(target_arch = "wasm32"))] pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) { if app.agent_chat.detecting.is_some() { return; } let (tx, rx) = std::sync::mpsc::channel(); let ctx = ctx.clone(); std::thread::spawn(move || { let _ = tx.send(discover::detect()); ctx.request_repaint(); }); app.agent_chat.detecting = Some(rx); } /// Choose the model for this chat (the header's model picker): the agent switches to it, and it is /// remembered for this agent; an API endpoint's agent also keeps it as its default model. pub fn set_model(app: &mut SolveApp, model: &str) { if !app.agent_chat.models.iter().any(|m| m.id == model) { return; } if let Some(b) = app.agent_chat.backend.as_mut() { b.set_model(model); } app.agent_chat.model = model.to_string(); let agent = app.agent_chat.running_id.clone(); if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == agent && a.kind == agents::Kind::Api) { a.model = model.to_string(); } app.preferences.agent_models.insert(agent, model.to_string()); } /// A new session is ready: put back the model last chosen for this agent, when it offers it. fn apply_saved_model(app: &mut SolveApp) { let chat = &app.agent_chat; let Some(saved) = app.preferences.agent_models.get(&chat.running_id).cloned() else { return }; if saved != chat.model && chat.models.iter().any(|m| m.id == saved) { set_model(app, &saved); } } /// Whether the model picker shows: only for an agent that reports models. pub fn models_shown(app: &SolveApp) -> bool { !app.agent_chat.models.is_empty() && app.agent_chat.state().running() } /// Switch the agent to one of its own modes (the header's mode picker). pub fn set_mode(app: &mut SolveApp, mode: &str) { if app.agent_chat.modes.iter().any(|(id, _)| id == mode) && let Some(b) = app.agent_chat.backend.as_mut() { b.set_mode(mode); app.agent_chat.mode = mode.to_string(); } } /// `ui.agent_chat` on the control channel (tests and screenshots): `enabled`, `command`, `open`, /// `side`, `minimized`, `width`, `refresh`, `connect`, `stop`, `send`, `attach_view`, /// `permission` (an option id, or `"cancel"`), `approve` (`true`/`false`, the oldest held /// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state. pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result { if let Some(e) = p.get("features").and_then(Value::as_bool) { app.preferences.ai_features = e; } if let Some(b) = p.get("toolbar_button").and_then(Value::as_bool) { app.preferences.ai_toolbar_button = b; } if let Some(m) = p.get("permissions") { let Some(mode) = m.as_str().and_then(agents::Mode::parse) else { return Err("permissions must be ask, edits or all".into()) }; app.preferences.ai_permissions = mode; } // Add an agent (as the Add agent dialog does), for tests and screenshots: {id?, name, kind, command | base_url + model}. if let Some(a) = p.get("add_agent") { let mut c: agents::AgentConfig = serde_json::from_value(a.clone()).map_err(|e| format!("add_agent: {e}"))?; if c.id.is_empty() { c.id = agents::new_id(&app.preferences, if c.kind == agents::Kind::Api { "api" } else { "custom" }); } c.key = false; app.preferences.agents.retain(|x| x.id != c.id); app.preferences.agents.push(c); } if let Some(id) = p.get("agent").and_then(Value::as_str) { if !agents::entries(app).iter().any(|e| e.id == id) { return Err(format!("no agent `{id}`")); } if app.agent_chat.agent_id.as_deref() != Some(id) { disconnect(app); app.agent_chat.agent_id = Some(id.to_string()); } } // The Add agent dialog: open on a kind (`installed`, `custom`, `api`), or `false` to close. if let Some(d) = p.get("add_dialog") { match d.as_str() { Some(k) => { let kind = match k { "installed" => settings::AddKind::Installed, "custom" => settings::AddKind::Custom, "api" => settings::AddKind::Api, _ => return Err("add_dialog must be installed, custom, api or false".into()), }; settings::open_add(app); if let Some(a) = app.agent_chat.settings.add.as_mut() { a.kind = kind; if kind != settings::AddKind::Api { a.name.clear(); } } } None => app.agent_chat.settings.add = None, } } if let Some(id) = p.get("default_agent").and_then(Value::as_str) { app.preferences.agent_default = id.to_string(); } if let Some(m) = p.get("mode").and_then(Value::as_str) { set_mode(app, m); } if let Some(m) = p.get("model").and_then(Value::as_str) { set_model(app, m); } if !shown(app) { return Ok(status(app)); } if let Some(o) = p.get("open").and_then(Value::as_bool) { app.agent_chat.open = o; } if let Some(s) = p.get("side").and_then(Value::as_str) { if !matches!(s, "left" | "right") { return Err("side must be left or right".into()); } app.preferences.agent_chat_side = s.into(); } if let Some(m) = p.get("minimized").and_then(Value::as_bool) { app.preferences.agent_chat_minimized = m; } if let Some(w) = p.get("width").and_then(Value::as_f64).filter(|w| w.is_finite()) { app.preferences.agent_chat_width = (w as f32).clamp(panel::MIN_WIDTH, panel::MAX_WIDTH); panel::forget_width(ctx); } if let Some(a) = p.get("attach_view").and_then(Value::as_bool) { app.agent_chat.attach_view = a; } if let Some(pick) = p.get("picker").and_then(Value::as_bool) { app.agent_chat.picking = Some(pick); } #[cfg(not(target_arch = "wasm32"))] { if p.get("refresh").and_then(Value::as_bool) == Some(true) { refresh(app, ctx); } if p.get("stop").and_then(Value::as_bool) == Some(true) { disconnect(app); } if p.get("connect").and_then(Value::as_bool) == Some(true) { connect(app, ctx); } } if let Some(text) = p.get("send").and_then(Value::as_str) { send(app, ctx, text.to_string()); } if let Some(o) = p.get("permission").and_then(Value::as_str) { let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Permission { answer: None, .. })) else { return Err("no permission request is waiting".into()); }; answer_permission(app, n, (o != "cancel").then_some(o)); } if let Some(a) = p.get("approve") { let Some(id) = app.agent_chat.held.first().map(|h| h.id) else { return Err("no request is waiting for approval".into()) }; match (a.as_bool(), a.as_str()) { (Some(allow), _) => approve(app, id, allow), (_, Some("always")) => approve_always(app, id), _ => return Err("approve must be true, false or \"always\"".into()), } } if p.get("cancel").and_then(Value::as_bool) == Some(true) { cancel(app); } if p.get("new_chat").and_then(Value::as_bool) == Some(true) { new_chat(app); } if p.get("send_now").and_then(Value::as_bool) == Some(true) { send_now(app, ctx); } if let Some(n) = p.get("remove_pending").and_then(Value::as_u64) { remove_pending(app, n as usize); } if p.get("undo").and_then(Value::as_bool) == Some(true) { let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) }; undo_changes(app, n); } Ok(status(app)) } pub fn status(app: &SolveApp) -> Value { let c = &app.agent_chat; let items: Vec = c .items .iter() .map(|i| match i { Item::User { text, image } => json!({"user": text, "image": image}), Item::Agent { thought, text } => json!({"agent": text, "thought": thought}), Item::Tool { id, title, kind, status, output } => { json!({"tool": id, "title": title, "kind": kind, "status": status.map(|s| format!("{s:?}")), "output": output}) } Item::Plan(p) => json!({"plan": p}), Item::Permission { title, options, answer, .. } => { json!({"permission": title, "options": options.iter().map(|o| json!({"id": o.id, "name": o.name, "kind": o.kind})).collect::>(), "answer": answer}) } Item::Approval { title, answer, .. } => json!({"approval": title, "answer": answer}), Item::Changes { steps, undone, .. } => json!({"changes": steps, "undone": undone}), Item::Notice { text, error } => json!({"notice": text, "error": error}), }) .collect(); #[cfg(not(target_arch = "wasm32"))] let detection = c.detection.as_ref().map(|d| { json!({ "found": d.found.iter().map(|f| json!({"id": f.id, "name": f.name, "argv": f.argv, "version": f.version, "login": f.login.id()})).collect::>(), "missing": d.missing.iter().map(|m| json!({"id": m.id, "install": m.install})).collect::>(), "ms": d.took_ms, }) }); #[cfg(target_arch = "wasm32")] let detection: Option = None; let pid = c.backend.as_ref().and_then(|b| b.pid()); json!({ "features": app.preferences.ai_features, "toolbarButton": app.preferences.ai_toolbar_button, "permissions": app.preferences.ai_permissions.id(), "agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::>(), "currentAgent": agents::current(app).map(|e| e.id), "models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::>(), "model": c.model, "modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), "mode": c.mode, "open": c.open, "side": app.preferences.agent_chat_side, "minimized": app.preferences.agent_chat_minimized, "width": app.preferences.agent_chat_width, "state": c.state().id(), "agent": c.agent, "images": c.images, "authStatus": c.auth_status, "authMethods": c.auth.iter().map(|m| json!({"id": m.id, "name": m.name, "kind": match &m.kind { acp::AuthKind::Agent => "agent", acp::AuthKind::Terminal { .. } => "terminal", acp::AuthKind::EnvVar { .. } => "env_var" }})).collect::>(), "toolsPort": c.tools_port, "pid": pid, "waiting": c.waiting(), "pending": c.pending.iter().map(|p| json!({"text": p.text, "image": p.image})).collect::>(), "stopped": c.stopped, "turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})), "detection": detection, "items": items, }) } #[cfg(test)] mod tests;