diff --git a/ROADMAP.md b/ROADMAP.md index abe444f7..0d01d654 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -90,7 +90,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist - Actions panel (record/playback, persisted), generic parameter dialogs for every "…" command. - JSON-lines control channel with real egui pointer and keyboard injection. - MCP server with 25 tools (drawing, text, effects, Pathfinder, transforms, graphs, text wrap, export, screenshots, any command), attached to the running app or headless. Tool descriptions and replies agree with the engine: format lists come from its format table, `save_file` says it keeps the document's own format, `inspect_document` reports the paint type's characters show, and selection exports report the encoder's warnings. - - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). + - MCP protocol: five prompts (`prompts/list` / `prompts/get`: poster, icon set, recolour, trace-and-style, export set), `completion/complete` for prompt arguments and template variables read from the live catalogues (formats, effect ids, command ids, trace presets, swatch and object ids), four resource templates (`vectorcraft://object/{id}`, `//command/{id}`, `//effect/{id}`, `//swatch/{name}`) so an agent reads one thing instead of the whole document, and `logging/setLevel` with `notifications/message`. `window.panel` ids are case-insensitive (display labels work too), `vectorcraft://object/{id}` reads through `document.node {summary: true}` (the same reply bytes from one lookup instead of summarizing the whole document: a leaf read on a 2,000-object document drops from ~20 ms to ~0.06 ms), large containers slice with `document.node {depth?, childLimit?}` (truncated levels report `childCount`), and the CLI sweeps the protocol surface over stdio against the compiled binary. `docs/mcp.md` has a Protocol section covering the capability table and what is deliberately absent (subscriptions, progress, cancellation, sampling, elicitation, Streamable HTTP — all of which need the transport to interleave reads). - Headless CLI (`vectorcraft-cli run`, `convert`, `info`, `bench`, `perf`, `mcp`). - Actions panel that records and plays back commands. - **UI:** Illustrator 2026 layout restyled to measured values: diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 00e025cd..72766988 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,7 +25,7 @@ pub fn specs() -> Vec { 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, summary?: the compact document.inspect-style summary instead of the full object JSON} → one object", has_doc, doc_node), + cmd!(query "document.node", "Inspect Object", [], None, "{id, summary?: compact summary, depth?: child levels in the summary (default all), childLimit?: children shown per node (default all; a level that shows fewer reports childCount)} → 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,18 @@ fn doc_activate(s: &mut Session, p: &Value) -> Result { fn doc_node(s: &mut Session, p: &Value) -> Result { 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))?; - if bool_or(p, "summary", false) { Ok(inspect::node_summary(n)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } + if bool_or(p, "summary", false) { Ok(inspect::node_summary_opts(n, slice_opts(p)?)) } else { Ok(serde_json::to_value(n).unwrap_or(Value::Null)) } +} + +/// `depth` / `childLimit` for a summary read: absent (or null) means no limit. +/// A present value that is not a non-negative integer is rejected rather than +/// silently reinterpreted. +fn slice_opts(p: &Value) -> Result { + let opt = |key: &str| match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| bad("document.node", format!("`{key}` must be a non-negative integer"))), + }; + Ok(inspect::SummaryOpts { depth: opt("depth")?, child_limit: opt("childLimit")? }) } /// Document Setup's units (also `document.setup {units}`). diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index c48339ff..f9c9a7f2 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -12,8 +12,23 @@ fn rect_json(r: Option) -> Value { } } -/// Compact tree summary of a node (for `document.inspect`). +/// How much of the subtree a node summary carries. +#[derive(Clone, Copy, Default)] +pub struct SummaryOpts { + /// Child levels to include (`None`: the whole subtree). `0` is the node alone. + pub depth: Option, + /// Children shown per node (`None`: all, top of the stack first). A level that shows + /// fewer children than it has reports `childCount`, so truncation is never silent. + pub child_limit: Option, +} + +/// Compact tree summary of a node (for `document.inspect`): the whole subtree. pub fn node_summary(n: &Node) -> Value { + node_summary_opts(n, SummaryOpts::default()) +} + +/// The same summary with [`SummaryOpts`] applied. +pub fn node_summary_opts(n: &Node, opts: SummaryOpts) -> Value { let mut v = json!({ "id": n.id.0, "name": n.display_name(), @@ -69,7 +84,15 @@ pub fn node_summary(n: &Node) -> Value { _ => {} } if let Some(ch) = n.children() { - v["children"] = Value::Array(ch.iter().rev().map(|c| node_summary(c)).collect()); + let total = ch.len(); + let take_n = opts.child_limit.map_or(total, |l| l.min(total as u64) as usize); + if opts.depth.is_none_or(|d| d > 0) { + let next = SummaryOpts { depth: opts.depth.map(|d| d.saturating_sub(1)), child_limit: opts.child_limit }; + v["children"] = Value::Array(ch.iter().rev().take(take_n).map(|c| node_summary_opts(c, next)).collect()); + } + if take_n < total || v.get("children").is_none() { + v["childCount"] = json!(total); + } } v } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9b4b8c7d..8db40bf1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -364,6 +364,47 @@ fn document_node_summary_matches_inspect() { assert!(s.execute("document.node", &json!({"id": 999999, "summary": true})).is_err()); } +/// `depth` and `childLimit` slice a summary read; a level that shows fewer children +/// than it has reports `childCount`, and invalid values are rejected, not reinterpreted. +#[test] +fn document_node_summary_slices_with_depth_and_limit() { + let mut s = session(); + rect(&mut s, 0.0, 0.0, 10.0, 10.0); + rect(&mut s, 20.0, 0.0, 10.0, 10.0); + s.execute("select.all", &json!({})).unwrap(); + let inner = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + rect(&mut s, 40.0, 0.0, 10.0, 10.0); + s.execute("select.all", &json!({})).unwrap(); + let outer = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let mut read = |p: Value| s.execute("document.node", &p).unwrap(); + + let g = read(json!({"id": outer, "summary": true})); + assert_eq!(g["children"].as_array().map(Vec::len), Some(2)); + assert!(g.get("childCount").is_none(), "nothing truncated: {g}"); + + let one = read(json!({"id": outer, "summary": true, "childLimit": 1})); + assert_eq!(one["children"].as_array().map(Vec::len), Some(1)); + assert_eq!(one["childCount"], 2); + + let flat = read(json!({"id": outer, "summary": true, "depth": 0})); + assert!(flat.get("children").is_none(), "{flat}"); + assert_eq!(flat["childCount"], 2); + + // Limits compose per level: the outer children show; the inner group's own + // children don't, and it says so. + let shallow = read(json!({"id": outer, "summary": true, "depth": 1})); + let kids = shallow["children"].as_array().unwrap(); + assert_eq!(kids.len(), 2); + assert!(shallow.get("childCount").is_none(), "{shallow}"); + let nested = kids.iter().find(|n| n["id"] == inner).unwrap(); + assert!(nested.get("children").is_none(), "{nested}"); + assert_eq!(nested["childCount"], 2); + + for bad in [json!({"id": outer, "summary": true, "depth": -1}), json!({"id": outer, "summary": true, "childLimit": "many"})] { + assert!(s.execute("document.node", &bad).is_err(), "{bad}"); + } +} + /// 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() { diff --git a/crates/mcp/src/resources.rs b/crates/mcp/src/resources.rs index 8b94fd78..928e2e0f 100644 --- a/crates/mcp/src/resources.rs +++ b/crates/mcp/src/resources.rs @@ -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.node {summary: true})", + description: "The layer or object with this id, with its children, bounds and paint (document.node {summary: true}). Large containers can be sliced with run_command document.node {id, summary, depth, childLimit}: truncated levels report childCount", mime: "application/json", live: Live::Objects, }, diff --git a/docs/mcp.md b/docs/mcp.md index e01d70cf..ca4c6017 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -75,7 +75,7 @@ is thousands of lines an agent pays for again on every change. | `uriTemplate` | Reads | |---|---| -| `vectorcraft://object/{id}` | One layer or object with its children, bounds and paint: `document.node {id, summary: true}`, the node as `document.inspect` lists it | +| `vectorcraft://object/{id}` | One layer or object with its children, bounds and paint: `document.node {id, summary: true}`, the node as `document.inspect` lists it. A large container can be sliced with `run_command document.node {id, summary: true, depth?, childLimit?}` (a level that shows fewer children reports `childCount`) | | `vectorcraft://command/{id}` | One command: label, menu path, shortcut, parameter description, enablement | | `vectorcraft://effect/{id}` | One live effect with its parameters and defaults | | `vectorcraft://swatch/{name}` | One swatch, colour, gradient or pattern swatch (percent-encode spaces) |