In-app agent chat: your own coding agents over ACP, plus OpenAI-compatible endpoints (#1102)

* 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>
This commit is contained in:
bherbruckandClaude Opus 5.5 authored and GitHub committed 2026-10-15 14:22:38 -04:00
1 parent 02fe495d38
commit 2d069ca186
36 files changed
+8078 -303

No files matched your search

+499
View File
@@ -0,0 +1,499 @@
//! The client side of the Agent Client Protocol (ACP, <https://agentclientprotocol.com>), version 1:
//! JSON-RPC 2.0, one message per line over the agent's stdin/stdout. This file only builds and
//! reads messages; the process lives in `process.rs` and the state in `mod.rs`, so everything
//! here is plain data and tested without an agent.
//!
//! What SolidCraft says it can do (`clientCapabilities`): no file system and no terminal (the agent
//! reaches the design through SolidCraft's MCP server instead), and terminal login, so an agent
//! that needs one describes a command for the user to run rather than asking for a key.
use serde_json::{Value, json};
/// The protocol version we speak.
pub const PROTOCOL_VERSION: u64 = 1;
/// JSON-RPC "method not found", our answer to every agent request we don't serve.
pub const METHOD_NOT_FOUND: i64 = -32601;
/// The error code ACP agents use for "authentication required".
pub const AUTH_REQUIRED: i64 = -32000;
/// One request or notification to send.
pub fn request(id: u64, method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})
}
pub fn notification(method: &str, params: Value) -> Value {
json!({"jsonrpc": "2.0", "method": method, "params": params})
}
pub fn reply(id: Value, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
pub fn reply_error(id: Value, code: i64, message: &str) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
pub fn initialize_params(version: &str) -> Value {
json!({
"protocolVersion": PROTOCOL_VERSION,
"clientCapabilities": {
"fs": {"readTextFile": false, "writeTextFile": false},
"terminal": false,
"auth": {"terminal": true},
"_meta": {"terminal-auth": true},
},
"clientInfo": {"name": "solidcraft", "title": "SolidCraft", "version": version},
})
}
/// The MCP server the agent starts to reach the design: `solidcraft-cli mcp --connect` bridged to
/// this app's control port. The server finds the port's token in the token file the app wrote
/// (docs/control-protocol.md), so the token never travels through the agent, and it keeps its
/// default folder limits (docs/mcp.md): no `--allow-any-path`.
#[derive(Clone, Debug, PartialEq)]
pub struct McpBridge {
pub cli: std::path::PathBuf,
pub port: u16,
}
impl McpBridge {
pub fn to_json(&self) -> Value {
json!({
"name": "solidcraft",
"command": self.cli.to_string_lossy(),
"args": ["mcp", "--connect", format!("127.0.0.1:{}", self.port)],
"env": [],
})
}
}
/// What every session is told about where it is. Agents that don't read `_meta` ignore it.
pub const SYSTEM_NOTE: &str = "You are running inside SolidCraft, a parametric 3D CAD application, as the user's assistant. \
The open design is reachable through the `solidcraft` MCP server: use its tools (list_commands, batch, execute, inspect_design, \
measure, screenshot, set_parameter, undo) to read and change the design the user is looking at, and verify with measure or \
inspect_design. Prefer one `batch` over many small calls. Saving, opening or exporting files waits for the user's approval in \
the chat, and each message allows a limited number of changes. Don't use shell or file tools: work through the SolidCraft tools. \
Keep answers short.";
/// The Claude Code adapter's own tools that reach the computer rather than the design: turned off
/// for the session (`_meta.claudeCode.options.disallowedTools`), so it works through SolidCraft's.
pub const CLAUDE_DISALLOWED_TOOLS: &[&str] =
&["Bash", "BashOutput", "KillShell", "Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch", "Task"];
pub fn session_new_params(cwd: &std::path::Path, bridge: Option<&McpBridge>) -> Value {
let servers: Vec<Value> = bridge.map(McpBridge::to_json).into_iter().collect();
json!({
"cwd": cwd.to_string_lossy(),
"mcpServers": servers,
"_meta": {
"systemPrompt": {"append": SYSTEM_NOTE},
// Hosts may opt a session out of "bypass permissions" (the Claude adapter reads it):
// every tool call the agent asks about reaches the panel.
// Its own shell and file tools are off: the agent works through SolidCraft's tools.
"claudeCode": {"options": {"allowDangerouslySkipPermissions": false, "disallowedTools": CLAUDE_DISALLOWED_TOOLS}},
},
})
}
pub fn prompt_params(session: &str, parts: &[super::backend::PromptPart]) -> Value {
json!({"sessionId": session, "prompt": super::backend::content_blocks(parts)})
}
/// The agent's modes in a `session/new` reply: the current one and each `(id, name)`.
pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> {
let m = session.get("modes")?;
let current = m.get("currentModeId").and_then(Value::as_str).unwrap_or("").to_string();
let available: Vec<(String, String)> = m
.get("availableModes")?
.as_array()?
.iter()
.take(16)
.filter_map(|x| {
let id = x.get("id")?.as_str()?.to_string();
let name = x.get("name").and_then(Value::as_str).unwrap_or(&id).to_string();
Some((id, name))
})
.collect();
(!available.is_empty()).then_some((current, available))
}
/// Whether the agent takes pictures in a message (`agentCapabilities.promptCapabilities.image`).
pub fn takes_images(init: &Value) -> bool {
init.pointer("/agentCapabilities/promptCapabilities/image").and_then(Value::as_bool).unwrap_or(false)
}
/// One way to log in that the agent offers (`authMethods` in its `initialize` reply).
#[derive(Clone, Debug, PartialEq)]
pub struct AuthMethod {
pub id: String,
pub name: String,
pub description: String,
pub kind: AuthKind,
}
#[derive(Clone, Debug, PartialEq)]
pub enum AuthKind {
/// The agent signs in by itself when asked (`authenticate`), e.g. by opening a browser.
Agent,
/// The user runs the agent's own login in a terminal: the agent's command plus `args`.
Terminal { args: Vec<String>, command: Option<(String, Vec<String>)> },
/// The agent reads a key from an environment variable the user sets themselves.
EnvVar { vars: Vec<String> },
}
pub fn auth_methods(init: &Value) -> Vec<AuthMethod> {
let strings = |v: Option<&Value>| -> Vec<String> {
v.and_then(Value::as_array).map(|a| a.iter().take(64).filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
};
init.get("authMethods")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(32)
.filter_map(|m| {
let s = |k: &str| m.get(k).and_then(Value::as_str).unwrap_or("").to_string();
let id = s("id");
if id.is_empty() {
return None;
}
let kind = match m.get("type").and_then(Value::as_str) {
Some("terminal") => {
let meta = m.get("_meta").and_then(|x| x.get("terminal-auth"));
let command = meta.and_then(|t| Some((t.get("command")?.as_str()?.to_string(), strings(t.get("args")))));
AuthKind::Terminal { args: strings(m.get("args")), command }
}
Some("env_var") => AuthKind::EnvVar {
vars: m
.get("vars")
.and_then(Value::as_array)
.map(|v| v.iter().take(16).filter_map(|x| x.get("name").and_then(Value::as_str).map(str::to_string)).collect())
.unwrap_or_default(),
},
// A key the agent would take from the client: SolidCraft never handles
// keys, so this becomes "set it in your environment" instead.
_ if m.get("_meta").and_then(|x| x.get("api-key")).is_some() => {
let provider = m
.get("_meta")
.and_then(|x| x.get("api-key"))
.and_then(|k| k.get("provider"))
.and_then(Value::as_str)
.unwrap_or("agent");
AuthKind::EnvVar { vars: vec![format!("{}_API_KEY", provider.to_ascii_uppercase())] }
}
_ => AuthKind::Agent,
};
Some(AuthMethod { id, name: s("name"), description: s("description"), kind })
})
.collect()
})
.unwrap_or_default()
}
/// The agent's name and version from its `initialize` reply.
pub fn agent_info(init: &Value) -> String {
let i = init.get("agentInfo");
let s = |k: &str| i.and_then(|i| i.get(k)).and_then(Value::as_str);
match (s("title").or(s("name")), s("version")) {
(Some(n), Some(v)) => format!("{n} {v}"),
(Some(n), None) => n.to_string(),
_ => String::new(),
}
}
/// A tool call's state as the agent reports it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ToolStatus {
Pending,
InProgress,
Completed,
Failed,
}
impl ToolStatus {
pub fn parse(s: &str) -> Option<ToolStatus> {
Some(match s {
"pending" => ToolStatus::Pending,
"in_progress" => ToolStatus::InProgress,
"completed" => ToolStatus::Completed,
"failed" => ToolStatus::Failed,
_ => return None,
})
}
}
/// One choice of a permission request.
#[derive(Clone, Debug, PartialEq)]
pub struct PermissionOption {
pub id: String,
pub name: String,
/// `allow_once`, `allow_always`, `reject_once` or `reject_always`.
pub kind: String,
}
/// What a message from the agent means for the panel.
#[derive(Clone, Debug, PartialEq)]
pub enum Incoming {
/// The reply to one of our requests.
Reply { id: u64, result: Result<Value, (i64, String)> },
/// Text the agent streams (`thought`: its reasoning, shown dimmed).
Text { thought: bool, text: String },
/// A new tool call, or news about one (`title`/`status` when they changed).
Tool { id: String, title: Option<String>, kind: Option<String>, status: Option<ToolStatus>, output: Option<String> },
/// The agent's plan: each entry with its status.
Plan(Vec<(String, String)>),
/// The agent asks before running a tool call; answered with [`permission_reply`].
Permission { rpc: Value, title: String, options: Vec<PermissionOption> },
/// The agent says whether it is logged in (an extension both adapters send).
AuthStatus(String),
/// The agent switched to another of its modes.
ModeChanged(String),
/// A request we don't serve (file system, terminal, …): answer "method not found".
Unsupported { rpc: Value, method: String },
/// Anything else (mode and command lists, usage): nothing to show.
Ignored,
}
/// The longest text kept from one tool call's output.
const MAX_OUTPUT: usize = 2000;
fn content_text(c: &Value) -> Option<String> {
// A content block, or a tool call content item wrapping one (`{type: "content", content}`).
let block = if c.get("type").and_then(Value::as_str) == Some("content") { c.get("content")? } else { c };
match block.get("type").and_then(Value::as_str) {
Some("text") => block.get("text").and_then(Value::as_str).map(str::to_string),
Some("image") => Some("[image]".into()),
Some("resource_link") => block.get("uri").and_then(Value::as_str).map(|u| format!("[{u}]")),
Some("diff") => block.get("path").and_then(Value::as_str).map(|p| format!("[diff {p}]")),
Some("terminal") => Some("[terminal]".into()),
_ => None,
}
}
fn tool_output(u: &Value) -> Option<String> {
let parts: Vec<String> = u.get("content")?.as_array()?.iter().take(16).filter_map(content_text).collect();
if parts.is_empty() {
return None;
}
let mut s = parts.join("\n");
if s.len() > MAX_OUTPUT {
let mut cut = MAX_OUTPUT;
while !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
s.push('…');
}
Some(s)
}
/// Read one line from the agent.
pub fn parse(line: &str) -> Option<Incoming> {
let msg: Value = serde_json::from_str(line.trim()).ok()?;
let method = msg.get("method").and_then(Value::as_str);
let params = msg.get("params").cloned().unwrap_or(Value::Null);
match (method, msg.get("id")) {
(None, Some(id)) => {
let id = id.as_u64()?;
let result = match msg.get("error") {
Some(e) => {
Err((e.get("code").and_then(Value::as_i64).unwrap_or(0), e.get("message").and_then(Value::as_str).unwrap_or("error").to_string()))
}
None => Ok(msg.get("result").cloned().unwrap_or(Value::Null)),
};
Some(Incoming::Reply { id, result })
}
(Some("session/request_permission"), Some(id)) => {
let call = params.get("toolCall");
let title = call.and_then(|c| c.get("title")).and_then(Value::as_str).unwrap_or("").to_string();
let options = params
.get("options")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(8)
.filter_map(|o| {
Some(PermissionOption {
id: o.get("optionId")?.as_str()?.to_string(),
name: o.get("name").and_then(Value::as_str).unwrap_or("").to_string(),
kind: o.get("kind").and_then(Value::as_str).unwrap_or("").to_string(),
})
})
.collect()
})
.unwrap_or_default();
Some(Incoming::Permission { rpc: id.clone(), title, options })
}
(Some(m), Some(id)) => Some(Incoming::Unsupported { rpc: id.clone(), method: m.to_string() }),
(Some("session/update"), None) => Some(update(params.get("update")?)),
(Some("_auth/status_update"), None) => {
params.get("authStatus").and_then(|s| s.get("label")).and_then(Value::as_str).map(|l| Incoming::AuthStatus(l.to_string()))
}
_ => Some(Incoming::Ignored),
}
}
fn update(u: &Value) -> Incoming {
let s = |k: &str| u.get(k).and_then(Value::as_str).map(str::to_string);
match u.get("sessionUpdate").and_then(Value::as_str) {
Some(kind @ ("agent_message_chunk" | "agent_thought_chunk")) => match u.get("content").and_then(content_text) {
Some(text) => Incoming::Text { thought: kind == "agent_thought_chunk", text },
None => Incoming::Ignored,
},
Some("tool_call" | "tool_call_update") => match s("toolCallId") {
Some(id) => Incoming::Tool {
id,
title: s("title"),
kind: s("kind"),
status: u.get("status").and_then(Value::as_str).and_then(ToolStatus::parse),
output: tool_output(u),
},
None => Incoming::Ignored,
},
Some("current_mode_update") => match s("currentModeId").or_else(|| s("modeId")) {
Some(m) => Incoming::ModeChanged(m),
None => Incoming::Ignored,
},
Some("plan") => Incoming::Plan(
u.get("entries")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(64)
.map(|e| {
let g = |k: &str| e.get(k).and_then(Value::as_str).unwrap_or("").to_string();
(g("content"), g("status"))
})
.collect()
})
.unwrap_or_default(),
),
_ => Incoming::Ignored,
}
}
/// The answer to a permission request: the option picked, or `None` for "cancelled".
pub fn permission_reply(rpc: Value, option: Option<&str>) -> Value {
let outcome = match option {
Some(o) => json!({"outcome": "selected", "optionId": o}),
None => json!({"outcome": "cancelled"}),
};
reply(rpc, json!({"outcome": outcome}))
}
/// Split a command line into the program and its arguments: whitespace separates, double or
/// single quotes group, a backslash before a quote keeps it. No shell runs it.
pub fn split_command(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut quote: Option<char> = None;
let mut started = false;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match (quote, c) {
(_, '\\') if matches!(chars.peek(), Some('"') | Some('\'')) => {
if let Some(n) = chars.next() {
cur.push(n);
}
started = true;
}
(None, '"' | '\'') => {
quote = Some(c);
started = true;
}
(Some(q), _) if c == q => quote = None,
(None, c) if c.is_whitespace() => {
if started {
out.push(std::mem::take(&mut cur));
started = false;
}
}
_ => {
cur.push(c);
started = true;
}
}
}
if started {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commands_split_like_a_shell_without_one() {
assert_eq!(split_command("gemini --acp"), ["gemini", "--acp"]);
assert_eq!(split_command(r#" "C:\Program Files\x\agent.exe" --flag 'a b' "#), [r"C:\Program Files\x\agent.exe", "--flag", "a b"]);
assert_eq!(split_command(r#"a "" b"#), ["a", "", "b"]);
assert!(split_command(" ").is_empty());
}
#[test]
fn the_session_gets_the_bridged_mcp_server_without_a_token_or_any_path() {
let b = McpBridge { cli: "/opt/solidcraft/solidcraft-cli".into(), port: 41234 };
let p = session_new_params(std::path::Path::new("/home/u/designs"), Some(&b));
let s = &p["mcpServers"][0];
assert_eq!(s["name"], "solidcraft");
assert_eq!(s["args"], json!(["mcp", "--connect", "127.0.0.1:41234"]));
let text = p.to_string();
assert!(!text.contains("allow-any-path") && !text.contains("TOKEN") && !text.contains("token"), "{text}");
assert!(p["_meta"]["claudeCode"]["options"]["disallowedTools"].as_array().is_some_and(|t| t.iter().any(|x| x == "Bash")));
assert_eq!(session_new_params(std::path::Path::new("/tmp"), None)["mcpServers"], json!([]));
}
#[test]
fn auth_methods_of_both_adapters_are_read() {
// As the Claude adapter offers them to a client with terminal login.
let claude = json!({"authMethods": [{"description": "Use Claude subscription ", "name": "Claude Subscription", "id": "claude-ai-login",
"type": "terminal", "args": ["--cli", "auth", "login", "--claudeai"],
"_meta": {"terminal-auth": {"command": "/usr/bin/node", "args": ["/x/dist/index.js", "--cli", "auth", "login", "--claudeai"], "label": "Claude Login"}}}]});
let m = auth_methods(&claude);
assert_eq!(m.len(), 1);
let AuthKind::Terminal { args, command } = &m[0].kind else { panic!("{m:?}") };
assert_eq!(args.last().map(String::as_str), Some("--claudeai"));
assert_eq!(command.as_ref().map(|c| c.0.as_str()), Some("/usr/bin/node"));
// As the Codex adapter offers them.
let codex = json!({"authMethods": [{"id": "chatgpt", "name": "Login with ChatGPT"},
{"type": "env_var", "id": "openai-api-key", "name": "Use OPENAI_API_KEY", "vars": [{"name": "OPENAI_API_KEY"}]}]});
let m = auth_methods(&codex);
assert_eq!(m[0].kind, AuthKind::Agent);
// Codex 2.x offers its key login as a method the client would feed: shown as a variable.
let codex2 = json!({"authMethods": [{"id": "api-key", "name": "API Key", "_meta": {"api-key": {"provider": "openai"}}}]});
assert_eq!(auth_methods(&codex2)[0].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] });
assert_eq!(m[1].kind, AuthKind::EnvVar { vars: vec!["OPENAI_API_KEY".into()] });
}
#[test]
fn updates_permissions_and_replies_are_told_apart() {
let text = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Hi"}}}}"#;
assert_eq!(parse(text), Some(Incoming::Text { thought: false, text: "Hi".into() }));
let tool = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"tool_call_update","toolCallId":"t1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ok"}}]}}}"#;
assert_eq!(
parse(tool),
Some(Incoming::Tool { id: "t1".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) })
);
let perm = r#"{"jsonrpc":"2.0","id":7,"method":"session/request_permission","params":{"sessionId":"s","toolCall":{"toolCallId":"t1","title":"batch"},"options":[{"optionId":"allow","name":"Allow","kind":"allow_once"},{"optionId":"reject","name":"Reject","kind":"reject_once"}]}}"#;
let Some(Incoming::Permission { rpc, title, options }) = parse(perm) else { panic!() };
assert_eq!((title.as_str(), options.len(), options[1].kind.as_str()), ("batch", 2, "reject_once"));
assert_eq!(permission_reply(rpc.clone(), Some("allow"))["result"]["outcome"], json!({"outcome": "selected", "optionId": "allow"}));
assert_eq!(permission_reply(rpc, None)["result"]["outcome"]["outcome"], "cancelled");
let fs = r#"{"jsonrpc":"2.0","id":8,"method":"fs/read_text_file","params":{}}"#;
assert!(matches!(parse(fs), Some(Incoming::Unsupported { .. })));
let err = r#"{"jsonrpc":"2.0","id":3,"error":{"code":-32000,"message":"Authentication required"}}"#;
assert_eq!(parse(err), Some(Incoming::Reply { id: 3, result: Err((AUTH_REQUIRED, "Authentication required".into())) }));
assert_eq!(parse("not json"), None);
let mode = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_mode_update","currentModeId":"plan"}}}"#;
assert_eq!(parse(mode), Some(Incoming::ModeChanged("plan".into())));
let new = json!({"sessionId": "s", "modes": {"currentModeId": "default", "availableModes": [{"id": "default", "name": "Default"}, {"id": "acceptEdits", "name": "Accept Edits"}]}});
assert_eq!(modes(&new), Some(("default".into(), vec![("default".into(), "Default".into()), ("acceptEdits".into(), "Accept Edits".into())])));
}
#[test]
fn long_tool_output_is_cut_on_a_character_boundary() {
let long = "é".repeat(3000);
let u = json!({"sessionUpdate": "tool_call", "toolCallId": "t", "content": [{"type": "content", "content": {"type": "text", "text": long}}]});
let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() };
assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…'));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! The ACP backend (desktop only): starts the agent the user picked as a child process and holds
//! the protocol's side of the conversation (initialize, the session, prompts, permission questions,
//! sign-in). The panel sees only [`Event`]s.
use std::collections::HashMap;
use serde_json::{Value, json};
use super::acp::{self, Incoming};
use super::backend::{ChatBackend, Event, PromptPart, StartContext};
use super::process::{AgentProcess, FromAgent};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Pending {
Initialize,
SessionNew,
Prompt,
Authenticate,
SetMode,
}
/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port
/// would otherwise reach the agent and outrank the chat endpoint's own token file.
const ENV_REMOVE: &[&str] = &["SOLIDCRAFT_CONTROL_TOKEN"];
pub struct AcpBackend {
proc: AgentProcess,
cwd: std::path::PathBuf,
bridge: Option<acp::McpBridge>,
next_id: u64,
pending: HashMap<u64, Pending>,
session: Option<String>,
agent: String,
images: bool,
auth: Vec<acp::AuthMethod>,
/// Open permission questions: our key → the agent's request id.
permissions: HashMap<u64, Value>,
next_key: u64,
exited: bool,
}
impl AcpBackend {
/// Start `argv` and say hello (`initialize`); the session follows when the agent answers.
pub fn start(argv: &[String], ctx: StartContext) -> Result<AcpBackend, String> {
let wake = ctx.wake;
let proc = AgentProcess::spawn(argv, &ctx.cwd, ENV_REMOVE, wake)?;
let mut b = AcpBackend {
proc,
cwd: ctx.cwd,
bridge: ctx.bridge,
next_id: 0,
pending: HashMap::new(),
session: None,
agent: String::new(),
images: false,
auth: Vec::new(),
permissions: HashMap::new(),
next_key: 0,
exited: false,
};
b.request(Pending::Initialize, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION")));
Ok(b)
}
fn request(&mut self, what: Pending, method: &str, params: Value) {
self.next_id += 1;
self.pending.insert(self.next_id, what);
self.proc.send(&acp::request(self.next_id, method, params));
}
fn new_session(&mut self) {
let params = acp::session_new_params(&self.cwd, self.bridge.as_ref());
self.request(Pending::SessionNew, "session/new", params);
}
fn handle(&mut self, msg: Incoming, out: &mut Vec<Event>) {
match msg {
Incoming::Reply { id, result } => {
let Some(what) = self.pending.remove(&id) else { return };
match (what, result) {
(Pending::Initialize, Ok(v)) => {
self.agent = acp::agent_info(&v);
self.images = acp::takes_images(&v);
self.auth = acp::auth_methods(&v);
self.new_session();
}
(Pending::SessionNew, Ok(v)) => match v.get("sessionId").and_then(Value::as_str) {
Some(s) => {
self.session = Some(s.to_string());
if let Some((current, available)) = acp::modes(&v) {
out.push(Event::Modes { current, available });
}
out.push(Event::Ready { agent: self.agent.clone(), images: self.images });
}
None => out.push(Event::Error("session/new: no sessionId".into())),
},
(Pending::Prompt, Ok(v)) => {
let reason = v.get("stopReason").and_then(Value::as_str).unwrap_or("");
out.push(Event::TurnEnded { reason: if reason == "end_turn" { String::new() } else { reason.to_string() } });
}
// Signed in: try the session again.
(Pending::Authenticate, Ok(_)) => self.new_session(),
(Pending::SetMode, Ok(_)) => {}
(what, Err((code, message))) => {
let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth");
if auth {
self.session = None;
if what == Pending::Prompt {
out.push(Event::TurnEnded { reason: "auth".into() });
}
out.push(Event::AuthRequired { methods: self.auth.clone() });
} else {
if what == Pending::Prompt {
out.push(Event::TurnEnded { reason: "error".into() });
}
out.push(Event::Error(message));
}
}
}
}
Incoming::Text { thought, text } => out.push(Event::Text { thought, text }),
Incoming::Tool { id, title, kind, status, output } => out.push(Event::Tool { id, title, kind, status, output }),
Incoming::Plan(p) => out.push(Event::Plan(p)),
Incoming::Permission { rpc, title, options } => {
self.next_key += 1;
self.permissions.insert(self.next_key, rpc);
out.push(Event::Permission { key: self.next_key, title, options });
}
Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)),
Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }),
Incoming::Unsupported { rpc, method } => {
self.proc.send(&acp::reply_error(rpc, acp::METHOD_NOT_FOUND, &format!("SolidCraft does not serve `{method}`")));
}
Incoming::Ignored => {}
}
}
}
impl ChatBackend for AcpBackend {
fn poll(&mut self) -> Vec<Event> {
let mut out = Vec::new();
if self.exited {
return out;
}
for _ in 0..512 {
match self.proc.from_agent.try_recv() {
Ok(FromAgent::Line(l)) => {
if let Some(m) = acp::parse(&l) {
self.handle(m, &mut out);
}
}
Ok(FromAgent::Exited(tail)) => {
self.exited = true;
self.session = None;
self.pending.clear();
out.push(Event::Exited { tail });
break;
}
Err(_) => break,
}
}
out
}
fn prompt(&mut self, parts: &[PromptPart]) {
if let Some(s) = self.session.clone() {
self.request(Pending::Prompt, "session/prompt", acp::prompt_params(&s, parts));
}
}
fn cancel(&mut self) {
// Open permission questions are answered "cancelled" first, as the protocol asks.
for (_, rpc) in self.permissions.drain() {
self.proc.send(&acp::permission_reply(rpc, None));
}
if let Some(s) = &self.session {
self.proc.send(&acp::notification("session/cancel", json!({"sessionId": s})));
}
}
fn answer_permission(&mut self, key: u64, option: Option<&str>) {
if let Some(rpc) = self.permissions.remove(&key) {
self.proc.send(&acp::permission_reply(rpc, option));
}
}
fn authenticate(&mut self, method: &str) {
self.request(Pending::Authenticate, "authenticate", json!({"methodId": method}));
}
fn set_mode(&mut self, mode: &str) {
if let Some(s) = self.session.clone() {
self.request(Pending::SetMode, "session/set_mode", json!({"sessionId": s, "modeId": mode}));
}
}
fn pid(&self) -> Option<u32> {
self.proc.pid()
}
fn shutdown(&mut self, wait: bool) {
self.proc.shutdown(wait);
}
}
/// Test connection: start the agent, say hello, open a session without tools, end it. What it
/// answered (its name and version), or what went wrong (it isn't signed in, it didn't answer).
pub fn test_agent(argv: &[String], cwd: &std::path::Path) -> Result<String, String> {
let mut p = AgentProcess::spawn(argv, cwd, ENV_REMOVE, || {})?;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
let wait_for = |p: &AgentProcess, id: u64| -> Result<Value, String> {
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match p.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
if let Some(Incoming::Reply { id: got, result }) = acp::parse(&l)
&& got == id
{
return result.map_err(|(code, m)| {
if code == acp::AUTH_REQUIRED || m.to_ascii_lowercase().contains("auth") {
crate::i18n::tr("ui.agent_chat_state_auth").to_string()
} else {
m
}
});
}
}
Ok(FromAgent::Exited(tail)) => {
return Err(tail.last().cloned().unwrap_or_else(|| crate::i18n::tr("ui.agent_chat_exited").to_string()));
}
Err(_) => return Err(crate::i18n::tr("ui.ai_no_answer").to_string()),
}
}
};
p.send(&acp::request(1, "initialize", acp::initialize_params(env!("CARGO_PKG_VERSION"))));
let init = wait_for(&p, 1);
let result = init.and_then(|v| {
p.send(&acp::request(2, "session/new", acp::session_new_params(cwd, None)));
wait_for(&p, 2).map(|_| {
let who = acp::agent_info(&v);
crate::i18n::format("ui.ai_test_acp_ok", &[("agent", if who.is_empty() { "?" } else { &who })])
})
});
p.shutdown(true);
result
}
+234
View File
@@ -0,0 +1,234 @@
//! The agents the chat can use: the installed ones it finds by itself (Claude Code, Codex,
//! Gemini CLI, OpenCode, …) and the ones the user adds (another ACP command, or an
//! OpenAI-compatible endpoint such as Ollama, LM Studio or OpenAI). Also the permission modes.
//! Preferences only: no design data, no keys (those are in the system keychain).
use serde::{Deserialize, Serialize};
/// How much the agent may do without asking.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
/// Every change to the design, file and destructive action asks first.
Ask,
/// Modelling commands run; files and destructive actions still ask (the default).
#[default]
Edits,
/// Nothing asks (crash reports and the folder limits still hold).
All,
}
impl Mode {
pub const ALL: [Mode; 3] = [Mode::Ask, Mode::Edits, Mode::All];
pub fn id(self) -> &'static str {
match self {
Mode::Ask => "ask",
Mode::Edits => "edits",
Mode::All => "all",
}
}
pub fn parse(s: &str) -> Option<Mode> {
Mode::ALL.into_iter().find(|m| m.id() == s)
}
pub fn label(self) -> &'static str {
crate::i18n::tr(match self {
Mode::Ask => "ui.ai_mode_ask",
Mode::Edits => "ui.ai_mode_edits",
Mode::All => "ui.ai_mode_all",
})
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
/// A program that speaks the Agent Client Protocol.
#[default]
Acp,
/// An OpenAI-compatible chat completions endpoint.
Api,
}
/// An agent the user added.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentConfig {
pub id: String,
pub name: String,
pub kind: Kind,
/// ACP: the command line.
pub command: String,
/// API: the endpoint (`…/v1`) and model.
pub base_url: String,
pub model: String,
/// API: a key is stored for it in the system keychain.
pub key: bool,
}
/// An agent as the picker and the switcher show it.
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
pub id: String,
pub name: String,
pub kind: Kind,
/// ACP: what to start.
pub argv: Vec<String>,
pub version: Option<String>,
/// `signed_in`, `key`, `signed_out`, `unknown` (found agents), or empty.
pub login: &'static str,
/// Found installed (not added by the user).
pub found: bool,
pub base_url: String,
pub model: String,
pub key: bool,
}
impl Entry {
fn from_config(c: &AgentConfig) -> Entry {
Entry {
id: c.id.clone(),
name: if c.name.trim().is_empty() { c.id.clone() } else { c.name.clone() },
kind: c.kind,
argv: super::acp::split_command(&c.command),
version: None,
login: "",
found: false,
base_url: c.base_url.clone(),
model: c.model.clone(),
key: c.key,
}
}
/// A short line under the name: the version, the model, or the command.
pub fn detail(&self) -> String {
match self.kind {
Kind::Api => format!("{} · {}", self.model, self.base_url),
Kind::Acp => match &self.version {
Some(v) => v.clone(),
None => super::panel::short_command(&self.argv.join(" ")),
},
}
}
}
/// Every agent the chat can use: the installed ones found, then the ones the user added.
pub fn entries(app: &crate::SolveApp) -> Vec<Entry> {
let mut out = Vec::new();
#[cfg(not(target_arch = "wasm32"))]
if let Some(d) = &app.agent_chat.detection {
out.extend(d.found.iter().map(|f| Entry {
id: f.id.to_string(),
name: f.name.to_string(),
kind: Kind::Acp,
argv: f.argv.clone(),
version: f.version.clone(),
login: f.login.id(),
found: true,
base_url: String::new(),
model: String::new(),
key: false,
}));
}
out.extend(app.preferences.agents.iter().map(Entry::from_config));
out
}
/// The agent a conversation uses: the one picked in the panel, else the default, else the first.
pub fn current(app: &crate::SolveApp) -> Option<Entry> {
let all = entries(app);
let pick = |id: &str| all.iter().find(|e| e.id == id).cloned();
app.agent_chat.agent_id.as_deref().and_then(pick).or_else(|| pick(&app.preferences.agent_default)).or_else(|| all.first().cloned())
}
/// The permission mode for an agent: its own, else the global one.
pub fn mode_for(p: &crate::prefs::Prefs, id: &str) -> Mode {
p.agent_permissions.get(id).copied().unwrap_or(p.ai_permissions)
}
/// Whether `tool` is always allowed for `agent`.
pub fn always_allowed(p: &crate::prefs::Prefs, agent: &str, tool: &str) -> bool {
p.agent_always_allow.get(agent).is_some_and(|t| t.iter().any(|x| x == tool))
}
/// Remember an "always allow" answer.
pub fn remember(p: &mut crate::prefs::Prefs, agent: &str, tool: &str) {
let list = p.agent_always_allow.entry(agent.to_string()).or_default();
if !list.iter().any(|x| x == tool) && list.len() < 500 {
list.push(tool.chars().take(200).collect());
}
}
/// A free id for a new agent: `prefix-1`, `prefix-2`, …
pub fn new_id(p: &crate::prefs::Prefs, prefix: &str) -> String {
(1..).map(|n| format!("{prefix}-{n}")).find(|id| !p.agents.iter().any(|a| &a.id == id)).unwrap_or_else(|| prefix.to_string())
}
/// OpenAI-compatible endpoints people use: name, base URL, whether it needs a key.
pub const API_PRESETS: &[(&str, &str, bool)] = &[
("Ollama", "http://localhost:11434/v1", false),
("LM Studio", "http://localhost:1234/v1", false),
("OpenAI", "https://api.openai.com/v1", true),
];
/// Whether a key may be sent to `url`: only over HTTPS, or to this computer.
pub fn key_safe_for(url: &str) -> bool {
let u = url.trim().to_ascii_lowercase();
if u.starts_with("https://") {
return true;
}
let Some(rest) = u.strip_prefix("http://") else { return false };
let host = rest.split(['/', '?']).next().unwrap_or("");
let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
let name = if host.starts_with('[') {
host.split(']').next().map(|h| format!("{h}]")).unwrap_or_default()
} else {
host.split(':').next().unwrap_or("").to_string()
};
matches!(name.as_str(), "localhost" | "127.0.0.1" | "[::1]")
}
/// A base URL the API backend accepts: `http(s)://host…`, without a trailing slash.
pub fn normalize_url(url: &str) -> Result<String, String> {
let u = url.trim().trim_end_matches('/').to_string();
let lower = u.to_ascii_lowercase();
if !(lower.starts_with("http://") || lower.starts_with("https://")) || u.len() < 10 || u.contains(char::is_whitespace) {
return Err(crate::i18n::tr("ui.ai_bad_url").to_string());
}
Ok(u)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_go_only_over_https_or_to_this_computer() {
assert!(key_safe_for("https://api.openai.com/v1"));
assert!(key_safe_for("http://localhost:11434/v1"));
assert!(key_safe_for("http://127.0.0.1:1234/v1"));
assert!(key_safe_for("http://[::1]:8080/v1"));
assert!(!key_safe_for("http://192.168.1.20:11434/v1"));
assert!(!key_safe_for("http://localhost.evil.example/v1"));
assert!(!key_safe_for("http://user@evil.example/v1"));
assert!(!key_safe_for("ftp://x"));
}
#[test]
fn modes_and_ids_round_trip() {
for m in Mode::ALL {
assert_eq!(Mode::parse(m.id()), Some(m));
}
let mut p = crate::prefs::Prefs::default();
assert_eq!(p.ai_permissions, Mode::Edits, "model edits run, files ask: the default");
p.agent_permissions.insert("codex".into(), Mode::Ask);
assert_eq!(mode_for(&p, "codex"), Mode::Ask);
assert_eq!(mode_for(&p, "claude"), Mode::Edits);
remember(&mut p, "claude", "mcp__solidcraft__save");
remember(&mut p, "claude", "mcp__solidcraft__save");
assert!(always_allowed(&p, "claude", "mcp__solidcraft__save") && !always_allowed(&p, "codex", "mcp__solidcraft__save"));
assert_eq!(p.agent_always_allow["claude"].len(), 1);
p.agents.push(AgentConfig { id: "api-1".into(), ..Default::default() });
assert_eq!(new_id(&p, "api"), "api-2");
assert_eq!(normalize_url(" http://localhost:11434/v1/ ").as_deref(), Ok("http://localhost:11434/v1"));
assert!(normalize_url("localhost:11434").is_err());
}
}
+485
View File
@@ -0,0 +1,485 @@
//! The API backend (desktop only): a model behind an OpenAI-compatible chat completions endpoint
//! (Ollama, LM Studio, OpenAI, any URL the user gives). SolidCraft runs the tool loop itself, with
//! the same tools the ACP agents get: it starts the same MCP server (`solidcraft-cli mcp
//! --connect` to the chat's own endpoint) and is its client, so every tool call passes the same
//! gate, limits and folder checks. The key, when there is one, comes from the system keychain for
//! this connection only.
//!
//! The loop runs on a thread of its own: a model request can take a while.
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
use serde_json::{Value, json};
use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus};
use super::process::{AgentProcess, FromAgent, ProcessHandle};
/// Model requests for one message, at most (each answer with tool calls is one).
pub const MAX_REQUESTS_PER_TURN: usize = 24;
/// Longest tool result handed back to the model.
const MAX_TOOL_TEXT: usize = 20_000;
/// Messages kept in the conversation (the system note stays).
const MAX_HISTORY: usize = 240;
/// How long one model request may take.
const REQUEST_TIMEOUT_S: u64 = 300;
/// How long one tool call may take (it may wait for the user's Allow).
const TOOL_TIMEOUT: Duration = Duration::from_secs(330);
/// Tools the model is not offered.
const HIDDEN_TOOLS: &[&str] = &["crash_reports"];
pub struct ApiConfig {
pub name: String,
pub base_url: String,
pub model: String,
pub key: Option<String>,
pub http: HttpFn,
}
enum Cmd {
Prompt(Vec<PromptPart>),
}
pub struct ApiBackend {
cmds: Option<Sender<Cmd>>,
events: Receiver<Event>,
cancel: Arc<AtomicBool>,
mcp: Option<ProcessHandle>,
}
impl ApiBackend {
pub fn start(cfg: ApiConfig, ctx: StartContext) -> Result<ApiBackend, String> {
if cfg.key.is_some() && !super::agents::key_safe_for(&cfg.base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
}
// The same MCP server an ACP agent would get.
let mcp = match &ctx.bridge {
Some(b) => {
let argv = vec![b.cli.to_string_lossy().into_owned(), "mcp".into(), "--connect".into(), format!("127.0.0.1:{}", b.port)];
Some(AgentProcess::spawn(&argv, &ctx.cwd, &["SOLIDCRAFT_CONTROL_TOKEN"], || {})?)
}
None => None,
};
let handle = mcp.as_ref().map(AgentProcess::handle);
let (cmd_tx, cmd_rx) = channel::<Cmd>();
let (ev_tx, events) = channel::<Event>();
let cancel = Arc::new(AtomicBool::new(false));
let stop = cancel.clone();
let wake: Arc<dyn Fn() + Send + Sync> = Arc::from(ctx.wake);
std::thread::spawn(move || {
let mut w = Worker { cfg, mcp, next: 0, tools: Vec::new(), history: Vec::new(), events: ev_tx, wake, cancel: stop };
w.run(cmd_rx);
});
Ok(ApiBackend { cmds: Some(cmd_tx), events, cancel, mcp: handle })
}
}
impl ChatBackend for ApiBackend {
fn poll(&mut self) -> Vec<Event> {
self.events.try_iter().take(512).collect()
}
fn prompt(&mut self, parts: &[PromptPart]) {
self.cancel.store(false, Ordering::Relaxed);
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::Prompt(parts.to_vec()));
}
}
fn cancel(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {}
fn authenticate(&mut self, _method: &str) {}
fn pid(&self) -> Option<u32> {
self.mcp.as_ref().and_then(ProcessHandle::pid)
}
fn shutdown(&mut self, wait: bool) {
self.cancel.store(true, Ordering::Relaxed);
self.cmds = None;
if let Some(h) = self.mcp.take() {
h.shutdown(wait);
}
}
}
impl Drop for ApiBackend {
fn drop(&mut self) {
self.shutdown(false);
}
}
struct Worker {
cfg: ApiConfig,
mcp: Option<AgentProcess>,
next: u64,
/// The MCP tools as OpenAI function tools.
tools: Vec<Value>,
history: Vec<Value>,
events: Sender<Event>,
wake: Arc<dyn Fn() + Send + Sync>,
cancel: Arc<AtomicBool>,
}
impl Worker {
fn emit(&self, e: Event) {
let _ = self.events.send(e);
(self.wake)();
}
fn run(&mut self, cmds: Receiver<Cmd>) {
if let Err(e) = self.start_mcp() {
self.emit(Event::Error(e));
}
self.history.push(json!({"role": "system", "content": super::acp::SYSTEM_NOTE}));
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
for cmd in cmds {
match cmd {
Cmd::Prompt(parts) => self.turn(parts),
}
}
if let Some(mut m) = self.mcp.take() {
m.shutdown(false);
}
}
/// One MCP request to the server; its `result`.
fn mcp_call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let Some(m) = self.mcp.as_ref() else { return Err("no SolidCraft tools in this session".into()) };
self.next += 1;
let id = self.next;
m.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let deadline = std::time::Instant::now() + TOOL_TIMEOUT;
loop {
let left = deadline.saturating_duration_since(std::time::Instant::now());
match m.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
let Ok(v) = serde_json::from_str::<Value>(l.trim()) else { continue };
if v.get("id").and_then(Value::as_u64) != Some(id) {
continue;
}
if let Some(e) = v.get("error") {
return Err(e.get("message").and_then(Value::as_str).unwrap_or("error").to_string());
}
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
}
Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()),
Err(_) => return Err("the SolidCraft tools did not answer".into()),
}
}
}
fn start_mcp(&mut self) -> Result<(), String> {
if self.mcp.is_none() {
return Ok(());
}
self.mcp_call(
"initialize",
json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "solidcraft-agent-chat", "version": env!("CARGO_PKG_VERSION")}}),
)?;
if let Some(m) = &self.mcp {
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
}
let list = self.mcp_call("tools/list", json!({}))?;
self.tools = openai_tools(&list);
Ok(())
}
fn turn(&mut self, parts: Vec<PromptPart>) {
self.history.push(user_message(&parts));
// Keep the system note; drop the oldest messages, never leaving a reply first.
while self.history.len() > MAX_HISTORY {
self.history.remove(1);
}
while self.history.len() > 2 && self.history.get(1).is_some_and(|m| m["role"] != "user") {
self.history.remove(1);
}
for _ in 0..MAX_REQUESTS_PER_TURN {
if self.cancel.load(Ordering::Relaxed) {
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
let reply = match self.request() {
Ok(r) => r,
Err(e) => {
self.emit(Event::Error(e));
self.emit(Event::TurnEnded { reason: "error".into() });
return;
}
};
let msg = reply.pointer("/choices/0/message").cloned().unwrap_or(Value::Null);
if let Some(text) = msg.get("content").and_then(Value::as_str).filter(|t| !t.is_empty()) {
self.emit(Event::Text { thought: false, text: text.to_string() });
}
let calls: Vec<Value> = msg.get("tool_calls").and_then(Value::as_array).cloned().unwrap_or_default();
let mut kept = json!({"role": "assistant", "content": msg.get("content").cloned().unwrap_or(Value::Null)});
if !calls.is_empty() {
kept["tool_calls"] = Value::Array(calls.clone());
}
self.history.push(kept);
if calls.is_empty() {
self.emit(Event::TurnEnded { reason: String::new() });
return;
}
for (i, c) in calls.iter().enumerate().take(32) {
let id = c.get("id").and_then(Value::as_str).map_or_else(|| format!("call-{i}"), str::to_string);
let name = c.pointer("/function/name").and_then(Value::as_str).unwrap_or("").to_string();
let args: Value = c
.pointer("/function/arguments")
.and_then(|a| if let Some(s) = a.as_str() { serde_json::from_str(s).ok() } else { Some(a.clone()) })
.unwrap_or(json!({}));
let title = tool_title(&name, &args);
let tool_id = format!("{id}-{}", self.next);
self.emit(Event::Tool {
id: tool_id.clone(),
title: Some(title),
kind: Some(String::new()),
status: Some(ToolStatus::InProgress),
output: None,
});
let (text, failed) = if self.cancel.load(Ordering::Relaxed) {
("cancelled by the user".to_string(), true)
} else if HIDDEN_TOOLS.contains(&name.as_str())
|| !self.tools.iter().any(|t| t.pointer("/function/name").and_then(Value::as_str) == Some(name.as_str()))
{
(format!("unknown tool `{name}`"), true)
} else {
match self.mcp_call("tools/call", json!({"name": name, "arguments": args})) {
Ok(r) => tool_text(&r),
Err(e) => (e, true),
}
};
let shown: String = text.chars().take(2000).collect();
self.emit(Event::Tool {
id: tool_id,
title: None,
kind: None,
status: Some(if failed { ToolStatus::Failed } else { ToolStatus::Completed }),
output: Some(shown),
});
self.history.push(json!({"role": "tool", "tool_call_id": id, "content": text}));
}
}
self.emit(Event::Text { thought: false, text: format!("\n({})", crate::i18n::tr("ui.ai_request_limit")) });
self.emit(Event::TurnEnded { reason: "max_turn_requests".into() });
}
fn request(&self) -> Result<Value, String> {
let mut body = json!({"model": self.cfg.model, "messages": self.history});
if !self.tools.is_empty() {
body["tools"] = Value::Array(self.tools.clone());
}
let mut headers = vec![("Content-Type".to_string(), "application/json".to_string())];
if let Some(k) = &self.cfg.key {
headers.push(("Authorization".into(), format!("Bearer {k}")));
}
let req = HttpRequest {
method: "POST",
url: format!("{}/chat/completions", self.cfg.base_url),
headers,
body: Some(body.to_string().into_bytes()),
timeout_s: REQUEST_TIMEOUT_S,
};
let (status, bytes) = (self.cfg.http)(&req)?;
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
}
Ok(v)
}
}
/// An endpoint's refusal as people read it.
pub fn http_error(status: u16, body: &Value) -> String {
let detail =
body.pointer("/error/message").or_else(|| body.get("error")).and_then(Value::as_str).unwrap_or("").chars().take(300).collect::<String>();
let what = match status {
401 | 403 => crate::i18n::tr("ui.ai_http_key"),
404 => crate::i18n::tr("ui.ai_http_not_found"),
429 => crate::i18n::tr("ui.ai_http_busy"),
_ => "",
};
format!(
"HTTP {status}{}{}",
if what.is_empty() { String::new() } else { format!(": {what}") },
if detail.is_empty() { String::new() } else { format!(" ({detail})") }
)
}
/// The message for what the user typed (and the view, as a data URL).
fn user_message(parts: &[PromptPart]) -> Value {
let images = parts.iter().any(|p| matches!(p, PromptPart::Image { .. }));
if !images {
let text: Vec<&str> = parts.iter().filter_map(|p| if let PromptPart::Text(t) = p { Some(t.as_str()) } else { None }).collect();
return json!({"role": "user", "content": text.join("\n")});
}
let content: Vec<Value> = parts
.iter()
.map(|p| match p {
PromptPart::Text(t) => json!({"type": "text", "text": t}),
PromptPart::Image { mime, base64 } => json!({"type": "image_url", "image_url": {"url": format!("data:{mime};base64,{base64}")}}),
})
.collect();
json!({"role": "user", "content": content})
}
/// MCP `tools/list` as OpenAI function tools (the hidden ones left out).
pub fn openai_tools(list: &Value) -> Vec<Value> {
list.get("tools")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(128)
.filter_map(|t| {
let name = t.get("name")?.as_str()?;
if HIDDEN_TOOLS.contains(&name) {
return None;
}
let params = t.get("inputSchema").cloned().filter(Value::is_object).unwrap_or(json!({"type": "object", "properties": {}}));
Some(json!({"type": "function", "function": {
"name": name,
"description": t.get("description").and_then(Value::as_str).unwrap_or(""),
"parameters": params,
}}))
})
.collect()
})
.unwrap_or_default()
}
/// A tool call's result as text for the model, and whether it failed.
fn tool_text(r: &Value) -> (String, bool) {
let failed = r.get("isError").and_then(Value::as_bool).unwrap_or(false);
let parts: Vec<String> = r
.get("content")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.take(16)
.map(|c| match c.get("type").and_then(Value::as_str) {
Some("text") => c.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
Some("image") => "[a picture of the view]".to_string(),
_ => String::new(),
})
.collect()
})
.unwrap_or_default();
let mut text = parts.join("\n");
if text.len() > MAX_TOOL_TEXT {
let mut cut = MAX_TOOL_TEXT;
while !text.is_char_boundary(cut) {
cut -= 1;
}
text.truncate(cut);
text.push_str("\n…(cut)");
}
(text, failed)
}
/// What a tool call card says: the tool and, for `execute` and `batch`, the commands.
fn tool_title(name: &str, args: &Value) -> String {
match name {
"execute" => format!("execute: {}", args.get("command").and_then(Value::as_str).unwrap_or("?")),
"batch" => {
let ids: Vec<&str> = args
.get("commands")
.and_then(Value::as_array)
.map(|c| c.iter().filter_map(|s| s.get("command").and_then(Value::as_str)).take(4).collect())
.unwrap_or_default();
format!("batch: {}", ids.join(", "))
}
_ => name.to_string(),
}
}
/// Ask an endpoint for its models (Test connection): how many, and whether `model` is one.
pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &str) -> Result<String, String> {
let mut headers = Vec::new();
if let Some(k) = key {
if !super::agents::key_safe_for(base_url) {
return Err(crate::i18n::tr("ui.ai_key_needs_https").to_string());
}
headers.push(("Authorization".to_string(), format!("Bearer {k}")));
}
let req = HttpRequest { method: "GET", url: format!("{base_url}/models"), headers, body: None, timeout_s: 15 };
let (status, bytes) = http(&req)?;
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
}
let ids: Vec<&str> =
v.get("data").and_then(Value::as_array).map(|a| a.iter().filter_map(|m| m.get("id").and_then(Value::as_str)).collect()).unwrap_or_default();
let found = model.is_empty() || ids.contains(&model);
Ok(crate::i18n::format(if found { "ui.ai_test_ok" } else { "ui.ai_test_no_model" }, &[("count", &ids.len().to_string()), ("model", model)]))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mcp_tools_become_function_tools_without_crash_reports() {
let list = json!({"tools": [
{"name": "batch", "description": "Run commands", "inputSchema": {"type": "object", "properties": {"commands": {"type": "array"}}}},
{"name": "crash_reports", "description": "x", "inputSchema": {"type": "object"}},
{"name": "measure"},
]});
let t = openai_tools(&list);
assert_eq!(t.len(), 2);
assert_eq!(t[0]["function"]["name"], "batch");
assert_eq!(t[1]["function"]["parameters"]["type"], "object");
}
#[test]
fn pictures_go_as_data_urls_and_text_alone_as_a_string() {
assert_eq!(user_message(&[PromptPart::Text("hi".into())])["content"], "hi");
let m = user_message(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]);
assert_eq!(m["content"][1]["image_url"]["url"], "data:image/png;base64,AA==");
}
/// A scripted endpoint: one answer with a tool call, then a plain answer. The loop runs the
/// tool (here without SolidCraft tools: an unknown tool fails) and reports the turn's end.
#[test]
fn the_tool_loop_runs_calls_and_ends_the_turn() {
let answers = std::sync::Mutex::new(vec![
json!({"choices": [{"message": {"role": "assistant", "content": "Done."}}]}),
json!({"choices": [{"message": {"role": "assistant", "content": "Measuring.", "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "measure", "arguments": "{}"}}]}}]}),
]);
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
let a = answers.lock().unwrap().pop().unwrap_or(json!({}));
Ok((200, a.to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "m".into(), key: None, http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut b = ApiBackend::start(cfg, ctx).unwrap();
b.prompt(&[PromptPart::Text("measure it".into())]);
let mut events = Vec::new();
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::TurnEnded { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
assert!(matches!(events.first(), Some(Event::Ready { .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Tool { status: Some(ToolStatus::Failed), .. })), "{events:?}");
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Done.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
// The second request carried the tool's answer.
let sent = seen.lock().unwrap();
assert_eq!(sent.len(), 2);
assert_eq!(sent[1]["messages"].as_array().unwrap().last().unwrap()["role"], "tool");
b.shutdown(true);
}
#[test]
fn keys_never_go_over_plain_http_to_another_computer() {
let http: HttpFn = Arc::new(|_: &HttpRequest| Ok((200, b"{}".to_vec())));
assert!(test_endpoint(&http, "http://10.0.0.2:11434/v1", Some("k"), "m").is_err());
let cfg = ApiConfig { name: "x".into(), base_url: "http://10.0.0.2/v1".into(), model: "m".into(), key: Some("k".into()), http };
let ctx = StartContext { cwd: std::env::temp_dir(), bridge: None, tools: None, wake: Box::new(|| {}) };
assert!(ApiBackend::start(cfg, ctx).is_err());
}
}
+154
View File
@@ -0,0 +1,154 @@
//! The seam between the chat panel and whatever answers it. The panel (conversation, sign-in,
//! approvals, turns, undo, the tool gate) only talks to a [`ChatBackend`]; the first backend is
//! [`crate::agent_chat::acp_client::AcpBackend`], which drives an agent the user already has over
//! the Agent Client Protocol. Another backend (one that calls a model API itself, say) slots in
//! by implementing the trait and sending its tool calls into [`StartContext::tools`]: they then
//! pass the same gate, count against the same per-turn limit and undo as one step, exactly like
//! the ACP agent's.
use serde_json::Value;
pub use super::acp::{AuthMethod, PermissionOption, ToolStatus};
/// One part of a message to the agent.
#[derive(Clone, Debug, PartialEq)]
pub enum PromptPart {
Text(String),
/// A picture of the design (PNG), sent only to agents that take images.
Image {
mime: String,
base64: String,
},
}
/// What a backend reports to the panel.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
/// Ready for a message. `agent`: its name and version; `images`: it takes pictures.
Ready { agent: String, images: bool },
/// Text it streams (`thought`: its reasoning, shown dimmed).
Text { thought: bool, text: String },
/// A new tool call, or news about one.
Tool { id: String, title: Option<String>, kind: Option<String>, status: Option<ToolStatus>, output: Option<String> },
/// Its plan, replaced whole: each entry with its status.
Plan(Vec<(String, String)>),
/// It asks before running something of its own; answered with [`ChatBackend::answer_permission`].
Permission { key: u64, title: String, options: Vec<PermissionOption> },
/// It needs the user to sign in; `methods` are its own ways to do so.
AuthRequired { methods: Vec<AuthMethod> },
/// What it says about its login.
AuthStatus(String),
/// Its own modes (an ACP agent's `modes`: ask, accept edits, plan, …) and the current one.
Modes { current: String, available: Vec<(String, String)> },
/// The message is answered (`reason`: why it stopped, empty for a normal end).
TurnEnded { reason: String },
/// Something failed; the backend is still running unless [`Event::Exited`] follows.
Error(String),
/// It ended; its last error output.
Exited { tail: Vec<String> },
}
/// What a backend gets to start.
pub struct StartContext {
/// Its working folder: an empty folder of SolidCraft's own, not the user's files.
pub cwd: std::path::PathBuf,
/// The MCP server that reaches this window (`solidcraft-cli mcp --connect` to the chat's own
/// endpoint), for backends that start MCP servers; `None` when it could not be set up.
pub bridge: Option<super::acp::McpBridge>,
/// Where tool calls run in process go, for backends that run their own tool loop.
pub tools: Option<std::sync::mpsc::Sender<crate::ControlRequest>>,
/// Wakes the window when something arrives.
pub wake: Box<dyn Fn() + Send + Sync>,
}
/// A chat backend. Methods never block the frame; replies arrive through [`ChatBackend::poll`].
pub trait ChatBackend {
/// What arrived since the last call.
fn poll(&mut self) -> Vec<Event>;
/// Send a message (the panel only sends one after [`Event::Ready`] and while no turn runs).
fn prompt(&mut self, parts: &[PromptPart]);
/// Stop the message being answered; open permission questions count as cancelled.
fn cancel(&mut self);
/// Answer an [`Event::Permission`] with one of its options (`None`: cancelled).
fn answer_permission(&mut self, key: u64, option: Option<&str>);
/// Ask the backend to sign in by itself with one of its methods (it opens the browser).
fn authenticate(&mut self, method: &str);
/// Switch to one of the modes it reported in [`Event::Modes`].
fn set_mode(&mut self, _mode: &str) {}
/// Its process, for status and tests.
fn pid(&self) -> Option<u32> {
None
}
/// End it and everything it started. `wait`: block until done (quitting).
fn shutdown(&mut self, wait: bool);
}
/// One HTTP request of the API backend (the desktop host sends it; nothing else in the chat
/// reaches the network).
#[derive(Clone, Debug, PartialEq)]
pub struct HttpRequest {
pub method: &'static str,
pub url: String,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub timeout_s: u64,
}
/// Sends an [`HttpRequest`]: the status and the body, or why it failed.
pub type HttpFn = std::sync::Arc<dyn Fn(&HttpRequest) -> Result<(u16, Vec<u8>), String> + Send + Sync>;
/// The system keychain (the desktop host's), for API keys: never in the preferences.
pub trait Secrets: Send + Sync {
fn get(&self, id: &str) -> Result<Option<String>, String>;
fn set(&self, id: &str, secret: &str) -> Result<(), String>;
fn delete(&self, id: &str) -> Result<(), String>;
}
/// Standard base64 with padding (RFC 4648), for pictures in messages.
pub fn base64(data: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
for chunk in data.chunks(3) {
let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)];
let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
for (i, shift) in [18u32, 12, 6, 0].into_iter().enumerate() {
if i <= chunk.len() {
out.push(char::from(T.get(((n >> shift) & 63) as usize).copied().unwrap_or(b'A')));
} else {
out.push('=');
}
}
}
out
}
/// A message's parts as ACP content blocks.
pub fn content_blocks(parts: &[PromptPart]) -> Vec<Value> {
parts
.iter()
.map(|p| match p {
PromptPart::Text(t) => serde_json::json!({"type": "text", "text": t}),
PromptPart::Image { mime, base64 } => serde_json::json!({"type": "image", "mimeType": mime, "data": base64}),
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_matches_the_rfc_examples() {
for (i, o) in [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")]
{
assert_eq!(base64(i.as_bytes()), o);
}
}
#[test]
fn pictures_go_as_image_blocks() {
let b = content_blocks(&[PromptPart::Text("hi".into()), PromptPart::Image { mime: "image/png".into(), base64: "AA==".into() }]);
assert_eq!(b[1]["type"], "image");
assert_eq!(b[1]["mimeType"], "image/png");
}
}
+316
View File
@@ -0,0 +1,316 @@
//! Which ACP agents are installed here (desktop only). A desktop app often starts without the
//! shell's PATH, so the usual install folders are searched too. Detection never starts a login
//! or a session: each agent found is asked for `--version` (with a time limit), and whether it
//! looks logged in comes only from whether its own credentials file exists.
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
/// An agent SolidCraft knows how to start in ACP mode.
pub struct Known {
pub id: &'static str,
pub name: &'static str,
/// Program names to look for, in order of preference.
pub programs: &'static [&'static str],
/// Arguments that put it in ACP mode.
pub args: &'static [&'static str],
/// Its one-line install hint.
pub install: &'static str,
/// Its own credentials file, relative to the home folder (`None`: can't tell), and the
/// variable that moves its settings folder (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`) with the file's
/// name inside it.
pub credentials: Option<&'static str>,
pub config_dir_var: Option<(&'static str, &'static str)>,
/// Variables holding a key it uses instead of a login.
pub key_vars: &'static [&'static str],
}
/// The agents shown in the picker. The first one found is the default.
pub const KNOWN: &[Known] = &[
Known {
id: "claude",
name: "Claude Code",
programs: &["claude-agent-acp", "claude-code-acp"],
args: &[],
install: "npm install -g @agentclientprotocol/claude-agent-acp",
credentials: Some(".claude/.credentials.json"),
config_dir_var: Some(("CLAUDE_CONFIG_DIR", ".credentials.json")),
key_vars: &["ANTHROPIC_API_KEY"],
},
Known {
id: "codex",
name: "Codex",
programs: &["codex-acp"],
args: &[],
install: "npm install -g @agentclientprotocol/codex-acp",
credentials: Some(".codex/auth.json"),
config_dir_var: Some(("CODEX_HOME", "auth.json")),
key_vars: &["OPENAI_API_KEY", "CODEX_API_KEY"],
},
Known {
id: "gemini",
name: "Gemini CLI",
programs: &["gemini"],
args: &["--acp"],
install: "npm install -g @google/gemini-cli",
credentials: Some(".gemini/oauth_creds.json"),
config_dir_var: None,
key_vars: &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
},
Known {
id: "opencode",
name: "OpenCode",
programs: &["opencode"],
args: &["acp"],
install: "npm install -g opencode-ai",
credentials: Some(".local/share/opencode/auth.json"),
config_dir_var: None,
key_vars: &[],
},
Known {
id: "copilot",
name: "GitHub Copilot CLI",
programs: &["copilot"],
args: &["--acp"],
install: "npm install -g @github/copilot",
credentials: None,
config_dir_var: None,
key_vars: &["GH_TOKEN", "GITHUB_TOKEN"],
},
Known {
id: "qwen",
name: "Qwen Code",
programs: &["qwen"],
args: &["--acp"],
install: "npm install -g @qwen-code/qwen-code",
credentials: Some(".qwen/oauth_creds.json"),
config_dir_var: None,
key_vars: &["DASHSCOPE_API_KEY"],
},
Known {
id: "goose",
name: "goose",
programs: &["goose"],
args: &["acp"],
install: "see block.github.io/goose (installer script or Homebrew)",
credentials: None,
config_dir_var: None,
key_vars: &[],
},
];
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Login {
/// Its credentials file is there.
LooksLoggedIn,
/// No login file, but one of its key variables is set.
KeyInEnvironment,
/// Its credentials file is missing (it may still use a key from the environment).
NotLoggedIn,
/// It keeps no file we can look at (system keychain, or unknown).
Unknown,
}
impl Login {
pub fn id(self) -> &'static str {
match self {
Login::LooksLoggedIn => "signed_in",
Login::KeyInEnvironment => "key",
Login::NotLoggedIn => "signed_out",
Login::Unknown => "unknown",
}
}
}
/// A known agent that isn't installed, with its one-line install hint.
#[derive(Clone, Debug)]
pub struct Missing {
pub id: &'static str,
pub name: &'static str,
pub install: &'static str,
}
#[derive(Clone, Debug)]
pub struct Found {
pub id: &'static str,
pub name: &'static str,
/// The full command: the program's path and its ACP arguments.
pub argv: Vec<String>,
pub version: Option<String>,
pub login: Login,
}
#[derive(Clone, Debug, Default)]
pub struct Detection {
pub found: Vec<Found>,
/// Known agents not found.
pub missing: Vec<Missing>,
pub took_ms: u64,
}
fn home() -> Option<PathBuf> {
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from).filter(|p| p.is_absolute())
}
/// PATH, then the usual places npm, Homebrew, nvm, Volta, bun and pipx put commands.
pub fn search_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = std::env::var_os("PATH").map(|p| std::env::split_paths(&p).collect()).unwrap_or_default();
if let Some(prefix) = std::env::var_os("NPM_CONFIG_PREFIX").map(PathBuf::from) {
dirs.push(if cfg!(windows) { prefix.clone() } else { prefix.join("bin") });
}
if let Some(h) = home() {
for d in [".local/bin", ".npm-global/bin", ".npm-packages/bin", ".volta/bin", ".bun/bin", ".cargo/bin", "bin"] {
dirs.push(h.join(d));
}
// nvm: every installed Node version's bin.
if let Ok(rd) = std::fs::read_dir(h.join(".nvm/versions/node")) {
let mut v: Vec<PathBuf> = rd.flatten().take(64).map(|e| e.path().join("bin")).collect();
v.sort();
dirs.extend(v.into_iter().rev());
}
}
if cfg!(windows) {
if let Some(a) = std::env::var_os("APPDATA") {
dirs.push(PathBuf::from(a).join("npm"));
}
} else {
for d in ["/opt/homebrew/bin", "/usr/local/bin", "/home/linuxbrew/.linuxbrew/bin", "/usr/bin"] {
dirs.push(PathBuf::from(d));
}
}
let mut seen = std::collections::HashSet::new();
dirs.retain(|d| d.is_absolute() && seen.insert(d.clone()));
dirs
}
/// The first `program` in `dirs` (on Windows also with `.exe`/`.cmd`).
pub fn find_in(program: &str, dirs: &[PathBuf]) -> Option<PathBuf> {
let names: Vec<String> =
if cfg!(windows) { ["", ".exe", ".cmd", ".bat"].iter().map(|e| format!("{program}{e}")).collect() } else { vec![program.to_string()] };
dirs.iter().flat_map(|d| names.iter().map(move |n| d.join(n))).find(|p| p.is_file())
}
/// `program --version`'s first line, or `None` after `limit` (the probe is then stopped).
fn version(program: &Path, limit: Duration) -> Option<String> {
let mut cmd = if cfg!(windows) && program.extension().is_some_and(|e| e.eq_ignore_ascii_case("cmd") || e.eq_ignore_ascii_case("bat")) {
let mut c = Command::new("cmd");
c.arg("/C").arg(program);
c
} else {
Command::new(program)
};
let mut child = cmd.arg("--version").stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().ok()?;
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)),
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut out = String::new();
std::io::Read::read_to_string(&mut child.stdout.take()?, &mut out).ok()?;
let line = out.lines().map(str::trim).find(|l| !l.is_empty())?;
Some(version_of(line))
}
/// The version number in a `--version` line (`codex-cli 0.162.1`, `2.1.295 (Claude Code)`,
/// `@scope/pkg 2.2.2`), else the line itself, cut short.
fn version_of(line: &str) -> String {
let v = line.split_whitespace().find(|w| w.trim_start_matches('v').starts_with(|c: char| c.is_ascii_digit())).unwrap_or(line);
v.chars().take(40).collect()
}
/// Whether `k` looks signed in, from files and variables only: nothing is started or asked.
fn login_of(k: &Known, home: Option<&Path>) -> Login {
let file = match k.config_dir_var.and_then(|(var, name)| std::env::var_os(var).filter(|v| !v.is_empty()).map(|d| PathBuf::from(d).join(name))) {
Some(p) => Some(p),
None => k.credentials.zip(home).map(|(rel, h)| h.join(rel)),
};
let key = || k.key_vars.iter().any(|v| std::env::var_os(v).is_some_and(|x| !x.is_empty()));
match file {
Some(f) if f.is_file() => Login::LooksLoggedIn,
_ if key() => Login::KeyInEnvironment,
// Claude Code keeps its login in the keychain on macOS: the file says nothing there.
_ if cfg!(target_os = "macos") && k.id == "claude" => Login::Unknown,
Some(_) => Login::NotLoggedIn,
None => Login::Unknown,
}
}
/// Look for every known agent. Blocking (each probe has a time limit): run it off the UI thread.
pub fn detect() -> Detection {
let t0 = Instant::now();
let dirs = search_dirs();
let home = home();
let mut d = Detection::default();
for k in KNOWN {
let Some(path) = k.programs.iter().find_map(|p| find_in(p, &dirs)) else {
d.missing.push(Missing { id: k.id, name: k.name, install: k.install });
continue;
};
let login = login_of(k, home.as_deref());
let mut argv = vec![path.to_string_lossy().into_owned()];
argv.extend(k.args.iter().map(|a| a.to_string()));
d.found.push(Found { id: k.id, name: k.name, version: version(&path, Duration::from_secs(3)), argv, login });
}
d.took_ms = u64::try_from(t0.elapsed().as_millis()).unwrap_or(u64::MAX);
d
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_program_is_found_in_the_first_folder_that_has_it() {
let root = std::env::temp_dir().join(format!("sc-agent-find-{}", std::process::id()));
let (a, b) = (root.join("a"), root.join("b"));
std::fs::create_dir_all(&a).unwrap();
std::fs::create_dir_all(&b).unwrap();
std::fs::write(b.join("codex-acp"), "").unwrap();
assert_eq!(find_in("codex-acp", &[a.clone(), b.clone()]), Some(b.join("codex-acp")));
assert_eq!(find_in("gemini", &[a, b]), None);
let _ = std::fs::remove_dir_all(root);
}
#[test]
fn the_version_number_is_picked_out_of_the_line() {
assert_eq!(version_of("@agentclientprotocol/codex-acp 2.2.2"), "2.2.2");
assert_eq!(version_of("2.1.295 (Claude Code)"), "2.1.295");
assert_eq!(version_of("goose v1.9.0"), "v1.9.0");
assert_eq!(version_of("dev build"), "dev build");
}
#[test]
fn signed_in_comes_from_the_agents_own_file_or_a_key_variable() {
let home = std::env::temp_dir().join(format!("sc-agent-login-{}", std::process::id()));
std::fs::create_dir_all(home.join(".gemini")).unwrap();
let gemini = KNOWN.iter().find(|k| k.id == "gemini").unwrap();
let qwen = KNOWN.iter().find(|k| k.id == "qwen").unwrap();
let goose = KNOWN.iter().find(|k| k.id == "goose").unwrap();
if std::env::var_os("GEMINI_API_KEY").is_none() && std::env::var_os("GOOGLE_API_KEY").is_none() {
assert_eq!(login_of(gemini, Some(&home)), Login::NotLoggedIn);
}
std::fs::write(home.join(".gemini/oauth_creds.json"), "{}").unwrap();
assert_eq!(login_of(gemini, Some(&home)), Login::LooksLoggedIn);
if std::env::var_os("DASHSCOPE_API_KEY").is_none() {
assert_eq!(login_of(qwen, Some(&home)), Login::NotLoggedIn);
}
assert_eq!(login_of(goose, Some(&home)), Login::Unknown, "keeps no file we know");
let _ = std::fs::remove_dir_all(home);
}
#[test]
fn every_known_agent_has_a_program_and_an_install_hint() {
for k in KNOWN {
assert!(!k.programs.is_empty() && !k.install.is_empty(), "{}", k.id);
}
assert_eq!(KNOWN.first().map(|k| k.id), Some("claude"), "the Claude Code adapter is the default");
}
}
+219
View File
@@ -0,0 +1,219 @@
//! What the chat's agent may do to the app. Every request its tools send (whatever the backend)
//! passes here before it runs: a short list of methods (run commands, read the design, move the
//! camera, render), with commands sorted into those that run at once, those that wait for the
//! user's Allow or Deny in the panel (anything that reads or writes files or replaces the design),
//! and those that never run for the agent (crash reports, which hold other designs and paths).
//! Plain data and rules, tested without a window.
use serde_json::Value;
/// Changes the agent may make in one turn (requests that run commands; reads don't count). The
/// user can continue with another message.
pub const DEFAULT_TURN_LIMIT: u32 = 24;
/// Requests of any kind in one turn, so an agent stuck reading the design in a loop stops too.
pub const MAX_REQUESTS_PER_TURN: u32 = 400;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Verdict {
/// Runs now. `change`: it runs a command that may change the design (counted per turn).
Run { change: bool },
/// Waits for Allow or Deny; the text says what it would do.
Ask(String),
/// Never for the agent; the reason goes back to it.
Refuse(String),
}
/// Methods the bridged MCP server uses; nothing else (clicks, preferences, quitting, the chat
/// itself) is the agent's.
const METHODS: &[&str] = &["engine.commands", "document.inspect", "engine.execute", "engine.batch", "ui.view", "ui.render", "ui.screenshot"];
/// Parameter names that hold a file or folder.
const PATH_KEYS: &[&str] = &["path", "paths", "file", "files", "dir", "folder", "out", "output", "filename", "source", "target_path"];
/// Commands that only read (not counted as changes).
fn reads_only(id: &str) -> bool {
id.starts_with("inspect.")
|| id.starts_with("model.")
|| id.ends_with(".inspect")
|| id.ends_with(".list")
|| matches!(id, "edit.checkpoint" | "render.status" | "render.backends" | "render.settings.get" | "file.slicers")
}
fn names_a_file(params: &Value) -> Option<String> {
let o = params.as_object()?;
PATH_KEYS.iter().find_map(|k| match o.get(*k) {
Some(Value::String(s)) if !s.is_empty() => Some(s.chars().take(200).collect()),
Some(Value::Array(a)) if !a.is_empty() => Some(a.iter().filter_map(Value::as_str).take(3).collect::<Vec<_>>().join(", ")),
_ => None,
})
}
/// One command as the agent would run it. `undo_left`: steps this turn may still undo without
/// reaching changes made before it.
pub fn command(id: &str, params: &Value, undo_left: usize) -> Verdict {
let id = id.trim();
if id.starts_with("diagnostics.") || id.contains("crash") {
return Verdict::Refuse(format!("`{id}` is not available in the agent chat: crash reports stay with the user"));
}
if id.starts_with("app.") || id.starts_with("prefs.") {
return Verdict::Refuse(format!("`{id}` is not available in the agent chat"));
}
// Files, and commands that replace or reload the whole design.
let file_command = id.starts_with("file.") || id.starts_with("doc.") || matches!(id, "render.export" | "render.start");
if let Some(p) = names_a_file(params) {
return Verdict::Ask(format!("{id}: {p}"));
}
if file_command && !reads_only(id) {
return Verdict::Ask(id.to_string());
}
// Undo past this turn's first change takes back the user's own work.
if id == "edit.undo" && undo_left == 0 {
return Verdict::Ask(format!("{id} (a step from before this message)"));
}
Verdict::Run { change: !reads_only(id) }
}
/// One request from the agent's tools: `method` and its `params` as the control channel has them.
pub fn request(method: &str, params: &Value, undo_left: usize) -> Verdict {
if !METHODS.contains(&method) {
return Verdict::Refuse(format!("`{method}` is not available in the agent chat"));
}
match method {
"engine.execute" => {
let id = params.get("command").and_then(Value::as_str).unwrap_or("");
command(id, params.get("params").unwrap_or(&Value::Null), undo_left)
}
"engine.batch" => {
let Some(steps) = params.get("commands").and_then(Value::as_array) else { return Verdict::Run { change: false } };
let mut asks = Vec::new();
let mut change = false;
// A batch can undo as many steps as it has: count down as it goes.
let mut undo_left = undo_left;
for s in steps.iter().take(10_000) {
let id = s.get("command").or_else(|| s.get("id")).and_then(Value::as_str).unwrap_or("");
match command(id, s.get("params").unwrap_or(&Value::Null), undo_left) {
Verdict::Refuse(e) => return Verdict::Refuse(e),
Verdict::Ask(a) => asks.push(a),
Verdict::Run { change: c } => {
change |= c;
if id == "edit.undo" {
undo_left = undo_left.saturating_sub(1);
} else if c {
undo_left += 1;
}
}
}
}
if asks.is_empty() {
Verdict::Run { change }
} else {
Verdict::Ask(format!("batch: {}", asks.into_iter().take(6).collect::<Vec<_>>().join("; ")))
}
}
// A picture saved to a file names it.
"ui.screenshot" | "ui.render" => match names_a_file(params) {
Some(_) if is_scratch(params) => Verdict::Run { change: false },
Some(p) => Verdict::Ask(format!("{method}: {p}")),
None => Verdict::Run { change: false },
},
_ => Verdict::Run { change: false },
}
}
/// What "Always allow" remembers for a request: the command it runs (`batch` for a batch).
pub fn tool_key(method: &str, params: &Value) -> String {
match method {
"engine.execute" => params.get("command").and_then(Value::as_str).unwrap_or("").chars().take(120).collect(),
"engine.batch" => "batch".into(),
m => m.into(),
}
}
/// A request as an approval card says it: the command, or a batch's commands.
pub fn describe(method: &str, params: &Value) -> String {
match method {
"engine.batch" => {
let steps = params.get("commands").and_then(Value::as_array).map_or(&[][..], Vec::as_slice);
let ids: Vec<&str> = steps.iter().filter_map(|s| s.get("command").or_else(|| s.get("id")).and_then(Value::as_str)).take(6).collect();
let more = if steps.len() > ids.len() { format!(" (+{})", steps.len() - ids.len()) } else { String::new() };
format!("batch: {}{more}", ids.join(", "))
}
_ => tool_key(method, params),
}
}
/// The MCP server saves the pictures it hands back in its own scratch folder, inside its
/// `allow_paths`; those need no question.
fn is_scratch(params: &Value) -> bool {
let Some(path) = params.get("path").and_then(Value::as_str) else { return false };
let path = std::path::Path::new(path);
if path.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return false;
}
let ok = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("png"));
ok && params.get("allow_paths").and_then(Value::as_array).is_some_and(|roots| {
roots
.iter()
.filter_map(Value::as_str)
.map(std::path::Path::new)
.any(|r| r.file_name().and_then(|n| n.to_str()).is_some_and(|n| n.starts_with("solidcraft-mcp-")) && path.starts_with(r))
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn exec(id: &str, params: Value) -> Verdict {
request("engine.execute", &json!({"command": id, "params": params}), 5)
}
#[test]
fn modelling_runs_and_counts_reads_dont() {
assert_eq!(exec("solid.box", json!({"x": 10})), Verdict::Run { change: true });
assert_eq!(exec("sketch.inspect", json!({"sketch": 1})), Verdict::Run { change: false });
assert_eq!(exec("inspect.measure", json!({})), Verdict::Run { change: false });
assert_eq!(request("document.inspect", &json!({}), 0), Verdict::Run { change: false });
}
#[test]
fn files_and_replacing_the_design_ask_first() {
assert!(matches!(exec("file.save", json!({"path": "/tmp/a.solidcraft"})), Verdict::Ask(a) if a.contains("/tmp/a.solidcraft")));
assert!(matches!(exec("file.new", json!({})), Verdict::Ask(_)));
assert!(matches!(exec("doc.open", json!({"path": "x.step"})), Verdict::Ask(_)));
assert!(matches!(exec("file.export", json!({"path": "x.stl"})), Verdict::Ask(_)));
// Any command given a file, whatever its area.
assert!(matches!(exec("sketch.import", json!({"file": "a.dxf"})), Verdict::Ask(_)));
let batch = json!({"commands": [{"command": "solid.box", "params": {}}, {"command": "file.save", "params": {"path": "/etc/x"}}]});
assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(a) if a.contains("/etc/x")));
}
#[test]
fn crash_reports_and_app_methods_are_never_the_agents() {
assert!(matches!(exec("diagnostics.crash_reports", json!({})), Verdict::Refuse(_)));
assert!(matches!(exec("diagnostics.crash_report", json!({"id": "x"})), Verdict::Refuse(_)));
let batch = json!({"commands": [{"command": "solid.box"}, {"command": "diagnostics.crash_reports"}]});
assert!(matches!(request("engine.batch", &batch, 0), Verdict::Refuse(_)));
for m in ["app.quit", "ui.click", "ui.prefs", "ui.agent_chat", "engine.script", "ui.key", "ui.file"] {
assert!(matches!(request(m, &json!({}), 0), Verdict::Refuse(_)), "{m}");
}
}
#[test]
fn undo_asks_only_past_the_turns_own_changes() {
assert_eq!(request("engine.execute", &json!({"command": "edit.undo"}), 1), Verdict::Run { change: true });
assert!(matches!(request("engine.execute", &json!({"command": "edit.undo"}), 0), Verdict::Ask(_)));
let batch = json!({"commands": [{"command": "solid.box"}, {"command": "edit.undo"}, {"command": "edit.undo"}]});
assert!(matches!(request("engine.batch", &batch, 0), Verdict::Ask(_)));
}
#[test]
fn the_mcp_servers_own_pictures_need_no_question() {
let scratch = json!({"path": "/tmp/solidcraft-mcp-41-a3/shot-1.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3"]});
assert_eq!(request("ui.screenshot", &scratch, 0), Verdict::Run { change: false });
let elsewhere = json!({"path": "/home/u/Desktop/shot.png", "allow_paths": ["/tmp/solidcraft-mcp-41-a3", "/home/u/Desktop"]});
assert!(matches!(request("ui.screenshot", &elsewhere, 0), Verdict::Ask(_)));
assert_eq!(request("ui.render", &json!({"width": 400}), 0), Verdict::Run { change: false });
}
}
+997
View File
@@ -0,0 +1,997 @@
//! 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;
+761
View File
@@ -0,0 +1,761 @@
//! The agent chat panel: docked left or right (switched by dragging its header to the other side,
//! or in Preferences), resizable, folded to a thin strip with Minimise, closed with ×. Its side,
//! width and fold are preferences, so they come back next time.
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
use super::*;
use crate::theme::Tokens;
pub const MIN_WIDTH: f32 = 280.0;
pub const MAX_WIDTH: f32 = 760.0;
/// The folded panel's width.
const STRIP: f32 = 30.0;
/// The keyboard shortcut that shows and hides the panel (also from its own text field).
pub const SHORTCUT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::A);
fn panel_id(left: bool) -> Id {
Id::new(if left { "sc_agent_chat_left" } else { "sc_agent_chat_right" })
}
/// Forget the panel's own idea of its width, so the preference applies on the next frame.
pub fn forget_width(ctx: &egui::Context) {
ctx.data_mut(|d| {
for left in [true, false] {
d.remove::<egui::containers::panel::PanelState>(panel_id(left));
}
});
}
/// Show or hide the panel (a folded panel unfolds first).
pub fn toggle(app: &mut SolveApp) {
let c = &mut app.agent_chat;
if !c.open {
c.open = true;
app.preferences.agent_chat_minimized = false;
} else if app.preferences.agent_chat_minimized {
app.preferences.agent_chat_minimized = false;
} else {
c.open = false;
}
}
/// The tooltip of the chat's buttons: its name and shortcut.
pub fn tip() -> String {
let key = if cfg!(target_os = "macos") { "Cmd+Shift+A" } else { "Ctrl+Shift+A" };
format!("{} ({key})", tr("ui.agent_chat"))
}
/// The panel, when the chat is turned on and open. Call before the central viewport is laid out.
pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
poll(app, &ctx);
// With AI features off nothing of the chat is drawn, and the shortcut does nothing.
if !shown(app) {
return;
}
super::settings::dialog(app, &ctx);
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
toggle(app);
}
if !app.agent_chat.open {
return;
}
// Look for installed agents the first time the panel opens (in the background).
#[cfg(not(target_arch = "wasm32"))]
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ctx);
}
let t = Tokens::get();
let left = app.preferences.agent_chat_side == "left";
let side = |id: Id| if left { egui::Panel::left(id) } else { egui::Panel::right(id) };
if app.preferences.agent_chat_minimized {
side(Id::new("sc_agent_chat_strip")).exact_size(STRIP).resizable(false).frame(egui::Frame::NONE.fill(t.panel)).show(ui, |ui| strip(app, ui));
return;
}
let width = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH);
let r = side(panel_id(left))
.default_size(width)
.size_range(MIN_WIDTH..=MAX_WIDTH)
.resizable(true)
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 6)))
.show(ui, |ui| body(app, ui, left));
crate::scenario::publish_rect("agent_chat:panel", r.response.rect);
let w = r.response.rect.width();
if w.is_finite() && (MIN_WIDTH..=MAX_WIDTH).contains(&w) {
app.preferences.agent_chat_width = w;
}
}
/// A small drawn button: `kind` is `minimize`, `close` or `expand`.
fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
let t = Tokens::get();
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
let c = r.center();
let s = Stroke::new(1.4, t.text);
match kind {
"minimize" => {
ui.painter().line_segment([c + vec2(-5.0, 4.0), c + vec2(5.0, 4.0)], s);
}
"close" => {
ui.painter().line_segment([c + vec2(-4.5, -4.5), c + vec2(4.5, 4.5)], s);
ui.painter().line_segment([c + vec2(-4.5, 4.5), c + vec2(4.5, -4.5)], s);
}
_ => {}
}
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
resp.on_hover_text(tip)
}
fn state_color(state: State) -> Color32 {
let t = Tokens::get();
match state {
State::Ready | State::Busy => t.accent,
State::NeedsAuth | State::Exited => t.warning,
_ => t.text_dim,
}
}
/// The folded panel: the chat icon (unfolds it), the agent's state, and a mark while something
/// waits for the user.
fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let r = ui.max_rect();
let button = Rect::from_center_size(pos2(r.center().x, r.top() + 22.0), vec2(26.0, 26.0));
let resp = ui.interact(button, Id::new("sc_agent_chat_unfold"), Sense::click());
crate::scenario::publish_handle("agent_chat:unfold", button.center());
if resp.hovered() {
ui.painter().rect_filled(button, 4.0, t.hover);
}
crate::icons::paint(ui.painter(), button.shrink(4.0), "agent_chat", t.icon, t.icon_fill, t.accent);
let state = app.agent_chat.state();
ui.painter().circle_filled(pos2(r.center().x, button.bottom() + 10.0), 3.5, state_color(state));
let waiting = app.agent_chat.waiting();
if waiting > 0 {
let at = button.right_top() + vec2(-3.0, 3.0);
ui.painter().circle_filled(at, 6.5, t.warning);
ui.painter().text(at, Align2::CENTER_CENTER, waiting.min(9).to_string(), egui::FontId::proportional(9.5), t.panel);
}
ui.painter().line_segment(
if app.preferences.agent_chat_side == "left" { [r.right_top(), r.right_bottom()] } else { [r.left_top(), r.left_bottom()] },
Stroke::new(1.0, t.border),
);
let tip = format!("{} · {}", tip(), state.label());
if resp.on_hover_text(tip).clicked() {
app.preferences.agent_chat_minimized = false;
}
}
/// The header: a grip to drag the panel to the other side, the title and state, Connect or Stop,
/// Minimise and Close.
fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
let t = Tokens::get();
let state = app.agent_chat.state();
ui.horizontal(|ui| {
let title = {
let (grip, _) = ui.allocate_exact_size(vec2(10.0, 18.0), Sense::hover());
for row in 0..3 {
for col in 0..2 {
ui.painter().circle_filled(grip.left_top() + vec2(2.5 + col as f32 * 5.0, 4.0 + row as f32 * 5.0), 1.1, t.text_dim);
}
}
let l = ui.label(RichText::new(tr("ui.agent_chat")).strong().size(14.0));
grip.union(l.rect)
};
let mut s = state.label().to_string();
if let Some(turn) = &app.agent_chat.turn
&& state == State::Busy
{
let limit = app.preferences.agent_chat_turn_limit;
s = format!("{s} {}", trf("ui.agent_chat_changes_of", &[("n", &turn.changes.to_string()), ("limit", &limit.to_string())]));
}
ui.label(RichText::new(s).color(state_color(state)).small());
// Drag the header across the window to dock on the other side.
let drag = ui.interact(title, Id::new("sc_agent_chat_grip"), Sense::drag());
crate::scenario::publish_handle("agent_chat:grip", title.center());
dock_drag(app, ui, &drag, left);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if glyph_button(ui, "close", tr("ui.agent_chat_close")).clicked() {
app.agent_chat.open = false;
}
if glyph_button(ui, "minimize", tr("ui.agent_chat_minimize")).clicked() {
app.preferences.agent_chat_minimized = true;
}
#[cfg(not(target_arch = "wasm32"))]
{
let b = if state.running() {
ui.button(tr("ui.agent_chat_stop")).on_hover_text(tr("ui.agent_chat_stop_tip"))
} else {
ui.button(tr("ui.agent_chat_connect"))
};
crate::scenario::publish_handle("agent_chat:connect", b.rect.center());
if b.clicked() {
if state.running() {
disconnect(app);
} else {
connect(app, ui.ctx());
}
}
}
});
});
}
/// While the header is dragged: the cursor grabs and the side it would dock on lights up; on
/// release over the other half of the window the panel moves there.
fn dock_drag(app: &mut SolveApp, ui: &egui::Ui, drag: &egui::Response, left: bool) {
if drag.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
let screen = ui.ctx().content_rect();
let target = |x: f32| x < screen.center().x;
if drag.dragged()
&& let Some(p) = ui.ctx().pointer_interact_pos()
{
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
let to_left = target(p.x);
let w = app.preferences.agent_chat_width.clamp(MIN_WIDTH, MAX_WIDTH);
let top = screen.top() + crate::toolbar::APP_BAR_HEIGHT;
let zone = if to_left {
Rect::from_min_max(pos2(screen.left(), top), pos2(screen.left() + w, screen.bottom()))
} else {
Rect::from_min_max(pos2(screen.right() - w, top), pos2(screen.right(), screen.bottom()))
};
let t = Tokens::get();
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Foreground, Id::new("sc_agent_chat_dock_hint")));
painter.rect(zone.shrink(4.0), 8.0, t.accent.gamma_multiply(0.18), Stroke::new(2.0, t.accent), egui::StrokeKind::Inside);
painter.text(
zone.center(),
Align2::CENTER_CENTER,
tr(if to_left { "ui.agent_chat_dock_left" } else { "ui.agent_chat_dock_right" }),
egui::FontId::proportional(14.0),
t.text,
);
}
if drag.drag_stopped()
&& let Some(p) = ui.ctx().pointer_interact_pos().or_else(|| ui.ctx().pointer_latest_pos())
&& target(p.x) != left
{
app.preferences.agent_chat_side = if left { "right" } else { "left" }.into();
}
}
fn body(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
header(app, ui, left);
#[cfg(not(target_arch = "wasm32"))]
switcher(app, ui);
#[cfg(target_arch = "wasm32")]
{
ui.label(tr("ui.agent_chat_desktop_only"));
}
#[cfg(not(target_arch = "wasm32"))]
desktop_body(app, ui);
}
#[cfg(not(target_arch = "wasm32"))]
fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, ui.ctx());
}
let state = app.agent_chat.state();
picker(app, ui);
if state == State::NeedsAuth {
auth_box(app, ui);
}
ui.separator();
// The input sits at the bottom; the conversation takes the rest.
let input_h = 92.0;
let avail = (ui.available_height() - input_h).max(80.0);
egui::ScrollArea::vertical().id_salt("sc_agent_chat_scroll").max_height(avail).auto_shrink([false, false]).stick_to_bottom(true).show(ui, |ui| {
conversation(app, ui);
});
ui.separator();
let busy = state == State::Busy;
let edit = egui::TextEdit::multiline(&mut app.agent_chat.input)
.hint_text(tr("ui.agent_chat_placeholder"))
.desired_rows(2)
.desired_width(f32::INFINITY)
.id_salt("sc_agent_chat_input");
let r = ui.add(edit);
crate::scenario::publish_rect("agent_chat:input", r.rect);
let enter = r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && !i.modifiers.shift);
ui.horizontal(|ui| {
let send_b = ui.add_enabled(!busy, egui::Button::new(tr("ui.agent_chat_send")));
crate::scenario::publish_handle("agent_chat:send", send_b.rect.center());
if (send_b.clicked() || (enter && !busy)) && !app.agent_chat.input.trim().is_empty() {
let text = std::mem::take(&mut app.agent_chat.input).trim_end_matches('\n').to_string();
send(app, ui.ctx(), text);
}
if busy && ui.button(tr("ui.agent_chat_cancel")).clicked() {
cancel(app);
}
// A picture of the view goes with the message to agents that take pictures.
let images = app.agent_chat.images || !state.running();
let attach = ui
.add_enabled(images, egui::Checkbox::new(&mut app.agent_chat.attach_view, tr("ui.agent_chat_attach_view")))
.on_hover_text(tr("ui.agent_chat_attach_view_tip"))
.on_disabled_hover_text(tr("ui.agent_chat_no_images"));
crate::scenario::publish_handle("agent_chat:attach", attach.rect.center());
});
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn login_chip(ui: &mut egui::Ui, login: &str) {
let t = Tokens::get();
let (text, color) = match login {
"signed_in" => (tr("ui.agent_chat_signed_in"), t.accent),
"key" => (tr("ui.agent_chat_key_in_env"), t.accent),
"signed_out" => (tr("ui.agent_chat_not_signed_in"), t.warning),
_ => return,
};
egui::Frame::NONE.stroke(Stroke::new(1.0, color)).corner_radius(8.0).inner_margin(egui::Margin::symmetric(6, 1)).show(ui, |ui| {
ui.label(RichText::new(text).color(color).size(11.0));
});
}
/// A small mark for an agent's kind: a terminal prompt for a program, a cloud for an endpoint.
pub(super) fn kind_icon(ui: &mut egui::Ui, kind: agents::Kind) {
let t = Tokens::get();
let (r, _) = ui.allocate_exact_size(vec2(16.0, 14.0), Sense::hover());
let p = ui.painter();
let s = Stroke::new(1.3, t.text_dim);
match kind {
agents::Kind::Acp => {
p.rect_stroke(r.shrink(1.0), 2.0, s, egui::StrokeKind::Inside);
p.line_segment([r.left_top() + vec2(4.0, 4.0), r.left_top() + vec2(7.0, 7.0)], s);
p.line_segment([r.left_top() + vec2(7.0, 7.0), r.left_top() + vec2(4.0, 10.0)], s);
p.line_segment([r.left_top() + vec2(8.5, 10.0), r.left_top() + vec2(12.0, 10.0)], s);
}
agents::Kind::Api => {
let c = r.center();
p.circle_stroke(c + vec2(-3.0, 1.5), 3.2, s);
p.circle_stroke(c + vec2(1.0, -1.0), 4.2, s);
p.circle_stroke(c + vec2(4.0, 2.0), 2.8, s);
}
}
}
/// The header's second row: the agent this conversation uses (a compact switcher), the agent's
/// own mode when it has modes, and the state of its login.
#[cfg(not(target_arch = "wasm32"))]
fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let all = agents::entries(app);
let current = agents::current(app);
ui.horizontal(|ui| {
let label = current.as_ref().map_or_else(
|| tr(if app.agent_chat.detecting.is_some() { "ui.agent_chat_detecting" } else { "ui.agent_chat_none_found_short" }).to_string(),
|e| e.name.clone(),
);
let w = (ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 120.0 }).clamp(120.0, 260.0);
let mut picked: Option<String> = None;
let mut add = false;
let combo = egui::ComboBox::from_id_salt("sc_agent_switch").width(w).selected_text(RichText::new(label).strong()).show_ui(ui, |ui| {
for e in &all {
let sel = current.as_ref().is_some_and(|c| c.id == e.id);
let r = ui.horizontal(|ui| {
kind_icon(ui, e.kind);
let r = ui.selectable_label(sel, &e.name);
ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0));
login_chip(ui, e.login);
r
});
crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.inner.rect.center());
if r.inner.clicked() {
picked = Some(e.id.clone());
}
}
ui.separator();
if ui.selectable_label(false, tr("ui.ai_add_agent")).clicked() {
add = true;
}
if ui.selectable_label(false, tr("ui.ai_settings")).clicked() {
crate::prefs::open_at(app, crate::prefs::PAGE_AI);
}
});
crate::scenario::publish_handle("agent_chat:switcher", combo.response.rect.center());
combo.response.on_hover_text(tr("ui.ai_switch_tip"));
if add {
super::settings::open_add(app);
}
if let Some(id) = picked
&& current.as_ref().is_none_or(|c| c.id != id)
{
disconnect(app);
app.agent_chat.agent_id = Some(id);
}
// The agent's own modes (ACP `modes`): its own tools follow them.
if !app.agent_chat.modes.is_empty() {
let modes = app.agent_chat.modes.clone();
let cur = modes.iter().find(|(id, _)| *id == app.agent_chat.mode).map_or_else(|| app.agent_chat.mode.clone(), |(_, n)| n.clone());
let mut pick: Option<String> = None;
let r = egui::ComboBox::from_id_salt("sc_agent_mode").width(100.0).selected_text(cur).show_ui(ui, |ui| {
for (id, name) in &modes {
if ui.selectable_label(*id == app.agent_chat.mode, name).clicked() {
pick = Some(id.clone());
}
}
});
crate::scenario::publish_handle("agent_chat:mode", r.response.rect.center());
r.response.on_hover_text(tr("ui.ai_agent_mode_tip"));
if let Some(m) = pick {
set_mode(app, &m);
}
}
});
if let Some(a) = &app.agent_chat.auth_status {
ui.label(RichText::new(a).color(t.text_dim).size(11.0));
}
}
/// Before the first connection: what's installed, how to install the rest, and Add agent.
#[cfg(not(target_arch = "wasm32"))]
fn picker(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let open = app.agent_chat.picking.unwrap_or(!app.agent_chat.state().running() && app.agent_chat.items.is_empty());
if !open {
return;
}
let all = agents::entries(app);
let current = agents::current(app);
ui.horizontal(|ui| {
ui.label(RichText::new(tr("ui.agent_chat_pick")).strong());
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let refreshing = app.agent_chat.detecting.is_some();
if ui.add_enabled(!refreshing, egui::Button::new(tr("ui.agent_chat_refresh")).small()).clicked() {
refresh(app, ui.ctx());
}
if ui.small_button(tr("ui.ai_add_agent")).clicked() {
super::settings::open_add(app);
}
});
});
if app.agent_chat.detecting.is_some() {
ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim));
}
for e in &all {
let selected = current.as_ref().is_some_and(|c| c.id == e.id);
let fill = if selected { t.accent_soft } else { Color32::TRANSPARENT };
let r = egui::Frame::NONE.fill(fill).corner_radius(5.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
kind_icon(ui, e.kind);
ui.label(RichText::new(&e.name).strong().color(t.text));
ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| login_chip(ui, e.login));
});
});
let resp = ui.interact(r.response.rect, Id::new(("sc_agent_pick", &e.id)), Sense::click());
crate::scenario::publish_handle(&format!("agent_chat:pick:{}", e.id), resp.rect.center());
if resp.clicked() {
app.agent_chat.agent_id = Some(e.id.clone());
}
}
let missing: Vec<discover::Missing> = app.agent_chat.detection.as_ref().map(|d| d.missing.clone()).unwrap_or_default();
if !missing.is_empty() {
let heading = if all.is_empty() { tr("ui.agent_chat_none_found") } else { tr("ui.agent_chat_not_installed") };
egui::CollapsingHeader::new(RichText::new(heading).color(t.text_dim)).id_salt("sc_agent_missing").default_open(all.is_empty()).show(
ui,
|ui| {
for m in missing {
ui.horizontal(|ui| {
ui.label(RichText::new(m.name).strong());
if ui.small_button(tr("ui.agent_chat_copy")).on_hover_text(m.install).clicked() {
ui.ctx().copy_text(m.install.to_string());
}
});
ui.add(egui::Label::new(RichText::new(m.install).monospace().size(11.0).color(t.text_dim)).selectable(true).wrap());
}
},
);
}
}
#[cfg(not(target_arch = "wasm32"))]
fn auth_box(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.warning)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(tr("ui.agent_chat_auth_title")).strong());
ui.label(RichText::new(tr("ui.agent_chat_auth_needed")).color(t.text_dim));
let methods = app.agent_chat.auth.clone();
let base = app.agent_chat.argv.iter().map(|a| quote(a)).collect::<Vec<_>>().join(" ");
if methods.is_empty() {
ui.label(tr("ui.agent_chat_auth_none"));
}
for m in methods {
ui.add_space(4.0);
let desc = m.description.trim();
ui.label(RichText::new(if desc.is_empty() || desc == m.name { m.name.clone() } else { format!("{} — {desc}", m.name) }).strong());
match &m.kind {
acp::AuthKind::Terminal { args, .. } => {
ui.label(tr("ui.agent_chat_auth_terminal"));
let cmd = format!("{base} {}", args.iter().map(|a| quote(a)).collect::<Vec<_>>().join(" "));
ui.horizontal_wrapped(|ui| {
egui::Frame::NONE.fill(t.toolbar).corner_radius(4.0).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(&cmd).monospace().size(11.0)).selectable(true).wrap());
});
if ui.small_button(tr("ui.agent_chat_copy")).clicked() {
ui.ctx().copy_text(cmd.clone());
}
});
}
acp::AuthKind::Agent => {
let b = ui.button(trf("ui.agent_chat_auth_agent", &[("name", &m.name)]));
crate::scenario::publish_handle(&format!("agent_chat:auth:{}", m.id), b.rect.center());
if b.clicked() {
app.agent_chat.authenticate(&m.id);
}
}
acp::AuthKind::EnvVar { vars } => {
ui.label(trf("ui.agent_chat_auth_env", &[("vars", &vars.join(", "))]));
}
}
}
ui.add_space(4.0);
if ui.button(tr("ui.agent_chat_auth_retry")).clicked() {
connect(app, ui.ctx());
}
});
}
#[cfg(not(target_arch = "wasm32"))]
fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let mut answer: Option<(usize, Option<String>)> = None;
// Allow (true), Deny (false), Always allow (None).
let mut approval: Option<(u64, Option<bool>)> = None;
let mut undo: Option<usize> = None;
let current_doc = app.session.doc.clone();
for (n, item) in app.agent_chat.items.iter().enumerate() {
match item {
Item::User { text, image } => {
ui.add_space(6.0);
egui::Frame::NONE.fill(t.accent_soft).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(text).color(t.text)).selectable(true).wrap());
if *image {
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0));
}
});
}
Item::Agent { thought, text } => {
let rt = if *thought { RichText::new(text).italics().color(t.text_dim) } else { RichText::new(text).color(t.text) };
ui.add(egui::Label::new(rt).selectable(true).wrap());
}
Item::Tool { title, kind, status, output, .. } => {
tool_card(ui, n, title, kind, *status, output.as_deref());
}
Item::Plan(entries) => {
for (c, s) in entries {
let (color, filled) = match s.as_str() {
"completed" => (t.accent, true),
"in_progress" => (t.warning, true),
_ => (t.text_dim, false),
};
ui.horizontal(|ui| {
status_dot(ui, color, filled);
ui.label(RichText::new(c).color(t.text_dim));
});
}
}
Item::Permission { title, options, answer: given, .. } => {
egui::Frame::group(ui.style()).stroke(Stroke::new(1.0, t.accent)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(trf("ui.agent_chat_permission", &[("title", title)])).strong());
match given {
Some(a) => {
ui.label(RichText::new(trf("ui.agent_chat_answered", &[("option", a)])).color(t.text_dim));
}
None => {
ui.horizontal_wrapped(|ui| {
for o in options {
let b = egui::Button::new(&o.name);
let b = if o.kind.starts_with("allow") { b.fill(t.accent_soft) } else { b };
let r = ui.add(b);
crate::scenario::publish_handle(&format!("agent_chat:permission:{}", o.id), r.rect.center());
if r.clicked() {
answer = Some((n, Some(o.id.clone())));
}
}
});
}
}
});
}
Item::Approval { id, title, tool, answer: given } => {
egui::Frame::group(ui.style()).stroke(Stroke::new(1.5, t.warning)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.label(RichText::new(tr("ui.agent_chat_approval")).strong());
ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5)).selectable(true).wrap());
match given {
Some(true) => {
ui.label(RichText::new(tr("ui.agent_chat_allowed")).color(t.text_dim));
}
Some(false) => {
ui.label(RichText::new(tr("ui.agent_chat_denied")).color(t.text_dim));
}
None => {
ui.horizontal(|ui| {
let allow = ui.add(egui::Button::new(tr("ui.agent_chat_allow")).fill(t.accent_soft));
let always = ui.button(tr("ui.ai_always_allow")).on_hover_text(trf("ui.ai_always_allow_tip", &[("tool", tool)]));
let deny = ui.button(tr("ui.agent_chat_deny"));
crate::scenario::publish_handle("agent_chat:allow", allow.rect.center());
crate::scenario::publish_handle("agent_chat:always", always.rect.center());
crate::scenario::publish_handle("agent_chat:deny", deny.rect.center());
if allow.clicked() {
approval = Some((*id, Some(true)));
}
if always.clicked() {
approval = Some((*id, None));
}
if deny.clicked() {
approval = Some((*id, Some(false)));
}
});
}
}
});
}
Item::Changes { steps, doc, undone } => {
ui.horizontal(|ui| {
let text = trf("ui.agent_chat_changes", &[("n", &steps.to_string())]);
ui.label(RichText::new(text).color(t.text_dim).size(11.5));
if *undone {
ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(11.5));
} else if Arc::ptr_eq(doc, &current_doc) {
let b = ui.button(tr("ui.agent_chat_undo_changes")).on_hover_text(tr("ui.ai_undo_tip"));
crate::scenario::publish_handle("agent_chat:undo", b.rect.center());
if b.clicked() {
undo = Some(n);
}
}
});
}
Item::Notice { text, error } => {
ui.label(RichText::new(text).color(if *error { t.error } else { t.text_dim }));
}
}
ui.add_space(2.0);
}
if app.agent_chat.items.is_empty() {
ui.add_space(12.0);
ui.label(RichText::new(tr("ui.agent_chat_intro")).color(t.text_dim));
ui.add_space(6.0);
ui.label(RichText::new(tr("ui.agent_chat_privacy")).color(t.text_dim).size(11.0));
}
if let Some((n, o)) = answer {
answer_permission(app, n, o.as_deref());
}
match approval {
Some((id, Some(allow))) => approve(app, id, allow),
Some((id, None)) => approve_always(app, id),
None => {}
}
if let Some(n) = undo {
undo_changes(app, n);
}
}
/// A tool call as a card: its state, name and kind on one line; a click unfolds what it returned.
#[cfg(not(target_arch = "wasm32"))]
fn tool_card(ui: &mut egui::Ui, n: usize, title: &str, kind: &str, status: Option<ToolStatus>, output: Option<&str>) {
let t = Tokens::get();
let (color, filled, state) = match status {
Some(ToolStatus::Completed) => (t.accent, true, "ui.ai_tool_done"),
Some(ToolStatus::Failed) => (t.error, true, "ui.ai_tool_failed"),
Some(ToolStatus::InProgress) => (t.warning, true, "ui.ai_tool_running"),
_ => (t.text_dim, false, "ui.ai_tool_waiting"),
};
egui::Frame::NONE.fill(t.toolbar).stroke(Stroke::new(1.0, t.border)).corner_radius(6.0).inner_margin(egui::Margin::symmetric(6, 3)).show(
ui,
|ui| {
ui.set_width(ui.available_width());
let id = ui.make_persistent_id(("sc_agent_tool", n));
let mut state_ = egui::collapsing_header::CollapsingState::load_with_default_open(ui.ctx(), id, false);
let head = ui.horizontal(|ui| {
status_dot(ui, color, filled);
// The state and kind keep their room at the right; the title is cut short before them.
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(RichText::new(tr(state)).size(10.5).color(color));
if !kind.is_empty() && kind != "other" {
ui.label(RichText::new(kind).size(10.5).color(t.text_dim));
}
ui.with_layout(Layout::left_to_right(Align::Center), |ui| {
ui.add(egui::Label::new(RichText::new(title).monospace().size(11.5).color(t.text)).truncate()).on_hover_text(title);
});
});
});
let click = ui.interact(head.response.rect, id.with("head"), Sense::click());
crate::scenario::publish_handle(&format!("agent_chat:tool:{n}"), head.response.rect.center());
if output.is_some() && click.clicked() {
state_.toggle(ui);
}
if output.is_some() {
click.on_hover_text(tr("ui.ai_tool_unfold"));
}
state_.show_body_unindented(ui, |ui| {
if let Some(o) = output {
ui.separator();
egui::ScrollArea::vertical().id_salt(("sc_agent_tool_out", n)).max_height(180.0).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(o).monospace().size(11.0)).selectable(true).wrap());
});
}
});
},
);
}
/// A small status mark drawn as a shape (no glyph a system font might lack): filled when the
/// step is running or done, hollow while it waits.
#[cfg(not(target_arch = "wasm32"))]
fn status_dot(ui: &mut egui::Ui, color: Color32, filled: bool) {
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
if filled {
ui.painter().circle_filled(r.center(), 4.0, color);
} else {
ui.painter().circle_stroke(r.center(), 3.5, Stroke::new(1.2, color));
}
}
/// A custom command as the picker shows it: the program's file name and its arguments, cut short.
pub(super) fn short_command(line: &str) -> String {
let argv = acp::split_command(line);
let mut parts: Vec<String> = argv.iter().map(|a| a.rsplit(['/', '\\']).next().unwrap_or(a).to_string()).collect();
if parts.is_empty() {
parts.push(line.to_string());
}
let s = parts.join(" ");
if s.chars().count() > 40 { format!("{}…", s.chars().take(40).collect::<String>()) } else { s }
}
/// Quote an argument for the command field when it holds spaces.
pub(super) fn quote(a: &str) -> String {
if a.is_empty() || a.contains(char::is_whitespace) || a.contains('"') { format!("\"{}\"", a.replace('"', "\\\"")) } else { a.to_string() }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_commands_show_short_in_the_picker() {
assert_eq!(short_command("python3 -I /very/long/path/to/mock_agent.py"), "python3 -I mock_agent.py");
assert!(short_command(&"x".repeat(100)).ends_with('…'));
}
#[test]
fn the_shortcut_shows_folds_and_hides() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
toggle(&mut app);
assert!(app.agent_chat.open);
app.preferences.agent_chat_minimized = true;
toggle(&mut app);
assert!(app.agent_chat.open && !app.preferences.agent_chat_minimized, "a folded panel unfolds");
toggle(&mut app);
assert!(!app.agent_chat.open);
}
}
+318
View File
@@ -0,0 +1,318 @@
//! The agent as a child process (desktop only): its stdout is read on a thread and handed to the
//! panel line by line, its stdin written on another so a slow agent never blocks a frame.
//!
//! Agents start helpers of their own (Node adapters start the CLI they wrap, which starts the MCP
//! servers it was given), so stopping only the program we started would leave those running. The
//! agent therefore gets a process group of its own on Unix (on Windows, `taskkill /T` walks its
//! tree), and [`AgentProcess::shutdown`] ends the whole group: stdin is closed first (an ACP agent
//! ends at end of input), then the group is asked to stop, then stopped. Everything is addressed
//! by the ids of the processes we started, never by name.
use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::{Duration, Instant};
/// Longest line read from the agent; a longer one is dropped.
const MAX_LINE: usize = 8 << 20;
/// Lines of the agent's stderr kept to explain a failed start.
const STDERR_LINES: usize = 40;
/// How long an agent gets to end by itself once its input is closed, and then once asked to stop.
const GRACE: Duration = Duration::from_millis(1200);
pub enum FromAgent {
Line(String),
/// stdout closed: the agent ended. Its last stderr lines, for the panel.
Exited(Vec<String>),
}
pub struct AgentProcess {
child: ProcessHandle,
to_agent: Option<Sender<String>>,
pub from_agent: Receiver<FromAgent>,
}
/// Ends the process from another thread than the one that talks to it.
#[derive(Clone)]
pub struct ProcessHandle(std::sync::Arc<std::sync::Mutex<Option<Child>>>);
impl ProcessHandle {
pub fn pid(&self) -> Option<u32> {
self.0.lock().ok().and_then(|c| c.as_ref().map(Child::id))
}
/// End it and everything it started; `wait`: block until done.
pub fn shutdown(&self, wait: bool) {
let Some(child) = self.0.lock().ok().and_then(|mut c| c.take()) else { return };
if wait {
end_tree(child);
} else {
std::thread::spawn(move || end_tree(child));
}
}
}
/// On Windows, npm installs `.cmd` shims, which only `cmd.exe` runs.
fn command_for(program: &str) -> Command {
let lower = program.to_ascii_lowercase();
if cfg!(windows) && (lower.ends_with(".cmd") || lower.ends_with(".bat")) {
let mut c = Command::new("cmd");
c.arg("/C").arg(program);
c
} else {
Command::new(program)
}
}
/// Start the agent in a process group of its own (Unix) or without a console window (Windows).
fn detach_group(cmd: &mut Command) {
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
cmd.process_group(0);
}
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: no console flashes up for a console agent.
cmd.creation_flags(0x0800_0000);
}
}
/// Signal the agent's whole process group (`signal`: `TERM` or `KILL`). The group's id is the
/// agent's own process id, as it was started with `process_group(0)`.
#[cfg(unix)]
fn signal_group(pgid: u32, signal: &str) {
if pgid <= 1 {
return;
}
let mut c = Command::new("kill");
c.args(["-s", signal]);
// procps' kill reads `-123` as an option without `--`; BSD kill takes it as is.
if cfg!(target_os = "linux") {
c.arg("--");
}
let _ = c.arg(format!("-{pgid}")).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status();
}
/// Windows: end the agent and every process it started.
#[cfg(windows)]
fn kill_tree(pid: u32) {
use std::os::windows::process::CommandExt;
let _ = Command::new("taskkill")
.args(["/T", "/F", "/PID", &pid.to_string()])
.creation_flags(0x0800_0000)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
/// Wait up to `limit` for the child to end; whether it did.
fn wait_for(child: &mut Child, limit: Duration) -> bool {
let start = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return true,
Ok(None) if start.elapsed() < limit => std::thread::sleep(Duration::from_millis(20)),
Ok(None) => return false,
Err(_) => return true,
}
}
}
/// End the agent and everything it started (see the module note). Blocks for at most about
/// three seconds.
fn end_tree(mut child: Child) {
let pid = child.id();
// Its input is already closed: a well-behaved agent ends now and stops its own helpers.
let ended = wait_for(&mut child, GRACE);
#[cfg(unix)]
{
// Helpers can outlive the agent: the group is signalled whether or not it ended. The
// group id stays reserved while any member lives, so it can't name someone else's.
signal_group(pid, "TERM");
if !ended {
wait_for(&mut child, GRACE);
}
signal_group(pid, "KILL");
}
#[cfg(windows)]
{
if !ended {
kill_tree(pid);
}
}
#[cfg(not(any(unix, windows)))]
let _ = (pid, ended);
let _ = child.kill();
let _ = child.wait();
}
impl AgentProcess {
/// Start `argv` (program first) in `cwd`; `wake` is called whenever a line arrives.
/// `env_remove`: variables the agent must not inherit.
pub fn spawn(
argv: &[String],
cwd: &std::path::Path,
env_remove: &[&str],
wake: impl Fn() + Send + Sync + 'static,
) -> Result<AgentProcess, String> {
let Some((program, args)) = argv.split_first() else { return Err("no agent command".into()) };
let mut cmd = command_for(program);
cmd.args(args).current_dir(cwd).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for v in env_remove {
cmd.env_remove(v);
}
detach_group(&mut cmd);
let mut child = cmd.spawn().map_err(|e| format!("{program}: {e}"))?;
let (Some(mut stdin), Some(stdout), Some(stderr)) = (child.stdin.take(), child.stdout.take(), child.stderr.take()) else {
end_tree(child);
return Err("the agent's pipes could not be opened".into());
};
let (to_agent, rx) = channel::<String>();
std::thread::spawn(move || {
for line in rx {
if stdin.write_all(line.as_bytes()).and_then(|()| stdin.write_all(b"\n")).and_then(|()| stdin.flush()).is_err() {
break;
}
}
// The sender is gone: stdin closes here, which is how an ACP agent learns to end.
});
let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::<String>::new()));
let tail_w = tail.clone();
std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
if let Ok(mut t) = tail_w.lock() {
if t.len() >= STDERR_LINES {
t.pop_front();
}
t.push_back(line.chars().take(400).collect());
}
}
});
let (tx, from_agent) = channel::<FromAgent>();
let wake = std::sync::Arc::new(wake);
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut buf = Vec::new();
match (&mut reader).take(MAX_LINE as u64 + 1).read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => break,
Ok(_) if buf.len() > MAX_LINE => {
// Skip the rest of an oversized line.
let mut rest = Vec::new();
if reader.read_until(b'\n', &mut rest).is_err() {
break;
}
}
Ok(_) => {
if tx.send(FromAgent::Line(String::from_utf8_lossy(&buf).into_owned())).is_err() {
return;
}
wake();
}
}
}
// Give stderr a moment to deliver the agent's last words.
std::thread::sleep(Duration::from_millis(100));
let lines = tail.lock().map(|t| t.iter().cloned().collect()).unwrap_or_default();
let _ = tx.send(FromAgent::Exited(lines));
wake();
});
Ok(AgentProcess { child: ProcessHandle(std::sync::Arc::new(std::sync::Mutex::new(Some(child)))), to_agent: Some(to_agent), from_agent })
}
pub fn send(&self, msg: &serde_json::Value) {
if let Some(t) = &self.to_agent {
let _ = t.send(msg.to_string());
}
}
pub fn pid(&self) -> Option<u32> {
self.child.pid()
}
pub fn handle(&self) -> ProcessHandle {
self.child.clone()
}
/// End the agent and its helpers. `wait`: block until they are gone (quitting the app);
/// otherwise the ending runs on a thread of its own and this returns at once.
pub fn shutdown(&mut self, wait: bool) {
self.to_agent = None;
self.child.shutdown(wait);
}
}
impl Drop for AgentProcess {
fn drop(&mut self) {
self.shutdown(false);
}
}
#[cfg(test)]
#[cfg(unix)]
mod tests {
use super::*;
fn alive(pid: u32) -> bool {
// `kill -0` checks that the process exists without touching it. A zombie still counts
// on Linux, so /proc's state is checked there too.
#[cfg(target_os = "linux")]
if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) {
return !stat.rsplit(')').next().is_some_and(|s| s.trim_start().starts_with('Z'));
}
Command::new("kill").args(["-0", &pid.to_string()]).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
}
/// Start a fake agent that leaves helpers behind, read the helpers' ids from it, end it.
fn helpers_after_shutdown(script: &str) -> Vec<u32> {
let dir = std::env::temp_dir();
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let mut p = AgentProcess::spawn(&argv, &dir, &[], || {}).unwrap();
let mut pids = vec![p.pid().unwrap()];
while pids.len() < 3 {
match p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() {
FromAgent::Line(l) => pids.push(l.trim().parse().unwrap()),
FromAgent::Exited(tail) => panic!("the fake agent ended early: {tail:?}"),
}
}
assert!(pids.iter().all(|&pid| alive(pid)), "all running before the shutdown: {pids:?}");
p.shutdown(true);
// Orphaned helpers are reaped by init a moment after they end.
let start = Instant::now();
while pids.iter().any(|&pid| alive(pid)) && start.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(50));
}
pids.into_iter().filter(|&pid| alive(pid)).collect()
}
/// No orphans: an agent that started helpers (as Node adapters start the CLI they wrap and
/// its MCP servers) is ended with all of them, whether it ends politely at end of input or
/// ignores both its input and the request to stop.
#[test]
fn stopping_the_agent_leaves_no_helpers_running() {
// A polite agent: ends when its input closes; its helpers would live on by themselves.
let polite = "sleep 300 & echo $!; sleep 300 & echo $!; cat >/dev/null";
assert_eq!(helpers_after_shutdown(polite), Vec::<u32>::new(), "polite agent");
// A stubborn one: ignores end of input and TERM (so do its helpers).
let stubborn = "trap '' TERM; (trap '' TERM; sleep 300) & echo $!; (trap '' TERM; sleep 300) & echo $!; while :; do sleep 1; done";
assert_eq!(helpers_after_shutdown(stubborn), Vec::<u32>::new(), "stubborn agent");
}
/// Dropping the process (closing the panel's connection any other way) ends it too.
#[test]
fn dropping_the_process_ends_its_group() {
let argv = vec!["sh".to_string(), "-c".to_string(), "sleep 300 & echo $!; wait".to_string()];
let p = AgentProcess::spawn(&argv, &std::env::temp_dir(), &[], || {}).unwrap();
let leader = p.pid().unwrap();
let FromAgent::Line(l) = p.from_agent.recv_timeout(Duration::from_secs(10)).unwrap() else { panic!("no helper id") };
let helper: u32 = l.trim().parse().unwrap();
drop(p);
let start = Instant::now();
while (alive(helper) || alive(leader)) && start.elapsed() < Duration::from_secs(8) {
std::thread::sleep(Duration::from_millis(50));
}
assert!(!alive(helper) && !alive(leader), "leader {leader} or helper {helper} still running");
}
}
+541
View File
@@ -0,0 +1,541 @@
//! Settings → AI: the master switch, the agents (found and added, each with Test, Default and,
//! for added ones, Remove), permissions (global and per agent, and the "always allow" answers),
//! and the panel. Plus the Add agent dialog: an installed ACP agent, a custom command, or an
//! OpenAI-compatible endpoint (whose key goes to the system keychain).
use egui::{RichText, Stroke};
use super::agents::{self, AgentConfig, Kind, Mode};
use super::*;
use crate::theme::Tokens;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AddKind {
#[default]
Installed,
Custom,
Api,
}
/// The Add agent dialog's fields.
#[derive(Default)]
pub struct AddState {
pub kind: AddKind,
pub name: String,
pub command: String,
pub installed: usize,
pub preset: usize,
pub base_url: String,
pub model: String,
pub key: String,
pub error: Option<String>,
test: Option<Test>,
}
/// A connection test running or done.
pub enum Test {
Running(std::sync::mpsc::Receiver<Result<String, String>>),
Done(Result<String, String>),
}
impl Test {
fn poll(&mut self) {
if let Test::Running(rx) = self
&& let Ok(r) = rx.try_recv()
{
*self = Test::Done(r);
}
}
}
/// Settings state that isn't preferences: the dialog and the tests.
#[derive(Default)]
pub struct State {
pub add: Option<AddState>,
pub tests: std::collections::HashMap<String, Test>,
}
/// Open the Add agent dialog.
pub fn open_add(app: &mut SolveApp) {
let preset = agents::API_PRESETS.first();
app.agent_chat.settings.add = Some(AddState {
base_url: preset.map(|p| p.1.to_string()).unwrap_or_default(),
name: preset.map(|p| p.0.to_string()).unwrap_or_default(),
..Default::default()
});
}
/// Run a connection test in the background (it starts the agent briefly, or asks the endpoint
/// for its models).
#[cfg(not(target_arch = "wasm32"))]
fn start_test(app: &SolveApp, e: &agents::Entry, key: Option<String>) -> Test {
let (tx, rx) = std::sync::mpsc::channel();
match e.kind {
Kind::Acp => {
let argv = e.argv.clone();
std::thread::spawn(move || {
let _ = tx.send(super::acp_client::test_agent(&argv, &working_dir()));
});
}
Kind::Api => {
let Some(http) = app.services.agent_http.clone() else { return Test::Done(Err(tr("ui.ai_no_http").to_string())) };
let (url, model) = (e.base_url.clone(), e.model.clone());
std::thread::spawn(move || {
let _ = tx.send(super::api_client::test_endpoint(&http, &url, key.as_deref(), &model));
});
}
}
Test::Running(rx)
}
fn test_line(ui: &mut egui::Ui, t: &Option<&Test>) {
let tk = Tokens::get();
match t {
Some(Test::Running(_)) => {
ui.label(RichText::new(tr("ui.ai_testing")).color(tk.text_dim).size(11.0));
}
Some(Test::Done(Ok(m))) => {
ui.label(RichText::new(m.clone()).color(tk.accent).size(11.0));
}
Some(Test::Done(Err(e))) => {
ui.add(egui::Label::new(RichText::new(e).color(tk.error).size(11.0)).wrap());
}
None => {}
}
}
fn section(ui: &mut egui::Ui, title: &str) {
ui.add_space(10.0);
ui.label(RichText::new(title).strong().size(13.0));
ui.separator();
}
fn note(ui: &mut egui::Ui, text: &str) {
let t = Tokens::get();
ui.add(egui::Label::new(RichText::new(text).size(11.0).color(t.text_dim)).wrap());
}
/// The AI page of Settings.
pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
let r = ui.checkbox(&mut app.preferences.ai_features, RichText::new(tr("ui.ai_show_features")).strong());
crate::scenario::publish_handle("prefs:ai_features", r.rect.center());
note(ui, tr("ui.ai_show_features_note"));
if cfg!(target_arch = "wasm32") {
note(ui, tr("ui.agent_chat_desktop_only"));
return;
}
if !app.preferences.ai_features {
return;
}
ui.add_enabled_ui(true, |ui| {
let r = ui.checkbox(&mut app.preferences.ai_toolbar_button, tr("ui.ai_toolbar_button"));
crate::scenario::publish_handle("prefs:ai_toolbar_button", r.rect.center());
});
note(ui, tr("ui.ai_toolbar_button_note"));
#[cfg(not(target_arch = "wasm32"))]
{
if app.agent_chat.detection.is_none() && app.agent_chat.detecting.is_none() {
refresh(app, &ui.ctx().clone());
}
agents_section(app, ui);
}
section(ui, tr("ui.ai_permissions"));
for m in Mode::ALL {
let r = ui.radio_value(&mut app.preferences.ai_permissions, m, m.label());
crate::scenario::publish_handle(&format!("prefs:ai_mode:{}", m.id()), r.rect.center());
note(
ui,
tr(match m {
Mode::Ask => "ui.ai_mode_ask_note",
Mode::Edits => "ui.ai_mode_edits_note",
Mode::All => "ui.ai_mode_all_note",
}),
);
}
if app.preferences.ai_permissions == Mode::All {
egui::Frame::NONE.stroke(Stroke::new(1.0, t.warning)).corner_radius(4.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.add(egui::Label::new(RichText::new(tr("ui.ai_mode_all_warning")).color(t.warning)).wrap());
});
}
note(ui, tr("ui.ai_modes_scope"));
// "Always allow" answers.
let remembered: Vec<(String, Vec<String>)> =
app.preferences.agent_always_allow.iter().filter(|(_, v)| !v.is_empty()).map(|(k, v)| (k.clone(), v.clone())).collect();
ui.add_space(4.0);
ui.label(RichText::new(tr("ui.ai_always_allowed")).strong());
if remembered.is_empty() {
note(ui, tr("ui.ai_always_allowed_none"));
}
let names: std::collections::HashMap<String, String> = agents::entries(app).into_iter().map(|e| (e.id, e.name)).collect();
for (agent, tools) in remembered {
ui.horizontal_wrapped(|ui| {
ui.label(names.get(&agent).cloned().unwrap_or_else(|| agent.clone()));
for tool in &tools {
ui.label(RichText::new(tool).monospace().size(11.0).color(t.text_dim));
}
if ui.small_button(tr("ui.ai_reset")).clicked() {
app.preferences.agent_always_allow.remove(&agent);
}
});
}
section(ui, tr("ui.ai_panel"));
egui::Grid::new("sc_ai_panel_grid").num_columns(2).spacing([16.0, 6.0]).show(ui, |ui| {
ui.label(tr("ui.agent_chat_side"));
ui.horizontal(|ui| {
let side = &mut app.preferences.agent_chat_side;
if ui.radio(side == "left", tr("ui.agent_chat_left")).clicked() {
*side = "left".into();
}
if ui.radio(side != "left", tr("ui.agent_chat_right")).clicked() {
*side = "right".into();
}
});
ui.end_row();
ui.label(tr("ui.agent_chat_turn_limit"));
ui.add(egui::DragValue::new(&mut app.preferences.agent_chat_turn_limit).range(1..=200));
ui.end_row();
});
ui.add_space(6.0);
note(ui, tr("ui.agent_chat_privacy_note"));
}
/// The agents: found and added, with Test, Default, a permission override and Remove.
#[cfg(not(target_arch = "wasm32"))]
fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
let t = Tokens::get();
section(ui, tr("ui.ai_agents"));
for test in app.agent_chat.settings.tests.values_mut() {
test.poll();
}
let all = agents::entries(app);
let d = &app.preferences.agent_default;
let default = if all.iter().any(|e| &e.id == d) { Some(d.clone()) } else { all.first().map(|e| e.id.clone()) };
if app.agent_chat.detecting.is_some() {
ui.label(RichText::new(tr("ui.agent_chat_detecting")).color(t.text_dim));
} else if all.is_empty() {
note(ui, tr("ui.agent_chat_none_found"));
}
let mut remove: Option<String> = None;
let mut test: Option<agents::Entry> = None;
for e in &all {
egui::Frame::NONE.stroke(Stroke::new(1.0, t.border)).corner_radius(5.0).inner_margin(egui::Margin::symmetric(8, 5)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
super::panel::kind_icon(ui, e.kind);
ui.label(RichText::new(&e.name).strong());
ui.label(RichText::new(e.detail()).color(t.text_dim).size(11.0));
super::panel::login_chip(ui, e.login);
if !e.found {
ui.label(RichText::new(tr("ui.ai_added")).color(t.text_dim).size(11.0));
}
});
ui.horizontal(|ui| {
let is_default = default.as_deref() == Some(e.id.as_str());
if ui.radio(is_default, tr("ui.ai_default")).clicked() {
app.preferences.agent_default = e.id.clone();
}
let b = ui.small_button(tr("ui.ai_test"));
crate::scenario::publish_handle(&format!("prefs:ai_test:{}", e.id), b.rect.center());
if b.on_hover_text(tr(if e.kind == Kind::Acp { "ui.ai_test_acp_tip" } else { "ui.ai_test_api_tip" })).clicked() {
test = Some(e.clone());
}
// Its own permission mode, or the global one.
let cur = app.preferences.agent_permissions.get(&e.id).copied();
let shown = cur.map_or_else(|| tr("ui.ai_mode_global").to_string(), |m| m.label().to_string());
let mut pick: Option<Option<Mode>> = None;
egui::ComboBox::from_id_salt(("sc_ai_mode", &e.id)).width(150.0).selected_text(shown).show_ui(ui, |ui| {
if ui.selectable_label(cur.is_none(), tr("ui.ai_mode_global")).clicked() {
pick = Some(None);
}
for m in Mode::ALL {
if ui.selectable_label(cur == Some(m), m.label()).clicked() {
pick = Some(Some(m));
}
}
});
match pick {
Some(Some(m)) => {
app.preferences.agent_permissions.insert(e.id.clone(), m);
}
Some(None) => {
app.preferences.agent_permissions.remove(&e.id);
}
None => {}
}
if !e.found && ui.small_button(tr("ui.ai_remove")).clicked() {
remove = Some(e.id.clone());
}
});
test_line(ui, &app.agent_chat.settings.tests.get(&e.id));
});
ui.add_space(3.0);
}
ui.horizontal(|ui| {
let b = ui.button(tr("ui.ai_add_agent"));
crate::scenario::publish_handle("prefs:ai_add", b.rect.center());
if b.clicked() {
open_add(app);
}
if ui.add_enabled(app.agent_chat.detecting.is_none(), egui::Button::new(tr("ui.agent_chat_refresh"))).clicked() {
refresh(app, &ui.ctx().clone());
}
});
if let Some(e) = test {
let key = if e.key { app.services.secrets.as_ref().and_then(|s| s.get(&e.id).ok().flatten()) } else { None };
let started = start_test(app, &e, key);
app.agent_chat.settings.tests.insert(e.id.clone(), started);
}
if let Some(id) = remove {
app.preferences.agents.retain(|a| a.id != id);
app.preferences.agent_permissions.remove(&id);
app.preferences.agent_always_allow.remove(&id);
if let Some(s) = &app.services.secrets {
let _ = s.delete(&id);
}
if app.agent_chat.agent_id.as_deref() == Some(id.as_str()) {
app.agent_chat.agent_id = None;
}
}
if app.agent_chat.settings.tests.values().any(|t| matches!(t, Test::Running(_))) {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(200));
}
}
/// The Add agent dialog (drawn every frame while it is open).
pub fn dialog(app: &mut SolveApp, ctx: &egui::Context) {
if app.agent_chat.settings.add.is_none() {
return;
}
#[cfg(not(target_arch = "wasm32"))]
let found: Vec<agents::Entry> = agents::entries(app).into_iter().filter(|e| e.found).collect();
#[cfg(target_arch = "wasm32")]
let found: Vec<agents::Entry> = Vec::new();
let has_keychain = app.services.secrets.is_some();
let mut open = true;
let mut add = false;
let mut test = false;
let t = Tokens::get();
egui::Window::new(tr("ui.ai_add_agent_title"))
.id(egui::Id::new("sc_ai_add"))
.collapsible(false)
.resizable(false)
.default_width(460.0)
.pivot(egui::Align2::CENTER_CENTER)
.default_pos(ctx.content_rect().center())
.open(&mut open)
.order(egui::Order::Foreground)
.show(ctx, |ui| {
let Some(s) = app.agent_chat.settings.add.as_mut() else { return };
if let Some(test) = s.test.as_mut() {
test.poll();
}
ui.horizontal(|ui| {
for (k, label) in
[(AddKind::Installed, "ui.ai_add_installed"), (AddKind::Custom, "ui.ai_add_custom"), (AddKind::Api, "ui.ai_add_api")]
{
let r = ui.selectable_value(&mut s.kind, k, tr(label));
crate::scenario::publish_handle(&format!("ai_add:{label}"), r.rect.center());
if r.clicked() {
s.error = None;
s.test = None;
s.command.clear();
s.name.clear();
if k == AddKind::Api
&& let Some((name, url, _)) = agents::API_PRESETS.get(s.preset)
{
s.name = name.to_string();
s.base_url = url.to_string();
}
}
}
});
ui.separator();
egui::Grid::new("sc_ai_add_grid").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| match s.kind {
AddKind::Installed => {
ui.label(tr("ui.agent_chat_pick"));
if found.is_empty() {
ui.label(RichText::new(tr("ui.agent_chat_none_found_short")).color(t.text_dim));
} else {
let sel = found.get(s.installed).map(|e| e.name.clone()).unwrap_or_default();
egui::ComboBox::from_id_salt("sc_ai_add_installed").width(260.0).selected_text(sel).show_ui(ui, |ui| {
for (i, e) in found.iter().enumerate() {
if ui.selectable_value(&mut s.installed, i, format!("{} {}", e.name, e.detail())).clicked() {
s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::<Vec<_>>().join(" ");
s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix"));
}
}
});
}
ui.end_row();
if s.command.is_empty()
&& let Some(e) = found.get(s.installed)
{
s.command = e.argv.iter().map(|a| super::panel::quote(a)).collect::<Vec<_>>().join(" ");
s.name = format!("{} ({})", e.name, tr("ui.ai_copy_suffix"));
}
ui.label(tr("ui.ai_name"));
ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0));
ui.end_row();
ui.label(tr("ui.agent_chat_command"));
ui.add(egui::TextEdit::singleline(&mut s.command).desired_width(300.0));
ui.end_row();
ui.label("");
ui.add(egui::Label::new(RichText::new(tr("ui.ai_add_installed_note")).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
}
AddKind::Custom => {
ui.label(tr("ui.ai_name"));
ui.add(egui::TextEdit::singleline(&mut s.name).hint_text("My agent").desired_width(300.0));
ui.end_row();
ui.label(tr("ui.agent_chat_command"));
ui.add(egui::TextEdit::singleline(&mut s.command).hint_text("my-agent --acp").desired_width(300.0));
ui.end_row();
ui.label("");
ui.add(egui::Label::new(RichText::new(tr("ui.agent_chat_custom_hint")).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
}
AddKind::Api => {
ui.label(tr("ui.ai_preset"));
let presets: Vec<String> =
agents::API_PRESETS.iter().map(|p| p.0.to_string()).chain(std::iter::once(tr("ui.ai_custom_url").to_string())).collect();
let sel = presets.get(s.preset).cloned().unwrap_or_default();
egui::ComboBox::from_id_salt("sc_ai_add_preset").width(200.0).selected_text(sel).show_ui(ui, |ui| {
for (i, p) in presets.iter().enumerate() {
if ui.selectable_value(&mut s.preset, i, p).clicked() {
if let Some((name, url, _)) = agents::API_PRESETS.get(i) {
s.base_url = url.to_string();
s.name = name.to_string();
} else {
s.base_url.clear();
}
}
}
});
ui.end_row();
ui.label(tr("ui.ai_name"));
ui.add(egui::TextEdit::singleline(&mut s.name).desired_width(300.0));
ui.end_row();
ui.label(tr("ui.ai_base_url"));
ui.add(egui::TextEdit::singleline(&mut s.base_url).hint_text("https://example.com/v1").desired_width(300.0));
ui.end_row();
ui.label(tr("ui.ai_model"));
ui.add(egui::TextEdit::singleline(&mut s.model).hint_text("qwen3:14b").desired_width(300.0));
ui.end_row();
ui.label(tr("ui.ai_key"));
ui.add_enabled(
has_keychain,
egui::TextEdit::singleline(&mut s.key).password(true).hint_text(tr("ui.ai_key_hint")).desired_width(300.0),
);
ui.end_row();
ui.label("");
let note = if has_keychain { tr("ui.ai_key_note") } else { tr("ui.ai_no_keychain") };
ui.add(egui::Label::new(RichText::new(note).size(11.0).color(t.text_dim)).wrap());
ui.end_row();
ui.label("");
ui.horizontal(|ui| {
if ui.button(tr("ui.ai_test")).clicked() {
test = true;
}
test_line(ui, &s.test.as_ref());
});
ui.end_row();
}
});
if let Some(e) = &s.error {
ui.label(RichText::new(e).color(t.error));
}
ui.separator();
ui.horizontal(|ui| {
let b = ui.add(egui::Button::new(tr("ui.ai_add")).fill(t.accent_soft));
crate::scenario::publish_handle("ai_add:add", b.rect.center());
if b.clicked() {
add = true;
}
});
});
if test {
#[cfg(not(target_arch = "wasm32"))]
if let Some(s) = app.agent_chat.settings.add.as_ref() {
let url = agents::normalize_url(&s.base_url);
let e = agents::Entry {
id: String::new(),
name: s.name.clone(),
kind: Kind::Api,
argv: Vec::new(),
version: None,
login: "",
found: false,
base_url: url.clone().unwrap_or_default(),
model: s.model.trim().to_string(),
key: false,
};
let key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty());
let result = match url {
Ok(_) => start_test(app, &e, key),
Err(err) => Test::Done(Err(err)),
};
if let Some(s) = app.agent_chat.settings.add.as_mut() {
s.test = Some(result);
}
}
}
if add {
match add_agent(app) {
Ok(()) => open = false,
Err(e) => {
if let Some(s) = app.agent_chat.settings.add.as_mut() {
s.error = Some(e);
}
}
}
}
if !open {
app.agent_chat.settings.add = None;
}
if app.agent_chat.settings.add.as_ref().is_some_and(|s| matches!(s.test, Some(Test::Running(_)))) {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
/// Add what the dialog holds; its key goes to the keychain.
fn add_agent(app: &mut SolveApp) -> Result<(), String> {
let Some(s) = app.agent_chat.settings.add.as_ref() else { return Ok(()) };
let name = s.name.trim().chars().take(80).collect::<String>();
let mut c = AgentConfig { name, ..Default::default() };
let mut key: Option<String> = None;
match s.kind {
AddKind::Installed | AddKind::Custom => {
let cmd = s.command.trim();
if acp::split_command(cmd).is_empty() {
return Err(tr("ui.ai_need_command").to_string());
}
c.kind = Kind::Acp;
c.command = cmd.chars().take(4096).collect();
c.id = agents::new_id(&app.preferences, "custom");
}
AddKind::Api => {
c.kind = Kind::Api;
c.base_url = agents::normalize_url(&s.base_url)?;
c.model = s.model.trim().to_string();
if c.model.is_empty() {
return Err(tr("ui.ai_need_model").to_string());
}
key = Some(s.key.trim().to_string()).filter(|k| !k.is_empty());
if key.is_some() && !agents::key_safe_for(&c.base_url) {
return Err(tr("ui.ai_key_needs_https").to_string());
}
c.id = agents::new_id(&app.preferences, "api");
}
}
if c.name.is_empty() {
c.name = c.id.clone();
}
if let Some(k) = key {
let Some(store) = &app.services.secrets else { return Err(tr("ui.ai_no_keychain").to_string()) };
store.set(&c.id, &k).map_err(|e| trf("ui.ai_keychain_error", &[("error", &e)]))?;
c.key = true;
}
app.preferences.agents.push(c);
Ok(())
}
+305
View File
@@ -0,0 +1,305 @@
use super::*;
use std::sync::mpsc::channel;
fn chat() -> AgentChat {
let mut c = AgentChat::default();
c.state = Some(State::Ready);
c
}
#[test]
fn streamed_text_joins_and_tool_updates_change_their_call() {
let mut c = chat();
for e in [
Event::Text { thought: false, text: "Mak".into() },
Event::Text { thought: false, text: "ing a box".into() },
Event::Tool { id: "t".into(), title: Some("batch".into()), kind: Some("other".into()), status: Some(ToolStatus::Pending), output: None },
Event::Tool { id: "t".into(), title: None, kind: None, status: Some(ToolStatus::Completed), output: Some("ok".into()) },
] {
c.on_event(e);
}
assert_eq!(c.items.len(), 2);
assert!(matches!(&c.items[0], Item::Agent { text, .. } if text == "Making a box"));
assert!(matches!(&c.items[1], Item::Tool { title, status: Some(ToolStatus::Completed), output: Some(o), .. } if title == "batch" && o == "ok"));
}
#[test]
fn a_sign_in_request_keeps_the_question_for_after_the_login() {
let mut c = chat();
c.last = Some(("make a cube".into(), false));
c.state = Some(State::Busy);
assert!(c.on_event(Event::TurnEnded { reason: "auth".into() }).is_some());
c.on_event(Event::AuthRequired { methods: vec![] });
assert_eq!(c.state(), State::NeedsAuth);
assert_eq!(c.queued, Some(("make a cube".into(), false)));
}
#[test]
fn a_plan_update_replaces_the_turns_plan() {
let mut c = chat();
c.push(Item::User { text: "go".into(), image: false });
c.on_event(Event::Plan(vec![("a".into(), "pending".into())]));
c.on_event(Event::Text { thought: false, text: "working".into() });
c.on_event(Event::Plan(vec![("a".into(), "completed".into())]));
let plans: Vec<_> = c.items.iter().filter(|i| matches!(i, Item::Plan(_))).collect();
assert_eq!(plans.len(), 1);
assert!(matches!(plans[0], Item::Plan(p) if p[0].1 == "completed"));
}
#[test]
fn a_permission_is_answered_once() {
let mut c = chat();
let opts = vec![PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() }];
c.push(Item::Permission { key: 5, title: "batch".into(), options: opts, answer: None });
assert_eq!(c.waiting(), 1);
c.answer(0, Some("allow"));
c.answer(0, None);
assert!(matches!(&c.items[0], Item::Permission { answer: Some(a), .. } if a == "Allow"));
assert_eq!(c.waiting(), 0);
}
/// An app with the chat's endpoint wired to a channel the test feeds, and a message in progress.
fn app_in_a_turn() -> (SolveApp, Sender<ControlRequest>) {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
// The default mode: modelling runs, files ask.
assert_eq!(app.preferences.ai_permissions, agents::Mode::Edits);
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
app.agent_chat.state = Some(State::Busy);
start_turn(&mut app, "two boxes".into(), false);
(app, tx)
}
fn ask(tx: &Sender<ControlRequest>, method: &str, params: Value) -> Receiver<Value> {
let (req, rx) = ControlRequest::new(method, params);
tx.send(req).unwrap();
rx
}
fn frame(app: &mut SolveApp) {
let ctx = egui::Context::default();
poll(app, &ctx);
app.drain_control(&ctx);
}
fn boxed(x: f64) -> Value {
json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10, "corner": [x, 0, 0]}})
}
/// Everything the agent changed in one message undoes as one step, and the conversation offers
/// that undo while nothing else has happened since.
#[test]
fn a_messages_changes_undo_as_one_step() {
let (mut app, tx) = app_in_a_turn();
let before = app.session.doc.clone();
let depth = app.session.undo.len();
let replies: Vec<_> = [0.0, 20.0, 40.0].into_iter().map(|x| ask(&tx, "engine.execute", boxed(x))).collect();
frame(&mut app);
for r in replies {
assert_eq!(r.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true);
}
assert_eq!(app.session.undo.len(), depth + 3);
assert_eq!(app.agent_chat.turn.as_ref().map(|t| t.changes), Some(3));
app.agent_chat.on_event(Event::TurnEnded { reason: String::new() });
end_turn(&mut app);
assert_eq!(app.session.undo.len(), depth + 1, "one step");
assert!(app.session.undo.last().is_some_and(|s| s.label.contains("two boxes")));
let n = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 3, .. })).expect("the changes are listed");
undo_changes(&mut app, n);
assert!(Arc::ptr_eq(&app.session.doc, &before), "back to the design before the message");
assert!(matches!(app.agent_chat.items[n], Item::Changes { undone: true, .. }));
}
/// Saving waits for Allow or Deny; Deny answers the agent without running it, Allow runs it.
#[test]
fn file_actions_wait_for_the_user() {
let (mut app, tx) = app_in_a_turn();
let dir = std::env::temp_dir().join(format!("sc-agent-chat-approve-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("a.solidcraft");
let save = json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]});
let denied = ask(&tx, "engine.execute", save.clone());
frame(&mut app);
assert_eq!(app.agent_chat.waiting(), 1);
assert!(denied.try_recv().is_err(), "nothing answered before the user decides");
let id = app.agent_chat.held[0].id;
approve(&mut app, id, false);
let r = denied.recv_timeout(std::time::Duration::from_secs(5)).unwrap();
assert!(r["error"].as_str().is_some_and(|e| e.contains("declined")), "{r}");
assert!(!path.exists());
let allowed = ask(&tx, "engine.execute", save);
frame(&mut app);
let id = app.agent_chat.held[0].id;
approve(&mut app, id, true);
frame(&mut app);
let r = allowed.recv_timeout(std::time::Duration::from_secs(30)).unwrap();
assert_eq!(r["ok"], true, "{r}");
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
/// Crash reports and app methods never run for the agent, requests outside a message don't
/// either, and a message stops taking changes at the limit.
#[test]
fn the_gate_refuses_and_limits() {
let (mut app, tx) = app_in_a_turn();
app.preferences.agent_chat_turn_limit = 2;
let crash = ask(&tx, "engine.execute", json!({"command": "diagnostics.crash_reports", "params": {}}));
let quit = ask(&tx, "app.quit", json!({"force": true}));
let ok: Vec<_> = (0..2).map(|i| ask(&tx, "engine.execute", boxed(f64::from(i) * 20.0))).collect();
let over = ask(&tx, "engine.execute", boxed(80.0));
let read = ask(&tx, "document.inspect", json!({}));
frame(&mut app);
let get = |r: Receiver<Value>| r.recv_timeout(std::time::Duration::from_secs(30)).unwrap();
assert!(get(crash)["error"].as_str().is_some_and(|e| e.contains("crash reports")));
assert!(get(quit)["error"].as_str().is_some_and(|e| e.contains("not available")));
for r in ok {
assert_eq!(get(r)["ok"], true);
}
assert!(get(over)["error"].as_str().is_some_and(|e| e.contains("limit of 2")));
assert_eq!(get(read)["ok"], true, "reads go on past the limit");
end_turn(&mut app);
let late = ask(&tx, "engine.execute", boxed(100.0));
frame(&mut app);
assert!(get(late)["error"].as_str().is_some_and(|e| e.contains("no message in progress")));
}
#[test]
fn merging_finds_the_turns_start_when_the_history_was_trimmed() {
// Distinct designs: what counts is which `Arc` a snapshot holds.
let doc = |_: usize| Arc::new(solidcraft_engine::doc::Document::default());
let snap = |d: &Arc<solidcraft_engine::doc::Document>| solidcraft_engine::Snapshot {
label: String::new(),
doc: d.clone(),
active_sketch: None,
pending_moves: Default::default(),
};
let docs: Vec<_> = (0..6).map(doc).collect();
// The turn began at depth 3 with docs[3]; the history then lost its oldest step.
let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect();
assert_eq!(merge_undo(&mut undo, &docs[3], 3, "Agent"), 3);
assert_eq!(undo.len(), 3);
assert!(Arc::ptr_eq(&undo[2].doc, &docs[3]) && undo[2].label == "Agent");
// A start that is no longer in the history: nothing is merged.
let mut undo: Vec<_> = docs[1..6].iter().map(snap).collect();
assert_eq!(merge_undo(&mut undo, &doc(9), 3, "Agent"), 0);
assert_eq!(undo.len(), 5);
}
/// People who don't want AI never see it: with "Show AI features" off there is no button, the
/// shortcut does nothing, no panel draws, nothing is looked for and nothing starts, even when
/// asked over the control channel. Turned on, the button is there; discovery waits for the panel.
#[test]
fn with_ai_features_off_nothing_of_the_chat_shows_or_runs() {
use crate::scenario::{handle_at, rect_of};
let mut h = crate::scenario::Harness::new();
let shortcut = json!({"key": "A", "shift": true, "cmd": true});
h.app.preferences.ai_features = false;
h.frames(3);
assert!(handle_at("toolbar:ai").is_none(), "no toolbar button");
h.call("ui.key", shortcut.clone());
h.frames(3);
h.call("ui.agent_chat", json!({"open": true, "send": "hello", "connect": true}));
h.frames(3);
assert!(!h.app.agent_chat.open && rect_of("agent_chat:panel").is_none(), "no panel");
assert!(h.app.agent_chat.detection.is_none() && h.app.agent_chat.detecting.is_none(), "nothing looked for");
assert!(h.app.agent_chat.backend.is_none() && h.app.agent_chat.tools_port.is_none(), "nothing started");
// The View settings menu has no chat entry either.
if let Some(view) = handle_at("nav:view") {
h.call("ui.click", json!({"x": view.x, "y": view.y}));
h.frames(3);
assert!(handle_at("view:agent_chat").is_none(), "no menu entry");
h.call("ui.click", json!({"x": view.x, "y": view.y}));
}
// On (the default): the button shows; discovery waits until the panel opens.
h.app.preferences.ai_features = true;
h.frames(3);
assert!(handle_at("toolbar:ai").is_some(), "the toolbar button");
assert!(h.app.agent_chat.detection.is_none() && h.app.agent_chat.detecting.is_none(), "nothing looked for yet");
h.call("ui.key", shortcut.clone());
h.frames(3);
assert!(h.app.agent_chat.open && rect_of("agent_chat:panel").is_some(), "Ctrl+Shift+A opens the panel");
assert!(h.app.agent_chat.detection.is_some() || h.app.agent_chat.detecting.is_some(), "the panel looks for agents");
assert!(h.app.agent_chat.backend.is_none(), "but starts none until a chat begins");
// Without the toolbar button the shortcut still works.
h.app.preferences.ai_toolbar_button = false;
h.call("ui.key", shortcut);
h.frames(3);
assert!(handle_at("toolbar:ai").is_none() && !h.app.agent_chat.open);
}
#[test]
fn ai_features_and_the_toolbar_button_are_on_by_default() {
let p = crate::prefs::Prefs::default();
assert!(p.ai_features && p.ai_toolbar_button);
}
/// "Ask every time": a modelling command waits for Allow; "Always allow" lets the same command
/// through from then on for this agent. "Allow everything": even a save runs without a question.
#[test]
fn permission_modes_decide_what_asks() {
let (mut app, tx) = app_in_a_turn();
app.agent_chat.running_id = "claude".into();
app.preferences.ai_permissions = agents::Mode::Ask;
let first = ask(&tx, "engine.execute", boxed(0.0));
frame(&mut app);
assert_eq!(app.agent_chat.waiting(), 1, "a modelling command asks");
let id = app.agent_chat.held[0].id;
approve_always(&mut app, id);
frame(&mut app);
assert_eq!(first.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true);
assert!(agents::always_allowed(&app.preferences, "claude", "solid.box"));
let second = ask(&tx, "engine.execute", boxed(20.0));
frame(&mut app);
assert_eq!(app.agent_chat.waiting(), 0, "remembered");
assert_eq!(second.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true);
// Another agent keeps asking.
app.agent_chat.running_id = "codex".into();
let _third = ask(&tx, "engine.execute", boxed(40.0));
frame(&mut app);
assert_eq!(app.agent_chat.waiting(), 1);
decline_all(&mut app, "test");
// Allow everything, set for this agent only.
app.preferences.agent_permissions.insert("codex".into(), agents::Mode::All);
let dir = std::env::temp_dir().join(format!("sc-agent-chat-all-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("b.solidcraft");
let save = ask(
&tx,
"engine.execute",
json!({"command": "file.save", "params": {"path": path.to_string_lossy()}, "allow_paths": [dir.to_string_lossy()]}),
);
frame(&mut app);
frame(&mut app);
assert_eq!(app.agent_chat.waiting(), 0);
assert_eq!(save.recv_timeout(std::time::Duration::from_secs(30)).unwrap()["ok"], true);
// Crash reports stay off limits whatever the mode.
let crash = ask(&tx, "engine.execute", json!({"command": "diagnostics.crash_reports"}));
frame(&mut app);
assert_eq!(crash.recv_timeout(std::time::Duration::from_secs(5)).unwrap()["ok"], false);
let _ = std::fs::remove_dir_all(&dir);
}
/// The agent's own questions: SolidCraft's tools are allowed (its gate checks them), an earlier
/// "Always allow" answers by itself, and both leave a note; anything else is asked.
#[test]
fn the_agents_own_questions_are_answered_by_the_mode_or_asked() {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.agent_chat.running_id = "claude".into();
let opts = || {
vec![
PermissionOption { id: "allow".into(), name: "Allow".into(), kind: "allow_once".into() },
PermissionOption { id: "always".into(), name: "Always".into(), kind: "allow_always".into() },
PermissionOption { id: "no".into(), name: "Reject".into(), kind: "reject_once".into() },
]
};
on_permission(&mut app, 1, "mcp__solidcraft__batch".into(), opts());
assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { text, .. }) if text.contains("mcp__solidcraft__batch")));
on_permission(&mut app, 2, "Run ls".into(), opts());
assert!(matches!(app.agent_chat.items.last(), Some(Item::Permission { answer: None, .. })));
let n = app.agent_chat.items.len() - 1;
answer_permission(&mut app, n, Some("always"));
assert!(agents::always_allowed(&app.preferences, "claude", "Run ls"));
on_permission(&mut app, 3, "Run ls".into(), opts());
assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered");
}