mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 11:57:36 -06:00
W1: MCP, control channel and CLI address an open document by id
- MCP: execute, batch, type_text, select_text, get_text, inspect_document, render_page and save_document take an optional doc (an open document's id); the headless server runs them through Session::with_document and the bridge passes doc to the app. New list_documents tool; new_document replies with the new document's id. Tool descriptions and the server instructions say that file.new and file.open open another document. - Control channel: engine.execute, document.inspect and ui.render take doc and work on that document while the window keeps the active one; file.close on an addressed document refuses unsaved changes unless forced; ui.* commands need the document on screen. ui.inspect reports document and documents. Unknown or malformed ids are errors. - CLI run starts with one open document; help and docs describe how file.new, file.open and view.switchWindows work in a run. - docs/mcp.md and docs/control-protocol.md describe document ids and doc.
This commit is contained in:
@@ -36,7 +36,11 @@ USAGE:
|
||||
{\"file\"?, \"template\"?, \"commands\": [{\"command\": id,
|
||||
\"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save
|
||||
formats follow the extension (docx and pdf in one run);
|
||||
relative paths resolve against the script's folder
|
||||
relative paths resolve against the script's folder.
|
||||
file.new and file.open open another document and
|
||||
make it active; later commands and the saves work on
|
||||
the active one; view.switchWindows lists the open
|
||||
documents and with {\"id\": N} makes one active
|
||||
wordcraft-cli commands [--json] list every command
|
||||
wordcraft-cli parity [--markdown] feature-catalog parity
|
||||
wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR]
|
||||
@@ -472,10 +476,14 @@ fn run<S: AsRef<OsStr>>(args: &[S]) -> Result<(), Failure> {
|
||||
let mut s = match (from_file, from_template) {
|
||||
(Some(f), _) => open(&f, password.as_deref())?,
|
||||
// Both go through `file.new`, which puts a blank document on the region's paper (#3152).
|
||||
// `file.new` opens beside the session's first document, which is then closed: the
|
||||
// run starts with one open document.
|
||||
(None, t) => {
|
||||
let mut s = session(wordcraft_doc::Document::new());
|
||||
let first = s.document_id();
|
||||
let params = t.map_or_else(|| json!({}), |t| json!({"template": t}));
|
||||
s.run("file.new", ¶ms).map_err(|e| e.to_string())?;
|
||||
s.close_document(first).map_err(|e| e.to_string())?;
|
||||
s
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open
|
||||
//! another document and make it active, and `--save` saves the active one.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn run(args: &[&str], out: &Path) -> Output {
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
cli.arg("run").args(args).arg("--save").arg(out);
|
||||
cli.output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_saves_the_document_opened_last() {
|
||||
let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let (src, out) = (dir.join("opened.txt"), dir.join("out.txt"));
|
||||
std::fs::write(&src, "Opened\n").unwrap();
|
||||
let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy()));
|
||||
|
||||
// A script that starts a new document and types into it saves that document.
|
||||
let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out);
|
||||
let fresh = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A file opened after typing is what is saved; the typed document stays out of it.
|
||||
let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out);
|
||||
let reopened = std::fs::read_to_string(&out).unwrap_or_default();
|
||||
// A run starts with one open document.
|
||||
let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli"));
|
||||
let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr));
|
||||
assert_eq!(fresh.trim(), "Fresh");
|
||||
assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr));
|
||||
assert_eq!(reopened.trim(), "Opened");
|
||||
assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr));
|
||||
let stdout = String::from_utf8_lossy(&listed.stdout);
|
||||
assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}");
|
||||
}
|
||||
+52
-23
@@ -35,30 +35,59 @@ impl Headless {
|
||||
self
|
||||
}
|
||||
|
||||
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"engine.execute" | "command" => {
|
||||
let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
|
||||
let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
self.session.run(&id, &p).map_err(|e| e.to_string())
|
||||
}
|
||||
"engine.commands" => Ok(self.session.registry.describe()),
|
||||
"document.inspect" => self.session.run("document.inspect", ¶ms).map_err(|e| e.to_string()),
|
||||
"ui.parity" => Ok(wordcraft_engine::catalog::parity(&self.session.registry)),
|
||||
"ui.render" | "render.page" => {
|
||||
let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1;
|
||||
let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32;
|
||||
let l = self.session.layout();
|
||||
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default());
|
||||
let png = img.to_png();
|
||||
Ok(
|
||||
json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}),
|
||||
)
|
||||
}
|
||||
m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")),
|
||||
other => self.session.run(other, ¶ms).map_err(|e| e.to_string()),
|
||||
/// Answer `method`; a document method addressed to another open document (`doc`) runs on
|
||||
/// that document, and the active one stays active.
|
||||
fn answer(&mut self, method: &str, mut params: Value) -> Result<Value, String> {
|
||||
if !DOC_METHODS.contains(&method) {
|
||||
return answer_on(&mut self.session, method, params);
|
||||
}
|
||||
let doc = doc_param(¶ms)?;
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.remove("doc");
|
||||
}
|
||||
match doc {
|
||||
None => answer_on(&mut self.session, method, params),
|
||||
Some(id) => self.session.with_document(id, |s| answer_on(s, method, params)).map_err(|e| e.to_string())?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The methods that work on one document, and so take `doc`: the id of the open document to work
|
||||
/// on (`view.switchWindows` lists them), the active one when absent.
|
||||
pub const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render", "render.page"];
|
||||
|
||||
/// The document a request addresses with `doc`: `None` (absent or null) is the active document.
|
||||
/// Anything but a whole number from 0 up is an error; whether it names an open document is for
|
||||
/// the session to say.
|
||||
pub fn doc_param(params: &Value) -> Result<Option<u64>, String> {
|
||||
match params.get("doc") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().map(Some).ok_or_else(|| "`doc` must be the id of an open document (list_documents lists them)".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn answer_on(session: &mut Session, method: &str, params: Value) -> Result<Value, String> {
|
||||
match method {
|
||||
"engine.execute" | "command" => {
|
||||
let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string();
|
||||
let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
session.run(&id, &p).map_err(|e| e.to_string())
|
||||
}
|
||||
"engine.commands" => Ok(session.registry.describe()),
|
||||
"document.inspect" => session.run("document.inspect", ¶ms).map_err(|e| e.to_string()),
|
||||
"ui.parity" => Ok(wordcraft_engine::catalog::parity(&session.registry)),
|
||||
"ui.render" | "render.page" => {
|
||||
let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1);
|
||||
let page = usize::try_from(page).unwrap_or(usize::MAX).saturating_sub(1);
|
||||
let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32;
|
||||
let l = session.layout();
|
||||
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?;
|
||||
let img = wordcraft_render::render_page(&session.doc, pg, scale, &Default::default());
|
||||
let png = img.to_png();
|
||||
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
|
||||
}
|
||||
m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")),
|
||||
other => session.run(other, ¶ms).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,10 @@ const INSTRUCTIONS: &str = "WordCraft is a word processor (a Microsoft Word clon
|
||||
parameters with list_commands and run them with execute (or several with batch). Positions are \
|
||||
{story:\"body\", path:[block,(row,cell,block)...], off:byteOffset}. Typical flow: new_document → type_text \
|
||||
\"Hello\" → execute text.newParagraph → execute para.style {style:\"Heading 1\"} → select_text {text:\"Hello\"} → \
|
||||
execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}.";
|
||||
execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}. \
|
||||
Several documents can be open: new_document and open_document open another one and make it active; \
|
||||
list_documents gives their ids, and `doc` on execute, batch and the other document tools works on one \
|
||||
without switching.";
|
||||
|
||||
/// Resource URIs.
|
||||
pub const DOC_URI: &str = "wordcraft://document";
|
||||
|
||||
@@ -152,3 +152,45 @@ fn type_text_stops_at_a_refused_paragraph_break() {
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}");
|
||||
assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]);
|
||||
}
|
||||
|
||||
/// W1: with several documents open, `doc` sends a tool to one of them; the active document stays
|
||||
/// active and unchanged, and an id that names no open document is an error.
|
||||
#[test]
|
||||
fn tools_address_an_open_document_by_id() {
|
||||
let mut s = Server::new(Box::new(Headless::default()));
|
||||
let first = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id");
|
||||
tool(&mut s, "type_text", json!({"text": "First"}));
|
||||
let second = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id");
|
||||
assert_ne!(first, second);
|
||||
tool(&mut s, "type_text", json!({"text": "Second"}));
|
||||
|
||||
tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first}));
|
||||
tool(&mut s, "batch", json!({"commands": [{"command": "text.insert", "params": {"text": " again"}}], "doc": first}));
|
||||
assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again");
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second");
|
||||
let inspected = tool(&mut s, "inspect_document", json!({"doc": first}));
|
||||
assert_eq!(inspected["blocks"][0]["text"], "First more again");
|
||||
let r = call(&mut s, 12, "tools/call", json!({"name": "render_page", "arguments": {"doc": first, "scale": 0.2}}));
|
||||
assert_eq!(r["result"]["content"][0]["type"], "image", "{r}");
|
||||
|
||||
let list = tool(&mut s, "list_documents", json!({}));
|
||||
let docs = list["documents"].as_array().unwrap();
|
||||
let ids: Vec<u64> = docs.iter().filter_map(|d| d["id"].as_u64()).collect();
|
||||
assert!(ids.contains(&first) && ids.contains(&second), "{list}");
|
||||
let active: Vec<u64> = docs.iter().filter(|d| d["active"] == true).filter_map(|d| d["id"].as_u64()).collect();
|
||||
assert_eq!(active, vec![second]);
|
||||
|
||||
for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] {
|
||||
for (name, args) in [
|
||||
("execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})),
|
||||
("get_text", json!({"doc": bad})),
|
||||
("inspect_document", json!({"doc": bad})),
|
||||
("render_page", json!({"doc": bad})),
|
||||
] {
|
||||
let r = call(&mut s, 13, "tools/call", json!({"name": name, "arguments": args}));
|
||||
assert_eq!(r["result"]["isError"], true, "{name} with doc {bad}: {r}");
|
||||
}
|
||||
}
|
||||
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second");
|
||||
assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again");
|
||||
}
|
||||
|
||||
+94
-29
@@ -48,6 +48,16 @@ fn n(desc: &str) -> Value {
|
||||
fn obj(props: Value, req: &[&str]) -> Value {
|
||||
json!({"type": "object", "properties": props, "required": req})
|
||||
}
|
||||
/// Adds the optional `doc` property to a tool's schema.
|
||||
fn with_doc(mut schema: Value) -> Value {
|
||||
if let Some(p) = schema.get_mut("properties").and_then(Value::as_object_mut) {
|
||||
p.insert("doc".into(), json!({"type": "integer", "minimum": 0, "description": DOC_DESC}));
|
||||
}
|
||||
schema
|
||||
}
|
||||
|
||||
const DOC_DESC: &str = "Id of the open document to work on (list_documents); the active document when left out. The active document stays active.";
|
||||
|
||||
fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value {
|
||||
json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}})
|
||||
}
|
||||
@@ -64,31 +74,31 @@ pub fn tool_definitions() -> Value {
|
||||
tool(
|
||||
"execute",
|
||||
"Run a command",
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.",
|
||||
obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]),
|
||||
"Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.",
|
||||
with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"batch",
|
||||
"Run several commands",
|
||||
"Run commands in order; stops at the first error unless keepGoing.",
|
||||
obj(
|
||||
"Run commands in order; stops at the first error unless keepGoing. `doc` sends every command to that open document.",
|
||||
with_doc(obj(
|
||||
json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}),
|
||||
&["commands"]
|
||||
),
|
||||
)),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"new_document",
|
||||
"New document",
|
||||
"Start a document: blank, sample, letter, resume or report.",
|
||||
"Start a document: blank, sample, letter, resume or report. It opens beside the documents already open and becomes the active one; the reply's `id` addresses it (`doc`).",
|
||||
obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"open_document",
|
||||
"Open",
|
||||
"Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one.",
|
||||
"Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one. The file opens beside the documents already open (it replaces a blank one nobody has used) and becomes the active one; a file that is already open becomes active again (`alreadyOpen`). The reply's `id` addresses it (`doc`).",
|
||||
obj(json!({"path": s("File path"), "password": s("Password, if the file is protected")}), &["path"]),
|
||||
false
|
||||
),
|
||||
@@ -96,36 +106,43 @@ pub fn tool_definitions() -> Value {
|
||||
"save_document",
|
||||
"Save",
|
||||
"Save (format from the extension: docx, pdf, odt, rtf, html, md, tex, txt, png).",
|
||||
obj(json!({"path": s("File path; omit to save in place")}), &[]),
|
||||
with_doc(obj(json!({"path": s("File path; omit to save in place")}), &[])),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"type_text",
|
||||
"Type text",
|
||||
"Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.",
|
||||
obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]),
|
||||
with_doc(obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"])),
|
||||
false
|
||||
),
|
||||
tool(
|
||||
"select_text",
|
||||
"Select text",
|
||||
"Select the n-th occurrence of some text.",
|
||||
obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]),
|
||||
with_doc(obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"])),
|
||||
false
|
||||
),
|
||||
tool("get_text", "Document text", "Body text with tracked deletions omitted.", obj(json!({}), &[]), true),
|
||||
tool("get_text", "Document text", "Body text with tracked deletions omitted.", with_doc(obj(json!({}), &[])), true),
|
||||
tool(
|
||||
"inspect_document",
|
||||
"Inspect document",
|
||||
"Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.",
|
||||
obj(json!({}), &[]),
|
||||
with_doc(obj(json!({}), &[])),
|
||||
true
|
||||
),
|
||||
tool(
|
||||
"render_page",
|
||||
"Render page",
|
||||
"Render a page to PNG to look at the result.",
|
||||
obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]),
|
||||
with_doc(obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[])),
|
||||
true
|
||||
),
|
||||
tool(
|
||||
"list_documents",
|
||||
"Open documents",
|
||||
"The open documents in the order they were opened: id, title, path, dirty (unsaved changes) and active (the one commands work on by default). new_document and open_document open another document beside these. Give a document's id as `doc` to execute, batch, type_text, select_text, get_text, inspect_document, render_page or save_document to work on it while it stays in the background; execute view.switchWindows {id} makes it the active one, file.closeDocument {id, force?} closes it.",
|
||||
obj(json!({}), &[]),
|
||||
true
|
||||
),
|
||||
tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true),
|
||||
@@ -154,8 +171,31 @@ pub fn tool_definitions() -> Value {
|
||||
])
|
||||
}
|
||||
|
||||
fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result<Value, String> {
|
||||
b.call("engine.execute", json!({"command": id, "params": params}))
|
||||
fn exec(b: &mut dyn Backend, id: &str, params: Value, doc: Option<&Value>) -> Result<Value, String> {
|
||||
b.call("engine.execute", addressed(json!({"command": id, "params": params}), doc))
|
||||
}
|
||||
|
||||
/// `params` with the tool's `doc`, when it gave one. The backend checks it: a session says
|
||||
/// whether it names an open document.
|
||||
fn addressed(mut params: Value, doc: Option<&Value>) -> Value {
|
||||
if let (Some(d), Some(o)) = (doc, params.as_object_mut()) {
|
||||
o.insert("doc".into(), d.clone());
|
||||
}
|
||||
params
|
||||
}
|
||||
|
||||
/// The active document's id, added to a reply that opened or made one.
|
||||
fn with_active_id(b: &mut dyn Backend, r: Result<Value, String>) -> Result<Value, String> {
|
||||
let mut v = r?;
|
||||
if v.get("id").is_none()
|
||||
&& let Ok(list) = exec(b, "view.switchWindows", json!({}), None)
|
||||
&& let Some(id) =
|
||||
list.get("documents").and_then(Value::as_array).and_then(|d| d.iter().find(|d| d["active"] == true)).and_then(|d| d.get("id"))
|
||||
&& let Some(o) = v.as_object_mut()
|
||||
{
|
||||
o.insert("id".into(), id.clone());
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
fn wrap(r: Result<Value, String>) -> ToolResult {
|
||||
@@ -167,6 +207,7 @@ fn wrap(r: Result<Value, String>) -> ToolResult {
|
||||
|
||||
pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
let st = |k: &str| a.get(k).and_then(Value::as_str);
|
||||
let doc = a.get("doc").filter(|d| !d.is_null());
|
||||
match name {
|
||||
"list_commands" => {
|
||||
let q = st("query").unwrap_or("").to_lowercase();
|
||||
@@ -188,7 +229,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
}
|
||||
}
|
||||
"execute" => match st("command") {
|
||||
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))),
|
||||
Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)),
|
||||
None => ToolResult::error("missing `command`"),
|
||||
},
|
||||
"batch" => {
|
||||
@@ -196,7 +237,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
let mut out = Vec::new();
|
||||
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})));
|
||||
let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})), doc);
|
||||
let failed = r.is_err();
|
||||
out.push(match r {
|
||||
Ok(v) => json!({"command": id, "ok": true, "result": v}),
|
||||
@@ -208,13 +249,17 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
}
|
||||
ToolResult::json(&Value::Array(out))
|
||||
}
|
||||
"new_document" => wrap(exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}))),
|
||||
"open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}))),
|
||||
"new_document" => {
|
||||
let r = exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}), None);
|
||||
wrap(with_active_id(b, r))
|
||||
}
|
||||
"open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}), None)),
|
||||
"list_documents" => wrap(exec(b, "view.switchWindows", json!({}), None)),
|
||||
"save_document" => {
|
||||
let p = st("path");
|
||||
let ext = p.and_then(|x| x.rsplit('.').next()).unwrap_or("").to_ascii_lowercase();
|
||||
if ext == "png" {
|
||||
wrap(exec(b, "file.exportPng", json!({"path": p})))
|
||||
wrap(exec(b, "file.exportPng", json!({"path": p}), doc))
|
||||
} else {
|
||||
wrap(exec(
|
||||
b,
|
||||
@@ -223,6 +268,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
Some(p) => json!({"path": p}),
|
||||
None => json!({}),
|
||||
},
|
||||
doc,
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -232,13 +278,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
let mut last = Ok(Value::Null);
|
||||
for (i, line) in t.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
last = exec(b, "text.newParagraph", json!({}));
|
||||
last = exec(b, "text.newParagraph", json!({}), doc);
|
||||
if last.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !line.is_empty() {
|
||||
last = exec(b, "text.insert", json!({"text": line, "raw": true}));
|
||||
last = exec(b, "text.insert", json!({"text": line, "raw": true}), doc);
|
||||
}
|
||||
if last.is_err() {
|
||||
break;
|
||||
@@ -246,19 +292,23 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
}
|
||||
wrap(last)
|
||||
} else {
|
||||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
|
||||
wrap(exec(b, "text.insert", json!({"text": t, "raw": true}), doc))
|
||||
}
|
||||
}
|
||||
"select_text" => {
|
||||
wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))})))
|
||||
}
|
||||
"get_text" => wrap(exec(b, "document.text", json!({}))),
|
||||
"inspect_document" => wrap(b.call("document.inspect", json!({}))),
|
||||
"select_text" => wrap(exec(
|
||||
b,
|
||||
"select.text",
|
||||
json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}),
|
||||
doc,
|
||||
)),
|
||||
"get_text" => wrap(exec(b, "document.text", json!({}), doc)),
|
||||
"inspect_document" => wrap(b.call("document.inspect", addressed(json!({}), doc))),
|
||||
"parity" => wrap(b.call("ui.parity", json!({}))),
|
||||
// Pictures travel inside the reply (`png`), headless or through the app: no file with a
|
||||
// predictable name in the shared temporary folder.
|
||||
"render_page" => {
|
||||
let params = json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))});
|
||||
let params =
|
||||
addressed(json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}), doc);
|
||||
match b.call("ui.render", params) {
|
||||
Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})),
|
||||
Err(e) => ToolResult::error(e),
|
||||
@@ -323,6 +373,21 @@ mod tests {
|
||||
assert_eq!(forwarded(json!({"path": "/tmp/a.docx"}))["password"], Value::Null);
|
||||
}
|
||||
|
||||
/// `doc` travels beside the command, not inside its params, so the app's control channel can
|
||||
/// run the command on that document; without one nothing is added.
|
||||
#[test]
|
||||
fn doc_is_forwarded_beside_the_command() {
|
||||
let mut b = Recording { seen: None };
|
||||
call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}, "doc": 7}));
|
||||
let (_, call) = b.seen.take().expect("the backend was called");
|
||||
assert_eq!(call["doc"], json!(7));
|
||||
assert!(call["params"].get("doc").is_none(), "{call}");
|
||||
call_tool(&mut b, "inspect_document", &json!({"doc": 7}));
|
||||
assert_eq!(b.seen.take().expect("called").1["doc"], json!(7));
|
||||
call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}}));
|
||||
assert!(b.seen.take().expect("called").1.get("doc").is_none());
|
||||
}
|
||||
|
||||
/// The password has to be in the schema, or a client has no way to know it can be sent.
|
||||
#[test]
|
||||
fn the_schema_advertises_the_password_as_optional() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Programmatic control of the running app (agents, tests, MCP bridge).
|
||||
//!
|
||||
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
|
||||
//! - `engine.execute {command, params}`: run any command (engine or `ui.*`)
|
||||
//! - `engine.execute {command, params, doc?}`: run any command (engine or `ui.*`)
|
||||
//! - `engine.commands {search?}`: engine and app print commands with enablement and parameters
|
||||
//! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf
|
||||
//! - `ui.wheel {x, y, dx?, dy, unit?, phase?}`: a mouse-wheel / trackpad scroll at a window point.
|
||||
@@ -12,6 +12,10 @@
|
||||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, page?, scale?}`: page PNG
|
||||
//! (without `path`, both reply with the PNG as base64 `png`)
|
||||
//! - `ui.parity`: feature catalog parity
|
||||
//!
|
||||
//! `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of an open document
|
||||
//! (`view.switchWindows` lists them): they work on that document while the window keeps showing
|
||||
//! the active one. Without `doc` they work on the active document.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
@@ -61,6 +65,8 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
|
||||
"focused": app.canvas.focused,
|
||||
"title": app.title_stem(),
|
||||
"dirty": app.session.dirty,
|
||||
"document": app.session.document_id(),
|
||||
"documents": app.session.documents(),
|
||||
"perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms},
|
||||
})
|
||||
}
|
||||
@@ -113,18 +119,99 @@ pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<&
|
||||
return None;
|
||||
}
|
||||
if id == "file.close" && app.session.dirty {
|
||||
return Some("the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close");
|
||||
return Some(UNSAVED);
|
||||
}
|
||||
// Comment text still being typed is unsaved work too (#1348): replacing the document would drop it.
|
||||
(crate::discards_document(id, params) && crate::panes::has_comment_drafts(app))
|
||||
.then_some("a comment is still being edited: finish it first, or pass {\"force\": true} to discard it")
|
||||
}
|
||||
|
||||
const UNSAVED: &str = "the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close";
|
||||
|
||||
/// The reply to a request that needs a document while none is open (#1129).
|
||||
const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first";
|
||||
|
||||
/// The methods that take `doc`.
|
||||
const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render"];
|
||||
|
||||
/// The open document other than the active one that a request's `doc` names; `None` for the
|
||||
/// active document (no `doc`, null, or the active document's id). An id that names no open
|
||||
/// document is an error.
|
||||
fn addressed(app: &WordApp, p: &Value) -> Result<Option<u64>, String> {
|
||||
let id = match p.get("doc") {
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v.as_u64().ok_or("`doc` must be the id of an open document (view.switchWindows lists them)")?,
|
||||
};
|
||||
if !app.session.is_open(id) {
|
||||
return Err(format!("no open document has id {id}"));
|
||||
}
|
||||
Ok((id != app.session.document_id()).then_some(id))
|
||||
}
|
||||
|
||||
/// The reply to work run on another document: its result, or why it couldn't run or failed.
|
||||
fn reply<E: std::fmt::Display, F: std::fmt::Display>(r: Result<Result<Value, E>, F>) -> Outcome {
|
||||
match r {
|
||||
Ok(Ok(v)) => ok(v),
|
||||
Ok(Err(e)) => err(e),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// A document method addressed to open document `doc`, which isn't the active one: it runs on
|
||||
/// that document, and the window keeps showing the active one.
|
||||
fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome {
|
||||
if app.no_document == Some(doc) {
|
||||
return err(NO_DOCUMENT);
|
||||
}
|
||||
match method {
|
||||
"document.inspect" => reply(app.session.with_document(doc, |s| s.run("document.inspect", p))),
|
||||
"ui.render" => {
|
||||
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1);
|
||||
let scale = p.get("scale").and_then(Value::as_f64).map_or(2.0, |v| v as f32);
|
||||
let path = p.get("path").and_then(Value::as_str);
|
||||
let r = app.session.with_document(doc, |s| match path {
|
||||
Some(path) => s.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})).map_err(|e| e.to_string()),
|
||||
None => render_png(s, page, scale),
|
||||
});
|
||||
reply(r)
|
||||
}
|
||||
_ => {
|
||||
let Some(id) = p.get("command").or(p.get("id")).and_then(Value::as_str) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
if id.starts_with("ui.") {
|
||||
return err(format!("`{id}` works on the window: make the document active first (view.switchWindows {{\"id\": {doc}}})"));
|
||||
}
|
||||
// Close, with the same guard as the window's: unsaved changes need `force`.
|
||||
if id == "file.close" {
|
||||
let force = params.get("force").and_then(Value::as_bool) == Some(true);
|
||||
if !force && app.session.documents().iter().any(|d| d.id == doc && d.dirty) {
|
||||
return err(UNSAVED);
|
||||
}
|
||||
return match app.session.close_document(doc) {
|
||||
Ok(()) => ok(json!({"closed": true, "id": doc, "documents": app.session.documents()})),
|
||||
Err(e) => err(e),
|
||||
};
|
||||
}
|
||||
let r = app.session.with_document(doc, |s| s.run(id, ¶ms));
|
||||
// UI requests the command made carry `document`, so the window can tell they aren't
|
||||
// about the document it shows.
|
||||
for req in std::mem::take(&mut app.session.ui_requests) {
|
||||
app.handle_request(&req);
|
||||
}
|
||||
reply(r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
if DOC_METHODS.contains(&req.method.as_str()) {
|
||||
match addressed(app, p) {
|
||||
Ok(Some(doc)) => return handle_in(app, doc, &req.method, p),
|
||||
Ok(None) => {}
|
||||
Err(e) => return err(e),
|
||||
}
|
||||
}
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32);
|
||||
match req.method.as_str() {
|
||||
@@ -263,7 +350,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
|
||||
},
|
||||
// No path: the PNG travels in the reply. Never a fixed name in the shared
|
||||
// temporary folder, where another user could have planted a link to a file.
|
||||
None => match render_png(app, page, scale) {
|
||||
None => match render_png(&app.session, page, scale) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
@@ -298,11 +385,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O
|
||||
|
||||
/// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless
|
||||
/// MCP server's `ui.render`).
|
||||
fn render_png(app: &WordApp, page: u64, scale: f32) -> Result<Value, String> {
|
||||
fn render_png(session: &wordcraft_engine::Session, page: u64, scale: f32) -> Result<Value, String> {
|
||||
let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1);
|
||||
let l = app.session.export_layout();
|
||||
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
|
||||
let img = wordcraft_render::render_page(&app.session.doc, pg, scale.clamp(0.1, 8.0), &Default::default());
|
||||
let l = session.export_layout();
|
||||
let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?;
|
||||
let img = wordcraft_render::render_page(&session.doc, pg, scale.clamp(0.1, 8.0), &Default::default());
|
||||
let png = img.to_png();
|
||||
Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}))
|
||||
}
|
||||
@@ -327,3 +414,7 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
}
|
||||
json!({"ok": true, "result": {"path": path, "width": w, "height": h}})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "control_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! The control channel's `doc`: methods addressed to an open document that isn't the active one.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
use super::{ControlRequest, Outcome, handle};
|
||||
use crate::{Services, WordApp};
|
||||
|
||||
fn app() -> WordApp {
|
||||
WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default())
|
||||
}
|
||||
|
||||
/// Send one request the way the control channel does and return the reply.
|
||||
fn send(a: &mut WordApp, method: &str, params: Value) -> Value {
|
||||
let ctx = egui::Context::default();
|
||||
let (req, _reply) = ControlRequest::new(method, params);
|
||||
match handle(a, &ctx, &req) {
|
||||
Outcome::Done(v) => v,
|
||||
Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a reply"}),
|
||||
}
|
||||
}
|
||||
|
||||
fn exec(a: &mut WordApp, command: &str, params: Value) -> Value {
|
||||
send(a, "engine.execute", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
fn body(a: &WordApp) -> String {
|
||||
a.session.doc.plain_text(wordcraft_doc::StoryRef::Body)
|
||||
}
|
||||
|
||||
/// Two documents, the first typed in and parked behind the second: (first, second).
|
||||
fn two_documents(a: &mut WordApp) -> (u64, u64) {
|
||||
let first = a.session.document_id();
|
||||
assert_eq!(exec(a, "text.insert", json!({"text": "First"}))["ok"], true);
|
||||
assert_eq!(exec(a, "file.new", json!({}))["ok"], true);
|
||||
let second = a.session.document_id();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(exec(a, "text.insert", json!({"text": "Second"}))["ok"], true);
|
||||
(first, second)
|
||||
}
|
||||
|
||||
/// W1: `doc` sends a command, an inspection or a render to a document in the background; the
|
||||
/// window's document stays active and unchanged, and an id that names no open document is an
|
||||
/// error.
|
||||
#[test]
|
||||
fn methods_address_an_open_document_by_id() {
|
||||
let mut a = app();
|
||||
let (first, second) = two_documents(&mut a);
|
||||
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(a.session.document_id(), second);
|
||||
assert_eq!(body(&a).trim(), "Second");
|
||||
let r = send(&mut a, "document.inspect", json!({"doc": first}));
|
||||
assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}");
|
||||
let r = send(&mut a, "ui.render", json!({"doc": first, "scale": 0.2}));
|
||||
assert!(r["result"]["png"].as_str().is_some_and(|p| !p.is_empty()), "{r}");
|
||||
// The active document's own id works like no `doc` at all.
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "!"}, "doc": second}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert_eq!(body(&a).trim(), "Second!");
|
||||
|
||||
let r = send(&mut a, "ui.inspect", json!({}));
|
||||
assert_eq!(r["result"]["document"], second, "{r}");
|
||||
let ids: Vec<u64> = r["result"]["documents"].as_array().into_iter().flatten().filter_map(|d| d["id"].as_u64()).collect();
|
||||
assert_eq!(ids, vec![first, second]);
|
||||
|
||||
// Window commands need the document on screen.
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "ui.tab", "params": {"value": "insert"}, "doc": first}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
|
||||
for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] {
|
||||
for (method, params) in [
|
||||
("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})),
|
||||
("document.inspect", json!({"doc": bad})),
|
||||
("ui.render", json!({"doc": bad})),
|
||||
] {
|
||||
let r = send(&mut a, method, params);
|
||||
assert_eq!(r["ok"], false, "{method} with doc {bad}: {r}");
|
||||
}
|
||||
}
|
||||
assert_eq!(a.session.document_id(), second);
|
||||
assert_eq!(body(&a).trim(), "Second!");
|
||||
let r = send(&mut a, "document.inspect", json!({"doc": first}));
|
||||
assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}");
|
||||
}
|
||||
|
||||
/// W1: `file.close` addressed to a document in the background refuses its unsaved changes
|
||||
/// unless forced (only a boolean true), as it does for the window's document (#1268), and closes
|
||||
/// only that document.
|
||||
#[test]
|
||||
fn closing_an_addressed_document_keeps_unsaved_work_unless_forced() {
|
||||
let mut a = app();
|
||||
let (first, second) = two_documents(&mut a);
|
||||
|
||||
for params in [json!({}), json!({"force": "true"}), json!({"force": 1})] {
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": params, "doc": first}));
|
||||
assert_eq!(r["ok"], false, "{r}");
|
||||
let e = r["error"].as_str().unwrap_or("");
|
||||
assert!(e.contains("unsaved") && e.contains("force"), "{e}");
|
||||
assert!(a.session.is_open(first));
|
||||
}
|
||||
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": {"force": true}, "doc": first}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!a.session.is_open(first));
|
||||
assert_eq!(a.session.document_id(), second);
|
||||
assert!(a.has_document() && !a.quit_requested);
|
||||
assert_eq!(body(&a).trim(), "Second");
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": first}));
|
||||
assert_eq!(r["ok"], false, "a closed document can't be addressed: {r}");
|
||||
|
||||
// A document without unsaved changes closes at once.
|
||||
assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true);
|
||||
let clean = a.session.document_id();
|
||||
assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true);
|
||||
let active = a.session.document_id();
|
||||
let r = send(&mut a, "engine.execute", json!({"command": "file.close", "doc": clean}));
|
||||
assert_eq!(r["ok"], true, "{r}");
|
||||
assert!(!a.session.is_open(clean) && a.session.is_open(second));
|
||||
assert_eq!(a.session.document_id(), active);
|
||||
}
|
||||
@@ -52,10 +52,10 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
|
||||
| Method | Params | Result |
|
||||
|---|---|---|
|
||||
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) |
|
||||
| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) |
|
||||
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
|
||||
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |
|
||||
| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
|
||||
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) |
|
||||
| `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates |
|
||||
| `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page |
|
||||
| `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag |
|
||||
@@ -64,12 +64,36 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
|
||||
| `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) |
|
||||
| `ui.text` | `text` | typed text through egui |
|
||||
| `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply |
|
||||
| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
|
||||
| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) |
|
||||
| `ui.parity` | — | feature-catalog parity |
|
||||
| `ui.resize` / `ui.focus` / `app.quit` | | window control |
|
||||
|
||||
Any other method name is treated as a command id: `{"method": "format.bold"}` works.
|
||||
|
||||
### Open documents
|
||||
|
||||
Several documents can be open at once. Each has an id, a whole number that stays the same while
|
||||
the document is open and is never reused by the running app. One document is the active one: the
|
||||
window shows it, and commands work on it unless told otherwise.
|
||||
|
||||
- `file.new` opens another document and makes it active. `file.open` does the same with a file;
|
||||
it replaces a blank document nobody has used yet, and a file that is already open becomes
|
||||
active again (`"alreadyOpen": true`). Its result carries the document's `id`.
|
||||
- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path",
|
||||
"dirty", "active"}]}`); with `{"id": n}` it makes that document active.
|
||||
- `file.closeDocument {"id"?, "force"?}` closes a document without touching the window.
|
||||
- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work
|
||||
on, next to their other params. The command runs on that document and the window keeps showing
|
||||
the active one:
|
||||
|
||||
```json
|
||||
{"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}}
|
||||
```
|
||||
|
||||
`doc` left out, or `null`, is the active document. An id that names no open document, or a value
|
||||
that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work
|
||||
on the document on screen only: with the id of another document they are an error.
|
||||
|
||||
The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed
|
||||
in [MCP server](mcp.md#character-and-paragraph-properties).
|
||||
|
||||
@@ -94,7 +118,9 @@ stay); save first (`file.save`), or pass `{"force": true}` to discard the change
|
||||
The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is
|
||||
guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved
|
||||
changes closes at once (#1268). Closing closes the document, not the app: the window stays and
|
||||
shows no page, and `file.new` or `file.open` goes on from there (#1712).
|
||||
shows no page, and `file.new` or `file.open` goes on from there (#1712). With `doc` naming a
|
||||
document in the background, `file.close` closes that document under the same rule (unsaved
|
||||
changes need `{"force": true}`), and the window keeps showing the active one.
|
||||
|
||||
### Native printing
|
||||
|
||||
|
||||
+28
-2
@@ -33,7 +33,10 @@ PDF in one run without a window:
|
||||
line win. A command is `{"command": id, "params": {…}}` or just the id. `save` is a path or a list;
|
||||
each output's format follows its extension. Relative `file` and `save` paths resolve against the
|
||||
script's folder (the working folder for stdin). `--cmd` flags run after the script's commands and
|
||||
`--save` after its outputs. A failing command stops the run with `command N (id): …` and exit
|
||||
`--save` after its outputs. The run starts with one open document. `file.new` and `file.open`
|
||||
open another one and make it active; later commands and the saves work on the active document.
|
||||
`view.switchWindows` lists the open documents (with `--print` their ids show), and
|
||||
`view.switchWindows={"id": n}` makes one active. A failing command stops the run with `command N (id): …` and exit
|
||||
status 1, and nothing is saved. Every output is checked (known extension, folder exists) before the first is written. Paths inside command params (a picture to insert, say) are relative to the working folder, not the script's. Scripts are capped at 4 MiB.
|
||||
|
||||
## Tracked changes by an agent
|
||||
@@ -106,8 +109,9 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `list_commands` | every command (filter with `query`) — over 270 of them |
|
||||
| `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` |
|
||||
| `batch` | run several commands in order |
|
||||
| `new_document` | blank, sample, letter, resume, report |
|
||||
| `new_document` | blank, sample, letter, resume, report; opens beside the open documents |
|
||||
| `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png |
|
||||
| `list_documents` | the open documents: `id`, `title`, `path`, `dirty`, `active` |
|
||||
| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) |
|
||||
| `select_text` | select the n-th occurrence of some text |
|
||||
| `get_text` / `inspect_document` | read the document back (verify without screenshots) |
|
||||
@@ -115,6 +119,28 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `parity` | feature coverage |
|
||||
| `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) |
|
||||
|
||||
### Several documents
|
||||
|
||||
`new_document` and `open_document` (the commands `file.new` and `file.open`) open another
|
||||
document beside the ones already open and make it the active one; their reply carries its `id`.
|
||||
`open_document` replaces a blank document nobody has used, and a file that is already open becomes
|
||||
active again (`alreadyOpen`). `list_documents` lists the open documents with their ids.
|
||||
|
||||
Tools work on the active document. `execute`, `batch`, `type_text`, `select_text`, `get_text`,
|
||||
`inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open
|
||||
document: the tool works on that document and the active one stays active.
|
||||
|
||||
```json
|
||||
{"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}}
|
||||
```
|
||||
|
||||
An id that names no open document, or a value that isn't a whole number from 0 up, is a tool error.
|
||||
`execute` with `view.switchWindows {"id": n}` makes a document active, and `file.closeDocument
|
||||
{"id": n, "force"?: true}` closes one (unsaved changes need `force`). Through `--connect`, `doc`
|
||||
reaches the app's control channel (see [Open documents](control-protocol.md#open-documents)); the
|
||||
app's window keeps showing the document it showed. `screenshot`, `click` and `key` act on the
|
||||
window, so they work on the document on screen.
|
||||
|
||||
`insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't
|
||||
replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user