@filipc77/cowrite 0.6.3 → 0.6.5

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/README.md CHANGED
@@ -14,11 +14,12 @@ Browser (Preview UI) Node.js Process Claude Code
14
14
  │ File preview │ │ HTTP + WebSocket │ │ │
15
15
  │ Text selection │◄───►│ server (port 3377) │ │ MCP tools: │
16
16
  │ Comment creation │ │ │ │ │
17
- │ Comment sidebar │ │ ┌────────────────┐ │ │ get_pending │
17
+ │ Comment sidebar │ │ ┌────────────────┐ │ │ get_pending │
18
18
  └──────────────────┘ │ │ CommentStore │ │ │ resolve │
19
19
  │ │ (shared memory)│ │ │ reply │
20
- │ └───────┬────────┘ │ │ get_annotated
21
- │ │ │ │ wait_for_comment
20
+ │ └───────┬────────┘ │ │ propose_change
21
+ │ │ │ │ get_annotated
22
+ │ │ │ │ wait_for_comment │
22
23
  │ ┌───────▼────────┐ │ │ │
23
24
  │ │ MCP Server │◄─╋────►│ │
24
25
  │ │ (stdio) │ │ │ │
@@ -135,6 +136,16 @@ Sends a reply from the agent, visible in the browser comment sidebar.
135
136
  | `commentId` | string | The comment ID to reply to |
136
137
  | `reply` | string | The reply text |
137
138
 
139
+ ### `propose_change`
140
+
141
+ Proposes a text replacement for a comment's selected text. The user sees a diff in the browser with Apply/Reject buttons. Preferred over direct file edits when responding to comments on selected text.
142
+
143
+ | Parameter | Type | Description |
144
+ |-----------|------|-------------|
145
+ | `commentId` | string | The comment ID to propose a change for |
146
+ | `newText` | string | The replacement text |
147
+ | `explanation` | string | Explanation of the change |
148
+
138
149
  ### `get_file_with_annotations`
139
150
 
140
151
  Returns the file content with inline comment markers at the positions where comments are anchored:
@@ -192,7 +203,7 @@ Cowrite ships with two built-in Claude Code skills (auto-installed to `.claude/s
192
203
  - The `UserPromptSubmit` hook injecting it on the next user message
193
204
  - `wait_for_comment` returning immediately (if `/watch` is active)
194
205
  - The agent calling `get_pending_comments` directly
195
- 6. The agent makes the change, calls `reply_to_comment` (visible in the browser), then `resolve_comment`
206
+ 6. The agent responds either calling `propose_change` (user sees a diff with Apply/Reject) or `reply_to_comment` + `resolve_comment`
196
207
  7. The browser receives the update via WebSocket and shows the reply and resolved state
197
208
 
198
209
  ## How Edits Flow
@@ -150,6 +150,46 @@ var CommentStore = class extends EventEmitter {
150
150
  `));
151
151
  return reply;
152
152
  }
153
+ addProposalReply(commentId, newText, explanation) {
154
+ const comment = this.comments.get(commentId);
155
+ if (!comment) return null;
156
+ const proposal = {
157
+ oldText: comment.selectedText,
158
+ newText,
159
+ explanation,
160
+ status: "pending"
161
+ };
162
+ const reply = {
163
+ id: randomUUID(),
164
+ from: "agent",
165
+ text: explanation,
166
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
167
+ proposal
168
+ };
169
+ comment.replies.push(reply);
170
+ if (comment.status === "pending") {
171
+ comment.status = "answered";
172
+ }
173
+ this.emit("change", comment);
174
+ this.persist().catch((err) => process.stderr.write(`Persist error: ${err}
175
+ `));
176
+ return reply;
177
+ }
178
+ updateProposalStatus(commentId, replyId, status) {
179
+ const comment = this.comments.get(commentId);
180
+ if (!comment) return false;
181
+ const reply = comment.replies.find((r) => r.id === replyId);
182
+ if (!reply?.proposal) return false;
183
+ reply.proposal.status = status;
184
+ if (status === "applied") {
185
+ comment.selectedText = reply.proposal.newText;
186
+ comment.length = reply.proposal.newText.length;
187
+ }
188
+ this.emit("change", comment);
189
+ this.persist().catch((err) => process.stderr.write(`Persist error: ${err}
190
+ `));
191
+ return true;
192
+ }
153
193
  get(commentId) {
154
194
  return this.comments.get(commentId) ?? null;
155
195
  }
@@ -359,6 +399,40 @@ function createMcpServer(store, projectDir, getPreviewPort) {
359
399
  };
360
400
  }
361
401
  );
402
+ server.tool(
403
+ "propose_change",
404
+ "Propose a text change for a comment's selected text. The user sees a diff and can Apply or Reject. Preferred over direct edits when responding to comments on selected text.",
405
+ {
406
+ commentId: z.string().describe("The comment ID to propose a change for"),
407
+ newText: z.string().describe("The replacement text"),
408
+ explanation: z.string().describe("Explanation of the change")
409
+ },
410
+ async ({ commentId, newText, explanation }) => {
411
+ const comment = store.get(commentId);
412
+ if (!comment) {
413
+ return {
414
+ content: [{ type: "text", text: `Comment ${commentId} not found.` }],
415
+ isError: true
416
+ };
417
+ }
418
+ if (!comment.selectedText) {
419
+ return {
420
+ content: [{ type: "text", text: `Comment ${commentId} is a file-level comment with no selected text. Use reply_to_comment instead.` }],
421
+ isError: true
422
+ };
423
+ }
424
+ const reply = store.addProposalReply(commentId, newText, explanation);
425
+ if (!reply) {
426
+ return {
427
+ content: [{ type: "text", text: `Failed to add proposal to comment ${commentId}.` }],
428
+ isError: true
429
+ };
430
+ }
431
+ return {
432
+ content: [{ type: "text", text: `Proposal added to comment ${commentId}. The user can now review and apply or reject it.` }]
433
+ };
434
+ }
435
+ );
362
436
  server.tool(
363
437
  "get_file_with_annotations",
364
438
  "Get file content with inline comment markers showing where comments are anchored.",
@@ -382,24 +456,49 @@ function createMcpServer(store, projectDir, getPreviewPort) {
382
456
  }
383
457
  }
384
458
  );
459
+ const lastSeenReplyCounts = /* @__PURE__ */ new Map();
460
+ function formatCommentPayload(comment, event) {
461
+ const file = relative(projectDir, comment.file);
462
+ const replies = comment.replies ?? [];
463
+ lastSeenReplyCounts.set(comment.id, replies.length);
464
+ const payload = { ...comment, file, event };
465
+ if (event === "follow_up" && replies.length > 0) {
466
+ const userReplies = replies.filter((r) => r.from === "user");
467
+ if (userReplies.length > 0) {
468
+ payload.latestUserReply = userReplies[userReplies.length - 1].text;
469
+ }
470
+ }
471
+ return payload;
472
+ }
385
473
  server.tool(
386
474
  "wait_for_comment",
387
- "Block until a new comment is posted in the live preview, then return it. This is the primary way to receive real-time comments \u2014 call it again immediately after handling each comment to keep listening. If it times out, call it again.",
475
+ "Block until a new or follow-up comment is posted in the live preview, then return it. Returns an 'event' field: 'new_comment' for brand-new comments, 'follow_up' when the user replied to an already-answered comment. For follow-ups, 'latestUserReply' contains the new reply text. Call again immediately after handling each result to keep listening.",
388
476
  {
389
477
  timeout: z.number().optional().describe("Max seconds to wait (default: 30)")
390
478
  },
391
479
  ({ timeout }, { signal }) => {
392
480
  const maxWait = (timeout ?? 30) * 1e3;
393
481
  const pending = store.getAll({ status: "pending" });
394
- if (pending.length > 0) {
395
- const latest = pending[pending.length - 1];
396
- const file = relative(projectDir, latest.file);
397
- return {
398
- content: [{
399
- type: "text",
400
- text: JSON.stringify({ ...latest, file }, null, 2)
401
- }]
402
- };
482
+ for (const c of pending) {
483
+ const prevCount = lastSeenReplyCounts.get(c.id);
484
+ if (prevCount === void 0) {
485
+ const payload = formatCommentPayload(c, "new_comment");
486
+ return {
487
+ content: [{
488
+ type: "text",
489
+ text: JSON.stringify(payload, null, 2)
490
+ }]
491
+ };
492
+ }
493
+ if (c.replies.length > prevCount) {
494
+ const payload = formatCommentPayload(c, "follow_up");
495
+ return {
496
+ content: [{
497
+ type: "text",
498
+ text: JSON.stringify(payload, null, 2)
499
+ }]
500
+ };
501
+ }
403
502
  }
404
503
  return new Promise((resolve6) => {
405
504
  const timer = setTimeout(() => {
@@ -409,13 +508,23 @@ function createMcpServer(store, projectDir, getPreviewPort) {
409
508
  content: [{ type: "text", text: count > 0 ? `Timeout, but ${count} pending comment(s) exist. Call get_pending_comments now.` : "No new comments yet. Call wait_for_comment again to keep listening." }]
410
509
  });
411
510
  }, maxWait);
412
- const onComment = (comment) => {
511
+ const onNewComment = (comment) => {
413
512
  cleanup();
414
- const file = relative(projectDir, comment.file);
513
+ const payload = formatCommentPayload(comment, "new_comment");
415
514
  resolve6({
416
515
  content: [{
417
516
  type: "text",
418
- text: JSON.stringify({ ...comment, file }, null, 2)
517
+ text: JSON.stringify(payload, null, 2)
518
+ }]
519
+ });
520
+ };
521
+ const onReopened = (comment) => {
522
+ cleanup();
523
+ const payload = formatCommentPayload(comment, "follow_up");
524
+ resolve6({
525
+ content: [{
526
+ type: "text",
527
+ text: JSON.stringify(payload, null, 2)
419
528
  }]
420
529
  });
421
530
  };
@@ -427,12 +536,12 @@ function createMcpServer(store, projectDir, getPreviewPort) {
427
536
  };
428
537
  const cleanup = () => {
429
538
  clearTimeout(timer);
430
- store.off("new_comment", onComment);
431
- store.off("comment_reopened", onComment);
539
+ store.off("new_comment", onNewComment);
540
+ store.off("comment_reopened", onReopened);
432
541
  signal?.removeEventListener("abort", onAbort);
433
542
  };
434
- store.on("new_comment", onComment);
435
- store.on("comment_reopened", onComment);
543
+ store.on("new_comment", onNewComment);
544
+ store.on("comment_reopened", onReopened);
436
545
  signal?.addEventListener("abort", onAbort, { once: true });
437
546
  if (signal?.aborted) {
438
547
  onAbort();
@@ -537,7 +646,9 @@ function createMcpServer(store, projectDir, getPreviewPort) {
537
646
  "",
538
647
  "Follow this loop:",
539
648
  "1. Call `get_pending_comments` to check for any comments already posted.",
540
- "2. Process each pending comment: read the file, make the requested change, then call `reply_to_comment` to explain what you did.",
649
+ "2. Process each pending comment: read the file, then respond:",
650
+ " - If the comment is on selected text, prefer `propose_change` to suggest a diff the user can Apply/Reject.",
651
+ " - For file-level comments or questions, use `reply_to_comment` to explain what you did.",
541
652
  " Your reply automatically marks the comment as 'answered'. The user will review and resolve it.",
542
653
  "3. Call `wait_for_comment` to block until the next comment (or reopened comment) arrives.",
543
654
  "4. When a comment arrives, process it the same way (step 2).",
@@ -821,6 +932,38 @@ function createPreviewServer(store, projectDir, port, initialFile) {
821
932
  await writeFile2(file, newContent, "utf-8");
822
933
  break;
823
934
  }
935
+ case "proposal_apply": {
936
+ const comment = store.get(msg.commentId);
937
+ if (!comment) {
938
+ send(ws, { type: "error", message: "Comment not found" });
939
+ break;
940
+ }
941
+ const reply = comment.replies.find((r) => r.id === msg.replyId);
942
+ if (!reply?.proposal || reply.proposal.status !== "pending") {
943
+ send(ws, { type: "error", message: "Proposal not found or not pending" });
944
+ break;
945
+ }
946
+ const pFile = comment.file;
947
+ const pWatcher = watchers.get(pFile);
948
+ if (!pWatcher) {
949
+ send(ws, { type: "error", message: "File not being watched" });
950
+ break;
951
+ }
952
+ const pContent = pWatcher.getContent();
953
+ const pSlice = pContent.slice(comment.offset, comment.offset + comment.length);
954
+ if (pSlice !== comment.selectedText) {
955
+ send(ws, { type: "error", message: "File content has changed \u2014 selected text no longer matches at this position" });
956
+ break;
957
+ }
958
+ const pNewContent = pContent.slice(0, comment.offset) + reply.proposal.newText + pContent.slice(comment.offset + comment.length);
959
+ await writeFile2(pFile, pNewContent, "utf-8");
960
+ store.updateProposalStatus(msg.commentId, msg.replyId, "applied");
961
+ break;
962
+ }
963
+ case "proposal_reject": {
964
+ store.updateProposalStatus(msg.commentId, msg.replyId, "rejected");
965
+ break;
966
+ }
824
967
  }
825
968
  }
826
969
  store.on("change", (comment) => {
@@ -887,7 +1030,7 @@ function createPreviewServer(store, projectDir, port, initialFile) {
887
1030
  // bin/cowrite.ts
888
1031
  import updateNotifier from "update-notifier";
889
1032
  import "module";
890
- var version = true ? "0.6.1" : createRequire(import.meta.url)("../package.json").version;
1033
+ var version = true ? "0.6.5" : createRequire(import.meta.url)("../package.json").version;
891
1034
  var USAGE = `
892
1035
  cowrite \u2014 Live commenting plugin for coding agent sessions
893
1036