Merge pull request #328 from fahrim/mcp-small-wins

MCP small wins: panel ids, object reads via document.node, stdio sweep
This commit is contained in:
Brandon Thomas
2026-10-07 17:02:30 -04:00
7 changed files with 223 additions and 39 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("file.close", "Close", ["File"], Some("Cmd+W"), "{index?}", has_doc, file_close),
cmd!("document.activate", "Activate Document", [], None, "{index}", always, doc_activate),
cmd!(query "document.inspect", "Inspect Document", [], None, "{} → layer tree, artboards, selection, history", has_doc, |s, _| Ok(inspect::document(s))),
cmd!(query "document.node", "Inspect Object", [], None, "{id} → full object JSON", has_doc, doc_node),
cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node),
cmd!(query "document.json", "Document JSON", [], None, "{} → complete document model", has_doc, |s, _| Ok(serde_json::to_value(&*s.doc()?.doc).unwrap_or(Value::Null))),
cmd!(
"document.setUnits",
@@ -106,7 +106,7 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result<Value> {
fn doc_node(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id").ok_or_else(|| bad("document.node", "missing id"))?;
let n = s.doc()?.doc.node(id).ok_or(EngineError::NoNode(id))?;
Ok(serde_json::to_value(n).unwrap_or(Value::Null))
if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) }
}
/// Document Setup's units (also `document.setup {units}`).
+15
View File
@@ -312,6 +312,21 @@ fn inspect_lists_everything() {
assert!(s.commands().len() > 100);
}
/// `document.node {summary: true}` answers the `document.inspect` shape for one node:
/// the same fields the full-tree summary carries, without serializing the whole tree.
#[test]
fn document_node_summary_matches_inspect() {
let mut s = session();
let id = rect(&mut s, 0.0, 0.0, 10.0, 10.0).0;
let full = s.execute("document.node", &json!({"id": id})).unwrap();
assert!(full.get("bounds").is_none(), "full object JSON carries geometry, not bounds: {full}");
let summary = s.execute("document.node", &json!({"id": id, "summary": true})).unwrap();
let v = s.execute("document.inspect", &json!({})).unwrap();
let node = v["layers"][0]["children"].as_array().unwrap().iter().find(|n| n["id"] == id).cloned().unwrap();
assert_eq!(summary, node, "{summary} vs {node}");
assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err());
}
/// Type reports the paint its characters show, and its own object-level paint apart from it.
#[test]
fn inspect_reports_the_paint_of_types_characters() {
+26 -25
View File
@@ -44,7 +44,7 @@ pub static TEMPLATES: &[Template] = &[
uri: "vectorcraft://object/{id}",
name: "object",
title: "One layer or object",
description: "The layer or object with this id, with its children, bounds and paint (document.inspect)",
description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true})",
mime: "application/json",
live: Live::Objects,
},
@@ -118,7 +118,9 @@ pub fn read(b: &mut dyn Backend, uri: &str) -> Result<Value, ReadError> {
return Err(ReadError::Invalid(format!("{} needs a value for `{}`", template.uri, variable_name(template.uri))));
}
let found = match template.name {
"object" => find_object(b, &var).map_err(ReadError::Backend)?,
// One direct `document.node` lookup through `engine.execute` (which reaches both
// backends), instead of pulling the whole layer tree out of `document.inspect`.
"object" => read_object(b, &var).map_err(ReadError::Backend)?,
// The command catalogue is a control method; the rest are engine commands.
"command" => find_in(b.call("engine.commands", json!({})).map_err(ReadError::Backend)?, None, "id", &var),
"effect" => find_in(exec(b, "effect.list").map_err(ReadError::Backend)?, Some("catalog"), "id", &var),
@@ -171,30 +173,23 @@ fn decode(s: &str) -> String {
String::from_utf8_lossy(&out).into_owned()
}
/// The layer or object with this id in the active document, with its subtree.
fn find_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let doc = exec(b, "document.inspect")?;
Ok(find_node(doc.get("layers"), id))
}
/// Depth-first search of the layer tree for a node whose `id` matches.
fn find_node(node: Option<&Value>, id: &str) -> Option<Value> {
let nodes = node?.as_array()?;
let mut stack: Vec<&Value> = nodes.iter().rev().collect();
while let Some(n) = stack.pop() {
let hit = match n.get("id") {
Some(Value::String(s)) => s == id,
Some(Value::Number(number)) => number.to_string() == id,
_ => false,
};
if hit {
return Some(n.clone());
}
if let Some(kids) = n.get("children").and_then(Value::as_array) {
stack.extend(kids.iter().rev());
}
/// The layer or object with this id in the active document, as its compact summary
/// (bounds, paint labels, subtree — the `document.inspect` shape for one node).
///
/// One `document.node {summary: true}` lookup through `engine.execute` (which reaches
/// both backends), instead of pulling the whole layer tree out of `document.inspect`
/// and scanning it: the reply matches the old read field for field, without the
/// O(document) serialize-and-summarize on every call.
///
/// A non-numeric id, or one that names nothing, is "not found" (the caller turns it into
/// `-32602`); backend failures are swallowed the same way, matching how [`crate::prompts`]
/// treats an unreachable catalogue (empty rather than an error).
fn read_object(b: &mut dyn Backend, id: &str) -> Result<Option<Value>, String> {
let Ok(num) = id.parse::<u64>() else { return Ok(None) };
match b.call("engine.execute", json!({"command": "document.node", "params": {"id": num, "summary": true}})) {
Ok(v) => Ok(Some(v)),
Err(_) => Ok(None),
}
None
}
/// The item in a query command's array whose `field` equals `value`.
@@ -261,5 +256,11 @@ mod tests {
let v = read(b.as_mut(), &format!("vectorcraft://object/{id}")).unwrap();
assert_eq!(v["id"].to_string(), id);
assert!(v["bounds"].is_object(), "{v}");
// Layers resolve too, not just drawn objects.
let inspect = b.call("document.inspect", json!({})).unwrap();
let layer = inspect["layers"].as_array().and_then(|l| l.first()).expect("a layer");
let lid = layer["id"].to_string();
let v = read(b.as_mut(), &format!("vectorcraft://object/{lid}")).unwrap();
assert_eq!(v["id"].to_string(), lid);
}
}
+2 -2
View File
@@ -233,8 +233,8 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"open_panel",
"Open panel",
"Open a panel in the desktop app's dock (e.g. Layers, Swatches, Stroke, Align, Pathfinder, Transform). Desktop app only.",
obj(json!({"panel": string("Panel name")}), &["panel"]),
"Open a panel in the desktop app's dock by id (layers, swatches, stroke, align, pathfinder, transform, …; case-insensitive, display labels like \"Layers\" work too). Desktop app only.",
obj(json!({"panel": string("Panel id or display label")}), &["panel"]),
false,
),
tool(
+64 -9
View File
@@ -171,7 +171,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
("window.taskBar", "Contextual Task Bar", "", "{}"),
("window.dock", "Panels", "Tab", "{} show/hide all panels"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke"),
("window.panel", "Show Panel", "", "{panel: id} e.g. layers, swatches, stroke (case-insensitive; display labels like \"Layers\" work too)"),
(
"window.collapseDock",
"Collapse Panels to Icons",
@@ -711,6 +711,28 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
),
];
/// Canonical panel id for `window.panel`: the dock tabs (`properties`, `layers`, `libraries`)
/// and every `ICON_PANELS` id, matched case-insensitively. Each icon panel's display label
/// is accepted too, so `"Layers"` and `"Swatches"` work the way agents write them.
fn normalize_panel(input: &str) -> Option<&'static str> {
let name = input.trim();
if name.eq_ignore_ascii_case("properties") {
return Some("properties");
}
if name.eq_ignore_ascii_case("layers") {
return Some("layers");
}
if name.eq_ignore_ascii_case("libraries") {
return Some("libraries");
}
for &(id, label, _) in ICON_PANELS.iter() {
if name.eq_ignore_ascii_case(id) || name.eq_ignore_ascii_case(label) {
return Some(id);
}
}
None
}
/// Handle a UI command. `None` = not a UI command (the engine handles it).
pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
if id == "app.language" {
@@ -935,25 +957,29 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
Ok(json!(collapsed))
}
"window.panel" => {
let panel = s("panel").unwrap_or_default();
match (panel.as_str(), DockTab::from_id(&panel)) {
let raw = s("panel").unwrap_or_default();
// Canonical id (case-insensitive; display labels work too), then the
// dock tab it names, if any.
let canonical = normalize_panel(&raw);
let tab = canonical.and_then(DockTab::from_id);
match (canonical, tab) {
// A collapsed dock pops the panel out of its icon, like the icon panels.
(p, Some(tab)) if app.ui.dock_collapsed => {
(Some(p), Some(tab)) if app.ui.dock_collapsed => {
app.ui.dock_tab = tab;
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
(_, Some(tab)) => {
(Some(_), Some(tab)) => {
app.ui.dock_tab = tab;
Ok(Value::Null)
}
(p, None) if ICON_PANELS.iter().any(|(id, _, _)| *id == p) => {
(Some(p), None) => {
app.ui.open_panel = if app.ui.open_panel.as_deref() == Some(p) { None } else { Some(p.to_string()) };
app.ui.dock = true;
Ok(json!({"open": app.ui.open_panel}))
}
(other, None) => Err(format!("unknown panel `{other}`")),
(None, _) => Err(format!("unknown panel `{raw}`")),
}
}
"window.brightness" => match s("brightness").as_deref().and_then(Brightness::parse) {
@@ -1237,9 +1263,10 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
"window.taskBar" => app.ui.task_bar,
"window.panel" => {
let panel = p.get("panel").and_then(Value::as_str).unwrap_or("");
match DockTab::from_id(panel) {
let canonical = normalize_panel(panel);
match canonical.and_then(DockTab::from_id) {
Some(tab) if !app.ui.dock_collapsed => app.ui.dock_tab == tab,
_ => app.ui.open_panel.as_deref() == Some(panel),
_ => canonical.is_some_and(|id| app.ui.open_panel.as_deref() == Some(id)),
}
}
"window.collapseDock" => app.ui.dock_collapsed,
@@ -3167,6 +3194,34 @@ mod tests {
}
}
#[test]
fn panel_ids_are_case_insensitive_and_labels_work() {
assert_eq!(normalize_panel("layers"), Some("layers"));
assert_eq!(normalize_panel("Layers"), Some("layers"));
assert_eq!(normalize_panel("LAYERS"), Some("layers"));
assert_eq!(normalize_panel("Swatches"), Some("swatches"));
assert_eq!(normalize_panel("swatches"), Some("swatches"));
assert_eq!(normalize_panel("Color Guide"), Some("colorGuide"));
assert_eq!(normalize_panel("colorguide"), Some("colorGuide"));
assert_eq!(normalize_panel(" Stroke "), Some("stroke"));
assert_eq!(normalize_panel("nope"), None);
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
assert_eq!(app.ui.dock_tab, crate::state::DockTab::Layers);
app.run("window.panel", json!({"panel": "Swatches"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("swatches"));
assert!(checked(&app, "window.panel", &json!({"panel": "Swatches"})).unwrap());
assert!(app.run("window.panel", json!({"panel": "nope"})).is_err());
// A collapsed dock pops the panel out of its icon, by canonical id.
app.ui.dock_collapsed = true;
app.run("window.panel", json!({"panel": "Layers"})).unwrap();
assert_eq!(app.ui.open_panel.as_deref(), Some("layers"));
assert!(checked(&app, "window.panel", &json!({"panel": "Layers"})).unwrap());
app.ui.dock_collapsed = false;
}
#[test]
fn saved_views_store_and_restore_the_view() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());