Files
OneCAD/crates/ui-egui/src/agent_chat/mod.rs
T
2026-10-16 13:26:23 -04:00

1255 lines
50 KiB
Rust

//! Agent chat: a side panel that talks to a coding agent the user already has (Claude Code,
//! Codex, Gemini CLI, 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<ToolStatus>,
output: Option<String>,
},
Plan(Vec<(String, String)>),
/// The agent asks before running something of its own (its options, its words).
Permission {
key: u64,
title: String,
options: Vec<PermissionOption>,
answer: Option<String>,
},
/// 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<bool>,
},
/// A message's changes, merged into one undo step; `doc`: the design right after it.
Changes {
steps: usize,
doc: Arc<solidcraft_engine::doc::Document>,
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<solidcraft_engine::doc::Document>,
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<dyn FnMut(Sender<ControlRequest>) -> Option<u16>>;
#[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<Item>,
pub input: String,
state: Option<State>,
agent: String,
images: bool,
/// Send a picture of the view with the next message.
pub attach_view: bool,
auth: Vec<acp::AuthMethod>,
auth_status: Option<String>,
/// 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<Pending>,
/// 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<String>,
/// 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<String>,
/// 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<egui::Rect>,
/// 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<String>,
/// The agent picked for this conversation (the header's switcher); `None`: the default.
pub agent_id: Option<String>,
/// 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<acp::ModelInfo>,
pub model: String,
/// An API endpoint's models, kept per endpoint (its base URL) for the next connection.
models_cache: std::collections::HashMap<String, Vec<acp::ModelInfo>>,
/// The running API agent's endpoint (empty for other agents).
running_endpoint: String,
backend: Option<Box<dyn ChatBackend>>,
turn: Option<Turn>,
held: Vec<Held>,
next_approval: u64,
/// The chat's control endpoint: the port, and its requests.
pub tools_port: Option<u16>,
tools_rx: Option<Receiver<ControlRequest>>,
tools_tx: Option<Sender<ControlRequest>>,
/// 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<ControlStarter>,
/// The picker is unfolded (it folds to one line once an agent is connected).
picking: Option<bool>,
#[cfg(not(target_arch = "wasm32"))]
detection: Option<discover::Detection>,
#[cfg(not(target_arch = "wasm32"))]
detecting: Option<Receiver<discover::Detection>>,
}
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<String>, 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<Outcome> {
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<String> = tail.into_iter().rev().take(6).collect::<Vec<_>>().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<PermissionOption> },
}
/// 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<std::path::PathBuf> {
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<Box<dyn ChatBackend>, 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<dyn ChatBackend>),
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<dyn ChatBackend>),
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::<Vec<_>>().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<u64> = 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<PermissionOption>) {
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<solidcraft_engine::Snapshot>,
start_doc: &Arc<solidcraft_engine::doc::Document>,
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<u64> = 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<Value, String> {
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<Value> = 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::<Vec<_>>(), "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::<Vec<_>>(),
"missing": d.missing.iter().map(|m| json!({"id": m.id, "install": m.install})).collect::<Vec<_>>(),
"ms": d.took_ms,
})
});
#[cfg(target_arch = "wasm32")]
let detection: Option<Value> = 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::<Vec<_>>(),
"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::<Vec<_>>(),
"model": c.model,
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"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::<Vec<_>>(),
"toolsPort": c.tools_port,
"pid": pid,
"waiting": c.waiting(),
"pending": c.pending.iter().map(|p| json!({"text": p.text, "image": p.image})).collect::<Vec<_>>(),
"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;