#3692: typed paragraphs break like Enter, still one command and one undo step

Ultrareview finding 2. MCP `type_text` with `paragraphs: true` sent one raw
text.insert with `\r` breaks, which skipped Enter: a break could land in a
one-line, check-box or drop-down content control (text.newParagraph refuses
there), and Enter's AutoFormat (--- borders, +---+ tables) no longer ran.

text.insert takes `paragraphs: true`: each line is typed raw and each break
runs text.newParagraph's own implementation (`enter`), inside the one
command, so there is one checkpoint. Where Enter would refuse, the command
fails and Session::run puts the document and history back (all or nothing).
Enter's AutoFormat normally records the plain Enter as its own undo step
after the fact (push_undo, a whole-document clone); inside typed paragraphs
that step is skipped, so AutoFormatted lines add no snapshot each. MCP sends
`paragraphs: true` and keeps MAX_TYPED_PARAGRAPHS.

Tests: mcp type_text_paragraphs_are_refused_in_a_one_line_control,
type_text_paragraphs_autoformat_like_enter_in_one_undo_step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-23 20:19:49 +01:00
co-authored by Claude Opus 5.5
parent 8a18026a13
commit 3a8ff6b6bc
3 changed files with 102 additions and 19 deletions
+49 -11
View File
@@ -10,7 +10,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#),
CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#),
CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"),
CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"),
CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"),
@@ -99,6 +99,9 @@ fn typing_options(s: &mut Session, text: &str) {
fn insert(s: &mut Session, v: &Value) -> CmdResult {
let text = p::req_str(v, "text")?;
let raw = p::bool(v, "raw").unwrap_or(false);
if p::bool(v, "paragraphs").unwrap_or(false) {
return insert_paragraphs(s, text);
}
typing_options(s, text);
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
@@ -576,30 +579,65 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
if super::controls::refuses_paragraph(s) {
return sel_result(s);
}
enter(s, true)?;
sel_result(s)
}
/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the
/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so
/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where
/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and,
/// like any failed command, nothing of it stays.
fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult {
typing_options(s, text);
let text = text.replace("\r\n", "\n");
for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() {
if i > 0 {
if super::controls::refuses_paragraph(s) {
return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into()));
}
enter(s, false)?;
}
if !line.is_empty() {
type_text(s, line)?;
}
}
sel_result(s)
}
/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a
/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step`
/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it
/// (typed paragraphs, one undo step for all) no extra snapshot is taken.
fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> {
super::controls::prepare_edit(s)?;
if let Some(text) = sample_text(s) {
let end = s.sel.focus.clone();
s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end };
type_text(s, &text)?;
return sel_result(s);
return type_text(s, &text);
}
let mut at = delete_selection(s)?;
let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table));
if let Some(auto) = auto {
// Word: Undo right after reverts only the AutoFormat, so the typed characters come back
// and the Enter stays. Record that plain Enter as its own undo step.
let (doc, sel) = (s.doc.clone(), s.sel.clone());
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
let plain_doc = std::mem::replace(&mut s.doc, doc);
let plain_sel = std::mem::replace(&mut s.sel, sel);
let plain = if undo_step {
let (doc, sel) = (s.doc.clone(), s.sel.clone());
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel)))
} else {
None
};
match auto {
EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?,
EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?,
}
s.goal_x = None;
s.push_undo("AutoFormat", plain_doc, plain_sel);
return sel_result(s);
if let Some((plain_doc, plain_sel)) = plain {
s.push_undo("AutoFormat", plain_doc, plain_sel);
}
return Ok(());
}
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
if s.sel.is_collapsed() && at.off > 0 {
@@ -610,7 +648,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
s.goal_x = None;
sel_result(s)
Ok(())
}
/// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab.
+48 -4
View File
@@ -120,7 +120,7 @@ fn headless_sessions_keep_the_dictionary_in_memory() {
assert!(h.session.user_dict_path.is_none());
}
/// A backend that keeps every command it is sent and refuses text with a paragraph break.
/// A backend that keeps every command it is sent and refuses typed paragraphs.
struct RefusesParagraph {
calls: std::sync::Arc<std::sync::Mutex<Vec<Value>>>,
}
@@ -128,8 +128,8 @@ struct RefusesParagraph {
impl crate::backend::Backend for RefusesParagraph {
fn call(&mut self, _method: &str, params: Value) -> Result<Value, String> {
self.calls.lock().unwrap().push(params.clone());
match params["params"]["text"].as_str() {
Some(t) if t.contains('\r') => Err("paragraph break refused".into()),
match params["params"]["paragraphs"].as_bool() {
Some(true) => Err("paragraph break refused".into()),
_ => Ok(json!({"ok": true})),
}
}
@@ -153,7 +153,8 @@ fn type_text_stops_at_a_refused_paragraph_break() {
let calls = calls.lock().unwrap();
assert_eq!(calls.len(), 1, "{calls:?}");
assert_eq!(calls[0]["command"], "text.insert");
assert_eq!(calls[0]["params"]["text"], "one\rtwo");
assert_eq!(calls[0]["params"]["text"], "one\ntwo");
assert_eq!(calls[0]["params"]["paragraphs"], true);
}
/// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document
@@ -179,3 +180,46 @@ fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() {
assert_eq!(r["result"]["isError"], true, "{r}");
assert!(calls.lock().unwrap().is_empty(), "refused before any command");
}
/// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's
/// implementation), so a one-line content control refuses it, and all of the text with it.
#[test]
fn type_text_paragraphs_are_refused_in_a_one_line_control() {
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "Name: "}));
tool(&mut s, "execute", json!({"command": "developer.plainText", "params": {"tag": "name"}}));
tool(&mut s, "execute", json!({"command": "caret.right"}));
let before = tool(&mut s, "get_text", json!({}))["text"].clone();
let r = call(&mut s, 13, "tools/call", json!({"name": "type_text", "arguments": {"text": "Ada\nLovelace", "paragraphs": true}}));
assert_eq!(r["result"]["isError"], true, "{r}");
assert_eq!(tool(&mut s, "get_text", json!({}))["text"], before, "nothing typed");
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1);
}
/// Ultrareview of #3692: Enter's AutoFormat runs on each typed paragraph break ("---" becomes a
/// border, "+---+---+" a table), and the lines are still one undo step however many of them
/// AutoFormat changes.
#[test]
fn type_text_paragraphs_autoformat_like_enter_in_one_undo_step() {
let mut s = Server::new(Box::new(Headless::default()));
tool(&mut s, "new_document", json!({}));
tool(&mut s, "type_text", json!({"text": "Above\n---\nBelow\n+---+---+\nCell", "paragraphs": true}));
let blocks = tool(&mut s, "inspect_document", json!({}))["blocks"].clone();
let blocks = blocks.as_array().unwrap();
assert_eq!(blocks[0]["text"], "Above", "{blocks:?}");
assert!(blocks[0]["props"]["borders"]["bottom"].is_object(), "--- is a border: {blocks:?}");
assert_eq!(blocks[1]["text"], "Below", "{blocks:?}");
assert_eq!(blocks[2]["type"], "table", "+---+---+ is a table: {blocks:?}");
assert!(blocks[2].to_string().contains("Cell"), "typing goes on in its first cell: {blocks:?}");
tool(&mut s, "execute", json!({"command": "edit.undo"}));
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo");
assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "");
// Many AutoFormatted lines: still one undo step (no snapshot per line).
let lines: Vec<&str> = (0..500).flat_map(|_| ["Text", "==="]).collect();
tool(&mut s, "type_text", json!({"text": lines.join("\n"), "paragraphs": true}));
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 501);
tool(&mut s, "execute", json!({"command": "edit.undo"}));
assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo");
}
+5 -4
View File
@@ -232,15 +232,16 @@ 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) {
// One `text.insert`: `\r` is a paragraph break there (Enter's split), so the
// lines go in as one edit and one undo step, all or nothing, instead of a command
// (and a whole-document undo snapshot) per line.
// One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat,
// and its refusal in a one-line control), but the lines go in as one edit and one
// undo step, all or nothing, instead of a command (and a whole-document undo
// snapshot) per line.
let t = t.replace("\r\n", "\n").replace('\r', "\n");
let lines = t.split(['\n', '\u{2029}']).count();
if lines > MAX_TYPED_PARAGRAPHS {
return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call"));
}
wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true})))
wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true})))
} else {
wrap(exec(b, "text.insert", json!({"text": t, "raw": true})))
}