Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36b940fdb8 | |||
| ce70fab1df | |||
| b51dae16a6 | |||
| 39735afd73 | |||
| eebbe6717c | |||
| e348433a39 |
@@ -45,7 +45,6 @@ import {
|
||||
TiptapPdf,
|
||||
PageBreak,
|
||||
SearchAndReplace,
|
||||
MultiCursor,
|
||||
Mention,
|
||||
TableDndExtension,
|
||||
TableHandleCommandsExtension,
|
||||
@@ -448,10 +447,6 @@ 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,6 +1,5 @@
|
||||
@import "./core.css";
|
||||
@import "./collaboration.css";
|
||||
@import "./multi-cursor.css";
|
||||
@import "./task-list.css";
|
||||
@import "./placeholder.css";
|
||||
@import "./drag-handle.css";
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -539,3 +539,115 @@ describe('AiChatToolsService model-friendly input validation (#190)', () => {
|
||||
expect(result.error?.message).toContain('parameter "pageId": missing (required)');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #294 F1 — the contract-parity test introspects only the ADVERTISED schema keys
|
||||
* (buildShape), not the execute bodies. Most execs are unchanged pass-throughs,
|
||||
* but two wirings actually CHANGED in the migration and are otherwise untested:
|
||||
* - movePage now forwards the newly-added optional `position` field to the
|
||||
* client (client.movePage(pageId, parentPageId, position));
|
||||
* - the table trio unified its `tableRef` param to `table` and must forward it
|
||||
* positionally. A field destructured under the wrong name would silently pass
|
||||
* `undefined` to the client (execute is `any`-cast, so tsc won't catch it).
|
||||
*/
|
||||
describe('AiChatToolsService #294 changed execute wirings', () => {
|
||||
const calls: Record<string, unknown[][]> = {
|
||||
movePage: [],
|
||||
tableInsertRow: [],
|
||||
tableDeleteRow: [],
|
||||
tableUpdateCell: [],
|
||||
};
|
||||
const fakeClient: Partial<DocmostClientLike> = {
|
||||
movePage: (...args: unknown[]) => {
|
||||
calls.movePage.push(args);
|
||||
return Promise.resolve({ success: true });
|
||||
},
|
||||
tableInsertRow: (...args: unknown[]) => {
|
||||
calls.tableInsertRow.push(args);
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
tableDeleteRow: (...args: unknown[]) => {
|
||||
calls.tableDeleteRow.push(args);
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
tableUpdateCell: (...args: unknown[]) => {
|
||||
calls.tableUpdateCell.push(args);
|
||||
return Promise.resolve({ ok: true });
|
||||
},
|
||||
};
|
||||
const tokenServiceStub = {
|
||||
generateAccessToken: jest.fn().mockResolvedValue('access-token'),
|
||||
generateCollabToken: jest.fn().mockResolvedValue('collab-token'),
|
||||
};
|
||||
let service: AiChatToolsService;
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of Object.keys(calls)) calls[k].length = 0;
|
||||
jest.spyOn(loader, 'loadDocmostMcp').mockResolvedValue(
|
||||
mockLoaded(function () {
|
||||
return fakeClient as DocmostClientLike;
|
||||
} as unknown as loader.DocmostClientCtor),
|
||||
);
|
||||
service = new AiChatToolsService(
|
||||
tokenServiceStub as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
asSink: () => ({ put: jest.fn(), has: jest.fn(), evict: jest.fn() }),
|
||||
} as never,
|
||||
);
|
||||
});
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
const buildTools = () =>
|
||||
service.forUser(
|
||||
{ id: 'user-1', email: 'u@example.com', workspaceId: 'ws-1' } as never,
|
||||
'session-1',
|
||||
'ws-1',
|
||||
'chat-1',
|
||||
);
|
||||
|
||||
it('movePage forwards the optional position to the client', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.movePage.execute(
|
||||
{ pageId: 'p1', parentPageId: 'parent1', position: 'a5' } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.movePage).toEqual([['p1', 'parent1', 'a5']]);
|
||||
});
|
||||
|
||||
it('movePage passes undefined position and null parent when omitted (unchanged behavior)', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.movePage.execute({ pageId: 'p2' } as never, {} as never);
|
||||
expect(calls.movePage).toEqual([['p2', null, undefined]]);
|
||||
});
|
||||
|
||||
it('tableInsertRow forwards the unified `table` param positionally', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.tableInsertRow.execute(
|
||||
{ pageId: 'p1', table: '#0', cells: ['a', 'b'], index: 2 } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.tableInsertRow).toEqual([['p1', '#0', ['a', 'b'], 2]]);
|
||||
});
|
||||
|
||||
it('tableDeleteRow forwards `table` positionally', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.tableDeleteRow.execute(
|
||||
{ pageId: 'p1', table: '#0', index: 1 } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.tableDeleteRow).toEqual([['p1', '#0', 1]]);
|
||||
});
|
||||
|
||||
it('tableUpdateCell forwards `table` positionally', async () => {
|
||||
const tools = await buildTools();
|
||||
await tools.tableUpdateCell.execute(
|
||||
{ pageId: 'p1', table: '#0', row: 1, col: 2, text: 'x' } as never,
|
||||
{} as never,
|
||||
);
|
||||
expect(calls.tableUpdateCell).toEqual([['p1', '#0', 1, 2, 'x']]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -316,50 +316,27 @@ export class AiChatToolsService {
|
||||
execute: async () => resolveCurrentPageResult(openedPage),
|
||||
}),
|
||||
|
||||
getPage: tool({
|
||||
description:
|
||||
'Fetch a single page as Markdown by its page id. Returns the page ' +
|
||||
'title and its Markdown content. Inline <span data-comment-id> tags ' +
|
||||
'in the markdown are comment highlight anchors (also present for ' +
|
||||
'RESOLVED threads) — treat them as markup, not page text.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||
const result = await client.getPage(pageId);
|
||||
const data = (result?.data ?? {}) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
return {
|
||||
title: data.title ?? '',
|
||||
markdown: typeof data.content === 'string' ? data.content : '',
|
||||
};
|
||||
},
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The execute body keeps this layer's { title, markdown } projection.
|
||||
getPage: sharedTool(sharedToolSpecs.getPage, async ({ pageId }) => {
|
||||
// getPage(pageId) -> { data: filterPage(page, markdown), success }.
|
||||
const result = await client.getPage(pageId);
|
||||
const data = (result?.data ?? {}) as {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
return {
|
||||
title: data.title ?? '',
|
||||
markdown: typeof data.content === 'string' ? data.content : '',
|
||||
};
|
||||
}),
|
||||
|
||||
// --- WRITE tools (all reversible — history/trash; §6.5 / D3) ---
|
||||
|
||||
createPage: tool({
|
||||
description:
|
||||
'Create a new page with a Markdown body in a space, optionally under ' +
|
||||
'a parent page. Returns the new page id and title. Reversible: a page ' +
|
||||
'can be moved to trash later.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
title: z.string().describe('The title of the new page.'),
|
||||
content: z
|
||||
.string()
|
||||
.describe('The page body as Markdown (may be empty).'),
|
||||
spaceId: z
|
||||
.string()
|
||||
.describe('The id of the space to create the page in.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional parent page id to nest the new page under.'),
|
||||
}),
|
||||
execute: async ({ title, content, spaceId, parentPageId }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
createPage: sharedTool(
|
||||
sharedToolSpecs.createPage,
|
||||
async ({ title, content, spaceId, parentPageId }) => {
|
||||
// createPage(title, content, spaceId, parentPageId?) ->
|
||||
// { data: filterPage(page, markdown), success }.
|
||||
const result = await client.createPage(
|
||||
@@ -375,7 +352,7 @@ export class AiChatToolsService {
|
||||
};
|
||||
return { id: data.id ?? data.slugId, title: data.title ?? title };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
updatePageContent: tool({
|
||||
description:
|
||||
@@ -399,115 +376,46 @@ export class AiChatToolsService {
|
||||
},
|
||||
}),
|
||||
|
||||
renamePage: tool({
|
||||
description:
|
||||
"Rename a page (change its title only; the body is untouched). " +
|
||||
'Reversible: rename back at any time.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to rename.'),
|
||||
title: z.string().describe('The new title.'),
|
||||
}),
|
||||
execute: async ({ pageId, title }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
renamePage: sharedTool(
|
||||
sharedToolSpecs.renamePage,
|
||||
async ({ pageId, title }) => {
|
||||
// renamePage(pageId, title) -> { success, pageId, title }.
|
||||
await client.renamePage(pageId, title);
|
||||
return { pageId, title };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
movePage: tool({
|
||||
description:
|
||||
'Move a page under a new parent page, or to the space root when no ' +
|
||||
'parent is given. Reversible: move it back at any time.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to move.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Target parent page id. Null/omitted moves the page to the ' +
|
||||
'space root.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, parentPageId }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The shared schema adds the optional `position` field this layer lacked
|
||||
// before; the execute now forwards it (the client already accepted it).
|
||||
movePage: sharedTool(
|
||||
sharedToolSpecs.movePage,
|
||||
async ({ pageId, parentPageId, position }) => {
|
||||
// movePage(pageId, parentPageId, position?) -> raw move response.
|
||||
await client.movePage(pageId, parentPageId ?? null);
|
||||
await client.movePage(pageId, parentPageId ?? null, position);
|
||||
return { pageId, parentPageId: parentPageId ?? null, moved: true };
|
||||
},
|
||||
),
|
||||
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// GUARDRAIL (§14 H4) preserved: the shared schema exposes ONLY pageId, so
|
||||
// permanentlyDelete/forceDelete are never part of the input and can never
|
||||
// be forwarded — the agent physically cannot permanently delete a page.
|
||||
deletePage: sharedTool(sharedToolSpecs.deletePage, async ({ pageId }) => {
|
||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||
// which is the soft-delete (trash) path on the server.
|
||||
await client.deletePage(pageId);
|
||||
return { pageId, trashed: true };
|
||||
}),
|
||||
|
||||
deletePage: tool({
|
||||
description:
|
||||
'Move a page to the trash (SOFT delete only — fully reversible; the ' +
|
||||
'page can be restored from trash). This NEVER permanently deletes.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to move to trash.'),
|
||||
}),
|
||||
// GUARDRAIL (§14 H4): the only field ever passed to the client is
|
||||
// pageId. permanentlyDelete/forceDelete are not part of the schema and
|
||||
// are never forwarded, so the agent physically cannot permanently
|
||||
// delete a page through this tool.
|
||||
execute: async ({ pageId }) => {
|
||||
// deletePage(pageId) hits POST /pages/delete with { pageId } only,
|
||||
// which is the soft-delete (trash) path on the server.
|
||||
await client.deletePage(pageId);
|
||||
return { pageId, trashed: true };
|
||||
},
|
||||
}),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): the description is
|
||||
// tuned for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
||||
// and "Reversible via the comment UI"); the standalone MCP `create_comment`
|
||||
// keeps its own wording. Kept per-layer.
|
||||
createComment: tool({
|
||||
description:
|
||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
||||
'comment (one level only — the backend rejects replies to replies). ' +
|
||||
'The comment is anchored inline to the given exact `selection` text ' +
|
||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||
"new top-level comment REQUIRES a `selection`. Replies inherit the " +
|
||||
"parent's anchor and take no selection. If the call fails with a " +
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||
'once in the page. Reversible via the comment UI.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().describe('The comment body as Markdown.'),
|
||||
selection: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.optional()
|
||||
.describe(
|
||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
||||
'formatting boundaries). Required for a new top-level comment; ' +
|
||||
'omit only when replying via parentCommentId.',
|
||||
),
|
||||
parentCommentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
||||
'of replies only).',
|
||||
),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
||||
'refused.',
|
||||
),
|
||||
}),
|
||||
execute: async ({
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// This layer keeps only its own execute-side guards (require a selection
|
||||
// for a top-level comment; reject suggestedText on a reply / without a
|
||||
// selection) — the schema+description are shared.
|
||||
createComment: sharedTool(
|
||||
sharedToolSpecs.createComment,
|
||||
async ({
|
||||
pageId,
|
||||
content,
|
||||
selection,
|
||||
@@ -548,26 +456,17 @@ export class AiChatToolsService {
|
||||
const data = (result?.data ?? {}) as { id?: string };
|
||||
return { commentId: data.id, pageId };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
resolveComment: tool({
|
||||
description:
|
||||
'Resolve or reopen a top-level comment thread (reversible — toggle ' +
|
||||
'the resolved flag). Only top-level comments can be resolved.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
commentId: z
|
||||
.string()
|
||||
.describe('The id of the top-level comment to resolve/reopen.'),
|
||||
resolved: z
|
||||
.boolean()
|
||||
.describe('true to resolve the thread, false to reopen it.'),
|
||||
}),
|
||||
execute: async ({ commentId, resolved }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
resolveComment: sharedTool(
|
||||
sharedToolSpecs.resolveComment,
|
||||
async ({ commentId, resolved }) => {
|
||||
// resolveComment(commentId, resolved) -> { success, commentId, resolved }.
|
||||
await client.resolveComment(commentId, resolved);
|
||||
return { commentId, resolved };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// --- READ tools (added) ---
|
||||
|
||||
@@ -585,33 +484,12 @@ export class AiChatToolsService {
|
||||
// hierarchy mode but is worded for the in-app agent; the standalone MCP
|
||||
// `list_pages` carries its own wording. Kept per-layer so each side tunes
|
||||
// its own guidance.
|
||||
listPages: tool({
|
||||
description:
|
||||
'List the most recent pages, optionally scoped to a single space. ' +
|
||||
'Returns a bounded list (default 50, max 100). Pass tree:true (with ' +
|
||||
"spaceId) to instead get the space's full page hierarchy as a nested tree.",
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional space id to scope the listing to.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum number of pages (1-100).'),
|
||||
tree: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'When true, return the full page hierarchy of the given space as a nested tree (children arrays) instead of the recent-pages flat list. Requires spaceId; ignores limit.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ spaceId, limit, tree }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listPages: sharedTool(
|
||||
sharedToolSpecs.listPages,
|
||||
async ({ spaceId, limit, tree }) =>
|
||||
await client.listPages(spaceId, limit, tree),
|
||||
}),
|
||||
),
|
||||
|
||||
listSidebarPages: tool({
|
||||
description:
|
||||
@@ -656,41 +534,34 @@ export class AiChatToolsService {
|
||||
}),
|
||||
),
|
||||
|
||||
// NOT shared (kept inline): the MCP tool name `table_get` is noun-first
|
||||
// while this key is `getTable` (verb-first), breaking the
|
||||
// snake_case(inAppKey) convention the shared registry enforces. Its
|
||||
// reference parameter is still named `table` (was `tableRef`) so it matches
|
||||
// the migrated table row/cell tools below.
|
||||
getTable: tool({
|
||||
description:
|
||||
'Read a table as a matrix of cell texts (plus a parallel cellIds ' +
|
||||
'matrix so cells can be addressed for rich edits).',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
table: z
|
||||
.string()
|
||||
.describe(
|
||||
'"#<index>" from getOutline, or a block id of any node inside ' +
|
||||
'the table.',
|
||||
'"#<index>" from the page outline, or a block id of any node ' +
|
||||
'inside the table.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef }) =>
|
||||
await client.getTable(pageId, tableRef),
|
||||
execute: async ({ pageId, table }) =>
|
||||
await client.getTable(pageId, table),
|
||||
}),
|
||||
|
||||
listComments: tool({
|
||||
description:
|
||||
'List comments on a page in one call. By DEFAULT only ACTIVE ' +
|
||||
'threads are returned; resolved threads (a resolved top-level ' +
|
||||
'comment and all its replies) are hidden and their count reported ' +
|
||||
'as `resolvedThreadsHidden` so you can re-query with ' +
|
||||
'`includeResolved: true` to see everything. Returns ' +
|
||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
includeResolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('default only active threads; true — include resolved'),
|
||||
}),
|
||||
execute: async ({ pageId, includeResolved }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
listComments: sharedTool(
|
||||
sharedToolSpecs.listComments,
|
||||
async ({ pageId, includeResolved }) =>
|
||||
await client.listComments(pageId, includeResolved),
|
||||
}),
|
||||
),
|
||||
|
||||
getComment: tool({
|
||||
description: 'Fetch a single comment by id (content as Markdown).',
|
||||
@@ -700,26 +571,12 @@ export class AiChatToolsService {
|
||||
execute: async ({ commentId }) => await client.getComment(commentId),
|
||||
}),
|
||||
|
||||
checkNewComments: tool({
|
||||
description:
|
||||
'Find new comments across a space (optionally scoped to a subtree) ' +
|
||||
'created after a given timestamp.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
spaceId: z.string().describe('The id of the space to scan.'),
|
||||
since: z
|
||||
.string()
|
||||
.describe('An ISO-8601 timestamp; only comments created after it.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional page id to scope the scan to that page and its ' +
|
||||
'descendants.',
|
||||
),
|
||||
}),
|
||||
execute: async ({ spaceId, since, parentPageId }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
checkNewComments: sharedTool(
|
||||
sharedToolSpecs.checkNewComments,
|
||||
async ({ spaceId, since, parentPageId }) =>
|
||||
await client.checkNewComments(spaceId, since, parentPageId),
|
||||
}),
|
||||
),
|
||||
|
||||
listShares: sharedTool(
|
||||
sharedToolSpecs.listShares,
|
||||
@@ -749,19 +606,14 @@ export class AiChatToolsService {
|
||||
await client.diffPageVersions(pageId, from, to),
|
||||
),
|
||||
|
||||
exportPageMarkdown: tool({
|
||||
description:
|
||||
'Export a page to a single self-contained Docmost-flavoured ' +
|
||||
'Markdown file (meta + body + comment threads). Lossless round-trip ' +
|
||||
'with importPageMarkdown.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to export.'),
|
||||
}),
|
||||
execute: async ({ pageId }) => {
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
exportPageMarkdown: sharedTool(
|
||||
sharedToolSpecs.exportPageMarkdown,
|
||||
async ({ pageId }) => {
|
||||
const markdown = await client.exportPageMarkdown(pageId);
|
||||
return { markdown };
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// --- WRITE tools (added; reversible via page history/trash) ---
|
||||
|
||||
@@ -811,28 +663,12 @@ export class AiChatToolsService {
|
||||
async ({ pageId, nodeId }) => await client.deleteNode(pageId, nodeId),
|
||||
),
|
||||
|
||||
updatePageJson: tool({
|
||||
description:
|
||||
"Replace a page's body with a full ProseMirror document — a full " +
|
||||
'overwrite — and/or update its title. Minimal example content: ' +
|
||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
||||
'[{"type":"text","text":"Hi"}]}]}. The content arg may be a JSON ' +
|
||||
'object or a JSON string (both accepted). Omit content for a ' +
|
||||
'title-only update. Reversible: the previous version is kept in page ' +
|
||||
'history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to update.'),
|
||||
content: z
|
||||
.any()
|
||||
.optional()
|
||||
.describe(
|
||||
'Full ProseMirror doc {"type":"doc","content":[...]} (JSON ' +
|
||||
'object or JSON string); omit for a title-only update.',
|
||||
),
|
||||
title: z.string().optional().describe('Optional new title.'),
|
||||
}),
|
||||
execute: async ({ pageId, content, title }) => {
|
||||
// Parity with the standalone MCP server (index.ts update_page_json):
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The execute body keeps this layer's content normalization (parity with
|
||||
// the standalone MCP server, index.ts update_page_json).
|
||||
updatePageJson: sharedTool(
|
||||
sharedToolSpecs.updatePageJson,
|
||||
async ({ pageId, content, title }) => {
|
||||
// undefined/null pass through as undefined (title-only / no-op); any
|
||||
// string is JSON.parsed (so an empty string "" throws, matching the
|
||||
// MCP server); an object is passed through unchanged.
|
||||
@@ -845,66 +681,29 @@ export class AiChatToolsService {
|
||||
}
|
||||
return await client.updatePageJson(pageId, doc, title);
|
||||
},
|
||||
}),
|
||||
),
|
||||
|
||||
// NOT in the shared registry: this layer names the table argument
|
||||
// `tableRef`, while the standalone MCP tool names it `table` (index.ts).
|
||||
// Sharing one buildShape would rename a model-facing parameter on one
|
||||
// transport, so the table row/cell tools stay per-layer by design.
|
||||
tableInsertRow: tool({
|
||||
description:
|
||||
'Insert a row of plain-text cells into a table. Reversible via ' +
|
||||
'page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
cells: z.array(z.string()).describe('The cell texts for the row.'),
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe('0-based insert position (omit/out-of-range to append).'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, cells, index }) =>
|
||||
await client.tableInsertRow(pageId, tableRef, cells, index),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// The table reference parameter was unified to `table` (was `tableRef`).
|
||||
tableInsertRow: sharedTool(
|
||||
sharedToolSpecs.tableInsertRow,
|
||||
async ({ pageId, table, cells, index }) =>
|
||||
await client.tableInsertRow(pageId, table, cells, index),
|
||||
),
|
||||
|
||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
||||
// divergence as tableInsertRow.
|
||||
tableDeleteRow: tool({
|
||||
description:
|
||||
'Delete a table row at a 0-based index. Reversible via page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
index: z.number().int().describe('0-based row index to delete.'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, index }) =>
|
||||
await client.tableDeleteRow(pageId, tableRef, index),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
tableDeleteRow: sharedTool(
|
||||
sharedToolSpecs.tableDeleteRow,
|
||||
async ({ pageId, table, index }) =>
|
||||
await client.tableDeleteRow(pageId, table, index),
|
||||
),
|
||||
|
||||
// NOT shared — same `tableRef` (here) vs `table` (MCP) parameter-name
|
||||
// divergence as tableInsertRow.
|
||||
tableUpdateCell: tool({
|
||||
description:
|
||||
'Set the plain-text content of a table cell at [row, col] (0-based). ' +
|
||||
'Reversible via page history.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page.'),
|
||||
tableRef: z
|
||||
.string()
|
||||
.describe('"#<index>" from getOutline, or a block id in the table.'),
|
||||
row: z.number().int().describe('0-based row index.'),
|
||||
col: z.number().int().describe('0-based column index.'),
|
||||
text: z.string().describe('The new cell text.'),
|
||||
}),
|
||||
execute: async ({ pageId, tableRef, row, col, text }) =>
|
||||
await client.tableUpdateCell(pageId, tableRef, row, col, text),
|
||||
}),
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
tableUpdateCell: sharedTool(
|
||||
sharedToolSpecs.tableUpdateCell,
|
||||
async ({ pageId, table, row, col, text }) =>
|
||||
await client.tableUpdateCell(pageId, table, row, col, text),
|
||||
),
|
||||
|
||||
copyPageContent: sharedTool(
|
||||
sharedToolSpecs.copyPageContent,
|
||||
@@ -918,25 +717,14 @@ export class AiChatToolsService {
|
||||
await client.importPageMarkdown(pageId, markdown),
|
||||
),
|
||||
|
||||
// INTENTIONAL per-transport divergence (not shared): adds a security
|
||||
// confirmation framing ("Only share when the user explicitly asked, since
|
||||
// this exposes the page to anyone with the link") for the in-app agent; the
|
||||
// standalone MCP `share_page` keeps the plain public-URL wording.
|
||||
sharePage: tool({
|
||||
description:
|
||||
'Make a page PUBLICLY accessible and return its public URL. ' +
|
||||
'Reversible via unsharePage. Only share when the user explicitly ' +
|
||||
'asked, since this exposes the page to anyone with the link.',
|
||||
inputSchema: modelFriendlyInput({
|
||||
pageId: z.string().describe('The id of the page to share.'),
|
||||
searchIndexing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Allow public search engines to index it (default true).'),
|
||||
}),
|
||||
execute: async ({ pageId, searchIndexing }) =>
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294).
|
||||
// Both layers already carried the security-confirmation framing, so there
|
||||
// was no real divergence to preserve — only wording drift.
|
||||
sharePage: sharedTool(
|
||||
sharedToolSpecs.sharePage,
|
||||
async ({ pageId, searchIndexing }) =>
|
||||
await client.sharePage(pageId, searchIndexing),
|
||||
}),
|
||||
),
|
||||
|
||||
unsharePage: sharedTool(
|
||||
sharedToolSpecs.unsharePage,
|
||||
|
||||
@@ -100,54 +100,26 @@ export const INLINE_TOOL_TIERS: Record<
|
||||
tier: 'core',
|
||||
catalogLine: 'getCurrentPage — the page the user is currently viewing.',
|
||||
},
|
||||
getPage: {
|
||||
tier: 'core',
|
||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||
},
|
||||
listPages: {
|
||||
tier: 'core',
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
},
|
||||
listComments: {
|
||||
tier: 'core',
|
||||
catalogLine: 'listComments — list all comments on a page (including resolved).',
|
||||
},
|
||||
// NOTE: getPage and listPages moved to @docmost/mcp's SHARED_TOOL_SPECS
|
||||
// (#294); they carry their own tier ('core') + catalogLine there.
|
||||
// NOTE: createComment, listComments and resolveComment moved to
|
||||
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own tier +
|
||||
// catalogLine there. getComment stays inline (MCP-only shape divergence is
|
||||
// n/a — it simply has no shared spec).
|
||||
getComment: {
|
||||
tier: 'core',
|
||||
catalogLine: 'getComment — fetch a single comment by id.',
|
||||
},
|
||||
createComment: {
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||
},
|
||||
resolveComment: {
|
||||
tier: 'core',
|
||||
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||
},
|
||||
|
||||
// --- deferred inline ---
|
||||
createPage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||
},
|
||||
// NOTE: createPage, renamePage, movePage, deletePage, updatePageJson and
|
||||
// exportPageMarkdown moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); they
|
||||
// carry their own deferred tier + catalogLine there.
|
||||
updatePageContent: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
"updatePageContent — replace a page's body (and optionally title) with new Markdown.",
|
||||
},
|
||||
renamePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||
},
|
||||
movePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||
},
|
||||
deletePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||
},
|
||||
listSidebarPages: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
@@ -157,42 +129,21 @@ export const INLINE_TOOL_TIERS: Record<
|
||||
tier: 'deferred',
|
||||
catalogLine: 'getTable — read a table as a matrix of cell texts and cell ids.',
|
||||
},
|
||||
checkNewComments: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'checkNewComments — find comments in a space created after a timestamp.',
|
||||
},
|
||||
// NOTE: tableInsertRow, tableDeleteRow and tableUpdateCell moved to
|
||||
// @docmost/mcp's SHARED_TOOL_SPECS (#294); they carry their own deferred tier +
|
||||
// catalogLine there. getTable stays inline (its MCP name table_get breaks the
|
||||
// snake_case(inAppKey) convention, so it has no shared spec).
|
||||
// NOTE: checkNewComments moved to @docmost/mcp's SHARED_TOOL_SPECS (#294);
|
||||
// it carries its own deferred tier + catalogLine there.
|
||||
getPageHistory: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'getPageHistory — fetch one page-history version with its ProseMirror content.',
|
||||
},
|
||||
exportPageMarkdown: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||
},
|
||||
updatePageJson: {
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||
},
|
||||
tableInsertRow: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||
},
|
||||
tableDeleteRow: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||
},
|
||||
tableUpdateCell: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||
},
|
||||
sharePage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||
},
|
||||
// NOTE: sharePage moved to @docmost/mcp's SHARED_TOOL_SPECS (#294); it carries
|
||||
// its own deferred tier + catalogLine there. transformPage stays inline (its
|
||||
// schema deliberately diverges — it omits the deleteComments field the MCP
|
||||
// docmost_transform exposes, a comment-deletion guardrail).
|
||||
transformPage: {
|
||||
tier: 'deferred',
|
||||
catalogLine: "transformPage — run a sandboxed JS transform over a page's document.",
|
||||
|
||||
@@ -20,7 +20,6 @@ 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";
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { MultiCursor } from "./multi-cursor";
|
||||
export * from "./multi-cursor";
|
||||
export default MultiCursor;
|
||||
@@ -1,453 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,545 +0,0 @@
|
||||
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;
|
||||
@@ -1,69 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import { SearchAndReplace } from './search-and-replace'
|
||||
export * from './search-and-replace'
|
||||
export * from './find-occurrences'
|
||||
export default SearchAndReplace
|
||||
@@ -29,7 +29,6 @@ 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 {
|
||||
@@ -77,6 +76,11 @@ declare module "@tiptap/core" {
|
||||
}
|
||||
}
|
||||
|
||||
interface TextNodesWithPosition {
|
||||
text: string;
|
||||
pos: number;
|
||||
}
|
||||
|
||||
const getRegex = (
|
||||
s: string,
|
||||
disableRegex: boolean,
|
||||
@@ -100,6 +104,10 @@ function processSearches(
|
||||
resultIndex: number,
|
||||
): ProcessedSearches {
|
||||
const decorations: Decoration[] = [];
|
||||
const results: Range[] = [];
|
||||
|
||||
let textNodesWithPosition: TextNodesWithPosition[] = [];
|
||||
let index = 0;
|
||||
|
||||
if (!searchTerm) {
|
||||
return {
|
||||
@@ -108,8 +116,43 @@ function processSearches(
|
||||
};
|
||||
}
|
||||
|
||||
// Shared find-all-occurrences primitive (also used by multi-cursor).
|
||||
const results: Range[] = findOccurrences(doc, searchTerm);
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < results.length; i += 1) {
|
||||
const r = results[i];
|
||||
|
||||
+83
-355
@@ -118,56 +118,19 @@ export function createDocmostMcpServer(config: DocmostMcpConfig): McpServer {
|
||||
// transport exposes a `tree:true` mode that returns the full nested hierarchy;
|
||||
// the in-app copy keeps the same tree option but is worded for the in-app agent.
|
||||
// Kept per-layer so each side can tune its own guidance.
|
||||
server.registerTool(
|
||||
"list_pages",
|
||||
{
|
||||
description:
|
||||
"List most recent pages in a space ordered by updatedAt (descending). " +
|
||||
"Returns a bounded list (default 50, max 100) — use search for lookups " +
|
||||
"in large spaces. Pass tree:true (with spaceId) to instead get the " +
|
||||
"space's full page hierarchy as a nested tree.",
|
||||
inputSchema: {
|
||||
spaceId: z.string().optional(),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe("Max pages to return (default 50, max 100)"),
|
||||
tree: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, return the space's full page hierarchy as a nested tree (each node has a children array) instead of the recent-by-updatedAt flat list. Requires spaceId; ignores limit.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ spaceId, limit, tree }) => {
|
||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
// Schema + description now live in @docmost/mcp's SHARED_TOOL_SPECS (#294). This
|
||||
// transport keeps applying its own defaults (limit=50, tree=false) in execute.
|
||||
registerShared(SHARED_TOOL_SPECS.listPages, async ({ spaceId, limit, tree }) => {
|
||||
const result = await docmostClient.listPages(spaceId, limit ?? 50, tree ?? false);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: get_page
|
||||
server.registerTool(
|
||||
"get_page",
|
||||
{
|
||||
description:
|
||||
"Get page details with content converted to Markdown. The conversion is " +
|
||||
"LOSSY (block ids, exact table/callout structure are approximated); for a " +
|
||||
"lossless representation use get_page_json. Inline <span data-comment-id> " +
|
||||
"tags in the markdown are comment highlight anchors (also present for " +
|
||||
"RESOLVED threads) — treat them as markup, not page text.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
},
|
||||
async ({ pageId }) => {
|
||||
const page = await docmostClient.getPage(pageId);
|
||||
return jsonContent(page);
|
||||
},
|
||||
);
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(SHARED_TOOL_SPECS.getPage, async ({ pageId }) => {
|
||||
const page = await docmostClient.getPage(pageId);
|
||||
return jsonContent(page);
|
||||
});
|
||||
|
||||
// Tool: get_page_json
|
||||
registerShared(SHARED_TOOL_SPECS.getPageJson, async ({ pageId }) => {
|
||||
@@ -201,6 +164,10 @@ registerShared(
|
||||
);
|
||||
|
||||
// Tool: table_get
|
||||
// NOT in the shared registry: the MCP tool name `table_get` is noun-first while
|
||||
// the in-app key is `getTable` (verb-first), breaking the snake_case(inAppKey)
|
||||
// convention the shared registry enforces (shared-tool-specs.contract.spec.ts).
|
||||
// Renaming the public MCP tool would break external clients, so it stays inline.
|
||||
server.registerTool(
|
||||
"table_get",
|
||||
{
|
||||
@@ -223,25 +190,10 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: table_insert_row
|
||||
// NOT in the shared registry: this transport names the table argument `table`,
|
||||
// while the in-app tool names it `tableRef` (ai-chat-tools.service.ts). Sharing
|
||||
// one buildShape would rename a public MCP parameter, so the table row/cell
|
||||
// tools stay per-transport by design.
|
||||
server.registerTool(
|
||||
"table_insert_row",
|
||||
{
|
||||
description:
|
||||
"Insert a row of plain-text cells into a table. `table` = `#<index>` or " +
|
||||
"a block id inside it. `cells` = text per column (padded to the table's " +
|
||||
"column count; error if more cells than columns). `index` = 0-based " +
|
||||
"insert position (0 inserts before the header); omit to append at the end.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
table: z.string().min(1),
|
||||
cells: z.array(z.string()),
|
||||
index: z.number().int().optional(),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294); the `table`
|
||||
// parameter name is the canonical one (the in-app layer was unified to it).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.tableInsertRow,
|
||||
async ({ pageId, table, cells, index }) => {
|
||||
const result = await docmostClient.tableInsertRow(
|
||||
pageId,
|
||||
@@ -254,22 +206,9 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: table_delete_row
|
||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
||||
// divergence as table_insert_row.
|
||||
server.registerTool(
|
||||
"table_delete_row",
|
||||
{
|
||||
description:
|
||||
"Delete the row at 0-based `index` from a table (`table` = `#<index>` or " +
|
||||
"a block id inside it). Refuses to delete the table's only row. An " +
|
||||
"out-of-range `index` throws. Deleting `index` 0 removes the header row, " +
|
||||
"and the next row becomes the new header.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
table: z.string().min(1),
|
||||
index: z.number().int(),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.tableDeleteRow,
|
||||
async ({ pageId, table, index }) => {
|
||||
const result = await docmostClient.tableDeleteRow(pageId, table, index);
|
||||
return jsonContent(result);
|
||||
@@ -277,24 +216,9 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: table_update_cell
|
||||
// NOT shared — same `table` (here) vs `tableRef` (in-app) parameter-name
|
||||
// divergence as table_insert_row.
|
||||
server.registerTool(
|
||||
"table_update_cell",
|
||||
{
|
||||
description:
|
||||
"Set the plain-text content of cell [row,col] (0-based) in a table " +
|
||||
"(`table` = `#<index>` or a block id inside it). Replaces the cell's " +
|
||||
"content with a single text paragraph; for rich formatting use patch_node " +
|
||||
"on the cell's paragraph id from table_get.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
table: z.string().min(1),
|
||||
row: z.number().int(),
|
||||
col: z.number().int(),
|
||||
text: z.string(),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.tableUpdateCell,
|
||||
async ({ pageId, table, row, col, text }) => {
|
||||
const result = await docmostClient.tableUpdateCell(
|
||||
pageId,
|
||||
@@ -308,22 +232,9 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: create_page
|
||||
server.registerTool(
|
||||
"create_page",
|
||||
{
|
||||
description:
|
||||
"Create a new page from Markdown in a space. Pass parentPageId to nest " +
|
||||
"it under a parent; omit it to create at the space root.",
|
||||
inputSchema: {
|
||||
title: z.string().min(1).describe("Title of the page"),
|
||||
content: z.string().min(1).describe("Markdown content"),
|
||||
spaceId: z.string().min(1),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional parent page ID to nest under"),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.createPage,
|
||||
async ({ title, content, spaceId, parentPageId }) => {
|
||||
const result = await docmostClient.createPage(
|
||||
title,
|
||||
@@ -336,32 +247,11 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: update_page_json
|
||||
server.registerTool(
|
||||
"update_page_json",
|
||||
{
|
||||
description:
|
||||
"Replace a page's content with a raw ProseMirror JSON document " +
|
||||
"(lossless write: preserves the block ids, callouts, tables and " +
|
||||
"attributes you pass in). Typical flow: get_page_json -> modify the " +
|
||||
"JSON -> update_page_json. Keep existing node ids intact so heading " +
|
||||
"anchors and history stay stable. Minimal full-doc example: " +
|
||||
'{"type":"doc","content":[{"type":"paragraph","content":' +
|
||||
'[{"type":"text","text":"Hi"}]}]}. `content` may be a JSON object or a ' +
|
||||
"JSON string (both accepted), and is OPTIONAL: omit it to update only " +
|
||||
"the title (though prefer rename_page for a title-only change). " +
|
||||
"Supplying neither content nor title is an error.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1).describe("ID of the page to update"),
|
||||
content: z
|
||||
.any()
|
||||
.optional()
|
||||
.describe(
|
||||
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
||||
"JSON string). Omit to rename only.",
|
||||
),
|
||||
title: z.string().optional().describe("Optional new title"),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's content normalization (parse a JSON-string content,
|
||||
// pass undefined/null through for a title-only/no-op update).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.updatePageJson,
|
||||
async ({ pageId, content, title }) => {
|
||||
// Only parse/validate the document when it was actually supplied; when it
|
||||
// is omitted, pass it straight through so the client performs a title-only
|
||||
@@ -379,26 +269,11 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: export_page_markdown
|
||||
server.registerTool(
|
||||
"export_page_markdown",
|
||||
{
|
||||
description:
|
||||
"Export a page to a single self-contained, lossless Docmost-flavoured " +
|
||||
"Markdown file (custom extensions): YAML-free meta header, body with " +
|
||||
"inline comment anchors and diagrams, and a trailing comments-thread " +
|
||||
"block. Designed for a download -> edit body -> import_page_markdown " +
|
||||
"round-trip that preserves everything, including comment highlights. " +
|
||||
"Comment THREADS are preserved in the file but are not re-pushed to the " +
|
||||
"server on import.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
},
|
||||
async ({ pageId }) => {
|
||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
||||
return { content: [{ type: "text" as const, text: md }] };
|
||||
},
|
||||
);
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(SHARED_TOOL_SPECS.exportPageMarkdown, async ({ pageId }) => {
|
||||
const md = await docmostClient.exportPageMarkdown(pageId);
|
||||
return { content: [{ type: "text" as const, text: md }] };
|
||||
});
|
||||
|
||||
// Tool: import_page_markdown
|
||||
registerShared(
|
||||
@@ -422,22 +297,11 @@ registerShared(
|
||||
);
|
||||
|
||||
// Tool: rename_page
|
||||
server.registerTool(
|
||||
"rename_page",
|
||||
{
|
||||
description:
|
||||
"Rename a page (change its title only) without touching or resending " +
|
||||
"its content.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1).describe("ID of the page to rename"),
|
||||
title: z.string().min(1).describe("New title"),
|
||||
},
|
||||
},
|
||||
async ({ pageId, title }) => {
|
||||
const result = await docmostClient.renamePage(pageId, title);
|
||||
return jsonContent(result);
|
||||
},
|
||||
);
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(SHARED_TOOL_SPECS.renamePage, async ({ pageId, title }) => {
|
||||
const result = await docmostClient.renamePage(pageId, title);
|
||||
return jsonContent(result);
|
||||
});
|
||||
|
||||
// Tool: edit_page_text
|
||||
registerShared(SHARED_TOOL_SPECS.editPageText, async ({ pageId, edits }) => {
|
||||
@@ -516,6 +380,10 @@ registerShared(SHARED_TOOL_SPECS.deleteNode, async ({ pageId, nodeId }) => {
|
||||
});
|
||||
|
||||
// Tool: insert_image
|
||||
// MCP-only by design (NOT in the shared registry): the in-app AI-chat agent
|
||||
// exposes no image tools (insert/replace), so there is no second layer to unify
|
||||
// — a SHARED_TOOL_SPECS entry's tier/catalogLine are in-app metadata and the
|
||||
// catalog-partition test forbids a spec without a live in-app tool (#294).
|
||||
server.registerTool(
|
||||
"insert_image",
|
||||
{
|
||||
@@ -561,6 +429,7 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: replace_image
|
||||
// MCP-only by design (see insert_image): no in-app equivalent, stays inline.
|
||||
server.registerTool(
|
||||
"replace_image",
|
||||
{
|
||||
@@ -603,25 +472,10 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: share_page
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy adds a
|
||||
// security-confirmation framing ("only share when the user explicitly asked,
|
||||
// since this exposes the page to anyone with the link") tuned for the in-app
|
||||
// agent; this transport keeps the plain public-URL wording.
|
||||
server.registerTool(
|
||||
"share_page",
|
||||
{
|
||||
description:
|
||||
"Make a page publicly accessible (idempotent) and return its public " +
|
||||
"URL. The URL format is <app>/share/<key>/p/<slugId>. This exposes the " +
|
||||
"page content to ANYONE with the URL — do it only when explicitly asked.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1).describe("ID of the page to share"),
|
||||
searchIndexing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Allow search engines to index the page (default true)"),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own `searchIndexing ?? true` default.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.sharePage,
|
||||
async ({ pageId, searchIndexing }) => {
|
||||
const result = await docmostClient.sharePage(pageId, searchIndexing ?? true);
|
||||
return jsonContent(result);
|
||||
@@ -641,29 +495,11 @@ registerShared(SHARED_TOOL_SPECS.listShares, async () => {
|
||||
});
|
||||
|
||||
// Tool: move_page
|
||||
server.registerTool(
|
||||
"move_page",
|
||||
{
|
||||
description:
|
||||
"Move a page under a new parent (nesting) or to the space root.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Target parent page ID. Pass 'null' or empty string to move to root.",
|
||||
),
|
||||
position: z
|
||||
.string()
|
||||
.min(5)
|
||||
.optional()
|
||||
.describe(
|
||||
"fractional-index position key; min 5 chars; omit to append at the end.",
|
||||
),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's cycle guard, its 'null'/'' -> null string coercion, and
|
||||
// its positive-confirmation check on the move response.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.movePage,
|
||||
async ({ pageId, parentPageId, position }) => {
|
||||
const finalParentId =
|
||||
parentPageId === "" || parentPageId === "null" ? null : parentPageId;
|
||||
@@ -698,49 +534,22 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: delete_page
|
||||
server.registerTool(
|
||||
"delete_page",
|
||||
{
|
||||
description:
|
||||
"Delete a single page by ID. SOFT delete only: the page is moved to " +
|
||||
"trash and can be restored; nothing is permanently deleted.",
|
||||
inputSchema: {
|
||||
pageId: z.string().min(1),
|
||||
},
|
||||
},
|
||||
async ({ pageId }) => {
|
||||
await docmostClient.deletePage(pageId);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
// Schema + description now live in the shared registry (#294). The shared schema
|
||||
// exposes ONLY pageId, so no permanent/force-delete flag can reach the client.
|
||||
registerShared(SHARED_TOOL_SPECS.deletePage, async ({ pageId }) => {
|
||||
await docmostClient.deletePage(pageId);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Successfully deleted page ${pageId}` },
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
// --- Comment tools (ported from upstream PR #3 by Max Nikitin) ---
|
||||
|
||||
// Tool: list_comments
|
||||
server.registerTool(
|
||||
"list_comments",
|
||||
{
|
||||
description:
|
||||
"List comments on a page in one call (pagination is handled " +
|
||||
"internally). By DEFAULT only ACTIVE threads are returned; resolved " +
|
||||
"threads (a resolved top-level comment and all its replies) are hidden " +
|
||||
"and their count reported as `resolvedThreadsHidden` so you can re-query " +
|
||||
"with `includeResolved: true` to see everything. Returns " +
|
||||
"`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page"),
|
||||
includeResolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"default only active threads; true — include resolved",
|
||||
),
|
||||
},
|
||||
},
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.listComments,
|
||||
async ({ pageId, includeResolved }) => {
|
||||
const comments = await docmostClient.listComments(pageId, includeResolved);
|
||||
return jsonContent(comments);
|
||||
@@ -748,55 +557,11 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: create_comment
|
||||
// INTENTIONAL per-transport divergence (not shared): the in-app copy tunes the
|
||||
// guidance for the in-app agent (e.g. "retry with a corrected EXACT selection"
|
||||
// and "Reversible via the comment UI"); this transport keeps its own wording.
|
||||
server.registerTool(
|
||||
"create_comment",
|
||||
{
|
||||
description:
|
||||
"Create a new comment on a page. The comment is ALWAYS inline and is " +
|
||||
"anchored to (highlights) its `selection` text — there are no page-level " +
|
||||
"comments. Content is provided as Markdown and automatically converted. " +
|
||||
"A top-level comment REQUIRES an exact `selection`; if the selection " +
|
||||
"cannot be found in the page the call fails (no orphan comment is left). " +
|
||||
"Replies (parentCommentId set) inherit the parent's anchor and take no " +
|
||||
"selection. You may also attach a `suggestedText` proposing a replacement " +
|
||||
"for the `selection`; a human applies (or rejects) it from the UI. When " +
|
||||
"`suggestedText` is set the `selection` MUST occur exactly once in the " +
|
||||
"page — expand it with surrounding context if it is ambiguous.",
|
||||
inputSchema: {
|
||||
pageId: z.string().describe("ID of the page to comment on"),
|
||||
content: z.string().min(1).describe("Comment content in Markdown format"),
|
||||
selection: z
|
||||
.string()
|
||||
.min(1)
|
||||
// Enforce the documented 250-char cap to match the description above.
|
||||
.max(250)
|
||||
.optional()
|
||||
.describe(
|
||||
"EXACT contiguous text from a single paragraph/block to anchor the " +
|
||||
"comment on (<=250 chars). Required for a top-level comment; omit " +
|
||||
"only when replying via parentCommentId.",
|
||||
),
|
||||
parentCommentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Parent comment ID to create a reply (max 2 nesting levels)"),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional proposed replacement (PLAIN TEXT) for the `selection`, " +
|
||||
"applied by a human via the UI (never auto-applied). REQUIRES a " +
|
||||
"`selection`; NOT allowed on a reply. When set, the `selection` must " +
|
||||
"be UNIQUE in the page — expand it with surrounding context (still " +
|
||||
"<=250 chars) if it occurs more than once, or the call is refused.",
|
||||
),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own guards (require a selection for a top-level
|
||||
// comment; reject suggestedText on a reply / without a selection).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.createComment,
|
||||
async ({ pageId, content, selection, parentCommentId, suggestedText }) => {
|
||||
if (!parentCommentId && (!selection || !selection.trim())) {
|
||||
throw new Error(
|
||||
@@ -872,28 +637,9 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: resolve_comment
|
||||
server.registerTool(
|
||||
"resolve_comment",
|
||||
{
|
||||
description:
|
||||
"Resolve (close) or reopen a comment thread. Only top-level comments can " +
|
||||
"be resolved — the server rejects resolving a reply. Reversible: pass " +
|
||||
"resolved=false to reopen. Resolving keeps the thread and its replies " +
|
||||
"(unlike delete_comment, which permanently removes them).",
|
||||
inputSchema: {
|
||||
commentId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("ID of the top-level comment thread to resolve or reopen"),
|
||||
resolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(
|
||||
"true (default) marks the thread resolved/closed; false reopens it",
|
||||
),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294).
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.resolveComment,
|
||||
async ({ commentId, resolved }) => {
|
||||
const result = await docmostClient.resolveComment(commentId, resolved);
|
||||
return jsonContent(result);
|
||||
@@ -901,30 +647,10 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: check_new_comments
|
||||
server.registerTool(
|
||||
"check_new_comments",
|
||||
{
|
||||
description:
|
||||
"Check for new comments across pages in a space since a given timestamp. " +
|
||||
"Optionally scope to a page subtree (folder). Returns only comments " +
|
||||
"created after the specified time.",
|
||||
inputSchema: {
|
||||
spaceId: z.string().describe("Space ID to check for new comments"),
|
||||
since: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"ISO 8601 timestamp — only return comments created after this time (e.g. '2026-03-10T00:00:00Z')",
|
||||
),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Optional root page ID to scope the check to a subtree (folder). " +
|
||||
"Only pages under this parent will be checked.",
|
||||
),
|
||||
},
|
||||
},
|
||||
// Schema + description now live in the shared registry (#294). The execute body
|
||||
// keeps this transport's own guard rejecting an unparseable `since` timestamp.
|
||||
registerShared(
|
||||
SHARED_TOOL_SPECS.checkNewComments,
|
||||
async ({ spaceId, since, parentPageId }) => {
|
||||
// Reject an unparseable timestamp up front: otherwise the comparison
|
||||
// against NaN silently treats every comment as "not new" and the tool
|
||||
@@ -1053,6 +779,8 @@ server.registerTool(
|
||||
);
|
||||
|
||||
// Tool: insert_footnote
|
||||
// MCP-only by design (see insert_image): the in-app AI-chat agent exposes no
|
||||
// footnote tool, so there is no second layer to unify — stays inline (#294).
|
||||
server.registerTool(
|
||||
"insert_footnote",
|
||||
{
|
||||
|
||||
@@ -316,6 +316,34 @@ export const SHARED_TOOL_SPECS = {
|
||||
|
||||
// --- share management ---
|
||||
|
||||
// Unified from the per-layer inline definitions (#294). Both layers already
|
||||
// carried the "only share when explicitly asked" security framing (the
|
||||
// "per-transport divergence" note on the old inline copies was stale), so
|
||||
// there was no real behavioral divergence to preserve — only wording drift.
|
||||
sharePage: {
|
||||
mcpName: 'share_page',
|
||||
inAppKey: 'sharePage',
|
||||
// CANONICAL: merges the MCP copy's URL-format + idempotency detail with the
|
||||
// in-app copy's reversibility note; keeps the security framing both had.
|
||||
description:
|
||||
'Make a page PUBLICLY accessible (idempotent) and return its public URL ' +
|
||||
'(format: <app>/share/<key>/p/<slugId>). This exposes the page content ' +
|
||||
'to ANYONE with the URL — only share when the user explicitly asked. ' +
|
||||
'Reversible: unshare it later to revoke the public URL.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'sharePage — make a page publicly accessible and return its URL.',
|
||||
// Reconciled: MCP's stricter .min(1) on pageId kept; field descriptions from
|
||||
// the in-app copy. The MCP execute keeps its own `searchIndexing ?? true`
|
||||
// default (a per-layer concern, not part of the shared schema).
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to share.'),
|
||||
searchIndexing: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Allow public search engines to index it (default true).'),
|
||||
}),
|
||||
},
|
||||
|
||||
unsharePage: {
|
||||
mcpName: 'unshare_page',
|
||||
inAppKey: 'unsharePage',
|
||||
@@ -509,4 +537,470 @@ export const SHARED_TOOL_SPECS = {
|
||||
pageId: z.string().min(1),
|
||||
}),
|
||||
},
|
||||
|
||||
// --- page tools (unified from the per-layer inline definitions, #294) ---
|
||||
//
|
||||
// Descriptions merge both layers (the MCP copy's richer structural notes + the
|
||||
// in-app copy's "Reversible via history/trash" framing where it added one).
|
||||
// Field constraints keep the MCP copy's stricter .min(1) EXCEPT where the
|
||||
// in-app layer deliberately allowed a looser value (documented per field).
|
||||
|
||||
getPage: {
|
||||
mcpName: 'get_page',
|
||||
inAppKey: 'getPage',
|
||||
description:
|
||||
'Fetch a single page as Markdown by its id. Returns the page title and ' +
|
||||
'its Markdown content. The Markdown conversion is LOSSY (block ids, exact ' +
|
||||
'table/callout structure are approximated); for a lossless representation ' +
|
||||
'use the lossless page-JSON read tool. Inline <span data-comment-id> tags in the markdown ' +
|
||||
'are comment highlight anchors (also present for RESOLVED threads) — ' +
|
||||
'treat them as markup, not page text.',
|
||||
tier: 'core',
|
||||
catalogLine: 'getPage — fetch a page as Markdown by its id.',
|
||||
// Reconciled: MCP's stricter .min(1) kept; in-app's more-informative
|
||||
// "(or slugId)" describe kept.
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id (or slugId) of the page.'),
|
||||
}),
|
||||
},
|
||||
|
||||
listPages: {
|
||||
mcpName: 'list_pages',
|
||||
inAppKey: 'listPages',
|
||||
description:
|
||||
'List the most recent pages (ordered by updatedAt, descending), ' +
|
||||
'optionally scoped to a single space. Returns a bounded list (default ' +
|
||||
'50, max 100) — use search for lookups in large spaces. Pass tree:true ' +
|
||||
"(with spaceId) to instead get the space's full page hierarchy as a " +
|
||||
'nested tree.',
|
||||
tier: 'core',
|
||||
catalogLine: "listPages — list recent pages, or a space's full page tree.",
|
||||
buildShape: (z) => ({
|
||||
spaceId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional space id to scope the listing to.'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.optional()
|
||||
.describe('Maximum number of pages (default 50, max 100).'),
|
||||
tree: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, return the space's full page hierarchy as a nested tree " +
|
||||
'(children arrays) instead of the recent-by-updatedAt flat list. ' +
|
||||
'Requires spaceId; ignores limit.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
createPage: {
|
||||
mcpName: 'create_page',
|
||||
inAppKey: 'createPage',
|
||||
description:
|
||||
'Create a new page with a Markdown body in a space, optionally under a ' +
|
||||
'parent page (omit parentPageId to create at the space root). Returns ' +
|
||||
'the new page id and title. Reversible: a page can be moved to trash ' +
|
||||
'later.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'createPage — create a new page with a Markdown body in a space.',
|
||||
// Reconciled schema DRIFT: the MCP copy pinned `content` to .min(1) while
|
||||
// the in-app copy left it unbounded and DOCUMENTS an empty body as valid
|
||||
// ("may be empty") — creating an empty page to fill in later is a real use
|
||||
// case. The looser (no-min) form is kept, so create_page now also accepts an
|
||||
// empty body (harmless — it creates an empty page) and no previously-valid
|
||||
// in-app input is ever rejected. `title`/`spaceId` keep the MCP .min(1)
|
||||
// (an empty title or space is never valid).
|
||||
buildShape: (z) => ({
|
||||
title: z.string().min(1).describe('The title of the new page.'),
|
||||
content: z.string().describe('The page body as Markdown (may be empty).'),
|
||||
spaceId: z.string().min(1).describe('The id of the space to create the page in.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Optional parent page id to nest the new page under.'),
|
||||
}),
|
||||
},
|
||||
|
||||
movePage: {
|
||||
mcpName: 'move_page',
|
||||
inAppKey: 'movePage',
|
||||
description:
|
||||
'Move a page under a new parent page, or to the space root when no ' +
|
||||
'parent is given. Reversible: move it back at any time.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'movePage — move a page under a new parent or to the space root.',
|
||||
// Reconciled schema DRIFT: the MCP copy exposed a `position` field
|
||||
// (fractional-index ordering) that the in-app copy lacked. Unified by
|
||||
// KEEPING position (the in-app client already accepts an optional position
|
||||
// arg, so the in-app execute now forwards it) — it is optional, so no
|
||||
// previously-valid in-app call is rejected. `parentPageId` is `.nullable()`
|
||||
// on both, so a real JSON null moves to root on either transport; the MCP
|
||||
// execute additionally coerces the strings 'null'/'' to null as a robustness
|
||||
// fallback (kept in its execute body, not in the shared schema).
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to move.'),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Target parent page id. Null or omitted moves the page to the space ' +
|
||||
'root.',
|
||||
),
|
||||
position: z
|
||||
.string()
|
||||
.min(5)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional fractional-index position key (min 5 chars); omit to ' +
|
||||
'append at the end.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
renamePage: {
|
||||
mcpName: 'rename_page',
|
||||
inAppKey: 'renamePage',
|
||||
description:
|
||||
'Rename a page (change its title only; the body is untouched, never ' +
|
||||
'resent). Reversible: rename back at any time.',
|
||||
tier: 'deferred',
|
||||
catalogLine: "renamePage — change a page's title only (body untouched).",
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to rename.'),
|
||||
title: z.string().min(1).describe('The new title.'),
|
||||
}),
|
||||
},
|
||||
|
||||
deletePage: {
|
||||
mcpName: 'delete_page',
|
||||
inAppKey: 'deletePage',
|
||||
description:
|
||||
'Move a page to the trash — SOFT delete only: the page can be restored ' +
|
||||
'from trash and nothing is ever permanently deleted.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'deletePage — move a page to trash (soft delete, reversible).',
|
||||
// GUARDRAIL preserved (§14 H4): the schema exposes ONLY pageId, so a
|
||||
// permanentlyDelete/forceDelete flag can never reach the client through this
|
||||
// tool (asserted by ai-chat-tools.service.spec.ts).
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to move to trash.'),
|
||||
}),
|
||||
},
|
||||
|
||||
updatePageJson: {
|
||||
mcpName: 'update_page_json',
|
||||
inAppKey: 'updatePageJson',
|
||||
description:
|
||||
"Replace a page's content with a raw ProseMirror JSON document (lossless " +
|
||||
'write: preserves the block ids, callouts, tables and attributes you pass ' +
|
||||
'in). Typical flow: read the page-JSON view -> modify the JSON -> write it back. ' +
|
||||
'Keep existing node ids intact so heading anchors and history stay ' +
|
||||
'stable. Minimal full-doc example: {"type":"doc","content":[{"type":' +
|
||||
'"paragraph","content":[{"type":"text","text":"Hi"}]}]}. `content` may be ' +
|
||||
'a JSON object or a JSON string (both accepted), and is OPTIONAL: omit it ' +
|
||||
'to update only the title (though prefer the rename-page tool for a title-only ' +
|
||||
'change). Supplying neither content nor title is an error. Reversible: ' +
|
||||
'the previous version is kept in page history.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
"updatePageJson — overwrite a page's body with a full ProseMirror document.",
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('ID of the page to update'),
|
||||
content: z
|
||||
.any()
|
||||
.optional()
|
||||
.describe(
|
||||
'ProseMirror document {"type":"doc","content":[...]} (JSON object or ' +
|
||||
'JSON string). Omit to update only the title.',
|
||||
),
|
||||
title: z.string().optional().describe('Optional new title'),
|
||||
}),
|
||||
},
|
||||
|
||||
exportPageMarkdown: {
|
||||
mcpName: 'export_page_markdown',
|
||||
inAppKey: 'exportPageMarkdown',
|
||||
// CANONICAL: the MCP copy (a strict superset of the terse in-app wording).
|
||||
description:
|
||||
'Export a page to a single self-contained, lossless Docmost-flavoured ' +
|
||||
'Markdown file (custom extensions): YAML-free meta header, body with ' +
|
||||
'inline comment anchors and diagrams, and a trailing comments-thread ' +
|
||||
'block. Designed for a download -> edit body -> page-Markdown import ' +
|
||||
'round-trip that preserves everything, including comment highlights. ' +
|
||||
'Comment THREADS are preserved in the file but are not re-pushed to the ' +
|
||||
'server on import.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'exportPageMarkdown — export a page to self-contained Markdown (body + comments).',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page to export.'),
|
||||
}),
|
||||
},
|
||||
|
||||
// --- comment tools (unified from the per-layer inline definitions, #294) ---
|
||||
//
|
||||
// create_comment and resolve_comment previously carried a "per-transport
|
||||
// divergence" note in BOTH layers; #294 unifies their schema + description
|
||||
// here. Only the four tools that genuinely exist in BOTH layers live in the
|
||||
// registry: create/list/resolve comment and check_new_comments.
|
||||
//
|
||||
// update_comment and delete_comment are intentionally NOT here: they exist
|
||||
// ONLY on the standalone MCP server. The in-app agent deliberately exposes no
|
||||
// hard comment edit/delete tool (comment edits are irreversible / not
|
||||
// version-tracked; see the guardrail tests in ai-chat-tools.service.spec.ts),
|
||||
// so there is nothing to unify — they stay inline in index.ts.
|
||||
|
||||
createComment: {
|
||||
mcpName: 'create_comment',
|
||||
inAppKey: 'createComment',
|
||||
// CANONICAL: the in-app copy (the more-maintained one). It keeps the same
|
||||
// rules as the MCP copy — inline-only, top-level requires a `selection`, no
|
||||
// page-level comments, replies inherit the anchor, suggestedText must be
|
||||
// unique — and adds the "retry with a corrected EXACT selection" and reply-
|
||||
// to-reply-rejected guidance the MCP copy lacked. Execute-side validation
|
||||
// (reject suggestedText on a reply, require a selection) stays per-layer.
|
||||
description:
|
||||
'Add an INLINE comment to a page, or reply to an existing top-level ' +
|
||||
'comment (one level only — the backend rejects replies to replies). ' +
|
||||
'The comment is anchored inline to the given exact `selection` text ' +
|
||||
'(which gets highlighted); page-level comments are NOT supported. A ' +
|
||||
'new top-level comment REQUIRES a `selection`. Replies inherit the ' +
|
||||
"parent's anchor and take no selection. If the call fails with a " +
|
||||
'"selection not found" error, retry with a corrected EXACT selection ' +
|
||||
'copied verbatim from a single paragraph/block. You may also attach a ' +
|
||||
'`suggestedText` proposing a replacement for the `selection` (a human ' +
|
||||
'applies it from the UI); when set, the `selection` must occur exactly ' +
|
||||
'once in the page. Reversible via the comment UI.',
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
'createComment — add an inline comment (optionally with a suggested edit).',
|
||||
// Reconciled schema: the field set is identical across both layers; the
|
||||
// only constraint drift is `content`, which the MCP copy pinned to
|
||||
// .min(1) while the in-app copy left unbounded — the stricter MCP form is
|
||||
// kept (an empty comment body is never valid).
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().describe('The id of the page to comment on.'),
|
||||
content: z.string().min(1).describe('The comment body as Markdown.'),
|
||||
selection: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(250)
|
||||
.optional()
|
||||
.describe(
|
||||
'EXACT contiguous text from a SINGLE paragraph/block to anchor ' +
|
||||
'(highlight) the comment on (<=250 chars, avoid spanning across ' +
|
||||
'formatting boundaries). Required for a new top-level comment; ' +
|
||||
'omit only when replying via parentCommentId.',
|
||||
),
|
||||
parentCommentId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional id of a TOP-LEVEL comment to reply to (one level ' +
|
||||
'of replies only).',
|
||||
),
|
||||
suggestedText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2000)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional proposed replacement (PLAIN TEXT) for the `selection`, ' +
|
||||
'applied by a human via the UI (never auto-applied). REQUIRES a ' +
|
||||
'`selection`; NOT allowed on a reply. When set, the `selection` ' +
|
||||
'must be UNIQUE in the page — expand it with surrounding context ' +
|
||||
'(still <=250 chars) if it occurs more than once, or the call is ' +
|
||||
'refused.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
listComments: {
|
||||
mcpName: 'list_comments',
|
||||
inAppKey: 'listComments',
|
||||
// CANONICAL: the two copies are near-identical; the MCP copy is the
|
||||
// superset (it keeps the "(pagination is handled internally)" note the
|
||||
// in-app copy dropped), so it is used verbatim.
|
||||
description:
|
||||
'List comments on a page in one call (pagination is handled ' +
|
||||
'internally). By DEFAULT only ACTIVE threads are returned; resolved ' +
|
||||
'threads (a resolved top-level comment and all its replies) are hidden ' +
|
||||
'and their count reported as `resolvedThreadsHidden` so you can re-query ' +
|
||||
'with `includeResolved: true` to see everything. Returns ' +
|
||||
'`{ items, resolvedThreadsHidden }`. Content is returned as Markdown.',
|
||||
tier: 'core',
|
||||
catalogLine:
|
||||
'listComments — list all comments on a page (including resolved).',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().describe('ID of the page'),
|
||||
includeResolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('default only active threads; true — include resolved'),
|
||||
}),
|
||||
},
|
||||
|
||||
resolveComment: {
|
||||
mcpName: 'resolve_comment',
|
||||
inAppKey: 'resolveComment',
|
||||
// CANONICAL: the MCP copy's richer wording, minus its snake_case reference
|
||||
// to `delete_comment` (a sibling tool that does NOT exist in the in-app
|
||||
// layer) — rephrased transport-neutrally per the registry convention.
|
||||
description:
|
||||
'Resolve (close) or reopen a top-level comment thread (reversible — ' +
|
||||
'pass resolved=false to reopen). Only top-level comments can be ' +
|
||||
'resolved; the server rejects resolving a reply. Resolving keeps the ' +
|
||||
'thread and its replies intact (it is not a deletion).',
|
||||
tier: 'core',
|
||||
catalogLine: 'resolveComment — resolve or reopen a comment thread.',
|
||||
// Reconciled schema: `resolved` drifted — the MCP copy made it optional
|
||||
// with .default(true) (resolve is the common case, documented), the in-app
|
||||
// copy made it required. The MCP form is kept (a strict superset: it never
|
||||
// rejects a previously-valid input and adds a sensible default), and
|
||||
// commentId keeps the MCP copy's stricter .min(1).
|
||||
buildShape: (z) => ({
|
||||
commentId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('ID of the top-level comment thread to resolve or reopen'),
|
||||
resolved: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.default(true)
|
||||
.describe(
|
||||
'true (default) marks the thread resolved/closed; false reopens it',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
checkNewComments: {
|
||||
mcpName: 'check_new_comments',
|
||||
inAppKey: 'checkNewComments',
|
||||
// CANONICAL: the MCP copy (the more detailed of the two). The MCP layer's
|
||||
// execute-side guard that rejects an unparseable `since` timestamp stays in
|
||||
// its execute body (per-layer logic), not in the shared schema.
|
||||
description:
|
||||
'Check for new comments across pages in a space since a given ' +
|
||||
'timestamp. Optionally scope to a page subtree (folder). Returns only ' +
|
||||
'comments created after the specified time.',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'checkNewComments — find comments in a space created after a timestamp.',
|
||||
// Reconciled schema: `since` keeps the MCP copy's stricter .min(1) (the
|
||||
// in-app copy left it unbounded); field descriptions use the MCP copy's
|
||||
// more detailed wording (it carries an example timestamp).
|
||||
buildShape: (z) => ({
|
||||
spaceId: z.string().describe('Space ID to check for new comments'),
|
||||
since: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe(
|
||||
"ISO 8601 timestamp — only return comments created after this time " +
|
||||
"(e.g. '2026-03-10T00:00:00Z')",
|
||||
),
|
||||
parentPageId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional root page ID to scope the check to a subtree (folder). ' +
|
||||
'Only pages under this parent will be checked.',
|
||||
),
|
||||
}),
|
||||
},
|
||||
|
||||
// --- table tools (unified from the per-layer inline definitions, #294) ---
|
||||
//
|
||||
// These tools carried a "NOT shared" note in BOTH layers because of a single
|
||||
// parameter-NAME drift: the MCP layer named the table reference `table` while
|
||||
// the in-app layer named it `tableRef`. #294 reconciles that drift by unifying
|
||||
// on the MCP name `table` — renaming the MCP public parameter would break
|
||||
// external MCP clients, whereas the in-app parameter is model-facing
|
||||
// (prompt-only) and safe to rename. The in-app execute bodies now destructure
|
||||
// `table` instead of `tableRef` (nothing else changes). Descriptions take the
|
||||
// MCP copy's richer wording (it documented `#<index>`, padding, header-row
|
||||
// behavior) plus the in-app copy's "Reversible via page history" note; sibling
|
||||
// tool references are phrased transport-neutrally.
|
||||
//
|
||||
// NOT here (kept inline in index.ts): table_get / getTable. Its MCP tool name
|
||||
// is noun-first (`table_get`) while the in-app key is verb-first (`getTable`),
|
||||
// so it breaks the snake_case(inAppKey) naming convention the registry enforces
|
||||
// (shared-tool-specs.contract.spec.ts). Renaming the public MCP tool would
|
||||
// break external clients, so it stays per-transport (its in-app param was still
|
||||
// aligned to `table` for consistency with the migrated trio below).
|
||||
|
||||
tableInsertRow: {
|
||||
mcpName: 'table_insert_row',
|
||||
inAppKey: 'tableInsertRow',
|
||||
description:
|
||||
'Insert a row of plain-text cells into a table. `table` is `#<index>` ' +
|
||||
'from the page outline, or a block id inside it. `cells` is the text per ' +
|
||||
"column (padded to the table's column count; an error if more cells than " +
|
||||
'columns). `index` is the 0-based insert position (0 inserts before the ' +
|
||||
'header); omit to append at the end. Reversible via page history.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableInsertRow — insert a row of plain-text cells into a table.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page.'),
|
||||
table: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||
cells: z.array(z.string()).describe('The cell texts for the row (one per column).'),
|
||||
index: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe('0-based insert position (0 inserts before the header); omit to append.'),
|
||||
}),
|
||||
},
|
||||
|
||||
tableDeleteRow: {
|
||||
mcpName: 'table_delete_row',
|
||||
inAppKey: 'tableDeleteRow',
|
||||
description:
|
||||
'Delete the row at 0-based `index` from a table (`table` is `#<index>` ' +
|
||||
'from the page outline, or a block id inside it). Refuses to delete the ' +
|
||||
"table's only row; an out-of-range `index` throws. Deleting `index` 0 " +
|
||||
'removes the header row, and the next row becomes the new header. ' +
|
||||
'Reversible via page history.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableDeleteRow — delete a table row at a 0-based index.',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page.'),
|
||||
table: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||
index: z.number().int().describe('0-based row index to delete.'),
|
||||
}),
|
||||
},
|
||||
|
||||
tableUpdateCell: {
|
||||
mcpName: 'table_update_cell',
|
||||
inAppKey: 'tableUpdateCell',
|
||||
description:
|
||||
'Set the plain-text content of cell [row, col] (0-based) in a table ' +
|
||||
'(`table` is `#<index>` from the page outline, or a block id inside it). ' +
|
||||
"Replaces the cell's content with a single text paragraph; for rich " +
|
||||
"formatting, patch the cell's paragraph id (obtained from reading the " +
|
||||
'table) instead. Reversible via page history.',
|
||||
tier: 'deferred',
|
||||
catalogLine: 'tableUpdateCell — set the text of a table cell at [row, col].',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1).describe('The id of the page.'),
|
||||
table: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('"#<index>" from the page outline, or a block id in the table.'),
|
||||
row: z.number().int().describe('0-based row index.'),
|
||||
col: z.number().int().describe('0-based column index.'),
|
||||
text: z.string().describe('The new cell text.'),
|
||||
}),
|
||||
},
|
||||
} satisfies Record<string, SharedToolSpec>;
|
||||
|
||||
Reference in New Issue
Block a user