@filipc77/cowrite 0.1.0

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 ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@filipc77/cowrite",
3
+ "version": "0.1.0",
4
+ "description": "Live commenting plugin for coding agent sessions",
5
+ "type": "module",
6
+ "bin": {
7
+ "cowrite": "./dist/bin/cowrite.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsx bin/cowrite.ts preview",
11
+ "build": "tsup",
12
+ "lint": "tsc --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "ui"
19
+ ],
20
+ "keywords": ["mcp", "claude", "comments", "live-preview"],
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.12.1",
24
+ "chokidar": "^4.0.3",
25
+ "marked": "^15.0.7",
26
+ "ws": "^8.18.1",
27
+ "zod": "^3.24.2"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^22.13.5",
31
+ "@types/ws": "^8.18.0",
32
+ "tsup": "^8.4.0",
33
+ "tsx": "^4.19.3",
34
+ "typescript": "^5.7.3",
35
+ "vitest": "^3.0.7"
36
+ }
37
+ }
package/ui/client.js ADDED
@@ -0,0 +1,338 @@
1
+ // @ts-check
2
+
3
+ /** @typedef {import('../src/types.js').Comment} Comment */
4
+ /** @typedef {import('../src/types.js').WSServerMessage} WSServerMessage */
5
+
6
+ const $ = (sel) => document.querySelector(sel);
7
+ const fileContentEl = $("#fileContent");
8
+ const commentListEl = $("#commentList");
9
+ const commentCountEl = $("#commentCount");
10
+ const filePathEl = $("#filePath");
11
+ const statusEl = $("#status");
12
+ const popup = $("#commentPopup");
13
+ const popupSelection = $("#popupSelection");
14
+ const commentInput = $("#commentInput");
15
+
16
+ /** @type {Comment[]} */
17
+ let comments = [];
18
+ let currentFile = "";
19
+ let currentContent = "";
20
+ let ws = null;
21
+ let selectionInfo = null;
22
+
23
+ // --- WebSocket ---
24
+
25
+ function connect() {
26
+ const protocol = location.protocol === "https:" ? "wss:" : "ws:";
27
+ ws = new WebSocket(`${protocol}//${location.host}`);
28
+
29
+ ws.onopen = () => {
30
+ statusEl.textContent = "Connected";
31
+ statusEl.className = "status connected";
32
+ };
33
+
34
+ ws.onclose = () => {
35
+ statusEl.textContent = "Disconnected";
36
+ statusEl.className = "status";
37
+ setTimeout(connect, 2000);
38
+ };
39
+
40
+ ws.onmessage = (event) => {
41
+ /** @type {WSServerMessage} */
42
+ const msg = JSON.parse(event.data);
43
+ switch (msg.type) {
44
+ case "file_update":
45
+ currentFile = msg.file;
46
+ currentContent = msg.content;
47
+ filePathEl.textContent = msg.file;
48
+ fileContentEl.innerHTML = msg.html;
49
+ applyHighlights();
50
+ break;
51
+ case "comments_update":
52
+ comments = msg.comments;
53
+ commentCountEl.textContent = String(comments.filter(c => c.status === "pending").length);
54
+ renderComments();
55
+ applyHighlights();
56
+ break;
57
+ case "error":
58
+ console.error("Server error:", msg.message);
59
+ break;
60
+ }
61
+ };
62
+ }
63
+
64
+ function send(msg) {
65
+ if (ws && ws.readyState === WebSocket.OPEN) {
66
+ ws.send(JSON.stringify(msg));
67
+ }
68
+ }
69
+
70
+ // --- Selection & Comment Creation ---
71
+
72
+ document.addEventListener("mouseup", (e) => {
73
+ // Don't trigger when clicking inside popup or sidebar
74
+ if (popup.contains(e.target) || $("#sidebar").contains(e.target)) return;
75
+
76
+ const selection = window.getSelection();
77
+ if (!selection || selection.isCollapsed) {
78
+ hidePopup();
79
+ return;
80
+ }
81
+
82
+ const text = selection.toString().trim();
83
+ if (!text) {
84
+ hidePopup();
85
+ return;
86
+ }
87
+
88
+ // Compute character offset in the source content
89
+ const offset = computeOffset(selection, text);
90
+ if (offset === -1) {
91
+ hidePopup();
92
+ return;
93
+ }
94
+
95
+ selectionInfo = { offset, length: text.length, selectedText: text };
96
+
97
+ // Position popup near the selection
98
+ const range = selection.getRangeAt(0);
99
+ const rect = range.getBoundingClientRect();
100
+ popup.style.left = `${Math.min(rect.left, window.innerWidth - 340)}px`;
101
+ popup.style.top = `${rect.bottom + 8}px`;
102
+ popupSelection.textContent = text.length > 120 ? text.slice(0, 120) + "..." : text;
103
+ commentInput.value = "";
104
+ popup.hidden = false;
105
+ commentInput.focus();
106
+ });
107
+
108
+ function computeOffset(selection, text) {
109
+ // For plain text: use data-offset on span.line elements
110
+ const anchor = selection.anchorNode;
111
+ if (!anchor) return -1;
112
+
113
+ // Walk up to find a [data-offset] element
114
+ let node = anchor.nodeType === Node.TEXT_NODE ? anchor.parentElement : anchor;
115
+ while (node && !node.dataset?.offset && node !== fileContentEl) {
116
+ node = node.parentElement;
117
+ }
118
+
119
+ if (node?.dataset?.offset !== undefined) {
120
+ // Plain text mode: compute from data-offset + text offset within the line
121
+ const lineOffset = parseInt(node.dataset.offset, 10);
122
+ const nodeText = node.textContent || "";
123
+ const idx = nodeText.indexOf(text);
124
+ if (idx !== -1) return lineOffset + idx;
125
+ }
126
+
127
+ // Fallback: search for the text in the raw content
128
+ const idx = currentContent.indexOf(text);
129
+ return idx;
130
+ }
131
+
132
+ function hidePopup() {
133
+ popup.hidden = true;
134
+ selectionInfo = null;
135
+ }
136
+
137
+ $("#commentSubmit").addEventListener("click", submitComment);
138
+ $("#commentCancel").addEventListener("click", hidePopup);
139
+
140
+ commentInput.addEventListener("keydown", (e) => {
141
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
142
+ submitComment();
143
+ }
144
+ if (e.key === "Escape") {
145
+ hidePopup();
146
+ }
147
+ });
148
+
149
+ function submitComment() {
150
+ const text = commentInput.value.trim();
151
+ if (!text || !selectionInfo) return;
152
+
153
+ send({
154
+ type: "comment_add",
155
+ file: currentFile,
156
+ offset: selectionInfo.offset,
157
+ length: selectionInfo.length,
158
+ selectedText: selectionInfo.selectedText,
159
+ comment: text,
160
+ });
161
+
162
+ hidePopup();
163
+ window.getSelection()?.removeAllRanges();
164
+ }
165
+
166
+ // --- Comment Sidebar Rendering ---
167
+
168
+ function renderComments() {
169
+ if (comments.length === 0) {
170
+ commentListEl.innerHTML = `
171
+ <div class="empty-state">
172
+ <p>No comments yet.</p>
173
+ <p>Select text in the preview and add a comment.</p>
174
+ </div>
175
+ `;
176
+ return;
177
+ }
178
+
179
+ commentListEl.innerHTML = comments.map((c) => `
180
+ <div class="comment-card ${c.status}" data-id="${c.id}">
181
+ <div class="comment-selected-text">${escapeHtml(c.selectedText)}</div>
182
+ <div class="comment-text">${escapeHtml(c.comment)}</div>
183
+ ${c.replies.length > 0 ? `
184
+ <div class="comment-replies">
185
+ ${c.replies.map((r) => `
186
+ <div class="reply ${r.from}">
187
+ <div class="reply-from ${r.from}">${r.from}</div>
188
+ <div>${escapeHtml(r.text)}</div>
189
+ </div>
190
+ `).join("")}
191
+ </div>
192
+ ` : ""}
193
+ <div class="comment-meta">
194
+ <span>${timeAgo(c.createdAt)}</span>
195
+ <span class="comment-status ${c.status}">${c.status}</span>
196
+ </div>
197
+ ${c.status === "pending" ? `
198
+ <div class="comment-actions">
199
+ <button onclick="showReplyForm('${c.id}')">Reply</button>
200
+ <button onclick="resolveComment('${c.id}')">Resolve</button>
201
+ </div>
202
+ ` : ""}
203
+ <div class="reply-form" id="reply-form-${c.id}" hidden>
204
+ <textarea rows="2" placeholder="Reply..."></textarea>
205
+ <div class="popup-actions" style="margin-top:6px;">
206
+ <button onclick="submitReply('${c.id}')" style="font-size:11px;padding:4px 10px;border-radius:4px;border:none;background:var(--accent);color:var(--bg);cursor:pointer;">Send</button>
207
+ </div>
208
+ </div>
209
+ </div>
210
+ `).join("");
211
+
212
+ // Click to scroll to highlight
213
+ for (const card of commentListEl.querySelectorAll(".comment-card")) {
214
+ card.addEventListener("click", (e) => {
215
+ if (e.target.tagName === "BUTTON" || e.target.tagName === "TEXTAREA") return;
216
+ const id = card.dataset.id;
217
+ const highlight = fileContentEl.querySelector(`[data-comment-id="${id}"]`);
218
+ if (highlight) {
219
+ highlight.scrollIntoView({ behavior: "smooth", block: "center" });
220
+ highlight.style.outline = "2px solid var(--accent)";
221
+ setTimeout(() => highlight.style.outline = "", 1500);
222
+ }
223
+ });
224
+ }
225
+ }
226
+
227
+ // Global functions for inline onclick handlers
228
+ window.showReplyForm = function (id) {
229
+ const form = document.getElementById(`reply-form-${id}`);
230
+ if (form) {
231
+ form.hidden = !form.hidden;
232
+ if (!form.hidden) form.querySelector("textarea").focus();
233
+ }
234
+ };
235
+
236
+ window.resolveComment = function (id) {
237
+ send({ type: "comment_resolve", commentId: id });
238
+ };
239
+
240
+ window.submitReply = function (id) {
241
+ const form = document.getElementById(`reply-form-${id}`);
242
+ const textarea = form?.querySelector("textarea");
243
+ const text = textarea?.value.trim();
244
+ if (!text) return;
245
+ send({ type: "comment_reply", commentId: id, text });
246
+ textarea.value = "";
247
+ form.hidden = true;
248
+ };
249
+
250
+ // --- Comment Highlights ---
251
+
252
+ function applyHighlights() {
253
+ // Remove existing highlights
254
+ for (const el of fileContentEl.querySelectorAll(".comment-highlight")) {
255
+ const parent = el.parentNode;
256
+ parent.replaceChild(document.createTextNode(el.textContent), el);
257
+ parent.normalize();
258
+ }
259
+
260
+ if (comments.length === 0) return;
261
+
262
+ // Build a text-node map
263
+ const walker = document.createTreeWalker(fileContentEl, NodeFilter.SHOW_TEXT);
264
+ const textNodes = [];
265
+ let totalOffset = 0;
266
+
267
+ while (walker.nextNode()) {
268
+ const node = walker.currentNode;
269
+ const text = node.textContent || "";
270
+ textNodes.push({ node, start: totalOffset, end: totalOffset + text.length });
271
+ totalOffset += text.length;
272
+ }
273
+
274
+ const fullText = textNodes.map((n) => n.node.textContent).join("");
275
+
276
+ // For each comment, find the selected text and wrap in highlight
277
+ for (const comment of comments) {
278
+ const searchText = comment.selectedText;
279
+ // Search near the expected offset first, then globally
280
+ let textIdx = fullText.indexOf(searchText, Math.max(0, comment.offset - 50));
281
+ if (textIdx === -1 || Math.abs(textIdx - comment.offset) > 200) {
282
+ textIdx = fullText.indexOf(searchText);
283
+ }
284
+ if (textIdx === -1) continue;
285
+
286
+ wrapRange(textNodes, textIdx, textIdx + searchText.length, comment);
287
+ }
288
+ }
289
+
290
+ function wrapRange(textNodes, start, end, comment) {
291
+ for (let i = 0; i < textNodes.length; i++) {
292
+ const tn = textNodes[i];
293
+ if (tn.end <= start || tn.start >= end) continue;
294
+
295
+ const nodeStart = Math.max(start - tn.start, 0);
296
+ const nodeEnd = Math.min(end - tn.start, tn.node.textContent.length);
297
+
298
+ const range = document.createRange();
299
+ range.setStart(tn.node, nodeStart);
300
+ range.setEnd(tn.node, nodeEnd);
301
+
302
+ const span = document.createElement("span");
303
+ span.className = `comment-highlight ${comment.status}`;
304
+ span.dataset.commentId = comment.id;
305
+ span.title = comment.comment;
306
+
307
+ try {
308
+ range.surroundContents(span);
309
+ // After wrapping, we need to update remaining text nodes
310
+ // Simply break — for simplicity, one highlight per pass is fine
311
+ return;
312
+ } catch {
313
+ // Range may cross element boundaries; skip this highlight
314
+ return;
315
+ }
316
+ }
317
+ }
318
+
319
+ // --- Utilities ---
320
+
321
+ function escapeHtml(text) {
322
+ const div = document.createElement("div");
323
+ div.textContent = text;
324
+ return div.innerHTML;
325
+ }
326
+
327
+ function timeAgo(iso) {
328
+ const diff = Date.now() - new Date(iso).getTime();
329
+ const mins = Math.floor(diff / 60000);
330
+ if (mins < 1) return "just now";
331
+ if (mins < 60) return `${mins}m ago`;
332
+ const hours = Math.floor(mins / 60);
333
+ if (hours < 24) return `${hours}h ago`;
334
+ return `${Math.floor(hours / 24)}d ago`;
335
+ }
336
+
337
+ // --- Init ---
338
+ connect();
package/ui/index.html ADDED
@@ -0,0 +1,39 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Cowrite — Live Preview</title>
7
+ <link rel="stylesheet" href="styles.css">
8
+ </head>
9
+ <body>
10
+ <header>
11
+ <h1>Cowrite</h1>
12
+ <span class="file-path" id="filePath">—</span>
13
+ <span class="status" id="status">Connecting...</span>
14
+ </header>
15
+
16
+ <main>
17
+ <div class="content-panel" id="contentPanel">
18
+ <div id="fileContent"></div>
19
+ </div>
20
+
21
+ <div class="sidebar" id="sidebar">
22
+ <h2>Comments <span class="comment-count" id="commentCount">0</span></h2>
23
+ <div id="commentList"></div>
24
+ </div>
25
+ </main>
26
+
27
+ <!-- Floating comment button/form -->
28
+ <div class="comment-popup" id="commentPopup" hidden>
29
+ <div class="popup-selection" id="popupSelection"></div>
30
+ <textarea id="commentInput" placeholder="Leave a comment..." rows="3"></textarea>
31
+ <div class="popup-actions">
32
+ <button id="commentSubmit">Submit</button>
33
+ <button id="commentCancel" class="secondary">Cancel</button>
34
+ </div>
35
+ </div>
36
+
37
+ <script src="client.js"></script>
38
+ </body>
39
+ </html>