diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 02e5624b3..dba3ed9de 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -723,6 +723,13 @@ fn fake_app() -> (String, std::thread::JoinHandle>) { /// [`fake_app`], answering `ui.dialog.confirm` with the error `refusal` when one is given. fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thread::JoinHandle>) { + fake_app_with(refusal, true) +} + +/// [`fake_app_refusing_confirm`]. With `answers_in_set`, `ui.dialog.set {action}` also confirms or +/// cancels the dialog and replies `{dialog, result}`, as the app does; without it the set ignores +/// `action`, as an app that predates it does. +fn fake_app_with(refusal: Option<&'static str>, answers_in_set: bool) -> (String, std::thread::JoinHandle>) { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap().to_string(); let h = std::thread::spawn(move || { @@ -738,6 +745,17 @@ fn fake_app_refusing_confirm(refusal: Option<&'static str>) -> (String, std::thr "engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}), "ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}), "ui.dialog.set" if !dialog_open => json!({"ok": false, "error": "no dialog open"}), + "ui.dialog.set" if answers_in_set && msg["params"].get("action").is_some() => { + let dialog = json!({"echo": {"fields": msg["params"]["fields"]}}); + match msg["params"]["action"].as_str() { + Some("confirm") if refusal.is_some() => json!({"ok": false, "error": refusal}), + Some("confirm") => json!({"ok": true, "result": {"dialog": dialog, "result": {"echo": {}}}}), + _ => { + dialog_open = false; + json!({"ok": true, "result": {"dialog": dialog, "result": null}}) + } + } + } "ui.dialog.confirm" if refusal.is_some() => json!({"ok": false, "error": refusal}), m if m.starts_with("ui.dialog.") => { dialog_open &= m != "ui.dialog.cancel"; @@ -775,8 +793,10 @@ fn remote_forwards_methods() { assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}})); } +/// Fields and `action` go to the app in one `ui.dialog.set`, so the dialog the fields are set in is +/// the one confirmed or canceled. #[test] -fn answer_dialog_sets_fields_in_order_then_confirms() { +fn answer_dialog_sets_fields_in_order_and_confirms_in_one_request() { let (addr, h) = fake_app(); let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); let set = json!([{"field": "discard", "value": true}, {"field": "note"}]); @@ -785,7 +805,11 @@ fn answer_dialog_sets_fields_in_order_then_confirms() { let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); let fields = json!([{"field": "discard", "value": true}, {"field": "note", "value": null}]); assert_eq!(reply, json!({"dialog": {"echo": {"fields": fields}}, "result": {"echo": {}}})); - let r = call(&mut s, 2, "answer_dialog", json!({"action": "cancel"})); + let r = call(&mut s, 2, "answer_dialog", json!({"set": set})); + assert_eq!(r["isError"], false, "{r}"); + let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(reply, json!({"dialog": {"echo": {"fields": fields}}, "result": null})); + let r = call(&mut s, 3, "answer_dialog", json!({"action": "cancel"})); assert_eq!(r["isError"], false, "{r}"); // Bad arguments are refused before anything reaches the app. for (i, args) in [ @@ -807,8 +831,26 @@ fn answer_dialog_sets_fields_in_order_then_confirms() { drop(s); let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); - assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm", "ui.dialog.cancel"]); - assert_eq!(seen[0]["params"], json!({"fields": fields})); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.set", "ui.dialog.cancel"]); + assert_eq!(seen[0]["params"], json!({"fields": fields, "action": "confirm"})); + assert_eq!(seen[1]["params"], json!({"fields": fields})); +} + +/// An app whose `ui.dialog.set` predates `action` replies with the dialog alone; the dialog is +/// then confirmed with its own `ui.dialog.confirm`. +#[test] +fn answer_dialog_confirms_separately_when_the_app_set_did_not_answer() { + let (addr, h) = fake_app_with(None, false); + let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap())); + let set = json!([{"field": "copy", "value": true}]); + let r = call(&mut s, 1, "answer_dialog", json!({"set": set, "action": "confirm"})); + assert_eq!(r["isError"], false, "{r}"); + let reply: Value = serde_json::from_str(&text_of(&r)).unwrap(); + assert_eq!(reply, json!({"dialog": {"echo": {"fields": set, "action": "confirm"}}, "result": {"echo": {}}})); + drop(s); + let seen = h.join().unwrap(); + let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); + assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); } /// When the app refuses the `ui.dialog.set`, the call is an error and nothing is confirmed. @@ -826,7 +868,7 @@ fn answer_dialog_does_not_confirm_when_the_app_refuses_the_set() { let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); assert_eq!(methods, ["ui.dialog.cancel", "ui.dialog.set"], "one set call, refused, and no confirm"); - assert_eq!(seen[1]["params"], json!({"fields": set})); + assert_eq!(seen[1]["params"], json!({"fields": set, "action": "confirm"})); } /// The app refuses to confirm a plug-in's install approval for automation: the call is an error @@ -843,7 +885,7 @@ fn answer_dialog_reports_that_the_app_refuses_to_approve_a_plugin_install() { drop(s); let seen = h.join().unwrap(); let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect(); - assert_eq!(methods, ["ui.dialog.set", "ui.dialog.confirm"]); + assert_eq!(methods, ["ui.dialog.set"]); } #[test] diff --git a/crates/mcp/src/tools/definitions.rs b/crates/mcp/src/tools/definitions.rs index 8fe45b83e..27d92394f 100644 --- a/crates/mcp/src/tools/definitions.rs +++ b/crates/mcp/src/tools/definitions.rs @@ -307,7 +307,7 @@ pub fn tool_definitions() -> Vec { tool( "answer_dialog", "Answer dialog", - "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none. When the app refuses the ui.dialog.set, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", + "Set fields of the desktop app's open dialog in order, then confirm (OK) or cancel it, as the control protocol's ui.dialog.set, ui.dialog.confirm and ui.dialog.cancel do. The app stores all the fields or none and answers that same dialog in the same request (ui.dialog.set with action). When the app refuses the ui.dialog.set, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. inspect_ui shows the open dialog and its fields (ui.dialog). After invoke_menu app.quit or file.close replies {\"pending\":\"saveChanges\"}: {action:\"confirm\"} saves, {set:[{field:\"discard\",value:true}],action:\"confirm\"} closes without saving, {action:\"cancel\"} cancels. Returns {dialog, result}: the dialog after the fields were set, and the reply to confirm or cancel. Desktop app only.", obj( json!({ "set": { @@ -316,7 +316,7 @@ pub fn tool_definitions() -> Vec { "items": obj(json!({"field": string("Dialog field"), "value": {"description": "The field's new value (any JSON; default null)"}}), &["field"]), "description": "Fields to set, in order (one ui.dialog.set that sets all of them or none)", }, - "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog"}, + "action": {"type": "string", "enum": ["confirm", "cancel"], "description": "Then confirm (OK) or cancel the dialog the fields were set in"}, }), &[], ), diff --git a/crates/mcp/src/tools/handlers.rs b/crates/mcp/src/tools/handlers.rs index ea2c6ec70..e76b5704a 100644 --- a/crates/mcp/src/tools/handlers.rs +++ b/crates/mcp/src/tools/handlers.rs @@ -334,9 +334,10 @@ pub(super) fn transform(b: &mut dyn Backend, a: &Args) -> Result /// Most fields one `answer_dialog` call may set. pub(super) const MAX_DIALOG_FIELDS: usize = 256; -/// Sets the open dialog's fields in order with one `ui.dialog.set {fields}`, which stores all of -/// them or none, then confirms or cancels it. Every argument is checked before anything is sent to -/// the app. +/// Sets the open dialog's fields in order with one `ui.dialog.set {fields, action?}`, which stores +/// all of them or none and then confirms or cancels that same dialog, so a dialog another +/// connection opens in between is not answered in its place. Every argument is checked before +/// anything is sent to the app. pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result { let set = match a.get("set") { None => &[][..], @@ -355,21 +356,37 @@ pub(super) fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result None, - Some(v) if v == "confirm" => Some("ui.dialog.confirm"), - Some(v) if v == "cancel" => Some("ui.dialog.cancel"), + let (action, then) = match a.get("action") { + None => (None, None), + Some(v) if v == "confirm" => (Some("confirm"), Some("ui.dialog.confirm")), + Some(v) if v == "cancel" => (Some("cancel"), Some("ui.dialog.cancel")), Some(_) => return Err("`action` must be confirm or cancel".into()), }; if fields.is_empty() && then.is_none() { return Err("give `set` and/or `action`".into()); } - let dialog = if fields.is_empty() { Value::Null } else { b.call("ui.dialog.set", json!({"fields": fields}))? }; - let result = match then { - Some(method) => b.call(method, json!({}))?, - None => Value::Null, - }; - Ok(json!({"dialog": dialog, "result": result})) + if fields.is_empty() { + let result = match then { + Some(method) => b.call(method, json!({}))?, + None => Value::Null, + }; + return Ok(json!({"dialog": Value::Null, "result": result})); + } + let mut params = json!({"fields": fields}); + if let Some(action) = action { + params["action"] = json!(action); + } + let reply = b.call("ui.dialog.set", params)?; + match then { + None => Ok(json!({"dialog": reply, "result": Value::Null})), + // The app answered the dialog in the same request. + Some(_) if reply.get("dialog").is_some() => Ok(reply), + // An app that predates `action` replied with the dialog alone and did not answer it. + Some(method) => { + let result = b.call(method, json!({}))?; + Ok(json!({"dialog": reply, "result": result})) + } + } } pub(super) fn screenshot(b: &mut dyn Backend, a: &Args) -> Result { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 419d4ab90..7fd0d8edf 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -17,8 +17,9 @@ //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` //! - `ui.dialog.set {field, value}` or `{fields: [{field, value}…]}` (all or none; setting a field -//! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]) / -//! `ui.dialog.confirm` / `ui.dialog.cancel` +//! the dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`]; +//! `action: confirm | cancel` then confirms or cancels that dialog in the same request and replies +//! `{dialog, result}`) / `ui.dialog.confirm` / `ui.dialog.cancel` //! - `ui.resize {width, height}`, `ui.focus`, `ui.screenshot {path?, data?}` (`data: true`: the PNG //! comes back as `pngBase64` as well) //! - `ui.render {path?, scale?, artboard?, data?, traceViews?}`: render an artboard headlessly @@ -87,6 +88,22 @@ fn dialog_fields(p: &Value) -> Result, String> { .collect() } +/// What `ui.dialog.set {action}` does with the dialog once its fields are set. +enum DialogAnswer { + Confirm, + Cancel, +} + +/// The `action` of a `ui.dialog.set`: none, `confirm` or `cancel`. It is checked before any field is set. +fn dialog_action(p: &Value) -> Result, String> { + match p.get("action") { + None => Ok(None), + Some(v) if v == "confirm" => Ok(Some(DialogAnswer::Confirm)), + Some(v) if v == "cancel" => Ok(Some(DialogAnswer::Cancel)), + Some(_) => Err("`action` must be confirm or cancel".into()), + } +} + fn reserve_synthetic(app: &mut VectorcraftApp, additional: usize) -> Result<(), String> { if additional > MAX_SYNTHETIC_EVENTS.saturating_sub(app.synthetic.len()) { return Err(format!("synthetic input queue limit ({MAX_SYNTHETIC_EVENTS} events); retry after pending input drains")); @@ -401,6 +418,10 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques Ok(fields) => fields, Err(e) => return err(e), }; + let action = match dialog_action(p) { + Ok(action) => action, + Err(e) => return err(e), + }; // Every field is checked before any is set, so a request sets all of them or none. if let Some((f, _)) = fields.iter().find(|(f, _)| !crate::dialogs::accepts_field(d, f)) { let names = crate::dialogs::field_names(d).join(", "); @@ -419,7 +440,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques return err(e); } } - ok(app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default()) + let dialog = app.ui.dialog.as_ref().and_then(|d| serde_json::to_value(d).ok()).unwrap_or_default(); + // `action` answers the dialog the fields were just set in, within this request. + match action { + None => ok(dialog), + Some(DialogAnswer::Confirm) => match crate::dialogs::confirm(app) { + Ok(result) => ok(json!({"dialog": dialog, "result": result})), + Err(e) => err(e), + }, + Some(DialogAnswer::Cancel) => { + crate::dialogs::cancel(app); + ok(json!({"dialog": dialog, "result": null})) + } + } } None => err("no dialog open"), }, diff --git a/crates/ui-egui/src/control/tests.rs b/crates/ui-egui/src/control/tests.rs index 6d46e6c0f..0a9c042a1 100644 --- a/crates/ui-egui/src/control/tests.rs +++ b/crates/ui-egui/src/control/tests.rs @@ -254,6 +254,38 @@ fn dialog_set_refuses_a_field_list_over_the_limit() { assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order"); } +/// `ui.dialog.set {fields, action}` confirms or cancels the dialog it set the fields in, within the +/// same request, and replies `{dialog, result}`. A bad `action` or a refused field sets nothing and +/// leaves the dialog open. +#[test] +fn dialog_set_with_an_action_answers_the_same_dialog() { + let mut app = VectorcraftApp::new(Session::new(), Services::default()); + let open = |app: &mut VectorcraftApp| app.ui.dialog = Some(crate::state::Dialog::new("note", json!({"text": "a"}))); + let text = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| d.fields["text"].clone()); + open(&mut app); + for params in [ + json!({"fields": [{"field": "text", "value": "b"}], "action": "ok"}), + json!({"fields": [{"field": "text", "value": "b"}], "action": true}), + json!({"fields": [{"field": "text", "value": "b"}], "action": null}), + json!({"fields": [{"field": "text", "value": "b"}, {"field": "nope", "value": 1}], "action": "confirm"}), + ] { + let r = input_request(&mut app, "ui.dialog.set", params.clone()); + assert_eq!(r["ok"], false, "{params}: {r}"); + assert_eq!(text(&app), Some(json!("a")), "{params}: the dialog stays open as it was"); + } + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [{"field": "text", "value": "b"}], "action": "confirm"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"], json!({"dialog": {"kind": "note", "fields": {"text": "b"}}, "result": null})); + assert!(app.ui.dialog.is_none(), "confirmed in the same request"); + open(&mut app); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [{"field": "text", "value": "c"}], "action": "cancel"})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"], json!({"dialog": {"kind": "note", "fields": {"text": "c"}}, "result": null})); + assert!(app.ui.dialog.is_none(), "canceled in the same request"); + let r = input_request(&mut app, "ui.dialog.set", json!({"fields": [], "action": "confirm"})); + assert_eq!(r["error"], "no dialog open"); +} + /// #1004: `ui.render` (the MCP `screenshot` of a connected app) renders the artboard asked /// for, at the scale asked for; the first without one. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 4fa8de6da..02cc5c438 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,7 @@ the two can still win that race. | `ui.click` / `ui.drag` | `{x, y, button?, count?, shift?, alt?, ctrl?, cmd?}` / `{x, y, toX, toY, steps?, count?, button?, shift?, alt?, ctrl?, cmd?}` | egui pointer input; see synthetic input budgets above | | `ui.wheel` | `{x, y, dy?, dx?, unit?: "line"\|"point", shift?, alt?, cmd?}` | a mouse wheel turn over screen point (x, y): `dy` notches up (+) or down, `dx` sideways. Over the canvas the wheel scrolls, Cmd-wheel (Ctrl on Windows and Linux) scrolls sideways and Alt-wheel (Option on the Mac) zooms about the pointer (with the `ctrlWheelScrollsSideways` preference off, Cmd-wheel zooms too); with the `zoomWithMouseWheel` preference the wheel and Alt-wheel zoom about the pointer, Shift-wheel scrolls up and down and Cmd-wheel (Ctrl on Windows and Linux) sideways. Over a focused numeric field (click it first) each notch steps its value as Up/Down do (Shift: ten, Cmd/Ctrl: a tenth) and the panel stays put; over anything else in a panel the wheel scrolls it. Over the canvas, the wheel preferences in `docs/mcp.md` change what these keys do | | `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `outline`: View › Outline for the active document only (each document has its own, a document opens in Preview, and `ui.inspect` shows it as `view.outline`; an error without a document). `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) | -| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value}` or `{fields: [{field, value}…]}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes | +| `ui.dialog.set` / `.confirm` / `.cancel` | `{field, value, action?}` or `{fields: [{field, value}…], action?}` | fill and submit the open dialog. `ui.dialog.set` takes the fields the dialog has (`ui.inspect`), `__` fields, `discard` on a dialog with a discard button, `preview` on one with a live preview, the fields that the dialog reads but adds only when a control changes (such as `searchFolder`, or `copy` in the Transform dialogs), and the fields it passes to the command it runs: every parameter of the `command` dialog's command (`ui.paramDialog`), and the `textureImage` that Load Texture… sets in the Glass and Texturizer effects; any other field is an error that lists the dialog's fields, and the dialog stays as it was `fields` sets the list (at most 256 items) in order (a missing `value` is `null`) and checks every item first: when one is refused, the call is an error and no field changes. `action: "confirm"` or `"cancel"` on `ui.dialog.set` then confirms or cancels the same dialog in the same request, as `ui.dialog.confirm` and `ui.dialog.cancel` do, and the reply is `{dialog, result}`: the dialog after the fields were set and the reply to confirm (`null` for cancel); any other `action` is an error and no field changes | | `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. The inline PNG is capped at 64 MiB: with a `path` the file is still written and `pngBase64` is omitted when the image is larger, while a path-less `data: true` request fails if either the raw RGBA image or the encoded PNG exceeds the cap. Needs a presented frame: with the screen locked or the window minimized/covered it fails after ~8 s with an explanatory error | | `ui.render` | `{path?, scale?, artboard?, data?, traceViews?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first; `traceViews: true`: Image Trace objects draw in their View, as on the canvas, instead of their tracing result): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | | `ui.render` | `{path?, scale?, artboard?, data?}` | render an artboard headlessly (PNG with the pixels of `app.export {format: "png", background: "white"}` at the same scale; `artboard`: its 0-based index, default the first): written to `path`, else (or with `data: true` as well) sent back as `pngBase64` | diff --git a/docs/mcp.md b/docs/mcp.md index bc2048393..b5553c330 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -460,7 +460,7 @@ opens the picker through `run_command` with the remote backend. | `set_paint` | `{fill?, stroke?, strokeWidth?, ids?}` | Applies to the selection (or `ids`) and becomes the default for new art. Returns `{applied, objects}`: `objects` is the number of objects painted, as the `objects` of the `paint.setFill`, `paint.setStroke` and `stroke.set` replies (the largest of them): a selected group counts the objects inside it. When it is 0, a `note` says that only the paint for new art changed. | | `press_key` | `{key, mods?}` | Remote: a real key event. Headless: runs the command or tool bound to that shortcut, or sends the key to the busy tool (digits too: `5` while dragging with the Perspective Selection tool; `e` while dragging a Selection or Group Selection marquee; `c` and `f` while dragging out an arc; `f`, `v`, `x` and `c` while dragging out a rectangular or polar grid). | | `type_text` | `{text}` | Remote only. | -| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields}` (a missing `value` is `null`). The app stores all of them or none. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and `action` is not sent. Then `action` `confirm` (OK, `ui.dialog.confirm`) or `cancel` (`ui.dialog.cancel`). Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | +| `answer_dialog` | `{set?: [{field, value?}], action?}` | Remote only. Sets fields of the app's open dialog in the order given with one `ui.dialog.set {fields, action?}` (a missing `value` is `null`). The app stores all of them or none, then answers that same dialog with `action` in the same request: `confirm` (OK) or `cancel`. Another connection cannot open a dialog between the two steps. When the app refuses the `ui.dialog.set`, for example because no dialog is open, the call is an error and the dialog is not confirmed or canceled. `action` alone is sent as `ui.dialog.confirm` or `ui.dialog.cancel`. Give `set`, `action` or both; `set` holds at most 256 fields. `inspect_ui` shows the open dialog and its fields as `ui.dialog`. Returns `{dialog, result}`: the dialog after the fields were set (or `null`) and the reply to `confirm` or `cancel` (or `null`). After `invoke_menu` `app.quit`, `file.close` or `file.closeAll` replies `{"pending": "saveChanges"}`, `{action: "confirm"}` saves, `{set: [{field: "discard", value: true}], action: "confirm"}` closes without saving and `{action: "cancel"}` cancels the close or quit. | | `invoke_menu` | `{command, params?}` | Invokes a menu item by command id. Includes UI commands such as `view.*` and `window.*` in remote mode. | | `open_panel` | `{panel}` | Remote only. `panel` is a panel id (`layers`, `swatches`, `colorGuide`, …, as `window.panel` takes) or its display label (`"Color Guide"`), in any case. | | `screenshot` | `{path?, scale?, artboard?, window?, max_side?, traceViews?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard with the pixels of a PNG `export` with `background: "white"` at the same scale; `traceViews:true` draws Image Trace objects in the view set with `imageTrace.setView`, as in the app's window; `window:true` captures the app window (remote only), as `ui_screenshot` does, and `max_side` (1–4096, window captures only) scales the returned image down as described for `ui_screenshot`. With `path` the backend (the headless session or the app) writes the PNG there too; a window capture written there keeps its full size with `max_side`. |