From c8f103c1487ac29db562ca241893df6e02ceb033 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 24 Oct 2026 18:14:33 +0800 Subject: [PATCH 1/2] MCP: type_text with paragraphs is one command (#3973) It was a text.newParagraph and a text.insert per line. Each Session::run that edits snapshots the whole document for Undo and for rollback, so a command costs about the document's size: 10,000 lines took 2.8 s through MCP (release build; doubling the lines cost 2.4-3.2x), and left an Undo step per line. text.insert already takes a carriage return as a paragraph break, so the lines go in as one command: ~10 ms for 10,000 (linear), one Undo step. Measured per call on a 10,000-paragraph document (release, microseconds): text.newParagraph 188 in run (61 + 66 for the two snapshots, 53 in the handler), text.insert of one character 85 (47 snapshot, 31 handler), and caret.docEnd 261, all in its handler (it finds the last paragraph by listing them all). The snapshots are the bulk, which is why a command per line cannot be linear; this does not change them. Behaviour: a CRLF is one break, not two (the per-line way inserted the carriage return as a break and then added its own). The breaks no longer run Enter's AutoCorrect: the per-line way capitalised every line but the last (no Enter followed it) and fixed typos there, so what came out depended on the line's place; the text now goes in as written, as the tool's raw insert already did within each line. Test: the paragraphs made equal those of the per-line way (in a list and not, blank lines, leading and trailing breaks, indents, tabs) for text AutoCorrect has nothing to change; lowercase lines and a typo stay as written; CRLF; one Undo removes every line. Fails on the old code. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 1 + crates/mcp/src/tests.rs | 71 +++++++++++++++++++++++++++++++++++++++++ crates/mcp/src/tools.rs | 23 ++++--------- docs/mcp.md | 2 +- 4 files changed, 79 insertions(+), 18 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 802e12c64..d796bd609 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). - 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. +- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 781f24d02..c49ef936f 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -59,6 +59,77 @@ fn agent_writes_a_formatted_document() { assert!(p["percent"].as_f64().unwrap() > 50.0); } +/// The document's paragraphs as (text, in a list) from `inspect_document`. +fn paragraphs(s: &mut Server) -> Vec<(String, bool)> { + let doc = tool(s, "inspect_document", json!({})); + doc["blocks"].as_array().unwrap().iter().map(|b| (b["text"].as_str().unwrap_or("").to_string(), !b["list"].is_null())).collect() +} + +/// `type_text` with `paragraphs` is one command: it used to be a `text.newParagraph` and a +/// `text.insert` per line, each snapshotting the whole document for Undo (quadratic: 10,000 lines +/// took 2.8 s; one command takes 7 ms) and each its own Undo step (#3973). The document it makes +/// is the one the per-line way made, a CRLF is one break, and one Undo takes all of it back. +#[test] +fn type_text_paragraphs_is_one_step_and_makes_the_same_paragraphs() { + let per_line = |s: &mut Server, text: &str| { + for (i, line) in text.split('\n').enumerate() { + if i > 0 { + tool(s, "execute", json!({"command": "text.newParagraph"})); + } + if !line.is_empty() { + tool(s, "type_text", json!({"text": line})); + } + } + }; + let setup = |s: &mut Server, listed: bool| { + tool(s, "new_document", json!({})); + tool(s, "type_text", json!({"text": "Start"})); + if listed { + tool(s, "execute", json!({"command": "para.bullets"})); + } + }; + for listed in [false, true] { + // Text AutoCorrect has nothing to change, so only the paragraphs are compared. + for text in ["One\nTwo\nThree", "One\n\n\nTwo", "\nLead", "Trail\n", "A b c\n Indented\n\tTabbed", "X"] { + let mut old = Server::new(Box::new(Headless::default())); + setup(&mut old, listed); + per_line(&mut old, text); + let mut new = Server::new(Box::new(Headless::default())); + setup(&mut new, listed); + tool(&mut new, "type_text", json!({"text": text, "paragraphs": true})); + assert_eq!(paragraphs(&mut new), paragraphs(&mut old), "{text:?} (in a list: {listed})"); + } + } + + // The lines are typed as they are: Enter's AutoCorrect (a capital for the next sentence, a + // fixed typo) doesn't run on the breaks. The per-line way capitalised "two" (and not the last + // line, which no Enter followed), so what was written depended on the line's place. + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "one\ntwo teh\nthree", "paragraphs": true})); + let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); + assert_eq!(texts, ["one", "two teh", "three"]); + + // CRLF is one break per line end. + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "a\r\nb\r\nc", "paragraphs": true})); + let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); + assert_eq!(texts, ["a", "b", "c"]); + + // One Undo step takes all the lines back. + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "keep"})); + // Moving the caret ends the typing that "keep" was part of, so it isn't undone with the rest. + tool(&mut s, "execute", json!({"command": "caret.docEnd"})); + tool(&mut s, "type_text", json!({"text": "\none\ntwo\nthree\nfour", "paragraphs": true})); + assert_eq!(paragraphs(&mut s).len(), 5); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + let texts: Vec = paragraphs(&mut s).into_iter().map(|p| p.0).collect(); + assert_eq!(texts, ["keep"], "one Undo removes every line"); +} + #[test] fn resources() { let mut s = Server::new(Box::new(Headless::default())); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 47e162a0b..f543043e4 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value { 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.", + "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 (all in one step, one Undo; the line breaks are plain text breaks: no AutoCorrect or AutoFormat runs on them).", obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false ), @@ -229,22 +229,11 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - let mut last = Ok(Value::Null); - for (i, line) in t.split('\n').enumerate() { - if i > 0 { - last = exec(b, "text.newParagraph", json!({})); - if last.is_err() { - break; - } - } - if !line.is_empty() { - last = exec(b, "text.insert", json!({"text": line, "raw": true})); - } - if last.is_err() { - break; - } - } - wrap(last) + // One command for all the lines: `text.insert` takes a carriage return as a + // paragraph break. A command per line was quadratic in the document (each one + // snapshots all of it for Undo: 10,000 lines took 2.8 s, 7 ms in one command), + // and left an Undo step per line. CRLF is one break, not two (#3973). + wrap(exec(b, "text.insert", json!({"text": t.replace("\r\n", "\n").replace('\n', "\r"), "raw": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) } diff --git a/docs/mcp.md b/docs/mcp.md index 5ed66634d..c45228763 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -108,7 +108,7 @@ files it was given rather than guarding against other software on the computer. | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs, in one command and one Undo step: 10,000 lines take ~10 ms; the line breaks are plain breaks, so AutoCorrect doesn't run on them) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG | From c4ea6770b27796be395ae46f811a7838244dbaa2 Mon Sep 17 00:00:00 2001 From: Michael Date: Sat, 24 Oct 2026 18:20:26 +0800 Subject: [PATCH 2/2] Typed line breaks do not split a one-line content control type_text with paragraphs is one text.insert now, and insert_text turns a carriage return into a paragraph break whatever it is inside, so a one-line plain-text control, where Enter does nothing, was split in two (text.insert of "a\rb" had always done that; the per-line way never sent the break because text.newParagraph refused it). Typed text now leaves the breaks out there, as the Enter key does; pasted text keeps its own. The #1356 test (a refused paragraph break is reported and nothing is typed after it) is now about a refused insert: the lines are one command, so a protected document refuses all of them at once. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- crates/engine/src/cmd/controls.rs | 19 +++++++++++++++++++ crates/engine/src/cmd/mod.rs | 6 +++++- crates/mcp/src/tests.rs | 29 +++++++++++++++++------------ 4 files changed, 42 insertions(+), 14 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d796bd609..53d6ec5df 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,7 +142,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm - 2026-10-23: Font dialog: "NaN" or "inf" typed in the Size box previews the text at 12 pt. "NaN" reads as a number, and a font size that is not a number stopped the app while drawing the preview (#3663). - 2026-10-23: Tables: a cell span read from a `.json` document (any 32-bit number) no longer overflows when a column is inserted through the cell or the cell is merged: the span is counted from its clamped value and stays within 63 columns. It panicked in debug builds and wrapped to a small span in release (#3637). - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. -- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). +- 2026-10-24: MCP `type_text` with `paragraphs: true` is one command (#3973): it was a `text.newParagraph` and a `text.insert` per line, each snapshotting the whole document for Undo, so 10,000 lines took 2.8 s (doubling the lines cost 2.4–3.2×) and left an Undo step per line; now they take ~10 ms (linear) and one Undo takes them all back. A CRLF is one break, not two, and the breaks no longer run AutoCorrect (the per-line way capitalised every line but the last). Typed line breaks are the Enter key, so in a one-line content control, where Enter does nothing, they no longer split it into paragraphs. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). Four geometry-dependent layout controls request Source Sans 3 explicitly. Installed substitute fonts no longer choose their line breaks. Assertions and renderer font fallback policy are unchanged. diff --git a/crates/engine/src/cmd/controls.rs b/crates/engine/src/cmd/controls.rs index 77af7edc4..dca3cffb0 100644 --- a/crates/engine/src/cmd/controls.rs +++ b/crates/engine/src/cmd/controls.rs @@ -864,6 +864,25 @@ mod editing_tests { assert_eq!(typed, None, "a placeholder style localized in id and name"); } + /// Typed text with line breaks in it (`type_text` with paragraphs, a typed paste) is the + /// Enter key pressed between the lines, so a one-line control, where Enter does nothing, stays + /// one paragraph, and a multi-line one splits (#3973: the line breaks went in as one command + /// and split a one-line control). + #[test] + fn typed_line_breaks_do_not_split_a_one_line_control() { + for (multi_line, paragraphs) in [(false, 1), (true, 2)] { + let mut s = Session::new(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Name: "})); + run(&mut s, "developer.plainText", json!({"title": "Name", "tag": "name"})); + run(&mut s, "developer.properties", json!({"control": "name", "multiLine": multi_line})); + run(&mut s, "caret.right", json!({})); + run(&mut s, "text.insert", json!({"text": "Ada\rLovelace", "raw": true})); + assert_eq!(s.doc.body.len(), paragraphs, "multi-line control: {multi_line}"); + let r = s.doc.control_at(&s.sel.focus).unwrap(); + assert_eq!(r.control.tag, "name", "still inside the control"); + } + } + #[test] fn typing_replaces_the_placeholder_and_emptying_brings_it_back() { let mut s = Session::new(wordcraft_doc::Document::new()); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 2e71472a7..763fad036 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -311,8 +311,12 @@ fn insert_text(s: &mut Session, text: &str, enter: bool) -> Result<(), CmdError> } let mut at = s.sel.focus.clone(); let mut first = true; + // Typed line breaks are the Enter key between the lines: in a one-line control it does + // nothing, so the lines run together and the control stays one paragraph (pasted text keeps + // its breaks, as before). + let no_breaks = enter && controls::refuses_paragraph(s); for part in text.split(['\r', '\u{2029}']) { - if !first { + if !first && !no_breaks { at = split_para_as(s, &at, enter)?; } first = false; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index c49ef936f..12ea973f1 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -191,17 +191,17 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend whose paragraph break is refused while text insertion would succeed. -struct RefusesParagraph { - calls: std::sync::Arc>>, +/// A backend that refuses every text insertion (a protected document, say). +struct RefusesInsert { + calls: std::sync::Arc>>, } -impl crate::backend::Backend for RefusesParagraph { +impl crate::backend::Backend for RefusesInsert { fn call(&mut self, _method: &str, params: Value) -> Result { let command = params["command"].as_str().unwrap_or("").to_string(); - self.calls.lock().unwrap().push(command.clone()); + self.calls.lock().unwrap().push((command.clone(), params["params"].clone())); match command.as_str() { - "text.newParagraph" => Err("paragraph break refused".into()), + "text.insert" => Err("editing refused".into()), _ => Ok(json!({"ok": true})), } } @@ -209,17 +209,22 @@ impl crate::backend::Backend for RefusesParagraph { false } fn describe(&self) -> String { - "refuses paragraphs".into() + "refuses edits".into() } } -/// #1356: a refused paragraph break is reported and the next line is not typed after it. +/// #1356: a refused edit is reported and nothing is typed after it. The lines are one +/// `text.insert` now (#3973), so a protected document refuses all of them at once, never a +/// line at a time with the next one typed after the break that failed. #[test] -fn type_text_stops_at_a_refused_paragraph_break() { +fn type_text_paragraphs_reports_a_refusal_and_types_nothing_after_it() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); + let mut s = Server::new(Box::new(RefusesInsert { calls: calls.clone() })); let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); - assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); - assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("editing refused"), "{r}"); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "one command for all the lines: {calls:?}"); + assert_eq!(calls[0].0, "text.insert"); + assert_eq!(calls[0].1["text"], "one\rtwo", "the line break is a paragraph break"); }