Compare commits

..

2 Commits

Author SHA1 Message Date
agent_coder 8b36294b7b test(editor): pin remote-delete cursor contract + correct scope docblock (#196 review round 1)
- F2: add a remap test for a REMOTE (meta-less) delete of a cursor's own range,
  pinning the collapse-not-drop contract — the deleted-over cursor collapses to a
  zero-width caret at the deletion point and stays in the set; others keep their
  occurrence (this is the riskiest remap path, previously covered only for insert).
- F1: reword the out-of-scope docblock — occurrences inside tables/code-blocks/
  callouts ARE matched and edited as plain text (no schema-aware cursor); they
  were never a 'deliberately not built' boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 04:41:06 +03:00
agent_coder 0fa2f9fb91 feat(editor): multi-cursor editing MVP — select all occurrences + type into all (#196)
Variant A of #196: VS Code-style multi-cursor limited to "select all
occurrences of a word (or the selection) and type into all at once", built on
top of the existing SearchAndReplace mass-transaction machinery.

- New `MultiCursor` Tiptap extension (packages/editor-ext/src/lib/multi-cursor/):
  Cmd/Ctrl+Shift+L selects all occurrences, Cmd/Ctrl+D adds the next, typing /
  Backspace / Delete apply to every cursor in ONE reverse-order transaction (so a
  single undo reverts the whole multi-edit), Esc / click / navigation collapse.
- Cursors live in plugin state and are remapped on every docChanged — covering
  remote Yjs edits (applied as ordinary transactions) with no collab-specific code.
- Extracted a shared `findOccurrences` util so SearchAndReplace and MultiCursor
  no longer duplicate the occurrence walk (behaviour-preserving).
- Conscious v1 out-of-scope boundaries (Variant B) documented in the extension.

Registered in mainExtensions; carets styled distinctly from collaborative carets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 04:24:34 +03:00
16 changed files with 1148 additions and 225 deletions
+1 -1
View File
@@ -294,7 +294,7 @@ Vite SPA. Code is organized by feature under `apps/client/src/features/*` (mirro
- **Errors must never be swallowed or shown as generic messages.** Every caught error MUST (1) be logged in full to the console/logger — error name, message, stack, `cause`, and (for HTTP/provider failures) the status code and response body — and (2) be surfaced to the user with a *specific, human-readable explanation of what actually went wrong*, never a bare generic string like "Something went wrong" / "Could not start recording" / "Transcription failed". Include the real reason (the underlying error/provider message) in the user-facing text. On the server, wrap third-party/provider failures with `describeProviderError` (or equivalent) and rethrow as a meaningful HTTP status + message — never let them collapse into an opaque 500. On the client, `console.error(<context>, err)` the raw error AND show the extracted reason (e.g. `err.response?.data?.message`, or the error `name: message`) in the notification.
- The version string shown in the UI comes from `APP_VERSION` (CI/Docker) or `git describe --tags --always` (local), resolved in `vite.config.ts` — not from `package.json`.
- Server TS config is permissive (`noImplicitAny: false`, `strictNullChecks: false`, `no-explicit-any` lint disabled). Follow the existing relaxed style rather than tightening types broadly.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`, `ai`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons. The `ai@6.0.134` patch disables the SDK's O(n²) cumulative `partialOutput` accumulation when no output strategy is requested (server heap OOM on long agent runs, #184; tripwire test: `apps/server/src/integrations/ai/ai-sdk-partial-output.patch.spec.ts`) — it MUST be re-created via `pnpm patch` when bumping `ai`.
- Dependency versions are heavily pinned via `pnpm.overrides` and `pnpm.patchedDependencies` (`scimmy`, `yjs`) in the root `package.json`. Don't bump pinned/patched deps casually; the patches and overrides exist for compatibility/security reasons.
- **Adding/renaming/removing an MCP tool requires updating `SERVER_INSTRUCTIONS`** in `packages/mcp/src/index.ts` — the intent-routing guide MCP clients receive on initialize. This applies both to inline `server.registerTool(...)` calls in `index.ts` and to specs in `packages/mcp/src/tool-specs.ts`. Enforced by `packages/mcp/test/unit/server-instructions.test.mjs`, which fails when a registered tool is not mentioned in the guide (deliberate opt-outs go into its `EXCEPTIONS` list). `packages/mcp/build/` is gitignored and rebuilt in CI/Docker via `pnpm build` (same convention as `git-sync`/`prosemirror-markdown`) — never commit it; rebuild locally after editing to run the tests.
## CI / release
-8
View File
@@ -169,14 +169,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **The server no longer runs out of heap during long autonomous agent runs.** A
new pnpm patch on `ai@6.0.134` stops the SDK from building a cumulative
snapshot of the ENTIRE turn text on every streamed text-delta when no output
strategy was requested (our server never requests one). Unpatched, those
O(n²) `partialOutput` snapshots piled up in a never-consumed internal
`tee()` branch of the stream result — a ~20-step, ~28k-chunk agent run
retained ~1.7 GB and OOM'd the 2 GB JS heap. Streaming granularity is
unchanged; the patch must be re-created if `ai` is ever bumped. (#184)
- **Internal links in exported Markdown no longer lose their visible text.** A
link whose target page name had no file extension (e.g. a bare title) was
collapsed to empty text during export, producing an unclickable, label-less
@@ -45,6 +45,7 @@ import {
TiptapPdf,
PageBreak,
SearchAndReplace,
MultiCursor,
Mention,
TableDndExtension,
TableHandleCommandsExtension,
@@ -447,6 +448,10 @@ export const mainExtensions = [
};
},
}).configure(),
// Multi-cursor editing (MVP / Variant A): select-all-occurrences + type into
// all at once. Does not depend on collaboration, so it lives in mainExtensions
// (available in both the plain and collaborative editors).
MultiCursor,
Columns,
Column,
AutoJoiner.configure({
@@ -1,5 +1,6 @@
@import "./core.css";
@import "./collaboration.css";
@import "./multi-cursor.css";
@import "./task-list.css";
@import "./placeholder.css";
@import "./drag-handle.css";
@@ -0,0 +1,60 @@
/*
* Multi-cursor (issue #196). Deliberately DISTINCT from the collaboration
* carets (collaboration.css) so a user never confuses their own multi-cursors
* with a co-author's caret: solid accent-blue carets + a translucent blue
* range highlight, versus the thin dark collaboration caret with a name label.
*/
/* A secondary caret rendered as a Decoration.widget at each cursor position. */
.multi-cursor__caret {
position: relative;
display: inline-block;
width: 0;
height: 1em;
vertical-align: text-bottom;
pointer-events: none;
}
.multi-cursor__caret::after {
content: "";
position: absolute;
left: -1px;
top: 0;
bottom: 0;
width: 2px;
background: #2b6cb0;
animation: multi-cursor-blink 1s steps(1) infinite;
}
/* Optional label class reserved for future per-cursor annotations. */
.multi-cursor__label {
position: absolute;
top: -1.4em;
left: -1px;
font-size: 0.7rem;
line-height: normal;
padding: 0.05rem 0.25rem;
border-radius: 3px 3px 3px 0;
background: #2b6cb0;
color: #fff;
white-space: nowrap;
user-select: none;
pointer-events: none;
}
/* Inline highlight for a multi-cursor RANGE (from < to). */
.multi-cursor__selection {
background: rgba(43, 108, 176, 0.28);
border-radius: 2px;
}
@keyframes multi-cursor-blink {
0%,
50% {
opacity: 1;
}
51%,
100% {
opacity: 0;
}
}
@@ -1,92 +0,0 @@
import { readFileSync } from 'fs';
import { streamText } from 'ai';
import { MockLanguageModelV3, simulateReadableStream } from 'ai/test';
/**
* Regression tests for patches/ai@6.0.134.patch (server heap OOM on long
* autonomous agent runs, #184).
*
* Unpatched ai@6.0.134 substitutes the default text() output strategy even
* when the caller passes NO `output` option. Its createOutputTransformStream
* then accumulates the ENTIRE turn text and, on EVERY text-delta, enqueues a
* flat snapshot of all text so far as `partialOutput` (O(n^2) memory). Those
* snapshots pile up in the never-consumed leftover tee() branch of
* DefaultStreamTextResult.baseStream, which is what OOM'd production during a
* ~28k-chunk agent turn. The pnpm patch skips partialOutput production
* entirely when no output strategy was requested, while keeping per-delta
* streaming granularity.
*/
describe('ai@6.0.134 pnpm patch: no partialOutput accumulation without an output strategy', () => {
const makeModel = () =>
new MockLanguageModelV3({
doStream: async () => ({
stream: simulateReadableStream({
chunks: [
{ type: 'stream-start' as const, warnings: [] },
{ type: 'text-start' as const, id: '1' },
{ type: 'text-delta' as const, id: '1', delta: 'Hello' },
{ type: 'text-delta' as const, id: '1', delta: ', ' },
{ type: 'text-delta' as const, id: '1', delta: 'world!' },
{ type: 'text-end' as const, id: '1' },
{
type: 'finish' as const,
finishReason: { unified: 'stop' as const, raw: 'stop' },
usage: {
inputTokens: {
total: 1,
noCache: undefined,
cacheRead: undefined,
cacheWrite: undefined,
},
outputTokens: { total: 1, text: 1, reasoning: undefined },
},
},
],
}),
}),
});
it('preserves per-delta streaming granularity in textStream', async () => {
const result = streamText({ model: makeModel(), prompt: 'hi' });
const deltas: string[] = [];
for await (const delta of result.textStream) {
deltas.push(delta);
}
// The patch must NOT coalesce or drop deltas: three model deltas arrive
// as three separate textStream chunks.
expect(deltas).toEqual(['Hello', ', ', 'world!']);
});
it('emits NO partialOutput values when the caller did not request an output strategy', async () => {
const result = streamText({ model: makeModel(), prompt: 'hi' });
// Fully consume the primary stream first (mirrors production usage).
for await (const _ of result.textStream) {
// drain
}
const partials: unknown[] = [];
for await (const partial of result.experimental_partialOutputStream) {
partials.push(partial);
}
// TRIPWIRE: on unpatched ai@6.0.134 the default text() output strategy
// yields one cumulative partial per text-delta here (['Hello', 'Hello, ',
// 'Hello, world!']). An empty stream proves the patch is applied and no
// cumulative snapshots are being produced (and thus none can pile up in
// the leftover internal tee branch).
expect(partials).toEqual([]);
});
it('both installed dist builds (CJS and ESM) carry the patch marker', () => {
// Secondary guard: pins the patch to BOTH bundles the SDK ships, since
// the NestJS server consumes CJS while other tooling may load ESM.
const cjsPath = require.resolve('ai');
const mjsPath = cjsPath.replace(/index\.js$/, 'index.mjs');
expect(cjsPath).toMatch(/index\.js$/);
expect(readFileSync(cjsPath, 'utf8')).toContain('PATCH(docmost');
expect(readFileSync(mjsPath, 'utf8')).toContain('PATCH(docmost');
});
});
+1 -2
View File
@@ -96,8 +96,7 @@
"pnpm": {
"patchedDependencies": {
"scimmy@1.3.5": "patches/scimmy@1.3.5.patch",
"yjs@13.6.30": "patches/yjs@13.6.30.patch",
"ai@6.0.134": "patches/ai@6.0.134.patch"
"yjs@13.6.30": "patches/yjs@13.6.30.patch"
},
"overrides": {
"prosemirror-changeset": "2.4.0",
+1
View File
@@ -20,6 +20,7 @@ export * from "./lib/html-embed/html-embed";
export * from "./lib/mention";
export * from "./lib/markdown";
export * from "./lib/search-and-replace";
export * from "./lib/multi-cursor";
export * from "./lib/embed-provider";
export * from "./lib/subpages";
export * from "./lib/transclusion";
@@ -0,0 +1,3 @@
import { MultiCursor } from "./multi-cursor";
export * from "./multi-cursor";
export default MultiCursor;
@@ -0,0 +1,453 @@
import { describe, it, expect } from "vitest";
import { Editor } from "@tiptap/core";
import { Document } from "@tiptap/extension-document";
import { Paragraph } from "@tiptap/extension-paragraph";
import { Text } from "@tiptap/extension-text";
import { Bold } from "@tiptap/extension-bold";
import { Node as PMNode } from "@tiptap/pm/model";
import { MultiCursor, multiCursorPluginKey, MAX_CURSORS } from "./multi-cursor";
import { findOccurrences } from "../search-and-replace/find-occurrences";
const extensions = [Document, Paragraph, Text, Bold, MultiCursor];
function makeEditor(content?: any) {
return new Editor({
extensions,
content: content ?? { type: "doc", content: [{ type: "paragraph" }] },
});
}
function doc(...paragraphs: string[]) {
return {
type: "doc",
content: paragraphs.map((text) => ({
type: "paragraph",
content: text ? [{ type: "text", text }] : [],
})),
};
}
function paraTexts(d: PMNode): string[] {
const out: string[] = [];
d.forEach((node) => {
if (node.type.name === "paragraph") out.push(node.textContent);
});
return out;
}
function cursors(editor: Editor) {
return multiCursorPluginKey.getState(editor.state)!.cursors;
}
// Simulate typing a character through the real handleTextInput routing (the
// browser path). someMethod-equivalent: dispatch a DOM-ish text input by calling
// the view's input handler directly.
function typeText(editor: Editor, text: string) {
const { from, to } = editor.state.selection;
// props.handleTextInput is what ProseMirror calls on beforeinput/keypress.
const handled = editor.view.someProp(
"handleTextInput",
(fn) => fn(editor.view, from, to, text) || false,
);
if (!handled) {
// Fall back to a normal insertion (no active multi-cursor set).
editor.view.dispatch(editor.state.tr.insertText(text, from, to));
}
}
function pressKey(editor: Editor, key: string) {
editor.view.someProp("handleKeyDown", (fn) =>
fn(editor.view, new KeyboardEvent("keydown", { key })),
);
}
describe("multi-cursor: selectAllOccurrences", () => {
it("finds EVERY occurrence of a repeated word under the cursor", () => {
const editor = makeEditor(doc("foo bar foo baz foo"));
// Cursor inside the first "foo".
editor.commands.setTextSelection(2);
expect(editor.commands.selectAllOccurrences()).toBe(true);
const cs = cursors(editor);
expect(cs.length).toBe(3);
// Every cursor spans a "foo".
for (const c of cs) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
}
editor.destroy();
});
it("uses the current non-empty selection as the term", () => {
const editor = makeEditor(doc("ab abc ab abcd ab"));
// Select the first "ab".
editor.commands.setTextSelection({ from: 1, to: 3 });
expect(editor.state.doc.textBetween(1, 3)).toBe("ab");
editor.commands.selectAllOccurrences();
// Literal substring match (selection is not whole-word), so every "ab"
// including those inside "abc"/"abcd" is matched: 5 total.
const cs = cursors(editor);
expect(cs.length).toBe(5);
editor.destroy();
});
it("whole-word matching from a word cursor does not match substrings", () => {
const editor = makeEditor(doc("cat category cat scatter cat"));
editor.commands.setTextSelection(2); // inside first "cat"
editor.commands.selectAllOccurrences();
// Only the three standalone "cat" words, not "category"/"scatter".
expect(cursors(editor).length).toBe(3);
editor.destroy();
});
});
describe("multi-cursor: mass typing (single transaction)", () => {
it("types text into N carets at once", () => {
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(3);
// Typing replaces each selected "foo" with "X".
typeText(editor, "X");
expect(paraTexts(editor.state.doc)).toEqual(["X X X"]);
// The cursors are now carets right after each inserted "X".
const cs = cursors(editor);
expect(cs.length).toBe(3);
for (const c of cs) expect(c.from).toBe(c.to);
editor.destroy();
});
it("continues typing at the resulting carets (append semantics)", () => {
const editor = makeEditor(doc("a a a"));
editor.commands.setTextSelection(1);
editor.commands.selectAllOccurrences();
typeText(editor, "b"); // each "a" -> "b"
typeText(editor, "c"); // append at each caret -> "bc"
expect(paraTexts(editor.state.doc)).toEqual(["bc bc bc"]);
editor.destroy();
});
it("applies the whole multi-edit in a SINGLE transaction (one undo step)", () => {
// "One Cmd/Ctrl+Z undoes the whole multi-edit" holds iff the N edits land in
// ONE transaction (history groups by transaction). @tiptap/extension-history
// is not a dependency here, so rather than exercise undo we assert the
// property that guarantees it: typing into N cursors is exactly ONE dispatch.
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(3);
const orig = editor.view.dispatch.bind(editor.view);
let dispatches = 0;
editor.view.dispatch = (tr) => {
dispatches += 1;
return orig(tr);
};
typeText(editor, "Z");
editor.view.dispatch = orig;
expect(dispatches).toBe(1); // all three edits share one transaction
expect(paraTexts(editor.state.doc)).toEqual(["Z Z Z"]);
editor.destroy();
});
it("off-by-one guard: reverse-order iteration keeps every position valid", () => {
// If the mass edit iterated FORWARD, inserting at an earlier cursor would
// shift every later cursor and corrupt the result. Different-length
// replacement makes such a bug visible.
const editor = makeEditor(doc("x x x x"));
editor.commands.setTextSelection(1);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(4);
typeText(editor, "LONG");
expect(paraTexts(editor.state.doc)).toEqual(["LONG LONG LONG LONG"]);
editor.destroy();
});
});
describe("multi-cursor: mass Backspace / Delete", () => {
it("Backspace removes one char before each caret", () => {
const editor = makeEditor(doc("foo foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
// Collapse selections to carets at the END of each "foo" by typing then
// removing is complex; instead type to convert ranges into carets first.
typeText(editor, "ab"); // each "foo" -> "ab", carets after "ab"
expect(paraTexts(editor.state.doc)).toEqual(["ab ab ab"]);
pressKey(editor, "Backspace"); // remove the trailing "b" at each caret
expect(paraTexts(editor.state.doc)).toEqual(["a a a"]);
editor.destroy();
});
it("Delete removes one char after each caret", () => {
const editor = makeEditor(doc("fooX fooX"));
// Literal (selection) match of "foo" -> both occurrences inside "fooX".
editor.commands.setTextSelection({ from: 1, to: 4 }); // first "foo"
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
typeText(editor, "foo"); // rewrite "foo", carets now sit before each "X"
expect(paraTexts(editor.state.doc)).toEqual(["fooX fooX"]);
pressKey(editor, "Delete"); // remove the "X" after each caret
expect(paraTexts(editor.state.doc)).toEqual(["foo foo"]);
editor.destroy();
});
it("Backspace at a block-start caret is a no-op for that cursor", () => {
const editor = makeEditor(doc("ab", "ab"));
// Select both "ab" then convert to carets at start by replacing with "".
editor.commands.setTextSelection({ from: 1, to: 3 }); // first "ab"
editor.commands.selectAllOccurrences();
// Move carets to block start: type "" is not possible; instead delete range.
pressKey(editor, "Backspace"); // deletes each selected "ab"
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
// Carets are now at each block start; another Backspace must not throw and
// must not merge blocks (still two empty paragraphs).
pressKey(editor, "Backspace");
expect(paraTexts(editor.state.doc)).toEqual(["", ""]);
editor.destroy();
});
});
describe("multi-cursor: addNextOccurrence (Cmd/Ctrl+D)", () => {
it("first press selects the current word, next press adds the next", () => {
const editor = makeEditor(doc("go go go"));
editor.commands.setTextSelection(2); // inside first "go"
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(1);
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(2);
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(3);
// Nothing left to add — stays at 3.
editor.commands.addNextOccurrence();
expect(cursors(editor).length).toBe(3);
for (const c of cursors(editor)) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("go");
}
editor.destroy();
});
});
describe("multi-cursor: position remapping", () => {
it("remaps cursors after a LOCAL edit before them", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
// Insert unrelated text at the very start (pos 1), shifting everything +5.
editor.view.dispatch(editor.state.tr.insertText("HELLO", 1));
const after = cursors(editor);
expect(after.length).toBe(before.length);
for (let i = 0; i < after.length; i += 1) {
expect(after[i].from).toBe(before[i].from + 5);
expect(after[i].to).toBe(before[i].to + 5);
// And they still point at "foo".
expect(editor.state.doc.textBetween(after[i].from, after[i].to)).toBe(
"foo",
);
}
editor.destroy();
});
it("remaps cursors after a simulated REMOTE edit (ordinary transaction)", () => {
const editor = makeEditor(doc("foo bar foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
expect(before.length).toBe(2);
// y-prosemirror applies remote changes as ordinary transactions. Emulate a
// remote insertion between the two "foo"s (inside "bar", pos 6) with a tr
// that carries NO multi-cursor meta — exactly like a collaborator's edit.
const tr = editor.state.tr.insertText("ZZ", 6);
editor.view.dispatch(tr);
const after = cursors(editor);
// The first "foo" (before the insertion) is unchanged; the second shifts +2.
expect(after[0].from).toBe(before[0].from);
expect(after[1].from).toBe(before[1].from + 2);
for (const c of after) {
expect(editor.state.doc.textBetween(c.from, c.to)).toBe("foo");
}
editor.destroy();
});
it("a REMOTE delete UNDER a cursor collapses it to a caret (not drop), leaving others intact", () => {
// The riskiest remap path: a collaborator deletes the very text one cursor
// spans. Both edges map with assoc +1 and there is no drop logic, so the
// deleted-over cursor CONTRACT is: it collapses to a zero-width caret at the
// deletion point (from === to) and STAYS in the set — it is not removed.
// Untouched cursors keep spanning their occurrence. Pinning this makes the
// collapse-not-drop choice explicit (review #372 F2).
const editor = makeEditor(doc("foo bar foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
const before = cursors(editor).map((c) => ({ ...c }));
expect(before.length).toBe(2);
// Remote (no multi-cursor meta) delete of the FIRST "foo" range.
const tr = editor.state.tr.delete(before[0].from, before[0].to);
editor.view.dispatch(tr);
const after = cursors(editor);
// Still two cursors — the deleted-over one is NOT dropped.
expect(after.length).toBe(2);
// The first collapsed to a caret at the deletion point.
expect(after[0].from).toBe(after[0].to);
expect(after[0].from).toBe(before[0].from);
// The second still spans "foo" (shifted left by the 3 removed chars).
expect(after[1].from).toBe(before[1].from - 3);
expect(editor.state.doc.textBetween(after[1].from, after[1].to)).toBe("foo");
// Sanity: the document now reads " bar foo".
expect(paraTexts(editor.state.doc)).toEqual([" bar foo"]);
editor.destroy();
});
});
describe("multi-cursor: collapse / exit", () => {
it("exitMultiCursor clears the set", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
editor.commands.exitMultiCursor();
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
it("an arrow key collapses the set", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
pressKey(editor, "ArrowRight");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
});
describe("multi-cursor: collapse on composition / mousedown", () => {
// Invoke a plugin handleDOMEvents handler through the real prop plumbing.
function fireDOM(editor: Editor, name: string): void {
editor.view.someProp("handleDOMEvents", (handlers: any) => {
const h = handlers && handlers[name];
if (h) h(editor.view, new Event(name));
return false;
});
}
it("collapses the set on compositionstart (IME) — MVP does not multi-IME", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
fireDOM(editor, "compositionstart");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
it("collapses the set on a plain mousedown (VS Code behaviour)", () => {
const editor = makeEditor(doc("foo foo"));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
fireDOM(editor, "mousedown");
expect(cursors(editor).length).toBe(0);
editor.destroy();
});
});
describe("multi-cursor: hard cap", () => {
it("never activates more than MAX_CURSORS cursors", () => {
const many = new Array(MAX_CURSORS + 20).fill("w").join(" ");
const editor = makeEditor(doc(many));
editor.commands.setTextSelection(2);
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(MAX_CURSORS);
editor.destroy();
});
});
describe("multi-cursor: marks are carried across a mass edit", () => {
it("preserves marks spanning each replaced range", () => {
const editor = makeEditor({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "a " },
{ type: "text", marks: [{ type: "bold" }], text: "key" },
{ type: "text", text: " b " },
{ type: "text", marks: [{ type: "bold" }], text: "key" },
],
},
],
});
editor.commands.setTextSelection(3); // inside first bold "key"
editor.commands.selectAllOccurrences();
expect(cursors(editor).length).toBe(2);
typeText(editor, "NEW");
// Both replacements keep the bold mark.
let boldRuns = 0;
editor.state.doc.descendants((node) => {
if (
node.isText &&
node.text === "NEW" &&
node.marks.some((m) => m.type.name === "bold")
) {
boldRuns += 1;
}
});
expect(boldRuns).toBe(2);
editor.destroy();
});
});
// The extracted find-occurrences util must return the SAME occurrences that the
// old inline walk produced (and that search-and-replace still relies on).
describe("find-occurrences util", () => {
it("finds all matches of a literal regex across text nodes", () => {
const editor = makeEditor(doc("foo foofoo foo"));
const results = findOccurrences(editor.state.doc, /foo/gu);
// 4 occurrences: two standalone + two inside "foofoo".
expect(results.length).toBe(4);
for (const r of results) {
expect(editor.state.doc.textBetween(r.from, r.to)).toBe("foo");
}
editor.destroy();
});
it("ignores whitespace-only matches and empty regex", () => {
const editor = makeEditor(doc("a b c"));
expect(findOccurrences(editor.state.doc, null as any).length).toBe(0);
// A whitespace regex yields no results (matches are trimmed away).
expect(findOccurrences(editor.state.doc, /\s/gu).length).toBe(0);
editor.destroy();
});
it("finds a match spanning two differently-marked contiguous text nodes", () => {
const editor = makeEditor({
type: "doc",
content: [
{
type: "paragraph",
content: [
{ type: "text", text: "wo" },
{ type: "text", marks: [{ type: "bold" }], text: "rd" },
],
},
],
});
const results = findOccurrences(editor.state.doc, /word/gu);
expect(results.length).toBe(1);
expect(editor.state.doc.textBetween(results[0].from, results[0].to)).toBe(
"word",
);
editor.destroy();
});
});
@@ -0,0 +1,545 @@
import { Extension, Range } from "@tiptap/core";
import { Decoration, DecorationSet, EditorView } from "@tiptap/pm/view";
import {
Plugin,
PluginKey,
TextSelection,
type EditorState,
} from "@tiptap/pm/state";
import { Mark } from "@tiptap/pm/model";
import { findOccurrences } from "../search-and-replace/find-occurrences";
/**
* Multi-cursor editing MVP (issue #196, "Variant A").
*
* VS Code-style multi-cursor limited to "select all occurrences of a word (or
* the current selection) and type into all of them at once", built ON TOP OF
* the search-and-replace mass-transaction machinery:
*
* - Cmd/Ctrl+Shift+L (selectAllOccurrences): the word under the cursor (or the
* current non-empty selection) -> ALL its occurrences become active cursors.
* - Cmd/Ctrl+D (addNextOccurrence): add the NEXT occurrence of the term.
* - Typing / Backspace / Delete apply to EVERY active cursor in ONE
* transaction (so a single Cmd/Ctrl+Z undoes the whole multi-edit).
* - Esc (exitMultiCursor): collapse back to a single cursor.
*
* The single-transaction, reverse-order edit mechanic mirrors `replaceAll` in
* search-and-replace.ts: we iterate cursors from the END of the document to the
* START so an earlier edit never invalidates a later position, carrying the
* marks that span each range.
*
* CONSCIOUS v1 OUT-OF-SCOPE BOUNDARIES (these are "Variant B", deliberately NOT
* built here):
* - Alt+Click arbitrary carets and Alt+drag column selection.
* - Cmd/Ctrl+Alt+Up/Down "add cursor on the adjacent line".
* - Simultaneous IME / composition input into multiple positions on
* `compositionstart` we collapse back to a single cursor.
* - Cursors spanning different schema nodes in one edit.
*
* NOT out of scope, but worth stating precisely: there is NO schema-aware or
* structural cursor. Occurrences are found by a plain text-node walk
* (`findOccurrences`), so a term that appears inside a table cell, code block or
* callout DOES get a cursor there and IS edited as plain text, exactly like
* `replaceAll`. There is no special table/code handling; the per-cursor try/catch
* only SKIPS a cursor whose edit would violate the schema (never applied
* half-way), it does not exclude those node types from matching.
*/
interface MultiCursorState {
// Each active cursor: a caret when from === to, a range when from < to.
cursors: Range[];
}
export const multiCursorPluginKey = new PluginKey<MultiCursorState>(
"multiCursor",
);
// Hard safety cap on simultaneously-active cursors — stop adding past it.
export const MAX_CURSORS = 100;
export interface MultiCursorStorage {
// Whether the active term matches whole words only. Set to true when the set
// was seeded from a bare cursor (word under caret), false when seeded from an
// explicit selection (literal substring match, like VS Code). Remembered so
// addNextOccurrence keeps matching the same way as selectAllOccurrences.
wholeWord: boolean;
}
declare module "@tiptap/core" {
interface Storage {
multiCursor: MultiCursorStorage;
}
interface Commands<ReturnType> {
multiCursor: {
/** Select all occurrences of the word/selection as active cursors. */
selectAllOccurrences: () => ReturnType;
/** Add the next occurrence of the current term to the cursor set. */
addNextOccurrence: () => ReturnType;
/** Collapse the multi-cursor set back to a single cursor. */
exitMultiCursor: () => ReturnType;
};
}
}
// ---------------------------------------------------------------------------
// Term helpers
// ---------------------------------------------------------------------------
function escapeRegExp(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// A "word" is a run of letters/numbers/underscore; those get whole-word
// matching (\b…\b) so a term never matches inside a larger word. Anything else
// (punctuation, phrases) is matched literally. Case-sensitive, like VS Code.
function isWordTerm(s: string): boolean {
return /^[\p{L}\p{N}_]+$/u.test(s);
}
// wholeWord uses \b…\b so the term never matches inside a larger word; it only
// applies to word-like terms (a term containing punctuation cannot be
// whole-word-bounded meaningfully). Otherwise the term is matched literally.
function buildTermRegex(term: string, wholeWord: boolean): RegExp {
const esc = escapeRegExp(term);
return wholeWord && isWordTerm(term)
? new RegExp(`\\b${esc}\\b`, "gu")
: new RegExp(esc, "gu");
}
// Word under a position: returns the exact { from, to } range and its text, or
// null if the position is not inside a word in a textblock.
function getWordAt(
state: EditorState,
pos: number,
): { from: number; to: number; text: string } | null {
const $pos = state.doc.resolve(pos);
const parent = $pos.parent;
if (!parent.isTextblock) return null;
const text = parent.textContent;
const offset = $pos.parentOffset;
const start = $pos.start();
const wordRe = /[\p{L}\p{N}_]+/gu;
let m: RegExpExecArray | null;
while ((m = wordRe.exec(text)) !== null) {
const s = m.index;
const e = m.index + m[0].length;
if (offset >= s && offset <= e) {
return { from: start + s, to: start + e, text: m[0] };
}
}
return null;
}
// ---------------------------------------------------------------------------
// Plugin-state access
// ---------------------------------------------------------------------------
function getCursors(state: EditorState): Range[] {
const st = multiCursorPluginKey.getState(state);
return st ? st.cursors : [];
}
function setCursors(view: EditorView, cursors: Range[]): void {
view.dispatch(view.state.tr.setMeta(multiCursorPluginKey, cursors));
}
function collapse(view: EditorView): void {
setCursors(view, []);
}
// ---------------------------------------------------------------------------
// The single-transaction, reverse-order mass edit (mirrors replaceAll)
// ---------------------------------------------------------------------------
interface EditOp {
from: number;
to: number;
// Text to insert at `from` after deleting [from, to); "" for a pure delete.
text: string;
}
/**
* Apply one edit per cursor in ONE transaction. Ops are processed from the END
* of the document to the START so an earlier edit never shifts a later position
* (mirrors `replaceAll`). Each cursor is wrapped independently: a schema
* violation SKIPS that one cursor instead of throwing away the whole
* transaction, so the document is never left half-applied.
*
* After building the transaction the new cursor positions are recomputed by
* mapping each op's original anchor through `tr.mapping` (which also remaps any
* concurrent changes), so carets land right after their inserted text.
*/
function dispatchMassEdit(view: EditorView, ops: EditOp[]): boolean {
if (!ops.length) return false;
const { state } = view;
const tr = state.tr;
const schema = state.schema;
// Ascending by `from`; iterate reverse so earlier positions stay valid.
const sorted = [...ops].sort((a, b) => a.from - b.from);
const appliedLen: number[] = new Array(sorted.length).fill(0);
for (let i = sorted.length - 1; i >= 0; i -= 1) {
const { from, to, text } = sorted[i];
try {
let marks: readonly Mark[] = [];
if (text) {
if (to > from) {
// Carry all marks spanning the replaced range.
const set = new Set<Mark>();
tr.doc.nodesBetween(from, to, (node) => {
if (node.isText && node.marks) {
node.marks.forEach((mk) => set.add(mk));
}
});
marks = Array.from(set);
} else {
// Caret: continue the marks active at the insertion point.
marks = state.storedMarks || state.doc.resolve(from).marks();
}
}
// ONE atomic step per cursor: replaceWith covers both insert (from === to)
// and replace (to > from); a pure delete (empty text) uses delete. This
// can never leave a cursor half-applied (deleted but not re-inserted) the
// way a separate delete-then-insert pair could if the insert step threw.
if (text) {
tr.replaceWith(from, to, schema.text(text, marks as Mark[]));
} else if (to > from) {
tr.delete(from, to);
}
appliedLen[i] = text.length;
} catch {
// Per-cursor backstop (text-only MVP): drop this cursor's edit, keep the
// rest of the transaction intact.
appliedLen[i] = 0;
}
}
if (!tr.docChanged) return false;
// Recompute cursor carets from the ORIGINAL op anchors through the full map.
const newCursors: Range[] = sorted.map((op, i) => {
const start = tr.mapping.map(op.from, -1);
const caret = start + appliedLen[i];
return { from: caret, to: caret };
});
tr.setMeta(multiCursorPluginKey, newCursors);
// Park the native selection on the last caret so the browser draws exactly
// one real caret; the rest are our decoration widgets.
const last = newCursors[newCursors.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from));
view.dispatch(tr);
return true;
}
function buildDeleteOps(
state: EditorState,
cursors: Range[],
forward: boolean,
): EditOp[] {
return cursors.map((c) => {
// A selected range: Backspace/Delete removes the whole range.
if (c.to > c.from) return { from: c.from, to: c.to, text: "" };
const $pos = state.doc.resolve(c.from);
if (forward) {
// Delete: at the end of a textblock there is nothing to remove (a no-op;
// MVP does not merge blocks across a multi-cursor set).
if ($pos.parentOffset >= $pos.parent.content.size) {
return { from: c.from, to: c.from, text: "" };
}
return { from: c.from, to: c.from + 1, text: "" };
}
// Backspace: at the start of a textblock there is nothing to remove.
if ($pos.parentOffset <= 0) {
return { from: c.from, to: c.from, text: "" };
}
return { from: c.from - 1, to: c.from, text: "" };
});
}
// ---------------------------------------------------------------------------
// Extension
// ---------------------------------------------------------------------------
export const MultiCursor = Extension.create<unknown, MultiCursorStorage>({
name: "multiCursor",
addStorage() {
return { wholeWord: true };
},
addCommands() {
return {
selectAllOccurrences:
() =>
({ editor, state, tr, dispatch }) => {
let term: string;
// A bare cursor expands to the whole word; an explicit selection is
// matched literally (VS Code semantics).
const wholeWord = state.selection.empty;
if (wholeWord) {
const word = getWordAt(state, state.selection.from);
if (!word) return false;
term = word.text;
} else {
term = state.doc.textBetween(
state.selection.from,
state.selection.to,
);
}
if (!term.trim()) return false;
editor.storage.multiCursor.wholeWord = wholeWord;
const results = findOccurrences(
state.doc,
buildTermRegex(term, wholeWord),
).slice(0, MAX_CURSORS);
if (!results.length) return false;
if (dispatch) {
tr.setMeta(multiCursorPluginKey, results);
const last = results[results.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
dispatch(tr);
}
return true;
},
addNextOccurrence:
() =>
({ editor, state, tr, dispatch }) => {
const existing = getCursors(state);
let cursors: Range[];
if (!existing.length) {
// First press: turn the current word/selection into the one cursor.
let range: Range;
const wholeWord = state.selection.empty;
if (wholeWord) {
const word = getWordAt(state, state.selection.from);
if (!word) return false;
range = { from: word.from, to: word.to };
} else {
range = { from: state.selection.from, to: state.selection.to };
}
editor.storage.multiCursor.wholeWord = wholeWord;
cursors = [range];
} else {
// Subsequent press: add the next unselected occurrence of the term,
// matched the SAME way (whole-word vs literal) the set was seeded.
if (existing.length >= MAX_CURSORS) return true;
const first = existing[0];
const term = state.doc.textBetween(first.from, first.to);
if (!term.trim()) return false;
const results = findOccurrences(
state.doc,
buildTermRegex(term, editor.storage.multiCursor.wholeWord),
);
const keys = new Set(existing.map((c) => `${c.from}:${c.to}`));
const notSelected = results.filter(
(r) => !keys.has(`${r.from}:${r.to}`),
);
if (!notSelected.length) return true; // all occurrences selected
const maxTo = Math.max(...existing.map((c) => c.to));
const next =
notSelected.find((r) => r.from >= maxTo) || notSelected[0];
cursors = [...existing, next];
}
if (dispatch) {
tr.setMeta(multiCursorPluginKey, cursors);
const last = cursors[cursors.length - 1];
tr.setSelection(TextSelection.create(tr.doc, last.from, last.to));
dispatch(tr);
}
return true;
},
exitMultiCursor:
() =>
({ tr, dispatch }) => {
if (dispatch) {
tr.setMeta(multiCursorPluginKey, []);
dispatch(tr);
}
return true;
},
};
},
addKeyboardShortcuts() {
return {
"Mod-Shift-l": () => {
this.editor.commands.selectAllOccurrences();
// Always consume so the browser's default is prevented.
return true;
},
"Mod-d": () => {
this.editor.commands.addNextOccurrence();
// Consume unconditionally to prevent the browser's Cmd/Ctrl+D bookmark.
return true;
},
Escape: () => {
// Only swallow Escape while a multi-cursor set is active; otherwise let
// Escape keep its other behaviours (e.g. closing dialogs).
if (!getCursors(this.editor.state).length) return false;
return this.editor.commands.exitMultiCursor();
},
};
},
addProseMirrorPlugins() {
return [
new Plugin<MultiCursorState>({
key: multiCursorPluginKey,
state: {
init: () => ({ cursors: [] }),
apply(tr, value): MultiCursorState {
// A command (or a mass edit) can set/clear the cursor set directly.
// Its cursors are already in the post-transaction coordinate space,
// so they take priority over remapping.
const meta = tr.getMeta(multiCursorPluginKey) as
| Range[]
| undefined;
if (meta !== undefined) {
return { cursors: meta.slice(0, MAX_CURSORS) };
}
if (!value.cursors.length) return value;
// Remap surviving cursors across ANY doc change — this covers both
// local edits and REMOTE Yjs edits (y-prosemirror applies remote
// changes as ordinary transactions, so mapping them here keeps every
// multi-cursor correctly positioned without special-casing collab).
if (tr.docChanged) {
// Map both edges with the SAME association (+1) so content
// inserted at a boundary shifts the whole cursor right and a caret
// (from === to) can never invert into a range.
const cursors = value.cursors.map((c) => ({
from: tr.mapping.map(c.from, 1),
to: tr.mapping.map(c.to, 1),
}));
return { cursors };
}
return value;
},
},
props: {
decorations(state) {
const st = multiCursorPluginKey.getState(state);
if (!st || !st.cursors.length) return DecorationSet.empty;
const decorations: Decoration[] = [];
st.cursors.forEach((c, i) => {
if (c.from === c.to) {
decorations.push(
Decoration.widget(
c.from,
() => {
const el = document.createElement("span");
el.className = "multi-cursor__caret";
return el;
},
{ side: 0, key: `mc-caret-${i}` },
),
);
} else {
decorations.push(
Decoration.inline(c.from, c.to, {
class: "multi-cursor__selection",
}),
);
}
});
return DecorationSet.create(state.doc, decorations);
},
handleTextInput(view, _from, _to, text) {
const cursors = getCursors(view.state);
if (!cursors.length) return false;
// Insert `text` at EVERY cursor in one transaction. Returning true
// prevents ProseMirror's own single-position insert at the native
// selection, so there is no double-insert there.
const ops = cursors.map((c) => ({
from: c.from,
to: c.to,
text,
}));
return dispatchMassEdit(view, ops);
},
handleKeyDown(view, event) {
const cursors = getCursors(view.state);
if (!cursors.length) return false;
if (event.key === "Backspace") {
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, false));
return true;
}
if (event.key === "Delete") {
dispatchMassEdit(view, buildDeleteOps(view.state, cursors, true));
return true;
}
// Let modifier combinations (our own shortcuts, copy, etc.) through
// WITHOUT collapsing the set.
if (event.metaKey || event.ctrlKey || event.altKey) return false;
// Navigation / block keys collapse back to a single cursor, then let
// ProseMirror handle the movement on the native selection.
const COLLAPSE_KEYS = [
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Home",
"End",
"PageUp",
"PageDown",
"Enter",
"Tab",
];
if (COLLAPSE_KEYS.includes(event.key)) {
collapse(view);
return false;
}
return false;
},
handleDOMEvents: {
// A plain click exits multi-cursor (VS Code behaviour).
mousedown: (view) => {
if (getCursors(view.state).length) collapse(view);
return false;
},
// MVP does not drive multi-position IME — collapse on composition.
compositionstart: (view) => {
if (getCursors(view.state).length) collapse(view);
return false;
},
},
},
}),
];
},
});
export default MultiCursor;
@@ -0,0 +1,69 @@
import { Range } from "@tiptap/core";
import { Node as PMNode } from "@tiptap/pm/model";
interface TextNodesWithPosition {
text: string;
pos: number;
}
/**
* Shared "find all occurrences of a term in the doc" primitive.
*
* Walks every text node of the document and returns each regex match as a
* `{ from, to }` range. Contiguous text nodes (which may differ only by marks)
* are concatenated into a single run, so a match that spans e.g. "wo" + bold
* "rd" is still found; runs are split by any non-text node, so a match never
* crosses a node boundary. Whitespace-only matches are ignored.
*
* This is used by BOTH search-and-replace (highlight/replace) and multi-cursor
* (turn occurrences into active cursors) so the two stay behaviourally in sync.
* Extracted verbatim from the original `processSearches` walk.
*/
export function findOccurrences(doc: PMNode, searchTerm: RegExp): Range[] {
const results: Range[] = [];
if (!searchTerm) return results;
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
return results;
}
@@ -1,3 +1,4 @@
import { SearchAndReplace } from './search-and-replace'
export * from './search-and-replace'
export * from './find-occurrences'
export default SearchAndReplace
@@ -29,6 +29,7 @@ import {
type Transaction,
} from "@tiptap/pm/state";
import { Node as PMNode, Mark } from "@tiptap/pm/model";
import { findOccurrences } from "./find-occurrences";
declare module "@tiptap/core" {
interface Storage {
@@ -76,11 +77,6 @@ declare module "@tiptap/core" {
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (
s: string,
disableRegex: boolean,
@@ -104,10 +100,6 @@ function processSearches(
resultIndex: number,
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
@@ -116,43 +108,8 @@ function processSearches(
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos,
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos,
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(
([matchText]) => matchText.trim(),
);
for (const m of matches) {
if (m[0] === "") break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length,
});
}
}
}
// Shared find-all-occurrences primitive (also used by multi-cursor).
const results: Range[] = findOccurrences(doc, searchTerm);
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
-68
View File
@@ -1,68 +0,0 @@
diff --git a/dist/index.js b/dist/index.js
index ae447a12f7823ec0a00837ee9f0eb809a610d5f8..a3402b2c2d021ef432cfa76e35d370073d525135 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -6578,9 +6578,19 @@ function createOutputTransformStream(output) {
controller.enqueue({ part: chunk, partialOutput: void 0 });
return;
}
- text2 += chunk.text;
textChunk += chunk.text;
textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata;
+ if (output == null) {
+ // PATCH(docmost #OOM): no output strategy requested -> publish each
+ // text-delta immediately and do NOT build cumulative partialOutput
+ // snapshots. Unpatched, the default text() output snapshots the ENTIRE
+ // accumulated turn text on every delta (O(n^2) memory) and those
+ // snapshots pile up in the never-consumed leftover tee branch of
+ // DefaultStreamTextResult.baseStream -> heap OOM on long agent turns.
+ publishTextChunk({ controller });
+ return;
+ }
+ text2 += chunk.text;
const result = await output.parsePartialOutput({ text: text2 });
if (result !== void 0) {
const currentJson = JSON.stringify(result.partial);
@@ -6959,7 +6969,7 @@ var DefaultStreamTextResult = class {
})
);
}
- this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor);
+ this.baseStream = stream.pipeThrough(createOutputTransformStream(output)).pipeThrough(eventProcessor);
const { maxRetries, retry } = prepareRetries({
maxRetries: maxRetriesArg,
abortSignal
diff --git a/dist/index.mjs b/dist/index.mjs
index 663875332e3f9a9bd167c25583c515876f42951b..b840b0502c9894df983e0154805abb80e70e6331 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -6501,9 +6501,19 @@ function createOutputTransformStream(output) {
controller.enqueue({ part: chunk, partialOutput: void 0 });
return;
}
- text2 += chunk.text;
textChunk += chunk.text;
textProviderMetadata = (_a21 = chunk.providerMetadata) != null ? _a21 : textProviderMetadata;
+ if (output == null) {
+ // PATCH(docmost #OOM): no output strategy requested -> publish each
+ // text-delta immediately and do NOT build cumulative partialOutput
+ // snapshots. Unpatched, the default text() output snapshots the ENTIRE
+ // accumulated turn text on every delta (O(n^2) memory) and those
+ // snapshots pile up in the never-consumed leftover tee branch of
+ // DefaultStreamTextResult.baseStream -> heap OOM on long agent turns.
+ publishTextChunk({ controller });
+ return;
+ }
+ text2 += chunk.text;
const result = await output.parsePartialOutput({ text: text2 });
if (result !== void 0) {
const currentJson = JSON.stringify(result.partial);
@@ -6882,7 +6892,7 @@ var DefaultStreamTextResult = class {
})
);
}
- this.baseStream = stream.pipeThrough(createOutputTransformStream(output != null ? output : text())).pipeThrough(eventProcessor);
+ this.baseStream = stream.pipeThrough(createOutputTransformStream(output)).pipeThrough(eventProcessor);
const { maxRetries, retry } = prepareRetries({
maxRetries: maxRetriesArg,
abortSignal
+5 -8
View File
@@ -44,9 +44,6 @@ overrides:
ip-address: 10.1.1
patchedDependencies:
ai@6.0.134:
hash: f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9
path: patches/ai@6.0.134.patch
scimmy@1.3.5:
hash: 775d80f86830b2c5dd1a250c9802c10f8fc3da3c7898373de5aa0c23993d1673
path: patches/scimmy@1.3.5.patch
@@ -626,10 +623,10 @@ importers:
version: 8.3.0(socket.io-adapter@2.5.4)
ai:
specifier: ^6.0.134
version: 6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6)
version: 6.0.134(zod@4.3.6)
ai-sdk-ollama:
specifier: ^3.8.1
version: 3.8.1(ai@6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6))(zod@4.3.6)
version: 3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6)
bcrypt:
specifier: ^6.0.0
version: 6.0.0
@@ -16358,17 +16355,17 @@ snapshots:
agent-base@7.1.4: {}
ai-sdk-ollama@3.8.1(ai@6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6))(zod@4.3.6):
ai-sdk-ollama@3.8.1(ai@6.0.134(zod@4.3.6))(zod@4.3.6):
dependencies:
'@ai-sdk/provider': 3.0.8
'@ai-sdk/provider-utils': 4.0.21(zod@4.3.6)
ai: 6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6)
ai: 6.0.134(zod@4.3.6)
jsonrepair: 3.13.3
ollama: 0.6.3
transitivePeerDependencies:
- zod
ai@6.0.134(patch_hash=f60bfc3357e01e1f3978c6c40fdd65aeb33fefaad7179cde8676465b6c5ff4d9)(zod@4.3.6):
ai@6.0.134(zod@4.3.6):
dependencies:
'@ai-sdk/gateway': 3.0.77(zod@4.3.6)
'@ai-sdk/provider': 3.0.8