@frebreco/canvas 0.3.0-next.8 → 0.3.0-next.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frebreco/canvas",
3
- "version": "0.3.0-next.8",
3
+ "version": "0.3.0-next.9",
4
4
  "description": "A multiplayer canvas for coding agents that run on your machine.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,10 @@
13
13
  * agent hears why a file can't be shown. It listens on loopback only: agents
14
14
  * run on this machine.
15
15
  *
16
+ * An agent's comment (ADR 0006) gets the text of its lines here, from the
17
+ * shared set: the browser keeps it to find the lines again as the file
18
+ * changes.
19
+ *
16
20
  * Scratch files (ADR 0005) never reach the browser as content: a call's
17
21
  * `content` becomes a file in `.canvas/scratch/` here, and the call goes on
18
22
  * with its path. Reading and writing them needs no board, so those tools are
@@ -22,6 +26,7 @@
22
26
  import { randomBytes } from "node:crypto";
23
27
  import type { McpServer } from "@agentclientprotocol/sdk";
24
28
  import { BOARD_SERVER_NAME, BOARD_TOOLS, boardInstructions } from "../shared/board-tools";
29
+ import { linesOf, quoteOf } from "../shared/comments";
25
30
  import type { FileContent } from "../shared/protocol";
26
31
  import type { Scratch } from "./scratch";
27
32
  import type { Skill } from "./skills";
@@ -182,6 +187,7 @@ export class BoardMcp {
182
187
  delete args.content;
183
188
  delete args.name;
184
189
  } else this.checkPath(args, notes);
190
+ if (tool === "add_comment") this.quote(args);
185
191
  if (Array.isArray(args.files))
186
192
  args.files = args.files.map((value: unknown) => {
187
193
  if (typeof value !== "object" || value === null)
@@ -238,6 +244,26 @@ export class BoardMcp {
238
244
  }
239
245
  }
240
246
 
247
+ /** An agent's comment carries the text of its lines. */
248
+ private quote(args: Record<string, unknown>) {
249
+ if (typeof args.path !== "string" || !args.path.trim())
250
+ throw new Error("a comment needs the path of the file it is on");
251
+ const path = args.path.trim().replace(/^(\.\/)+/, "");
252
+ const file = this.options.read(path);
253
+ if (file.kind !== "text")
254
+ throw new Error(
255
+ `${path} can't be commented on: ${file.kind === "denied" ? file.reason : file.kind}`,
256
+ );
257
+ const start = Math.floor(Number(args.start_line));
258
+ const end = args.end_line === undefined ? start : Math.floor(Number(args.end_line));
259
+ const quote = quoteOf(file.text, start, end);
260
+ if (quote === null)
261
+ throw new Error(
262
+ `lines ${start}-${end} aren't in ${path}: it has ${linesOf(file.text).length} lines`,
263
+ );
264
+ Object.assign(args, { path, start_line: start, end_line: end, quote });
265
+ }
266
+
241
267
  private relay(sessionId: string, tool: string, args: Record<string, unknown>) {
242
268
  const callId = crypto.randomUUID();
243
269
  const result = new Promise<ToolResult>((resolve) => {
@@ -11,11 +11,15 @@
11
11
 
12
12
  export const BOARD_TOOL_NAMES = [
13
13
  "view_board",
14
+ "view_frame",
14
15
  "open_frame",
15
16
  "update_frame",
16
17
  "close_frame",
17
18
  "read_board_file",
18
19
  "write_board_file",
20
+ "add_comment",
21
+ "edit_comment",
22
+ "delete_comment",
19
23
  ] as const;
20
24
  export type BoardToolName = (typeof BOARD_TOOL_NAMES)[number];
21
25
 
@@ -72,6 +76,33 @@ export interface UpdateFrameArgs extends Placement, FileTarget, ScratchContent,
72
76
  readonly title?: string;
73
77
  readonly url?: string;
74
78
  readonly view?: "preview" | "source";
79
+ /** A comment's id: show its file at its lines. */
80
+ readonly comment?: string;
81
+ }
82
+
83
+ export interface ViewFrameArgs {
84
+ readonly frame: string;
85
+ }
86
+
87
+ export interface AddCommentArgs {
88
+ readonly frame: string;
89
+ readonly path: string;
90
+ readonly start_line: number;
91
+ readonly end_line?: number;
92
+ readonly body: string;
93
+ /** The lines' text, which `canvas serve` adds (ADR 0006). */
94
+ readonly quote?: string;
95
+ }
96
+
97
+ export interface EditCommentArgs {
98
+ readonly frame: string;
99
+ readonly comment: string;
100
+ readonly body: string;
101
+ }
102
+
103
+ export interface DeleteCommentArgs {
104
+ readonly frame: string;
105
+ readonly comment: string;
75
106
  }
76
107
 
77
108
  export interface CloseFrameArgs {
@@ -180,6 +211,18 @@ export const BOARD_TOOLS: ReadonlyArray<{
180
211
  },
181
212
  },
182
213
  },
214
+ {
215
+ name: "view_frame",
216
+ description:
217
+ "Look at one frame in full. For a file frame: what it shows, its list of files, and its " +
218
+ "comments — people's and agents' notes on lines of its files, with ids, authors and lines. " +
219
+ "view_board says which frames have comments.",
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: { frame: { type: "string", description: "Id of the frame." } },
223
+ required: ["frame"],
224
+ },
225
+ },
183
226
  {
184
227
  name: "open_frame",
185
228
  description:
@@ -220,6 +263,10 @@ export const BOARD_TOOLS: ReadonlyArray<{
220
263
  ...scratchContent,
221
264
  ...fileList,
222
265
  view: { type: "string", enum: ["preview", "source"] },
266
+ comment: {
267
+ type: "string",
268
+ description: "file: a comment's id (view_frame) — show its file at its lines.",
269
+ },
223
270
  url: { type: "string" },
224
271
  title: { type: "string" },
225
272
  ...placement,
@@ -264,6 +311,51 @@ export const BOARD_TOOLS: ReadonlyArray<{
264
311
  required: ["content"],
265
312
  },
266
313
  },
314
+ {
315
+ name: "add_comment",
316
+ description:
317
+ "Comment on lines of a file, in a file frame: it shows below the lines for everyone, " +
318
+ "whatever file the frame shows. Markdown. The frame keeps it; people read it there.",
319
+ inputSchema: {
320
+ type: "object",
321
+ properties: {
322
+ frame: { type: "string", description: "Id of the file frame." },
323
+ path: {
324
+ type: "string",
325
+ description: "The file, relative to the project root, or canvas:scratch/<name>.",
326
+ },
327
+ start_line: { type: "integer", minimum: 1 },
328
+ end_line: { type: "integer", minimum: 1, description: "Default: start_line." },
329
+ body: { type: "string", description: "The comment, markdown." },
330
+ },
331
+ required: ["frame", "path", "start_line", "body"],
332
+ },
333
+ },
334
+ {
335
+ name: "edit_comment",
336
+ description: "Rewrite a comment's text. Only agents' comments: people's are theirs.",
337
+ inputSchema: {
338
+ type: "object",
339
+ properties: {
340
+ frame: { type: "string", description: "Id of the file frame." },
341
+ comment: { type: "string", description: "The comment's id (view_frame)." },
342
+ body: { type: "string", description: "The new text, markdown." },
343
+ },
344
+ required: ["frame", "comment", "body"],
345
+ },
346
+ },
347
+ {
348
+ name: "delete_comment",
349
+ description: "Remove a comment. Only agents' comments: people's are theirs.",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ frame: { type: "string", description: "Id of the file frame." },
354
+ comment: { type: "string", description: "The comment's id (view_frame)." },
355
+ },
356
+ required: ["frame", "comment"],
357
+ },
358
+ },
267
359
  ];
268
360
 
269
361
  /** The agent's standing context, sent as MCP server instructions. */
@@ -287,6 +379,7 @@ The ${BOARD_SERVER_NAME} tools let you see and change the board: ${BOARD_TOOL_NA
287
379
  - Scratch files hold what exists only to be shown on this board: a write-up, a diagram, an HTML visualisation. Pass the text as \`content\` (with a \`name\`) to open_frame or update_frame, or use write_board_file; canvas keeps them outside the project, as canvas:scratch/<name>. Don't write such files into the project for the board. Anything else — a temp file for your own work, a script, test data — goes wherever it would without canvas.
288
380
  - Any agent may read (read_board_file) and overwrite (write_board_file) any scratch file; view_board lists them.
289
381
  - To show several files for one topic, prefer one file frame with a list (files) over a frame per file: people click through it at their own pace. You decide the tree: display paths, folders, order, line ranges. A write-up (a scratch file) at the top and a visualisation at the bottom fit in the same list.
382
+ - People (and agents) comment on lines of files in a file frame. view_board says which frames have comments; view_frame lists them, with ids. You aren't told when someone comments: look when asked to, e.g. to address feedback. add_comment leaves one of yours; edit_comment and delete_comment change agents' comments, never people's. update_frame with a comment's id shows its file at its lines. A comment is "outdated" when its lines changed since; it shows what they were.
290
383
  - Markdown and HTML files render. HTML runs its scripts, but relative links and assets (CSS, images, other scripts) don't load, so inline them.${skillsSection(skills)}`;
291
384
  }
292
385
 
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Comments on the lines of a file (ADR 0006). A comment keeps the lines it
3
+ * was written on as text, its quote: files change under it — agents edit them
4
+ * live — and the quote is how it finds its lines again, or knows it can't.
5
+ * Pure, so `canvas serve` (which quotes agents' comments) and the browser
6
+ * agree on what a line is.
7
+ */
8
+
9
+ /** A file's lines as the frames number them: a final newline ends the last line. */
10
+ export function linesOf(text: string): string[] {
11
+ const lines = text.split("\n");
12
+ if (lines.length > 1 && lines.at(-1) === "") lines.pop();
13
+ return lines;
14
+ }
15
+
16
+ /** Lines `start`–`end` (1-based, inclusive) of `text`; null if the file hasn't got them. */
17
+ export function quoteOf(text: string, start: number, end: number): string | null {
18
+ const lines = linesOf(text);
19
+ if (start < 1 || end < start || end > lines.length) return null;
20
+ return lines.slice(start - 1, end).join("\n");
21
+ }
22
+
23
+ /**
24
+ * Where a quote is now: its old place if the lines there still read the same,
25
+ * else the nearest place they do. null if they're gone — the comment is
26
+ * outdated. A quote of blank lines only stays where it was: blank lines are
27
+ * everywhere, finding "them" elsewhere would be a guess.
28
+ */
29
+ export function relocate(
30
+ text: string,
31
+ quote: string,
32
+ start: number,
33
+ ): { start: number; end: number } | null {
34
+ const lines = linesOf(text);
35
+ const wanted = quote.split("\n");
36
+ const at = (s: number) => wanted.every((line, i) => lines[s - 1 + i] === line);
37
+ const found = (s: number) => ({ start: s, end: s + wanted.length - 1 });
38
+ const last = lines.length - wanted.length + 1;
39
+ if (start >= 1 && start <= last && at(start)) return found(start);
40
+ if (!quote.trim()) return null;
41
+ for (let distance = 1; distance < Math.max(start, last); distance++) {
42
+ if (start - distance >= 1 && start - distance <= last && at(start - distance))
43
+ return found(start - distance);
44
+ if (start + distance >= 1 && start + distance <= last && at(start + distance))
45
+ return found(start + distance);
46
+ }
47
+ return null;
48
+ }