From baf44657c176ecc87ca5214f4605d359d329362c Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 15 Oct 2026 23:58:28 -0400 Subject: [PATCH] Agent chat: queue messages while the agent works, Send now, Stop, New chat, and a model picker (#1566, #1571) (#1566) Thanks @bherbruck. --- assets/locales/de.json | 1 - assets/locales/en.json | 16 +- assets/locales/ja.json | 16 +- assets/locales/ko.json | 1 - assets/locales/tr.json | 1 - assets/locales/zh.json | 16 +- crates/ui-egui/src/agent_chat/acp.rs | 59 +++ crates/ui-egui/src/agent_chat/acp_client.rs | 21 +- crates/ui-egui/src/agent_chat/api_client.rs | 246 ++++++++++- crates/ui-egui/src/agent_chat/backend.rs | 7 + crates/ui-egui/src/agent_chat/mod.rs | 266 +++++++++++- crates/ui-egui/src/agent_chat/panel.rs | 195 ++++++++- crates/ui-egui/src/agent_chat/tests.rs | 453 ++++++++++++++++++++ crates/ui-egui/src/prefs.rs | 3 + docs/agent-chat.md | 23 +- docs/control-protocol.md | 2 +- 16 files changed, 1293 insertions(+), 33 deletions(-) diff --git a/assets/locales/de.json b/assets/locales/de.json index e87a5cd3..95fa4098 100644 --- a/assets/locales/de.json +++ b/assets/locales/de.json @@ -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", diff --git a/assets/locales/en.json b/assets/locales/en.json index 1e00199b..b9a583d1 100644 --- a/assets/locales/en.json +++ b/assets/locales/en.json @@ -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", diff --git a/assets/locales/ja.json b/assets/locales/ja.json index df92e435..a50b3417 100644 --- a/assets/locales/ja.json +++ b/assets/locales/ja.json @@ -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": "追加", diff --git a/assets/locales/ko.json b/assets/locales/ko.json index 1ac034ef..b5cec8c1 100644 --- a/assets/locales/ko.json +++ b/assets/locales/ko.json @@ -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": "추가", diff --git a/assets/locales/tr.json b/assets/locales/tr.json index 36798f87..8ccc757a 100644 --- a/assets/locales/tr.json +++ b/assets/locales/tr.json @@ -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", diff --git a/assets/locales/zh.json b/assets/locales/zh.json index 1481b970..2e6162e5 100644 --- a/assets/locales/zh.json +++ b/assets/locales/zh.json @@ -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": "添加", diff --git a/crates/ui-egui/src/agent_chat/acp.rs b/crates/ui-egui/src/agent_chat/acp.rs index 20814ad5..28315f8d 100644 --- a/crates/ui-egui/src/agent_chat/acp.rs +++ b/crates/ui-egui/src/agent_chat/acp.rs @@ -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)> { + let m = session.get("models")?; + let current = m.get("currentModelId").and_then(Value::as_str).unwrap_or("").to_string(); + let available: Vec = 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"})); + } } diff --git a/crates/ui-egui/src/agent_chat/acp_client.rs b/crates/ui-egui/src/agent_chat/acp_client.rs index 6e11bd49..ca9e16b7 100644 --- a/crates/ui-egui/src/agent_chat/acp_client.rs +++ b/crates/ui-egui/src/agent_chat/acp_client.rs @@ -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 { self.proc.pid() } diff --git a/crates/ui-egui/src/agent_chat/api_client.rs b/crates/ui-egui/src/agent_chat/api_client.rs index 878270f1..8c50fd31 100644 --- a/crates/ui-egui/src/agent_chat/api_client.rs +++ b/crates/ui-egui/src/agent_chat/api_client.rs @@ -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), + 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 { @@ -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::(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, 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, 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 { 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::::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::::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| { + 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::::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::::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::>(), ["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::::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())) }); diff --git a/crates/ui-egui/src/agent_chat/backend.rs b/crates/ui-egui/src/agent_chat/backend.rs index 301b9276..a44a5482 100644 --- a/crates/ui-egui/src/agent_chat/backend.rs +++ b/crates/ui-egui/src/agent_chat/backend.rs @@ -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 }, /// 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. diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index d7a1cefc..e4be8689 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -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, /// A message typed before the agent was ready. queued: Option<(String, bool)>, + /// Messages typed while the agent works, in order. They go as one prompt when the turn ends + /// (or at once with Send now); after Stop they wait for Send. + pub pending: Vec, + /// The queued message being edited, and its text so far. + pub pending_edit: Option<(usize, String)>, + /// Send now was pressed: the queue goes as soon as the cancelled turn has ended. + steering: bool, + /// The turn was stopped: its queue is not sent by itself. + stopped: bool, + /// Why the last turn ended. + ended: Option, + /// New chat while a message ran: its late end is not reported. + discard_end: bool, + /// The mode to put back when the fresh session is ready. + restore_mode: Option, + /// New chat is waiting for the user's yes (a message is running or queued). + pub confirm_new: bool, + /// Where the panel was drawn last (for its shortcuts). + pub panel_rect: Option, /// The last message sent, kept to send again after a sign-in. last: Option<(String, bool)>, /// The command the running agent was started with. @@ -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, + pub model: String, + /// An API endpoint's models, kept per endpoint (its base URL) for the next connection. + models_cache: std::collections::HashMap>, + /// The running API agent's endpoint (empty for other agents). + running_endpoint: String, backend: Option>, turn: Option, held: Vec, @@ -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 = tail.into_iter().rev().take(6).collect::>().into_iter().rev().collect(); let detail = if last.is_empty() { String::new() } else { format!("\n{}", last.join("\n")) }; self.notice(format!("{}{detail}", tr("ui.agent_chat_exited")), true); @@ -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::>().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 { 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 Result 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::>(), "currentAgent": agents::current(app).map(|e| e.id), + "models": c.models.iter().map(|m| json!({"id": m.id, "name": m.name, "description": m.description})).collect::>(), + "model": c.model, "modes": c.modes.iter().map(|(id, name)| json!({"id": id, "name": name})).collect::>(), "mode": c.mode, "open": c.open, @@ -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::>(), + "stopped": c.stopped, "turn": c.turn.as_ref().map(|t| json!({"changes": t.changes, "requests": t.requests})), "detection": detection, "items": items, diff --git a/crates/ui-egui/src/agent_chat/panel.rs b/crates/ui-egui/src/agent_chat/panel.rs index 44f0ecf2..087a26dc 100644 --- a/crates/ui-egui/src/agent_chat/panel.rs +++ b/crates/ui-egui/src/agent_chat/panel.rs @@ -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 = 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 = 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 = None; + let mut edit_done: Option<(usize, Option)> = 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()); } diff --git a/crates/ui-egui/src/agent_chat/tests.rs b/crates/ui-egui/src/agent_chat/tests.rs index a2208cc1..52eb2a90 100644 --- a/crates/ui-egui/src/agent_chat/tests.rs +++ b/crates/ui-egui/src/agent_chat/tests.rs @@ -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>>, + events: Arc>>, +} + +impl Script { + fn calls(&self) -> Vec { + 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 { + 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 { + 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 = std::fs::read_to_string(&log) + .unwrap() + .lines() + .filter_map(|l| serde_json::from_str::(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 = + log_text.lines().filter_map(|l| serde_json::from_str::(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::(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); +} diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 9ed41836..c5288792 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -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, + /// The model last chosen in the chat, per agent: put back when the agent offers it again. + pub agent_models: std::collections::BTreeMap, /// "Always allow" answers: per agent, the tools it may use without asking. pub agent_always_allow: std::collections::BTreeMap>, /// 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, diff --git a/docs/agent-chat.md b/docs/agent-chat.md index 005ff723..5529a94c 100644 --- a/docs/agent-chat.md +++ b/docs/agent-chat.md @@ -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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 2857b9ac..142ea263 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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) |