@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 +15 -4
- package/dist/bin/cowrite.js +162 -19
- package/dist/bin/cowrite.js.map +1 -1
- package/package.json +1 -1
- package/ui/client.js +365 -99
- package/ui/index.html +16 -7
- package/ui/styles.css +278 -5
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
|
-
│ └───────┬────────┘ │ │
|
|
21
|
-
│ │ │ │
|
|
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
|
|
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
|
package/dist/bin/cowrite.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
511
|
+
const onNewComment = (comment) => {
|
|
413
512
|
cleanup();
|
|
414
|
-
const
|
|
513
|
+
const payload = formatCommentPayload(comment, "new_comment");
|
|
415
514
|
resolve6({
|
|
416
515
|
content: [{
|
|
417
516
|
type: "text",
|
|
418
|
-
text: JSON.stringify(
|
|
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",
|
|
431
|
-
store.off("comment_reopened",
|
|
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",
|
|
435
|
-
store.on("comment_reopened",
|
|
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,
|
|
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.
|
|
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
|
|