Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566)

Thanks @bherbruck.
This commit is contained in:
bherbruck authored and GitHub committed 2026-10-15 23:58:28 -04:00
1 parent fdb059cfed
commit baf44657c1
16 files changed
+1293 -33

No files matched your search

-1
View File
@@ -613,7 +613,6 @@
"ui.agent_chat_undo_label": "Agenten-Chat: {message}",
"ui.agent_chat_undone": "(rückgängig gemacht)",
"ui.agent_chat_view_attached": "Ansicht angehängt",
"ui.agent_chat_wait_turn": "Der Agent arbeitet noch an der letzten Nachricht: Warten Sie oder klicken Sie auf Abbrechen.",
"ui.agent_cursor": "Agent-Cursor",
"ui.agent_cursor_speed": "Agent-Cursor-Geschwindigkeit",
"ui.ai_add": "Hinzufügen",
+15 -1
View File
@@ -795,6 +795,7 @@
"ui.agent_chat_detecting": "Looking for agents…",
"ui.agent_chat_dock_left": "Dock on the left",
"ui.agent_chat_dock_right": "Dock on the right",
"ui.agent_chat_edit": "Edit",
"ui.agent_chat_exited": "The agent stopped.",
"ui.agent_chat_failed_start": "The agent could not start: {error}",
"ui.agent_chat_intro": "Chat with a coding agent you already use, or a model endpoint you choose. It works on the open design through SolidCraft's tools: its changes show as it makes them and undo as one step per message. What it may do without asking is set in Preferences > AI.",
@@ -802,6 +803,10 @@
"ui.agent_chat_left": "Left",
"ui.agent_chat_limit": "The agent reached the limit of {limit} changes for one message. Send another message to let it go on.",
"ui.agent_chat_minimize": "Minimise to a strip",
"ui.agent_chat_model_tip": "The model the agent uses. Your choice is remembered for this agent.",
"ui.agent_chat_new": "New chat",
"ui.agent_chat_new_confirm": "Start a new chat? The current one is cleared.",
"ui.agent_chat_new_yes": "New chat",
"ui.agent_chat_no_cli": "solidcraft-cli was not found next to the app, so the agent can't reach the design.",
"ui.agent_chat_no_control": "The agent chat's endpoint did not start, so the agent can't reach the design.",
"ui.agent_chat_no_images": "This agent doesn't take pictures",
@@ -812,12 +817,21 @@
"ui.agent_chat_permission": "The agent asks to run: {title}",
"ui.agent_chat_pick": "Agent",
"ui.agent_chat_placeholder": "Ask the agent to change the design…",
"ui.agent_chat_placeholder_busy": "Queue a message for when the agent is done (Ctrl+Enter: send now)",
"ui.agent_chat_privacy": "Nothing goes anywhere except to the agent you run, under your own account.",
"ui.agent_chat_privacy_note": "SolidCraft sends nothing anywhere: the agent you run talks to its own service under your own account. SolidCraft never sees or stores a key or password.",
"ui.agent_chat_queue": "Queue",
"ui.agent_chat_queue_full": "Too many messages are waiting: remove one or wait for the agent.",
"ui.agent_chat_queued": "Queued",
"ui.agent_chat_queued_stopped": "Queued (stopped, not sent)",
"ui.agent_chat_refresh": "Refresh",
"ui.agent_chat_refused": "Not allowed for the agent: {reason}",
"ui.agent_chat_remove": "Remove from the queue",
"ui.agent_chat_right": "Right",
"ui.agent_chat_save": "Save",
"ui.agent_chat_send": "Send",
"ui.agent_chat_send_now": "Send now",
"ui.agent_chat_send_now_tip": "Stop the agent's current step and send this next (Ctrl+Enter)",
"ui.agent_chat_side": "Agent chat side",
"ui.agent_chat_signed_in": "signed in",
"ui.agent_chat_state_auth": "Sign-in needed",
@@ -828,12 +842,12 @@
"ui.agent_chat_state_starting": "Starting…",
"ui.agent_chat_stop": "Stop",
"ui.agent_chat_stop_tip": "Stop the agent and everything it started",
"ui.agent_chat_stop_turn_tip": "Stop the agent (Esc). Queued messages stay queued.",
"ui.agent_chat_turn_limit": "Changes per message",
"ui.agent_chat_undo_changes": "Undo these changes",
"ui.agent_chat_undo_label": "Agent chat: {message}",
"ui.agent_chat_undone": "(undone)",
"ui.agent_chat_view_attached": "View attached",
"ui.agent_chat_wait_turn": "The agent is still working on the last message: wait for it or press Cancel.",
"ui.agent_cursor": "Agent cursor",
"ui.agent_cursor_speed": "Agent cursor speed",
"ui.ai_add": "Add",
+15 -1
View File
@@ -795,6 +795,7 @@
"ui.agent_chat_detecting": "エージェントを検索中…",
"ui.agent_chat_dock_left": "左にドッキング",
"ui.agent_chat_dock_right": "右にドッキング",
"ui.agent_chat_edit": "編集",
"ui.agent_chat_exited": "エージェントが停止しました。",
"ui.agent_chat_failed_start": "エージェントを起動できませんでした: {error}",
"ui.agent_chat_intro": "普段使っているコーディングエージェント、または選んだモデルのエンドポイントとチャットできます。SolidCraft のツールで開いているデザインを操作し、変更はその場で表示され、メッセージごとに1回で元に戻せます。確認なしで許可する範囲は「環境設定 > AI」で設定します。",
@@ -802,6 +803,10 @@
"ui.agent_chat_left": "左",
"ui.agent_chat_limit": "エージェントが1メッセージあたりの変更上限 {limit} に達しました。続けさせるには、もう一度メッセージを送ってください。",
"ui.agent_chat_minimize": "細いバーに最小化",
"ui.agent_chat_model_tip": "エージェントが使うモデル。選択はこのエージェントに対して記憶されます。",
"ui.agent_chat_new": "新しいチャット",
"ui.agent_chat_new_confirm": "新しいチャットを始めますか? 現在のチャットは消去されます。",
"ui.agent_chat_new_yes": "新しいチャット",
"ui.agent_chat_no_cli": "アプリの隣に solidcraft-cli が見つからないため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_no_control": "エージェントチャットのエンドポイントが起動しなかったため、エージェントはデザインにアクセスできません。",
"ui.agent_chat_no_images": "このエージェントは画像を受け付けません",
@@ -812,12 +817,21 @@
"ui.agent_chat_permission": "エージェントが実行の許可を求めています: {title}",
"ui.agent_chat_pick": "エージェント",
"ui.agent_chat_placeholder": "デザインの変更をエージェントに依頼…",
"ui.agent_chat_placeholder_busy": "エージェントの作業が終わったら送るメッセージを入力(Ctrl+Enter: 今すぐ送信)",
"ui.agent_chat_privacy": "あなたのアカウントで実行するエージェント以外には、何も送信されません。",
"ui.agent_chat_privacy_note": "SolidCraft はどこにも何も送信しません。あなたが実行するエージェントが、あなた自身のアカウントでそのサービスと通信します。SolidCraft がキーやパスワードを見たり保存したりすることはありません。",
"ui.agent_chat_queue": "待機に追加",
"ui.agent_chat_queue_full": "待機中のメッセージが多すぎます。削除するか、エージェントの完了をお待ちください。",
"ui.agent_chat_queued": "待機中",
"ui.agent_chat_queued_stopped": "待機中(停止済み・未送信)",
"ui.agent_chat_refresh": "再検索",
"ui.agent_chat_refused": "エージェントには許可されていません: {reason}",
"ui.agent_chat_remove": "待機から削除",
"ui.agent_chat_right": "右",
"ui.agent_chat_save": "保存",
"ui.agent_chat_send": "送信",
"ui.agent_chat_send_now": "今すぐ送信",
"ui.agent_chat_send_now_tip": "エージェントの現在のステップを止めて、これを次に送信します(Ctrl+Enter)",
"ui.agent_chat_side": "エージェントチャットの位置",
"ui.agent_chat_signed_in": "サインイン済み",
"ui.agent_chat_state_auth": "サインインが必要",
@@ -828,12 +842,12 @@
"ui.agent_chat_state_starting": "起動中…",
"ui.agent_chat_stop": "停止",
"ui.agent_chat_stop_tip": "エージェントと、それが起動したものをすべて停止します",
"ui.agent_chat_stop_turn_tip": "エージェントを停止します(Esc)。待機中のメッセージは待機のままです。",
"ui.agent_chat_turn_limit": "1メッセージあたりの変更数",
"ui.agent_chat_undo_changes": "これらの変更を元に戻す",
"ui.agent_chat_undo_label": "エージェントチャット: {message}",
"ui.agent_chat_undone": "(元に戻しました)",
"ui.agent_chat_view_attached": "ビューを添付済み",
"ui.agent_chat_wait_turn": "エージェントはまだ前のメッセージを処理中です。待つか「キャンセル」を押してください。",
"ui.agent_cursor": "エージェントカーソル",
"ui.agent_cursor_speed": "エージェントカーソル速度",
"ui.ai_add": "追加",
-1
View File
@@ -613,7 +613,6 @@
"ui.agent_chat_undo_label": "에이전트 채팅: {message}",
"ui.agent_chat_undone": "(취소됨)",
"ui.agent_chat_view_attached": "뷰 첨부됨",
"ui.agent_chat_wait_turn": "에이전트가 아직 이전 메시지를 처리 중입니다. 기다리거나 취소를 누르세요.",
"ui.agent_cursor": "에이전트 커서",
"ui.agent_cursor_speed": "에이전트 커서 속도",
"ui.ai_add": "추가",
-1
View File
@@ -788,7 +788,6 @@
"ui.agent_chat_undo_label": "Ajan sohbeti: {message}",
"ui.agent_chat_undone": "(geri alındı)",
"ui.agent_chat_view_attached": "Görünüm eklendi",
"ui.agent_chat_wait_turn": "Ajan hâlâ son mesaj üzerinde çalışıyor: Bekleyin veya İptal'e tıklayın.",
"ui.agent_cursor": "Ajan imleci",
"ui.agent_cursor_speed": "Ajan imleci hızı",
"ui.ai_add": "Ekle",
+15 -1
View File
@@ -795,6 +795,7 @@
"ui.agent_chat_detecting": "正在查找智能体…",
"ui.agent_chat_dock_left": "停靠在左侧",
"ui.agent_chat_dock_right": "停靠在右侧",
"ui.agent_chat_edit": "编辑",
"ui.agent_chat_exited": "智能体已停止。",
"ui.agent_chat_failed_start": "智能体无法启动:{error}",
"ui.agent_chat_intro": "与你已在使用的编程智能体或你选择的模型端点聊天。它通过 SolidCraft 的工具处理打开的设计:更改即时显示,每条消息的更改可一步撤销。无需询问即可执行的范围在“首选项 > AI”中设置。",
@@ -802,6 +803,10 @@
"ui.agent_chat_left": "左侧",
"ui.agent_chat_limit": "智能体已达到每条消息 {limit} 项更改的上限。再发送一条消息可让它继续。",
"ui.agent_chat_minimize": "最小化为细条",
"ui.agent_chat_model_tip": "智能体使用的模型。你的选择会为此智能体记住。",
"ui.agent_chat_new": "新对话",
"ui.agent_chat_new_confirm": "开始新对话?当前对话将被清除。",
"ui.agent_chat_new_yes": "新对话",
"ui.agent_chat_no_cli": "在应用旁边未找到 solidcraft-cli,因此智能体无法访问设计。",
"ui.agent_chat_no_control": "智能体聊天的端点未能启动,因此智能体无法访问设计。",
"ui.agent_chat_no_images": "该智能体不接收图片",
@@ -812,12 +817,21 @@
"ui.agent_chat_permission": "智能体请求运行:{title}",
"ui.agent_chat_pick": "智能体",
"ui.agent_chat_placeholder": "让智能体修改设计…",
"ui.agent_chat_placeholder_busy": "输入消息,待智能体完成后发送(Ctrl+Enter:立即发送)",
"ui.agent_chat_privacy": "除了你以自己的账户运行的智能体之外,不会向任何地方发送任何内容。",
"ui.agent_chat_privacy_note": "SolidCraft 不向任何地方发送任何内容:你运行的智能体以你自己的账户与它自己的服务通信。SolidCraft 从不查看或保存任何密钥或密码。",
"ui.agent_chat_queue": "加入队列",
"ui.agent_chat_queue_full": "等待中的消息太多:请删除一条,或等待智能体完成。",
"ui.agent_chat_queued": "已排队",
"ui.agent_chat_queued_stopped": "已排队(已停止,未发送)",
"ui.agent_chat_refresh": "刷新",
"ui.agent_chat_refused": "不允许智能体执行:{reason}",
"ui.agent_chat_remove": "从队列中移除",
"ui.agent_chat_right": "右侧",
"ui.agent_chat_save": "保存",
"ui.agent_chat_send": "发送",
"ui.agent_chat_send_now": "立即发送",
"ui.agent_chat_send_now_tip": "停止智能体当前的步骤,并接着发送这条消息(Ctrl+Enter)",
"ui.agent_chat_side": "智能体聊天位置",
"ui.agent_chat_signed_in": "已登录",
"ui.agent_chat_state_auth": "需要登录",
@@ -828,12 +842,12 @@
"ui.agent_chat_state_starting": "正在启动…",
"ui.agent_chat_stop": "停止",
"ui.agent_chat_stop_tip": "停止智能体及其启动的所有进程",
"ui.agent_chat_stop_turn_tip": "停止智能体(Esc)。排队的消息仍保持排队。",
"ui.agent_chat_turn_limit": "每条消息的更改数",
"ui.agent_chat_undo_changes": "撤销这些更改",
"ui.agent_chat_undo_label": "智能体聊天:{message}",
"ui.agent_chat_undone": "(已撤销)",
"ui.agent_chat_view_attached": "已附加视图",
"ui.agent_chat_wait_turn": "智能体仍在处理上一条消息:请等待或点击取消。",
"ui.agent_cursor": "代理光标",
"ui.agent_cursor_speed": "代理光标速度",
"ui.ai_add": "添加",
+59
View File
@@ -115,6 +115,40 @@ pub fn modes(session: &Value) -> Option<(String, Vec<(String, String)>)> {
(!available.is_empty()).then_some((current, available))
}
/// One model an agent offers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModelInfo {
pub id: String,
pub name: String,
pub description: String,
}
/// The agent's models in a `session/new` or `session/load` reply (`models`: `currentModelId` and
/// `availableModels: [{modelId, name, description?}]`): the current one and each model. `None`
/// when the agent reports none.
pub fn models(session: &Value) -> Option<(String, Vec<ModelInfo>)> {
let m = session.get("models")?;
let current = m.get("currentModelId").and_then(Value::as_str).unwrap_or("").to_string();
let available: Vec<ModelInfo> = m
.get("availableModels")?
.as_array()?
.iter()
.take(200)
.filter_map(|x| {
let id = x.get("modelId").or_else(|| x.get("id"))?.as_str()?.to_string();
let name = x.get("name").and_then(Value::as_str).filter(|n| !n.is_empty()).unwrap_or(&id).to_string();
let description = x.get("description").and_then(Value::as_str).unwrap_or("").to_string();
Some(ModelInfo { id, name, description })
})
.collect();
(!available.is_empty()).then_some((current, available))
}
/// `session/set_model` parameters.
pub fn set_model_params(session: &str, model: &str) -> Value {
json!({"sessionId": session, "modelId": model})
}
/// 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)
@@ -245,6 +279,8 @@ pub enum Incoming {
AuthStatus(String),
/// The agent switched to another of its modes.
ModeChanged(String),
/// The agent switched to another model.
ModelChanged(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.
@@ -351,6 +387,10 @@ fn update(u: &Value) -> Incoming {
Some(m) => Incoming::ModeChanged(m),
None => Incoming::Ignored,
},
Some("current_model_update") => match s("currentModelId").or_else(|| s("modelId")) {
Some(m) => Incoming::ModelChanged(m),
None => Incoming::Ignored,
},
Some("plan") => Incoming::Plan(
u.get("entries")
.and_then(Value::as_array)
@@ -496,4 +536,23 @@ mod tests {
let Incoming::Tool { output: Some(o), .. } = update(&u) else { panic!() };
assert!(o.len() <= MAX_OUTPUT + 3 && o.ends_with('…'));
}
#[test]
fn the_models_of_a_session_reply_are_read_like_its_modes() {
let v = json!({"sessionId": "s", "models": {"currentModelId": "sonnet", "availableModels": [
{"modelId": "opus", "name": "Opus", "description": "Most capable"},
{"modelId": "sonnet", "name": "Sonnet"},
{"modelId": "haiku"},
{"name": "no id"}]}});
let (current, list) = models(&v).unwrap();
assert_eq!(current, "sonnet");
let names: Vec<_> = list.iter().map(|m| (m.id.as_str(), m.name.as_str(), m.description.as_str())).collect();
assert_eq!(names, [("opus", "Opus", "Most capable"), ("sonnet", "Sonnet", ""), ("haiku", "haiku", "")]);
// An agent without models, or with an empty list, reports none.
assert!(models(&json!({"sessionId": "s"})).is_none());
assert!(models(&json!({"models": {"currentModelId": "x", "availableModels": []}})).is_none());
let changed = r#"{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"s","update":{"sessionUpdate":"current_model_update","currentModelId":"haiku"}}}"#;
assert_eq!(parse(changed), Some(Incoming::ModelChanged("haiku".into())));
assert_eq!(set_model_params("s", "opus"), json!({"sessionId": "s", "modelId": "opus"}));
}
}
+20 -1
View File
@@ -17,6 +17,7 @@ enum Pending {
Prompt,
Authenticate,
SetMode,
SetModel,
}
/// Variables the agent doesn't inherit: a control token set for the app's own `--control` port
@@ -90,6 +91,9 @@ impl AcpBackend {
if let Some((current, available)) = acp::modes(&v) {
out.push(Event::Modes { current, available });
}
if let Some((current, available)) = acp::models(&v) {
out.push(Event::Models { current, available });
}
out.push(Event::Ready { agent: self.agent.clone(), images: self.images });
}
None => out.push(Event::Error("session/new: no sessionId".into())),
@@ -100,7 +104,7 @@ impl AcpBackend {
}
// Signed in: try the session again.
(Pending::Authenticate, Ok(_)) => self.new_session(),
(Pending::SetMode, Ok(_)) => {}
(Pending::SetMode | Pending::SetModel, Ok(_)) => {}
(what, Err((code, message))) => {
let auth = code == acp::AUTH_REQUIRED || message.to_ascii_lowercase().contains("auth");
if auth {
@@ -128,6 +132,7 @@ impl AcpBackend {
}
Incoming::AuthStatus(s) => out.push(Event::AuthStatus(s)),
Incoming::ModeChanged(m) => out.push(Event::Modes { current: m, available: Vec::new() }),
Incoming::ModelChanged(m) => out.push(Event::Models { 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}`")));
}
@@ -178,6 +183,14 @@ impl ChatBackend for AcpBackend {
}
}
fn new_session(&mut self) {
if self.pending.values().any(|p| *p == Pending::Prompt) {
self.cancel();
}
self.session = None;
self.new_session();
}
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));
@@ -194,6 +207,12 @@ impl ChatBackend for AcpBackend {
}
}
fn set_model(&mut self, model: &str) {
if let Some(s) = self.session.clone() {
self.request(Pending::SetModel, "session/set_model", acp::set_model_params(&s, model));
}
}
fn pid(&self) -> Option<u32> {
self.proc.pid()
}
+236 -10
View File
@@ -14,6 +14,7 @@ use std::time::Duration;
use serde_json::{Value, json};
use super::acp::ModelInfo;
use super::backend::{ChatBackend, Event, HttpFn, HttpRequest, PromptPart, StartContext, ToolStatus};
use super::process::{AgentProcess, FromAgent, ProcessHandle};
@@ -27,6 +28,8 @@ const MAX_HISTORY: usize = 240;
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);
/// How often a waiting request or tool call looks at the cancel flag.
const CANCEL_POLL: Duration = Duration::from_millis(40);
/// Tools the model is not offered.
const HIDDEN_TOOLS: &[&str] = &["crash_reports"];
@@ -41,6 +44,8 @@ pub struct ApiConfig {
enum Cmd {
Prompt(Vec<PromptPart>),
NewChat,
SetModel(String),
}
pub struct ApiBackend {
@@ -90,6 +95,18 @@ impl ChatBackend for ApiBackend {
fn cancel(&mut self) {
self.cancel.store(true, Ordering::Relaxed);
}
fn set_model(&mut self, model: &str) {
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::SetModel(model.to_string()));
}
}
fn new_session(&mut self) {
// A running turn is cancelled; the worker resets the history once it has ended.
self.cancel.store(true, Ordering::Relaxed);
if let Some(c) = &self.cmds {
let _ = c.send(Cmd::NewChat);
}
}
fn answer_permission(&mut self, _key: u64, _option: Option<&str>) {}
fn authenticate(&mut self, _method: &str) {}
fn pid(&self) -> Option<u32> {
@@ -134,9 +151,25 @@ impl Worker {
}
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 });
// The endpoint's models for the picker; an endpoint that does not list them shows none.
if let Ok(list) = model_list(&self.cfg.http, &self.cfg.base_url, self.cfg.key.as_deref()) {
let mut available = list;
if !self.cfg.model.is_empty() && !available.iter().any(|m| m.id == self.cfg.model) {
available.insert(0, ModelInfo { id: self.cfg.model.clone(), name: self.cfg.model.clone(), description: String::new() });
}
if !available.is_empty() {
self.emit(Event::Models { current: self.cfg.model.clone(), available });
}
}
for cmd in cmds {
match cmd {
Cmd::Prompt(parts) => self.turn(parts),
Cmd::SetModel(m) => self.cfg.model = m,
Cmd::NewChat => {
self.history.truncate(1);
self.cancel.store(false, Ordering::Relaxed);
self.emit(Event::Ready { agent: format!("{} · {}", self.cfg.name, self.cfg.model), images: true });
}
}
}
if let Some(mut m) = self.mcp.take() {
@@ -152,7 +185,12 @@ impl Worker {
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());
// Wait in short slices so Stop and Send now do not wait for a slow tool: its late
// answer is skipped later (its id no longer matches).
if self.cancel.load(Ordering::Relaxed) {
return Err("cancelled by the user".into());
}
let left = deadline.saturating_duration_since(std::time::Instant::now()).min(CANCEL_POLL);
match m.from_agent.recv_timeout(left) {
Ok(FromAgent::Line(l)) => {
let Ok(v) = serde_json::from_str::<Value>(l.trim()) else { continue };
@@ -165,6 +203,7 @@ impl Worker {
return Ok(v.get("result").cloned().unwrap_or(Value::Null));
}
Ok(FromAgent::Exited(_)) => return Err("the SolidCraft tools stopped".into()),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => {}
Err(_) => return Err("the SolidCraft tools did not answer".into()),
}
}
@@ -196,12 +235,25 @@ impl Worker {
self.history.remove(1);
}
for _ in 0..MAX_REQUESTS_PER_TURN {
// The history as of here is complete (every tool call answered): a cancelled request
// goes back to it, so no half-finished round is left to confuse the next message.
let sound = self.history.len();
if self.cancel.load(Ordering::Relaxed) {
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
let reply = match self.request() {
Ok(_) if self.cancel.load(Ordering::Relaxed) => {
self.history.truncate(sound);
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
Ok(r) => r,
Err(_) if self.cancel.load(Ordering::Relaxed) => {
self.history.truncate(sound);
self.emit(Event::TurnEnded { reason: "cancelled".into() });
return;
}
Err(e) => {
self.emit(Event::Error(e));
self.emit(Event::TurnEnded { reason: "error".into() });
@@ -282,7 +334,23 @@ impl Worker {
body: Some(body.to_string().into_bytes()),
timeout_s: REQUEST_TIMEOUT_S,
};
let (status, bytes) = (self.cfg.http)(&req)?;
// The request runs on a thread of its own so that Stop does not wait for the model: a
// cancelled one is abandoned (the host's own timeout ends it) and its answer dropped.
let (tx, rx) = channel();
let http = self.cfg.http.clone();
std::thread::spawn(move || {
let _ = tx.send(http(&req));
});
let (status, bytes) = loop {
if self.cancel.load(Ordering::Relaxed) {
return Err("cancelled by the user".into());
}
match rx.recv_timeout(CANCEL_POLL) {
Ok(r) => break r?,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => return Err("the request ended without an answer".into()),
}
};
let v: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
if status != 200 {
return Err(http_error(status, &v));
@@ -413,6 +481,32 @@ fn tool_title(name: &str, args: &Value) -> String {
}
}
/// `GET {base_url}/models`: the model ids, in the order the endpoint lists them.
fn model_ids(http: &HttpFn, base_url: &str, headers: Vec<(String, String)>) -> Result<Vec<String>, String> {
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));
}
Ok(v.get("data")
.and_then(Value::as_array)
.map(|a| a.iter().take(500).filter_map(|m| m.get("id").and_then(Value::as_str).map(String::from)).collect())
.unwrap_or_default())
}
/// The endpoint's models for the picker.
pub fn model_list(http: &HttpFn, base_url: &str, key: Option<&str>) -> Result<Vec<ModelInfo>, 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}")));
}
Ok(model_ids(http, base_url, headers)?.into_iter().map(|id| ModelInfo { name: id.clone(), id, description: String::new() }).collect())
}
/// 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();
@@ -422,14 +516,8 @@ pub fn test_endpoint(http: &HttpFn, base_url: &str, key: Option<&str>, model: &s
}
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 ids = model_ids(http, base_url, headers)?;
let ids: Vec<&str> = ids.iter().map(String::as_str).collect();
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)]))
}
@@ -469,6 +557,9 @@ mod tests {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
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()))
@@ -494,6 +585,138 @@ mod tests {
b.shutdown(true);
}
/// Cancel while a model request is in flight ends the turn at once (the request is
/// abandoned, its late answer dropped), leaves no half-finished round in the history and
/// the next message goes through.
#[test]
fn cancelling_a_request_ends_the_turn_and_the_next_message_still_works() {
let (release_tx, release_rx) = channel::<()>();
let release_rx = std::sync::Mutex::new(release_rx);
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
let n = {
let mut l = log.lock().unwrap();
l.push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
l.len()
};
if n == 1 {
// The first request hangs until the test lets it go, then answers late.
let _ = release_rx.lock().unwrap().recv_timeout(Duration::from_secs(20));
return Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Too late."}}]}).to_string().into_bytes()));
}
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "Steered."}}]}).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();
let wait = |b: &mut ApiBackend, events: &mut Vec<Event>| {
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));
}
};
b.prompt(&[PromptPart::Text("long job".into())]);
let start = std::time::Instant::now();
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(10));
}
b.cancel();
let mut events = Vec::new();
wait(&mut b, &mut events);
assert!(start.elapsed() < Duration::from_secs(8), "the turn ended while the request still hung");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason == "cancelled"), "{events:?}");
assert!(!events.iter().any(|e| matches!(e, Event::Text { .. } | Event::Error(_))), "{events:?}");
// The next message is answered, and the late answer of the first never shows up.
b.prompt(&[PromptPart::Text("[interrupted] do this instead".into())]);
let mut events = Vec::new();
wait(&mut b, &mut events);
let _ = release_tx.send(());
assert!(events.iter().any(|e| matches!(e, Event::Text { text, .. } if text == "Steered.")), "{events:?}");
assert!(matches!(events.last(), Some(Event::TurnEnded { reason }) if reason.is_empty()), "{events:?}");
let sent = seen.lock().unwrap();
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
assert_eq!(roles, ["system", "user", "user"], "no reply or tool call of the cancelled round is left");
b.shutdown(true);
}
/// New chat resets the model's history to the system note: the next request carries nothing
/// of the conversation before it.
#[test]
fn a_new_chat_resets_the_history_to_the_system_note() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).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();
let run = |b: &mut ApiBackend, f: &dyn Fn(&mut ApiBackend), want: usize| {
f(b);
let mut events = Vec::new();
let start = std::time::Instant::now();
while events.iter().filter(|e| matches!(e, Event::TurnEnded { .. } | Event::Ready { .. })).count() < want
&& start.elapsed() < Duration::from_secs(10)
{
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
};
run(&mut b, &|b| b.prompt(&[PromptPart::Text("one".into())]), 2);
run(&mut b, &|b| b.new_session(), 1);
run(&mut b, &|b| b.prompt(&[PromptPart::Text("two".into())]), 1);
let sent = seen.lock().unwrap();
let roles: Vec<&str> = sent[1]["messages"].as_array().unwrap().iter().map(|m| m["role"].as_str().unwrap_or("")).collect();
assert_eq!(roles, ["system", "user"]);
assert_eq!(sent[1]["messages"][1]["content"], "two");
b.shutdown(true);
}
/// The endpoint's `/models` fills the picker (with the current model first if it is not
/// listed), and choosing one changes the model of the next request.
#[test]
fn the_endpoint_lists_models_and_a_choice_changes_the_next_request() {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
assert!(r.url.ends_with("/models"));
return Ok((200, json!({"data": [{"id": "big"}, {"id": "small"}]}).to_string().into_bytes()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, json!({"choices": [{"message": {"role": "assistant", "content": "ok"}}]}).to_string().into_bytes()))
});
let cfg = ApiConfig { name: "Test".into(), base_url: "http://localhost:1/v1".into(), model: "local".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();
let mut events = Vec::new();
let start = std::time::Instant::now();
while !events.iter().any(|e| matches!(e, Event::Models { .. })) && start.elapsed() < Duration::from_secs(10) {
events.extend(b.poll());
std::thread::sleep(Duration::from_millis(10));
}
let Some(Event::Models { current, available }) = events.iter().find(|e| matches!(e, Event::Models { .. })) else { panic!("{events:?}") };
assert_eq!(current, "local");
assert_eq!(available.iter().map(|m| m.id.as_str()).collect::<Vec<_>>(), ["local", "big", "small"]);
b.set_model("big");
b.prompt(&[PromptPart::Text("hi".into())]);
let start = std::time::Instant::now();
while seen.lock().unwrap().is_empty() && start.elapsed() < Duration::from_secs(10) {
std::thread::sleep(Duration::from_millis(10));
}
assert_eq!(seen.lock().unwrap()[0]["model"], "big");
b.shutdown(true);
}
#[test]
fn tool_arguments_are_an_object_or_an_error() {
assert_eq!(tool_arguments(None), Ok(json!({})));
@@ -515,6 +738,9 @@ mod tests {
let seen = Arc::new(std::sync::Mutex::new(Vec::<Value>::new()));
let log = seen.clone();
let http: HttpFn = Arc::new(move |r: &HttpRequest| {
if r.method == "GET" {
return Ok((200, b"{\"data\":[]}".to_vec()));
}
log.lock().unwrap().push(serde_json::from_slice(r.body.as_deref().unwrap_or(b"null")).unwrap());
Ok((200, answers.lock().unwrap().pop().unwrap_or(json!({})).to_string().into_bytes()))
});
+7
View File
@@ -40,6 +40,8 @@ pub enum Event {
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)> },
/// Its models (empty `available`: only the current one changed).
Models { current: String, available: Vec<super::acp::ModelInfo> },
/// 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.
@@ -69,10 +71,15 @@ pub trait ChatBackend {
fn prompt(&mut self, parts: &[PromptPart]);
/// Stop the message being answered; open permission questions count as cancelled.
fn cancel(&mut self);
/// Start a fresh conversation on the running backend (no restart); [`Event::Ready`] follows.
/// A message being answered is cancelled first.
fn new_session(&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);
/// Use one of the models it reported in [`Event::Models`].
fn set_model(&mut self, _model: &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.
+259 -7
View File
@@ -145,6 +145,13 @@ struct Turn {
label: String,
}
/// A message typed while the agent works: shown in the transcript as "Queued" until it is sent.
#[derive(Clone, Debug, PartialEq)]
pub struct Pending {
pub text: String,
pub image: bool,
}
/// A request held for Allow or Deny.
struct Held {
id: u64,
@@ -171,6 +178,25 @@ pub struct AgentChat {
auth_status: Option<String>,
/// A message typed before the agent was ready.
queued: Option<(String, bool)>,
/// Messages typed while the agent works, in order. They go as one prompt when the turn ends
/// (or at once with Send now); after Stop they wait for Send.
pub pending: Vec<Pending>,
/// The queued message being edited, and its text so far.
pub pending_edit: Option<(usize, String)>,
/// Send now was pressed: the queue goes as soon as the cancelled turn has ended.
steering: bool,
/// The turn was stopped: its queue is not sent by itself.
stopped: bool,
/// Why the last turn ended.
ended: Option<String>,
/// New chat while a message ran: its late end is not reported.
discard_end: bool,
/// The mode to put back when the fresh session is ready.
restore_mode: Option<String>,
/// New chat is waiting for the user's yes (a message is running or queued).
pub confirm_new: bool,
/// Where the panel was drawn last (for its shortcuts).
pub panel_rect: Option<egui::Rect>,
/// The last message sent, kept to send again after a sign-in.
last: Option<(String, bool)>,
/// The command the running agent was started with.
@@ -182,6 +208,13 @@ pub struct AgentChat {
/// The agent's own modes, and the current one.
modes: Vec<(String, String)>,
mode: String,
/// The models the agent offers (none: no picker), and the current one.
pub models: Vec<acp::ModelInfo>,
pub model: String,
/// An API endpoint's models, kept per endpoint (its base URL) for the next connection.
models_cache: std::collections::HashMap<String, Vec<acp::ModelInfo>>,
/// The running API agent's endpoint (empty for other agents).
running_endpoint: String,
backend: Option<Box<dyn ChatBackend>>,
turn: Option<Turn>,
held: Vec<Held>,
@@ -227,6 +260,7 @@ impl AgentChat {
}
fn stop_backend(&mut self, wait: bool) {
self.steering = false;
if let Some(mut b) = self.backend.take() {
b.cancel();
b.shutdown(wait);
@@ -275,6 +309,14 @@ impl AgentChat {
self.images = images;
self.state = Some(State::Ready);
self.picking = Some(false);
// Keep the mode across a new chat.
if let Some(m) = self.restore_mode.take()
&& self.modes.iter().any(|(id, _)| *id == m)
&& let Some(b) = self.backend.as_mut()
{
b.set_mode(&m);
self.mode = m;
}
}
Event::Text { thought, text } => {
if let Some(Item::Agent { thought: t, text: last }) = self.items.last_mut()
@@ -320,6 +362,15 @@ impl AgentChat {
}
self.mode = current;
}
Event::Models { current, available } => {
if !available.is_empty() {
if !self.running_endpoint.is_empty() {
self.models_cache.insert(self.running_endpoint.clone(), available.clone());
}
self.models = available;
}
self.model = current;
}
Event::AuthRequired { methods } => {
self.auth = methods;
self.state = Some(State::NeedsAuth);
@@ -334,6 +385,10 @@ impl AgentChat {
if self.state() == State::Busy {
self.state = Some(State::Ready);
}
self.ended = Some(reason.clone());
if std::mem::take(&mut self.discard_end) {
return Some(Outcome::TurnEnd);
}
if !matches!(reason.as_str(), "" | "auth" | "error") {
self.notice(format!("({reason})"), false);
}
@@ -347,6 +402,7 @@ impl AgentChat {
}
Event::Exited { tail } => {
self.state = Some(State::Exited);
self.steering = false;
let last: Vec<String> = tail.into_iter().rev().take(6).collect::<Vec<_>>().into_iter().rev().collect();
let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) };
self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true);
@@ -468,6 +524,14 @@ pub fn connect(app: &mut SolveApp, ctx: &egui::Context) {
chat.running_id = entry.id.clone();
chat.modes.clear();
chat.mode.clear();
chat.models.clear();
chat.model.clear();
chat.running_endpoint = if entry.kind == agents::Kind::Api { entry.base_url.clone() } else { String::new() };
// An endpoint's models from the last time, until the new list arrives.
if let Some(list) = chat.models_cache.get(&chat.running_endpoint) {
chat.models = list.clone();
chat.model = entry.model.clone();
}
chat.state = Some(State::Starting);
chat.auth.clear();
chat.auth_status = None;
@@ -496,17 +560,44 @@ pub fn shutdown(app: &mut SolveApp) {
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).
/// Most messages waiting in the queue.
const MAX_PENDING: usize = 20;
/// Said to the agent before a message that interrupted it, so it knows its last step may be
/// incomplete.
const INTERRUPT_NOTE: &str = "[The user interrupted your previous step.]";
/// Send what the user typed: now if the agent is ready, queued behind the running message if it
/// is working, else once it is ready (starting it if needed).
pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
let text: String = text.chars().take(MAX_PROMPT).collect();
if text.trim().is_empty() {
return;
}
let image = app.agent_chat.attach_view && app.agent_chat.images;
if app.agent_chat.state() == State::Busy {
let chat = &mut app.agent_chat;
if chat.pending.len() >= MAX_PENDING {
chat.notice(tr("ui.agent_chat_queue_full"), false);
} else {
chat.pending.push(Pending { text, image });
}
return;
}
// Messages left waiting by Stop go first, in one prompt with this one.
if !app.agent_chat.pending.is_empty() {
app.agent_chat.pending.push(Pending { text, image });
send_queue(app, ctx, false);
return;
}
submit(app, ctx, text, image, false);
}
/// Put `text` in the conversation and send it (`interrupted`: the agent is told it cut in on its
/// previous step).
fn submit(app: &mut SolveApp, ctx: &egui::Context, text: String, image: bool, interrupted: bool) {
app.agent_chat.push(Item::User { text: text.clone(), image });
match app.agent_chat.state() {
State::Ready => start_turn(app, text, image),
State::Busy => app.agent_chat.notice(tr("ui.agent_chat_wait_turn"), false),
State::Ready => start_turn_with(app, text, image, interrupted.then_some(INTERRUPT_NOTE)),
s => {
app.agent_chat.queued = Some((text, image));
#[cfg(not(target_arch = "wasm32"))]
@@ -519,8 +610,105 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) {
}
}
/// Send the queued messages as one prompt, in order.
fn send_queue(app: &mut SolveApp, ctx: &egui::Context, interrupted: bool) {
let chat = &mut app.agent_chat;
chat.pending_edit = None;
if chat.pending.is_empty() {
return;
}
let all = std::mem::take(&mut chat.pending);
let image = all.iter().any(|p| p.image);
let text = all.into_iter().map(|p| p.text).collect::<Vec<_>>().join("\n\n");
let text: String = text.chars().take(MAX_PROMPT).collect();
submit(app, ctx, text, image, interrupted);
}
/// Send now: stop the running step and send the queue as the next prompt once the agent has said
/// it stopped (its note tells the agent it was interrupted). With nothing running, sends the queue.
pub fn send_now(app: &mut SolveApp, ctx: &egui::Context) {
if app.agent_chat.pending.is_empty() {
return;
}
if app.agent_chat.state() == State::Busy {
app.agent_chat.steering = true;
app.agent_chat.stopped = false;
interrupt(app);
} else {
send_queue(app, ctx, false);
}
}
/// New chat: stop what runs (as Stop does), clear the conversation and the queue, and start a
/// fresh session on the same agent (no restart); the agent and its mode stay, the design's undo
/// history is untouched.
pub fn new_chat(app: &mut SolveApp) {
let running = app.agent_chat.state() == State::Busy;
if running {
cancel(app);
}
decline_all(app, "the chat was cleared");
end_turn(app);
let chat = &mut app.agent_chat;
chat.confirm_new = false;
chat.items.clear();
chat.pending.clear();
chat.pending_edit = None;
chat.queued = None;
chat.last = None;
chat.steering = false;
chat.stopped = false;
chat.ended = None;
chat.discard_end = running;
if matches!(chat.state(), State::Ready | State::Busy) {
chat.restore_mode = Some(chat.mode.clone()).filter(|m| !m.is_empty());
chat.state = Some(State::Starting);
if let Some(b) = chat.backend.as_mut() {
b.new_session();
}
}
}
/// Whether New chat would throw something away that the user may want (so it asks first).
pub fn new_chat_needs_confirm(app: &SolveApp) -> bool {
app.agent_chat.state() == State::Busy || !app.agent_chat.pending.is_empty()
}
/// Take a queued message out of the queue.
pub fn remove_pending(app: &mut SolveApp, index: usize) {
let chat = &mut app.agent_chat;
if index < chat.pending.len() {
chat.pending.remove(index);
}
chat.pending_edit = None;
}
/// The turn ended: the queue goes on its own after a normal end, and after a Send now; not after
/// Stop, a sign-in request or an error.
fn after_turn(app: &mut SolveApp, ctx: &egui::Context) {
let reason = app.agent_chat.ended.take().unwrap_or_default();
let steering = std::mem::take(&mut app.agent_chat.steering);
if app.agent_chat.state() != State::Ready || app.agent_chat.pending.is_empty() {
return;
}
if steering && !matches!(reason.as_str(), "auth" | "error") {
send_queue(app, ctx, reason == "cancelled");
} else if !app.agent_chat.stopped && reason.is_empty() {
send_queue(app, ctx, false);
}
}
fn start_turn(app: &mut SolveApp, text: String, image: bool) {
let mut parts = vec![PromptPart::Text(text.clone())];
start_turn_with(app, text, image, None);
}
/// Start a message; `note` goes before the text for the agent, not into the conversation.
fn start_turn_with(app: &mut SolveApp, text: String, image: bool, note: Option<&str>) {
let sent = match note {
Some(n) => format!("{n}\n\n{text}"),
None => text.clone(),
};
let mut parts = vec![PromptPart::Text(sent)];
if image {
match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) {
Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }),
@@ -532,13 +720,24 @@ fn start_turn(app: &mut SolveApp, text: String, image: bool) {
Some(Turn { start_doc: app.session.doc.clone(), start_undo: app.session.undo.len(), changes: 0, requests: 0, limit_told: false, label });
app.agent_chat.last = Some((text, image));
app.agent_chat.state = Some(State::Busy);
app.agent_chat.stopped = false;
app.agent_chat.steering = false;
app.agent_chat.ended = None;
if let Some(b) = app.agent_chat.backend.as_mut() {
b.prompt(&parts);
}
}
/// Ask the agent to stop the message it is on; requests waiting for approval are declined.
/// Stop: ask the agent to stop the message it is on; requests waiting for approval are declined.
/// Messages queued meanwhile stay queued (they wait for Send).
pub fn cancel(app: &mut SolveApp) {
app.agent_chat.steering = false;
app.agent_chat.stopped = true;
interrupt(app);
}
/// Ask the agent to stop the message it is on and decline what waits for approval.
fn interrupt(app: &mut SolveApp) {
decline_all(app, "the user stopped the message");
if let Some(b) = app.agent_chat.backend.as_mut() {
b.cancel();
@@ -775,10 +974,16 @@ pub fn poll(app: &mut SolveApp, ctx: &egui::Context) {
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::TurnEnd) => {
end_turn(app);
after_turn(app, ctx);
}
Some(Outcome::Permission { key, title, options }) => on_permission(app, key, title, options),
None => {}
}
if ready {
apply_saved_model(app);
}
if ready && let Some((text, image)) = app.agent_chat.queued.take() {
start_turn(app, text, image);
}
@@ -804,6 +1009,37 @@ pub fn refresh(app: &mut SolveApp, ctx: &egui::Context) {
app.agent_chat.detecting = Some(rx);
}
/// Choose the model for this chat (the header's model picker): the agent switches to it, and it is
/// remembered for this agent; an API endpoint's agent also keeps it as its default model.
pub fn set_model(app: &mut SolveApp, model: &str) {
if !app.agent_chat.models.iter().any(|m| m.id == model) {
return;
}
if let Some(b) = app.agent_chat.backend.as_mut() {
b.set_model(model);
}
app.agent_chat.model = model.to_string();
let agent = app.agent_chat.running_id.clone();
if let Some(a) = app.preferences.agents.iter_mut().find(|a| a.id == agent && a.kind == agents::Kind::Api) {
a.model = model.to_string();
}
app.preferences.agent_models.insert(agent, model.to_string());
}
/// A new session is ready: put back the model last chosen for this agent, when it offers it.
fn apply_saved_model(app: &mut SolveApp) {
let chat = &app.agent_chat;
let Some(saved) = app.preferences.agent_models.get(&chat.running_id).cloned() else { return };
if saved != chat.model && chat.models.iter().any(|m| m.id == saved) {
set_model(app, &saved);
}
}
/// Whether the model picker shows: only for an agent that reports models.
pub fn models_shown(app: &SolveApp) -> bool {
!app.agent_chat.models.is_empty() && app.agent_chat.state().running()
}
/// Switch the agent to one of its own modes (the header's mode picker).
pub fn set_mode(app: &mut SolveApp, mode: &str) {
if app.agent_chat.modes.iter().any(|(id, _)| id == mode)
@@ -817,7 +1053,7 @@ pub fn set_mode(app: &mut SolveApp, mode: &str) {
/// `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.
/// request), `cancel` (Stop), `send_now` (steer: stop, then send the queue), `remove_pending` (index), `undo` (the newest message's changes); returns the panel's state.
pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Value, String> {
if let Some(e) = p.get("features").and_then(Value::as_bool) {
app.preferences.ai_features = e;
@@ -875,6 +1111,9 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if let Some(m) = p.get("mode").and_then(Value::as_str) {
set_mode(app, m);
}
if let Some(m) = p.get("model").and_then(Value::as_str) {
set_model(app, m);
}
if !shown(app) {
return Ok(status(app));
}
@@ -932,6 +1171,15 @@ pub fn control(app: &mut SolveApp, ctx: &egui::Context, p: &Value) -> Result<Val
if p.get("cancel").and_then(Value::as_bool) == Some(true) {
cancel(app);
}
if p.get("new_chat").and_then(Value::as_bool) == Some(true) {
new_chat(app);
}
if p.get("send_now").and_then(Value::as_bool) == Some(true) {
send_now(app, ctx);
}
if let Some(n) = p.get("remove_pending").and_then(Value::as_u64) {
remove_pending(app, n as usize);
}
if p.get("undo").and_then(Value::as_bool) == Some(true) {
let Some(n) = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { .. })) else { return Err("no changes to undo".into()) };
undo_changes(app, n);
@@ -976,6 +1224,8 @@ pub fn status(app: &SolveApp) -> Value {
"permissions": app.preferences.ai_permissions.id(),
"agents": agents::entries(app).iter().map(|e| json!({"id": e.id, "name": e.name, "kind": if e.kind == agents::Kind::Api { "api" } else { "acp" }, "found": e.found, "version": e.version, "login": e.login})).collect::<Vec<_>>(),
"currentAgent": agents::current(app).map(|e| e.id),
"models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::<Vec<_>>(),
"model": c.model,
"modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::<Vec<_>>(),
"mode": c.mode,
"open": c.open,
@@ -990,6 +1240,8 @@ pub fn status(app: &SolveApp) -> Value {
"toolsPort": c.tools_port,
"pid": pid,
"waiting": c.waiting(),
"pending": c.pending.iter().map(|p| json!({"text": p.text, "image": p.image})).collect::<Vec<_>>(),
"stopped": c.stopped,
"turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})),
"detection": detection,
"items": items,
+188 -7
View File
@@ -58,6 +58,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
if ctx.input_mut(|i| i.consume_shortcut(&SHORTCUT)) {
toggle(app);
}
// New chat, when the chat has focus (its input, or the pointer over the panel).
let in_chat = ctx.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")))
|| ctx.pointer_hover_pos().is_some_and(|p| app.agent_chat.panel_rect.is_some_and(|r| r.contains(p)));
if app.agent_chat.open && in_chat && ctx.input_mut(|i| i.consume_shortcut(&NEW_CHAT_SHORTCUT)) {
request_new_chat(app);
}
if !app.agent_chat.open {
return;
}
@@ -81,13 +87,27 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
.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);
app.agent_chat.panel_rect = Some(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`.
/// New chat (when the chat has focus).
pub const NEW_CHAT_SHORTCUT: egui::KeyboardShortcut =
egui::KeyboardShortcut::new(egui::Modifiers::COMMAND.plus(egui::Modifiers::SHIFT), egui::Key::L);
/// Clear the chat, asking first when something would be lost.
fn request_new_chat(app: &mut SolveApp) {
if new_chat_needs_confirm(app) {
app.agent_chat.confirm_new = true;
} else {
new_chat(app);
}
}
/// A small drawn button: `kind` is `minimize`, `close`, `stopturn` 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());
@@ -104,6 +124,15 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
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);
}
"newchat" => {
// A speech bubble with a plus.
ui.painter().rect_stroke(Rect::from_center_size(c, vec2(14.0, 11.0)), 2.5, s, egui::StrokeKind::Inside);
ui.painter().line_segment([c + vec2(-2.5, 0.0), c + vec2(2.5, 0.0)], Stroke::new(1.2, t.text));
ui.painter().line_segment([c + vec2(0.0, -2.5), c + vec2(0.0, 2.5)], Stroke::new(1.2, t.text));
}
"stopturn" => {
ui.painter().rect_filled(Rect::from_center_size(c, vec2(9.0, 9.0)), 1.5, t.error);
}
_ => {}
}
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
@@ -185,6 +214,16 @@ fn header(app: &mut SolveApp, ui: &mut egui::Ui, left: bool) {
app.preferences.agent_chat_minimized = true;
}
#[cfg(not(target_arch = "wasm32"))]
if glyph_button(
ui,
"newchat",
&format!("{} ({})", tr("ui.agent_chat_new"), if cfg!(target_os = "macos") { "Cmd+Shift+L" } else { "Ctrl+Shift+L" }),
)
.clicked()
{
request_new_chat(app);
}
#[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"))
@@ -261,6 +300,23 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
refresh(app, ui.ctx());
}
let state = app.agent_chat.state();
if app.agent_chat.confirm_new {
let t = Tokens::get();
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.label(tr("ui.agent_chat_new_confirm"));
ui.horizontal(|ui| {
let yes = ui.button(tr("ui.agent_chat_new_yes"));
crate::scenario::publish_handle("agent_chat:new_confirm", yes.rect.center());
if yes.clicked() {
new_chat(app);
}
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
app.agent_chat.confirm_new = false;
}
});
});
}
picker(app, ui);
if state == State::NeedsAuth {
auth_box(app, ui);
@@ -275,21 +331,38 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::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"))
.hint_text(tr(if busy { "ui.agent_chat_placeholder_busy" } else { "ui.agent_chat_placeholder" }))
.desired_rows(2)
.desired_width(f32::INFINITY)
.id_salt("sc_agent_chat_input");
// Enter sends (queues while the agent works); Ctrl/Cmd+Enter sends now; Esc stops. They are
// read before the field sees them so no newline is typed.
let focused = ui.memory(|m| m.has_focus(Id::new("sc_agent_chat_input")));
let (steer, esc) = if focused {
ui.input_mut(|i| (i.consume_key(egui::Modifiers::COMMAND, egui::Key::Enter), busy && i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)))
} else {
(false, false)
};
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);
if esc {
cancel(app);
}
ui.horizontal(|ui| {
let send_b = ui.add_enabled(!busy, egui::Button::new(tr("ui.agent_chat_send")));
let send_b = ui.button(tr(if busy { "ui.agent_chat_queue" } else { "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() {
if (send_b.clicked() || enter || steer) && !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 steer {
send_now(app, ui.ctx());
}
} else if steer {
// Nothing typed: Ctrl+Enter sends what is already queued.
send_now(app, ui.ctx());
}
if busy && ui.button(tr("ui.agent_chat_cancel")).clicked() {
if busy && glyph_button(ui, "stopturn", tr("ui.agent_chat_stop_turn_tip")).clicked() {
cancel(app);
}
// A picture of the view goes with the message to agents that take pictures.
@@ -350,7 +423,9 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|| 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 models = models_shown(app);
let w =
(ui.available_width() - if app.agent_chat.modes.is_empty() { 4.0 } else { 116.0 } - if models { 146.0 } else { 0.0 }).clamp(90.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| {
@@ -405,6 +480,27 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
set_mode(app, &m);
}
}
// The agent's models (ACP `models`, or an endpoint's list): shown only when it reports some.
if models {
let list = app.agent_chat.models.clone();
let cur = list.iter().find(|m| m.id == app.agent_chat.model).map_or_else(|| app.agent_chat.model.clone(), |m| m.name.clone());
let mut pick: Option<String> = None;
let r = egui::ComboBox::from_id_salt("sc_agent_model").width(140.0).truncate().selected_text(cur).show_ui(ui, |ui| {
for m in &list {
let row = ui.selectable_label(m.id == app.agent_chat.model, &m.name);
let row = if m.description.is_empty() { row } else { row.on_hover_text(&m.description) };
crate::scenario::publish_handle(&format!("agent_chat:model:{}", m.id), row.rect.center());
if row.clicked() {
pick = Some(m.id.clone());
}
}
});
crate::scenario::publish_handle("agent_chat:model", r.response.rect.center());
r.response.on_hover_text(tr("ui.agent_chat_model_tip"));
if let Some(m) = pick {
set_model(app, &m);
}
}
});
if let Some(a) = &app.agent_chat.auth_status {
ui.label(RichText::new(a).color(t.text_dim).size(11.0));
@@ -641,12 +737,97 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
}
ui.add_space(2.0);
}
if app.agent_chat.items.is_empty() {
let (mut send_now_clicked, mut send_queue_clicked) = (false, false);
let mut remove: Option<usize> = None;
let mut edit_done: Option<(usize, Option<String>)> = None;
let busy = app.agent_chat.state() == State::Busy;
let stopped = app.agent_chat.stopped;
let editing = app.agent_chat.pending_edit.clone();
let mut start_edit: Option<(usize, String)> = None;
let mut edit_buf = editing.as_ref().map(|(_, b)| b.clone());
for (n, p) in app.agent_chat.pending.iter().enumerate() {
ui.add_space(6.0);
egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.accent)).corner_radius(6.0).inner_margin(egui::Margin::same(6)).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
ui.label(RichText::new(tr(tag)).color(t.accent).strong().size(11.0));
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
let x = ui.add(egui::Button::new(RichText::new("×").size(14.0)).frame(false)).on_hover_text(tr("ui.agent_chat_remove"));
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
if x.clicked() {
remove = Some(n);
}
if editing.as_ref().is_none_or(|(i, _)| *i != n) {
let e = ui.add(egui::Button::new(RichText::new(tr("ui.agent_chat_edit")).size(11.5)).frame(false));
crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center());
if e.clicked() {
start_edit = Some((n, p.text.clone()));
}
}
});
});
match (&editing, edit_buf.as_mut()) {
(Some((i, _)), Some(buf)) if *i == n => {
let te = ui.add(egui::TextEdit::multiline(buf).desired_rows(2).desired_width(f32::INFINITY));
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
ui.horizontal(|ui| {
if ui.button(tr("ui.agent_chat_save")).clicked() {
edit_done = Some((n, Some(buf.clone())));
}
if ui.button(tr("ui.agent_chat_cancel")).clicked() {
edit_done = Some((n, None));
}
});
}
_ => {
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
if p.image {
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(11.0));
}
}
}
});
}
if !app.agent_chat.pending.is_empty() && editing.is_none() {
ui.horizontal(|ui| {
if busy {
let b = ui.button(tr("ui.agent_chat_send_now")).on_hover_text(tr("ui.agent_chat_send_now_tip"));
crate::scenario::publish_handle("agent_chat:send_now", b.rect.center());
send_now_clicked = b.clicked();
} else {
let b = ui.add(egui::Button::new(tr("ui.agent_chat_send")).fill(t.accent_soft));
crate::scenario::publish_handle("agent_chat:send_queue", b.rect.center());
send_queue_clicked = b.clicked();
}
});
}
if let Some(start) = start_edit {
app.agent_chat.pending_edit = Some(start);
} else if let (Some(buf), Some(slot)) = (edit_buf, app.agent_chat.pending_edit.as_mut()) {
slot.1 = buf;
}
if app.agent_chat.items.is_empty() && app.agent_chat.pending.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, text)) = edit_done {
if let (Some(text), Some(p)) = (text.filter(|t| !t.trim().is_empty()), app.agent_chat.pending.get_mut(n)) {
p.text = text;
}
app.agent_chat.pending_edit = None;
}
if let Some(n) = remove {
remove_pending(app, n);
}
if send_now_clicked {
send_now(app, ui.ctx());
}
if send_queue_clicked {
send_now(app, ui.ctx());
}
if let Some((n, o)) = answer {
answer_permission(app, n, o.as_deref());
}
+453
View File
@@ -303,3 +303,456 @@ fn the_agents_own_questions_are_answered_by_the_mode_or_asked() {
on_permission(&mut app, 3, "Run ls".into(), opts());
assert!(matches!(app.agent_chat.items.last(), Some(Item::Notice { .. })), "remembered");
}
// ---- Messages typed while the agent works ----
use std::sync::Mutex;
/// A backend that records what the panel asks of it, and reports what the test pushes.
#[derive(Clone, Default)]
struct Script {
calls: Arc<Mutex<Vec<String>>>,
events: Arc<Mutex<Vec<Event>>>,
}
impl Script {
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn report(&self, e: Event) {
self.events.lock().unwrap().push(e);
}
}
impl ChatBackend for Script {
fn poll(&mut self) -> Vec<Event> {
std::mem::take(&mut *self.events.lock().unwrap())
}
fn prompt(&mut self, parts: &[PromptPart]) {
let text = parts.iter().find_map(|p| if let PromptPart::Text(t) = p { Some(t.clone()) } else { None }).unwrap_or_default();
self.calls.lock().unwrap().push(format!("prompt: {text}"));
}
fn cancel(&mut self) {
self.calls.lock().unwrap().push("cancel".into());
}
fn new_session(&mut self) {
self.calls.lock().unwrap().push("new_session".into());
}
fn set_model(&mut self, model: &str) {
self.calls.lock().unwrap().push(format!("set_model: {model}"));
}
fn answer_permission(&mut self, _: u64, _: Option<&str>) {}
fn authenticate(&mut self, _: &str) {}
fn shutdown(&mut self, _: bool) {}
}
/// An app whose agent is scripted and ready.
fn app_with_script() -> (SolveApp, Script, egui::Context) {
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
let script = Script::default();
app.agent_chat.backend = Some(Box::new(script.clone()));
app.agent_chat.state = Some(State::Ready);
(app, script, egui::Context::default())
}
fn ended(script: &Script, reason: &str) {
script.report(Event::TurnEnded { reason: reason.into() });
}
fn texts(app: &SolveApp) -> Vec<String> {
app.agent_chat.pending.iter().map(|p| p.text.clone()).collect()
}
/// While the agent works a message is queued, not dropped and not sent; it is not in the
/// transcript as a sent message, but waits as a pending one that can be edited and removed.
#[test]
fn a_message_typed_while_busy_is_queued() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "make a box".into());
assert_eq!(app.agent_chat.state(), State::Busy);
send(&mut app, &ctx, "then a cylinder".into());
send(&mut app, &ctx, " ".into());
send(&mut app, &ctx, "and a sphere".into());
assert_eq!(texts(&app), ["then a cylinder", "and a sphere"]);
assert_eq!(script.calls(), ["prompt: make a box"], "nothing else was sent");
let users = app.agent_chat.items.iter().filter(|i| matches!(i, Item::User { .. })).count();
assert_eq!(users, 1, "queued messages are not sent messages yet");
// Remove one; edit the other.
remove_pending(&mut app, 0);
assert_eq!(texts(&app), ["and a sphere"]);
app.agent_chat.pending[0].text = "and a cone".into();
assert_eq!(app.agent_chat.waiting(), 0);
assert!(status(&app)["pending"][0]["text"] == "and a cone");
}
/// When the turn ends normally the queue goes as the next prompt, all messages in order in one.
#[test]
fn the_queue_is_sent_when_the_turn_ends() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "first".into());
send(&mut app, &ctx, "second".into());
send(&mut app, &ctx, "third".into());
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: first", "prompt: second\n\nthird"]);
assert!(app.agent_chat.pending.is_empty());
assert_eq!(app.agent_chat.state(), State::Busy, "the next turn is under way");
assert!(matches!(app.agent_chat.items.last(), Some(Item::User { text, .. }) if text == "second\n\nthird"));
// Nothing queued: the end of the turn just ends it.
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.state(), State::Ready);
assert_eq!(script.calls().len(), 2);
}
/// Stop does not send the queue by itself; it stays, with a Send that sends it later.
#[test]
fn stop_keeps_the_queue() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "first".into());
send(&mut app, &ctx, "later".into());
cancel(&mut app);
assert_eq!(script.calls(), ["prompt: first", "cancel"]);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.state(), State::Ready);
assert_eq!(texts(&app), ["later"], "still queued");
assert_eq!(script.calls().len(), 2, "not sent");
// Send (the bubble's button) sends it, without the interruption note.
send_now(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: first", "cancel", "prompt: later"]);
assert!(app.agent_chat.pending.is_empty());
}
/// Send now: cancel first, the prompt only after the agent has ended the cancelled turn, with a
/// note that the previous step was interrupted (shown without it in the conversation).
#[test]
fn send_now_cancels_then_sends_with_a_note() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "build a gearbox".into());
send(&mut app, &ctx, "use 12 teeth instead".into());
send_now(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: build a gearbox", "cancel"], "cancel alone, the prompt waits for the end");
poll(&mut app, &ctx);
assert_eq!(script.calls().len(), 2);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(script.calls(), ["prompt: build a gearbox".to_string(), "cancel".into(), format!("prompt: {INTERRUPT_NOTE}\n\nuse 12 teeth instead")]);
assert!(matches!(app.agent_chat.items.last(), Some(Item::User { text, .. }) if text == "use 12 teeth instead"));
// Stop after Send now wins: nothing more is sent by itself.
send(&mut app, &ctx, "one more".into());
send_now(&mut app, &ctx);
cancel(&mut app);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(texts(&app), ["one more"]);
}
/// The steered message also goes after a turn that ended by itself just before the cancel arrived.
#[test]
fn send_now_after_the_turn_already_ended_still_sends() {
let (mut app, script, ctx) = app_with_script();
send(&mut app, &ctx, "a".into());
send(&mut app, &ctx, "b".into());
send_now(&mut app, &ctx);
ended(&script, "");
poll(&mut app, &ctx);
assert_eq!(script.calls().last().map(String::as_str), Some("prompt: b"), "no note: nothing was interrupted");
}
/// What an interrupted turn changed undoes as one step, and the steered message starts its own.
#[test]
fn an_interrupted_turns_changes_undo_as_one_step() {
let (mut app, script, ctx) = app_with_script();
let (tx, rx) = channel();
app.agent_chat.tools_rx = Some(rx);
let before = app.session.doc.clone();
let depth = app.session.undo.len();
send(&mut app, &ctx, "two boxes".into());
let replies: Vec<_> = [0.0, 20.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);
}
send(&mut app, &ctx, "stop, make it one".into());
send_now(&mut app, &ctx);
ended(&script, "cancelled");
poll(&mut app, &ctx);
assert_eq!(app.session.undo.len(), depth + 1, "the interrupted message is one step");
let n = app.agent_chat.items.iter().rposition(|i| matches!(i, Item::Changes { steps: 2, .. })).expect("its changes are listed");
// The steered message is on its own turn.
assert!(app.agent_chat.turn.as_ref().is_some_and(|t| t.label.contains("stop, make it one") && t.changes == 0));
undo_changes(&mut app, n);
assert!(Arc::ptr_eq(&app.session.doc, &before));
}
/// The same with a real agent process speaking ACP: `session/cancel` goes out, the agent answers
/// the held prompt with the cancelled stop reason, and only then does the queued text go as a new
/// `session/prompt`.
#[cfg(unix)]
#[test]
fn send_now_with_an_acp_agent_sends_cancel_then_the_prompt() {
let dir = std::env::temp_dir().join(format!("sc-agent-chat-steer-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("received.log");
// A fake agent: answers initialize and session/new; holds a prompt whose text says "slow"
// until it gets session/cancel (then answers it "cancelled"); answers other prompts at once.
let script = r#"
log="$1"; held=""
while IFS= read -r line; do
printf '%s\n' "$line" >> "$log"
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":1,"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
*'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1"}}\n' "$id" ;;
*'"method":"session/prompt"'*)
case "$line" in
*slow*) held="$id" ;;
*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" ;;
esac ;;
*'"method":"session/cancel"'*)
if [ -n "$held" ]; then printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"cancelled"}}\n' "$held"; held=""; fi ;;
esac
done
"#;
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap()));
app.agent_chat.state = Some(State::Starting);
let ctx = egui::Context::default();
let until = |app: &mut SolveApp, what: &str, done: &dyn Fn(&SolveApp) -> bool| {
let t = std::time::Instant::now();
while !done(app) {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out waiting for {what}");
poll(app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(10));
}
};
until(&mut app, "the agent to be ready", &|a| a.agent_chat.state() == State::Ready);
send(&mut app, &ctx, "slow job".into());
send(&mut app, &ctx, "do this instead".into());
assert_eq!(texts(&app), ["do this instead"]);
send_now(&mut app, &ctx);
until(&mut app, "the steered turn to finish", &|a| a.agent_chat.state() == State::Ready && a.agent_chat.pending.is_empty());
let methods: Vec<String> = std::fs::read_to_string(&log)
.unwrap()
.lines()
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
.filter_map(|v| {
let m = v["method"].as_str()?.to_string();
let text = v.pointer("/params/prompt/0/text").and_then(Value::as_str).unwrap_or("");
Some(if text.is_empty() { m } else { format!("{m}: {text}") })
})
.collect();
assert_eq!(
methods,
[
"initialize".to_string(),
"session/new".into(),
"session/prompt: slow job".into(),
"session/cancel".into(),
format!("session/prompt: {INTERRUPT_NOTE}\n\ndo this instead"),
]
);
disconnect(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
/// New chat: the running message is stopped, the transcript and the queue are cleared, a fresh
/// session starts on the same agent, the mode stays, and the design's undo history is untouched.
#[test]
fn new_chat_clears_everything_and_keeps_the_agent_and_mode() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.modes = vec![("plan".into(), "Plan".into()), ("code".into(), "Code".into())];
app.agent_chat.mode = "plan".into();
app.agent_chat.agent_id = Some("claude".into());
let depth = app.session.undo.len();
assert!(!new_chat_needs_confirm(&app));
send(&mut app, &ctx, "first".into());
send(&mut app, &ctx, "queued".into());
assert!(new_chat_needs_confirm(&app), "a running message and a queue ask first");
new_chat(&mut app);
assert_eq!(script.calls(), ["prompt: first", "cancel", "new_session"]);
assert!(app.agent_chat.items.is_empty() && app.agent_chat.pending.is_empty());
assert_eq!(app.agent_chat.state(), State::Starting);
assert_eq!(app.agent_chat.agent_id.as_deref(), Some("claude"));
// The cancelled message's late end is not reported; the fresh session is ready again.
ended(&script, "cancelled");
script.report(Event::Modes { current: "code".into(), available: vec![] });
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.state(), State::Ready);
assert!(app.agent_chat.items.is_empty(), "no notice about the old message");
assert_eq!(app.agent_chat.mode, "plan", "the mode is kept");
assert_eq!(app.session.undo.len(), depth);
assert!(!new_chat_needs_confirm(&app));
}
/// With a real ACP agent, New chat sends `session/new` on the same process (no restart).
#[cfg(unix)]
#[test]
fn new_chat_with_an_acp_agent_opens_a_new_session_on_the_same_process() {
let dir = std::env::temp_dir().join(format!("sc-agent-chat-new-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("received.log");
let script = r#"
log="$1"; n=0
while IFS= read -r line; do
printf '%s\n' "$line" >> "$log"
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
*'"method":"session/new"'*) n=$((n+1)); printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s%s"}}\n' "$id" "$n" ;;
*'"method":"session/prompt"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"stopReason":"end_turn"}}\n' "$id" ;;
esac
done
"#;
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap()));
app.agent_chat.state = Some(State::Starting);
let ctx = egui::Context::default();
let until = |app: &mut SolveApp, done: &dyn Fn(&SolveApp) -> bool| {
let t = std::time::Instant::now();
while !done(app) {
assert!(t.elapsed() < std::time::Duration::from_secs(20), "timed out");
poll(app, &ctx);
std::thread::sleep(std::time::Duration::from_millis(10));
}
};
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
send(&mut app, &ctx, "hello".into());
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
assert!(!app.agent_chat.items.is_empty());
let pid = app.agent_chat.backend.as_ref().and_then(|b| b.pid());
new_chat(&mut app);
assert!(app.agent_chat.items.is_empty());
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
assert_eq!(app.agent_chat.backend.as_ref().and_then(|b| b.pid()), pid, "same process");
send(&mut app, &ctx, "again".into());
until(&mut app, &|a| a.agent_chat.state() == State::Ready);
let log_text = std::fs::read_to_string(&log).unwrap();
let methods: Vec<String> =
log_text.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).filter_map(|v| v["method"].as_str().map(String::from)).collect();
assert_eq!(methods, ["initialize", "session/new", "session/prompt", "session/new", "session/prompt"]);
assert!(log_text.lines().last().is_some_and(|l| l.contains("\"s2\"")), "the prompt goes to the new session: {log_text}");
disconnect(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
fn models_reply(current: &str) -> Event {
let m = |id: &str| acp::ModelInfo { id: id.into(), name: id.to_uppercase(), description: String::new() };
Event::Models { current: current.into(), available: vec![m("fast"), m("smart")] }
}
/// Choosing a model tells the agent, shows it as current and remembers it for this agent; a model
/// the agent did not offer is ignored.
#[test]
fn choosing_a_model_sets_it_and_remembers_it() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.running_id = "claude".into();
script.report(models_reply("fast"));
poll(&mut app, &ctx);
assert!(models_shown(&app));
assert_eq!(app.agent_chat.model, "fast");
set_model(&mut app, "smart");
set_model(&mut app, "nonsense");
assert_eq!(script.calls(), ["set_model: smart"]);
assert_eq!(app.agent_chat.model, "smart");
assert_eq!(app.preferences.agent_models.get("claude").map(String::as_str), Some("smart"));
assert_eq!(status(&app)["model"], "smart");
// The agent switching its own model is shown too.
script.report(Event::Models { current: "fast".into(), available: vec![] });
poll(&mut app, &ctx);
assert_eq!(app.agent_chat.model, "fast");
assert_eq!(app.agent_chat.models.len(), 2, "the list stays");
}
/// The next session puts back the model chosen last time, when the agent offers it.
#[test]
fn the_remembered_model_is_set_on_the_next_session() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.running_id = "claude".into();
app.preferences.agent_models.insert("claude".into(), "smart".into());
script.report(models_reply("fast"));
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert_eq!(script.calls(), ["set_model: smart"]);
assert_eq!(app.agent_chat.model, "smart");
// Already on it, or no longer offered: nothing is sent.
script.report(models_reply("smart"));
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
app.preferences.agent_models.insert("claude".into(), "gone".into());
script.report(models_reply("fast"));
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert_eq!(script.calls().len(), 1);
}
/// An agent that reports no models gets no picker; another agent's choice is not applied to it.
#[test]
fn an_agent_without_models_shows_no_picker() {
let (mut app, script, ctx) = app_with_script();
app.agent_chat.running_id = "other".into();
app.preferences.agent_models.insert("claude".into(), "smart".into());
script.report(Event::Ready { agent: "fake".into(), images: false });
poll(&mut app, &ctx);
assert!(!models_shown(&app));
assert!(script.calls().is_empty());
assert_eq!(status(&app)["models"], json!([]));
}
/// With a real ACP agent the choice goes out as `session/set_model` with the session and model.
#[cfg(unix)]
#[test]
fn set_model_goes_to_an_acp_agent() {
let dir = std::env::temp_dir().join(format!("sc-agent-chat-model-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let log = dir.join("received.log");
let script = r#"
log="$1"
while IFS= read -r line; do
printf '%s\n' "$line" >> "$log"
id=$(printf '%s' "$line" | sed -n 's/.*"id":\([0-9][0-9]*\).*/\1/p')
case "$line" in
*'"method":"initialize"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"agentInfo":{"name":"fake","version":"1"}}}\n' "$id" ;;
*'"method":"session/new"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{"sessionId":"s1","models":{"currentModelId":"fast","availableModels":[{"modelId":"fast","name":"Fast"},{"modelId":"smart","name":"Smart"}]}}}\n' "$id" ;;
*'"method":"session/set_model"'*) printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" ;;
esac
done
"#;
let argv = vec!["sh".to_string(), "-c".to_string(), script.to_string(), "fake-agent".to_string(), log.to_string_lossy().into_owned()];
let start = backend::StartContext { cwd: dir.clone(), bridge: None, tools: None, wake: Box::new(|| {}) };
let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default());
app.agent_chat.backend = Some(Box::new(acp_client::AcpBackend::start(&argv, start).unwrap()));
app.agent_chat.state = Some(State::Starting);
app.agent_chat.running_id = "fake".into();
app.preferences.agent_models.insert("fake".into(), "smart".into());
let ctx = egui::Context::default();
let t = std::time::Instant::now();
loop {
poll(&mut app, &ctx);
let logged = std::fs::read_to_string(&log).unwrap_or_default();
if logged.contains("session/set_model") {
break;
}
assert!(t.elapsed() < std::time::Duration::from_secs(20), "no set_model: {logged}");
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(models_shown(&app) && app.agent_chat.model == "smart", "the remembered model was put back");
let logged = std::fs::read_to_string(&log).unwrap();
let set: Value = logged.lines().filter_map(|l| serde_json::from_str::<Value>(l).ok()).find(|v| v["method"] == "session/set_model").unwrap();
assert_eq!(set["params"], json!({"sessionId": "s1", "modelId": "smart"}));
// A choice made in the picker goes out too.
set_model(&mut app, "fast");
let t = std::time::Instant::now();
while std::fs::read_to_string(&log).unwrap_or_default().matches("session/set_model").count() < 2 {
assert!(t.elapsed() < std::time::Duration::from_secs(20));
std::thread::sleep(std::time::Duration::from_millis(10));
}
disconnect(&mut app);
let _ = std::fs::remove_dir_all(&dir);
}
+3
View File
@@ -348,6 +348,8 @@ pub struct Prefs {
/// How much the agent may do without asking (`ask`, `edits`, `all`), and per agent.
pub ai_permissions: crate::agent_chat::agents::Mode,
pub agent_permissions: std::collections::BTreeMap<String, crate::agent_chat::agents::Mode>,
/// The model last chosen in the chat, per agent: put back when the agent offers it again.
pub agent_models: std::collections::BTreeMap<String, String>,
/// "Always allow" answers: per agent, the tools it may use without asking.
pub agent_always_allow: std::collections::BTreeMap<String, Vec<String>>,
/// The side the panel docks on (`left` or `right`), its width, and whether it is folded to a strip.
@@ -435,6 +437,7 @@ impl Default for Prefs {
agent_default: String::new(),
ai_permissions: crate::agent_chat::agents::Mode::Edits,
agent_permissions: Default::default(),
agent_models: Default::default(),
agent_always_allow: Default::default(),
agent_chat_side: "right".into(),
agent_chat_width: 380.0,
+22 -1
View File
@@ -41,7 +41,28 @@ when you send a message or press **Connect**.
unfold it. **Close** (×) hides the panel. Neither stops the agent; **Stop** does.
- **The agent switcher** under the title picks the agent for this conversation; switching stops
the one running. Next to it, when the agent has modes of its own (Claude Code has *Default*,
*Accept Edits*, *Plan*…), a second drop-down sets the agent's mode.
*Accept Edits*, *Plan*…), a second drop-down sets the agent's mode. When the agent reports its models (an ACP agent's
model list, or an endpoint's `/models`), a model drop-down shows the one in use and changes it;
SolidCraft remembers the last choice for each agent and puts it back on the next session. An
endpoint's agent also keeps it as its default model. An agent that reports no models shows no
drop-down.
## Typing while the agent works
The input stays enabled during a turn. **Enter** queues the message: it shows in the conversation
as a **Queued** bubble that you can **Edit** or remove (×) until it is sent. Several queued
messages go as one prompt, in order, when the turn ends normally.
- **Send now** (on the bubble, or **Ctrl+Enter** / **Cmd+Enter** in the input) stops the current
step, waits for the agent to say it stopped, then sends the queue as the next prompt, with a
short note that the agent's previous step was interrupted. What the interrupted message changed
undoes as one step, like any other message.
- **Stop** (the square next to the input, or **Esc** in the input) stops the agent and keeps
the queue: the bubbles wait for **Send**.
- **New chat** (the header button, **Ctrl+Shift+L** / **Cmd+Shift+L** with the chat focused)
stops what runs, clears the conversation and the queue and starts a fresh session on the same
agent, keeping the agent and its mode. It asks first when a message is running or queued. The
design's undo history is untouched.
## Agents
+1 -1
View File
@@ -147,7 +147,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox.
| `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits |
| `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first |
| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it |
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?`, `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
| `ui.agent_chat` | `features?`, `toolbar_button?`, `permissions?` (`ask`, `edits`, `all`), `add_agent?` ({`name`, `kind`: `acp` or `api`, `command` or `base_url` + `model`}; no keys), `default_agent?`, `agent?` (the conversation's agent, by id), `mode?` (the agent's own mode), `model?` (one of its models), `open?`, `side?` (`left`, `right`), `minimized?`, `width?`, `picker?`, `refresh?`, `connect?`, `stop?`, `send?`: text, `attach_view?`, `permission?`: an option id or `"cancel"` (the newest question of the agent's own), `approve?`: true, false or `"always"` (the oldest request held for Allow/Deny), `cancel?` (Stop: the queue stays), `send_now?` (stop the running step, then send the queued messages), `remove_pending?` (index), `new_chat?` (clear the chat, new session), `undo?` (the last message's changes) | the chat's state: `features`, `agents` (found and added), `currentAgent`, `state` (off, starting, ready, busy, auth, exited), `agent`, `modes`, `mode`, `models`, `model`, `authMethods`, `toolsPort`, `pid`, `waiting`, `turn`, `pending` (queued messages), `stopped`, `detection` and the conversation (`items`). With `features` off only the settings apply. Desktop only; see [Agent chat](agent-chat.md) |
| `ui.resize` | `width`, `height` | |
| `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |