Merge pull request #4057 from dotCooCoo/feat/mcp-answer-dialog

MCP: answer_dialog sets the fields of the app's open dialog and confirms or cancels it
This commit is contained in:
BFlat
2026-10-26 09:19:25 +07:00
committed by GitHub
5 changed files with 220 additions and 10 deletions
+69
View File
@@ -697,12 +697,19 @@ fn fake_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
let (stream, _) = listener.accept().unwrap();
let mut out = stream.try_clone().unwrap();
let mut seen = vec![];
// A dialog is open until `ui.dialog.cancel` closes it; then `ui.dialog.set` is refused, as in the app.
let mut dialog_open = true;
for line in BufReader::new(stream).lines() {
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
let reply = match msg["method"].as_str().unwrap() {
"document.inspect" => json!({"ok": true, "result": {"title": "Remote", "layers": []}}),
"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"}),
m if m.starts_with("ui.dialog.") => {
dialog_open &= m != "ui.dialog.cancel";
json!({"ok": true, "result": {"echo": msg["params"]}})
}
_ => json!({"ok": false, "error": "nope"}),
};
let mut reply = reply;
@@ -735,6 +742,68 @@ fn remote_forwards_methods() {
assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}}));
}
#[test]
fn answer_dialog_sets_fields_in_order_then_confirms() {
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"}]);
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();
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"}));
assert_eq!(r["isError"], false, "{r}");
// Bad arguments are refused before anything reaches the app.
for (i, args) in [
json!({}),
json!({"set": []}),
json!({"set": {"field": "copies"}}),
json!({"set": [{"value": 1}]}),
json!({"set": [{"field": "copies", "value": 1}, 7]}),
json!({"set": [{"field": "copies", "valu": 1}], "action": "confirm"}),
json!({"set": vec![json!({"field": "copies"}); 257]}),
json!({"action": "ok"}),
]
.into_iter()
.enumerate()
{
let r = call(&mut s, 10 + i as u64, "answer_dialog", args.clone());
assert_eq!(r["isError"], true, "{args} → {r}");
}
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}));
}
/// When the app refuses the `ui.dialog.set`, the call is an error and nothing is confirmed.
#[test]
fn answer_dialog_does_not_confirm_when_the_app_refuses_the_set() {
let (addr, h) = fake_app();
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
let r = call(&mut s, 1, "answer_dialog", json!({"action": "cancel"}));
assert_eq!(r["isError"], false, "{r}");
let set = json!([{"field": "copy", "value": true}, {"field": "dy", "value": 2}]);
let r = call(&mut s, 2, "answer_dialog", json!({"set": set, "action": "confirm"}));
assert_eq!(r["isError"], true, "{r}");
assert!(text_of(&r).contains("no dialog open"), "{r}");
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.cancel", "ui.dialog.set"], "one set call, refused, and no confirm");
assert_eq!(seen[1]["params"], json!({"fields": set}));
}
#[test]
fn answer_dialog_needs_the_desktop_app() {
let mut s = server();
let r = call(&mut s, 1, "answer_dialog", json!({"action": "confirm"}));
assert_eq!(r["isError"], true, "{r}");
assert!(text_of(&r).contains("needs the desktop app"), "{r}");
}
#[test]
fn remote_does_not_replay_when_edit_reply_is_lost() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
+63
View File
@@ -319,6 +319,24 @@ pub fn tool_definitions() -> Vec<Value> {
obj(json!({"text": string("Text to type")}), &["text"]),
false,
),
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.",
obj(
json!({
"set": {
"type": "array",
"maxItems": MAX_DIALOG_FIELDS,
"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"},
}),
&[],
),
false,
),
tool(
"invoke_menu",
"Invoke menu",
@@ -843,6 +861,47 @@ fn transform(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
Ok(json!({"applied": done}))
}
/// Most fields one `answer_dialog` call may set.
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.
fn answer_dialog(b: &mut dyn Backend, a: &Args) -> Result<Value, String> {
let set = match a.get("set") {
None => &[][..],
Some(Value::Array(s)) => s.as_slice(),
Some(_) => return Err("`set` must be a list of {field, value?}".into()),
};
if set.len() > MAX_DIALOG_FIELDS {
return Err(format!("`set` holds {} fields; at most {MAX_DIALOG_FIELDS}", set.len()));
}
let mut fields = vec![];
for (i, item) in set.iter().enumerate() {
let item = item.as_object().ok_or_else(|| format!("set {i} must be {{field, value?}}"))?;
if let Some(k) = item.keys().find(|k| *k != "field" && *k != "value") {
return Err(format!("set {i}: unknown key `{k}` (expected field, value)"));
}
let field = item.get("field").and_then(Value::as_str).ok_or_else(|| format!("set {i}: missing string `field`"))?;
fields.push(json!({"field": field, "value": item.get("value").cloned().unwrap_or(Value::Null)}));
}
let then = match a.get("action") {
None => None,
Some(v) if v == "confirm" => Some("ui.dialog.confirm"),
Some(v) if v == "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}))
}
fn screenshot(b: &mut dyn Backend, a: &Args) -> Result<ToolResult, String> {
let path = a.get("path").and_then(Value::as_str).map(|p| absolute_path(b, p)).transpose()?;
let path = path.as_deref();
@@ -1105,6 +1164,10 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result<ToolResult, Str
need_ui(b, name)?;
j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?)
}
"answer_dialog" => {
need_ui(b, name)?;
j(answer_dialog(b, a)?)
}
"open_panel" => {
need_ui(b, name)?;
j(b.call("ui.set", json!({"panel": req_str(a, "panel")?}))?)
+85 -6
View File
@@ -15,8 +15,9 @@
//! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`:
//! 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}` / `ui.dialog.confirm` / `ui.dialog.cancel` (setting a field the
//! dialog does not read is an error that lists its fields: [`crate::dialogs::accepts_field`])
//! - `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`
//! - `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
@@ -47,6 +48,8 @@ pub(crate) const MAX_DRAG_STEPS: u64 = 256;
pub(crate) const MAX_CLICK_COUNT: u64 = 16;
pub(crate) const MAX_SYNTHETIC_EVENTS: usize = 1024;
pub(crate) const MAX_CONTROL_REQUESTS_PER_FRAME: usize = 64;
/// Most items one `ui.dialog.set {fields}` may store, as MCP `answer_dialog` allows.
pub(crate) const MAX_DIALOG_FIELDS: usize = 256;
fn input_count(p: &Value, key: &str, default: u64, max: u64) -> Result<usize, String> {
let n = match p.get(key) {
@@ -60,6 +63,29 @@ fn input_count(p: &Value, key: &str, default: u64, max: u64) -> Result<usize, St
usize::try_from(n.max(1)).map_err(|_| format!("`{key}` is out of range"))
}
/// The fields one `ui.dialog.set` stores, in order: `{field, value?}`, or `{fields: [{field, value?}…]}`.
/// Every item is checked before any is stored, so a refused item leaves the dialog as it was.
fn dialog_fields(p: &Value) -> Result<Vec<(String, Value)>, String> {
let Some(list) = p.get("fields") else {
let f = p.get("field").and_then(Value::as_str).ok_or("missing `field`")?;
return Ok(vec![(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null))]);
};
if p.get("field").is_some() {
return Err("give `field` or `fields`, not both".into());
}
let list = list.as_array().ok_or("`fields` must be a list of {field, value?}")?;
if list.len() > MAX_DIALOG_FIELDS {
return Err(format!("`fields` holds {} items; at most {MAX_DIALOG_FIELDS}", list.len()));
}
list.iter()
.enumerate()
.map(|(i, item)| {
let f = item.get("field").and_then(Value::as_str).ok_or_else(|| format!("fields {i}: missing string `field`"))?;
Ok((f.to_string(), item.get("value").cloned().unwrap_or(Value::Null)))
})
.collect()
}
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"));
@@ -410,12 +436,18 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context, req: &ControlReques
}
"ui.dialog.set" => match app.ui.dialog.as_mut() {
Some(d) => {
let Some(f) = s("field") else { return err("missing `field`") };
if !crate::dialogs::accepts_field(d, f) {
let fields = match dialog_fields(p) {
Ok(fields) => fields,
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(", ");
return err(format!("the `{}` dialog has no field `{f}`; its fields: {names}", d.kind));
}
d.fields.insert(f.to_string(), p.get("value").cloned().unwrap_or(Value::Null));
for (f, value) in fields {
d.fields.insert(f, value);
}
ok(serde_json::to_value(&*d).unwrap_or_default())
}
None => err("no dialog open"),
@@ -590,7 +622,7 @@ mod tests {
use serde_json::{Value, json};
use vectorcraft_engine::Session;
use super::{ControlRequest, Outcome, handle};
use super::{ControlRequest, MAX_DIALOG_FIELDS, Outcome, handle};
use crate::state::Dialog;
use crate::{Services, VectorcraftApp};
@@ -741,6 +773,53 @@ mod tests {
}
}
/// `ui.dialog.set {fields}` stores the whole list in order, or nothing when an item is refused.
#[test]
fn dialog_set_stores_a_field_list_whole_or_not_at_all() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt", "dy": "0 pt"})));
let fields = |app: &VectorcraftApp| app.ui.dialog.as_ref().map(|d| serde_json::to_value(&d.fields).unwrap());
let before = fields(&app);
for params in [
json!({"fields": [{"field": "copy", "value": true}, {"value": 1}]}),
json!({"fields": [{"field": "copy", "value": true}, 7]}),
json!({"fields": {"field": "copy"}}),
json!({"field": "dx", "fields": [{"field": "copy", "value": true}]}),
json!({"fields": [{"field": "copy", "value": true}, {"field": "note"}]}),
] {
let r = input_request(&mut app, "ui.dialog.set", params.clone());
assert_eq!(r["ok"], false, "{params}: {r}");
assert_eq!(fields(&app), before, "{params}: a refused list stores no field");
}
let list = json!([{"field": "copy", "value": true}, {"field": "dx", "value": "3 pt"}, {"field": "dx", "value": "5 pt"}, {"field": "dy"}]);
let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list}));
assert_eq!(r["ok"], true, "{r}");
let after = fields(&app).unwrap();
assert_eq!(after, json!({"copy": true, "dx": "5 pt", "dy": null}));
assert_eq!(r["result"]["fields"], after);
let r = input_request(&mut app, "ui.dialog.set", json!({"field": "dy", "value": "2 pt"}));
assert_eq!(r["ok"], true, "{r}");
assert_eq!(fields(&app).unwrap()["dy"], "2 pt");
app.ui.dialog = None;
assert_eq!(input_request(&mut app, "ui.dialog.set", json!({"fields": []}))["error"], "no dialog open");
}
/// `ui.dialog.set {fields}` stores at most `MAX_DIALOG_FIELDS` items; a longer list stores none.
#[test]
fn dialog_set_refuses_a_field_list_over_the_limit() {
let mut app = VectorcraftApp::new(Session::new(), Services::default());
app.ui.dialog = Some(crate::state::Dialog::new("move", json!({"dx": "0 pt"})));
let list = |n: usize| Value::Array((0..n).map(|i| json!({"field": "dx", "value": format!("{i} pt")})).collect());
let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS + 1)}));
assert_eq!(r["ok"], false, "{r}");
assert_eq!(r["error"], format!("`fields` holds {} items; at most {MAX_DIALOG_FIELDS}", MAX_DIALOG_FIELDS + 1));
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!("0 pt")), "a refused list stores no field");
let r = input_request(&mut app, "ui.dialog.set", json!({"fields": list(MAX_DIALOG_FIELDS)}));
assert_eq!(r["ok"], true, "{r}");
let last = format!("{} pt", MAX_DIALOG_FIELDS - 1);
assert_eq!(app.ui.dialog.as_ref().map(|d| d.fields["dx"].clone()), Some(json!(last)), "the whole list is stored, in order");
}
/// #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]
+1 -3
View File
@@ -102,10 +102,8 @@ 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 and Cmd- or Alt-wheel (Option on the Mac) zooms about the pointer; 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 |
| `ui.set` | `{brightness?, panel?, rulers?, outline?, grid?, smartGuides?, boundingBox?, controlBar?, imageTracePreview?}` | `imageTracePreview`: the Image Trace panel's Preview (`ui.inspect` shows it as `ui.image_trace_preview`) |
| `ui.dialog.set` / `.confirm` / `.cancel` | `{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 |
| `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.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.dialog.set` / `.confirm` / `.cancel` | `{field, value}` | fill and submit the open dialog |
| `ui.screenshot` | `{path?, data?}` | capture the window (PNG), written to `path`; `data: true` sends it back as `pngBase64` too. 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` |
| `ui.resize` / `ui.focus` | | |
+2 -1
View File
@@ -9,7 +9,7 @@ It has two backends:
| Mode | How | What works |
|---|---|---|
| **Remote** | `vectorcraft-cli mcp --connect 127.0.0.1:7979` (the app must run with `vectorcraft --control 7979`) | Everything. Tool calls are forwarded over the [control protocol](control-protocol.md), so you watch the app change live. |
| **Headless** | `vectorcraft-cli mcp --headless` | An in-process engine session with a CPU renderer. Everything except the UI-only tools (`inspect_ui` or `ui_inspect`, `type_text`, `open_panel`, `screenshot {window:true}` or `ui_screenshot`). |
| **Headless** | `vectorcraft-cli mcp --headless` | An in-process engine session with a CPU renderer. Everything except the UI-only tools (`inspect_ui` or `ui_inspect`, `type_text`, `answer_dialog`, `open_panel`, `screenshot {window:true}` or `ui_screenshot`). |
With no flag, the server tries `127.0.0.1:7979` and falls back to headless. Logs go to stderr; stdout carries
only protocol messages.
@@ -418,6 +418,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
| `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). |
| `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. |
| `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`. |