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 <noreply@anthropic.com>
This commit is contained in:
Michael
2026-10-24 18:14:33 +08:00
co-authored by Claude Sonnet 5.5
parent cf2fe4733e
commit c8f103c148
4 changed files with 79 additions and 18 deletions
+1
View File
@@ -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.
+71
View File
@@ -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<String> = 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<String> = 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<String> = 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()));
+6 -17
View File
@@ -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})))
}
+1 -1
View File
@@ -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 |