* Agent chat prototype: drive an ACP agent the user already has from a side panel A desktop-only chat panel (off by default) that starts a coding agent over the Agent Client Protocol and gives it SolidCraft's MCP server, bridged to the open window through the token-protected control channel with the default folder limits. It finds installed agents (Claude Code and Codex adapters, Gemini CLI, Copilot, Qwen, OpenCode, goose) with version and login state, streams text, tool calls and plans, shows the agent's permission requests, and when an agent isn't signed in shows its own login methods without handling any credential. Design notes and findings: docs/design/in-app-agent-chat.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: say only what the end-to-end run showed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Agent chat: gate, one undo per message, docking, process groups, discovery polish Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Agent chat: Settings → AI, several agents with a switcher, OpenAI-compatible endpoints, permission modes, toolbar button Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Agent chat: translations, tool cards, Add agent dialog fixes, test agent saves on its own Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Agent chat: third-party licences for the keychain crates; clippy Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Agent chat: badge text from the theme tokens Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Agent chat: Auto-allow model edits is the default permission mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
998 lines
40 KiB
Rust
998 lines
40 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 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: (usize, usize) = (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 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)>,
|
|
/// 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,
|
|
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) {
|
|
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);
|
|
}
|
|
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::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);
|
|
}
|
|
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);
|
|
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.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);
|
|
}
|
|
|
|
/// Send what the user typed: now if the agent is ready, else once it is (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;
|
|
app.agent_chat.push(Item::User { text: text.clone(), image });
|
|
match app.agent_chat.state() {
|
|
State::Ready => start_turn(app, text, image),
|
|
State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false),
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
|
|
let mut parts = vec![PromptPart::Text(text.clone())];
|
|
if image && let Some(png) = crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
|
|
parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) });
|
|
}
|
|
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);
|
|
if let Some(b) = app.agent_chat.backend.as_mut() {
|
|
b.prompt(&parts);
|
|
}
|
|
}
|
|
|
|
/// Ask the agent to stop the message it is on; requests waiting for approval are declined.
|
|
pub fn cancel(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),
|
|
Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options),
|
|
None => {}
|
|
}
|
|
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);
|
|
}
|
|
|
|
/// 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`, `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 !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("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),
|
|
"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(),
|
|
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
|
|
"detection": detection,
|
|
"items": items,
|
|
})
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|